// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package cmd import ( "context" "database/sql" "fmt" "log/slog" "os" "strings" "time" "git.coopcloud.tech/wiki-cafe/member-console/internal/billing" "git.coopcloud.tech/wiki-cafe/member-console/internal/config" "git.coopcloud.tech/wiki-cafe/member-console/internal/db" "git.coopcloud.tech/wiki-cafe/member-console/internal/domains" "git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements" "git.coopcloud.tech/wiki-cafe/member-console/internal/identity" "git.coopcloud.tech/wiki-cafe/member-console/internal/instance" "git.coopcloud.tech/wiki-cafe/member-console/internal/integration" "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations" stripeintegration "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe" stripemod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store" stripewf "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/workflows" "git.coopcloud.tech/wiki-cafe/member-console/internal/logging" "git.coopcloud.tech/wiki-cafe/member-console/internal/migrate" "git.coopcloud.tech/wiki-cafe/member-console/internal/organization" "git.coopcloud.tech/wiki-cafe/member-console/internal/server" "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows" wfBilling "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/billing" wfDomains "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/domains" wfEnt "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/entitlements" wfMaintenance "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/maintenance" "github.com/spf13/cobra" "github.com/spf13/viper" enumspb "go.temporal.io/api/enums/v1" "go.temporal.io/sdk/client" ) // domainsReconciler is the optional capability an installed integration // implements to reconcile the domains registry against its own state at boot // (design D8): ensuring the operator roots its configuration implies, and // backfilling registry rows for resources that predate the registry. // // Declared HERE, at its point of use, rather than in internal/integrations: // the composition root is the only place that holds both the integration // registry and the live *sql.DB, and an integration satisfies this // structurally without importing anything to say so — the same pattern // server.RouteProvider and workflows.WorkflowProvider follow, for the same // import-cycle reason. // // Implementations MUST be idempotent: the loop below runs on every boot, with // no gate of any kind, and doubles as the backstop for allocation orphans. type domainsReconciler interface { ReconcileDomains(ctx context.Context, database *sql.DB, logger *slog.Logger) error } var startCmd = &cobra.Command{ Use: "start", Short: "Start serving the member-console web application", Long: `The start command starts an HTTP server that serves the member-console web application from the components directory in the current directory. The server listens on port 8080 by default, unless a different port is specified using the --port flag.`, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { // Create base context for the application ctx := context.Background() // Set up structured logging env := viper.GetString("env") logger := logging.SetupLogger(env) // Store logger in context ctx = logging.WithContext(ctx, logger) // Installed integrations (FedWiki, Stripe, ...) are read from the // registry once and reused for every registry-driven concern below: // secret/file resolution, config validation, and (further down, // after the database is up) route/workflow wiring. integs := integrations.All() var integrationConfigSpecs []config.ConfigKey for _, integ := range integs { if cp, ok := integ.(config.ConfigProvider); ok { integrationConfigSpecs = append(integrationConfigSpecs, cp.ConfigSpec()...) } } // Check every installed integration's own declarations before // reading a single configured value: a Type that disagrees with // its default, an Enum with the wrong default shape, or a // Positive on a key that cannot carry it is a programming error // in that integration, not a bad deployment value, so it fails // boot in development regardless of what any environment sets // (design D1, D6, typed-config-keys). if err := config.CheckSpecs(integrationConfigSpecs); err != nil { logger.Error("invalid integration config declaration", slog.Any("error", err)) os.Exit(1) } // Resolve *-secret / *-secret-file pairs into Viper before validating or // starting any service, so file-backed secrets (e.g. stripe-api-key-file) // are present when ValidateStart reads them. Shared with config validate // (D10, typed-config-keys), so both read a deployment's secret-file // wiring identically. if err := resolveSecretFiles(integrationConfigSpecs); err != nil { logger.Error("configuration error", slog.Any("error", err)) os.Exit(1) } // Validate required configuration before initializing any service, so a // misconfiguration fails fast with an actionable, aggregated error rather // than a late, cryptic downstream failure. integrationConfigSpecs drives // the generic required-together check (e.g. Stripe's api-key/webhook- // secret pair) instead of a hardcoded per-integration conditional. if err := config.ValidateStart(integrationConfigSpecs); err != nil { logger.Error("invalid configuration", slog.Any("error", err)) os.Exit(1) } // The Stripe mode is a property of the API key, derived here beside // the other configuration checks so a key with an unrecognized // prefix fails boot rather than mislabeling live money as test // data downstream (design D7). stripeMode, err := stripeintegration.ModeForKey(viper.GetString("stripe-api-key")) if err != nil { logger.Error("invalid configuration", slog.Any("error", err)) os.Exit(1) } // The key's fingerprint, derived here beside its mode: a digest, // never the key. The environment check records which key it ran // under, and this is what the next boot compares against that // record to learn the key moved (stripe-environment-stamp D3). stripeKeyFingerprint := stripeintegration.FingerprintForKey(viper.GetString("stripe-api-key")) // Database Setup dbDSN := viper.GetString("db-dsn") dbConfig := db.DefaultDBConfig(dbDSN) migrationSources := migrate.Sources() database, err := db.ConnectAndMigrate(ctx, logger, dbConfig, migrationSources) if err != nil { logger.Error("failed to initialize database", slog.Any("error", err)) os.Exit(1) } defer database.Close() // Layer operator-set config overrides (core.integration_config_ // overrides) over the environment. Must run here — after migrations // (the table exists), before any route/workflow registration (that's // when integrations capture their config) and before goroutines // (viper.Set is not synchronized). Overrides apply on restart by // design; see internal/config.ApplyOverlay. overrideRows, err := integration.New(database).ListConfigOverrides(ctx) if err != nil { logger.Error("failed to load integration config overrides", slog.Any("error", err)) os.Exit(1) } overrides := make([]config.Override, 0, len(overrideRows)) for _, row := range overrideRows { overrides = append(overrides, config.Override{Key: row.Key, Value: row.Value}) } if err := config.ApplyOverlay(integrationConfigSpecs, overrides); err != nil { logger.Error("invalid integration config override", slog.Any("error", err)) os.Exit(1) } // Register providers from code into the registry (parallel to how // migration sources are enumerated). Runs after migrations, so the // resource keys each provider manifest names already exist for stamping. // Data state a booted deployment guarantees (providers, the system // tenant, the expired-claim sweep, webhook partitions, each // integration's domains reconciliation with its operator roots). // Shared with seed-demo so a reset-and-reseeded database matches a // booted one (bootstate.go). if err := ensureBootDataState(ctx, database, logger, integs); err != nil { logger.Error("boot data state", slog.Any("error", err)) os.Exit(1) } // Collect HTTP route mounts, Temporal workflow/activity // registrations, and UI (template/static) mounts from installed // integrations (integs, gathered at the top of this function). // server.RouteProvider and workflows.WorkflowProvider are declared // at their point of use (not in internal/integrations) to avoid // import cycles — see those interfaces' doc comments. // integrations.UIProvider has no such cycle (it references no // server/workflows types) and is declared alongside Integration // itself. cmd/start.go is the only place that imports // internal/integrations, internal/server, and internal/workflows // together, so it's the only place these type assertions can // happen. var routeMounts []server.RouteMount var workflowProviders []workflows.WorkflowProvider var uiMounts []server.UIMount var dashboardCards []server.DashboardCard var integrationConfigs []server.IntegrationConfigInfo for _, integ := range integs { if cp, ok := integ.(config.ConfigProvider); ok { manifest := integ.Provider().ProviderManifest() integrationConfigs = append(integrationConfigs, server.IntegrationConfigInfo{ Key: integ.Key(), DisplayName: manifest.DisplayName, SurfacePath: manifest.OperatorSurfacePath, Keys: cp.ConfigSpec(), }) } if rp, ok := integ.(server.RouteProvider); ok { routeMounts = append(routeMounts, server.RouteMount{ Register: rp.RegisterRoutes, CSRFExemptPaths: rp.CSRFExemptPaths(), }) } if wp, ok := integ.(workflows.WorkflowProvider); ok { workflowProviders = append(workflowProviders, wp) } if uip, ok := integ.(integrations.UIProvider); ok { uiMounts = append(uiMounts, server.UIMount{ Key: integ.Key(), Templates: uip.Templates(), Static: uip.Static(), }) } if dp, ok := integ.(server.DashboardCardProvider); ok { dashboardCards = append(dashboardCards, dp.DashboardCards()...) } } // Retrieve the configuration values from Viper port := viper.GetString("port") // Claim lifecycle policy (design D6): read once here and carried on // server.Config → server.Deps, so core's Domains page and every // integration surface that claims names allocate under one policy. // The Temporal worker needs none of it — every policy decision is made // in the registry at allocation time. Unset keys leave the matching // field zero, which the registry resolves to its shipped default. domainsPolicy := domains.Policy{ ClaimWindow: viper.GetDuration("domains-claim-window"), PendingCap: viper.GetInt("domains-pending-cap"), AbandonBudget: configuredBudget(viper.GetInt("domains-abandon-budget")), AbandonWindow: viper.GetDuration("domains-abandon-window"), ScopeLabels: viper.GetInt("domains-scope-labels"), InitiationBudget: configuredBudget(viper.GetInt("domains-initiation-budget")), } // Resolved once and threaded as data (server config, worker config, // integration Deps): the DNS target members point external custom // domains at. Empty disables external domain claims deployment-wide. domainsConnectTarget := viper.GetString("domains-connect-target") // Create Temporal client if configured var temporalClient client.Client temporalHost := viper.GetString("temporal-host") if temporalHost != "" { temporalOAuthTokenURL := viper.GetString("temporal-oauth-token-url") temporalOAuthClientID := viper.GetString("temporal-oauth-client-id") temporalOAuthClientSecret := viper.GetString("temporal-oauth-client-secret") temporalOAuthScopes := viper.GetStringSlice("temporal-oauth-scopes") clientCfg := workflows.ClientConfig{ HostPort: temporalHost, Namespace: viper.GetString("temporal-namespace"), Logger: logger, ConnectTimeout: viper.GetDuration("temporal-connect-timeout"), } if temporalOAuthTokenURL != "" || temporalOAuthClientID != "" || temporalOAuthClientSecret != "" || len(temporalOAuthScopes) > 0 { clientCfg.OAuthTokenProvider = &workflows.OAuthTokenProviderConfig{ TokenURL: temporalOAuthTokenURL, ClientID: temporalOAuthClientID, ClientSecret: temporalOAuthClientSecret, Scopes: temporalOAuthScopes, } } var err error temporalClient, err = workflows.NewClient(ctx, clientCfg) if err != nil { logger.Error("failed to connect to Temporal", slog.Any("error", err)) os.Exit(1) } defer temporalClient.Close() // Start Temporal worker workerCfg := workflows.DefaultWorkerConfig(database, logger) workerCfg.WorkflowProviders = workflowProviders workerCfg.DomainsConnectTarget = domainsConnectTarget worker, err := workflows.NewWorker(temporalClient, workerCfg) if err != nil { logger.Error("failed to create Temporal worker", slog.Any("error", err)) os.Exit(1) } if err := worker.Start(); err != nil { logger.Error("failed to start Temporal worker", slog.Any("error", err)) os.Exit(1) } defer worker.Stop() // Run each installed integration's Startup hook (Stripe's hand-started // webhook/outbox-poller workflows, FedWiki's sync schedule, ...) // instead of naming them here. Each implementation logs and swallows // its own failures (see workflows.WorkflowProvider's doc comment); this // loop only logs defensively for a well-behaved implementation that // does return an error. for _, wp := range workflowProviders { if err := wp.Startup(ctx, temporalClient, workerCfg.TaskQueue, database, logger); err != nil { logger.Error("integration workflow startup failed", slog.Any("error", err)) } } // Start the entitlement recompute poller, the drain's backstop: // a rule change above the synchronous cap leaves one pending // obligation per pool it owes, the commit starts that change's // drain itself, and this poller covers a start that failed, a // process that died mid-drain and an operator Retry that // requeued failed rows. Conflict policy USE_EXISTING, so a // second console process joins the running poller. if _, err := temporalClient.ExecuteWorkflow(ctx, client.StartWorkflowOptions{ ID: "entitlement-recompute-poller", TaskQueue: workerCfg.TaskQueue, WorkflowIDConflictPolicy: enumspb.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING, }, wfEnt.PollEntitlementRecompute, wfEnt.PollEntitlementRecomputeInput{}); err != nil { logger.Error("failed to start entitlement recompute poller workflow", slog.Any("error", err)) } else { logger.Info("started entitlement recompute poller workflow") } // The Stripe environment check (stripe-environment-stamp D3). // A Stripe id resolves in the environment that made it and in // no other, so a key that moved leaves every mapping pointing // at an object this deployment can no longer read. Boot // compares the key's fingerprint with the one the last check // ran under; when they differ, or no check has ever run, every // product and price id is unverified again and the read-back // is started. Started, not awaited, like the poller above: a // deployment whose Stripe is unreachable still comes up, and a // start that fails is logged and left to the provider page's // control. if stripeMode != "" { startStripeEnvironmentCheck(ctx, temporalClient, workerCfg.TaskQueue, database, stripeKeyFingerprint, logger) } // Set up the billing scheduled-change sweep — the backstop firing // path for due subscription scheduled changes. Period-end // cancellations also fire via the Stripe deletion webhook, so a // failure here is non-fatal. sweepInterval := viper.GetDuration("billing-sweep-interval") if sweepInterval == 0 { sweepInterval = wfBilling.DefaultSweepInterval } sweepSchedule := wfBilling.NewScheduleManager(temporalClient, logger) if err := sweepSchedule.EnsureSweepSchedule(ctx, wfBilling.SweepScheduleConfig{ Interval: sweepInterval, }); err != nil { logger.Error("failed to set up billing sweep schedule", slog.Any("error", err)) } // Set up the domain claim expiry sweep — the backstop firing path // for pending claims past their deadline. A claim's own // verification workflow normally expires it; this catches the ones // whose workflow is gone. The console also sweeps once per boot // (above), which is what covers deployments with no Temporal at // all, so a failure here is non-fatal. claimSweepInterval := viper.GetDuration("domains-expiry-sweep-interval") if claimSweepInterval == 0 { claimSweepInterval = wfDomains.DefaultExpirySweepInterval } claimSweepSchedule := wfDomains.NewScheduleManager(temporalClient, logger) if err := claimSweepSchedule.EnsureExpirySweepSchedule(ctx, wfDomains.ExpirySweepScheduleConfig{ Interval: claimSweepInterval, }); err != nil { logger.Error("failed to set up domain claim expiry sweep schedule", slog.Any("error", err)) } // Set up the webhook_events partition ensure schedule (design.md D3, // webhook-partition-maintenance): the backstop for deployments that // run longer than the partition lookahead window without // restarting. The console also ensures partitions once per boot // (above), which is what covers deployments with no Temporal at // all, so a failure here is non-fatal. Unlike the sweeps above, the // spec requires triggering an immediate run on registration. webhookPartitionInterval := viper.GetDuration("webhook-partition-ensure-interval") if webhookPartitionInterval == 0 { webhookPartitionInterval = wfMaintenance.DefaultWebhookPartitionEnsureInterval } webhookPartitionSchedule := wfMaintenance.NewScheduleManager(temporalClient, logger) if err := webhookPartitionSchedule.EnsureWebhookPartitionSchedule(ctx, wfMaintenance.WebhookPartitionScheduleConfig{ Interval: webhookPartitionInterval, TriggerImmediately: true, }); err != nil { logger.Error("failed to set up webhook partition ensure schedule", slog.Any("error", err)) } } else { logger.Warn("Temporal not configured - integration workflows and scheduled jobs will be unavailable") } // Create server config. stripeDashboardURL is Stripe-specific plumbing // (the derived mode mapped to a dashboard base URL) owned by the // Stripe integration and only consumed // downstream by the payments-seam operator billing UI (internal/ // server/operator_billing.go, operator_partials.go), which stays in // core per design.md Decision 6 (design D7). stripeDashboardURL := stripeintegration.DashboardURL(stripeMode) if stripeMode != "" { logger.Info("stripe mode derived from the API key", slog.String("mode", stripeMode)) } serverConfig := server.Config{ Port: port, Env: env, Logger: logger, Database: database, IdentityQ: identity.New(database), OrgQ: organization.New(database), EntitlementsQ: entitlements.New(database), BillingQ: billing.New(database), StripeQ: stripemod.New(database), DomainsQ: domains.New(database), TemporalClient: temporalClient, StripeWebhookSecret: viper.GetString("stripe-webhook-secret"), StripeAPIKey: viper.GetString("stripe-api-key"), StripeDashboardURL: stripeDashboardURL, StripeMode: stripeMode, StripeKeyFingerprint: stripeKeyFingerprint, BaseURL: viper.GetString("base-url"), AskFallbackURL: viper.GetString("domains-ask-fallback-url"), DomainsPolicy: domainsPolicy, DomainsConnectTarget: domainsConnectTarget, RouteMounts: routeMounts, UIMounts: uiMounts, DashboardCards: dashboardCards, IntegrationConfigs: integrationConfigs, } // Start the server if err := server.Start(ctx, serverConfig); err != nil { logger.Error("server failed to start", slog.Any("error", err)) } }, } // startStripeEnvironmentCheck runs boot's half of the environment check // (stripe-environment-stamp D3): read the record of the key the last check // ran under, and when it names another key, or names none, clear every // product and price id's verification and start the read-back. // // Nothing here waits for Stripe. The clears are two statements against the // console's own tables, and the workflow is started and abandoned, so a // deployment whose Stripe or Temporal is unreachable boots at the same // speed it does today. A failed clear is logged and the check still // starts: the read-back is what settles each row, and a stale // verification timestamp on a row the check is about to rewrite is worth // less than the run. func startStripeEnvironmentCheck(ctx context.Context, c client.Client, taskQueue string, database *sql.DB, fingerprint string, logger *slog.Logger) { raw, found, err := instance.NewStore(database).GetJSON(ctx, instance.StripeEnvironmentCheck) if err != nil { logger.Error("could not read the stripe environment check record", slog.Any("error", err)) return } if !stripemod.EnvironmentCheckNeeded(raw, found, fingerprint) { return } q := stripemod.New(database) if err := q.ClearProductMappingVerifiedAt(ctx); err != nil { logger.Error("could not clear the product mappings' verification", slog.Any("error", err)) } if err := q.ClearPriceMappingVerifiedAt(ctx); err != nil { logger.Error("could not clear the price mappings' verification", slog.Any("error", err)) } if _, err := stripewf.StartEnvironmentCheck(ctx, c, taskQueue, stripewf.EnvironmentCheckTriggerBoot); err != nil { logger.Error("failed to start the stripe environment check workflow", slog.Any("error", err)) return } logger.Info("started the stripe environment check workflow", slog.String("trigger", stripewf.EnvironmentCheckTriggerBoot), slog.String("key_fingerprint", stripemod.FingerprintPrefix(fingerprint))) } func init() { // Register flags with Cobra // DO NOT SET DEFAULT VALUES HERE. Use viper.SetDefault() instead. https://github.com/spf13/viper/issues/671 // General configuration startCmd.Flags().StringP("port", "p", "", "Port to listen on") startCmd.Flags().String("base-url", "", "Address at which the server is exposed") startCmd.Flags().String("env", "", "Environment label; development selects text logs, anything else JSON") startCmd.Flags().String("deployment-name", "", "Name this deployment presents for itself on the member and operator mastheads, page titles, and OpenGraph tags (default \"Member Console\")") startCmd.Flags().String("db-dsn", "", "PostgreSQL connection string (e.g., postgres://user:pass@localhost:5432/dbname?sslmode=disable; required, or db-dsn-file)") startCmd.Flags().String("db-dsn-file", "", "Path to file containing the PostgreSQL connection string") startCmd.Flags().String("valkey-addr", "", "Valkey/Redis address for session storage (host:port)") // OIDC configuration startCmd.Flags().String("oidc-sp-client-id", "", "OIDC Client ID") startCmd.Flags().String("oidc-idp-issuer-url", "", "OIDC Identity Provider Issuer URL") startCmd.Flags().String("valkey-username", "", "Username for the session store (ACL auth; optional)") startCmd.Flags().String("valkey-password", "", "Password for the session store (required; or valkey-password-file)") startCmd.Flags().String("valkey-password-file", "", "Path to file containing the session store password") startCmd.Flags().Bool("valkey-tls", false, "Connect to the session store over TLS") startCmd.Flags().Bool("valkey-tls-skip-verify", false, "Do not verify the session store's TLS certificate (self-signed stores only)") startCmd.Flags().String("oidc-idp-account-url", "", "URL of the identity provider's self-service account console, opened by the shell's Identity and Access control (default: issuer URL + /account, Keycloak's shape)") startCmd.Flags().String("oidc-sp-client-secret", "", "OIDC Client Secret") startCmd.Flags().String("oidc-sp-client-secret-file", "", "Path to file containing OIDC Client Secret") startCmd.Flags().Duration("billing-sweep-interval", time.Hour, "Interval between billing scheduled-change sweeps (backstop firing path)") // Domains registry configuration. The fallback URL is a migration-window // seam: registry misses (and only misses) are forwarded to a legacy // on-demand-TLS answerer, so a deployment can strangle a filesystem-based // answerer incrementally. Empty — the default — refuses unknown names. startCmd.Flags().String("domains-ask-fallback-url", "", "Legacy on-demand-TLS answerer consulted for names unknown to the domains registry (empty disables)") startCmd.Flags().String("domains-connect-target", "", "DNS target members point external custom domains at (CNAME host or A-record IP); empty disables external domain claims deployment-wide") // Claim lifecycle policy (design D6). Flag-only core keys: they appear on // no operator settings page, so the operator domains surface prints the // effective set. Defaults are internal/domains' own constants, applied via // viper.SetDefault below — a single-tenant coop and a public multi-tenant // host want different numbers, and the defaults suit the latter. startCmd.Flags().Duration("domains-claim-window", 0, "How long a member has to publish the TXT challenge before a pending external domain claim expires (default 24h)") startCmd.Flags().Int("domains-pending-cap", 0, "Concurrent pending external domain verifications allowed per workspace (default 5)") startCmd.Flags().Int("domains-abandon-budget", 0, "Verifications a workspace may start and abandon under one domain scope within the abandonment window before further claims are refused (default 3; 0 disables the ledger)") startCmd.Flags().Duration("domains-abandon-window", 0, "Rolling window abandoned verifications are counted over (default 168h)") startCmd.Flags().Int("domains-scope-labels", 0, "Trailing DNS labels forming the scope abandonments are counted under, so sibling names share one scope (default 2)") startCmd.Flags().Int("domains-initiation-budget", 0, "New external domain claims allowed per workspace per rolling 24h (default 10; 0 disables the cap)") startCmd.Flags().Duration("domains-expiry-sweep-interval", wfDomains.DefaultExpirySweepInterval, "Interval between domain claim expiry sweeps (backstop for claims whose verification workflow is gone)") startCmd.Flags().Duration("webhook-partition-ensure-interval", wfMaintenance.DefaultWebhookPartitionEnsureInterval, "Interval between core.webhook_events partition ensure runs (backstop keeping monthly partitions provisioned ahead of need)") // Support configuration startCmd.Flags().String("support-url", "", "URL for users to get support (shown in error messages)") // Integration configuration: every installed integration (FedWiki, // Stripe, ...) declares its own flags/defaults via ConfigSpec instead // of each key being hand-declared here — see internal/config.ConfigKey // and internal/config.ConfigProvider. This replaces what used to be // separate "FedWiki configuration" (farm API URL, allowed domains, // site scheme, admin token) and "Stripe configuration" (api key, // webhook secret, mode) blocks. registerIntegrationConfigFlags(startCmd) // Temporal configuration startCmd.Flags().String("temporal-host", "", "Temporal server host:port (e.g., localhost:7233)") startCmd.Flags().String("temporal-namespace", "", "Temporal namespace") startCmd.Flags().String("temporal-oauth-token-url", "", "OAuth2 token endpoint for Temporal authentication") startCmd.Flags().String("temporal-oauth-client-id", "", "OAuth2 client ID for Temporal authentication") startCmd.Flags().String("temporal-oauth-client-secret", "", "OAuth2 client secret for Temporal authentication") startCmd.Flags().String("temporal-oauth-client-secret-file", "", "Path to file containing Temporal OAuth2 client secret") startCmd.Flags().StringSlice("temporal-oauth-scopes", nil, "OAuth2 scopes for Temporal authentication (comma-separated)") startCmd.Flags().Duration("temporal-connect-timeout", 0, "Max time to retry connecting to Temporal on startup before failing (0 = 90s)") // Bind all flags to Viper viper.BindPFlags(startCmd.Flags()) // Set default values viper.SetDefault("port", "8080") viper.SetDefault("valkey-addr", "localhost:6379") viper.SetDefault("env", "development") viper.SetDefault("deployment-name", config.DefaultDeploymentName) viper.SetDefault("temporal-namespace", "default") viper.SetDefault("temporal-connect-timeout", 90*time.Second) viper.SetDefault("billing-sweep-interval", time.Hour) viper.SetDefault("domains-claim-window", domains.ExternalClaimWindow) viper.SetDefault("domains-pending-cap", domains.DefaultPendingCap) viper.SetDefault("domains-abandon-budget", domains.DefaultAbandonBudget) viper.SetDefault("domains-abandon-window", domains.DefaultAbandonWindow) viper.SetDefault("domains-scope-labels", domains.DefaultScopeLabels) viper.SetDefault("domains-initiation-budget", domains.DefaultInitiationBudget) viper.SetDefault("domains-expiry-sweep-interval", wfDomains.DefaultExpirySweepInterval) viper.SetDefault("webhook-partition-ensure-interval", wfMaintenance.DefaultWebhookPartitionEnsureInterval) // fedwiki-site-scheme and the other integration defaults are set by // registerIntegrationConfigFlags above, from each integration's // declared ConfigSpec. // Add the command to the root command rootCmd.AddCommand(startCmd) } // registerIntegrationConfigFlags binds a cobra flag — and, for secrets, its // "-file" companion — for every ConfigKey each installed integration // declares via ConfigProvider, and sets the key's default via // viper.SetDefault. This replaces what used to be per-integration flag // literals and SetDefault calls hand-maintained in this file (see // internal/config.ConfigKey's doc comment for the declarations this reads). // Flags always register with a zero-value default (matching the // "DO NOT SET DEFAULT VALUES HERE" convention above): the real default // flows through viper.SetDefault instead. func registerIntegrationConfigFlags(cmd *cobra.Command) { for _, integ := range integrations.All() { cp, ok := integ.(config.ConfigProvider) if !ok { continue } for _, key := range cp.ConfigSpec() { // A key's value type (declared, or inferred from its Default; // design D1, typed-config-keys) decides its flag constructor: // bool, duration, int and list keys get their matching pflag // type; a url key is a string in Go, so it registers as a // string flag like a bare string or enum key does (spec // integration-config-declaration, "A URL key's flag and value // go through the seam"). No integration knob needs // hand-declaring in core. switch key.TypeOf() { case config.TypeList: cmd.Flags().StringSlice(key.Name, nil, key.Usage) case config.TypeBool: cmd.Flags().Bool(key.Name, false, key.Usage) case config.TypeDuration: cmd.Flags().Duration(key.Name, 0, key.Usage) case config.TypeInt: cmd.Flags().Int(key.Name, 0, key.Usage) default: // string, url, enum cmd.Flags().String(key.Name, "", key.Usage) } if key.Secret { cmd.Flags().String(key.Name+"-file", "", "Path to file containing the "+key.Name+" value") } if key.Default != nil { viper.SetDefault(key.Name, key.Default) } } } } // configuredBudget maps a configured claim budget onto domains.Policy's // encoding. A configured 0 means "never refuse for this reason", which the // struct's zero value cannot carry — there it means "unconfigured, use the // default" — so it becomes domains.PolicyDisabled. func configuredBudget(configured int) int { if configured == 0 { return domains.PolicyDisabled } return configured } // loadFromFile reads a file and returns its contents as a trimmed string func loadFromFile(path string) (string, error) { data, err := os.ReadFile(path) if err != nil { return "", err } return strings.TrimSpace(string(data)), nil } // resolveSecretFiles loads every "-file" secret variant (core's own // small, hand-listed set plus every installed integration's declared // Secret keys, config.SecretPairsFrom) into Viper, so a file-backed // secret (e.g. stripe-api-key-file) is present before any validation reads it. // Both a direct value and a file path for the same key is a fatal // misconfiguration, reported as an error rather than exiting here: shared // between start's own boot sequence and config validate (design D10, // typed-config-keys), so a deployment's secret-file wiring is read // identically by both, and each caller decides how to report the failure // and exit. func resolveSecretFiles(integrationConfigSpecs []config.ConfigKey) error { secretPairs := []config.SecretPair{ {Name: "oidc-sp-client-secret", FileName: "oidc-sp-client-secret-file"}, {Name: "temporal-oauth-client-secret", FileName: "temporal-oauth-client-secret-file"}, {Name: "valkey-password", FileName: "valkey-password-file"}, {Name: "db-dsn", FileName: "db-dsn-file"}, } secretPairs = append(secretPairs, config.SecretPairsFrom(integrationConfigSpecs)...) for _, pair := range secretPairs { value := viper.GetString(pair.Name) file := viper.GetString(pair.FileName) // Both a direct value and a file path is ambiguous. if value != "" && file != "" { return fmt.Errorf("%s: both a direct value and a file path are set; use only one", pair.Name) } // If a file path is provided, load the value from the file. if file != "" { loaded, err := loadFromFile(file) if err != nil { return fmt.Errorf("%s: failed to load configuration from file %s: %w", pair.Name, file, err) } viper.Set(pair.Name, loaded) } } return nil }