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

267 lines
20 KiB
Markdown

---
phase: 12-initial-setup-wizard
plan: 02
type: tdd
wave: 2
depends_on: ["12-01"]
files_modified:
- apps/api/src/lib/setupGuard.ts
- apps/api/src/routes/setup.ts
- apps/api/src/index.ts
- apps/api/src/auth/middleware.ts
- apps/api/tests/routes/setup.test.ts
autonomous: true
requirements: [SETUP-01, SETUP-02, SETUP-04]
must_haves:
truths:
- "GET /api/setup/status returns {setupComplete:false} on a fresh instance and {setupComplete:true} after completion, reachable WITHOUT auth (before the OIDC guard)"
- "The wizard collects non-secret config (oidc_issuer, oidc_client_id, vapid_public_key, app_external_url) into app_config via POST /api/setup/config"
- "Each input validates before completing: DB connects, VAPID structurally valid (32/65-byte via setVapidDetails), OIDC discovery resolves, Fastmail app password reaches CalDAV PROPFIND"
- "A second call to any setup endpoint after completion returns 423 (guard re-evaluated fresh every call — Pitfall 8)"
- "POST /api/setup/complete promotes the local user to admin, sets app_config.setup_complete, after which the guard locks"
- "OIDC boot config reads env OR app_config so a fresh unconfigured instance does not crash at boot"
artifacts:
- path: "apps/api/src/lib/setupGuard.ts"
provides: "isSetupLocked() — real per-call DB evaluation (setup_complete OR effectively-configured)"
exports: ["isSetupLocked"]
- path: "apps/api/src/routes/setup.ts"
provides: "setupRouter: /status, /config, /validate/db, /validate/oidc, /validate/vapid, /credential, /complete"
exports: ["setupRouter"]
- path: "apps/api/src/index.ts"
provides: "setupRouter mounted at /api/setup BEFORE the /api/* OIDC chain"
contains: "app.route('/api/setup', setupRouter)"
key_links:
- from: "apps/api/src/routes/setup.ts"
to: "apps/api/src/lib/setupGuard.ts"
via: "isSetupLocked() first statement in every handler"
pattern: "isSetupLocked"
- from: "apps/api/src/routes/setup.ts"
to: "apps/api/src/broker/credentialSync.ts"
via: "validateEncryptAndStoreCredential(localUserId, ...)"
pattern: "validateEncryptAndStoreCredential"
- from: "apps/api/src/index.ts"
to: "apps/api/src/routes/setup.ts"
via: "pre-auth mount before devAuthBypass()"
pattern: "api/setup"
---
<objective>
Build the pre-auth `/api/setup/*` API surface: the real `isSetupLocked()` 423 guard (D-10), the
setup router (status / config-collect / validate db|oidc|vapid / credential / complete), the
index.ts pre-auth mount, and the OIDC boot-config env-OR-app_config fallback (Pitfall 8 / D-02 / D-03 / A2).
This is a TDD plan: the 423 guard test (Pitfall 8) is the canonical RED-first test, written and failing
before the happy path is implemented.
Purpose: This is the security-critical core of Phase 12 — the only app surface outside the OIDC guard.
SETUP-01 (collect/guided), SETUP-02 (validate-each-input), and SETUP-04 (per-call 423 lock) all land here.
Output: A working, tested pre-auth setup API; local-user + credential provisioning via the shared helper.
</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-CONTEXT.md
@.planning/phases/12-initial-setup-wizard/12-RESEARCH.md
@.planning/phases/12-initial-setup-wizard/12-PATTERNS.md
@apps/api/src/routes/admin.ts
@apps/api/src/routes/health.ts
@apps/api/src/broker/credentialSync.ts
@apps/api/src/index.ts
</context>
## Artifacts this phase produces (Plan 02 portion)
- `isSetupLocked()` — real impl: 423 if `app_config.setup_complete='true'` OR (a `member_credentials` row exists AND `VAPID_PRIVATE_KEY` + `VAPID_PUBLIC_KEY` env present); re-queried every call
- Routes: `GET /api/setup/status`, `POST /api/setup/config`, `POST /api/setup/validate/db`, `POST /api/setup/validate/oidc`, `POST /api/setup/validate/vapid`, `POST /api/setup/credential`, `POST /api/setup/complete`
- app_config keys written: `oidc_issuer`, `oidc_client_id`, `vapid_public_key`, `app_external_url`, `setup_complete`
- `apps/api/src/index.ts`: `app.route('/api/setup', setupRouter)` mounted before `app.use('/api/*', devAuthBypass())`
- OIDC boot config: reads `OIDC_ISSUER`/`OIDC_CLIENT_ID`/`OIDC_AUTH_EXTERNAL_URL` from env OR app_config fallback
<tasks>
<task type="tdd">
<name>Task 1: isSetupLocked() guard + the RED-first 423 tests (SETUP-04, Pitfall 8)</name>
<files>apps/api/src/lib/setupGuard.ts, apps/api/tests/routes/setup.test.ts</files>
<read_first>
- apps/api/src/lib/setupGuard.ts (the Wave-0 stub being made real)
- apps/api/tests/routes/setup.test.ts (the Wave-0 scaffold to turn green)
- apps/api/dist/lib/householdTimezone.js (analog: app_config read pattern)
- .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §setupGuard.ts (exact read shape) + §Shared Pattern 1
- .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Pattern 3 (fresh-per-call) + Pitfall 2
</read_first>
<behavior>
- isSetupLocked() returns true when app_config.setup_complete === 'true'
- isSetupLocked() returns true when a member_credentials row exists AND both VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY env are set (D-10 effective-config branch)
- isSetupLocked() returns false on a fresh instance (no flag, no credential)
- RED-first: POST /api/setup/complete twice → first 200, second 423 (Pitfall 8) — write this test against the not-yet-real router and confirm it fails before Task 2
- The guard re-queries the DB on every call (no module-level cache) — a test that flips setup_complete between two calls sees the change
</behavior>
<action>
Implement the real isSetupLocked() in setupGuard.ts per PATTERNS.md §setupGuard.ts: read app_config
`setup_complete` (return true if value==='true'); else select one member_credentials row and check
`!!process.env.VAPID_PRIVATE_KEY && !!process.env.VAPID_PUBLIC_KEY`, returning `!!credRow &&
vapidPresent`. MUST NOT hoist the result to a module-level variable — every call re-queries (D-10).
Turn the Wave-0 guard tests GREEN against the real helper, and write the RED-first
`POST /api/setup/complete` twice → 200 then 423 test (it will fail until Task 2's /complete handler
exists — that RED state is the point). Mock db.select per the admin.test.ts convention.
</action>
<acceptance_criteria>
- source: `grep -c "export async function isSetupLocked" apps/api/src/lib/setupGuard.ts` returns 1
- source: setupGuard.ts has no module-level `let locked`/cache (`grep -E "^(let|const) .*=.*isSetupLocked|cachedLock" apps/api/src/lib/setupGuard.ts` returns nothing)
- source: setupGuard reads both VAPID env vars (`grep -c "VAPID_PRIVATE_KEY" apps/api/src/lib/setupGuard.ts` and `grep -c "VAPID_PUBLIC_KEY" apps/api/src/lib/setupGuard.ts` each >= 1)
- test: the guard unit tests (setup_complete branch + effective-config branch + fresh-false) pass
</acceptance_criteria>
<verify>
<automated>cd apps/api && pnpm test -- setup 2>&1 | grep -Eq "passed|failed"</automated>
</verify>
<done>isSetupLocked() is real, fresh-per-call; guard branch tests pass; the 423-after-complete test exists and is RED pending Task 2.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: setup router — status, config-collect, validate/{db,oidc,vapid}, credential, complete (SETUP-01/02)</name>
<files>apps/api/src/routes/setup.ts, apps/api/tests/routes/setup.test.ts</files>
<read_first>
- apps/api/src/routes/setup.ts (the Wave-0 stub router being filled)
- apps/api/src/routes/admin.ts (analog: noEchoHook l.54-64, credentialSchema l.47-52, validateEncryptAndStoreCredential call + error mapping l.102-122, app_config upsert)
- apps/api/src/routes/health.ts (analog: DB connectivity check `db.execute(sql\`SELECT 1\`)`)
- apps/api/src/broker/credentialSync.ts (signature: validateEncryptAndStoreCredential(userId, fastmailEmail, appPassword, providerType); CredentialValidationError)
- apps/api/src/auth/user.ts (analog: mysql2 $returningId() + re-select for the local-user insert, l.126-141)
- .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §setup.ts (all handler patterns) + §Shared Patterns 1-5
- .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Pattern 5 (helper reuse) + §Pattern 7 (VAPID) + §Pattern 8 (OIDC discovery) + Pitfalls 1,5,7
</read_first>
<behavior>
- GET /api/setup/status → {setupComplete: boolean} derived from app_config.setup_complete; reachable pre-auth
- POST /api/setup/config → upserts oidc_issuer, oidc_client_id, vapid_public_key, app_external_url into app_config; validates issuer is an https URL (reject non-https → 400)
- POST /api/setup/validate/db → 200 on `SELECT 1` success, 503 on failure
- POST /api/setup/validate/oidc → fetch {issuer}/.well-known/openid-configuration (5s timeout); 200 ok, 400 on unreachable/non-2xx
- POST /api/setup/validate/vapid → setVapidDetails(subject, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY); 200 valid, 400 on structural failure; reads private key ONLY from process.env (never app_config/DB)
- POST /api/setup/credential → inserts the pre-OIDC local user (oidc_iss NULL, claimed=false, is_admin=true) FIRST, then calls validateEncryptAndStoreCredential(localUserId, email, password, 'caldav'); CredentialValidationError→400 (no echo), other→503
- POST /api/setup/complete → sets app_config.setup_complete='true'; returns 200 first call, 423 second (guard)
- EVERY handler: isSetupLocked() is the FIRST statement; if locked → 423
- app password NEVER logged/echoed (noEchoHook; no console.log of c.req.valid('json'))
</behavior>
<action>
Fill setupRouter in setup.ts. Import { isSetupLocked } from '../lib/setupGuard.js'; copy the
admin.ts noEchoHook (l.54-64) and the credential error-mapping idiom (l.102-122). The FIRST statement
in every handler: `const locked = await isSetupLocked(); if (locked) return c.json({ error: 'Setup
already complete' }, 423);`. Implement each route per the §setup.ts patterns:
/status reads app_config.setup_complete and returns {setupComplete}; /config zod-validates
{oidcIssuer:https-url, oidcClientId, vapidPublicKey, appExternalUrl} and upserts each via
`db.insert(appConfig).values({key,value}).onDuplicateKeyUpdate({set:{value}})` with keys
'oidc_issuer'|'oidc_client_id'|'vapid_public_key'|'app_external_url'; /validate/db does
`db.execute(sql\`SELECT 1\`)`; /validate/oidc fetches the discovery doc with
`AbortSignal.timeout(5000)`; /validate/vapid calls `webpush.setVapidDetails(subject ||
'mailto:validate@familysync.local', process.env.VAPID_PUBLIC_KEY ?? '', process.env.VAPID_PRIVATE_KEY
?? '')` in try/catch — NEVER read the private key from app_config or return it; /credential inserts
the local user via $returningId()+re-select (oidcIss:null, oidcSub:null, claimed:false, isAdmin:true,
color: first unused from COLOR_PALETTE) THEN calls the shared helper with that id and providerType
'caldav' (Pitfall 5 — user row must exist before the FK insert); use noEchoHook + CredentialValidationError→400/503;
/complete upserts setup_complete='true' then returns 200. Do NOT create new crypto and do NOT call
/api/admin/credentials (D-09 — reuse the shared helper directly). Turn the Wave-0 + Task-1 RED tests
GREEN, including the 423-after-complete and the validate 200/400/503 cases.
</action>
<acceptance_criteria>
- source: every handler calls the guard first — `grep -c "isSetupLocked" apps/api/src/routes/setup.ts` returns >= 7 (one per route)
- source: setup.ts reuses the shared helper, no new crypto (`grep -c "validateEncryptAndStoreCredential" apps/api/src/routes/setup.ts` >= 1; `grep -Ec "createCipheriv|createHash|randomBytes|encryptPassword" apps/api/src/routes/setup.ts` returns 0)
- source: setup.ts never calls the admin route (`grep -c "api/admin" apps/api/src/routes/setup.ts` returns 0)
- source: VAPID private key read only from env (`grep -E "VAPID_PRIVATE_KEY" apps/api/src/routes/setup.ts` shows only `process.env.VAPID_PRIVATE_KEY`; no app_config read of a private key)
- source: noEchoHook present (`grep -c "noEchoHook" apps/api/src/routes/setup.ts` >= 1) and no log of the password (`grep -Ec "console\.(log|error|warn)\(.*appPassword|console\.(log|error|warn)\(.*valid\('json'\)" apps/api/src/routes/setup.ts` returns 0)
- source: the four new app_config keys written (`grep -Ec "oidc_issuer|oidc_client_id|vapid_public_key|app_external_url" apps/api/src/routes/setup.ts` >= 4)
- test: all setup route tests pass incl. POST /complete twice → 200 then 423
</acceptance_criteria>
<verify>
<automated>cd apps/api && pnpm test -- setup && pnpm typecheck</automated>
</verify>
<done>setupRouter implements all 7 routes; guard is first in each; credential reuses the shared helper (no new crypto, no admin-route call); VAPID private key never leaves env; all setup tests green incl. the Pitfall-8 423 regression.</done>
</task>
<task type="execute">
<name>Task 3: Mount setupRouter pre-auth + OIDC boot env-OR-app_config fallback (Pitfall 8 / D-02 / D-03 / A2)</name>
<files>apps/api/src/index.ts, apps/api/src/auth/middleware.ts</files>
<read_first>
- apps/api/src/index.ts (the file being modified — mount order l.33-55, VAPID boot l.117-139)
- apps/api/src/auth/middleware.ts (oidcAuthMiddleware / processOAuthCallback — where OIDC config is read at boot)
- .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §index.ts (exact insert point)
- .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Env Kernel vs DB Config Split + Open Question 1 + Pitfall 8 (Recommendation: option (a) env-OR-app_config fallback) + Assumptions A1/A2
</read_first>
<action>
In apps/api/src/index.ts, add `import { setupRouter } from './routes/setup.js';` and insert
`app.route('/api/setup', setupRouter);` BEFORE `app.use('/api/*', devAuthBypass())` (mirrors the
/health pre-auth pattern, PATTERNS.md §index.ts) so /api/setup/* is never caught by the OIDC guard
(Pitfall 1). For Pitfall 8 / Open Question 1: confirm where @hono/oidc-auth reads OIDC_ISSUER /
OIDC_CLIENT_ID / OIDC_AUTH_EXTERNAL_URL (read auth/middleware.ts and verify A2 — call-time vs
import-time). Implement Recommendation (a): the OIDC config used by oidcAuthMiddleware resolves from
env first (Docker process.env, then .env fallback per D-03), falling back to the app_config keys (oidc_issuer, oidc_client_id, app_external_url) when
the env var is absent — so a fresh unconfigured instance does not crash at boot (no env, no
app_config yet, OIDC simply unconfigured until setup completes) and a wizard-configured instance
reads the app_config values. Keep the existing devBypass/persistSessionCookie ordering intact. Do
NOT defer the middleware mount (option b) or rewrite to lazy-per-request (option c) unless A2 review
proves env values are read at import time AND a fresh boot crashes — if so, document the chosen
deviation in the SUMMARY.
</action>
<acceptance_criteria>
- source: `grep -c "app.route('/api/setup', setupRouter)" apps/api/src/index.ts` returns 1
- source: the setup mount precedes the devAuthBypass mount — `awk '/api\/setup., setupRouter/{s=NR} /devAuthBypass\(\)/{d=NR} END{exit !(s>0 && s<d)}' apps/api/src/index.ts` exits 0
- source: OIDC config has an app_config fallback path (`grep -Ec "oidc_issuer|app_config|appConfig" apps/api/src/auth/middleware.ts` >= 1) OR the SUMMARY documents A2 found import-time reads requiring option (b)/(c)
- test: full API suite green and the app boots without OIDC env set (a fresh-boot test or the existing boot path does not throw)
</acceptance_criteria>
<verify>
<automated>cd apps/api && pnpm typecheck && pnpm test</automated>
</verify>
<done>setupRouter mounted pre-auth before the /api/* OIDC chain; OIDC boot config resolves env-OR-app_config so a fresh instance does not crash; full API suite green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| unauthenticated client → /api/setup/* | The ONLY pre-auth API surface; the 423 lock is the only thing protecting it once configured |
| client form → app_config | operator-supplied oidc_issuer/client_id/vapid_public_key/app_external_url written to DB |
| client form → CalDAV / member_credentials | Fastmail app password validated + encrypted; must never be logged/echoed/stored plaintext |
## Pre-auth exposure (before vs after setup_complete)
- **Before setup_complete:** an unauthenticated caller can reach all /api/setup/* routes — this is by design (the wizard is pre-auth). Reachable actions: read status, write non-secret app_config, run validations, provision the single local user + credential, flip setup_complete. No secret is ever returned. Only the household operator standing up the instance is expected here; the instance is not yet publicly routed until the operator finishes.
- **After setup_complete:** isSetupLocked() returns true → every /api/setup/* route returns 423. The lock is the sole protection; it is re-evaluated fresh per call (no startup cache) so a manual DB edit or a second instance cannot get a stale "unlocked".
- **First-login-claims window (D-08, handled in Plan 03):** only household members can reach Authelia OIDC at all, so the single unclaimed local user can only be claimed by a household member — acceptable for a 2-person self-hosted app.
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-12-04 | Tampering | setup endpoint replay after completion | mitigate | isSetupLocked() first statement in every handler; 423; re-evaluated per call, never cached (D-10); RED-first Pitfall-8 test |
| T-12-05 | Information Disclosure | app password echoed in 400 | mitigate | noEchoHook (admin.ts) — Zod error details never returned; no console.log of password or valid('json') |
| T-12-06 | Information Disclosure | VAPID_PRIVATE_KEY / APP_PASSWORD_ENCRYPTION_KEY in DB or response | mitigate | D-01 env floor — no app_config key for these; /validate/vapid reads private key only from process.env, returns only {ok} |
| T-12-07 | Spoofing | first-login-claims claiming wrong user | accept | Claim query (Plan 03) is `oidc_iss IS NULL AND claimed=false LIMIT 1`; exactly one pending user in a 2-person household; OIDC reach requires household membership |
| T-12-08 | Tampering | OIDC issuer SSRF via /config | mitigate | Validate issuer is https:// at /config; discovery fetch is server-side with a 5s timeout |
| T-12-09 | Tampering | /api/setup/* caught by OIDC guard (302) | mitigate | Mounted before app.use('/api/*', devAuthBypass()) — acceptance-checked ordering (Pitfall 1) |
| T-12-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages this plan (RESEARCH §No New Packages) — no legitimacy checkpoint needed |
</threat_model>
<verification>
- `pnpm --filter @familysync/api test -- setup` green incl. POST /complete twice → 200 then 423
- `cd apps/api && pnpm typecheck` green; full `pnpm --filter @familysync/api test` green
- Source greps: guard-first in every handler; no new crypto; no admin-route call; VAPID private key env-only; no password log
- /api/setup mount precedes devAuthBypass; OIDC boot has env-OR-app_config fallback
</verification>
<success_criteria>
- SETUP-01: GET /api/setup/status pre-auth + config-collect into app_config
- SETUP-02: DB / OIDC / VAPID / CalDAV validations each gate the flow
- SETUP-04: per-call 423 guard (Pitfall 8 regression green)
- Fresh instance boots without OIDC env (env-OR-app_config fallback)
</success_criteria>
<output>
Create `.planning/phases/12-initial-setup-wizard/12-02-SUMMARY.md` when done
</output>