133 lines
5.8 KiB
Markdown
133 lines
5.8 KiB
Markdown
|
|
# Configuration: application vs environment — design
|
||
|
|
|
||
|
|
Date: 2026-08-03
|
||
|
|
Status: approved
|
||
|
|
|
||
|
|
## Problem
|
||
|
|
|
||
|
|
All runtime tuning today is either an env var (`internal/config`, process-level)
|
||
|
|
or a per-user `profile` column. There is no home for instance-global,
|
||
|
|
behavior-affecting settings shared by all users, and some env vars are
|
||
|
|
misnamed or shouldn't be env vars at all. From `docs/IDEAS.md` ("configuration").
|
||
|
|
|
||
|
|
## Concepts
|
||
|
|
|
||
|
|
- **Application configuration**: key/value pairs, stored in the database,
|
||
|
|
common to all users, cold (read once at startup; a change takes effect on
|
||
|
|
the next backend restart — no hot reload). Unrelated to the execution
|
||
|
|
environment.
|
||
|
|
- **Environment configuration**: env vars, tied to the execution environment
|
||
|
|
(paths, URLs, credentials for infrastructure). Unchanged mechanism.
|
||
|
|
|
||
|
|
## Data model
|
||
|
|
|
||
|
|
New table in `schema.sql`:
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE config (
|
||
|
|
key TEXT PRIMARY KEY,
|
||
|
|
value TEXT NOT NULL,
|
||
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
- Deliberately **not** user-scoped — the single exception to the per-user
|
||
|
|
convention, because application configuration is instance-global by
|
||
|
|
definition. A schema comment records this.
|
||
|
|
- The table stores **overrides only**; defaults live in code. Absent key =
|
||
|
|
default in effect.
|
||
|
|
- Regenerate `docs/DATABASE.md` (`go run ./cmd/dumpschema`) after the schema
|
||
|
|
change. Breaking schema change: delete the local DB and let `Open()`
|
||
|
|
recreate it (normal pre-production remedy).
|
||
|
|
|
||
|
|
## Application-config registry
|
||
|
|
|
||
|
|
`internal/config` gains a registry of known keys — name, default, validator,
|
||
|
|
description. Launch set (one key):
|
||
|
|
|
||
|
|
| key | type | default | validation |
|
||
|
|
|--------------------|---------------|---------|----------------------|
|
||
|
|
| `session.duration` | integer hours | `720` | integer, > 0 |
|
||
|
|
|
||
|
|
- `config.Load()` (env) stays as-is: fail-fast, runs before the DB opens.
|
||
|
|
- New `config.LoadApp(getter)` reads overrides through a small store-backed
|
||
|
|
getter, merges defaults, validates, and returns a typed `AppConfig` struct
|
||
|
|
(`SessionDuration time.Duration`). `main.go` calls it after `store.Open`
|
||
|
|
and wires the result into `api.SessionConfig.Duration`.
|
||
|
|
- Writes validate against the registry: unknown keys and invalid values are
|
||
|
|
rejected, so the `config` table cannot accumulate junk.
|
||
|
|
|
||
|
|
## Environment-variable changes (all breaking; pre-production, no shims)
|
||
|
|
|
||
|
|
| before | after |
|
||
|
|
|----------------------------|----------------------------|
|
||
|
|
| `GENIUSRUN_ADDR` | `GENIUSRUN_BACKEND_ADDR` |
|
||
|
|
| `GARMIN_WRAPPER_PYTHON` | `GENIUSRUN_PYTHON_PATH` |
|
||
|
|
| `GARMIN_TOKENSTORE` | `GENIUSRUN_TOKENSTORE_PATH`|
|
||
|
|
| `GENIUSRUN_MIN_CONFIDENCE` | removed — hard-coded to `classify.DefaultMinConfidence` (0.6) |
|
||
|
|
| `GENIUSRUN_SESSION_DURATION` | removed — becomes app-config key `session.duration` |
|
||
|
|
| `GENIUSRUN_SESSION_SECRET` | unchanged (stays env; still required, >= 32 chars) |
|
||
|
|
|
||
|
|
Also update: `backend/.env` (local), `CLAUDE.md`, config tests, and any doc
|
||
|
|
references.
|
||
|
|
|
||
|
|
## API
|
||
|
|
|
||
|
|
Both routes sit behind the usual session + `requireProvisionedUser` gates.
|
||
|
|
Any provisioned user may read and write (single-operator app in practice).
|
||
|
|
|
||
|
|
- `GET /api/config` →
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"application": [
|
||
|
|
{"key": "session.duration", "value": "720", "default": "720",
|
||
|
|
"overridden": false, "description": "Session cookie lifetime in hours"}
|
||
|
|
],
|
||
|
|
"environment": [
|
||
|
|
{"name": "GENIUSRUN_BACKEND_ADDR", "value": ":8080"},
|
||
|
|
{"name": "GENIUSRUN_OIDC_CLIENT_SECRET", "value": "•••• (set)"}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
```
|
||
|
|
- Secrets (`GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET`) are
|
||
|
|
masked as `•••• (set)` / `(unset)`; values never leave the process.
|
||
|
|
- `main.go` builds the display-safe environment snapshot once (from
|
||
|
|
`config.Config`, masking applied) and passes it to `api.NewServer` —
|
||
|
|
handlers never call `os.Getenv`.
|
||
|
|
- `PUT /api/config` with `{"session.duration": "168"}`:
|
||
|
|
validates every pair against the registry (reject unknown key / invalid
|
||
|
|
value with 400 naming the offender; all-or-nothing), upserts, returns the
|
||
|
|
same shape as GET. Cold semantics: response carries no magic — the UI
|
||
|
|
states that changes apply after a backend restart.
|
||
|
|
|
||
|
|
## Frontend
|
||
|
|
|
||
|
|
- **Config page** at browser path `/config` — `App.tsx` derives the view from
|
||
|
|
`window.location.pathname` (no router library; `history.pushState` on
|
||
|
|
navigation so the URL is shareable/deep-linkable, and Vite's SPA fallback
|
||
|
|
serves it in dev; a production reverse proxy needs SPA fallback for the
|
||
|
|
path).
|
||
|
|
- Page content: an "Application configuration" card (editable fields per
|
||
|
|
registry entry, Save button, a visible note "changes take effect after the
|
||
|
|
backend restarts"), and a read-only "Environment configuration" card
|
||
|
|
listing name/value pairs, secrets masked by the backend.
|
||
|
|
- **Header changes** (`App.tsx`), left to right after the tabs:
|
||
|
|
1. Profile button: icon-only, user-profile glyph (👤). The profile name
|
||
|
|
moves into `title`/`aria-label` — no visible text.
|
||
|
|
2. New settings button: icon-only gear (⚙), navigates to `/config`.
|
||
|
|
3. Log out: icon-only cross (✕), still the POST `<form>` (OIDC logout
|
||
|
|
needs a real navigation). `title`/`aria-label` "Log out".
|
||
|
|
- No visible text labels on any of the three; same emoji/glyph style as
|
||
|
|
the existing tabs, no icon library added.
|
||
|
|
|
||
|
|
## Testing
|
||
|
|
|
||
|
|
- `internal/store`: config upsert/read round-trip; overrides-only semantics.
|
||
|
|
- `internal/config`: table-driven registry tests — default when absent,
|
||
|
|
override applied, invalid value rejected, unknown key rejected.
|
||
|
|
- `internal/api`: httptest — GET shape + secret masking; PUT happy path,
|
||
|
|
unknown key 400, invalid value 400; 403 until provisioned (inherited gate,
|
||
|
|
asserted once).
|
||
|
|
- Frontend: live click-through — header icons, navigate to `/config`, edit
|
||
|
|
`session.duration`, save, verify persisted via GET and after restart.
|