M go.mod => go.mod +1 -1
@@ 6,6 6,7 @@ require (
github.com/coreos/go-oidc/v3 v3.18.0
github.com/go-chi/chi/v5 v5.2.5
github.com/go-playground/validator/v10 v10.30.2
+ github.com/go-viper/mapstructure/v2 v2.4.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/jmoiron/sqlx v1.4.0
github.com/prometheus/client_golang v1.23.2
@@ 24,7 25,6 @@ require (
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
- github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
A internal/config/config.go => internal/config/config.go +232 -0
@@ 0,0 1,232 @@
+// Package config loads and validates the lethe server configuration from a
+// YAML file plus LETHE_* environment overrides.
+//
+// The Config struct is split into substructs (Server, Database, Auth, Logging,
+// Ingest); each substruct is tagged with `config-section:""` so that steward
+// (Phase 4+) can inject them by type into individual services.
+//
+// Validation runs in fail-fast mode: any unknown YAML key, missing required
+// field, or out-of-range value rejects the load with a culpa-wrapped error
+// carrying a machine-readable code (CONFIG_NOT_FOUND, CONFIG_PARSE,
+// CONFIG_VALIDATE). There are no silent fallbacks: the only defaults applied
+// are the ones explicitly listed in registerDefaults.
+package config
+
+import (
+ "errors"
+ "io/fs"
+ "os"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/go-playground/validator/v10"
+ "github.com/go-viper/mapstructure/v2"
+ "github.com/spf13/viper"
+ "go.bigb.es/auxilia/culpa"
+)
+
+// Config is the root configuration object. Each substruct is exposed to
+// steward as a config section via the `config-section:""` tag.
+type Config struct {
+ Server ServerConfig `mapstructure:"server" config-section:""`
+ Database DatabaseConfig `mapstructure:"database" config-section:""`
+ Auth AuthConfig `mapstructure:"auth" config-section:""`
+ Logging LoggingConfig `mapstructure:"logging" config-section:""`
+ Ingest IngestConfig `mapstructure:"ingest" config-section:""`
+}
+
+// ServerConfig controls the HTTP listener.
+type ServerConfig struct {
+ Bind string `mapstructure:"bind" validate:"required,loopback_bind"`
+ ShutdownGrace time.Duration `mapstructure:"shutdown_grace" validate:"gt=0"`
+}
+
+// DatabaseConfig points at the SQLite file and tunes its busy timeout.
+type DatabaseConfig struct {
+ Path string `mapstructure:"path" validate:"required"`
+ BusyTimeout time.Duration `mapstructure:"busy_timeout" validate:"gt=0"`
+}
+
+// AuthConfig is the authentication policy. At least one of ForwardAuth or
+// OIDC must be enabled (enforced by the auth_at_least_one struct-level
+// validator) and Admins must be a subset of AllowedUsers (enforced by
+// admins_subset_of_allowed).
+type AuthConfig struct {
+ AllowedUsers []string `mapstructure:"allowed_users" validate:"min=1,dive,required"`
+ Admins []string `mapstructure:"admins" validate:"dive,required"`
+ ForwardAuth ForwardAuthConfig `mapstructure:"forward_auth"`
+ OIDC OIDCConfig `mapstructure:"oidc"`
+}
+
+// ForwardAuthConfig consumes a trusted reverse-proxy header.
+type ForwardAuthConfig struct {
+ Enabled bool `mapstructure:"enabled"`
+ UserHeader string `mapstructure:"user_header"`
+}
+
+// OIDCConfig validates bearer tokens against an OIDC issuer.
+type OIDCConfig struct {
+ Enabled bool `mapstructure:"enabled"`
+ Issuer string `mapstructure:"issuer" validate:"required_if=Enabled true,omitempty,url"`
+ Audience string `mapstructure:"audience" validate:"required_if=Enabled true"`
+ UsernameClaim string `mapstructure:"username_claim"`
+}
+
+// LoggingConfig selects log level and formatter.
+type LoggingConfig struct {
+ Level string `mapstructure:"level" validate:"required,oneof=debug info warn error"`
+ Format string `mapstructure:"format" validate:"required,oneof=tint json"`
+}
+
+// IngestConfig caps payload sizes and chunking on the ingest path.
+type IngestConfig struct {
+ MaxBodyBytes int64 `mapstructure:"max_body_bytes" validate:"gt=0"`
+ MaxTurnContentBytes int64 `mapstructure:"max_turn_content_bytes" validate:"gt=0,ltefield=MaxBodyBytes"`
+ ChunkSize int `mapstructure:"chunk_size" validate:"gt=0"`
+}
+
+// loopbackBindRe matches `127.0.0.1` or `127.0.0.1:<port>` (1-5 digits).
+// Other interfaces are rejected so the listener never binds publicly.
+var loopbackBindRe = regexp.MustCompile(`^127\.0\.0\.1(?::\d{1,5})?$`)
+
+// Load reads YAML from path, applies env overrides, fills documented
+// defaults, and validates. Errors carry a culpa code.
+func Load(path string) (*Config, error) {
+ v := viper.New()
+ v.SetConfigFile(path)
+ v.SetEnvPrefix("LETHE")
+ v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
+ v.AutomaticEnv()
+
+ registerDefaults(v)
+
+ if err := v.ReadInConfig(); err != nil {
+ // viper returns *fs.PathError for missing files; treat any read
+ // failure as CONFIG_NOT_FOUND vs CONFIG_PARSE based on type.
+ if _, ok := err.(viper.ConfigFileNotFoundError); ok {
+ return nil, culpa.WithCode(culpa.Wrap(err, "config file not found"), "CONFIG_NOT_FOUND")
+ }
+ // Distinguish "missing file" (os PathError) from a parse failure.
+ if isFileMissing(err) {
+ return nil, culpa.WithCode(culpa.Wrap(err, "config file not found"), "CONFIG_NOT_FOUND")
+ }
+ return nil, culpa.WithCode(culpa.Wrap(err, "parse config"), "CONFIG_PARSE")
+ }
+
+ // Stitch comma-separated env overrides for slice fields. viper does
+ // not split string env values into []string by itself, so we do it
+ // here for the known slice fields under auth.
+ splitCSVEnv(v, "auth.allowed_users", "LETHE_AUTH_ALLOWED_USERS")
+ splitCSVEnv(v, "auth.admins", "LETHE_AUTH_ADMINS")
+
+ var cfg Config
+ if err := v.UnmarshalExact(
+ &cfg,
+ viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
+ mapstructure.StringToTimeDurationHookFunc(),
+ mapstructure.StringToSliceHookFunc(","),
+ )),
+ func(c *mapstructure.DecoderConfig) {
+ c.ErrorUnused = true
+ },
+ ); err != nil {
+ return nil, culpa.WithCode(culpa.Wrap(err, "decode config"), "CONFIG_PARSE")
+ }
+
+ val := newValidator()
+ if err := val.Struct(&cfg); err != nil {
+ return nil, culpa.WithCode(culpa.Wrap(err, "validate config"), "CONFIG_VALIDATE")
+ }
+ return &cfg, nil
+}
+
+// MustLoad calls Load and panics on error. Used by main.go where a bad
+// config means the process cannot start anyway.
+func MustLoad(path string) *Config {
+ cfg, err := Load(path)
+ if err != nil {
+ panic(err)
+ }
+ return cfg
+}
+
+// registerDefaults sets the documented defaults. Notably, neither
+// auth.forward_auth.enabled nor auth.oidc.enabled has a default — the
+// operator must explicitly enable at least one mode.
+func registerDefaults(v *viper.Viper) {
+ v.SetDefault("server.shutdown_grace", 10*time.Second)
+ v.SetDefault("database.busy_timeout", 5*time.Second)
+ v.SetDefault("auth.forward_auth.user_header", "Remote-User")
+ v.SetDefault("auth.oidc.username_claim", "preferred_username")
+ v.SetDefault("ingest.max_body_bytes", int64(16*1024*1024))
+ v.SetDefault("ingest.max_turn_content_bytes", int64(4*1024*1024))
+ v.SetDefault("ingest.chunk_size", 500)
+}
+
+// splitCSVEnv converts a comma-separated environment variable into a
+// []string and stores it on viper under key. We cannot rely on viper's
+// AutomaticEnv to do this for slice destinations because the env value
+// arrives as a single string after viper resolves it.
+func splitCSVEnv(v *viper.Viper, key, envName string) {
+ raw, ok := os.LookupEnv(envName)
+ if !ok {
+ return
+ }
+ parts := strings.Split(raw, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ out = append(out, p)
+ }
+ v.Set(key, out)
+}
+
+// newValidator builds a *validator.Validate with our custom rules
+// registered.
+func newValidator() *validator.Validate {
+ val := validator.New(validator.WithRequiredStructEnabled())
+ // "loopback_bind" is a field-level rule so it can be expressed via the
+ // validate tag on ServerConfig.Bind alongside required.
+ _ = val.RegisterValidation("loopback_bind", func(fl validator.FieldLevel) bool {
+ return loopbackBindRe.MatchString(fl.Field().String())
+ })
+ val.RegisterStructValidation(authStructValidator, AuthConfig{})
+ return val
+}
+
+// authStructValidator enforces both auth_at_least_one and
+// admins_subset_of_allowed. Struct-level is the right level: both rules
+// straddle multiple fields of AuthConfig.
+func authStructValidator(sl validator.StructLevel) {
+ auth, ok := sl.Current().Interface().(AuthConfig)
+ if !ok {
+ return
+ }
+ if !auth.ForwardAuth.Enabled && !auth.OIDC.Enabled {
+ sl.ReportError(auth.ForwardAuth.Enabled, "ForwardAuth.Enabled", "forward_auth.enabled", "auth_at_least_one", "")
+ }
+ allowed := make(map[string]struct{}, len(auth.AllowedUsers))
+ for _, u := range auth.AllowedUsers {
+ allowed[u] = struct{}{}
+ }
+ for _, admin := range auth.Admins {
+ if _, found := allowed[admin]; !found {
+ sl.ReportError(auth.Admins, "Admins", "admins", "admins_subset_of_allowed", "")
+ return
+ }
+ }
+}
+
+// isFileMissing reports whether err describes a missing-file condition
+// from viper.ReadInConfig. viper wraps the underlying os error.
+func isFileMissing(err error) bool {
+ var pe *fs.PathError
+ if errors.As(err, &pe) {
+ return errors.Is(pe.Err, fs.ErrNotExist)
+ }
+ return errors.Is(err, fs.ErrNotExist)
+}
A internal/config/config_test.go => internal/config/config_test.go +381 -0
@@ 0,0 1,381 @@
+package config_test
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "sourcecraft.dev/bigbes/lethe/internal/config"
+)
+
+// validForwardAuthYAML is a baseline valid configuration with forward-auth
+// enabled. Tests typically clone and tweak this string for negative cases.
+const validForwardAuthYAML = `
+server:
+ bind: "127.0.0.1:8080"
+database:
+ path: "./lethe.db"
+auth:
+ allowed_users: ["alice", "bob"]
+ admins: ["alice"]
+ forward_auth:
+ enabled: true
+ oidc:
+ enabled: false
+logging:
+ level: "info"
+ format: "tint"
+ingest: {}
+`
+
+const validOIDCYAML = `
+server:
+ bind: "127.0.0.1:8080"
+database:
+ path: "./lethe.db"
+auth:
+ allowed_users: ["alice"]
+ forward_auth:
+ enabled: false
+ oidc:
+ enabled: true
+ issuer: "https://auth.example.com"
+ audience: "lethe"
+logging:
+ level: "info"
+ format: "tint"
+ingest: {}
+`
+
+const validBothEnabledYAML = `
+server:
+ bind: "127.0.0.1:8080"
+database:
+ path: "./lethe.db"
+auth:
+ allowed_users: ["alice"]
+ forward_auth:
+ enabled: true
+ oidc:
+ enabled: true
+ issuer: "https://auth.example.com"
+ audience: "lethe"
+logging:
+ level: "info"
+ format: "tint"
+ingest: {}
+`
+
+func writeYAML(t *testing.T, body string) string {
+ t.Helper()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.yaml")
+ if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
+ t.Fatalf("write yaml: %v", err)
+ }
+ return path
+}
+
+func TestLoad_ValidForwardAuth(t *testing.T) {
+ path := writeYAML(t, validForwardAuthYAML)
+ cfg, err := config.Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if cfg.Server.Bind != "127.0.0.1:8080" {
+ t.Errorf("Server.Bind = %q", cfg.Server.Bind)
+ }
+ if !cfg.Auth.ForwardAuth.Enabled {
+ t.Error("ForwardAuth.Enabled = false, want true")
+ }
+}
+
+func TestLoad_ValidOIDC(t *testing.T) {
+ path := writeYAML(t, validOIDCYAML)
+ cfg, err := config.Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if !cfg.Auth.OIDC.Enabled {
+ t.Error("OIDC.Enabled = false, want true")
+ }
+ if cfg.Auth.OIDC.Issuer != "https://auth.example.com" {
+ t.Errorf("OIDC.Issuer = %q", cfg.Auth.OIDC.Issuer)
+ }
+}
+
+func TestLoad_ValidBothEnabled(t *testing.T) {
+ path := writeYAML(t, validBothEnabledYAML)
+ if _, err := config.Load(path); err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+}
+
+func TestLoad_Defaults(t *testing.T) {
+ path := writeYAML(t, validForwardAuthYAML)
+ cfg, err := config.Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if cfg.Auth.ForwardAuth.UserHeader != "Remote-User" {
+ t.Errorf("ForwardAuth.UserHeader = %q, want Remote-User", cfg.Auth.ForwardAuth.UserHeader)
+ }
+ if cfg.Auth.OIDC.UsernameClaim != "preferred_username" {
+ t.Errorf("OIDC.UsernameClaim = %q, want preferred_username", cfg.Auth.OIDC.UsernameClaim)
+ }
+ if cfg.Ingest.MaxBodyBytes != 16*1024*1024 {
+ t.Errorf("Ingest.MaxBodyBytes = %d, want %d", cfg.Ingest.MaxBodyBytes, 16*1024*1024)
+ }
+ if cfg.Ingest.MaxTurnContentBytes != 4*1024*1024 {
+ t.Errorf("Ingest.MaxTurnContentBytes = %d, want %d", cfg.Ingest.MaxTurnContentBytes, 4*1024*1024)
+ }
+ if cfg.Ingest.ChunkSize != 500 {
+ t.Errorf("Ingest.ChunkSize = %d, want 500", cfg.Ingest.ChunkSize)
+ }
+ if cfg.Database.BusyTimeout != 5*time.Second {
+ t.Errorf("Database.BusyTimeout = %s, want 5s", cfg.Database.BusyTimeout)
+ }
+ if cfg.Server.ShutdownGrace != 10*time.Second {
+ t.Errorf("Server.ShutdownGrace = %s, want 10s", cfg.Server.ShutdownGrace)
+ }
+}
+
+func TestLoad_EmptyAllowlistRejected(t *testing.T) {
+ body := `
+server:
+ bind: "127.0.0.1:8080"
+database:
+ path: "./lethe.db"
+auth:
+ allowed_users: []
+ forward_auth:
+ enabled: true
+logging:
+ level: "info"
+ format: "tint"
+ingest: {}
+`
+ if _, err := config.Load(writeYAML(t, body)); err == nil {
+ t.Fatal("expected error for empty allowed_users, got nil")
+ }
+}
+
+func TestLoad_NonLoopbackBindRejected(t *testing.T) {
+ body := strings.Replace(validForwardAuthYAML, `"127.0.0.1:8080"`, `"0.0.0.0:8080"`, 1)
+ if _, err := config.Load(writeYAML(t, body)); err == nil {
+ t.Fatal("expected error for non-loopback bind, got nil")
+ }
+}
+
+func TestLoad_BothAuthModesDisabledRejected(t *testing.T) {
+ body := `
+server:
+ bind: "127.0.0.1:8080"
+database:
+ path: "./lethe.db"
+auth:
+ allowed_users: ["alice"]
+ forward_auth:
+ enabled: false
+ oidc:
+ enabled: false
+logging:
+ level: "info"
+ format: "tint"
+ingest: {}
+`
+ if _, err := config.Load(writeYAML(t, body)); err == nil {
+ t.Fatal("expected error when both auth modes are disabled, got nil")
+ }
+}
+
+func TestLoad_OIDCEnabledWithoutIssuerRejected(t *testing.T) {
+ body := `
+server:
+ bind: "127.0.0.1:8080"
+database:
+ path: "./lethe.db"
+auth:
+ allowed_users: ["alice"]
+ forward_auth:
+ enabled: false
+ oidc:
+ enabled: true
+ audience: "lethe"
+logging:
+ level: "info"
+ format: "tint"
+ingest: {}
+`
+ if _, err := config.Load(writeYAML(t, body)); err == nil {
+ t.Fatal("expected error for OIDC enabled without issuer, got nil")
+ }
+}
+
+func TestLoad_OIDCEnabledWithNonURLIssuerRejected(t *testing.T) {
+ body := `
+server:
+ bind: "127.0.0.1:8080"
+database:
+ path: "./lethe.db"
+auth:
+ allowed_users: ["alice"]
+ forward_auth:
+ enabled: false
+ oidc:
+ enabled: true
+ issuer: "not a url"
+ audience: "lethe"
+logging:
+ level: "info"
+ format: "tint"
+ingest: {}
+`
+ if _, err := config.Load(writeYAML(t, body)); err == nil {
+ t.Fatal("expected error for non-URL issuer, got nil")
+ }
+}
+
+func TestLoad_AdminNotInAllowedUsersRejected(t *testing.T) {
+ body := `
+server:
+ bind: "127.0.0.1:8080"
+database:
+ path: "./lethe.db"
+auth:
+ allowed_users: ["alice"]
+ admins: ["mallory"]
+ forward_auth:
+ enabled: true
+logging:
+ level: "info"
+ format: "tint"
+ingest: {}
+`
+ if _, err := config.Load(writeYAML(t, body)); err == nil {
+ t.Fatal("expected error when admin is not in allowed_users, got nil")
+ }
+}
+
+func TestLoad_EmptyAdminsAccepted(t *testing.T) {
+ body := `
+server:
+ bind: "127.0.0.1:8080"
+database:
+ path: "./lethe.db"
+auth:
+ allowed_users: ["alice"]
+ admins: []
+ forward_auth:
+ enabled: true
+logging:
+ level: "info"
+ format: "tint"
+ingest: {}
+`
+ if _, err := config.Load(writeYAML(t, body)); err != nil {
+ t.Fatalf("expected empty admins to be accepted, got %v", err)
+ }
+}
+
+func TestLoad_MissingDatabasePathRejected(t *testing.T) {
+ body := `
+server:
+ bind: "127.0.0.1:8080"
+database: {}
+auth:
+ allowed_users: ["alice"]
+ forward_auth:
+ enabled: true
+logging:
+ level: "info"
+ format: "tint"
+ingest: {}
+`
+ if _, err := config.Load(writeYAML(t, body)); err == nil {
+ t.Fatal("expected error for missing database.path, got nil")
+ }
+}
+
+func TestLoad_MaxTurnContentBytesGreaterThanMaxBodyBytesRejected(t *testing.T) {
+ body := `
+server:
+ bind: "127.0.0.1:8080"
+database:
+ path: "./lethe.db"
+auth:
+ allowed_users: ["alice"]
+ forward_auth:
+ enabled: true
+logging:
+ level: "info"
+ format: "tint"
+ingest:
+ max_body_bytes: 1024
+ max_turn_content_bytes: 4096
+`
+ if _, err := config.Load(writeYAML(t, body)); err == nil {
+ t.Fatal("expected error when max_turn_content_bytes > max_body_bytes, got nil")
+ }
+}
+
+func TestLoad_UnknownYAMLKeyRejected(t *testing.T) {
+ body := `
+server:
+ bind: "127.0.0.1:8080"
+ totally_unknown_key: 42
+database:
+ path: "./lethe.db"
+auth:
+ allowed_users: ["alice"]
+ forward_auth:
+ enabled: true
+logging:
+ level: "info"
+ format: "tint"
+ingest: {}
+`
+ if _, err := config.Load(writeYAML(t, body)); err == nil {
+ t.Fatal("expected error for unknown YAML key, got nil")
+ }
+}
+
+func TestLoad_EnvOverride(t *testing.T) {
+ // Use a YAML body without admins so the override test only exercises
+ // the slice-decoding path (LETHE_AUTH_ALLOWED_USERS overrides YAML).
+ body := `
+server:
+ bind: "127.0.0.1:8080"
+database:
+ path: "./lethe.db"
+auth:
+ allowed_users: ["alice"]
+ forward_auth:
+ enabled: true
+logging:
+ level: "info"
+ format: "tint"
+ingest: {}
+`
+ path := writeYAML(t, body)
+ t.Setenv("LETHE_AUTH_ALLOWED_USERS", "carol,dave")
+ cfg, err := config.Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ got := cfg.Auth.AllowedUsers
+ if len(got) != 2 || got[0] != "carol" || got[1] != "dave" {
+ t.Errorf("AllowedUsers = %v, want [carol dave]", got)
+ }
+}
+
+func TestMustLoad_PanicsOnError(t *testing.T) {
+ defer func() {
+ if r := recover(); r == nil {
+ t.Fatal("expected MustLoad to panic on missing file")
+ }
+ }()
+ config.MustLoad("/nonexistent/path/that/should/not/exist.yaml")
+}
M internal/deps/deps.go => internal/deps/deps.go +7 -9
@@ 1,22 1,20 @@
// Package deps records the locked set of direct dependencies for the lethe
-// server during early scaffolding (Phase 1). Real packages adopt these as
-// they come online (config — viper/validator; server — chi/prometheus;
-// database — sqlx/modernc.org/sqlite/golang-migrate; auth — go-oidc;
-// platform — auxilia steward/culpa/scribe).
+// server during early scaffolding. Real packages adopt these as they come
+// online (server — chi/prometheus; database — sqlx/modernc.org/sqlite/
+// golang-migrate; auth — go-oidc; platform — auxilia steward/scribe).
//
-// Once every dep below has at least one real importer, this file is
-// expected to disappear in the same commit that completes the migration.
+// Phase 2 promoted viper, validator/v10, and culpa to real imports under
+// internal/config; they no longer appear here. Once every dep below has at
+// least one real importer, this file is expected to disappear in the same
+// commit that completes the migration.
package deps
import (
_ "github.com/coreos/go-oidc/v3/oidc"
_ "github.com/go-chi/chi/v5"
- _ "github.com/go-playground/validator/v10"
_ "github.com/golang-migrate/migrate/v4"
_ "github.com/jmoiron/sqlx"
_ "github.com/prometheus/client_golang/prometheus"
- _ "github.com/spf13/viper"
- _ "go.bigb.es/auxilia/culpa"
_ "go.bigb.es/auxilia/scribe"
_ "go.bigb.es/auxilia/steward"
_ "modernc.org/sqlite"