---
phase: 12-initial-setup-wizard
verified: 2026-06-16T20:20:00Z
status: passed
score: 9/9 must-haves verified
overrides_applied: 0
human_verification_resolved: "2026-06-16 — end-to-end wizard test against real Fastmail completed by operator (12-UAT.md Test 5: CalDAV PROPFIND validated, 'Setup complete' shown, /setup locks). UAT re-verification confirmed all 6 diagnosed gaps closed (12-UAT.md status: complete; 6 passed, 1 env-blocked-but-test-covered)."
re_verification:
  previous_status: gaps_found
  previous_score: 8/9
  gaps_closed:
    - "VAPID validation wired into wizard UI: validateSetupVapid imported, vapid ValidationRow rendered, sequential DB → OIDC → VAPID chain enforced, bothPassed gates on all three"
  gaps_remaining: []
  regressions: []
human_verification:
  - test: "Complete the wizard end-to-end against a real Fastmail account"
    expected: "Step 3 Credential entry with the operator's real Fastmail email + app password (CalDAV scope) produces 'Credential verified.' and then 'Setup complete' terminal screen with Sign in link to /; re-navigating to /setup shows 'Already Locked' screen"
    why_human: "Requires live Fastmail CalDAV PROPFIND against a real account and real app password; no mock can substitute for the live endpoint validation"
    resolved: "2026-06-16 — operator completed the wizard end-to-end against their real Fastmail account (12-UAT.md Test 5). DB evidence: setup_complete=true, credential me@lucasberger.ca stored; 'Setup complete' terminal shown; /setup locks (Test 6)."
---

# Phase 12: Initial Setup Wizard — Re-Verification Report

**Phase Goal:** On first run (no admin/credentials configured), the operator is guided through a validated, step-by-step wizard to bootstrap the app — env presence, generated secrets to copy, DB/OIDC/VAPID/app-password validation — instead of hand-editing .env / docker-compose.yml; once complete, the setup endpoints lock.

**Verified:** 2026-06-16T20:20:00Z
**Status:** passed (human verification resolved 2026-06-16 — see 12-UAT.md)
**Re-verification:** Yes — after CR-01 gap closure (commit 0d53249); human end-to-end item satisfied via 12-UAT.md re-verification

---

## Re-Verification Summary

The single BLOCKER from initial verification (CR-01: VAPID validation absent from wizard UI) is now **CLOSED**. Code evidence:

- `apps/pwa/src/routes/SetupPage.tsx` line 30: `validateSetupVapid` imported
- Line 445-448: `validationRows` state type is `Pick<ValidationRowStatus, 'db' | 'oidc' | 'vapid'>` with `vapid: 'idle'`
- Lines 461-479: sequential chain DB → OIDC → VAPID enforced in `configMutation.onSuccess`; `setBothPassed(true)` only called after `validateSetupVapid()` resolves
- Lines 665-673: `<ValidationRow state={validationRows.vapid} ...>` rendered in Step2Config JSX
- Line 691: `{bothPassed && configSaved && <ActionRow ...>}` gates "Continue" on all three passing
- PWA tests: 249/249 pass (commit 0d53249 GREEN run confirmed)
- Both apps typecheck clean

---

## Goal Achievement

### Observable Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Schema migration makes users.oidc_iss/oidc_sub nullable, adds users.claimed, and is applied to the dev DB | VERIFIED | 0002_lethal_millenium_guard.sql has ALTER TABLE MODIFY COLUMN making both nullable + ADD COLUMN claimed boolean NOT NULL; schema.ts confirms notNull() removed from both; _journal.json references the migration |
| 2 | Existing OIDC users are backfilled claimed=true | VERIFIED | Migration SQL: `UPDATE users SET claimed = true WHERE oidc_iss IS NOT NULL;` present |
| 3 | 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 | VERIFIED | Live run: all 4 values produced with correct shapes (64 hex / 64 hex / ~87 base64url / ~43 base64url); no writeFile/appendFile/fetch/db imports; root package.json "generate-secrets" script confirmed |
| 4 | GET /api/setup/status is reachable pre-auth and returns {setupComplete:false/true} | VERIFIED | setupRouter mounted at line 49 in index.ts, BEFORE devAuthBypass() at line 54; /status calls isSetupLocked() and returns { setupComplete: locked }; App.tsx gate confirmed by 249/249 PWA tests |
| 5 | Wizard collects non-secret config (oidc_issuer, oidc_client_id, vapid_public_key, app_external_url) into app_config via POST /api/setup/config | VERIFIED | setup.ts /config handler upserts all 4 keys via onDuplicateKeyUpdate; configSchema validates oidcIssuer as https-only URL |
| 6 | Each input validates before completing: DB connects, VAPID structurally valid (32/65-byte via setVapidDetails), OIDC discovery resolves, Fastmail app password reaches CalDAV PROPFIND | VERIFIED | DB (validateSetupDb), OIDC (validateSetupOidc), and VAPID (validateSetupVapid) now run sequentially in configMutation.onSuccess; bothPassed gates on all three; CalDAV PROPFIND runs via /credential; VAPID ValidationRow renders at line 665 |
| 7 | A second call to any mutating setup endpoint after completion returns 423 (per-call guard) | VERIFIED | isSetupLocked() called as first statement in all 6 mutating handlers; no module-level cache; Pitfall-8 double-complete test in setup.test.ts; 249/249 PWA tests pass |
| 8 | POST /api/setup/complete promotes the local user to admin, sets app_config.setup_complete, after which the guard locks | VERIFIED | /credential inserts user with isAdmin:true + calls validateEncryptAndStoreCredential; /complete upserts setup_complete='true'; isSetupLocked() checks this flag on every call |
| 9 | First OIDC login after setup_complete claims the unclaimed local user (preserving is_admin, no email keying) | VERIFIED | user.ts: reads app_config.setup_complete, then queries WHERE isNull(users.oidcIss) AND eq(users.claimed, false) LIMIT 1; sets claimed:true; shouldBeAdmin gated on flagRow?.value !== 'true'; no email-keyed lookup |

**Score:** 9/9 truths verified

---

### Deferred Items

None.

---

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/src/db/migrations/0002_lethal_millenium_guard.sql` | nullable oidc_iss/oidc_sub + claimed column + backfill UPDATE | VERIFIED | All three DDL changes confirmed; backfill present |
| `scripts/generate-secrets.mjs` | Bootstrap secret generation helper | VERIFIED | Produces 4 correct-shape values; no side-effects |
| `apps/api/src/lib/setupGuard.ts` | isSetupLocked() — real per-call DB evaluation | VERIFIED | Both branches (setup_complete flag + effective-config); no module-level cache |
| `apps/api/src/routes/setup.ts` | 7-route setup router | VERIFIED | All 7 routes present; 6 mutating routes guard-first; noEchoHook on credential; VAPID private key from env only |
| `apps/api/src/auth/user.ts` | upsertUser with first-login-claims branch | VERIFIED | setup_complete read; unclaimed query; no email keying; shouldBeAdmin gated |
| `apps/pwa/src/routes/SetupPage.tsx` | Standalone multi-step wizard with DB, OIDC, and VAPID validation rows | VERIFIED | 1100+ lines; VAPID import at line 30; vapid ValidationRow at line 665; sequential chain lines 461-479; bothPassed gates Continue |
| `apps/pwa/src/api/client.ts` | fetchSetupStatus, postSetupConfig, validateSetupDb/Oidc/Vapid, postSetupCredential, postSetupComplete | VERIFIED | All 6+ functions exported; SetupAlreadyLockedError class; 423 handled; validateSetupVapid at line 654 |
| `apps/pwa/src/App.tsx` | setup-status gate + /setup route + redirect | VERIFIED | setupQuery with staleTime:0; /setup route; SetupPage imported; setupComplete===false triggers Navigate; 249/249 PWA tests |

---

### Key Link Verification

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `apps/api/src/index.ts` | `apps/api/src/routes/setup.ts` | `app.route('/api/setup', setupRouter)` | WIRED | Line 49; before devAuthBypass() at line 54 — mount ordering verified |
| `apps/api/src/routes/setup.ts` | `apps/api/src/lib/setupGuard.ts` | `isSetupLocked()` first in every handler | WIRED | 10 grep hits; 6 with if-locked-return-423 |
| `apps/api/src/routes/setup.ts` | `apps/api/src/broker/credentialSync.ts` | `validateEncryptAndStoreCredential(localUserId, ...)` | WIRED | Present in /credential handler |
| `apps/pwa/src/App.tsx` | `/api/setup/status` | `fetchSetupStatus` in `setupQuery` | WIRED | fetchSetupStatus imported from client.ts; staleTime:0; redirects on setupComplete===false |
| `apps/pwa/src/routes/SetupPage.tsx` | `/api/setup/config, /validate/db, /validate/oidc, /validate/vapid, /credential, /complete` | TanStack mutations | WIRED | All 6 routes called; validateSetupVapid called at line 469 |
| `apps/api/src/auth/middleware.ts` | `app_config (oidc_issuer, oidc_client_id, app_external_url)` | `oidcConfigFallbackMiddleware` | WIRED | Reads 3 keys from app_config when env vars absent |

---

### Data-Flow Trace (Level 4)

| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `SetupPage.tsx` / ValidationRow | `validationRows.db`, `validationRows.oidc`, `validationRows.vapid` | `validateSetupDb()`, `validateSetupOidc()`, `validateSetupVapid()` mutations | Yes — live HTTP calls to backend routes | FLOWING |
| `App.tsx` / setup gate | `setupQuery.data.setupComplete` | `fetchSetupStatus()` → `GET /api/setup/status` → `isSetupLocked()` → DB | Yes — real DB read on every load | FLOWING |

---

### Behavioral Spot-Checks

| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| generate-secrets produces 4 correct values | Carried from initial verification | All 4 patterns matched | PASS |
| /api/setup mounted before /api/* OIDC guard | Carried from initial verification | setup at line 49, devAuthBypass at line 54 | PASS |
| validateSetupVapid imported in SetupPage.tsx | `grep -n "validateSetupVapid" apps/pwa/src/routes/SetupPage.tsx` | Line 30: import; line 469: call site | PASS |
| VAPID sequential chain: vapid only runs after OIDC passes | Lines 466-479 in SetupPage.tsx | Nested try-catch confirms DB → OIDC → VAPID ordering | PASS |
| bothPassed only set after all three pass | Line 471: `setBothPassed(true)` inside innermost try after `validateSetupVapid()` | Confirmed | PASS |
| PWA typecheck clean | `pnpm --filter @familysync/pwa typecheck` | Exit 0, no errors | PASS |
| API typecheck clean | `pnpm --filter @familysync/api typecheck` | Exit 0, no errors | PASS |
| PWA tests 249/249 pass | `pnpm --filter @familysync/pwa test --run` | 249 passed (21 test files) | PASS |
| API tests | `pnpm --filter @familysync/api test --run` | ER_ACCESS_DENIED — MariaDB service not running in dev host shell; not a code defect | SKIP (env) |

---

### Probe Execution

Step 7c: No probe-*.sh scripts declared or present for Phase 12.

---

### Requirements Coverage

| Requirement | Plans | Description | Status | Evidence |
|-------------|-------|-------------|--------|---------|
| SETUP-01 | 02, 03, 04 | Guided wizard bootstrap on first run | SATISFIED | /status pre-auth gate; App.tsx redirect; SetupPage renders standalone; first-login-claims in user.ts; all 4 wizard steps functional |
| SETUP-02 | 02, 04 | Wizard validates each input before completing | SATISFIED | DB + OIDC + VAPID + CalDAV all validated; VAPID ValidationRow rendered; bothPassed gates on all three; gap CR-01 closed in commit 0d53249 |
| SETUP-03 | 01 | Secrets generated for copy-paste; never persisted | SATISFIED | generate-secrets.mjs prints 4 values to stdout only; no file/DB writes; VAPID_PRIVATE_KEY read from env only in /validate/vapid |
| SETUP-04 | 01, 02 | Setup endpoints lock after completion; guard per-call | SATISFIED | isSetupLocked() first in all 6 mutating handlers; no module-level cache; effective-config branch; Pitfall-8 double-complete test present |

---

### Anti-Patterns Found

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| None found | — | No TBD/FIXME/XXX markers in SetupPage.tsx | — | — |

Code-review warnings from 12-REVIEW.md (WR-01..WR-05, IN-01..IN-04) are advisory and do not block the phase goal. They are carried as follow-ups:

| Finding | File | Severity | Goal Impact |
|---------|------|----------|-------------|
| WR-01: orphaned user row on re-select failure | setup.ts | Warning | Edge-case only; does not affect primary flow |
| WR-02: concurrent /credential creates duplicate unclaimed rows | setup.ts | Warning | Low-probability race; self-hosted 2-person app |
| WR-03: misleading test mock hides missing 2nd /credential 423 coverage | setup.test.ts | Warning | Test coverage gap for effective-config branch |
| WR-04: appExternalUrl accepts http:// | setup.ts | Warning | Operator can set non-HTTPS redirect_uri; Authelia will reject at OIDC login |
| WR-05: oidcConfigFallbackMiddleware permanently mutates process.env | middleware.ts | Warning | No update path without container restart; affects test isolation |

---

### Human Verification Required

#### 1. End-to-End Wizard Completion with Real Fastmail Credentials

**Test:** Bring up a fresh instance (no setup_complete, no member_credentials). Navigate to the app root, confirm redirect to /setup. Complete all wizard steps: Welcome → Instance Config (with real OIDC/VAPID values from generate-secrets, real Authelia issuer/client-id) → Credential (with a real Fastmail account email + CalDAV app password). Confirm "Setup complete" terminal screen appears with Sign in link. Re-navigate to /setup and confirm "Already Locked" screen.

**Expected:** Step 2 runs DB → OIDC → VAPID validations sequentially and all three show success before "Continue" appears. Step 3 posts to /api/setup/credential (CalDAV PROPFIND succeeds against Fastmail), then /api/setup/complete returns 200, wizard shows terminal screen. /setup after that shows AlreadyLocked.

**Why human:** Requires a live Fastmail CalDAV PROPFIND against a real account with a real app password scoped to Calendars/CalDAV. Authelia OIDC discovery requires the Authelia instance to be reachable from the API container. No mock can substitute for either live endpoint.

---

## Gaps Summary

No code gaps remain. The single BLOCKER (CR-01, SETUP-02 VAPID validation absent from wizard UI) was resolved in commit 0d53249. All 9 must-have truths are VERIFIED in code. The only remaining item is the human end-to-end test against live Fastmail credentials.

---

_Verified: 2026-06-15T15:35:00Z_
_Verifier: Claude (gsd-verifier) — re-verification after CR-01 gap closure_
