Lane 2: first-run setup's own state, and a backup read-back (#728) #754

Merged
claude-bot merged 4 commits from build/lane2-setup-server into main 2026-09-22 20:18:29 +00:00
Collaborator

Lane 2 of the v4.5.0 redesign (#728), the second PR of its series: the server side of PUB-8, setting up a new server (IS-251–IS-258, #439, D68, D70). Backend only; the wizard's pages are a later PR.

The happy surprise: six of the seven steps already had somewhere to write

Going through them one at a time, the wizard turns out to be a path through endpoints the product already has, not a new settings system:

Step Writes through
You (IS-251) nothing — the first account is already made an administrator at first sign-in
Backups (IS-252) PUT /api/admin/backup/config, POST /api/admin/backup/run, GET /api/admin/backup/logs
The key (IS-253) nothing — GET /api/admin/settings/secrets-health already answers the question
What is kept (IS-254) PUT /api/admin/settings/retention
The models (IS-255) PUT /api/admin/settings/bot, then whisper/test and llm/test — Instance health's own tests, which is what PUB-8's header asked for, "not a second set (#439)"
Discord (IS-256) PUT /api/admin/settings/bot, POST /api/admin/settings/bot/regenerate-key, GET /api/admin/settings/bot/ping

regenerate-key already returns the key exactly once, and its docstring already says so — which is the drawing's "Made for you. Put it in the bot's environment; it is not shown again", word for word. The drawing got that one right.

So no step writes settings by a second path. A wizard with its own write routes would be a second set of validation, audit and encryption rules to keep in step with the first, and they would drift.

What is actually new: the state

Nothing recorded that an instance had been set up at all. GET /api/setup/state, POST /api/setup/steps/{step} and POST /api/setup/finish, all admin-only, keep done, skipped and completed_at.

Two behaviours are the feature rather than plumbing, and each has a test:

  • Re-recording a step replaces its earlier outcome. An administrator who comes back and does a step they skipped must watch it leave the list. A list that cannot empty is a nag, not a reminder.
  • finish never refuses over a skipped step. D70: an unfinished setup reminds, and never blocks.

An unknown step or outcome is a 400 with this project's {"detail": "..."} rather than FastAPI's list-shaped 422 — outcome is validated in the service for exactly that reason.

No migration. setup_state is a key in the existing app_settings JSON store.

The backup read-back (IS-252)

POST /api/admin/backup/verify takes the newest successful backup, or one named by log_id, and runs pg_restore --list over the archive:

{ "verified": true, "path": "/app/backups/questboard_20260922T140211Z.dump",
  "size_bytes": 1181116006, "entries": 412, "taken_at": "…", "detail": null }

This reads the archive; it does not restore it — recorded as a departure on #728 before it was built. A real restore needs a scratch database, the time to load a full dump into it, and an answer for what happens when the dump is large; that belongs with the backup feature, not inside a first-run wizard. So the step's wording says "read it back" and claims no more than it does.

It still catches the failures that actually happen: a missing file, a truncated or corrupt archive, an archive written by a newer pg_dump than this server's pg_restore can read (#429's problem from the other side), a timeout, and pg_restore itself being absent — each a verified: false with one sentence, never a 500. pg_restore is in the backend image, at /usr/bin/pg_restore; the PGDG client package bundles it with pg_dump.

Nothing puts the archive path or pg_restore's raw stderr anywhere a non-admin can read.

A real limitation, filed rather than papered over: #753

A backup that has been shipped off-host reports that it has no local copy to read. That is not a bug in this endpoint: run_scheduled_backup computes the local path and then overwrites backup_logs.destination with the redacted rclone spec, so the dump's filename is gone from the row even though the file is still on disk — the code says so two lines above.

The consequence is the wrong way round: verification works on instances that keep backups only locally, and not on the ones that took the trouble to ship them off-host, which is the configuration #414 exists to encourage. Filed as #753 rather than fixed here, because it is a defect in the backup feature and this endpoint behaves correctly in its presence.

The other recorded departure: the key step shows no key

IS-253. The drawing shows QB_SECRET_KEY=2f9c…a41e with a Copy button; this PR adds no endpoint that returns any part of it, and the wizard will not draw one.

GET /api/admin/settings/bot's own docstring states the rule the whole settings layer follows — "Secrets are never returned — only presence flags" — and SECRET_KEY is the key every other secret is encrypted with. It also buys the operator nothing: they put it in the server's environment themselves. The step's information comes from GET /api/admin/settings/secrets-health, which #428 and #430 built for precisely the question "is every encrypted setting readable with this key", after a restore onto a host with a different one produced a system that looked restored and was not.

Checks

ruff format --check and ruff check over webapp/backend/ scripts/ webapp/frontend/e2e/ at the pinned 0.4.4: clean. Full backend suite 3875 passed, 13 skipped; the 26 new tests cover admin-only on all four endpoints (401 and 403), the replace-on-re-record behaviour in both directions, finish-with-skips, and every verified: false branch including pg_restore being absent.

🤖 Generated with Claude Code

Lane 2 of the v4.5.0 redesign (#728), the second PR of its series: the server side of **PUB-8, setting up a new server** (`IS-251`–`IS-258`, #439, D68, D70). Backend only; the wizard's pages are a later PR. ## The happy surprise: six of the seven steps already had somewhere to write Going through them one at a time, the wizard turns out to be a **path through endpoints the product already has**, not a new settings system: | Step | Writes through | |---|---| | You (`IS-251`) | nothing — the first account is already made an administrator at first sign-in | | Backups (`IS-252`) | `PUT /api/admin/backup/config`, `POST /api/admin/backup/run`, `GET /api/admin/backup/logs` | | The key (`IS-253`) | nothing — `GET /api/admin/settings/secrets-health` already answers the question | | What is kept (`IS-254`) | `PUT /api/admin/settings/retention` | | The models (`IS-255`) | `PUT /api/admin/settings/bot`, then `whisper/test` and `llm/test` — **Instance health's own tests**, which is what PUB-8's header asked for, "not a second set (#439)" | | Discord (`IS-256`) | `PUT /api/admin/settings/bot`, `POST /api/admin/settings/bot/regenerate-key`, `GET /api/admin/settings/bot/ping` | `regenerate-key` already returns the key **exactly once**, and its docstring already says so — which is the drawing's "Made for you. Put it in the bot's environment; it is not shown again", word for word. The drawing got that one right. **So no step writes settings by a second path.** A wizard with its own write routes would be a second set of validation, audit and encryption rules to keep in step with the first, and they would drift. ## What is actually new: the state Nothing recorded that an instance had been set up at all. `GET /api/setup/state`, `POST /api/setup/steps/{step}` and `POST /api/setup/finish`, all admin-only, keep `done`, `skipped` and `completed_at`. Two behaviours are the feature rather than plumbing, and each has a test: - **Re-recording a step replaces its earlier outcome.** An administrator who comes back and does a step they skipped must watch it leave the list. A list that cannot empty is a nag, not a reminder. - **`finish` never refuses over a skipped step.** D70: an unfinished setup **reminds, and never blocks**. An unknown step or outcome is a 400 with this project's `{"detail": "..."}` rather than FastAPI's list-shaped 422 — `outcome` is validated in the service for exactly that reason. **No migration.** `setup_state` is a key in the existing `app_settings` JSON store. ## The backup read-back (`IS-252`) `POST /api/admin/backup/verify` takes the newest successful backup, or one named by `log_id`, and runs `pg_restore --list` over the archive: ```json { "verified": true, "path": "/app/backups/questboard_20260922T140211Z.dump", "size_bytes": 1181116006, "entries": 412, "taken_at": "…", "detail": null } ``` **This reads the archive; it does not restore it** — [recorded as a departure on #728](https://git.rhoving.com/rbrooks/Quest-Board/issues/728) before it was built. A real restore needs a scratch database, the time to load a full dump into it, and an answer for what happens when the dump is large; that belongs with the backup feature, not inside a first-run wizard. So the step's wording says "read it back" and claims no more than it does. It still catches the failures that actually happen: a missing file, a truncated or corrupt archive, an archive written by a newer `pg_dump` than this server's `pg_restore` can read (#429's problem from the other side), a timeout, and `pg_restore` itself being absent — each a `verified: false` with one sentence, never a 500. `pg_restore` **is** in the backend image, at `/usr/bin/pg_restore`; the PGDG client package bundles it with `pg_dump`. Nothing puts the archive path or `pg_restore`'s raw stderr anywhere a non-admin can read. ## A real limitation, filed rather than papered over: #753 A backup that has been **shipped off-host** reports that it has no local copy to read. That is not a bug in this endpoint: `run_scheduled_backup` computes the local path and then overwrites `backup_logs.destination` with the redacted rclone spec, so the dump's filename is gone from the row even though the file is still on disk — the code says so two lines above. The consequence is the wrong way round: verification works on instances that keep backups only locally, and not on the ones that took the trouble to ship them off-host, which is the configuration #414 exists to encourage. Filed as **#753** rather than fixed here, because it is a defect in the backup feature and this endpoint behaves correctly in its presence. ## The other recorded departure: the key step shows no key `IS-253`. The drawing shows `QB_SECRET_KEY=2f9c…a41e` with a Copy button; this PR adds no endpoint that returns any part of it, and the wizard will not draw one. `GET /api/admin/settings/bot`'s own docstring states the rule the whole settings layer follows — *"Secrets are never returned — only presence flags"* — and `SECRET_KEY` is the key every other secret is encrypted **with**. It also buys the operator nothing: they put it in the server's environment themselves. The step's information comes from `GET /api/admin/settings/secrets-health`, which #428 and #430 built for precisely the question "is every encrypted setting readable with this key", after a restore onto a host with a different one produced a system that looked restored and was not. ## Checks `ruff format --check` and `ruff check` over `webapp/backend/ scripts/ webapp/frontend/e2e/` at the pinned 0.4.4: clean. Full backend suite **3875 passed, 13 skipped**; the 26 new tests cover admin-only on all four endpoints (401 and 403), the replace-on-re-record behaviour in both directions, finish-with-skips, and every `verified: false` branch including `pg_restore` being absent. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Lane 2 PR 2 (PUB-8, IS-251-258): the wizard's seven steps drive endpoints
that already exist (Bot Settings, Backups, Retention, the AI test buttons),
so the only new state is whether a server has been through setup at all.

- app/services/settings_service.py: KEY_SETUP_STATE (unencrypted — no
  secrets), the SetupState dataclass, and get/record/finish_setup helpers.
  Re-recording a step moves it between done/skipped rather than duplicating
  it, so the skipped list can empty (D70). finish() never inspects skipped —
  D70: reminds, never blocks.
- app/routers/setup.py (new): GET /api/setup/state, POST
  /api/setup/steps/{step}, POST /api/setup/finish — all require_admin. An
  unknown step or outcome is a 400 via ValueError from the service, not a
  500 or a silent no-op. Writes nothing but this state; each step's actual
  settings still go through its own existing admin endpoint.
- app/main.py: mounts the new router at /api beside admin.
- app/routers/admin.py: POST /admin/backup/verify (IS-252) — reads the
  newest successful backup (or one by log_id) with `pg_restore --list`
  rather than restoring it (a recorded departure from the drawing). Handles
  a missing/truncated archive, an off-host-shipped backup whose local path
  isn't recoverable from backup_logs.destination once rclone has redacted
  it (#414), and an image without pg_restore, all as `verified: false` with
  a one-sentence `detail` — never a 500, never raw stderr or the real path
  in the response detail.
- tests/test_setup.py (new), tests/test_admin.py: admin-only enforcement,
  the replace-on-re-record and never-blocks-on-finish behaviours, the
  unknown-step 400, and the read-back's missing/truncated/off-host/
  pg_restore-absent paths.

Does not touch the key step's SECRET_KEY display, the AI test buttons, or
frontend — all explicitly out of scope per the lane-2 contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs(changelog): first-run setup's state, and the backup read-back (#728)
Some checks failed
CI / Backend lint (ruff) (pull_request) Successful in 38s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m1s
CI / Bot/backend version sync (pull_request) Successful in 30s
CI / Feedback site tests (pull_request) Successful in 59s
CI / Bot tests and audit (pull_request) Successful in 2m38s
CI / Frontend tests, audit, and build (pull_request) Failing after 3m19s
CI / Docker image build (pull_request) Successful in 4m47s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 3m28s
CI / Backend migration, tests, and audit (pull_request) Successful in 9m41s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 13m2s
b5a919ef9b
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into build/lane2-setup-server
Some checks failed
CI / Backend lint (ruff) (pull_request) Successful in 58s
CI / Feedback site tests (pull_request) Successful in 51s
CI / Docker image build (pull_request) Successful in 17s
CI / Bot/backend version sync (pull_request) Successful in 39s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m51s
CI / Bot tests and audit (pull_request) Successful in 3m25s
CI / Frontend tests, audit, and build (pull_request) Successful in 5m15s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Failing after 46s
CI / Backend migration, tests, and audit (pull_request) Successful in 10m39s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 13m48s
76da06f710
# Conflicts:
#	CHANGELOG.md
Merge remote-tracking branch 'origin/main' into build/lane2-setup-server
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 39s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m22s
CI / Bot/backend version sync (pull_request) Successful in 40s
CI / Feedback site tests (pull_request) Successful in 1m8s
CI / Docker image build (pull_request) Successful in 37s
CI / Bot tests and audit (pull_request) Successful in 2m33s
CI / Frontend tests, audit, and build (pull_request) Successful in 3m58s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 4m53s
CI / Backend migration, tests, and audit (pull_request) Successful in 10m35s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 17m51s
edf2919e09
Sign in to join this conversation.
No description provided.