Files
geniusrun/docs/superpowers/plans/2026-08-03-configuration.md
Christophe Vila e2b2bf9611 refactor: merge internal/sync into internal/garmin, regroup api files and routes
Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes
garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes
internal/log; the test mock moves into the garmin package as MockClient
(breaking the test-only import cycle the merge created); stale test
URLs and type names updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:04:18 +02:00

1018 lines
36 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.