diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 5627321..4c46790 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -249,7 +249,14 @@ Plans:
- **Secrets stay in env, never in DB** (Pitfalls 8 & 10): the wizard validates secrets by performing a test operation (test encrypt/decrypt, structural VAPID check), never by accepting/storing the key value; no DB column for `vapid_private_key` or `app_password_encryption_key`; never log/echo the app password.
- Hard constraints: `GET /api/setup/status` mounts **before** the OIDC guard (like `/health`); do NOT create `/api/setup/credentials` — reuse the Phase 10 admin routes; Drizzle generate+migrate (any `app_config` seeding via migration).
-**Plans**: TBD
+**Plans**: 4 plans in 3 waves
+
+Plans:
+- [ ] 12-01-PLAN.md — Schema migration (nullable OIDC + claimed) + generate-secrets helper (SETUP-03) + Wave-0 scaffolds
+- [ ] 12-02-PLAN.md — Pre-auth /api/setup/* router + isSetupLocked 423 guard + index mount + OIDC boot fallback (SETUP-01/02/04)
+- [ ] 12-03-PLAN.md — First-login-claims rework in upsertUser (D-08, SETUP-01)
+- [ ] 12-04-PLAN.md — PWA SetupPage wizard + App.tsx gate + UI-SPEC revision (SETUP-01/02)
+
**UI hint**: yes
### Phase 13: Real Lint Gate (ESLint)
diff --git a/.planning/phases/12-initial-setup-wizard/12-01-PLAN.md b/.planning/phases/12-initial-setup-wizard/12-01-PLAN.md
new file mode 100644
index 0000000..d425e12
--- /dev/null
+++ b/.planning/phases/12-initial-setup-wizard/12-01-PLAN.md
@@ -0,0 +1,270 @@
+---
+phase: 12-initial-setup-wizard
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - apps/api/src/db/schema.ts
+ - apps/api/src/db/migrations/0002_*.sql
+ - apps/api/src/db/migrations/meta/_journal.json
+ - scripts/generate-secrets.mjs
+ - package.json
+ - apps/api/src/routes/setup.ts
+ - apps/api/src/lib/setupGuard.ts
+ - apps/api/tests/routes/setup.test.ts
+ - apps/api/tests/auth/user.test.ts
+autonomous: true
+requirements: [SETUP-03]
+must_haves:
+ truths:
+ - "Schema migration makes users.oidc_iss/oidc_sub nullable, adds users.claimed, and is APPLIED to the dev DB"
+ - "Existing OIDC users are backfilled claimed=true so first-login-claims never matches them"
+ - "npm run generate-secrets prints SESSION_SECRET, APP_PASSWORD_ENCRYPTION_KEY, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY for pasting into env — never to the DB"
+ - "Stub setup.ts router + setupGuard.ts exist so Wave-1 imports resolve"
+ - "Wave-0 test files exist with at least one failing/red placeholder per SETUP requirement"
+ artifacts:
+ - path: "apps/api/src/db/migrations/0002_*.sql"
+ provides: "nullable oidc_iss/oidc_sub + claimed column + backfill UPDATE"
+ contains: "claimed"
+ - path: "scripts/generate-secrets.mjs"
+ provides: "Bootstrap secret generation helper"
+ contains: "generateVAPIDKeys"
+ - path: "apps/api/src/lib/setupGuard.ts"
+ provides: "isSetupLocked stub (real impl in plan 02)"
+ exports: ["isSetupLocked"]
+ - path: "apps/api/src/routes/setup.ts"
+ provides: "setupRouter stub Hono router"
+ exports: ["setupRouter"]
+ - path: "apps/api/tests/routes/setup.test.ts"
+ provides: "Wave-0 test scaffold for SETUP-01/02/03/04 + 423 guard"
+ key_links:
+ - from: "apps/api/src/db/schema.ts"
+ to: "apps/api/src/db/migrations/0002_*.sql"
+ via: "drizzle-kit generate"
+ pattern: "claimed"
+ - from: "package.json"
+ to: "scripts/generate-secrets.mjs"
+ via: "generate-secrets npm script"
+ pattern: "generate-secrets"
+---
+
+
+Lay the Phase 12 foundation: the schema migration (nullable OIDC identity + `claimed` marker, applied
+via Drizzle generate+migrate with the existing-user backfill), the `npm run generate-secrets` repo
+helper (SETUP-03, D-05), and the Wave-0 scaffolds (stub `setup.ts` router, stub `setupGuard.ts`, and
+the `setup.test.ts` + `user.test.ts` test files) so Wave-1 plans import cleanly and write tests RED-first.
+
+Purpose: Plans 02 and 03 both depend on the migrated schema (`users.claimed`, nullable `oidc_iss`)
+and on the stub router/guard existing as import targets. SETUP-03 (secret generation) is fully owned here.
+Output: Applied 0002 migration, `scripts/generate-secrets.mjs`, package.json script, stub setup.ts +
+setupGuard.ts, and red test scaffolds.
+
+
+
+@$HOME/.claude/gsd-core/workflows/execute-plan.md
+@$HOME/.claude/gsd-core/templates/summary.md
+
+
+
+@.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/db/schema.ts
+@apps/api/src/db/migrations/0001_famous_mad_thinker.sql
+
+
+## Artifacts this phase produces (Plan 01 portion)
+
+- `users.claimed` column (boolean, default false, NOT NULL)
+- `users.oidc_iss` / `users.oidc_sub` → nullable (was NOT NULL)
+- Migration `apps/api/src/db/migrations/0002_*.sql` + journal entry — APPLIED
+- `scripts/generate-secrets.mjs` + root `package.json` `"generate-secrets"` script
+- `apps/api/src/lib/setupGuard.ts` exporting `isSetupLocked()` (stub → real impl in Plan 02)
+- `apps/api/src/routes/setup.ts` exporting `setupRouter` (stub → real impl in Plan 02)
+- `apps/api/tests/routes/setup.test.ts` (Wave-0 scaffold)
+
+
+
+
+ Task 1: [BLOCKING] Schema change + generate+migrate (nullable OIDC identity, claimed marker, backfill)
+ apps/api/src/db/schema.ts, apps/api/src/db/migrations/0002_*.sql, apps/api/src/db/migrations/meta/_journal.json
+
+ - apps/api/src/db/schema.ts (the `users` table at lines ~35-51 and `appConfig` at ~282-286 — the file being modified)
+ - apps/api/src/db/migrations/0001_famous_mad_thinker.sql (analog: prior migration shape, PATTERNS.md §0002_*.sql)
+ - .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §`apps/api/src/db/schema.ts` and §`0002_*.sql` (exact field edits + backfill SQL)
+ - .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Runtime State Inventory + Pitfall 9 (unique-constraint/NULL behavior)
+
+
+ In apps/api/src/db/schema.ts, edit the `users` table (D-07): remove `.notNull()` from `oidcIss`
+ (`varchar('oidc_iss', { length: 512 })`) and `oidcSub` (`varchar('oidc_sub', { length: 256 })`),
+ and add `claimed: boolean('claimed').default(false).notNull()`. Leave the `uniq_oidc_identity`
+ unique constraint on (oidcIss, oidcSub) unchanged (MariaDB treats NULLs as distinct in unique
+ indexes — multiple NULLs allowed, which is correct). Add a comment above `appConfig` documenting the
+ new Phase 12 keys ('oidc_issuer', 'oidc_client_id', 'vapid_public_key', 'app_external_url';
+ 'setup_complete' already exists) and the prohibition: NEVER add 'vapid_private_key' or
+ 'app_password_encryption_key' (D-01 / SC-3).
+ Then generate the migration: `pnpm --filter @familysync/api exec drizzle-kit generate`. NEVER use
+ `drizzle-kit push` (D-Task5-DDL — false destructive diff on MariaDB 11). Open the produced
+ 0002_*.sql and (a) confirm it contains MODIFY/ALTER making oidc_iss/oidc_sub nullable + ADD COLUMN
+ claimed (not a DROP/recreate of users data), and (b) APPEND the backfill statement
+ `UPDATE \`users\` SET \`claimed\` = true WHERE \`oidc_iss\` IS NOT NULL;` so existing OIDC users are
+ marked claimed (prevents first-login-claims from matching them). If drizzle emits a
+ DROP CONSTRAINT/ADD CONSTRAINT pair on the unique index (Pitfall 9), keep it — it is safe with
+ nullable columns.
+ Apply the migration: `pnpm --filter @familysync/api exec drizzle-kit migrate`. The apply step is
+ mandatory and non-skippable: typecheck/build pass from schema.ts types WITHOUT the live DB change,
+ so verification below must prove the column exists in the DB.
+
+
+ - source: `grep -c "claimed" apps/api/src/db/schema.ts` returns >= 1
+ - source: `grep -v '^#' apps/api/src/db/schema.ts | grep -E "oidc_iss.*notNull\(\)|oidc_sub.*notNull\(\)"` returns nothing (notNull removed from both)
+ - source: a file matching `apps/api/src/db/migrations/0002_*.sql` exists and `grep -i "claimed" $(ls apps/api/src/db/migrations/0002_*.sql)` matches
+ - source: `grep -ic "UPDATE .users. SET .claimed. = true WHERE .oidc_iss. IS NOT NULL" $(ls apps/api/src/db/migrations/0002_*.sql)` returns 1
+ - CLI: migration applied — the dev DB `users` table has a `claimed` column (verified by drizzle-kit migrate exiting 0 and a follow-up `SELECT claimed FROM users LIMIT 1` style check via the test DB harness in Task 4)
+ - source: `apps/api/src/db/migrations/meta/_journal.json` references the 0002 migration
+
+
+ cd apps/api && pnpm exec drizzle-kit migrate && pnpm typecheck
+
+ schema.ts has nullable oidc_iss/oidc_sub + claimed; 0002 migration generated, contains the backfill UPDATE, and is applied to the dev DB; typecheck green.
+
+
+
+ Task 2: generate-secrets repo helper (SETUP-03 / D-05)
+ scripts/generate-secrets.mjs, package.json
+
+ - scripts/check-audit.mjs (analog: plain-ESM .mjs script structure, PATTERNS.md §generate-secrets.mjs)
+ - .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Pattern 6 + §Open Question 2 (VAPID format, script location/toolchain)
+ - package.json (the root scripts block being modified)
+
+
+ Create scripts/generate-secrets.mjs as a plain ESM script (no TypeScript compilation): import
+ `generateVAPIDKeys` from web-push (resolve from apps/api/node_modules, e.g.
+ `'../apps/api/node_modules/web-push/src/index.js'`), and `randomBytes` from `node:crypto`. Compute
+ `SESSION_SECRET = randomBytes(32).toString('hex')`, `APP_PASSWORD_ENCRYPTION_KEY =
+ randomBytes(32).toString('hex')`, and `const vapid = generateVAPIDKeys()`. Print a copy-paste block
+ to stdout with a header comment ("FamilySync Bootstrap Secrets", timestamp, "Paste into your
+ docker-compose.yml environment block", "cannot be recovered if lost") followed by the four lines
+ `SESSION_SECRET=...`, `APP_PASSWORD_ENCRYPTION_KEY=...`, `VAPID_PUBLIC_KEY=${vapid.publicKey}`,
+ `VAPID_PRIVATE_KEY=${vapid.privateKey}`. The script ONLY prints to stdout — it MUST NOT write any
+ file, touch the DB, or call any API (SC-3: secrets never persisted). Add to the ROOT package.json
+ scripts: `"generate-secrets": "node scripts/generate-secrets.mjs"`.
+
+
+ - source: `grep -c "generateVAPIDKeys" scripts/generate-secrets.mjs` returns >= 1
+ - source: `grep -c "randomBytes(32).toString('hex')" scripts/generate-secrets.mjs` returns >= 2 (session secret + enc key)
+ - source: scripts/generate-secrets.mjs contains no `writeFile`/`appendFile`/`fetch`/`db` (`grep -E "writeFile|appendFile|fetch\(|from '.*db" scripts/generate-secrets.mjs` returns nothing)
+ - source: root package.json scripts has `"generate-secrets"` (`node -e "process.exit(require('./package.json').scripts['generate-secrets']?0:1)"` exits 0)
+ - behavior: `node scripts/generate-secrets.mjs` prints SESSION_SECRET (64 hex chars), APP_PASSWORD_ENCRYPTION_KEY (64 hex chars), VAPID_PUBLIC_KEY (base64url ~87 chars), VAPID_PRIVATE_KEY (base64url ~43 chars)
+
+
+ node scripts/generate-secrets.mjs | grep -E "^SESSION_SECRET=[0-9a-f]{64}$" && node scripts/generate-secrets.mjs | grep -E "^APP_PASSWORD_ENCRYPTION_KEY=[0-9a-f]{64}$" && node scripts/generate-secrets.mjs | grep -E "^VAPID_PUBLIC_KEY=.{80,}$" && node scripts/generate-secrets.mjs | grep -E "^VAPID_PRIVATE_KEY=.{40,}$"
+
+ `node scripts/generate-secrets.mjs` prints all four correctly-shaped values; nothing is written to disk or DB; root package.json wires the script.
+
+
+
+ Task 3: Stub setupGuard.ts + setup.ts router (Wave-0 import targets)
+ apps/api/src/lib/setupGuard.ts, apps/api/src/routes/setup.ts
+
+ - apps/api/src/routes/health.ts (analog: minimal Hono router export + file-doc-comment, PATTERNS.md §Shared Pattern 5)
+ - apps/api/dist/lib/householdTimezone.js (analog: app_config read shape for the real impl in Plan 02)
+ - .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §setupGuard.ts and §setup.ts (the import patterns Plan 02 fills in)
+
+
+ Create apps/api/src/lib/setupGuard.ts exporting an async `isSetupLocked(): Promise`. For
+ this Wave-0 stub, return `false` (real per-call DB evaluation lands in Plan 02). Add a doc comment:
+ "Re-evaluated fresh on every call — NEVER cache at module level (D-10). Real impl: Plan 02."
+ Create apps/api/src/routes/setup.ts exporting `setupRouter = new Hono()` with a file-doc-comment
+ noting it mounts at /api/setup BEFORE the /api/* OIDC chain (pre-auth surface, like /health). Leave
+ it as an empty router (handlers added in Plan 02). Do NOT mount it in index.ts yet (Plan 02 owns
+ the index.ts mount to keep file ownership clean).
+
+
+ - source: `grep -c "export async function isSetupLocked" apps/api/src/lib/setupGuard.ts` returns 1
+ - source: `grep -c "export const setupRouter" apps/api/src/routes/setup.ts` returns 1
+ - test: typecheck passes (`cd apps/api && pnpm typecheck`)
+
+
+ cd apps/api && pnpm typecheck
+
+ setupGuard.ts exports isSetupLocked (stub returns false); setup.ts exports an empty setupRouter; typecheck green.
+
+
+
+ Task 4: Wave-0 test scaffolds (setup.test.ts + user.test.ts claim placeholder)
+ apps/api/tests/routes/setup.test.ts, apps/api/tests/auth/user.test.ts
+
+ - apps/api/tests/routes/admin.test.ts (analog: Vitest + Hono route test conventions, mock of credentialSync + db)
+ - apps/api/tests/auth/user.test.ts (the existing upsertUser test file being extended)
+ - .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Validation Architecture (Phase Requirements → Test Map + Wave 0 Gaps)
+ - .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §setup.test.ts
+
+
+ Create apps/api/tests/routes/setup.test.ts following the admin.test.ts mock conventions (mock
+ ../src/db/client.js and ../src/broker/credentialSync.js). Add describe/it scaffolds — each marked
+ with `it.todo(...)` or a placeholder `expect(true).toBe(false)` so they are visibly RED until Plan
+ 02 implements them — covering: GET /api/setup/status fresh→{setupComplete:false}; status after
+ complete→{setupComplete:true}; POST /api/setup/validate/vapid 200 valid / 400 truncated; POST
+ /api/setup/validate/oidc 400 unreachable; POST /api/setup/credential PROPFIND-fail→400; the 423
+ guard (Pitfall 8): POST /api/setup/complete twice → first 200, second 423; and D-10 effective-config
+ branch: any /api/setup/* → 423 when a member_credentials row exists AND VAPID env present. The 423
+ guard test (SETUP-04) MUST be written here in Wave 0 so it is RED before the happy path is built.
+ In apps/api/tests/auth/user.test.ts, add a describe block (it.todo placeholders) for D-08
+ first-login-claims: when setup_complete='true', the first OIDC login claims the single unclaimed
+ local user (oidc_iss IS NULL AND claimed=false), populates oidc_iss/oidc_sub, sets claimed=true,
+ preserves is_admin; and asserts NO email-keyed lookup.
+
+
+ - source: `grep -c "423" apps/api/tests/routes/setup.test.ts` returns >= 1 (the Pitfall 8 guard test present)
+ - source: `grep -Ec "validate/vapid|validate/oidc|/credential|/complete|/status" apps/api/tests/routes/setup.test.ts` returns >= 4 (all setup routes referenced)
+ - source: `grep -Ec "claimed|first-login-claim|unclaimed" apps/api/tests/auth/user.test.ts` returns >= 1
+ - test: the suite runs without import/collection errors (`pnpm --filter @familysync/api test -- setup` exits with test results, not a load error — todos/red placeholders are expected at this stage)
+
+
+ cd apps/api && pnpm test -- setup 2>&1 | grep -Eq "Tests|todo|passed|failed"
+
+ setup.test.ts scaffolds all SETUP-01..04 cases incl. the RED 423-guard test; user.test.ts has the D-08 claim scaffold; the suite collects without import errors.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| operator shell → repo | generate-secrets output crosses to the operator's clipboard/env; must never reach DB or logs |
+| schema.ts → live DB | migration applied to a populated `users` table; a destructive diff would orphan/lose user rows |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-12-01 | Information Disclosure | generate-secrets.mjs | mitigate | Script prints to stdout only — no writeFile/appendFile/fetch/db access (acceptance-checked); SC-3 secrets never persisted |
+| T-12-02 | Tampering | 0002 migration on populated users | mitigate | Drizzle generate+migrate (NEVER push); review generated SQL for MODIFY (not DROP); backfill `claimed=true WHERE oidc_iss IS NOT NULL` so existing rows are not orphaned |
+| T-12-03 | Information Disclosure | schema.ts app_config keys | mitigate | Comment + acceptance gate forbidding vapid_private_key / app_password_encryption_key columns (D-01) |
+| T-12-SC | Tampering | npm/pip/cargo installs | accept | This plan installs ZERO new packages (web-push + node:crypto already present, RESEARCH §No New Packages) — no legitimacy checkpoint needed |
+
+
+
+- `cd apps/api && pnpm exec drizzle-kit migrate` exits 0 and the dev DB `users.claimed` column exists
+- `node scripts/generate-secrets.mjs` prints all four correctly-shaped secret lines
+- `cd apps/api && pnpm typecheck` green
+- `pnpm --filter @familysync/api test -- setup` collects (red scaffolds expected)
+
+
+
+- Migration applied: nullable oidc_iss/oidc_sub + claimed column + backfill UPDATE in 0002_*.sql
+- SETUP-03 satisfied: generate-secrets prints session secret, encryption key, VAPID pair; nothing persisted
+- Stub setupGuard.ts + setup.ts exist as Wave-1 import targets
+- RED test scaffolds exist (incl. the 423 guard test before the happy path)
+
+
+
diff --git a/.planning/phases/12-initial-setup-wizard/12-02-PLAN.md b/.planning/phases/12-initial-setup-wizard/12-02-PLAN.md
new file mode 100644
index 0000000..f5bf4e0
--- /dev/null
+++ b/.planning/phases/12-initial-setup-wizard/12-02-PLAN.md
@@ -0,0 +1,266 @@
+---
+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"
+---
+
+
+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.
+
+
+
+@$HOME/.claude/gsd-core/workflows/execute-plan.md
+@$HOME/.claude/gsd-core/templates/summary.md
+
+
+
+@.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
+
+
+## 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
+
+
+
+
+ Task 1: isSetupLocked() guard + the RED-first 423 tests (SETUP-04, Pitfall 8)
+ apps/api/src/lib/setupGuard.ts, apps/api/tests/routes/setup.test.ts
+
+ - 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
+
+
+ - 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
+
+
+ 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.
+
+
+ - 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
+
+
+ cd apps/api && pnpm test -- setup 2>&1 | grep -Eq "passed|failed"
+
+ isSetupLocked() is real, fresh-per-call; guard branch tests pass; the 423-after-complete test exists and is RED pending Task 2.
+
+
+
+ Task 2: setup router — status, config-collect, validate/{db,oidc,vapid}, credential, complete (SETUP-01/02)
+ apps/api/src/routes/setup.ts, apps/api/tests/routes/setup.test.ts
+
+ - 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
+
+
+ - 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'))
+
+
+ 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.
+
+
+ - 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
+
+
+ cd apps/api && pnpm test -- setup && pnpm typecheck
+
+ 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.
+
+
+
+ Task 3: Mount setupRouter pre-auth + OIDC boot env-OR-app_config fallback (Pitfall 8 / D-02 / D-03 / A2)
+ apps/api/src/index.ts, apps/api/src/auth/middleware.ts
+
+ - 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
+
+
+ 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.
+
+
+ - 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= 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)
+
+
+ cd apps/api && pnpm typecheck && pnpm test
+
+ 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.
+
+
+
+
+
+## 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 |
+
+
+
+- `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
+
+
+
+- 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)
+
+
+
diff --git a/.planning/phases/12-initial-setup-wizard/12-03-PLAN.md b/.planning/phases/12-initial-setup-wizard/12-03-PLAN.md
new file mode 100644
index 0000000..c997c56
--- /dev/null
+++ b/.planning/phases/12-initial-setup-wizard/12-03-PLAN.md
@@ -0,0 +1,152 @@
+---
+phase: 12-initial-setup-wizard
+plan: 03
+type: tdd
+wave: 2
+depends_on: ["12-01"]
+files_modified:
+ - apps/api/src/auth/user.ts
+ - apps/api/tests/auth/user.test.ts
+autonomous: true
+requirements: [SETUP-01]
+must_haves:
+ truths:
+ - "The first OIDC login AFTER app_config.setup_complete='true' claims the single unclaimed local user (oidc_iss IS NULL AND claimed=false), populating oidc_iss/oidc_sub and setting claimed=true"
+ - "The claimed user keeps its is_admin and credential — no new admin row is created"
+ - "The claim NEVER keys on email — match is by oidc_iss IS NULL AND claimed=false only (D-10)"
+ - "Existing OIDC users (claimed=true from the Plan-01 backfill) are matched by identity as before and never re-claimed"
+ - "When setup_complete is not yet true (or no unclaimed user exists), upsertUser falls through to the normal new-user insert path"
+ artifacts:
+ - path: "apps/api/src/auth/user.ts"
+ provides: "upsertUser with the first-login-claims branch (repurposed first-login-wins)"
+ contains: "claimed"
+ - path: "apps/api/tests/auth/user.test.ts"
+ provides: "D-08 first-login-claims tests (claim, no-email-key, no-double-claim, fallthrough)"
+ contains: "claimed"
+ key_links:
+ - from: "apps/api/src/auth/user.ts"
+ to: "app_config.setup_complete"
+ via: "read before the claim branch"
+ pattern: "setup_complete"
+ - from: "apps/api/src/auth/user.ts"
+ to: "users (oidc_iss IS NULL AND claimed=false)"
+ via: "claim query"
+ pattern: "isNull\\(users.oidcIss\\)"
+---
+
+
+Rework `upsertUser` in `apps/api/src/auth/user.ts` to implement first-login-claims (D-08): the first
+OIDC login after `app_config.setup_complete='true'` claims the single unclaimed pre-OIDC local user
+(provisioned by the wizard in Plan 02) instead of minting a fresh admin. This repurposes the Phase 10
+first-login-wins bootstrap — the WR-01 rework the code comment at user.ts l.114 explicitly defers to
+Phase 12. TDD plan: claim behavior tests are written before/with the logic change.
+
+Purpose: Without this, the wizard-created local user (oidc_iss NULL, is_admin=true, holding the
+validated credential) would be orphaned and the first OIDC login would create a second admin. SETUP-01's
+"first run → guided bootstrap" only closes the loop once the operator's OIDC identity adopts that local user.
+Output: A claim-aware upsertUser that preserves the identity model (no email keying) and the credential + admin status.
+
+
+
+@$HOME/.claude/gsd-core/workflows/execute-plan.md
+@$HOME/.claude/gsd-core/templates/summary.md
+
+
+
+@.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/auth/user.ts
+
+
+## Artifacts this phase produces (Plan 03 portion)
+
+- `upsertUser` first-login-claims branch in `apps/api/src/auth/user.ts`:
+ - reads `app_config.setup_complete`
+ - when true, claims the unclaimed local user (`WHERE oidc_iss IS NULL AND claimed=false LIMIT 1`), sets `oidc_iss`/`oidc_sub`/`claimed=true`, preserves `is_admin` + credential
+ - `shouldBeAdmin` for the normal insert path becomes `setup_complete !== 'true' && admin count === 0`
+- `apps/api/tests/auth/user.test.ts` — D-08 claim test cases (turning the Plan-01 scaffolds green)
+
+
+
+
+ Task 1: First-login-claims branch in upsertUser (D-08)
+ apps/api/src/auth/user.ts, apps/api/tests/auth/user.test.ts
+
+ - apps/api/src/auth/user.ts (the file being modified — identity lookup l.76-97, first-login-wins block l.112-123, insert path l.125-141)
+ - apps/api/tests/auth/user.test.ts (existing upsertUser tests + the Plan-01 D-08 scaffold)
+ - .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §auth/user.ts (the exact replacement pattern, import additions, claim query)
+ - .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §Pattern 4 + Pitfall 4 (no email keying) + §Migration backfill (claimed=true for existing OIDC users)
+
+
+ - Existing identity match (oidc_iss+oidc_sub present) → returns/updates that row as today (unchanged); never re-claims
+ - setup_complete='true' AND an unclaimed user exists (oidc_iss IS NULL AND claimed=false) → claim it: set oidc_iss, oidc_sub, claimed=true, keep is_admin; return the claimed row
+ - setup_complete='true' AND no unclaimed user → normal insert path, NOT auto-admin (an admin already exists from the claim model)
+ - setup_complete !== 'true' → existing first-login-wins behavior preserved (shouldBeAdmin = admin count === 0)
+ - Claim query uses isNull(users.oidcIss) AND eq(users.claimed,false) — asserts NO claims.email / no email column lookup
+
+
+ Per PATTERNS.md §auth/user.ts: add `isNull` to the drizzle-orm import and `appConfig` to the
+ schema import. After the existing identity lookup (step 1, l.76-97) and before the insert (step 4),
+ read `app_config.setup_complete`. If its value === 'true', select the single unclaimed user
+ `WHERE isNull(users.oidcIss) AND eq(users.claimed, false) LIMIT 1`; if found, `db.update(users).set({
+ oidcIss, oidcSub, claimed: true, displayName: displayName ?? unclaimed.displayName }).where(eq(
+ users.id, unclaimed.id))` and return `{ ...unclaimed, oidcIss, oidcSub, claimed: true }` (is_admin
+ preserved — not overwritten). Replace the `shouldBeAdmin = Number(count) === 0` line with
+ `shouldBeAdmin = flagRow?.value !== 'true' && Number(count) === 0` so the normal insert path no
+ longer self-promotes once setup is complete. MUST NOT introduce any email-keyed matching (D-10 /
+ Pitfall 4). Turn the Plan-01 D-08 scaffolds GREEN and add: claim success (fields + is_admin
+ preserved), no-double-claim (a claimed user is not re-claimed), no-email-key (assert the query path
+ references no email), and the setup_complete-false fallthrough.
+
+
+ - source: `grep -c "isNull(users.oidcIss)" apps/api/src/auth/user.ts` returns >= 1
+ - source: claim path reads setup_complete (`grep -c "setup_complete" apps/api/src/auth/user.ts` >= 1)
+ - source: NO email keying in the claim — `grep -Ec "claims\.email|users\.email|eq\(.*email" apps/api/src/auth/user.ts` returns 0
+ - source: shouldBeAdmin gated on setup_complete (`grep -Ec "value !== 'true'.*count|flagRow.*shouldBeAdmin|shouldBeAdmin =.*!= 'true'" apps/api/src/auth/user.ts` >= 1)
+ - source: the claim sets claimed=true (`grep -c "claimed: true" apps/api/src/auth/user.ts` >= 1)
+ - test: user.test.ts D-08 cases pass (claim success/admin-preserved, no-double-claim, fallthrough)
+
+
+ cd apps/api && pnpm test -- user && pnpm typecheck
+
+ upsertUser claims the unclaimed local user after setup_complete, preserves is_admin, never keys on email, and falls through correctly when setup is incomplete; user.test.ts green.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Authelia OIDC callback → upsertUser | claims supplied by the IdP drive the claim/merge of a pre-existing local user |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-12-10 | Spoofing | first-login-claims claiming the wrong user | accept | Claim query is `oidc_iss IS NULL AND claimed=false LIMIT 1`; exactly one pending user exists in a 2-person household; OIDC reach requires Authelia household membership (documented claim-window assumption, D-08) |
+| T-12-11 | Elevation of Privilege | unexpected auto-admin after setup | mitigate | shouldBeAdmin gated to `setup_complete !== 'true'` — once setup completes, new logins do not self-promote; admin comes only from the claimed local user |
+| T-12-12 | Tampering | email-keyed identity coupling | mitigate | Acceptance gate forbids claims.email/users.email lookups (D-10 / Pitfall 4); match is identity-null + claimed-false only |
+| T-12-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages this plan — no legitimacy checkpoint needed |
+
+
+
+- `pnpm --filter @familysync/api test -- user` green (claim, no-double-claim, no-email-key, fallthrough)
+- `cd apps/api && pnpm typecheck` green
+- Source greps: isNull(users.oidcIss) present; no email keying; shouldBeAdmin gated on setup_complete
+
+
+
+- D-08 first-login-claims: first OIDC login after setup_complete claims the unclaimed local user, preserving is_admin + credential
+- No email coupling; existing OIDC users (backfilled claimed=true) never re-claimed
+- Normal insert path no longer auto-promotes admin once setup is complete
+
+
+
diff --git a/.planning/phases/12-initial-setup-wizard/12-04-PLAN.md b/.planning/phases/12-initial-setup-wizard/12-04-PLAN.md
new file mode 100644
index 0000000..aaf3152
--- /dev/null
+++ b/.planning/phases/12-initial-setup-wizard/12-04-PLAN.md
@@ -0,0 +1,247 @@
+---
+phase: 12-initial-setup-wizard
+plan: 04
+type: execute
+wave: 3
+depends_on: ["12-02"]
+files_modified:
+ - .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md
+ - apps/pwa/src/api/client.ts
+ - apps/pwa/src/routes/SetupPage.tsx
+ - apps/pwa/src/App.tsx
+ - apps/pwa/src/App.test.tsx
+autonomous: false
+requirements: [SETUP-01, SETUP-02]
+must_haves:
+ truths:
+ - "On a fresh instance (GET /api/setup/status → {setupComplete:false}), the app redirects to /setup and renders the wizard with no AppNav/BottomTabBar"
+ - "The revised wizard collects non-secret config (OIDC issuer/client_id, VAPID public key, app URL) as input fields, then validates DB/OIDC/VAPID/CalDAV before completing"
+ - "There is no in-wizard secret-generation step (D-05 — generation is the repo helper, pre-boot)"
+ - "Completing the wizard (POST /api/setup/complete) shows the terminal 'Setup complete' screen with a Sign in link to /"
+ - "Navigating to /setup after completion (423) renders the 'Already Locked' screen"
+ - "When setupComplete:true, normal app boot proceeds (no /setup redirect)"
+ artifacts:
+ - path: ".planning/phases/12-initial-setup-wizard/12-UI-SPEC.md"
+ provides: "Revised Wizard-Steps + Interaction-Contract (Step 2 dropped, Steps 3/4 collect config)"
+ contains: "config"
+ - path: "apps/pwa/src/routes/SetupPage.tsx"
+ provides: "The standalone multi-step wizard component"
+ min_lines: 80
+ - path: "apps/pwa/src/App.tsx"
+ provides: "setup-status gate + /setup route"
+ contains: "setup"
+ key_links:
+ - from: "apps/pwa/src/App.tsx"
+ to: "/api/setup/status"
+ via: "setupQuery on load → redirect to /setup when unconfigured"
+ pattern: "setup/status|setupStatus"
+ - from: "apps/pwa/src/routes/SetupPage.tsx"
+ to: "/api/setup/* (config, validate, credential, complete)"
+ via: "TanStack Query mutations"
+ pattern: "setup/(config|validate|credential|complete)"
+---
+
+
+Deliver the PWA side of the wizard: revise `12-UI-SPEC.md` (drop the Generate-Secrets step per D-05;
+make the OIDC/VAPID step collect non-secret config inputs per D-02), build `SetupPage.tsx` (the
+standalone full-page wizard following the revised UI-SPEC and the AdminPage/CredentialSheet patterns),
+add the App.tsx setup-status gate + `/setup` route, and wire the `apps/pwa/src/api/client.ts` setup
+client functions. Verify the flow with playwright-cli (desktop Chromium) per the CLAUDE.md convention.
+
+Purpose: This is the operator-facing surface that closes SETUP-01 (guided bootstrap instead of
+hand-editing files) and surfaces SETUP-02's per-input validation. The API routes (Plan 02) are the
+contract this consumes.
+Output: A working /setup wizard, the App-level gate, and a revised UI-SPEC matching D-02/D-04/D-05.
+
+
+
+@$HOME/.claude/gsd-core/workflows/execute-plan.md
+@$HOME/.claude/gsd-core/templates/summary.md
+
+
+
+@.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
+@.planning/phases/12-initial-setup-wizard/12-UI-SPEC.md
+@apps/pwa/src/routes/AdminPage.tsx
+@apps/pwa/src/components/CredentialSheet.tsx
+@apps/pwa/src/App.tsx
+@apps/pwa/src/api/client.ts
+
+
+## Artifacts this phase produces (Plan 04 portion)
+
+- Revised `12-UI-SPEC.md`: Step 2 (Generate Secrets) dropped; the OIDC/VAPID step gains input fields for oidc_issuer/oidc_client_id/vapid_public_key (+ app URL); 4-step flow (Welcome / Config / Validate / Credential — or planner-chosen equivalent) consistent with D-02/D-04/D-05
+- `apps/pwa/src/api/client.ts`: `fetchSetupStatus`, `postSetupConfig`, `validateSetupDb/Oidc/Vapid`, `postSetupCredential`, `postSetupComplete`
+- `apps/pwa/src/routes/SetupPage.tsx`: standalone wizard (no AppNav/BottomTabBar), Surfaces 1-8 per the revised UI-SPEC, plain-text JSX (no dangerouslySetInnerHTML)
+- `apps/pwa/src/App.tsx`: `setupQuery` on /api/setup/status (staleTime 0) + `/setup` route + redirect gate when `setupComplete:false`
+
+
+
+
+ Task 1: Revise 12-UI-SPEC.md (drop Generate-Secrets; config-collect inputs per D-02/D-04/D-05)
+ .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md
+
+ - .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md (the file being revised — §Surface 2 step labels, §Surface 4 Generated-Secret block, §Wizard Steps, §Copywriting Contract)
+ - .planning/phases/12-initial-setup-wizard/12-RESEARCH.md §UI-SPEC Revision Requirements (the authoritative table of what changes vs stays)
+ - .planning/phases/12-initial-setup-wizard/12-CONTEXT.md D-02/D-04/D-05 + the ⚠ Supersedes notes
+
+
+ Revise ONLY the Wizard-Steps, Interaction-Contract, Step-Indicator labels, Surface-4, and
+ Copywriting sections per RESEARCH.md §UI-SPEC Revision Requirements. DROP Step 2 "Generate Secrets"
+ entirely (no Secret Blocks, no acknowledgement checkboxes, no POST /api/setup/generate — generation
+ is the pre-boot repo helper, D-05); remove the Surface-4 Generated-Secret-Block section (or mark it
+ removed). Re-number the step indicator to the revised set (planner's call per CONTEXT discretion,
+ e.g. Welcome / Config / Validate / Credential — 4 steps). Convert the OIDC/VAPID step to COLLECT
+ non-secret config via input fields (oidc_issuer, oidc_client_id, vapid_public_key, app_external_url)
+ that POST to /api/setup/config, THEN validate (D-02). Update Step-1 description copy to remove the
+ "copy of docker-compose.yml to paste generated secrets into" reference. Leave the design system,
+ tokens, spacing, typography, color, a11y contract, security display rules, the Credential step, and
+ the Terminal/Locked screens UNCHANGED — do NOT re-derive the design system.
+
+
+ - source: the Generated-Secrets step is gone (`grep -ic "Generate Secrets\|Generated Secrets" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` returns 0, or any remaining hit is explicitly marked "REMOVED")
+ - source: the OIDC/config step now references input fields for the config keys (`grep -Ec "oidc_issuer|oidc_client_id|vapid_public_key|app_external_url|/api/setup/config" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` >= 2)
+ - source: no in-wizard generate endpoint (`grep -c "/api/setup/generate" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` returns 0)
+ - source: design-system sections retained (`grep -c "Design System\|Spacing Scale\|Accessibility Contract" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` >= 3)
+
+
+ ! grep -iq "/api/setup/generate" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md && grep -Eq "oidc_issuer|/api/setup/config" .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md
+
+ UI-SPEC steps revised: no generate-secrets step, config-collect inputs for the OIDC/VAPID step, step indicator re-numbered; design system untouched.
+
+
+
+ Task 2: Setup API client + SetupPage wizard component
+ apps/pwa/src/api/client.ts, apps/pwa/src/routes/SetupPage.tsx
+
+ - apps/pwa/src/api/client.ts (the file being extended — fetchMe l.74, saveCredential l.429 patterns)
+ - apps/pwa/src/routes/AdminPage.tsx (analog: page component, useQuery/useMutation, section-label/button styles, PATTERNS.md §SetupPage.tsx)
+ - apps/pwa/src/components/CredentialSheet.tsx (analog: credential field layout, validation-state row, helper link, plain-text JSX — Step Credential reuses this exactly)
+ - .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md (the REVISED contract from Task 1 — surfaces, copy, a11y)
+ - .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §SetupPage.tsx (imports, mutation, step-state patterns)
+
+
+ - fetchSetupStatus() GETs /api/setup/status → { setupComplete: boolean }
+ - postSetupConfig(payload) POSTs the four non-secret config values to /api/setup/config
+ - validateSetupDb/Oidc/Vapid() POST the three validation routes; map non-200 to a typed failure
+ - postSetupCredential({fastmailEmail, appPassword}) POSTs /api/setup/credential
+ - postSetupComplete() POSTs /api/setup/complete
+ - SetupPage renders the revised steps (Welcome → Config → Validate → Credential), the step indicator (Surface 2), per-step validation-state rows (Surface 5), the terminal "Setup complete" screen (Surface 7) on success, and the "Already Locked" screen (Surface 8) when status/complete returns 423
+ - No AppNav/BottomTabBar; role="main"; step heading h2; aria-live status rows; all copy plain-text JSX (no dangerouslySetInnerHTML)
+
+
+ Add the setup client functions to apps/pwa/src/api/client.ts following the existing fetch/JSON
+ conventions (same error-shape handling as fetchMe/saveCredential). Build
+ apps/pwa/src/routes/SetupPage.tsx per the REVISED UI-SPEC (Task 1) and PATTERNS.md §SetupPage.tsx:
+ local `useState` step cursor (no URL params, D-06 stateless); a TanStack `useMutation` per
+ POST step advancing the cursor onSuccess and surfacing a Surface-5 failure row onError; reuse the
+ CredentialSheet field/validation idiom verbatim for the Credential step; render Surface 7 on
+ /complete success and Surface 8 when an API call returns 423. Use the existing tokens.css custom
+ properties and lucide-react icons named in the UI-SPEC. All copy must be plain-text JSX children —
+ NO dangerouslySetInnerHTML (UI-SPEC security contract). Render standalone — no AppNav/BottomTabBar.
+
+
+ - source: client.ts exports the setup functions (`grep -Ec "fetchSetupStatus|postSetupConfig|postSetupComplete|postSetupCredential" apps/pwa/src/api/client.ts` >= 4)
+ - source: SetupPage references all setup routes (`grep -Ec "setup/config|setup/validate|setup/credential|setup/complete|setup/status" apps/pwa/src/routes/SetupPage.tsx` >= 4 — directly or via the client imports)
+ - source: no dangerouslySetInnerHTML (`grep -c "dangerouslySetInnerHTML" apps/pwa/src/routes/SetupPage.tsx` returns 0)
+ - source: standalone — SetupPage does not import AppNav/BottomTabBar (`grep -Ec "AppNav|BottomTabBar" apps/pwa/src/routes/SetupPage.tsx` returns 0)
+ - source: a11y — role="main" + aria-live present (`grep -Ec "role=\"main\"|aria-live" apps/pwa/src/routes/SetupPage.tsx` >= 1)
+ - test: `pnpm --filter @familysync/pwa typecheck` and `pnpm --filter @familysync/pwa build` green
+
+
+ cd apps/pwa && pnpm typecheck && pnpm build
+
+ Setup client functions added; SetupPage renders the revised 4-step wizard standalone with terminal/locked screens, no dangerouslySetInnerHTML; pwa typecheck + build green.
+
+
+
+ Task 3: App.tsx setup-status gate + /setup route + redirect
+ apps/pwa/src/App.tsx, apps/pwa/src/App.test.tsx
+
+ - apps/pwa/src/App.tsx (the file being modified — meQuery l.65-70, Routes block l.133-153, isAdmin loading-gate l.144-150)
+ - apps/pwa/src/App.test.tsx (existing App routing tests to extend, if present; else mirror the meQuery test setup)
+ - .planning/phases/12-initial-setup-wizard/12-PATTERNS.md §App.tsx (setupQuery + gate + Navigate pattern)
+ - .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md §Routing & App-Level Gate
+
+
+ In apps/pwa/src/App.tsx add `import { SetupPage } from './routes/SetupPage.js';` and a
+ `setupQuery = useQuery({ queryKey: ['setupStatus'], queryFn: fetchSetupStatus, retry: false,
+ staleTime: 0 })` alongside meQuery (staleTime 0 — the gate must not be stale, mirrors D-10 spirit).
+ Add `} />` to the Routes block. Add the redirect gate:
+ while setupQuery is loading render nothing (prevent flash, mirror the isAdmin loading-gate l.144-150);
+ when `setupQuery.data?.setupComplete === false`, redirect all non-/setup routes to /setup
+ (``); when true, normal app boot proceeds. The /setup route renders
+ standalone — ensure the gate prevents AppNav/BottomTabBar from rendering over the wizard when
+ unconfigured (per UI-SPEC §Routing). Extend App.test.tsx: setupComplete:false → SetupPage/redirect
+ rendered; setupComplete:true → normal calendar route.
+
+
+ - source: setupQuery present (`grep -Ec "setupStatus|fetchSetupStatus" apps/pwa/src/App.tsx` >= 1)
+ - source: /setup route added (`grep -c "/setup" apps/pwa/src/App.tsx` >= 1)
+ - source: SetupPage imported (`grep -c "SetupPage" apps/pwa/src/App.tsx` >= 1)
+ - source: redirect gate keyed on setupComplete (`grep -Ec "setupComplete === false|setupComplete\\?" apps/pwa/src/App.tsx` >= 1)
+ - test: `pnpm --filter @familysync/pwa test -- App` green (both setupComplete branches)
+
+
+ cd apps/pwa && pnpm test -- App && pnpm typecheck
+
+ App.tsx queries /api/setup/status, exposes the /setup route, and redirects to /setup when unconfigured (no flash, no nav over wizard); App.test.tsx covers both branches.
+
+
+
+ Task 4: Verify the /setup wizard flow end-to-end (playwright-cli desktop)
+ Drive the /setup flow with playwright-cli (desktop Chromium) against a fresh/unconfigured DB per the verification steps below; escalate to the human only for steps playwright-cli cannot perform (e.g. a live Authelia/Fastmail round-trip).
+ The /setup wizard flow end-to-end in the PWA: redirect-to-/setup when unconfigured, the revised 4-step flow (Welcome → Config → Validate → Credential), validation-state rows, and the terminal "Setup complete" screen. Per CLAUDE.md the executor MUST first drive this with playwright-cli (desktop Chromium) — only fall back to a human if a step genuinely cannot be driven headlessly.
+
+ 1. Bring up the dev stack against a FRESH/unconfigured DB (no setup_complete, no member_credentials) — see MEMORY familysync-dev-stack-setup; the API + PWA dev servers + MariaDB.
+ 2. Using playwright-cli (`/usr/local/bin/playwright-cli`), navigate to the app root and confirm it redirects to /setup and renders the wizard with NO AppNav/BottomTabBar.
+ 3. Drive the wizard: Config step accepts the OIDC issuer/client_id + VAPID public key + app URL inputs and POSTs /api/setup/config; Validate step shows pending→success rows for DB/OIDC/VAPID (mock or live as available); Credential step accepts a Fastmail email + app password (use a known-good or mocked credential) and shows "Credential verified."; Complete shows the "Setup complete" terminal screen with a Sign in link to /.
+ 4. Re-navigate to /setup after completion and confirm the "Already Locked" screen renders (API 423).
+ 5. Capture screenshots of the wizard, a validation-success row, and the terminal screen into the phase dir for the SUMMARY.
+ Only escalate to the human for steps playwright-cli cannot perform (e.g. a live Authelia/Fastmail round-trip if no mock is wired) — note any such steps explicitly.
+
+ Type "approved" or describe the issues observed
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| operator browser → /api/setup/* | the wizard is the unauthenticated client of the pre-auth API; it submits non-secret config + the Fastmail app password |
+| SetupPage render → DOM | operator-supplied copy/config values rendered; XSS risk if not plain-text |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-12-13 | Information Disclosure | wizard never displays/handles secrets | mitigate | D-05 — no generate-secrets step; the wizard never receives SESSION_SECRET/encryption key/VAPID private key; only the non-secret VAPID public key is an input |
+| T-12-14 | Tampering (XSS) | SetupPage rendering operator input | mitigate | No dangerouslySetInnerHTML (acceptance-checked); all copy + config values rendered as plain-text JSX children (UI-SPEC security contract) |
+| T-12-15 | Information Disclosure | app password in the Credential step | mitigate | type="password" input (UI-SPEC); reuses CredentialSheet idiom; server-side noEchoHook (Plan 02) ensures the value is never echoed back |
+| T-12-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages — lucide-react/react-query/react-router already installed (RESEARCH §Standard Stack) |
+
+
+
+- `cd apps/pwa && pnpm typecheck && pnpm build` green
+- `pnpm --filter @familysync/pwa test -- App` green (both setupComplete branches)
+- UI-SPEC revised: no generate-secrets step, config-collect inputs present
+- playwright-cli desktop smoke: redirect→wizard→config→validate→credential→complete + locked screen
+
+
+
+- SETUP-01: fresh instance redirects to /setup; guided multi-step wizard renders standalone
+- SETUP-02: each input validates (DB/OIDC/VAPID/CalDAV) before the step completes
+- D-05 honored: no in-wizard secret generation
+- Terminal + Already-Locked screens behave per UI-SPEC; playwright-cli smoke passes
+
+
+
diff --git a/.planning/phases/12-initial-setup-wizard/12-VALIDATION.md b/.planning/phases/12-initial-setup-wizard/12-VALIDATION.md
index 37d4499..756b337 100644
--- a/.planning/phases/12-initial-setup-wizard/12-VALIDATION.md
+++ b/.planning/phases/12-initial-setup-wizard/12-VALIDATION.md
@@ -1,8 +1,8 @@
---
phase: 12
slug: initial-setup-wizard
-status: draft
-nyquist_compliant: false
+status: ready
+nyquist_compliant: true
wave_0_complete: false
created: 2026-06-15
---
@@ -17,20 +17,20 @@ created: 2026-06-15
| Property | Value |
|----------|-------|
-| **Framework** | {pytest 7.x / jest 29.x / vitest / go test / other} |
-| **Config file** | {path or "none — Wave 0 installs"} |
-| **Quick run command** | `{quick command}` |
-| **Full suite command** | `{full command}` |
-| **Estimated runtime** | ~{N} seconds |
+| **Framework** | Vitest (API: `apps/api/tests/`; PWA: `apps/pwa`) + Playwright (e2e) |
+| **Config file** | `apps/api/vitest.config.ts`, `apps/pwa/vitest` config, `apps/pwa/playwright.config.ts` |
+| **Quick run command** | `pnpm --filter @familysync/api test -- setup` |
+| **Full suite command** | `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test` |
+| **Estimated runtime** | ~30–60 seconds (API + PWA unit) |
---
## Sampling Rate
-- **After every task commit:** Run `{quick run command}`
-- **After every plan wave:** Run `{full suite command}`
-- **Before `/gsd-verify-work`:** Full suite must be green
-- **Max feedback latency:** {N} seconds
+- **After every task commit:** Run `pnpm --filter @familysync/api test -- setup` (API tasks) or `pnpm --filter @familysync/pwa test -- App` (PWA tasks)
+- **After every plan wave:** Run `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test`
+- **Before `/gsd-verify-work`:** Full suite + `pnpm test:e2e` green
+- **Max feedback latency:** 60 seconds
---
@@ -38,7 +38,18 @@ created: 2026-06-15
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
-| {N}-01-01 | 01 | 1 | REQ-{XX} | T-{N}-01 / — | {expected secure behavior or "N/A"} | unit | `{command}` | ✅ / ❌ W0 | ⬜ pending |
+| 12-01-01 | 01 | 1 | SETUP-03 (schema) | T-12-02 | Migration MODIFY (not DROP) + backfill; no orphaned rows | integration | `cd apps/api && pnpm exec drizzle-kit migrate && pnpm typecheck` | ✅ | ⬜ pending |
+| 12-01-02 | 01 | 1 | SETUP-03 | T-12-01 | Secrets to stdout only — never DB/file/log | unit (script) | `node scripts/generate-secrets.mjs \| grep -E ...` | ✅ | ⬜ pending |
+| 12-01-03 | 01 | 1 | — (scaffold) | — | Import targets only | typecheck | `cd apps/api && pnpm typecheck` | ✅ | ⬜ pending |
+| 12-01-04 | 01 | 1 | SETUP-01/02/03/04 | T-12-04 | RED 423-guard test before happy path | unit (scaffold) | `cd apps/api && pnpm test -- setup` | ✅ W0 | ⬜ pending |
+| 12-02-01 | 02 | 2 | SETUP-04 | T-12-04 | Per-call 423; no startup cache | unit | `cd apps/api && pnpm test -- setup` | ✅ | ⬜ pending |
+| 12-02-02 | 02 | 2 | SETUP-01/02 | T-12-05/06/08 | noEchoHook; VAPID priv env-only; https issuer; no new crypto | integration | `cd apps/api && pnpm test -- setup && pnpm typecheck` | ✅ | ⬜ pending |
+| 12-02-03 | 02 | 2 | SETUP-01 | T-12-09 | Pre-auth mount before OIDC guard; env-OR-app_config boot | integration | `cd apps/api && pnpm typecheck && pnpm test` | ✅ | ⬜ pending |
+| 12-03-01 | 03 | 2 | SETUP-01 (D-08) | T-12-10/11/12 | Claim by oidc_iss IS NULL+claimed=false; no email key; admin gated | unit | `cd apps/api && pnpm test -- user && pnpm typecheck` | ✅ | ⬜ pending |
+| 12-04-01 | 04 | 3 | SETUP-01/02 | T-12-13 | UI-SPEC: no generate-secrets step | doc grep | `grep -Eq "oidc_issuer\|/api/setup/config" 12-UI-SPEC.md` | ✅ | ⬜ pending |
+| 12-04-02 | 04 | 3 | SETUP-01/02 | T-12-14/15 | No dangerouslySetInnerHTML; password input | typecheck+build | `cd apps/pwa && pnpm typecheck && pnpm build` | ✅ | ⬜ pending |
+| 12-04-03 | 04 | 3 | SETUP-01 | — | Redirect gate; no flash | unit | `cd apps/pwa && pnpm test -- App && pnpm typecheck` | ✅ | ⬜ pending |
+| 12-04-04 | 04 | 3 | SETUP-01/02 | T-12-14 | End-to-end wizard flow (playwright-cli desktop) | e2e / human | playwright-cli drive `/setup` (see plan) | ✅ | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
@@ -46,11 +57,12 @@ created: 2026-06-15
## Wave 0 Requirements
-- [ ] `{tests/test_file.py}` — stubs for REQ-{XX}
-- [ ] `{tests/conftest.py}` — shared fixtures
-- [ ] `{framework install}` — if no framework detected
+- [ ] `apps/api/tests/routes/setup.test.ts` — scaffolds SETUP-01/02/03/04 incl. the RED 423-guard test (Pitfall 8) — created in Plan 01 Task 4
+- [ ] `apps/api/tests/auth/user.test.ts` — D-08 first-login-claims scaffold — extended in Plan 01 Task 4
+- [ ] `apps/api/src/routes/setup.ts` — stub Hono router (import target) — Plan 01 Task 3
+- [ ] `apps/api/src/lib/setupGuard.ts` — stub isSetupLocked (import target) — Plan 01 Task 3
-*If none: "Existing infrastructure covers all phase requirements."*
+*Existing Vitest + Playwright infrastructure covers all other phase requirements.*
---
@@ -58,19 +70,21 @@ created: 2026-06-15
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
-| {behavior} | REQ-{XX} | {reason} | {steps} |
+| Live Authelia OIDC discovery round-trip | SETUP-02 | No live Authelia in test env; mock the discovery fetch in unit tests | If a live Authelia is available, validate /api/setup/validate/oidc against the real issuer; otherwise rely on the fetch-mock unit test |
+| Live Fastmail CalDAV PROPFIND | SETUP-02 | Requires a real Fastmail app password; unit tests mock createFastmailClient | Optional live check with a known-good app password during the playwright-cli smoke (Plan 04 Task 4) |
+| iOS-Safari standalone behavior | — | Not in scope this phase; wizard is desktop-driven | N/A — desktop Chromium via playwright-cli covers the wizard per CLAUDE.md |
-*If none: "All phase behaviors have automated verification."*
+*All automatable phase behaviors have automated verification; the above need live services or are out of scope.*
---
## Validation Sign-Off
-- [ ] All tasks have `` verify or Wave 0 dependencies
-- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
-- [ ] Wave 0 covers all MISSING references
-- [ ] No watch-mode flags
-- [ ] Feedback latency < {N}s
-- [ ] `nyquist_compliant: true` set in frontmatter
+- [x] All tasks have `` verify or Wave 0 dependencies
+- [x] Sampling continuity: no 3 consecutive tasks without automated verify (the only checkpoint, 12-04-04, follows three automated PWA tasks)
+- [x] Wave 0 covers all MISSING references (setup.test.ts, user.test.ts, setup.ts stub, setupGuard.ts stub — all in Plan 01)
+- [x] No watch-mode flags
+- [x] Feedback latency < 60s
+- [x] `nyquist_compliant: true` set in frontmatter
-**Approval:** {pending / approved YYYY-MM-DD}
+**Approval:** approved 2026-06-15