Files
geniusrun/docs/superpowers/plans/2026-08-03-configuration.md

1018 lines
36 KiB
Markdown
Raw Permalink Normal View History

# 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<ConfigResponse>`, `api.updateConfig(values: Record<string,string>)`, `<Config />` 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<ConfigResponse>("/api/config"),
updateConfig: (values: Record<string, string>) =>
request<ConfigResponse>("/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<ConfigResponse | null>(null);
const [edits, setEdits] = useState<Record<string, string>>({});
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 (
<div className="page">
{loadFailed ? <p className="empty-state">Couldn't load configuration.</p> : <p>Loading...</p>}
</div>
);
}
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 (
<div className="page">
<fieldset className="kind-editor">
<legend>Application configuration</legend>
<p className="field-hint">
Stored in the database, shared by all users. Changes take effect after the backend restarts.
</p>
{config.application.map((entry) => (
<label key={entry.key}>
{entry.key}
<input
value={edits[entry.key] ?? entry.value}
onChange={(e) => setEdits({ ...edits, [entry.key]: e.target.value })}
/>
<span className="field-hint">
{entry.description} (default: {entry.default})
</span>
</label>
))}
<button type="button" disabled={saving || dirty.length === 0} onClick={save}>
Save
</button>
</fieldset>
<fieldset className="kind-editor">
<legend>Environment configuration</legend>
<p className="field-hint">Read-only — set through environment variables on the backend process.</p>
<dl className="env-config">
{config.environment.map((entry) => (
<div key={entry.name}>
<dt>{entry.name}</dt>
<dd>{entry.value === "" ? "(unset)" : entry.value}</dd>
</div>
))}
</dl>
</fieldset>
</div>
);
}
```
- [ ] **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<TabKey>("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<string | null>(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
<div className="header-actions">
<button
type="button"
className={showProfile ? "icon-button active" : "icon-button"}
title={profileName ?? "Profile"}
aria-label={profileName ?? "Profile"}
onClick={() => {
leaveConfig();
setShowProfile(true);
}}
>
👤
</button>
<button
type="button"
className={showConfig ? "icon-button active" : "icon-button"}
title="Settings"
aria-label="Settings"
onClick={openConfig}
>
⚙️
</button>
<form method="post" action={`${BASE_URL}/api/session/logout`} title={session.email}>
<button type="submit" className="icon-button" aria-label="Log out" title="Log out">
</button>
</form>
</div>
```
Main render: `<main>{showConfig ? <Config /> : showProfile ? <Profile onSaved={(p) => setProfileName(p.Name)} /> : <Active />}</main>`.
- [ ] **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.