Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.5 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, gap_closure, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | gap_closure | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 12-initial-setup-wizard | 06 | execute | 1 |
|
true | true |
|
|
Gap 2 (major): POST /api/setup/validate/vapid validates the env VAPID pair via webpush.setVapidDetails but never compares against the wizard-entered vapid_public_key. An operator typed BH123 (clearly invalid) and the row still went green because the env pair was valid. The fix: assert the submitted/persisted vapid_public_key equals process.env.VAPID_PUBLIC_KEY (the public half of the configured pair) so a wrong key fails the row and gates Continue.
Gap 3 (minor, backend half): the DB connection is configured via Docker env (DB_HOST/PORT/USER/PASSWORD), not collected in the wizard, so the "database connection verified" row has no on-screen referent. Surface the non-secret DB name so the PWA (Plan 05) can render a read-only field giving that row a referent.
Purpose: A wrong VAPID key must fail (push silently breaks in production otherwise — SETUP-02); the DB row must reference something visible.
Output: validate/vapid rejects mismatched keys; GET /api/setup/status returns { setupComplete, dbName }.
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/12-initial-setup-wizard/12-UAT.md @.planning/phases/12-initial-setup-wizard/12-02-SUMMARY.mdFiles to edit (already in context for the planner; executor should read before editing)
@apps/api/src/routes/setup.ts @apps/api/src/api/../tests/routes/setup.test.ts @apps/pwa/src/api/client.ts
Task 1: validate/vapid asserts submitted key matches env public key (gap 2) apps/api/src/routes/setup.ts, apps/api/tests/routes/setup.test.ts - When VAPID_PRIVATE_KEY/VAPID_PUBLIC_KEY env are set AND app_config.vapid_public_key equals process.env.VAPID_PUBLIC_KEY → 200 { ok: true } (existing happy path preserved). - When app_config.vapid_public_key is present but does NOT equal process.env.VAPID_PUBLIC_KEY (e.g. "BH123") → 400 { ok: false } with a non-echoing error message; the response NEVER contains VAPID_PRIVATE_KEY. - When app_config.vapid_public_key row is absent → 400 { ok: false } (cannot validate without the operator-submitted key). - When env VAPID keys are missing → existing 400 path preserved. - When setup is locked → existing 423 path preserved (isSetupLocked() first). In the `POST /validate/vapid` handler (apps/api/src/routes/setup.ts, currently ~line 202), after the existing `isSetupLocked()` 423 guard and the existing env-presence check, add an equality assertion BEFORE the `webpush.setVapidDetails` structural check:- Read the operator-submitted public key from app_config: SELECT value FROM app_config WHERE key = 'vapid_public_key' (use the existing `db.select({ value: appConfig.value }).from(appConfig).where(eq(appConfig.key, 'vapid_public_key')).limit(1)` idiom already used by the validate/oidc handler).
- If that row is absent OR its value !== process.env.VAPID_PUBLIC_KEY, return 400 { ok: false, error: 'VAPID public key does not match the configured key pair. Paste the exact VAPID_PUBLIC_KEY printed by `npm run generate-secrets`.' }. This is the gap-2 assertion: a wrong key now fails the row.
- Keep the existing `webpush.setVapidDetails(subject, publicKey, privateKey)` structural check AFTER the equality check, still reading BOTH keys ONLY from process.env. Do NOT read VAPID_PRIVATE_KEY from app_config and NEVER return it (T-12-06 / D-01 preserved — the equality compares the submitted PUBLIC key to the env PUBLIC key only).
In apps/api/tests/routes/setup.test.ts, extend the validate/vapid suite (RED first): add a test that mocks app_config.vapid_public_key returning a value different from process.env.VAPID_PUBLIC_KEY and asserts a 400 plus that the JSON body has no key matching VAPID_PRIVATE_KEY; update the existing happy-path test so the mocked app_config value equals process.env.VAPID_PUBLIC_KEY (otherwise it would now 400). Add a test for the absent-row → 400 case.
ONLY the database NAME is surfaced — never DB_HOST, DB_USER, or DB_PASSWORD (those are connection secrets/topology; the name alone is the on-screen referent the operator asked for). Do NOT add DB_PASSWORD or any secret to any response.
In apps/pwa/src/api/client.ts, add `dbName?: string | null` to the `SetupStatusResponse` interface (~line 538) so the PWA (Plan 05) consumes a typed field. No other client.ts changes.
In apps/api/tests/routes/setup.test.ts, update the GET /status test(s) to assert the response includes `dbName` reflecting the mocked/process env DB name (set process.env.DB_NAME in the test or assert the key is present).
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| pre-auth client → /api/setup/* | Unauthenticated operator input crosses here before OIDC is configured |
| process.env → response body | Secret env vars must not leak into pre-auth JSON |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-12-06 | Information Disclosure | validate/vapid | mitigate | VAPID_PRIVATE_KEY read ONLY from process.env, never compared/returned; equality check uses PUBLIC keys only; test asserts no VAPID_PRIVATE_KEY in body |
| T-12-3DB | Information Disclosure | GET /status dbName | mitigate | Only process.env.DB_NAME surfaced; DB_HOST/DB_USER/DB_PASSWORD never added to any response (grep-checked) |
| T-12-04 | Tampering/Replay | all setup routes | mitigate | isSetupLocked() remains the first await in every handler (unchanged) |
| </threat_model> |
<success_criteria>
- Gap 2 closed: a wrong wizard-entered VAPID public key fails validate/vapid (400) and therefore gates Continue.
- Gap 3 backend closed: status exposes the non-secret DB name for the PWA read-only field.
- No secret (VAPID_PRIVATE_KEY, DB_PASSWORD) appears in any response. </success_criteria>