feat(config): app-config registry, env-var renames, wire session.duration from DB
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
80
backend/internal/config/appconfig.go
Normal file
80
backend/internal/config/appconfig.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppKey describes one application-configuration key: instance-global,
|
||||
// stored (as an override) in the DB `config` table, read once at startup.
|
||||
// Cold -- a changed value applies on the next backend restart.
|
||||
type AppKey struct {
|
||||
Key string
|
||||
Default string
|
||||
Description string
|
||||
Validate func(value string) error
|
||||
}
|
||||
|
||||
// KeySessionDuration is the session cookie lifetime, in integer hours.
|
||||
const KeySessionDuration = "session.duration"
|
||||
|
||||
var appRegistry = []AppKey{
|
||||
{
|
||||
Key: KeySessionDuration,
|
||||
Default: "720",
|
||||
Description: "Session cookie lifetime in hours",
|
||||
Validate: validatePositiveInt,
|
||||
},
|
||||
}
|
||||
|
||||
// AppRegistry returns every known application-configuration key, in
|
||||
// display order.
|
||||
func AppRegistry() []AppKey { return appRegistry }
|
||||
|
||||
func validatePositiveInt(v string) error {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n <= 0 {
|
||||
return fmt.Errorf("must be a positive integer, got %q", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAppValue rejects unknown keys and invalid values -- every write
|
||||
// path's guard, so the config table can never accumulate junk.
|
||||
func ValidateAppValue(key, value string) error {
|
||||
for _, k := range appRegistry {
|
||||
if k.Key == key {
|
||||
if err := k.Validate(value); err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unknown configuration key %q", key)
|
||||
}
|
||||
|
||||
// AppConfig is the typed result of merging DB overrides over registry
|
||||
// defaults.
|
||||
type AppConfig struct {
|
||||
SessionDuration time.Duration
|
||||
}
|
||||
|
||||
// LoadApp merges overrides (store.ConfigValues' map) over defaults. Pure
|
||||
// -- it takes the raw map instead of a *store.DB so this package needs no
|
||||
// store dependency and the merge logic tests without a database. A bad
|
||||
// stored value fails fast, same posture as Load() for env vars.
|
||||
func LoadApp(overrides map[string]string) (AppConfig, error) {
|
||||
merged := map[string]string{}
|
||||
for _, k := range appRegistry {
|
||||
merged[k.Key] = k.Default
|
||||
}
|
||||
for key, value := range overrides {
|
||||
if err := ValidateAppValue(key, value); err != nil {
|
||||
return AppConfig{}, fmt.Errorf("app config: %w", err)
|
||||
}
|
||||
merged[key] = value
|
||||
}
|
||||
hours, _ := strconv.Atoi(merged[KeySessionDuration])
|
||||
return AppConfig{SessionDuration: time.Duration(hours) * time.Hour}, nil
|
||||
}
|
||||
79
backend/internal/config/appconfig_test.go
Normal file
79
backend/internal/config/appconfig_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadApp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
overrides map[string]string
|
||||
want time.Duration
|
||||
wantErr string
|
||||
}{
|
||||
{name: "defaults when no overrides", overrides: nil, want: 720 * time.Hour},
|
||||
{name: "override applied", overrides: map[string]string{"session.duration": "168"}, want: 168 * time.Hour},
|
||||
{name: "invalid stored value", overrides: map[string]string{"session.duration": "zero"}, wantErr: "session.duration"},
|
||||
{name: "unknown stored key", overrides: map[string]string{"bogus.key": "1"}, wantErr: "unknown configuration key"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := LoadApp(tt.overrides)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("LoadApp: %v", err)
|
||||
}
|
||||
if got.SessionDuration != tt.want {
|
||||
t.Fatalf("SessionDuration = %v, want %v", got.SessionDuration, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAppValue(t *testing.T) {
|
||||
if err := ValidateAppValue("session.duration", "24"); err != nil {
|
||||
t.Fatalf("valid value rejected: %v", err)
|
||||
}
|
||||
if err := ValidateAppValue("session.duration", "-1"); err == nil {
|
||||
t.Fatal("negative hours accepted")
|
||||
}
|
||||
if err := ValidateAppValue("session.duration", "1.5"); err == nil {
|
||||
t.Fatal("non-integer accepted")
|
||||
}
|
||||
if err := ValidateAppValue("nope", "1"); err == nil {
|
||||
t.Fatal("unknown key accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayEnv_MasksSecrets(t *testing.T) {
|
||||
cfg := Config{
|
||||
Addr: ":8080", OIDCClientSecret: "hunter2",
|
||||
SessionSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||||
}
|
||||
entries := map[string]string{}
|
||||
for _, e := range cfg.DisplayEnv() {
|
||||
entries[e.Name] = e.Value
|
||||
}
|
||||
if entries["GENIUSRUN_BACKEND_ADDR"] != ":8080" {
|
||||
t.Errorf("GENIUSRUN_BACKEND_ADDR = %q", entries["GENIUSRUN_BACKEND_ADDR"])
|
||||
}
|
||||
if entries["GENIUSRUN_OIDC_CLIENT_SECRET"] != "•••• (set)" {
|
||||
t.Errorf("client secret not masked: %q", entries["GENIUSRUN_OIDC_CLIENT_SECRET"])
|
||||
}
|
||||
if entries["GENIUSRUN_SESSION_SECRET"] != "•••• (set)" {
|
||||
t.Errorf("session secret not masked: %q", entries["GENIUSRUN_SESSION_SECRET"])
|
||||
}
|
||||
empty := Config{}
|
||||
for _, e := range empty.DisplayEnv() {
|
||||
if e.Name == "GENIUSRUN_OIDC_CLIENT_SECRET" && e.Value != "(unset)" {
|
||||
t.Errorf("unset secret = %q, want (unset)", e.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,7 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds geniusrund's process-level configuration.
|
||||
@@ -26,7 +24,7 @@ type Config struct {
|
||||
GarminPythonPath string
|
||||
// GarminTokenStoreRoot is the root directory under which each user's
|
||||
// Garmin session cache lives (one subdirectory per user id, e.g.
|
||||
// "<root>/3"). Read from the GARMIN_TOKENSTORE env var, configured
|
||||
// "<root>/3"). Read from the GENIUSRUN_TOKENSTORE_PATH env var, configured
|
||||
// independently of DBPath -- if unset, it defaults to a ".garmin"
|
||||
// directory relative to the working directory the process is started
|
||||
// from, not derived from DBPath in any way, so every deployment gets
|
||||
@@ -35,7 +33,6 @@ type Config struct {
|
||||
// api.Server.garminFor), so it can never be silently left empty.
|
||||
GarminTokenStoreRoot string
|
||||
|
||||
MinConfidence float64
|
||||
// LogLevel controls internal/applog's JSON logger ("debug"|"info"|"warn"|"error").
|
||||
LogLevel string
|
||||
|
||||
@@ -62,7 +59,6 @@ type Config struct {
|
||||
OIDCRedirectURL string
|
||||
OIDCRequiredRole string
|
||||
SessionSecret []byte
|
||||
SessionDuration time.Duration
|
||||
SessionSecure bool
|
||||
}
|
||||
|
||||
@@ -70,18 +66,16 @@ type Config struct {
|
||||
// for anything optional. Returns an error if a required variable is unset.
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Addr: getEnvDefault("GENIUSRUN_ADDR", ":8080"),
|
||||
Addr: getEnvDefault("GENIUSRUN_BACKEND_ADDR", ":8080"),
|
||||
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
|
||||
GarminPythonPath: getEnvDefault("GARMIN_WRAPPER_PYTHON", "python3"),
|
||||
GarminTokenStoreRoot: getEnvDefault("GARMIN_TOKENSTORE", ".garmin"),
|
||||
MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6),
|
||||
GarminPythonPath: getEnvDefault("GENIUSRUN_PYTHON_PATH", "python3"),
|
||||
GarminTokenStoreRoot: getEnvDefault("GENIUSRUN_TOKENSTORE_PATH", ".garmin"),
|
||||
LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
|
||||
BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
|
||||
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
|
||||
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
|
||||
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
|
||||
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
|
||||
SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour),
|
||||
}
|
||||
|
||||
if cfg.BackendURL == "" {
|
||||
@@ -117,20 +111,35 @@ func getEnvDefault(key, def string) string {
|
||||
return def
|
||||
}
|
||||
|
||||
func getEnvFloat(key string, def float64) float64 {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return def
|
||||
// EnvEntry is one environment-configuration variable as displayed on the
|
||||
// config page. Display-only: secrets are masked here, so raw values never
|
||||
// leave the process.
|
||||
type EnvEntry struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
func getEnvDuration(key string, def time.Duration) time.Duration {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
return d
|
||||
// DisplayEnv returns the environment configuration as a display-safe
|
||||
// list, in stable order, secrets masked.
|
||||
func (c Config) DisplayEnv() []EnvEntry {
|
||||
mask := func(set bool) string {
|
||||
if set {
|
||||
return "•••• (set)"
|
||||
}
|
||||
return "(unset)"
|
||||
}
|
||||
return []EnvEntry{
|
||||
{Name: "GENIUSRUN_BACKEND_ADDR", Value: c.Addr},
|
||||
{Name: "GENIUSRUN_DB_PATH", Value: c.DBPath},
|
||||
{Name: "GENIUSRUN_PYTHON_PATH", Value: c.GarminPythonPath},
|
||||
{Name: "GENIUSRUN_TOKENSTORE_PATH", Value: c.GarminTokenStoreRoot},
|
||||
{Name: "GENIUSRUN_LOG_LEVEL", Value: c.LogLevel},
|
||||
{Name: "GENIUSRUN_BACKEND_URL", Value: c.BackendURL},
|
||||
{Name: "GENIUSRUN_FRONTEND_URL", Value: c.FrontendURL},
|
||||
{Name: "GENIUSRUN_OIDC_ISSUER_URL", Value: c.OIDCIssuerURL},
|
||||
{Name: "GENIUSRUN_OIDC_CLIENT_ID", Value: c.OIDCClientID},
|
||||
{Name: "GENIUSRUN_OIDC_CLIENT_SECRET", Value: mask(c.OIDCClientSecret != "")},
|
||||
{Name: "GENIUSRUN_OIDC_REQUIRED_ROLE", Value: c.OIDCRequiredRole},
|
||||
{Name: "GENIUSRUN_SESSION_SECRET", Value: mask(len(c.SessionSecret) > 0)},
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func setRequiredEnv(t *testing.T) {
|
||||
@@ -30,9 +29,6 @@ func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) {
|
||||
if cfg.OIDCRequiredRole != "geniusrun-user" {
|
||||
t.Errorf("OIDCRequiredRole = %q, want default", cfg.OIDCRequiredRole)
|
||||
}
|
||||
if cfg.SessionDuration != 720*time.Hour {
|
||||
t.Errorf("SessionDuration = %v, want default 720h", cfg.SessionDuration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
|
||||
@@ -80,7 +76,7 @@ func TestLoad_SessionSecretTooShort(t *testing.T) {
|
||||
|
||||
func TestLoad_GarminTokenStoreRootDefaultsIndependentlyOfDBPath(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GARMIN_TOKENSTORE", "")
|
||||
t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "")
|
||||
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
||||
|
||||
cfg, err := Load()
|
||||
@@ -94,7 +90,7 @@ func TestLoad_GarminTokenStoreRootDefaultsIndependentlyOfDBPath(t *testing.T) {
|
||||
|
||||
func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GARMIN_TOKENSTORE", "/custom/tokenstores")
|
||||
t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "/custom/tokenstores")
|
||||
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
||||
|
||||
cfg, err := Load()
|
||||
@@ -134,7 +130,7 @@ func TestLoad_LogLevelExplicitOverridesDefault(t *testing.T) {
|
||||
|
||||
func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GARMIN_WRAPPER_PYTHON", "")
|
||||
t.Setenv("GENIUSRUN_PYTHON_PATH", "")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
@@ -147,7 +143,7 @@ func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) {
|
||||
|
||||
func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GARMIN_WRAPPER_PYTHON", "/opt/venv/bin/python3")
|
||||
t.Setenv("GENIUSRUN_PYTHON_PATH", "/opt/venv/bin/python3")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
@@ -184,10 +180,9 @@ func TestLoad_FrontendURLExplicitOverridesDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_CustomRoleAndDuration(t *testing.T) {
|
||||
func TestLoad_CustomRole(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
|
||||
t.Setenv("GENIUSRUN_SESSION_DURATION", "24h")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
@@ -196,7 +191,4 @@ func TestLoad_CustomRoleAndDuration(t *testing.T) {
|
||||
if cfg.OIDCRequiredRole != "admin" {
|
||||
t.Errorf("OIDCRequiredRole = %q", cfg.OIDCRequiredRole)
|
||||
}
|
||||
if cfg.SessionDuration != 24*time.Hour {
|
||||
t.Errorf("SessionDuration = %v", cfg.SessionDuration)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user