Files
member-console/internal/config/validate.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

183 lines
7.2 KiB
Go

// 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)
}