# Configuration (application vs environment) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Instance-global application configuration (DB-backed `config` table, cold reload) alongside renamed environment configuration, exposed via `GET`/`PUT /api/config` and a `/config` page reached from a new icon-only header button. **Architecture:** Defaults live in a registry in `internal/config`; the DB stores overrides only. `config.Load()` (env, fail-fast) is unchanged in mechanism but renames vars; new pure `config.LoadApp(overrides)` merges DB overrides over defaults into a typed struct wired once at startup (cold — restart to apply). The API serves a combined view (editable app config + masked read-only env snapshot built in `main.go`). The SPA gets a pathname-driven `/config` view and an icon-only header (👤 profile / ⚙️ settings / ✕ logout). **Tech Stack:** Go (chi, SQLite), React + TypeScript (no router library — `history.pushState` + `popstate`). **Spec:** `docs/superpowers/specs/2026-08-03-configuration-design.md` ## Global Constraints - Env var renames (breaking, no fallbacks, no shims): `GENIUSRUN_ADDR`→`GENIUSRUN_BACKEND_ADDR`, `GARMIN_WRAPPER_PYTHON`→`GENIUSRUN_PYTHON_PATH`, `GARMIN_TOKENSTORE`→`GENIUSRUN_TOKENSTORE_PATH`; `GENIUSRUN_MIN_CONFIDENCE` and `GENIUSRUN_SESSION_DURATION` removed; `GENIUSRUN_SESSION_SECRET` unchanged. - The one app-config key at launch: `session.duration`, integer hours, default `"720"`, must be > 0. - Secret masking strings, verbatim: `•••• (set)` and `(unset)`. - `config` table is deliberately NOT user-scoped (documented exception). - No `ALTER TABLE`/migration shims — edit `schema.sql`, delete the local DB file, regenerate `docs/DATABASE.md` via `go run ./cmd/dumpschema`. - Before every commit: `go build ./... && go vet ./...` clean, `gofmt -l .` silent (backend); `npm run build` + `npm run lint` clean (frontend). - Backend commands run from `backend/`; frontend from `frontend/`. - Known pre-existing failure to ignore (do not chase, do not fix): `TestProfile_DefaultsThenUpdate` fails while the local `schema.sql` backfill-default change (1095→90) is uncommitted. --- ### Task 1: `config` table + store accessors **Files:** - Modify: `backend/internal/store/schema.sql` (append after the `sync_runs` table) - Create: `backend/internal/store/config.go` - Test: `backend/internal/store/config_test.go` **Interfaces:** - Produces: `(*store.DB).ConfigValues(ctx) (map[string]string, error)` and `(*store.DB).SetConfigValue(ctx, key, value string) error` — consumed by Tasks 2 and 3. - [ ] **Step 1: Append the table to `schema.sql`** ```sql -- Application configuration: instance-global key/value overrides, shared -- by every user -- deliberately the one table with no user_id, because -- application configuration is common to all users by definition. Stores -- overrides only; defaults live in internal/config's app-key registry. -- Cold: read once at startup, a change applies on the next backend restart. CREATE TABLE config ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); ``` - [ ] **Step 2: Write the failing test** `backend/internal/store/config_test.go`: ```go package store import ( "context" "testing" ) func TestConfigValues_EmptyThenUpsertOverwrites(t *testing.T) { db := openTestDB(t) ctx := context.Background() values, err := db.ConfigValues(ctx) if err != nil { t.Fatalf("ConfigValues (empty): %v", err) } if len(values) != 0 { t.Fatalf("expected no overrides in a fresh DB, got %v", values) } if err := db.SetConfigValue(ctx, "session.duration", "168"); err != nil { t.Fatalf("SetConfigValue: %v", err) } // Same key again: upsert must overwrite, not error or duplicate. if err := db.SetConfigValue(ctx, "session.duration", "24"); err != nil { t.Fatalf("SetConfigValue (overwrite): %v", err) } values, err = db.ConfigValues(ctx) if err != nil { t.Fatalf("ConfigValues: %v", err) } if len(values) != 1 || values["session.duration"] != "24" { t.Fatalf("expected {session.duration: 24}, got %v", values) } } ``` (`openTestDB` already exists in `store_test.go`.) - [ ] **Step 3: Run test to verify it fails** Run: `go test ./internal/store/... -run TestConfigValues -v` Expected: FAIL — `db.ConfigValues undefined`. - [ ] **Step 4: Implement `backend/internal/store/config.go`** ```go package store import ( "context" "fmt" ) // ConfigValues returns every application-configuration override row as // key -> value. An absent key means its default (from internal/config's // app-key registry) is in effect -- the table stores overrides only. func (db *DB) ConfigValues(ctx context.Context) (map[string]string, error) { rows, err := db.QueryContext(ctx, `SELECT key, value FROM config`) if err != nil { return nil, fmt.Errorf("config values: %w", err) } defer rows.Close() values := map[string]string{} for rows.Next() { var k, v string if err := rows.Scan(&k, &v); err != nil { return nil, fmt.Errorf("scan config row: %w", err) } values[k] = v } return values, rows.Err() } // SetConfigValue upserts one override row. Key validation is the caller's // job (config.ValidateAppValue) -- the store stays a dumb K/V layer. func (db *DB) SetConfigValue(ctx context.Context, key, value string) error { if _, err := db.ExecContext(ctx, ` INSERT INTO config (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, key, value); err != nil { return fmt.Errorf("set config %q: %w", key, err) } return nil } ``` - [ ] **Step 5: Run test to verify it passes** Run: `go test ./internal/store/... -run TestConfigValues -v` Expected: PASS. - [ ] **Step 6: Regenerate the schema doc** Run: `go run ./cmd/dumpschema` Expected: `docs/DATABASE.md` gains the `config` table. - [ ] **Step 7: Commit** ```bash git add internal/store/schema.sql internal/store/envconfig.go internal/store/envconfig_test.go ../docs/DATABASE.md git commit -m "feat(store): config table for instance-global app-config overrides" ``` --- ### Task 2: app-config registry + env renames, wired into `main.go` **Files:** - Create: `backend/internal/config/appconfig.go` - Modify: `../../../backend/internal/config/envconfig.go`, `backend/cmd/geniusrund/main.go` - Modify: `backend/.env` (local, gitignored — edit but never commit), `CLAUDE.md` - Test: create `backend/internal/config/appconfig_test.go`, modify `../../../backend/internal/config/envconfig_test.go` **Interfaces:** - Consumes: `store.ConfigValues` (Task 1). - Produces (consumed by Task 3): - `config.AppKey{Key, Default, Description string; Validate func(string) error}` - `config.AppRegistry() []AppKey` - `config.ValidateAppValue(key, value string) error` - `config.KeySessionDuration = "session.duration"` - `config.AppConfig{SessionDuration time.Duration}` via `config.LoadApp(overrides map[string]string) (AppConfig, error)` - `config.EnvEntry{Name, Value string}` via `(config.Config).DisplayEnv() []EnvEntry` - Removes: `Config.MinConfidence`, `Config.SessionDuration`, `getEnvFloat`, `getEnvDuration`. - [ ] **Step 1: Write the failing tests** `backend/internal/config/appconfig_test.go`: ```go 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) } } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `go test ./internal/config/... -run 'TestLoadApp|TestValidateAppValue|TestDisplayEnv' -v` Expected: FAIL — `LoadApp`/`ValidateAppValue`/`DisplayEnv` undefined. - [ ] **Step 3: Implement `backend/internal/config/appconfig.go`** ```go 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 } ``` - [ ] **Step 4: Rework `../../../backend/internal/config/envconfig.go`** In `Config`: delete the `MinConfidence` and `SessionDuration` fields. In `Load()`, the first block becomes (note: no `MinConfidence`, no `SessionDuration` lines anywhere in `Load` anymore): ```go cfg := Config{ Addr: getEnvDefault("GENIUSRUN_BACKEND_ADDR", ":8080"), DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"), 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"), } ``` Delete `getEnvFloat` and `getEnvDuration` (now unused). Update the `GARMIN_TOKENSTORE` mention in the `TokenStoreRoot` doc comment to `GENIUSRUN_TOKENSTORE_PATH`. Append to the same file (kept next to `Config` so a new env var can't be added without the reviewer seeing this list): ```go // 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 } // 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)}, } } ``` - [ ] **Step 5: Update `envconfig_test.go` for the renames/removals** (exact edits) - `TestLoad_DerivesOIDCSettingsFromBackendURL`: delete the `cfg.SessionDuration != 720*time.Hour` if-block. - `TestLoad_GarminTokenStoreRootDefaultsIndependentlyOfDBPath` and `TestLoad_GarminTokenStoreRootExplicitOverridesDefault`: `t.Setenv("GARMIN_TOKENSTORE", ...)` → `t.Setenv("GENIUSRUN_TOKENSTORE_PATH", ...)`. - `TestLoad_GarminPythonPathDefaultsToPython3` and `TestLoad_GarminPythonPathExplicitOverridesDefault`: `t.Setenv("GARMIN_WRAPPER_PYTHON", ...)` → `t.Setenv("GENIUSRUN_PYTHON_PATH", ...)`. - `TestLoad_CustomRoleAndDuration`: rename to `TestLoad_CustomRole`; delete the `t.Setenv("GENIUSRUN_SESSION_DURATION", "24h")` line and the `cfg.SessionDuration != 24*time.Hour` if-block. - The `time` import in `envconfig_test.go` becomes unused after these edits — remove it (the new `envconfig_test.go` has its own). - [ ] **Step 6: Rewire `backend/cmd/geniusrund/main.go`** After the `store.Open` block, insert: ```go 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) } ``` In the `api.NewServer(...)` call: `appsync.Config{MinConfidence: cfg.MinConfidence}` → `appsync.Config{}` (a zero `MinConfidence` already defaults to `classify.DefaultMinConfidence` in `internal/sync/service.go` — that IS the "hard code it"; leave `sync.Config`'s field alone, tests use it), and `Duration: cfg.SessionDuration` → `Duration: appCfg.SessionDuration`. - [ ] **Step 7: Verify build + suites** Run: `go build ./... && go vet ./... && gofmt -l . && go test ./...` Expected: PASS (modulo the known pre-existing failure in Global Constraints). - [ ] **Step 8: Update local `backend/.env`** (do not commit) Rename in place: the `GARMIN_WRAPPER_PYTHON="..."` line → `GENIUSRUN_PYTHON_PATH="..."` (same value); commented `# GENIUSRUN_ADDR` → `# GENIUSRUN_BACKEND_ADDR`, `# GARMIN_TOKENSTORE=".garmin"` → `# GENIUSRUN_TOKENSTORE_PATH=".garmin"`; delete the `# GENIUSRUN_MIN_CONFIDENCE` and `# GENIUSRUN_SESSION_DURATION` lines. - [ ] **Step 9: Update `CLAUDE.md`** Replace every mention: `GARMIN_WRAPPER_PYTHON`→`GENIUSRUN_PYTHON_PATH` (Commands + Garmin integration sections), `GARMIN_TOKENSTORE`→`GENIUSRUN_TOKENSTORE_PATH` (Garmin integration section). In the classification section, `GENIUSRUN_MIN_CONFIDENCE` mention → "hard-coded `classify.DefaultMinConfidence` (0.6)". In Authentication: remove `GENIUSRUN_SESSION_DURATION (default 720h)` from optional env vars; add a sentence that session duration is application configuration (`session.duration`, hours, default 720) editable on the `/config` page, applied on restart. - [ ] **Step 10: Commit** ```bash git add internal/config/ cmd/geniusrund/main.go ../CLAUDE.md git commit -m "feat(config): app-config registry, env-var renames, wire session.duration from DB" ``` --- ### Task 3: `GET`/`PUT /api/config` + env snapshot into the server **Files:** - Create: `../../../backend/internal/api/config.go` - Modify: `backend/internal/api/server.go` (Server struct + routes), `backend/cmd/geniusrund/main.go` (EnvVars wiring) - Test: `../../../backend/internal/api/config_test.go` **Interfaces:** - Consumes: `config.AppRegistry`, `config.ValidateAppValue` (Task 2); `store.ConfigValues`, `store.SetConfigValue` (Task 1); `(config.Config).DisplayEnv()` (Task 2, in main.go). - Produces: `api.EnvVar{Name, Value string}` (json `name`/`value`), `Server.EnvVars []EnvVar` field; routes `GET /api/config`, `PUT /api/config` behind `requireProvisionedUser`. - [ ] **Step 1: Write the failing tests** `../../../backend/internal/api/config_test.go`: ```go package api import ( "encoding/json" "net/http" "testing" "geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin/mock" "geniusrun/backend/internal/store" appsync "geniusrun/backend/internal/sync" authmock "geniusrun/backend/internal/auth/mock" ) type configTestResponse struct { Application []struct { Key string `json:"key"` Value string `json:"value"` Default string `json:"default"` Overridden bool `json:"overridden"` Description string `json:"description"` } `json:"application"` Environment []struct { Name string `json:"name"` Value string `json:"value"` } `json:"environment"` } func TestConfig_GetDefaultsAndEnvSnapshot(t *testing.T) { s, _, _ := newTestServer(t) s.EnvVars = []EnvVar{{Name: "GENIUSRUN_OIDC_CLIENT_SECRET", Value: "•••• (set)"}} rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) } var resp configTestResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if len(resp.Application) != 1 { t.Fatalf("expected 1 app-config entry, got %d", len(resp.Application)) } e := resp.Application[0] if e.Key != "session.duration" || e.Value != "720" || e.Default != "720" || e.Overridden || e.Description == "" { t.Fatalf("unexpected default entry: %+v", e) } if len(resp.Environment) != 1 || resp.Environment[0].Value != "•••• (set)" { t.Fatalf("env snapshot not passed through: %+v", resp.Environment) } } func TestConfig_PutPersistsValidatesAllOrNothing(t *testing.T) { s, _, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"session.duration": "168"}) if rec.Code != http.StatusOK { t.Fatalf("valid PUT status = %d, body = %s", rec.Code, rec.Body.String()) } var resp configTestResponse json.Unmarshal(rec.Body.Bytes(), &resp) if resp.Application[0].Value != "168" || !resp.Application[0].Overridden { t.Fatalf("override not reflected: %+v", resp.Application[0]) } if rec = doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"bogus.key": "1"}); rec.Code != http.StatusBadRequest { t.Fatalf("unknown key status = %d, want 400", rec.Code) } rec = doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"session.duration": "zero"}) if rec.Code != http.StatusBadRequest { t.Fatalf("invalid value status = %d, want 400", rec.Code) } rec = doJSON(t, router, http.MethodGet, "/api/config", nil) json.Unmarshal(rec.Body.Bytes(), &resp) if resp.Application[0].Value != "168" { t.Fatalf("rejected PUT still changed the value: %+v", resp.Application[0]) } } // Same gate as every data route: an unprovisioned session gets 403, per // the repo's adversarial-isolation testing convention. func TestConfig_RequiresProvisionedUser(t *testing.T) { db, err := store.Open(t.TempDir() + "/config_gate_test.db") if err != nil { t.Fatalf("store.Open: %v", err) } defer db.Close() m := &mock.Client{} s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) if rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil); rec.Code != http.StatusForbidden { t.Fatalf("GET status = %d, want 403", rec.Code) } if rec := doJSON(t, s.Router(), http.MethodPut, "/api/config", map[string]string{"session.duration": "1"}); rec.Code != http.StatusForbidden { t.Fatalf("PUT status = %d, want 403", rec.Code) } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `go test ./internal/api/... -run TestConfig_ -v` Expected: FAIL — `EnvVar` undefined / compile error. - [ ] **Step 3: Implement** Add to the `Server` struct in `server.go` (after `SyncConfig appsync.Config`): ```go // EnvVars is the read-only, display-safe environment-configuration // snapshot served by GET /api/config -- built once in main.go from // config.Config.DisplayEnv() (secrets already masked there); handlers // never call os.Getenv. EnvVars []EnvVar ``` Routes, inside the `requireProvisionedUser` group next to `r.Post("/reclassify", ...)`: ```go r.Get("/config", s.handleGetConfig) r.Put("/config", s.handlePutConfig) ``` `../../../backend/internal/api/config.go`: ```go package api import ( "encoding/json" "net/http" "geniusrun/backend/internal/config" ) // EnvVar is one read-only environment-configuration entry, already // display-safe (masking happens in config.Config.DisplayEnv). type EnvVar struct { Name string `json:"name"` Value string `json:"value"` } type appConfigEntry struct { Key string `json:"key"` Value string `json:"value"` Default string `json:"default"` Overridden bool `json:"overridden"` Description string `json:"description"` } // configView assembles the GET/PUT response: registry defaults overlaid // with DB overrides, plus the env snapshot. func (s *Server) configView(r *http.Request) (map[string]any, error) { overrides, err := s.DB.ConfigValues(r.Context()) if err != nil { return nil, err } registry := config.AppRegistry() app := make([]appConfigEntry, 0, len(registry)) for _, k := range registry { value, overridden := overrides[k.Key] if !overridden { value = k.Default } app = append(app, appConfigEntry{ Key: k.Key, Value: value, Default: k.Default, Overridden: overridden, Description: k.Description, }) } envVars := s.EnvVars if envVars == nil { envVars = []EnvVar{} } return map[string]any{"application": app, "environment": envVars}, nil } func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { resp, err := s.configView(r) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, resp) } // handlePutConfig updates application configuration. Cold: saved values // take effect on the next backend restart; the response is just the // refreshed view, same shape as GET. func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) { var body map[string]string if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } // Validate every pair before writing anything -- all-or-nothing. for key, value := range body { if err := config.ValidateAppValue(key, value); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } } for key, value := range body { if err := s.DB.SetConfigValue(r.Context(), key, value); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } } resp, err := s.configView(r) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, resp) } ``` Then wire the snapshot in `main.go`, right after the `api.NewServer(...)` call: ```go for _, e := range cfg.DisplayEnv() { server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value}) } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `go test ./internal/api/... -run TestConfig_ -v` then `go build ./... && go vet ./... && gofmt -l . && go test ./...` Expected: PASS (modulo the known pre-existing failure). - [ ] **Step 5: Commit** ```bash git add internal/api/envconfig.go internal/api/envconfig_test.go internal/api/server.go cmd/geniusrund/main.go git commit -m "feat(api): GET/PUT /api/config serving app + env configuration" ``` --- ### Task 4: frontend — `/config` page and icon-only header **Files:** - Modify: `frontend/src/types/api.ts`, `frontend/src/api/client.ts` - Create: `frontend/src/pages/Config.tsx` - Modify: `frontend/src/App.tsx`, `frontend/src/App.css` **Interfaces:** - Consumes: `GET/PUT /api/config` (Task 3 shapes, exactly). - Produces: `api.getConfig(): Promise`, `api.updateConfig(values: Record)`, `` page, header buttons 👤/⚙️/✕. - [ ] **Step 1: Types** — append to `frontend/src/types/api.ts`: ```ts export interface AppConfigEntry { key: string; value: string; default: string; overridden: boolean; description: string; } export interface EnvVarEntry { name: string; value: string; } export interface ConfigResponse { application: AppConfigEntry[]; environment: EnvVarEntry[]; } ``` - [ ] **Step 2: Client** — in `frontend/src/api/client.ts`, import `ConfigResponse` and add before the `// Progression` block: ```ts // Application/environment configuration (the /config page). Cold: saved // values take effect after the backend restarts. getConfig: () => request("/api/config"), updateConfig: (values: Record) => request("/api/config", { method: "PUT", body: JSON.stringify(values) }), ``` - [ ] **Step 3: Page** — create `frontend/src/pages/Config.tsx`: ```tsx import { useEffect, useState } from "react"; import { api } from "../api/client"; import { showError, showSuccess } from "../banner"; import type { ConfigResponse } from "../types/api"; // Reached only via the header's gear button (or typing /config) -- not a // nav tab. Application configuration is instance-global and cold: the // backend reads it once at startup, so saves apply on the next restart. export function Config() { const [config, setConfig] = useState(null); const [edits, setEdits] = useState>({}); const [saving, setSaving] = useState(false); const [loadFailed, setLoadFailed] = useState(false); useEffect(() => { api .getConfig() .then(setConfig) .catch((e) => { setLoadFailed(true); showError(e instanceof Error ? e.message : String(e)); }); }, []); if (!config) { return (
{loadFailed ?

Couldn't load configuration.

:

Loading...

}
); } const dirty = config.application.filter( (entry) => edits[entry.key] !== undefined && edits[entry.key] !== entry.value, ); async function save() { setSaving(true); try { const updated = await api.updateConfig( Object.fromEntries(dirty.map((entry) => [entry.key, edits[entry.key]])), ); setConfig(updated); setEdits({}); showSuccess("Saved — changes take effect after the backend restarts."); } catch (e) { showError(e instanceof Error ? e.message : String(e)); } finally { setSaving(false); } } return (
Application configuration

Stored in the database, shared by all users. Changes take effect after the backend restarts.

{config.application.map((entry) => ( ))}
Environment configuration

Read-only — set through environment variables on the backend process.

{config.environment.map((entry) => (
{entry.name}
{entry.value === "" ? "(unset)" : entry.value}
))}
); } ``` - [ ] **Step 4: `App.tsx`** — pathname-driven config view + icon header. Add `import { Config } from "./pages/Config";`. Replace the state/handlers at the top of `App`: ```tsx function App({ session }: { session: SessionInfo }) { const [tab, setTab] = useState("activities"); // Profile and Config aren't tabs: Profile is account settings (reached // via the avatar button), Config is instance settings (gear button, // pathname-addressable as /config so it can be deep-linked). const [showProfile, setShowProfile] = useState(false); const [showConfig, setShowConfig] = useState(window.location.pathname === "/config"); const [profileName, setProfileName] = useState(null); useEffect(() => { api.getProfile().then((p) => setProfileName(p.Name)).catch(() => {}); }, []); useEffect(() => { const onPop = () => setShowConfig(window.location.pathname === "/config"); window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); function openConfig() { if (window.location.pathname !== "/config") history.pushState({}, "", "/config"); setShowProfile(false); setShowConfig(true); } // Leaving the config view restores the root URL so /config always means // "config is showing" and browser back/forward keep working. function leaveConfig() { if (window.location.pathname === "/config") history.pushState({}, "", "/"); setShowConfig(false); } ``` Tab buttons: `className={!showProfile && !showConfig && t.key === tab ? "tab active" : "tab"}` and ```tsx onClick={() => { leaveConfig(); setShowProfile(false); setTab(t.key); }} ``` Header actions block becomes: ```tsx
``` Main render: `
{showConfig ? : showProfile ? setProfileName(p.Name)} /> : }
`. - [ ] **Step 5: `App.css`** — delete the `.profile-name` blocks (base, `::before`, `:hover`, `.active` — the ⚙️ `::before` glyph moves conceptually to the settings button) and both `.logout-link` blocks; add in their place: ```css /* Icon-only header buttons (profile / settings / log out): circular chips, deliberately unlike the rectangular .tab buttons -- these are account/ instance actions, not application views. No visible text: the glyph is the label (full text lives in title/aria-label). */ .icon-button { display: inline-flex; align-items: center; justify-content: center; width: 2.2rem; height: 2.2rem; background: #1a1d24; border: 1px solid #2a2d35; color: #9aa0ab; border-radius: 999px; cursor: pointer; font-size: 0.95rem; } .icon-button:hover:not(.active) { border-color: #3b82f6; color: #e6e6e6; } .icon-button.active { background: #3b82f6; border-color: #3b82f6; color: white; } .env-config div { display: flex; gap: 1rem; padding: 0.25rem 0; } .env-config dt { min-width: 18rem; color: #9aa0ab; font-family: monospace; } .env-config dd { margin: 0; font-family: monospace; overflow-wrap: anywhere; } ``` - [ ] **Step 6: Build + lint** Run: `npm run build && npm run lint` Expected: clean (pre-existing PaceField fast-refresh warnings are fine). - [ ] **Step 7: Commit** ```bash git add src/types/api.ts src/api/client.ts src/pages/Config.tsx src/App.tsx src/App.css git commit -m "feat(frontend): /config page + icon-only header (profile/settings/logout)" ``` --- ### Task 5: end-to-end verification (live, no mocks) **Files:** none (verification only; local `backend/geniusrun.db*` deleted). - [ ] **Step 1: Recreate the local DB** — the schema gained a table and `Open()` skips schema application on existing DBs. ⚠️ Destructive to local synced data (re-syncable from Garmin); this is the CLAUDE.md-sanctioned remedy, but **confirm with the user before deleting**. Then: stop any running backend and `rm backend/geniusrun.db backend/geniusrun.db-shm backend/geniusrun.db-wal`. - [ ] **Step 2: Start both servers** — `./backend/start.sh` (verify it boots with the renamed `.env` vars; a failure here means Task 2 Step 8 was missed) and `./frontend/start.sh`. - [ ] **Step 3: Live click-through** (real login, no route mocking, per CLAUDE.md): - Header shows 👤 ⚙️ ✕ icon-only buttons; hover shows titles; profile button still opens the Profile page. - ⚙️ navigates to `/config` (URL bar shows `/config`); browser Back returns to the app; a direct load of `http://localhost:5173/config` lands on the page after login. - Page shows `session.duration` = 720 (default), env list with `GENIUSRUN_OIDC_CLIENT_SECRET` and `GENIUSRUN_SESSION_SECRET` shown as `•••• (set)`. - Set `session.duration` to `168`, Save → success banner mentioning restart; reload page → value 168. - Enter `0` and Save → error banner (400), value unchanged after reload. - Restart the backend and log in again — boot must succeed reading the override, and login must still work. - [ ] **Step 4: Full suites one last time** — `go build ./... && go vet ./... && gofmt -l . && go test ./...` (backend), `npm run build && npm run lint` (frontend). Report the known pre-existing failure separately if still present.