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

36 KiB

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_ADDRGENIUSRUN_BACKEND_ADDR, GARMIN_WRAPPER_PYTHONGENIUSRUN_PYTHON_PATH, GARMIN_TOKENSTOREGENIUSRUN_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

-- 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:

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
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
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:

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
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):

	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):

// 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:

	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.SessionDurationDuration: 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_PYTHONGENIUSRUN_PYTHON_PATH (Commands + Garmin integration sections), GARMIN_TOKENSTOREGENIUSRUN_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
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:

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):

	// 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", ...):

				r.Get("/config", s.handleGetConfig)
				r.Put("/config", s.handlePutConfig)

../../../backend/internal/api/config.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:

	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
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:

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:
  // 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:
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:
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

              onClick={() => {
                leaveConfig();
                setShowProfile(false);
                setTab(t.key);
              }}

Header actions block becomes:

        <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:
/* 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
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 timego 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.