Admin-gated Settings section: move model selection out of env, and enforce is_admin for the first time #79

Closed
opened 2026-07-21 22:08:03 +00:00 by steve · 0 comments
Owner

Requested: "I'd like things like the LLM model to be in a Settings section". Scope decision from the same conversation: instance-wide, admin-only.

What already exists (more than expected)

is_admin has been in the schema since migration 0001 and is fully plumbed — but never enforced anywhere:

  • internal/store/migrations/0001_init.sql:21is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0,1))
  • First registered user becomes admin race-free, inside the INSERT (internal/store/users.go:56-58): SELECT ..., (SELECT count(*) FROM users) = 0
  • domain.User.IsAdmin is serialized to the client (unlike PasswordHash, which is json:"-")
  • web/src/lib/auth.ts:12 already parses isAdmin: z.boolean(), so useMe().data.isAdmin works today

grep requireAdmin returns nothing. There is no admin middleware, no admin-gated route, no service check. Settings would be the first feature to use a flag that has been sitting there since day one. Note OIDC-provisioned users go through the same CreateUser, so on a pure-OIDC instance the first login also gets admin.

The actual hard part: agent config is resolved at boot, not held

This is what makes it more than a form.

agent.NewRunner (internal/agent/runtime.go:48-69) resolves the config into an opaque llm.Model and bakes the API key into a provider registry. Runner holds {svc, model} — no *config.Config. And the agent's routes are conditionally registered (internal/api/api.go:143-159): if the agent was off at boot, /api/v1/agent/chat does not exist, and no amount of settings-toggling can conjure a gin route into being.

So a runtime toggle needs three things that are each easy to miss:

  1. Register agent routes unconditionally, with a nil-guard in the handler. agentChat currently assumes non-nil (internal/api/agent.go:81, h.agent.Run(...)) and would panic.
  2. Swap handlers.agent under an atomic.Pointer. It's a plain field; hot-swapping it mid-request is a data race — and -race in CI will catch it only if a test exercises the swap.
  3. Drop staleTime: Infinity on useCapabilities (web/src/lib/agent.ts:18-24). The comment says "server config; it doesn't change under a running page" — this issue makes that false. Saving settings must invalidate ['capabilities'].

GET /capabilities (api.go:204) already reads h.agent != nil at request time, so it reflects a swap immediately once the above is done.

For contrast: LocalAuth and Registration are already read per-request from s.cfg (internal/service/auth.go:50,121,135,154,161), so those would work with nothing more than a mutable store behind the pointer. Everything else in Config is baked in at boot.

Secrets stay in the environment

OLLAMA_CLOUD_API_KEY does not move into the database. Model selection and enablement are settings; the credential is not. Putting it in SQLite puts it in every backup and in the blast radius of the undo history. The Settings UI should show whether a key is present and working, never its value, and never accept one.

This means an admin can change which model runs but not whose account pays for it, which is the right split for a self-hosted household instance.

Proposed shape

Migration 0010 introduces the first instance-level state. There's no precedent — every existing preference hangs off a garden or object row (gardens.unit_pref, grid_size_cm/snap_to_grid from 0005). A single-row settings table with a CHECK (id = 1) guard is the least surprising option:

CREATE TABLE instance_settings (
    id             INTEGER PRIMARY KEY CHECK (id = 1),
    agent_model    TEXT    NOT NULL DEFAULT '',
    agent_enabled  INTEGER NOT NULL DEFAULT 0 CHECK (agent_enabled IN (0,1)),
    version        INTEGER NOT NULL DEFAULT 1,
    updated_at     TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);

Empty agent_model means "fall back to the env var", so an instance that never opens Settings behaves exactly as it does now and PANSY_AGENT_MODEL keeps working. That matters for the Docker/Compose story in the README.

Precedence: DB setting (if set) > env var > DefaultAgentModel. Document it in the README env table, which currently describes env as authoritative.

Routes: GET /api/v1/settings (admin), PATCH /api/v1/settings (admin, version-guarded like every other mutable row). Plus a requireAdmin() middleware next to requireAuth() in internal/api/auth.go.

Model validation. majordomo passes model ids through verbatim and never checks them against a catalog — a bad string fails at the API on the next turn, not at save time. Settings should offer a "test" action that resolves the spec and does a trivial completion, so a typo is caught while the admin is looking at the form rather than the next time someone asks the assistant a question.

Frontend: /settings route is three lines mirroring plantsRoute (web/src/router.tsx), guard extends requireAuth with an isAdmin check, nav entry in AppShell.tsx:5-8 conditioned on user.isAdmin. GardenFormModal.tsx is the closest template for the form (local state per field, 409-rebase on conflict, TextField/Select/Alert primitives).

Follow-on, not in scope here

web/src/pages/PlantsPage.tsx:30-32 keeps unit preference in localStorage with the comment "There's no per-user unit preference server-side (only per-garden)". That's the obvious first per-user setting, but per-user settings are a different table and a different ACL story — worth its own issue once this lands.

Tests

Per CLAUDE.md: anything addressed by its own id needs an API-level test through the router. Specifically needed here — a non-admin gets 403 from both routes, the version guard 409s, an empty agent_model falls back to env, and the runner swap is exercised under -race.

Requested: "I'd like things like the LLM model to be in a Settings section". Scope decision from the same conversation: **instance-wide, admin-only**. ## What already exists (more than expected) `is_admin` has been in the schema since migration 0001 and is fully plumbed — but **never enforced anywhere**: - `internal/store/migrations/0001_init.sql:21` — `is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0,1))` - First registered user becomes admin race-free, inside the INSERT (`internal/store/users.go:56-58`): `SELECT ..., (SELECT count(*) FROM users) = 0` - `domain.User.IsAdmin` is serialized to the client (unlike `PasswordHash`, which is `json:"-"`) - `web/src/lib/auth.ts:12` already parses `isAdmin: z.boolean()`, so `useMe().data.isAdmin` works **today** `grep requireAdmin` returns nothing. There is no admin middleware, no admin-gated route, no service check. Settings would be the first feature to use a flag that has been sitting there since day one. Note OIDC-provisioned users go through the same `CreateUser`, so on a pure-OIDC instance the first login also gets admin. ## The actual hard part: agent config is resolved at boot, not held This is what makes it more than a form. `agent.NewRunner` (`internal/agent/runtime.go:48-69`) resolves the config into an opaque `llm.Model` and bakes the API key into a provider registry. `Runner` holds `{svc, model}` — no `*config.Config`. And the agent's **routes are conditionally registered** (`internal/api/api.go:143-159`): if the agent was off at boot, `/api/v1/agent/chat` does not exist, and no amount of settings-toggling can conjure a gin route into being. So a runtime toggle needs three things that are each easy to miss: 1. **Register agent routes unconditionally**, with a nil-guard in the handler. `agentChat` currently assumes non-nil (`internal/api/agent.go:81`, `h.agent.Run(...)`) and would panic. 2. **Swap `handlers.agent` under an `atomic.Pointer`.** It's a plain field; hot-swapping it mid-request is a data race — and `-race` in CI will catch it only if a test exercises the swap. 3. **Drop `staleTime: Infinity` on `useCapabilities`** (`web/src/lib/agent.ts:18-24`). The comment says *"server config; it doesn't change under a running page"* — this issue makes that false. Saving settings must invalidate `['capabilities']`. `GET /capabilities` (`api.go:204`) already reads `h.agent != nil` at request time, so it reflects a swap immediately once the above is done. For contrast: `LocalAuth` and `Registration` are already read **per-request** from `s.cfg` (`internal/service/auth.go:50,121,135,154,161`), so those would work with nothing more than a mutable store behind the pointer. Everything else in `Config` is baked in at boot. ## Secrets stay in the environment **`OLLAMA_CLOUD_API_KEY` does not move into the database.** Model selection and enablement are settings; the credential is not. Putting it in SQLite puts it in every backup and in the blast radius of the undo history. The Settings UI should show whether a key is present and working, never its value, and never accept one. This means an admin can change *which model* runs but not *whose account* pays for it, which is the right split for a self-hosted household instance. ## Proposed shape **Migration 0010** introduces the first instance-level state. There's no precedent — every existing preference hangs off a garden or object row (`gardens.unit_pref`, `grid_size_cm`/`snap_to_grid` from 0005). A single-row settings table with a `CHECK (id = 1)` guard is the least surprising option: ```sql CREATE TABLE instance_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), agent_model TEXT NOT NULL DEFAULT '', agent_enabled INTEGER NOT NULL DEFAULT 0 CHECK (agent_enabled IN (0,1)), version INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) ); ``` Empty `agent_model` means "fall back to the env var", so an instance that never opens Settings behaves exactly as it does now and `PANSY_AGENT_MODEL` keeps working. That matters for the Docker/Compose story in the README. **Precedence:** DB setting (if set) > env var > `DefaultAgentModel`. Document it in the README env table, which currently describes env as authoritative. **Routes:** `GET /api/v1/settings` (admin), `PATCH /api/v1/settings` (admin, version-guarded like every other mutable row). Plus a `requireAdmin()` middleware next to `requireAuth()` in `internal/api/auth.go`. **Model validation.** majordomo passes model ids through verbatim and never checks them against a catalog — a bad string fails at the API on the next turn, not at save time. Settings should offer a "test" action that resolves the spec and does a trivial completion, so a typo is caught while the admin is looking at the form rather than the next time someone asks the assistant a question. **Frontend:** `/settings` route is three lines mirroring `plantsRoute` (`web/src/router.tsx`), guard extends `requireAuth` with an `isAdmin` check, nav entry in `AppShell.tsx:5-8` conditioned on `user.isAdmin`. `GardenFormModal.tsx` is the closest template for the form (local state per field, 409-rebase on conflict, `TextField`/`Select`/`Alert` primitives). ## Follow-on, not in scope here `web/src/pages/PlantsPage.tsx:30-32` keeps unit preference in `localStorage` with the comment *"There's no per-user unit preference server-side (only per-garden)"*. That's the obvious first **per-user** setting, but per-user settings are a different table and a different ACL story — worth its own issue once this lands. ## Tests Per CLAUDE.md: anything addressed by its own id needs an API-level test through the router. Specifically needed here — a non-admin gets 403 from both routes, the version guard 409s, an empty `agent_model` falls back to env, and the runner swap is exercised under `-race`.
steve closed this issue 2026-07-22 02:53:56 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: steve/pansy#79