- 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
183 lines
7.2 KiB
Go
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)
|
|
}
|