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:
2026-08-04 00:11:56 +02:00
parent f6712497ba
commit e6cb21c65a
6 changed files with 210 additions and 43 deletions

View File

@@ -35,6 +35,15 @@ func main() {
}
defer db.Close()
overrides, err := db.ConfigValues(context.Background())
if err != nil {
log.Fatalf("app config: %v", err)
}
appCfg, err := config.LoadApp(overrides)
if err != nil {
log.Fatalf("app config: %v", err)
}
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
IssuerURL: cfg.OIDCIssuerURL,
ClientID: cfg.OIDCClientID,
@@ -49,11 +58,9 @@ func main() {
server := api.NewServer(db, garmin.NewClient, garmin.Config{
PythonPath: cfg.GarminPythonPath,
TokenStorePath: cfg.GarminTokenStoreRoot,
}, appsync.Config{
MinConfidence: cfg.MinConfidence,
}, authVerifier, api.SessionConfig{
}, appsync.Config{}, authVerifier, api.SessionConfig{
Secret: cfg.SessionSecret,
Duration: cfg.SessionDuration,
Duration: appCfg.SessionDuration,
Secure: cfg.SessionSecure,
BackendURL: cfg.BackendURL,
FrontendURL: cfg.FrontendURL,

View 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
}

View 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)
}
}
}

View File

@@ -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
}

View File

@@ -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)
}
}