Files
familysync/.planning/phases/12-initial-setup-wizard/12-06-PLAN.md
T

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
apps/api/src/routes/setup.ts
apps/api/tests/routes/setup.test.ts
apps/pwa/src/api/client.ts
true true
SETUP-02
truths artifacts key_links
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)
path provides contains
apps/api/src/routes/setup.ts validate/vapid asserts submitted key matches env public key; status returns dbName VAPID_PUBLIC_KEY
path provides contains
apps/pwa/src/api/client.ts SetupStatusResponse.dbName field dbName
from to via pattern
POST /api/setup/validate/vapid app_config.vapid_public_key compare submitted key against process.env.VAPID_PUBLIC_KEY vapid_public_key
from to via pattern
GET /api/setup/status process.env.DB_NAME non-secret DB name surfaced in response dbName
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 }.

<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.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

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.
cd apps/api && set -a; source ../../.env; set +a; DB_HOST=127.0.0.1 pnpm test -- setup 2>&1 | tail -20 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. Task 2: Expose non-secret DB name via GET /api/setup/status (gap 3 backend) apps/api/src/routes/setup.ts, apps/api/tests/routes/setup.test.ts, apps/pwa/src/api/client.ts 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).
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 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.

<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>
- `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

<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>
Create `.planning/phases/12-initial-setup-wizard/12-06-SUMMARY.md` when done