- 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
257 lines
10 KiB
Go
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
|
|
}
|