Files
member-console/cmd/start.go
T

681 lines
34 KiB
Go

// 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
// "<name>-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 "<name>-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
}