Files
member-console/cmd/start.go
T
cgalo5758 8e3c68c6be Make UI surfaces honestly reflect system state
- Add deployment-name branding to titles, mastheads, and OG tags
- Share one grant delivery-state query with lineage across grants
  surfaces
- Show pool status/usage, org owners, and config readiness
- Make billing views projection-aware with recency and sync vocabulary
- Guard FedWiki creation without domains and render route-aware 404s
2026-08-23 01:45:52 -05:00

645 lines
31 KiB
Go

package cmd
import (
"context"
"database/sql"
"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/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"
"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/systemtenant"
"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"
wfMaintenance "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/maintenance"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"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()...)
}
}
// Resolve *-secret / *-secret-file pairs into Viper before validating or
// starting any service, so file-backed secrets (e.g. csrf-secret-file)
// are present when ValidateStart reads them. A conflict or unreadable
// file is a fatal misconfiguration — exit non-zero, not a soft return.
// Core's own secrets are hand-listed (a small, stable set); every
// installed integration's secret keys are derived from its declared
// ConfigSpec via config.SecretPairsFrom instead of being hand-
// enumerated here too.
secretPairs := []config.SecretPair{
{Name: "oidc-sp-client-secret", FileName: "oidc-sp-client-secret-file"},
{Name: "csrf-secret", FileName: "csrf-secret-file"},
{Name: "temporal-oauth-client-secret", FileName: "temporal-oauth-client-secret-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 != "" {
logger.Error("configuration error",
slog.String("config", pair.Name),
slog.String("error", "both direct value and file path provided; use only one"))
os.Exit(1)
}
// If a file path is provided, load the value from the file.
if file != "" {
loaded, err := loadFromFile(file)
if err != nil {
logger.Error("failed to load configuration from file",
slog.String("config", pair.Name),
slog.String("file", file),
slog.Any("error", err))
os.Exit(1)
}
viper.Set(pair.Name, loaded)
}
}
// 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)
}
// 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.
providerSources := make([]integration.ProviderSource, 0, len(integs))
for _, integ := range integs {
ps := integ.Provider()
// D7: each integration declares its slug twice (Slug() and
// its manifest's Slug); assert they agree before this
// integration's manifest is registered, so a drift fails
// boot with a named error instead of silently splitting the
// operator Integrations page later.
if err := integration.AssertSlugMatch(integ.Slug(), ps.ProviderManifest()); err != nil {
logger.Error("integration slug mismatch", slog.Any("error", err))
os.Exit(1)
}
providerSources = append(providerSources, ps)
}
if err := integration.RegisterProviders(ctx, database, providerSources); err != nil {
logger.Error("failed to register providers", slog.Any("error", err))
os.Exit(1)
}
// Ensure the singleton System tenant exists (it owns ownerless provider
// resources). Runs after migrations; fatal on failure. The resulting
// workspace ID isn't needed here — the FedWiki integration re-derives it
// itself (Ensure is idempotent) inside its WorkflowProvider Startup hook,
// only when its sync schedule is actually enabled.
if _, err := systemtenant.Ensure(ctx, database); err != nil {
logger.Error("failed to ensure system tenant", slog.Any("error", err))
os.Exit(1)
}
// Reconcile the domains registry against each integration's own
// state (design D8): operator roots ensured from deployment
// configuration, and placements backfilled for resources that
// predate the registry. UNCONDITIONAL and here, not in an
// integration's Startup hook — that hook runs only when Temporal is
// configured (and, for FedWiki, only when its sync is enabled, which
// defaults to false), so a default deployment would boot with no
// operator roots and /domains/ask would refuse every hosted name it
// serves. Runs after migrations (the registry tables exist) and after
// provider registration (placements FK core.providers(slug)), before
// the server and worker start.
//
// Fatal on failure, matching RegisterProviders above: a registry that
// does not fit the deployment's own namespace configuration is a
// misconfiguration, and booting past it would serve a console that
// silently cannot allocate or authorize names.
// Expire pending claims whose verification window has elapsed
// (claim-expiry-and-carve-guards D1/D3). Core registry work with no
// integration in it, so it lives here rather than inside an
// integration's ReconcileDomains: a deployment with no integrations
// installed still has claims that expire.
//
// BEFORE the reconciler loop below, deliberately. A stranded pending
// claim is exactly what stops boot reconciliation from adopting the
// resource under it, and a stranded pending EXTERNAL claim at a
// configured farm domain makes EnsureOperatorRoot fatal — sweeping
// first can clear that on its own.
//
// Logged, not fatal, unlike the loop: an unswept claim holds one name,
// where an unensurable root leaves every hosted name unallocatable.
// The recurring Temporal schedule below re-runs this; the boot pass is
// what makes it independent of Temporal being configured at all.
if result, err := domains.NewRegistry(database).SweepExpiredClaims(ctx); err != nil {
logger.Error("domain claim expiry sweep failed",
slog.Int("candidates", result.Candidates),
slog.Int("expired", result.Expired),
slog.Int("failed", result.Failed),
slog.Any("error", err))
} else if result.Candidates > 0 {
logger.Info("domain claim expiry sweep completed",
slog.Int("candidates", result.Candidates),
slog.Int("expired", result.Expired))
}
// Ensure core.webhook_events has monthly RANGE partitions for the
// current month and the months ahead (webhook-partition-maintenance):
// the baseline migration only creates the current + next two months
// (internal/db/migrations/00001_init.sql:1048-1064), and there is no
// DEFAULT partition, so webhook ingestion would otherwise fail once
// the deployment runs past that window.
//
// Logged, not fatal, like the sweep above: the current-month
// partition almost always already exists, and failing boot on a
// transient DB error would turn a months-away risk into an
// immediate outage. The recurring Temporal schedule below re-runs
// this; the boot pass is what makes it independent of Temporal
// being configured at all.
if err := db.EnsureWebhookEventPartitions(ctx, database, time.Now(), db.DefaultWebhookPartitionMonthsAhead); err != nil {
logger.Warn("webhook_events partition ensure failed", slog.Any("error", err))
}
for _, integ := range integs {
dr, ok := integ.(domainsReconciler)
if !ok {
continue
}
if err := dr.ReconcileDomains(ctx, database, logger); err != nil {
logger.Error("failed to reconcile domains registry",
slog.String("integration", integ.Slug()),
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{
Slug: integ.Slug(),
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{
Slug: integ.Slug(),
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")
csrfSecret := viper.GetString("csrf-secret")
// 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))
}
}
// 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
// (mapping stripe-mode 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.
stripeDashboardURL := stripeintegration.DashboardURL(viper.GetString("stripe-mode"))
serverConfig := server.Config{
Port: port,
Env: env,
CSRFSecret: csrfSecret,
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,
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))
}
},
}
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 (development/production)")
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)")
startCmd.Flags().String("valkey-addr", "", "Valkey/Redis address for session storage (host:port)")
startCmd.Flags().String("csrf-secret", "", "Secret key for CSRF protection (must be exactly 32 bytes)")
startCmd.Flags().String("csrf-secret-file", "", "Path to file containing CSRF secret key")
// 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("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 stripe-mode 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 Go type is carried by its declared Default value
// (integration-config-parity D2): bool and duration keys travel
// this seam like strings do, so no integration knob needs
// hand-declaring in core.
switch key.Default.(type) {
case []string:
cmd.Flags().StringSlice(key.Name, nil, key.Usage)
case bool:
cmd.Flags().Bool(key.Name, false, key.Usage)
case time.Duration:
cmd.Flags().Duration(key.Name, 0, key.Usage)
default:
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
}