toolup-forgetoolup-forge

Phase 11.G — `fromEnv` helpers (consumer migration)

Phase 11.G — fromEnv helpers (consumer migration)

What changes. SDK adds env-var-driven construction helpers for every common composition-root substrate dimension (logger, secret store, blob storage, notification channel, auth provider, server config), plus a client-side bundle-constants helper. Reference consumers shrink composition roots from ~1000 lines of env-var dispatch to ~30 lines of substrate construction.

Env-var contract. Every TOOLUP_* env var honoured by the pre-11.G hand-written reference composition root is honoured by the new helpers with byte-for-byte identical fallback messages. Startup-log diff before/after migration is empty across all five PlatformMode paths.

Scope. Forge SDK ships the helpers + a runnable sample (samples/MinimalApp/) + this doc. Consumer adoption is opt-in per deployment.

Helper surface (verbatim signatures)

Helper Module Replaces (lines in the reference consumer's Server.fs pre-11.G)
ConsoleLogger.fromEnv () ToolUp.Platform.ConsoleLogger 80–105 (resolvedLogLevel + resolvedTraceCategories + let private logger)
ConsoleLogger.envSettings () ToolUp.Platform.ConsoleLogger (helper to mirror level + categories into ServerConfig)
SecretStore.fromEnv logger resolvers ToolUp.Platform.SecretStore 174–211 (entire TOOLUP_SECRET_STORE dispatch)
BlobStorageEnv.fromEnv logger resolvers ToolUp.Platform.BlobStorageEnv 215–242 (entire TOOLUP_BLOB_STORAGE dispatch)
NotificationChannel.fromEnv logger resolvers ToolUp.Platform.NotificationChannel 256–297 (entire TOOLUP_NOTIFICATION_CHANNEL dispatch + triple)
AuthProvider.fromEnv logger oidcBuilder ToolUp.Platform.AuthProvider 132–162 (entire TOOLUP_AUTH_MODE dispatch)
ServerConfig.fromEnv logger overrides ToolUp.Platform.ServerConfig 704–875 (entire ServerConfig.defaults with … block — 170 lines)
HealthCheckDefaults.fromEnv builders secret notifProbe ToolUp.Platform.HealthCheckDefaults 939–944 (withHealthCheck chain)
ConfigValidators.fromEnv oidcResolver notifValidator ToolUp.Platform.ConfigValidators 950–956 (withConfigValidator chain)
BundleConstants.{moduleFilter,agGridLicense,clerkPublishableKey} ToolUp.Platform.BundleConstants client-side 19–42 (three [<Emit>] lets)
ClientConfigDefaults.fromBundleConstants overrides ToolUp.Platform.ClientConfigDefaults client-side 118–159 (entire ClientConfig.defaults with … block)

Naming caveats (Core-module collision)

Three helpers carry suffixed module names because the obvious name was already taken by an interface module in ToolUp.Platform.Core (and F# doesn't merge top-level modules across assemblies):

  • BlobStorageEnv.fromEnv (not BlobStorage.fromEnv) — Core's module BlobStorage carries BlobMetadata + IBlobStorage.
  • HealthCheckDefaults.fromEnv (not HealthChecks.fromEnv) — Core's module HealthChecks carries IHealthCheck.

ConsoleLogger.fromEnv / NotificationChannel.fromEnv / ServerConfig.fromEnv augment their existing Server-side / Core-side modules directly — no suffix needed.

Substrate-cleanliness: cloud-companion resolvers

SecretStore.fromEnv, BlobStorageEnv.fromEnv, and NotificationChannel.fromEnv take resolver lists so ToolUp.Platform.Server doesn't take hard <PackageReference> deps on every cloud companion. The consumer threads in one entry per companion the deployment has wired:

let secretStore =
    SecretStore.fromEnv logger [
        { Name = "azure-key-vault";     Resolve = ToolUp.Secrets.AzureKeyVault.fromEnv }
        { Name = "aws-secrets-manager"; Resolve = ToolUp.Secrets.AwsSecretsManager.fromEnv }
        { Name = "vault";               Resolve = ToolUp.Secrets.HashiCorpVault.fromEnv }
    ]

let blobStorage =
    BlobStorageEnv.fromEnv logger [
        { Name = "azure"; Resolve = fun () -> ToolUp.Storage.AzureBlobStorage.fromEnv None }
        { Name = "s3";    Resolve = ToolUp.Storage.AwsS3Storage.fromEnv }
        { Name = "gcs";   Resolve = ToolUp.Storage.GoogleCloudStorage.fromEnv }
    ]

let notificationChannel, notifChannelHealth, notifChannelValidator =
    NotificationChannel.fromEnv logger [
        { Name = "redis"
          ConnectionEnvVar = "TOOLUP_REDIS_CONNECTION"
          Resolve =
            fun lg connStr ->
                let mux, channel =
                    ToolUp.Platform.NotificationChannels.Redis.RedisNotificationChannel.connect connStr (Some lg)

                Some(
                    channel,
                    Some(ToolUp.Platform.NotificationChannels.RedisHealth.RedisNotificationChannelHealth.create mux),
                    Some(ToolUp.Platform.NotificationChannels.RedisValidator.create mux)
                ) }
    ]

let authProvider =
    AuthProvider.fromEnv logger ToolUp.AuthProviders.OidcAuthProvider.fromConfig

Consumers that don't ship a given cloud companion simply omit the corresponding resolver — TOOLUP_SECRET_STORE=azure-key-vault in a consumer without the ToolUp.Secrets.AzureKeyVault reference falls back to encrypted-local with a Warn, same posture as today.

ServerConfigOverrides

Curated record carrying the small set of fields that need consumer-specific values on top of ServerConfig.fromEnv's env-var-derived baseline:

{ ServerConfigOverrides.referenceApp with
    PublicPath = Some "public"
    SlowRequestThresholdOverrides = Some slowRequestOverrides
    EnableDevEndpoints =
#if DEBUG
        Some true
#else
        Some false
#endif
    AutoBootstrapDevAdmin =
#if DEBUG
        Some "dev-admin"
#else
        None
#endif }

ServerConfigOverrides.referenceApp pre-bundles Webhooks = EnabledWebhooks, AuditLog = EnabledAuditLog, SecurityHardening = DefaultSecurityHardening. Consumers not in the reference posture use ServerConfigOverrides.empty and add their own overrides selectively.

Before / after — reference consumer Server.fs

Before (lines 43–298 of pre-11.G Server.fs)

// ~250 lines of:
//   - envVar helper
//   - resolvedLogLevel / resolvedTraceCategories parsing
//   - let private logger = ConsoleLogger(...)
//   - secretsMasterKey extraction
//   - `do match TOOLUP_PLATFORM_MODE with ...` warning block
//   - authProvider dispatch on TOOLUP_AUTH_MODE
//   - secretStore dispatch on TOOLUP_SECRET_STORE
//   - blobStorage dispatch on TOOLUP_BLOB_STORAGE
//   - notificationChannel triple build from TOOLUP_NOTIFICATION_CHANNEL

After

let logger = ConsoleLogger.fromEnv ()

let secretStore =
    SecretStore.fromEnv logger [
        { Name = "azure-key-vault";     Resolve = ToolUp.Secrets.AzureKeyVault.fromEnv }
        { Name = "aws-secrets-manager"; Resolve = ToolUp.Secrets.AwsSecretsManager.fromEnv }
        { Name = "vault";               Resolve = ToolUp.Secrets.HashiCorpVault.fromEnv }
    ]

let blobStorage =
    BlobStorageEnv.fromEnv logger [
        { Name = "azure"; Resolve = fun () -> ToolUp.Storage.AzureBlobStorage.fromEnv None }
        { Name = "s3";    Resolve = ToolUp.Storage.AwsS3Storage.fromEnv }
        { Name = "gcs";   Resolve = ToolUp.Storage.GoogleCloudStorage.fromEnv }
    ]

let notificationChannel, notifHealth, notifValidator =
    NotificationChannel.fromEnv logger [ Wiring.redisResolver ]

let authProvider = AuthProvider.fromEnv logger ToolUp.AuthProviders.OidcAuthProvider.fromConfig

Wiring.redisResolver lives in a sibling Wiring.fs file alongside other per-deployment construction (algorithm provider singletons, AI provider descriptors / builders / platform bundle, system-prompt composition, per-module API factories). See docs/platform/composition-roots.md for the recommended layout.

Before (lines 704–875 of pre-11.G Server.fs)

let private config = {
    ServerConfig.defaults with
        PublicPath = "public"
        Mode = platformMode ()
        ModuleFilter = envVar "TOOLUP_MODULE"
        RequireHttps = envFlag "TOOLUP_REQUIRE_HTTPS"
        // … 165 more lines of field-by-field env reads + validators
}

After

let config =
    ServerConfig.fromEnv logger {
        ServerConfigOverrides.referenceApp with
            PublicPath = Some "public"
            SlowRequestThresholdOverrides = Some Wiring.slowRequestOverrides
            EnableDevEndpoints =
#if DEBUG
                Some true
#else
                Some false
#endif
            AutoBootstrapDevAdmin =
#if DEBUG
                Some "dev-admin"
#else
                None
#endif
    }

Client-side migration — reference consumer's Client.fs

Before (lines 19–159 of pre-11.G Client.fs)

[<Emit("__TOOLUP_MODULE__")>]
let private toolupModuleFilter: string = jsNative

[<Emit("__AG_GRID_LICENSE__")>]
let private agGridLicenseKey: string = jsNative

[<Emit("__CLERK_PUBLISHABLE_KEY__")>]
let private clerkPublishableKey: string = jsNative

let private gridModules = AgGridEnterprise.gridModuleConfig agGridLicenseKey
AgGridEnterprise.registerCharts ()

let private aiMode = ConfiguredAIAssistant { ... }
let private authUI = #if DEBUG NoAuthUI #else ClerkAuthUI { ... } #endif
let private handlers = { ... }
// … 30 more lines DEBUG-gated console-trace / dev-admin flags
let private config = { ClientConfig.defaults with … }   // ~40 lines

After

let private gridModules = AgGridEnterprise.gridModuleConfig BundleConstants.agGridLicense
AgGridEnterprise.registerCharts ()

let private authUI =
#if DEBUG
    NoAuthUI
#else
    if System.String.IsNullOrEmpty BundleConstants.clerkPublishableKey then
        failwith
            "CLERK_PUBLISHABLE_KEY was empty when bundling this Release build. Set it in CI (see DEPLOYMENT_CI.md) and rebuild."

    ClerkAuthUI { PublishableKey = BundleConstants.clerkPublishableKey }
#endif

let private config =
    ClientConfigDefaults.fromBundleConstants {
        ClientConfigOverrides.referenceApp with
            AppName = Some "ToolUp"
            AppLogo = Some "favicon.png"
            Mode = Some Individual
            GridModules = Some gridModules
            AuthUI = Some authUI
            Handlers =
                Some {
                    ClientHandlerRegistry.empty with
                        AuthUIHandlers =
#if DEBUG
                            []
#else
                            [ ToolUp.AuthProviders.ClerkRegister.handler ]
#endif
                        NarrativeCommitHandler = Some KnowledgeBaseView.narrativeCommitHandler
                }
            PublicEntryDispatchers = Some Wiring.publicEntryDispatchers
            EnableElmishConsoleTrace =
#if DEBUG
                Some true
#else
                Some false
#endif
            ShowDebugOnlyModules =
#if DEBUG
                Some true
#else
                Some false
#endif
            DevDefaultUserId =
#if DEBUG
                Some "dev-admin"
#else
                None
#endif
    }

AIClientConfig.run aiMode config modules

Fable [<Emit>] propagation (open question, verify with sample)

BundleConstants.fs reads the three Vite-injected constants via [<Emit>] declarations inside ToolUp.Platform.Client. For consumer projects that take ToolUp.Platform.Client via <ProjectReference> (in-tree samples + templates) or via the Fable source-in-nupkg path (every NuGet consumer), Vite's define substitution should reach the emitted JS regardless of which source file the [<Emit>] originated in — Vite substitutes textually against the produced JS, and Fable emits one JS file per F# file (cross-project or not).

Verify empirically: the samples/MinimalApp/ build proves this. If the sample sees __TOOLUP_MODULE__ as a literal at runtime (not the empty string from the typeof-guard, not the value from Vite's define), consumers should use ClientConfigDefaults.fromBundleConstantValues and pass values from their own [<Emit>] declarations.

Verification steps (consumer adoption PR)

  1. Startup-log diff. Boot the app pre- and post-migration with the same env var values. Diff the first 200 lines of startup output. Should be empty across all five PlatformMode paths (Anonymous, AuthenticatedEphemeral, Individual, Team, MultiTeam).
  2. Validator chain. Phase 6m mode-validators (HeaderAuthProviderModeValidator, EncryptedSecretStoreModeValidator, InProcessJobSchedulerModeValidator, RateLimitModeValidator, SseAuthModeValidator) still fire on the same env-var settings — no regression.
  3. dotnet build clean on the consumer's sln after the migration.
  4. Fable JSdotnet fable -o output from the client project + spot-check that the Vite-define values substitute into the bundled JS for __TOOLUP_MODULE__ etc.
  5. Line count. Server.fs ≤ 50 executable lines, Client.fs ≤ 30. Algorithm singletons + per-module API factories + system-prompt composition move to a new sibling Wiring.fs file.

Rollback

Each helper is a strict superset of the pre-11.G hand-written code. Revert the consumer PR if the migration causes any regression; the SDK helpers stay shipped (additive — they don't change the hand-written code path's behaviour).