- 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
645 lines
31 KiB
Go
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
|
|
}
|