// Package config validates the configuration required to run member-console // before any service is initialized, so a misconfiguration fails fast with an // actionable message instead of a late, cryptic downstream error. package config import ( "errors" "fmt" "net/url" "strings" "git.coopcloud.tech/wiki-cafe/member-console/internal/middleware" "github.com/spf13/viper" ) // ValidateStart checks the configuration required by `member-console start` and // returns a single aggregated error naming every problem at once (nil when the // configuration is valid). It reads the global Viper configuration, so it must be // called after flags, environment variables, the config file, and any // *-secret-file references have been resolved — and before any service // (database, Temporal, HTTP server) is initialized. // // integrationSpecs is the aggregated ConfigKey declarations from every // installed integration (see ConfigProvider) — the composition root // collects them via a registry loop (internal/integrations.All, type- // asserted against ConfigProvider) and passes them in here, so this // function validates each integration's required-together groups // generically instead of a hardcoded per-integration conditional. func ValidateStart(integrationSpecs []ConfigKey) error { var errs []error // --- Unconditionally required --- dsn := strings.TrimSpace(viper.GetString("db-dsn")) if dsn == "" { errs = append(errs, required("db-dsn", "e.g. postgres://user:pass@host:5432/dbname?sslmode=disable")) } else if u, err := url.Parse(dsn); err != nil || (u.Scheme != "postgres" && u.Scheme != "postgresql") { errs = append(errs, fmt.Errorf("db-dsn is not a valid PostgreSQL URL (want postgres://…): %q", dsn)) } if strings.TrimSpace(viper.GetString("valkey-addr")) == "" { errs = append(errs, required("valkey-addr", "the Valkey/Redis session store, e.g. localhost:6379")) } if err := requireURL("oidc-idp-issuer-url", "the OIDC issuer, e.g. https://idp.example.com/realms/main"); err != nil { errs = append(errs, err) } if strings.TrimSpace(viper.GetString("oidc-sp-client-id")) == "" { errs = append(errs, required("oidc-sp-client-id", "the OIDC client ID registered for this console")) } if err := requireURL("base-url", "the public URL of this console, e.g. https://console.example.com"); err != nil { errs = append(errs, err) } // deployment-name defaults to config.DefaultDeploymentName ("Member // Console") via viper.SetDefault, so this only fires when an operator // explicitly overrides it to blank or whitespace — the one input shape // the default cannot protect against (design decision 5, // ux-honest-surfaces). if strings.TrimSpace(viper.GetString("deployment-name")) == "" { errs = append(errs, errors.New("deployment-name cannot be blank or whitespace-only; leave it unset to use the default (\"Member Console\") or set a non-blank name")) } // Reuse the authoritative 32-byte check so the rule lives in one place and // fires here at boot instead of last, inside server.Start. if _, err := middleware.ParseCSRFKey(viper.GetString("csrf-secret")); err != nil { errs = append(errs, fmt.Errorf("csrf-secret is invalid: %w", err)) } // --- Conditional: Temporal --- if strings.TrimSpace(viper.GetString("temporal-host")) != "" { if strings.TrimSpace(viper.GetString("temporal-namespace")) == "" { errs = append(errs, required("temporal-namespace", `the Temporal namespace, e.g. "default"`)) } // OAuth is optional, but partial OAuth config never works: require the // token URL, client ID, and client secret together (or none). tokenURL := strings.TrimSpace(viper.GetString("temporal-oauth-token-url")) clientID := strings.TrimSpace(viper.GetString("temporal-oauth-client-id")) clientSecret := strings.TrimSpace(viper.GetString("temporal-oauth-client-secret")) if (tokenURL != "" || clientID != "" || clientSecret != "") && (tokenURL == "" || clientID == "" || clientSecret == "") { errs = append(errs, errors.New("temporal OAuth is partially configured: set temporal-oauth-token-url, temporal-oauth-client-id, and temporal-oauth-client-secret together (or none to disable Temporal auth)")) } } // --- Conditional: installed integrations --- errs = append(errs, validateRequiredGroups(integrationSpecs)...) errs = append(errs, validateEnums(integrationSpecs)...) return errors.Join(errs...) } // validateEnums checks every declared key with a non-empty Enum: the // resolved value (from flags, environment, or the config file — ValidateStart // runs before ApplyOverlay, so operator overrides are not yet layered in) // must be a member of the declared set. An empty/unset value passes — // presence is a separate concern, governed by RequiredGroup where it // applies. Membership uses enumMember (overlay.go), the same check // CoerceOverride applies to operator-set overrides, so the two validation // paths cannot diverge (design D8). func validateEnums(specs []ConfigKey) []error { var errs []error for _, k := range specs { if len(k.Enum) == 0 { continue } value := strings.TrimSpace(viper.GetString(k.Name)) if value == "" { continue } if !enumMember(k, value) { errs = append(errs, fmt.Errorf( "%s: value %q is not one of %s", k.Name, value, strings.Join(k.Enum, "|"))) } } return errs } // validateRequiredGroups checks every non-empty RequiredGroup shared by two // or more declared keys: either all keys in the group are set, or none are. // A group with some but not all keys set is rejected with a single // aggregated error per group, naming every key in it — the same shape and // UX the hardcoded "Conditional: Stripe" block used to produce by hand. func validateRequiredGroups(specs []ConfigKey) []error { type group struct { names []string set int } groups := make(map[string]*group) // Preserve first-seen group order so aggregated errors are deterministic. var order []string for _, k := range specs { if k.RequiredGroup == "" { continue } g, ok := groups[k.RequiredGroup] if !ok { g = &group{} groups[k.RequiredGroup] = g order = append(order, k.RequiredGroup) } g.names = append(g.names, k.Name) if strings.TrimSpace(viper.GetString(k.Name)) != "" { g.set++ } } var errs []error for _, name := range order { g := groups[name] if g.set > 0 && g.set < len(g.names) { errs = append(errs, fmt.Errorf( "%s is partially configured: set %s together (or leave them all empty to disable it)", name, strings.Join(g.names, " and "))) } } return errs } // requireURL validates that key is a non-empty, parseable URL with a scheme and // host, returning an actionable error otherwise. func requireURL(key, hint string) error { raw := strings.TrimSpace(viper.GetString(key)) if raw == "" { return required(key, hint) } if u, err := url.Parse(raw); err != nil || u.Scheme == "" || u.Host == "" { return fmt.Errorf("%s is not a valid URL (want scheme://host): %q", key, raw) } return nil } // required formats a missing-key error that names both ways to set the value. func required(key, hint string) error { env := "MC_" + strings.ToUpper(strings.ReplaceAll(key, "-", "_")) return fmt.Errorf("%s is required (set %s or the %s key in mc-config.yaml) — %s", key, env, key, hint) }