Files
member-console/internal/config/spec.go
T
cgalo5758 0b28a9dc29 Remediate security audit findings
- Replace gorilla/csrf with net/http CrossOriginProtection
- Require valkey-password and add TLS options for session store
- End session at /logout and revoke refresh tokens
- Re-derive identity and roles from provider every five minutes
- Process each Stripe webhook event in its own Temporal workflow
- Give each outbox entry its own workflow with Temporal retries
- Guard against stale Stripe events with provider timestamps
- Derive transport security from base-url scheme
2026-09-09 13:25:43 -05:00

257 lines
10 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package config
import (
"errors"
"fmt"
"strings"
"time"
)
// Type is a configuration key's value type, a closed set (design D1,
// typed-config-keys): string, url, duration, int, bool, list, enum. It
// decides the parser Parse applies, the flag constructor the composition
// root registers, and the control the settings surface renders: one seam
// every path reads instead of each switching on Default's Go type (which
// cannot tell a URL or a bare string apart, and knows nothing of "greater
// than zero").
type Type string
const (
TypeString Type = "string"
TypeURL Type = "url"
TypeDuration Type = "duration"
TypeInt Type = "int"
TypeBool Type = "bool"
TypeList Type = "list"
TypeEnum Type = "enum"
)
// ConfigKey declares one configuration key an integration owns — see
// design.md's "Declared config, spec-driven validation" decision
// (openspec/changes/integration-extraction/design.md). It is the single
// declaration cmd/start.go, this package's ValidateStart, and (once task
// 4.4 lands) internal/embeds/mc-config.yaml all derive their behavior
// from, replacing three previously hand-maintained, independently-drifting
// lists (cmd's flag/viper-default calls, validate.go's hardcoded
// per-integration conditional, and the YAML defaults file).
//
// Name is the viper/flag key (kebab-case, e.g. "stripe-api-key"). Default,
// when non-nil, is bound via viper.SetDefault; its dynamic type (string or
// []string today) tells cmd which cobra flag constructor to use. Usage is
// the flag's help text. Secret marks the key as sensitive: cmd also binds
// a "<name>-file" flag and, before validation runs, resolves a configured
// file path into Name (loading the value from disk) — mirroring the
// handling core's own secrets (oidc-sp-client-secret, stripe-api-key, ...)
// already receive. RequiredGroup, when non-empty, ties this key to every
// other declared key sharing the same tag: ValidateStart rejects a
// configuration where some but not all of a group's keys are set (e.g.
// Stripe's stripe-api-key and stripe-webhook-secret share the tag
// "Stripe" — both must be set to enable billing, or both left empty to
// disable it).
type ConfigKey struct {
Name string
Default any
Usage string
Secret bool
RequiredGroup string
// Enum, when non-empty, closes the key's value set. The runtime
// settings surface renders such keys as a select and rejects
// non-members at save time; the boot overlay re-checks stored
// overrides. ValidateStart also checks every declared Enum key's
// resolved environment/flag/config-file value for membership (design
// D8, schema-hardening), sharing enumMember with CoerceOverride so the
// two paths cannot diverge.
Enum []string
// Type is the key's declared value type. Leave it unset when the
// default's Go type already says enough: TypeOf infers string, bool,
// int, list and duration from Default, and enum from a non-empty Enum
// (design D1, typed-config-keys). Declare it explicitly for the two
// shapes inference cannot see: a URL (a string in Go, so TypeURL must
// be named), or a string key with no default that is not free text. A
// declared Type that disagrees with Default's Go type is a declaration
// error CheckSpecs refuses.
Type Type
// Positive marks an int or duration key whose zero value the key's own
// consumer treats as "unset, use the default" rather than "zero,
// disable it"; a saved zero would make the settings page state a
// value the running process silently ignores (design D6). Parse
// refuses zero for such a key with a named sentence. Declaring it on
// any other type is a declaration error CheckSpecs refuses.
Positive bool
}
// typeFromDefault infers a Type from def's Go type alone: bool, int,
// []string and time.Duration defaults imply bool, int, list and duration;
// anything else (a string default, or no default at all) implies string.
// This is the inference TypeOf applies when a key declares no Type and no
// Enum, and the same inference CheckSpecs names when reporting the actual
// shape of a Default that disagrees with a declared Type.
func typeFromDefault(def any) Type {
switch def.(type) {
case bool:
return TypeBool
case int:
return TypeInt
case []string:
return TypeList
case time.Duration:
return TypeDuration
default:
return TypeString
}
}
// TypeOf returns k's value type: the declared Type when set, TypeEnum when
// Enum is non-empty, or the type inferred from Default's Go type otherwise
// (design D1). Inference keeps every pre-existing declaration valid: a
// new integration needs no Type field for a string, bool, int, list or
// duration key, and Type exists for the two shapes inference cannot see: a
// URL, and a string key with no default that is not free text.
func (k ConfigKey) TypeOf() Type {
if k.Type != "" {
return k.Type
}
if len(k.Enum) > 0 {
return TypeEnum
}
return typeFromDefault(k.Default)
}
// typeContradictsDefault reports whether declared type t disagrees with
// def's Go type. A URL, an enum, and a bare string are all strings in Go,
// so all three accept a string default or none; every other type must
// match Default's Go type exactly. A nil Default contradicts nothing: there
// is no Go type to disagree with, which is exactly the shape a URL
// key with no default declares.
func typeContradictsDefault(t Type, def any) bool {
if def == nil {
return false
}
switch t {
case TypeURL, TypeEnum, TypeString:
_, ok := def.(string)
return !ok
case TypeDuration:
_, ok := def.(time.Duration)
return !ok
case TypeInt:
_, ok := def.(int)
return !ok
case TypeBool:
_, ok := def.(bool)
return !ok
case TypeList:
_, ok := def.([]string)
return !ok
default:
return false
}
}
// CheckSpecs validates every declared key's own shape, independent of any
// resolved value: a declared Type that disagrees with Default's Go type, a
// TypeEnum declared with no Enum members, a non-empty Enum whose Default is
// not a string, and Positive on a key whose type is not int or duration
// (design D1, D6). cmd/start.go runs it over every installed integration's
// spec at boot, before ValidateStart: a bad declaration is a programming
// error in an integration, not a bad deployment value, so it fails boot in
// development regardless of what any environment sets. The same call
// exercises every installed integration's ConfigSpec in tests.
func CheckSpecs(keys []ConfigKey) error {
var errs []error
for _, k := range keys {
if k.Type != "" && typeContradictsDefault(k.Type, k.Default) {
errs = append(errs, fmt.Errorf("%s: declared type %s contradicts its %s default", k.Name, k.Type, typeFromDefault(k.Default)))
}
if k.Type == TypeEnum && len(k.Enum) == 0 {
errs = append(errs, fmt.Errorf("%s: declares type enum with no Enum members", k.Name))
}
if len(k.Enum) > 0 && k.Default != nil {
if _, ok := k.Default.(string); !ok {
errs = append(errs, fmt.Errorf("%s: enum key has a non-string default", k.Name))
}
}
if k.Positive {
if t := k.TypeOf(); t != TypeInt && t != TypeDuration {
errs = append(errs, fmt.Errorf("%s: Positive is declared on a %s key; it applies only to int and duration keys", k.Name, t))
}
}
}
return errors.Join(errs...)
}
// ConfigProvider is implemented by integrations that declare their own
// configuration keys, so the composition root (cmd/start.go) can bind
// flags/env/defaults and this package can validate presence and
// required-together groups without a hardcoded per-integration block. It
// is declared here — in internal/config, not internal/integrations —
// because this package is the one that actually consumes ConfigKey values
// (ValidateStart's required-group check) and cmd/start.go already imports
// it for that call; declaring ConfigKey/ConfigProvider here instead means
// internal/config never has to import internal/integrations (and,
// transitively, every concrete adapter's own dependencies — internal/
// server, internal/workflows/*, the Temporal client, net/http) merely to
// reference a plain declaration struct. This mirrors why server.
// RouteProvider and workflows.WorkflowProvider are declared in their own
// consuming packages rather than in internal/integrations.
type ConfigProvider interface {
ConfigSpec() []ConfigKey
}
// SecretPair names a secret configuration key and its "<name>-file"
// companion flag, as cmd binds them. SecretPairsFrom derives the list cmd
// resolves (load the file, if any, into Name) before validation runs, so
// that list is data-driven from declarations instead of a hand-maintained
// literal per integration.
type SecretPair struct {
Name string
FileName string
}
// SecretPairsFrom returns the {Name, "<Name>-file"} pair for every
// declared key with Secret set, in declaration order.
func SecretPairsFrom(keys []ConfigKey) []SecretPair {
var pairs []SecretPair
for _, k := range keys {
if k.Secret {
pairs = append(pairs, SecretPair{Name: k.Name, FileName: k.Name + "-file"})
}
}
return pairs
}
// RequiredKeysUnresolved returns the names, in declaration order, of every
// key whose RequiredGroup is non-empty (ValidateStart's "required
// together" keys — see validateRequiredGroups) and whose resolved value
// (environment/flag/config-file/default, read straight from viper — the
// same layer ValidateStart and the settings page itself read) is empty
// after trimming. A key with no RequiredGroup imposes no requirement and
// is never reported.
//
// This is the ONE resolution every honest-status surface shares (ux-
// honest-surfaces design decision 2): the Integrations list, the
// overview's system region, and the per-integration settings page all
// derive their configuration-readiness signal from this function, so
// "not configured" means the same thing everywhere it appears. Secret
// keys are covered too — they are still bound into viper by cmd/start.go
// (via the "<name>-file" resolution), only their *value* is withheld from
// display, never their presence.
func RequiredKeysUnresolved(keys []ConfigKey) []string {
var missing []string
for _, k := range keys {
if k.RequiredGroup == "" {
continue
}
// effectiveString (overlay.go) is the same per-key resolution
// ApplyOverlay and validateRequiredGroups already read; trimming
// matches validateRequiredGroups' own presence check exactly.
if strings.TrimSpace(effectiveString(k)) == "" {
missing = append(missing, k.Name)
}
}
return missing
}