Files
familysync/.planning/milestones/v1.1-phases/12-initial-setup-wizard/12-06-PLAN.md
T
2026-06-18 22:21:38 -04:00

147 lines
9.5 KiB
Markdown

---
phase: 12-initial-setup-wizard
plan: 06
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/src/routes/setup.ts
- apps/api/tests/routes/setup.test.ts
- apps/pwa/src/api/client.ts
autonomous: true
gap_closure: true
requirements: [SETUP-02]
must_haves:
truths:
- "Entering a wrong/invalid VAPID public key in the wizard fails the VAPID validation row"
- "POST /api/setup/validate/vapid returns 400 when the submitted vapid_public_key does not match the env VAPID_PUBLIC_KEY"
- "The GET /api/setup/status response exposes the non-secret env DB name (no secrets)"
- "VAPID_PRIVATE_KEY is never returned in any response (T-12-06 preserved)"
artifacts:
- path: "apps/api/src/routes/setup.ts"
provides: "validate/vapid asserts submitted key matches env public key; status returns dbName"
contains: "VAPID_PUBLIC_KEY"
- path: "apps/pwa/src/api/client.ts"
provides: "SetupStatusResponse.dbName field"
contains: "dbName"
key_links:
- from: "POST /api/setup/validate/vapid"
to: "app_config.vapid_public_key"
via: "compare submitted key against process.env.VAPID_PUBLIC_KEY"
pattern: "vapid_public_key"
- from: "GET /api/setup/status"
to: "process.env.DB_NAME"
via: "non-secret DB name surfaced in response"
pattern: "dbName"
---
<objective>
Close UAT gaps 2 and 3 on the backend setup-route surface.
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 }`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<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.md
# Files 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
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: validate/vapid asserts submitted key matches env public key (gap 2)</name>
<files>apps/api/src/routes/setup.ts, apps/api/tests/routes/setup.test.ts</files>
<behavior>
- 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).
</behavior>
<action>
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.
</action>
<verify>
<automated>cd apps/api && set -a; source ../../.env; set +a; DB_HOST=127.0.0.1 pnpm test -- setup 2>&1 | tail -20</automated>
</verify>
<done>validate/vapid returns 400 for a mismatched/absent submitted key and 200 only when the submitted key equals process.env.VAPID_PUBLIC_KEY; no response path returns VAPID_PRIVATE_KEY; setup.test.ts vapid suite GREEN.</done>
</task>
<task type="auto">
<name>Task 2: Expose non-secret DB name via GET /api/setup/status (gap 3 backend)</name>
<files>apps/api/src/routes/setup.ts, apps/api/tests/routes/setup.test.ts, apps/pwa/src/api/client.ts</files>
<action>
In the `GET /status` handler (apps/api/src/routes/setup.ts, ~line 86), include the non-secret DB name in the response alongside the existing `setupComplete`. Source the name from `process.env.DB_NAME` (the Drizzle/mysql2 connection uses DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME — confirm the exact env var name by grepping apps/api/src/db/client.ts; use whatever that file reads for the database name). Return `{ setupComplete, dbName }` where dbName is `process.env.DB_NAME ?? null`.
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).
</action>
<verify>
<automated>cd apps/api && set -a; source ../../.env; set +a; DB_HOST=127.0.0.1 pnpm test -- setup 2>&1 | tail -15 && cd ../pwa && pnpm typecheck 2>&1 | tail -5</automated>
</verify>
<done>GET /api/setup/status returns `{ setupComplete, dbName }` with the non-secret DB name (no DB_PASSWORD/DB_HOST/DB_USER); SetupStatusResponse carries `dbName?: string | null`; api setup.test.ts GREEN; pwa typecheck clean.</done>
</task>
</tasks>
<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>
<verification>
- `cd apps/api && set -a; source ../../.env; set +a; DB_HOST=127.0.0.1 pnpm test -- setup` GREEN
- `grep -nE "VAPID_PRIVATE_KEY" apps/api/src/routes/setup.ts` shows it only inside the env-only structural check, never in a response/compare against app_config
- `grep -nE "DB_PASSWORD|DB_HOST|DB_USER" apps/api/src/routes/setup.ts | grep -i "status\|c.json"` returns nothing (no secret/topology in status response)
- `cd apps/pwa && pnpm typecheck` clean
</verification>
<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>
<output>
Create `.planning/phases/12-initial-setup-wizard/12-06-SUMMARY.md` when done
</output>