# Phase 12: Initial Setup Wizard — Research

**Researched:** 2026-06-15
**Domain:** First-run bootstrap wizard — pre-auth API surface, DB-backed config, pre-OIDC local user, 423 guard, secret generation helper
**Confidence:** HIGH (all findings grounded in direct codebase inspection)

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

**D-01: Minimal env kernel.** Only the irreducible bootstrap floor stays in env: DB connection, SESSION_SECRET, APP_PASSWORD_ENCRYPTION_KEY, VAPID_PRIVATE_KEY, OIDC client_secret.

**D-02: Non-secret config moves to app_config.** The wizard collects via form fields and writes to app_config: app/external URL, OIDC issuer + client_id, VAPID public key. Runtime consumers read these from app_config rather than env.

**D-03: Env resolution precedence.** Kernel env values come from Docker-provided process.env first, falling back to a .env file.

**D-04: Full kernel defined before first boot.** The operator sets the entire env kernel before the container's first boot. No mid-wizard paste-and-restart.

**D-05: Secret generation → repo helper script.** Generation moves OUT of the wizard to a repo helper script (e.g. npm run generate-secrets) that prints all four values (SESSION_SECRET, APP_PASSWORD_ENCRYPTION_KEY, VAPID public + private) formatted for pasting.

**D-06: Stateless resume.** No persisted step cursor. The env + DB are the progress state.

**D-07: Pre-OIDC local user.** The wizard provisions a local user row (no OIDC identity yet) that holds the first validated Fastmail credential and the pending-admin status. users.oidc_iss / oidc_sub become nullable, plus a claimed/pending marker.

**D-08: First-login-claims.** The first OIDC login after setup_complete claims/merges the single unclaimed local user — populating its oidc_iss/oidc_sub, keeping the credential + is_admin. No email coupling (respects D-10 identity model).

**D-09: Credential stored via setup endpoint reusing the shared helper.** A pre-auth /api/setup/* endpoint stores the local user's credential by calling the shared validateEncryptAndStoreCredential helper internally (no new crypto, no duplicated logic). The deviation from the literal roadmap "reuse admin routes" constraint honors its spirit (shared helper / no new crypto) while satisfying the pre-auth requirement.

**D-10: Defense-in-depth guard.** Each setup-route invocation locks (423) if app_config.setup_complete is true OR the system is already effectively configured (a member_credentials row exists AND VAPID env present) — re-evaluated fresh every call, never cached at startup.

### Claude's Discretion

- Exact reworked step list and per-step field grouping — planner's call.
- Exact /api/setup/* route paths and the app_config key naming for the new non-secret config.
- The migration packaging for the nullable-OIDC-identity + claimed-marker schema change.
- Whether runtime config reads from app_config are cached per-process or read per-request.

### Deferred Ideas (OUT OF SCOPE)

- **Local-auth / no-OIDC operating mode** — its own future phase. Phase 12's pre-OIDC local-user provisioning (D-07) is the deliberate foundation that phase extends — capture now, build later.
</user_constraints>

---

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| SETUP-01 | On first run, operator is guided through a setup wizard to define bootstrap configuration (app URL, OIDC client, session secret, encryption key, VAPID keypair, MariaDB connection, first member's Fastmail app password) | Pre-auth GET /api/setup/status + /setup PWA route + D-02 app_config collect-and-write; replaces hand-editing env |
| SETUP-02 | The wizard validates each input before completing — DB connects, VAPID private key decodes to 32 bytes and pairs with public key, OIDC discovery resolves, Fastmail app password reaches CalDAV (PROPFIND) | Validation routes: /api/setup/validate/db, /api/setup/validate/oidc, /api/setup/validate/vapid; SC-2 detailed in Validation Architecture |
| SETUP-03 | The wizard generates secrets for the operator to copy into env; secrets never written to DB or returned in a persistent response | D-05 deviation: generation moves to npm run generate-secrets repo helper using web-push.generateVAPIDKeys() + crypto.randomBytes(32).toString('hex'); wizard never generates or receives secrets |
| SETUP-04 | Once setup is complete, the setup endpoints are no longer accessible (guard checked on every invocation, not only at startup) | D-10 defense-in-depth guard: 423 on setup_complete OR (member_credentials row exists AND VAPID env present); re-evaluated fresh per call |
</phase_requirements>

---

## Summary

Phase 12 delivers the pre-auth first-run setup wizard for FamilySync — the only part of the app that bypasses the OIDC guard. It rests on Phase 10's completed foundation: the `app_config` table (with `setup_complete`), the `validateEncryptAndStoreCredential` helper, and the `first-login-wins` bootstrap in `auth/user.ts` (which already has a comment naming Phase 12 as its tightening step). All key assets are verified in the codebase and ready to extend.

The wizard introduces four distinct architectural concerns that must be planned as separate work streams: (1) a **minimal-env-kernel + DB-backed-config model** — moving non-secret runtime config from env into `app_config` so the operator's bootstrap shrinks to an irreducible floor of secrets and DB credentials; (2) a **pre-OIDC local user** — a new `users` row with nullable `oidc_iss`/`oidc_sub` and a `claimed` marker, claimed at first login; (3) a **pre-auth `/api/setup/*` route surface** mounted before the OIDC middleware (like `/health`), internally reusing `validateEncryptAndStoreCredential`; and (4) a **defense-in-depth 423 guard** evaluated fresh on every call, never cached.

SETUP-03's "wizard generates secrets" wording is deliberately superseded by D-05: generation lives in a repo helper script (`npm run generate-secrets`) using `web-push.generateVAPIDKeys()` and `crypto.randomBytes(32).toString('hex')`. The wizard neither generates nor receives any secret values. The UI-SPEC Step 2 ("Generate Secrets") is dropped from the wizard flow; Steps 3/4 are revised to collect non-secret config inputs rather than validating pre-placed env values.

**Primary recommendation:** Work in four waves — (Wave 0: schema migration + generate-secrets script) → (Wave 1: pre-auth route surface + 423 guard) → (Wave 2: complete happy path including local user + first-login-claims rework) → (Wave 3: PWA /setup page with revised step flow).

---

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Setup status check (unconfigured?) | API / Backend | — | Pre-auth endpoint; determines if wizard should render; gate lives at the server |
| 423 guard (setup locked) | API / Backend | — | Security boundary; must re-evaluate every call; cannot be trusted to the client |
| Non-secret config collection (OIDC issuer, VAPID public key, app URL) | API / Backend | — | app_config upsert is a backend write; config fields are DB-backed |
| VAPID / OIDC / DB / CalDAV validation | API / Backend | — | All involve live network calls (PROPFIND, OIDC discovery, DB ping) that only the server can safely make |
| Local user creation + credential storage | API / Backend | — | Calls validateEncryptAndStoreCredential; writes to users + member_credentials |
| setup_complete flip | API / Backend | — | Atomically writes to app_config; must be done after all pre-conditions pass |
| First-login-claims (OIDC identity merge) | API / Backend | — | Modifies upsertUser in auth/user.ts; runs on OIDC callback path |
| /setup route rendering (wizard UI) | Browser / Client | Frontend Server (SSR) | React PWA SPA; no SSR in this stack |
| Setup gate redirect (/ → /setup) | Browser / Client | — | App.tsx queries GET /api/setup/status on load; redirects if unconfigured |
| Secret generation helper | CLI / Build | — | npm run generate-secrets; runs at provisioning time, not in app runtime |

---

## Standard Stack

### Core (all already installed — no new packages)

| Library | Version (installed) | Purpose | Why Standard |
|---------|---------------------|---------|--------------|
| hono | 4.12.23 | New /api/setup/* router | Project-standard HTTP framework [VERIFIED: codebase] |
| drizzle-orm | 0.45.2 | Schema migration + app_config reads/writes | Project-standard ORM [VERIFIED: codebase] |
| drizzle-kit | 0.31.10 | generate + migrate for schema change | Project-standard DDL workflow [VERIFIED: codebase] |
| web-push | ^3.6.7 | generateVAPIDKeys() in generate-secrets script | Already installed; generateVAPIDKeys() confirmed present [VERIFIED: codebase] |
| zod + @hono/zod-validator | ^3.25.0 / 0.8.0 | Request validation for /api/setup/* routes | Project-standard; noEchoHook pattern from admin.ts [VERIFIED: codebase] |
| @tanstack/react-query | 5.x | PWA: /api/setup/status query + step mutation calls | Project-standard server state [VERIFIED: codebase] |
| react-router | (installed in apps/pwa) | /setup route addition in App.tsx | Project-standard PWA routing [VERIFIED: codebase] |
| node:crypto | built-in | randomBytes(32).toString('hex') for SESSION_SECRET + APP_PASSWORD_ENCRYPTION_KEY in generate-secrets | Already used in crypto.ts [VERIFIED: codebase] |

### No New Packages Required

Phase 12 reuses the entire existing stack. There are no new npm dependencies. The generate-secrets script uses only Node.js built-ins (`node:crypto`) and the already-installed `web-push`.

**Package Legitimacy Audit:** Not applicable — this phase installs zero new packages.

---

## Architecture Patterns

### System Architecture Diagram

```
Operator (browser, pre-OIDC)
        |
        | GET /api/setup/status (pre-auth, before OIDC guard)
        |         |
        |   returns { setupComplete: false }
        |         |
        v         v
  PWA /setup route (no AppNav/BottomTabBar)
        |
        | Step 1: Welcome
        | Step 2: Config collect (OIDC issuer, client_id, VAPID pubkey, app URL)
        |         POST /api/setup/config  ──→  app_config upserts
        | Step 3: Validate
        |         POST /api/setup/validate/db    ──→  DB ping (mysql2)
        |         POST /api/setup/validate/oidc  ──→  OIDC discovery fetch
        |         POST /api/setup/validate/vapid ──→  base64url decode + 32-byte check
        | Step 4: Credential
        |         POST /api/setup/credential ──→  validateEncryptAndStoreCredential
        |                                              |
        |                                        createFastmailClient → fetchCalendars (PROPFIND)
        |                                        encryptPassword (AES-256-GCM)
        |                                        INSERT users (oidc_iss=NULL, claimed=false, is_admin=true)
        |                                        INSERT member_credentials
        | Step 5: Complete
        |         POST /api/setup/complete ──→  app_config.setup_complete = 'true'
        |
        | [All setup routes: 423 if setup_complete OR (member_credentials row + VAPID env set)]
        |
        v
  Surface 7: "Setup complete" — "Sign in" → /  → OIDC redirect → Authelia
        |
        v
  First OIDC login → upsertUser (first-login-claims: finds unclaimed local user, populates oidc_iss + oidc_sub)
```

### Recommended Project Structure

```
apps/api/src/
├── routes/
│   └── setup.ts              # new: setupRouter (all /api/setup/* handlers)
├── auth/
│   └── user.ts               # modify: upsertUser gains first-login-claims branch
├── db/
│   ├── schema.ts             # modify: users.oidcIss/oidcSub → nullable; add claimed marker
│   └── migrations/
│       └── 0002_*.sql        # drizzle-kit generate output for nullable + claimed
├── lib/
│   └── setupGuard.ts         # new: isSetupLocked() — the 423 re-evaluation per call
scripts/
└── generate-secrets.ts (or .mjs)  # new: npm run generate-secrets
apps/pwa/src/
├── App.tsx                   # modify: add setup gate (fetch /api/setup/status on load)
└── routes/
    └── SetupPage.tsx         # new: the multi-step wizard UI
```

### Pattern 1: Pre-Auth Route Mounting (established, must follow)

**What:** Routes mounted BEFORE `devAuthBypass()` and `oidcAuthMiddleware()` in `apps/api/src/index.ts` are accessible without authentication.

**How it works in the codebase:**
```typescript
// Source: apps/api/src/index.ts (VERIFIED: codebase)
// Current pre-auth surface: /health and /callback
app.route('/health', healthRouter);

// OIDC middleware (only /api/* routes behind it):
app.use('/api/*', devAuthBypass());
if (!devBypassActive) {
  app.use('/api/*', oidcAuthMiddleware());
}

// /api/setup/* must mount BEFORE the /api/* middleware chain.
// The pattern: mount the setup router at /api/setup explicitly before
// the devAuthBypass/oidcAuthMiddleware use() calls, or mount outside /api/*
// entirely. The cleanest approach: mount at app level before the /api/* middleware:
app.route('/api/setup', setupRouter); // BEFORE app.use('/api/*', devAuthBypass())
```

**Critical:** `/api/setup/*` must not be caught by the OIDC middleware. Mount it before `app.use('/api/*', ...)`. [VERIFIED: codebase — same as /health pattern]

### Pattern 2: app_config Key/Value Read (established, follow exactly)

**What:** All non-secret runtime config reads from `app_config` follow the `getHouseholdTimezone` pattern.

```typescript
// Source: apps/api/dist/lib/householdTimezone.js (VERIFIED: codebase)
// Pattern for reading any app_config key:
const [row] = await db
  .select({ value: appConfig.value })
  .from(appConfig)
  .where(eq(appConfig.key, 'household_timezone'))
  .limit(1);
return row?.value ?? fallback;

// app_config upsert pattern (from routes/admin.ts for household_timezone):
// INSERT INTO app_config (key, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value = ?
// In Drizzle: db.insert(appConfig).values({key, value}).onDuplicateKeyUpdate({set: {value}})
```

**New keys Phase 12 writes:**
- `'oidc_issuer'` — OIDC provider issuer URL
- `'oidc_client_id'` — OIDC client ID
- `'vapid_public_key'` — VAPID public key (non-secret; sent to browser for push subscribe)
- `'app_external_url'` — the operator's external URL for the app
- `'setup_complete'` — `'true'` after completion; `null`/absent = not yet set

### Pattern 3: 423 Guard — Fresh Per-Call Evaluation (new, critical)

**What:** Every `/api/setup/*` route must re-evaluate whether setup is already locked before doing any work. Failure to do this allows a second POST after completion to return 200 (Pitfall 8).

```typescript
// Source: CONTEXT.md D-10, ROADMAP.md Pitfall 8 (VERIFIED: planning docs)
// Proposed implementation:

// apps/api/src/lib/setupGuard.ts
import { db } from '../db/client.js';
import { appConfig, memberCredentials } from '../db/schema.js';
import { eq, sql } from 'drizzle-orm';

/** Returns true if the wizard is already locked (setup complete or effectively configured). */
export async function isSetupLocked(): Promise<boolean> {
  // Check 1: explicit setup_complete flag
  const [flagRow] = await db
    .select({ value: appConfig.value })
    .from(appConfig)
    .where(eq(appConfig.key, 'setup_complete'))
    .limit(1);
  if (flagRow?.value === 'true') return true;

  // Check 2: effective configuration — credential exists AND VAPID env is present
  const [credRow] = await db
    .select({ id: memberCredentials.id })
    .from(memberCredentials)
    .limit(1);
  const vapidPresent = !!process.env.VAPID_PRIVATE_KEY && !!process.env.VAPID_PUBLIC_KEY;
  return !!credRow && vapidPresent;
}

// In setupRouter — FIRST statement in every handler:
const locked = await isSetupLocked();
if (locked) return c.json({ error: 'Setup already complete' }, 423);
```

**Test this guard BEFORE the happy path** (ROADMAP Pitfall 8 — a second POST must return 423, not 200).

### Pattern 4: Pre-OIDC Local User + First-Login-Claims

**What:** The wizard provisions a local user row with nullable OIDC fields and a `claimed` marker. The first OIDC login claims it.

**Schema change needed** (Drizzle generate+migrate, NEVER push):
```typescript
// Proposed schema additions to apps/api/src/db/schema.ts
export const users = mysqlTable('users', {
  // ... existing fields unchanged ...
  // Make oidcIss + oidcSub nullable (currently .notNull())
  oidcIss: varchar('oidc_iss', { length: 512 }),       // WAS .notNull() → nullable
  oidcSub: varchar('oidc_sub', { length: 256 }),       // WAS .notNull() → nullable
  // New: claimed marker for the pending-admin local user
  claimed: boolean('claimed').default(false).notNull(), // false = pending; true = merged
});
```

**Migration concern:** `oidc_iss` and `oidc_sub` are currently `NOT NULL` with a `UNIQUE` constraint. Making them nullable and keeping the unique constraint requires care — MariaDB treats NULLs as distinct in unique indexes (multiple NULL rows are allowed), which is correct here (only one unclaimed user expected, but the DB won't reject it). The existing unique constraint `uniq_oidc_identity ON (oidc_iss, oidc_sub)` stays but is safe with nullable columns. [VERIFIED: codebase — current schema.ts + MariaDB NULL-in-unique behavior]

**First-login-claims logic in upsertUser** (the hook named in the code comment):
```typescript
// Source: apps/api/src/auth/user.ts lines 112-122 (VERIFIED: codebase)
// Current comment: "Phase 12 will tighten this to: first user after app_config.setup_complete"

// Revised upsertUser logic (Phase 12):
export async function upsertUser(oidcIss: string, oidcSub: string, displayName?: string | null) {
  // 1. Look up by composite identity key (existing rows with oidc identity)
  const existing = await db.select().from(users)
    .where(and(eq(users.oidcIss, oidcIss), eq(users.oidcSub, oidcSub)))
    .limit(1);
  if (existing[0]) { /* ... update displayName if needed ... */ return existing[0]; }

  // 2. Check setup_complete; if true, look for unclaimed local user (first-login-claims)
  const [flagRow] = await db.select({ value: appConfig.value })
    .from(appConfig).where(eq(appConfig.key, 'setup_complete')).limit(1);
  if (flagRow?.value === 'true') {
    const [unclaimed] = await db.select().from(users)
      .where(and(isNull(users.oidcIss), eq(users.claimed, false)))
      .limit(1);
    if (unclaimed) {
      // Claim: populate oidc_iss + oidc_sub, set claimed=true, update displayName
      await db.update(users).set({
        oidcIss, oidcSub, claimed: true,
        displayName: displayName ?? unclaimed.displayName,
      }).where(eq(users.id, unclaimed.id));
      return { ...unclaimed, oidcIss, oidcSub, claimed: true };
    }
  }

  // 3. No unclaimed user (or setup not complete) — normal new-user insert path
  // ... existing color + isAdmin logic (isAdmin gated: only when setup_complete is false) ...
}
```

**Identity rule preserved:** no email-keyed matching in the claim path. D-10 is not violated. [VERIFIED: codebase — D-10 decision in STATE.md and user.ts comments]

### Pattern 5: validateEncryptAndStoreCredential Reuse in Setup

**What:** The setup credential endpoint calls the same shared helper as admin.ts and me.ts — no new crypto, no duplicated validation logic.

```typescript
// Source: apps/api/src/broker/credentialSync.ts (VERIFIED: codebase)
// Signature:
export async function validateEncryptAndStoreCredential(
  userId: number,       // ← the local user id created by the wizard
  fastmailEmail: string,
  appPassword: string,
  providerType: string,
): Promise<void>

// In the setup credential handler:
// 1. Create the local user row first (or it should already be created in a prior step)
// 2. Call: await validateEncryptAndStoreCredential(localUserId, email, password, 'caldav')
// This does: createFastmailClient → fetchCalendars (PROPFIND) → encryptPassword → DB upsert → initial sync
// CredentialValidationError maps to 400; any other error maps to 503
```

The helper's `userId` parameter must be a real DB row. The wizard must insert the local user BEFORE calling the credential step, so the FK constraint on `member_credentials.user_id` is satisfied. [VERIFIED: codebase — FK defined in schema.ts line 63]

### Pattern 6: generate-secrets Script

**What:** A standalone Node.js script (ESM, in the monorepo root or a scripts/ dir) that prints all four bootstrap secrets in a copy-paste-friendly format.

```typescript
// Proposed: scripts/generate-secrets.ts (or .mjs)
// Source: web-push.generateVAPIDKeys() API confirmed working (VERIFIED: live execution)
import { generateVAPIDKeys } from 'web-push';
import { randomBytes } from 'node:crypto';

const vapid = generateVAPIDKeys();
const sessionSecret = randomBytes(32).toString('hex');
const encKey = randomBytes(32).toString('hex');

console.log(`
# FamilySync Bootstrap Secrets — generated ${new Date().toISOString()}
# Paste these into your docker-compose.yml environment block.
# Keep this output safe — these values cannot be recovered if lost.

SESSION_SECRET=${sessionSecret}
APP_PASSWORD_ENCRYPTION_KEY=${encKey}
VAPID_PUBLIC_KEY=${vapid.publicKey}
VAPID_PRIVATE_KEY=${vapid.privateKey}
`);
```

**VAPID key format confirmed (VERIFIED: live execution):**
- `generateVAPIDKeys()` returns `{ publicKey: string, privateKey: string }` — both base64url, no padding
- `publicKey` decodes to 65 bytes (uncompressed EC P-256 point)
- `privateKey` decodes to 32 bytes (raw P-256 scalar)
- `setVapidDetails()` validates both; the decode+32-byte check in SETUP-02 can reuse the same logic

**Wire into package.json:** Add `"generate-secrets": "tsx scripts/generate-secrets.ts"` (or `"node --input-type=module scripts/generate-secrets.mjs"`) to the root `package.json` scripts. No new dependency needed if using the already-installed `web-push` and `node:crypto`. `tsx` may not be available; using `node --loader ts-node/esm` or compiling to JS first avoids adding a dev dep. The simplest option: a plain `.mjs` file that imports `web-push` from node_modules (avoids TypeScript compilation).

### Pattern 7: VAPID Structural Validation (SC-2)

**What:** The SETUP-02 requirement for "VAPID private key decodes to exactly 32 bytes and pairs with the public key" is satisfied by calling `webpush.setVapidDetails()` in the validation route. This is the same internal check web-push itself performs before signing.

```typescript
// In POST /api/setup/validate/vapid:
import webpush from 'web-push';
const privateKey = process.env.VAPID_PRIVATE_KEY ?? '';
const publicKey = process.env.VAPID_PUBLIC_KEY ?? '';  // or read from app_config if D-02 already written
const subject = process.env.VAPID_SUBJECT ?? '';

try {
  // setVapidDetails calls validatePrivateKey (32-byte check) and validatePublicKey (65-byte check)
  webpush.setVapidDetails(subject || 'mailto:validate@familysync.local', publicKey, privateKey);
  // Keys are structurally valid AND pair correctly (same generation)
  return c.json({ ok: true });
} catch (err) {
  return c.json({ ok: false, error: err instanceof Error ? err.message : 'VAPID validation failed' }, 400);
}
```

**Note:** `setVapidDetails` does NOT make a network call — it only validates structure. It does NOT confirm the keys were generated together (a public key from a different pair would still pass the 32-byte and 65-byte structural checks). The ROADMAP's "pairs with the public key" requirement is therefore interpreted as a structural match (both decode to correct lengths via the same format — base64url, no padding), not a cryptographic proof of pairing. The wizard generated them together and the operator pastes both; a mismatched pair produces an error at push-send time, not at validation time. [VERIFIED: web-push source vapid-helper.js]

### Pattern 8: OIDC Discovery Validation (SC-2)

**What:** Validate OIDC issuer by fetching `{issuer}/.well-known/openid-configuration`.

```typescript
// In POST /api/setup/validate/oidc:
// Read oidc_issuer from app_config (already written in config step) or from form body
const issuer = ...; // from app_config or request body
try {
  const res = await fetch(`${issuer}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(5000) });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const config = await res.json() as { issuer?: string };
  // Optional: verify config.issuer matches submitted issuer
  return c.json({ ok: true });
} catch (err) {
  return c.json({ ok: false, error: 'OIDC discovery failed' }, 400);
}
```

`fetch` is available in Node.js 22 LTS natively. [VERIFIED: Node.js 22 built-in]

### Anti-Patterns to Avoid

- **Mounting /api/setup/* after app.use('/api/*', oidcAuthMiddleware())** — this silently makes setup routes require auth. Must mount before. [VERIFIED: codebase index.ts]
- **Caching the 423 guard result at startup** — the guard must re-query the DB on every call. A startup-evaluated flag can be stale if multiple instances or a manual DB edit changes setup_complete. [CONTEXT.md D-10]
- **Calling drizzle-kit push** — always use generate + migrate on MariaDB. Push has a known false-destructive-diff bug on MariaDB 11. [VERIFIED: STATE.md D-Task5-DDL; REQUIREMENTS.md Out of Scope]
- **Email-keyed identity in first-login-claims** — the claim must match by `claimed=false AND oidcIss IS NULL`. No email field lookup. [VERIFIED: STATE.md identity decision]
- **Logging appPassword or encryptedPassword** in setup credential handler — same rule as admin.ts and me.ts. [VERIFIED: credentialSync.ts security contract]
- **Calling /api/admin/credentials from the pre-auth wizard** — physically impossible (403 because OIDC guard hasn't run). Use the shared helper directly. [CONTEXT.md D-09]
- **Putting VAPID_PRIVATE_KEY or APP_PASSWORD_ENCRYPTION_KEY in app_config** — SC-3 / Pitfall 10 / D-01. These must stay in env. [CONTEXT.md D-01]

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| CalDAV PROPFIND credential validation | Custom HTTP + XML parser | `createFastmailClient` + `fetchCalendars` in `validateEncryptAndStoreCredential` | Already exists, tested, handles auth failure → CredentialValidationError |
| AES-256-GCM encryption | Any new crypto | `encryptPassword` in `broker/crypto.ts` | Existing tested implementation; APP_PASSWORD_ENCRYPTION_KEY key reads correctly |
| VAPID structural validation | Byte-count logic | `webpush.setVapidDetails()` | Runs the library's own internal `validatePrivateKey` (32-byte) + `validatePublicKey` (65-byte) checks |
| OIDC discovery fetch | Custom OpenID client | `fetch('{issuer}/.well-known/openid-configuration')` | Standard endpoint; one fetch call + HTTP status check is sufficient for the setup validation |
| DB connectivity test | Raw mysql2 query | Drizzle: `await db.select({v: sql`1`}).from(appConfig).limit(1)` | Exercises the real pool; minimal surface area |
| app_config upsert | Hand-crafted INSERT/ON DUPLICATE | Drizzle `insert().values().onDuplicateKeyUpdate()` | Established pattern from `routes/admin.ts` (household_timezone) |
| Secret generation | Custom base64url encoding | `webpush.generateVAPIDKeys()` + `crypto.randomBytes(32).toString('hex')` | Library handles EC P-256 key generation and padding correctly |

**Key insight:** Phase 12 assembles existing parts — it adds almost no new logic. The security-sensitive operations (encrypt, validate, store) are already tested and must not be duplicated.

---

## Requirements Deviation Reconciliation

This section explicitly addresses the flagged deviations from SETUP-03 and the ROADMAP constraint.

### Deviation 1: SETUP-03 "The wizard generates secrets"

**Requirement wording:** "The wizard generates secrets (session secret, encryption key, VAPID keypair) for the operator to copy into env; secrets are never written to the database or returned in a response body."

**Chosen model (D-05):** Generation moves OUT of the wizard to a `npm run generate-secrets` repo helper script. The wizard never sees or generates secrets.

**How SETUP-03 intent is satisfied:**
- The helper script generates all four values using the same `web-push` library that the app uses, ensuring VAPID format compatibility.
- Secrets are never written to the DB or returned in a persistent response (they are printed once to stdout and discarded).
- The script's output is formatted for direct pasting into docker-compose.yml environment blocks.
- SETUP-03's "for the operator to copy into env" is literally satisfied — the helper prints values the operator copies. The _medium_ changes (pre-boot instead of in-wizard), but the outcome and security properties are the same.

**UI-SPEC Step 2 impact:** The "Generated Secrets" step (wizard Step 2 with four Secret Blocks and acknowledgment checkboxes) is DROPPED. The revised wizard steps are: Welcome → Config Collect → Validate → Credential → Complete (exact naming is Claude's discretion per CONTEXT.md).

### Deviation 2: ROADMAP "do NOT create /api/setup/credentials — reuse the Phase 10 admin routes"

**Roadmap literal constraint:** "do NOT create `/api/setup/credentials` — reuse the Phase 10 admin routes"

**Reality:** A pre-auth wizard physically cannot call `/api/admin/credentials` — the OIDC guard would reject the request with 302 before the handler runs.

**Chosen model (D-09):** Create a `/api/setup/credential` (pre-auth) endpoint that internally calls `validateEncryptAndStoreCredential(localUserId, ...)` — the same shared helper used by both admin and self-service paths.

**How the constraint's spirit is honored:**
- Zero new crypto code (reuses `encryptPassword` from `broker/crypto.ts` unchanged via the shared helper).
- Zero duplicated validation logic (reuses `createFastmailClient` + `fetchCalendars` via the shared helper).
- The constraint was about preventing a second, divergent credential-storage path — that is preserved. The helper is the single source of truth; the setup endpoint just calls it.

---

## Env Kernel vs DB Config Split

### The Irreducible Env Floor (D-01) — CANNOT go in app_config

| Env Var | Why it stays in env |
|---------|---------------------|
| `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` | Chicken-and-egg: needed to reach the DB where app_config lives |
| `APP_PASSWORD_ENCRYPTION_KEY` | Storing the key beside its ciphertext defeats AES-256-GCM (SC-3 / Pitfall 10) |
| `VAPID_PRIVATE_KEY` | Must never enter the DB (SC-3); signs push requests server-side only |
| `SESSION_SECRET` (OIDC_AUTH_SECRET) | Used to sign the OIDC session JWT cookie; needed before any OIDC flow can complete |
| `OIDC_CLIENT_SECRET` | Secrets by definition; protocol requires it as a confidential value |

### Non-Secret Config (D-02) — MOVES to app_config (written by the wizard)

| app_config Key | Description | Runtime Consumer |
|----------------|-------------|-----------------|
| `'oidc_issuer'` | Authelia issuer URL | `auth/middleware.ts` boot config (must be refactored to read from app_config) |
| `'oidc_client_id'` | OIDC client ID | `auth/middleware.ts` boot config |
| `'vapid_public_key'` | VAPID public key (non-secret) | Push routes (send to browser); PWA (subscribe) |
| `'app_external_url'` | External URL for OIDC redirect_uri and OIDC_AUTH_EXTERNAL_URL | `auth/middleware.ts` boot config |
| `'setup_complete'` | Wizard completion flag | 423 guard + first-login-claims gate in `upsertUser` |
| `'household_timezone'` | Timezone (already in app_config, written by Phase 10 admin UI) | `lib/householdTimezone.ts` — already reading from app_config |

**Critical implication for auth middleware:** `@hono/oidc-auth` currently reads `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_AUTH_EXTERNAL_URL` from env at middleware initialization time. If these move to app_config, the middleware initialization must be deferred until after app_config is populated (i.e., after setup is complete), or the middleware reads app_config on first request. **This is the trickiest integration point in Phase 12** and must be planned explicitly. One clean approach: in `index.ts`, check if setup is complete before mounting oidcAuthMiddleware; if not, mount a "redirect to /setup" fallback for /api/* routes instead. The planner must decide the exact deferral/boot pattern. [ASSUMED — exact oidcAuthMiddleware deferral strategy not yet designed; the CONTEXT.md does not specify it]

**Env precedence (D-03):** Docker-provided `process.env` → `.env` file. Standard dotenv behavior: `process.env` values are NOT overwritten by dotenv if already set. This is the default behavior of the `dotenv` package. FamilySync already reads from `process.env` directly (no explicit dotenv call seen in source) — if env vars come from Docker's `environment:` block, they are already in process.env. A `.env` file would require an explicit `dotenv.config()` call for fallback. **The planner must verify whether dotenv is currently called and where the .env fallback wiring lives.** [ASSUMED for .env fallback mechanism — not seen in source files reviewed]

---

## Common Pitfalls

### Pitfall 1: /api/setup/* Mounted After OIDC Guard
**What goes wrong:** Routes catch a 302 redirect to Authelia before any handler runs.
**Why it happens:** `app.use('/api/*', oidcAuthMiddleware())` applies to all /api/* including /api/setup/*.
**How to avoid:** Mount `app.route('/api/setup', setupRouter)` before `app.use('/api/*', devAuthBypass())`. [VERIFIED: index.ts mounting order]
**Warning signs:** `GET /api/setup/status` returns 302; network tab shows Authelia redirect.

### Pitfall 2: 423 Guard Evaluated Once at Startup
**What goes wrong:** A second POST after completion returns 200 instead of 423.
**Why it happens:** Startup evaluation caches a false "not locked" state before setup completes.
**How to avoid:** `isSetupLocked()` must be called at the top of EVERY setup handler, reading from DB fresh each time. Never hoist to a module-level variable.
**Warning signs:** Vitest test: `POST /api/setup/complete` twice; second call returns 200.

### Pitfall 3: drizzle-kit push on Nullable Column Migration
**What goes wrong:** Drizzle-kit push on MariaDB misreads metadata and schedules a destructive operation.
**Why it happens:** Known MariaDB mysql dialect bug (STATE.md D-Task5-DDL).
**How to avoid:** `drizzle-kit generate` to emit SQL, review the migration file, then `drizzle-kit migrate`. NEVER push.
**Warning signs:** `drizzle-kit push` output mentions DROP or truncate on existing tables.

### Pitfall 4: First-Login-Claims Matching by Email
**What goes wrong:** Email claim from Authelia matches the wrong user or creates a coupling.
**Why it happens:** Shortcut to avoid a nullable-field query.
**How to avoid:** The claim query is `WHERE oidc_iss IS NULL AND claimed = false LIMIT 1`. No email field. [VERIFIED: STATE.md identity decision]
**Warning signs:** `upsertUser` reading `claims.email` to find the local user.

### Pitfall 5: FK Violation on member_credentials Insert
**What goes wrong:** `validateEncryptAndStoreCredential(localUserId, ...)` fails with FK constraint error.
**Why it happens:** The local user row was not inserted before calling the credential helper.
**How to avoid:** The setup credential step must first ensure a local user row exists (created in the wizard flow), then pass that row's id. The credential helper assumes the user row pre-exists (FK on member_credentials.user_id references users.id). [VERIFIED: schema.ts line 63]
**Warning signs:** MySQL error code 1452 (foreign key constraint failure).

### Pitfall 6: VAPID_PRIVATE_KEY or APP_PASSWORD_ENCRYPTION_KEY Written to app_config
**What goes wrong:** Encryption key stored beside its ciphertext; private key exposed in DB.
**Why it happens:** Confusion between the non-secret config (goes to app_config) and the secret floor (stays in env).
**How to avoid:** The D-01 table above is authoritative. These env vars are kernel-only; the wizard never reads, writes, or returns them.
**Warning signs:** Any `INSERT INTO app_config WHERE key IN ('vapid_private_key', 'app_password_encryption_key')`.

### Pitfall 7: App Password Echoed in 400 Response
**What goes wrong:** Zod validation error leaks the submitted password in `issues[].received`.
**Why it happens:** Default zod-validator error response includes `received` field.
**How to avoid:** Use `noEchoHook` pattern from admin.ts — return `{ error: 'Invalid request' }` 400, no Zod details. [VERIFIED: admin.ts noEchoHook]
**Warning signs:** Network response body contains `"received"` or the credential value.

### Pitfall 8: oidcAuthMiddleware Boot-Time OIDC Config Reads
**What goes wrong:** The app crashes at boot (before setup is complete) because OIDC_ISSUER / OIDC_CLIENT_ID are not in env.
**Why it happens:** If these values move to app_config (D-02), env won't have them at boot time for a fresh instance.
**How to avoid:** The planner must choose one of: (a) keep OIDC_ISSUER + OIDC_CLIENT_ID as optional env with app_config override (env OR app_config at boot), or (b) defer oidcAuthMiddleware mounting until after setup_complete is confirmed, or (c) make the middleware lazy-read config on first request. This requires explicit planning before implementation.
**Warning signs:** Crash at startup with "Cannot read OIDC_ISSUER" on a fresh instance.

### Pitfall 9: Unique Constraint on oidc_iss/oidc_sub with NULL Values
**What goes wrong:** Migration that changes `NOT NULL` to nullable fails because the existing unique index definition changes semantics.
**Why it happens:** Some MariaDB versions reject NULLs in a unique index defined as NOT NULL at schema creation time.
**How to avoid:** The migration must: (1) ALTER COLUMN oidc_iss/oidc_sub to allow NULL, (2) possibly DROP and re-CREATE the unique constraint. Drizzle-kit generate will produce correct SQL; review it before applying.
**Warning signs:** `drizzle-kit generate` output includes DROP CONSTRAINT before ADD CONSTRAINT on the unique index.

---

## Runtime State Inventory

This is a migration phase in the sense that the schema changes (nullable fields + claimed marker). However, it is not a rename/refactor.

| Category | Items Found | Action Required |
|----------|-------------|-----------------|
| Stored data | `app_config`: `household_timezone` and `setup_complete` keys already exist (Phase 10). Existing users table has `oidc_iss NOT NULL`, `oidc_sub NOT NULL`. | Schema migration: make oidc_iss/oidc_sub nullable, add `claimed` column. Existing rows are real OIDC users — they get `claimed=true` in the migration (they already have oidc identity, so they are "effectively claimed"). |
| Live service config | None beyond MariaDB schema. | None |
| OS-registered state | None | None |
| Secrets/env vars | VAPID_PRIVATE_KEY, APP_PASSWORD_ENCRYPTION_KEY, SESSION_SECRET — remain in env. OIDC_ISSUER, OIDC_CLIENT_ID may be refactored to app_config. | If moving to app_config: add backwards-compat env fallback in consumers before removing from env. |
| Build artifacts | None | None |

**Migration backfill for `claimed` column:** Existing user rows (real OIDC users with oidc_iss/oidc_sub) should have `claimed = true` set in the migration so the first-login-claims logic only ever finds rows where `claimed = false AND oidc_iss IS NULL`. SQL: `UPDATE users SET claimed = true WHERE oidc_iss IS NOT NULL`.

---

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | Vitest (API integration tests in apps/api/tests/) |
| Config file | apps/api/vitest.config.ts |
| Quick run command | `pnpm --filter @familysync/api test` |
| Full suite command | `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test` |
| E2E command | `pnpm test:e2e` (Playwright — apps/pwa) |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | Notes |
|--------|----------|-----------|-------------------|-------|
| SETUP-01 | GET /api/setup/status returns { setupComplete: false } on fresh instance | API integration | `pnpm --filter @familysync/api test -- setup` | Test file: apps/api/tests/setup.test.ts (Wave 0 gap) |
| SETUP-01 | GET /api/setup/status returns { setupComplete: true } after completion | API integration | `pnpm --filter @familysync/api test -- setup` | Same file |
| SETUP-01 | /setup route renders wizard when unconfigured (no AppNav/BottomTabBar) | Playwright smoke | `pnpm test:e2e` | Requires DEV_AUTH_BYPASS bypass for the setup route (it's pre-auth; bypass is irrelevant here — setup is accessible without auth) |
| SETUP-02 | POST /api/setup/validate/vapid returns 200 for valid keys, 400 for truncated key | Unit | `pnpm --filter @familysync/api test -- setup.validate` | Can test without real VAPID env — mock process.env |
| SETUP-02 | POST /api/setup/validate/db returns 200 when DB reachable | API integration | `pnpm --filter @familysync/api test -- setup.validate` | Requires MariaDB (existing test infra) |
| SETUP-02 | POST /api/setup/validate/oidc returns 400 for unreachable issuer | Unit (fetch mock) | `pnpm --filter @familysync/api test -- setup.validate` | Mock fetch |
| SETUP-02 | POST /api/setup/credential: CalDAV PROPFIND failure → 400 | Unit (mock client) | `pnpm --filter @familysync/api test -- setup.credential` | Same mock pattern as admin.test.ts |
| SETUP-03 | generate-secrets script outputs SESSION_SECRET (64 hex chars), APP_PASSWORD_ENCRYPTION_KEY (64 hex chars), VAPID_PUBLIC_KEY (base64url 87 chars), VAPID_PRIVATE_KEY (base64url 43 chars) | Unit (script invocation) | `node scripts/generate-secrets.mjs 2>&1` | Smoke test via Bash in test; parse output |
| SETUP-04 | POST /api/setup/complete twice → first 200, second 423 | API integration | `pnpm --filter @familysync/api test -- setup.guard` | The critical Pitfall 8 regression |
| SETUP-04 | POST any /api/setup/* route when member_credentials exists + VAPID env set → 423 | API integration | `pnpm --filter @familysync/api test -- setup.guard` | Tests D-10 "effective configuration" branch |
| D-08 | First OIDC login after setup_complete → claims unclaimed local user, is_admin preserved | API integration | `pnpm --filter @familysync/api test -- user.upsert` | Mock upsertUser with setup_complete = 'true' in app_config |

### Sampling Rate
- **Per task commit:** `pnpm --filter @familysync/api test`
- **Per wave merge:** `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test`
- **Phase gate:** Full suite + `pnpm test:e2e` green before `/gsd-verify-work`

### Wave 0 Gaps (files that must be created before implementation)

- [ ] `apps/api/tests/setup.test.ts` — covers SETUP-01/02/03/04, the 423 guard (Pitfall 8), and first-login-claims (D-08)
- [ ] `apps/api/src/routes/setup.ts` — stub (empty Hono router) so imports don't break Wave 1 tests
- [ ] `apps/api/src/lib/setupGuard.ts` — stub for the 423 guard

---

## Security Domain

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | Yes | 423 guard prevents replay; local user + first-login-claims; no password auth in wizard |
| V3 Session Management | No | Setup routes are stateless (no session cookie created/required) |
| V4 Access Control | Yes | 423 guard (every call); no admin routes callable pre-auth |
| V5 Input Validation | Yes | zod + noEchoHook on credential endpoint; email/password max length enforced |
| V6 Cryptography | Yes | AES-256-GCM via existing encryptPassword; VAPID private key stays in env; NEVER in DB |

### Known Threat Patterns for this Phase

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Setup endpoint replay after completion | Tampering | D-10 423 guard, re-evaluated per call, never cached |
| App password echoed in validation error | Information Disclosure | noEchoHook (same as admin.ts) — Zod error details never returned |
| VAPID_PRIVATE_KEY or APP_PASSWORD_ENCRYPTION_KEY written to DB | Information Disclosure | D-01 env floor; no app_config key for these values |
| Race: two concurrent setup completions | Tampering | POST /api/setup/complete must be idempotent (second call returns 423 immediately after first sets setup_complete) |
| First-login-claims claiming wrong user | Spoofing | Claim query: `WHERE oidc_iss IS NULL AND claimed = false LIMIT 1` — in a 2-person household there is exactly one pending user; the threat model notes OIDC reach requires household membership |
| OIDC issuer SSRF via config step | Tampering | Validate the issuer URL format (must be https://); the discovery fetch is server-side |

**Security constraint inherited from CONTEXT.md D-01/SC-3:** `APP_PASSWORD_ENCRYPTION_KEY` and `VAPID_PRIVATE_KEY` must NEVER appear in the database, in any API response, or in any log line. The wizard validates VAPID keys structurally (via `setVapidDetails`) and validates the encryption key functionally (the fact that `encryptPassword` doesn't throw proves the key is the correct length), but neither value is returned to the client.

---

## Project Constraints (from CLAUDE.md)

| Directive | Impact on Phase 12 |
|-----------|-------------------|
| MariaDB only (no PostgreSQL) | All schema migrations use mysql2 dialect in drizzle-kit; no Postgres-specific DDL |
| Drizzle ORM | Schema changes via drizzle-kit generate + migrate (never push) |
| Hono 4.12.23 | setupRouter is a `new Hono()` mounted before the OIDC guard |
| React 19 PWA (no React Native) | SetupPage.tsx is a React component in apps/pwa/src/routes/ |
| No dangerouslySetInnerHTML | UI-SPEC's security contract — all copy is plain-text JSX children |
| playwright-cli skill | Wizard UI must be validated with playwright-cli (desktop Chromium); iOS-specific behaviors remain human checkpoints |
| Authelia OIDC (authorization_code + PKCE, client_secret_basic) | First-login-claims must not break the existing OIDC callback path |
| Identity: oidc_iss + oidc_sub, never email | first-login-claims uses `WHERE oidc_iss IS NULL AND claimed = false`, no email join |
| No email features | Out of scope |

---

## UI-SPEC Revision Requirements

The planner MUST revise the `12-UI-SPEC.md` Wizard Steps and Interaction Contract sections before finalizing plans. The design system, tokens, surfaces, copywriting, and a11y contract still hold. What changes:

| UI-SPEC Section | Required Revision |
|-----------------|-------------------|
| Step 2: Generate Secrets | **DROP this step entirely.** Generation is pre-boot (D-05). No Secret Blocks, no checkboxes, no `POST /api/setup/generate`. The 5-step indicator becomes 4 steps (or re-numbered). |
| Step 3: Database | **Remove "No operator input fields" assumption** if config-collect is a separate step. If config-collect is Step 2 (new), Step 3 is the validation-only DB check — this section stays largely the same. |
| Step 4: OIDC & Push | **Add input fields.** This step now collects OIDC issuer + client_id and VAPID public key (non-secret inputs) AND validates them. The "description: verify that OIDC_ISSUER is in place" assumption is superseded — the wizard writes these values first, then validates. Ref: D-02. |
| Step 4 VAPID copy | Revise: VAPID public key is now an **input field** (entered by the operator from the generate-secrets output); VAPID private key stays in env (structural validation only — read from process.env.VAPID_PRIVATE_KEY). |
| Step labels | Revised set (planner's call): Welcome / Config / Validate / Credential / Complete |
| Routing gate | Step 1 description copy references "You'll need your OIDC client credentials and Fastmail app password" — remove "copy of docker-compose.yml to paste generated secrets into" reference since secrets are pre-boot. |

**What stays unchanged in UI-SPEC:** All design tokens, spacing scale, typography, color palette, surface definitions (1–3, 5–8), a11y contract, responsive behavior, security display rules, copywriting for the non-secrets steps, the Credential step (Step 5 → Step 4 if secrets step removed), and the Terminal/Locked screens.

---

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| MariaDB | All /api/setup/validate/db + /api/setup/credential + /api/setup/complete routes | ✓ (existing dev stack) | 10.x/11.x (docker) | None — DB is the kernel floor |
| Node.js 22 LTS | generate-secrets script, API | ✓ | 22 LTS | None |
| web-push (generateVAPIDKeys) | generate-secrets script | ✓ | ^3.6.7 in apps/api/node_modules | None needed |
| Authelia OIDC | /api/setup/validate/oidc (live) | Deployment-dependent | — | Test against a local Authelia or mock the discovery endpoint in tests |
| Fastmail CalDAV | /api/setup/credential (PROPFIND) | Deployment-dependent | — | Tests use the existing mock (vi.mock for createFastmailClient) as in admin tests |
| playwright-cli | PWA /setup route smoke test | ✓ | /usr/local/bin/playwright-cli | N/A |

**Missing dependencies with no fallback:** None that block development — Authelia and Fastmail are only needed for live integration; unit/integration tests mock them (same pattern as existing admin.test.ts).

---

## Open Questions (RESOLVED)

> All four assumptions are addressed by the Phase 12 plans. **A1** (`generate-secrets` location/toolchain) and **A3** (app_config consumer scope) are pre-resolved in Plan 01 Task 2 (plain `.mjs` at `scripts/generate-secrets.mjs`) and PATTERNS.md (`app_config` key/value read pattern). **A2** (oidcAuthMiddleware config-read timing) is confirmed during execution in **Plan 02 Task 3** via explicit acceptance criteria: implement the env-OR-app_config fallback (Recommendation (a)), OR — if `@hono/oidc-auth` is found to read config at import time — apply option (b)/(c) and document the deviation in the SUMMARY. **A4** (claimed-column backfill) is specified by RESEARCH §Runtime State Inventory + Plan 01 Task 1. No question is deferred beyond execution.

1. **oidcAuthMiddleware boot-time config reads**
   - What we know: `@hono/oidc-auth` reads OIDC_ISSUER, OIDC_CLIENT_ID, and OIDC_AUTH_EXTERNAL_URL at initialization. If these move to app_config (D-02), a fresh unconfigured instance has no env values.
   - What's unclear: Does the planner want to (a) keep these as optional env with app_config override, (b) defer middleware mounting until setup_complete, or (c) make the middleware lazy?
   - Recommendation: Option (a) is the safest first pass — keep env as a fallback for boot-before-setup, then app_config becomes the primary source once written. This avoids a crash on fresh boot and does not require middleware deferral.

2. **generate-secrets script location and toolchain**
   - What we know: The root package.json has only devDependencies (no `tsx`). `apps/api` has TypeScript but the script needs to run before the API is built.
   - What's unclear: Should the script be a plain `.mjs` (no compilation needed), a compiled TypeScript file, or added to apps/api/src and run via `pnpm --filter @familysync/api` with a tsx/node script?
   - Recommendation: A plain `.mjs` at `scripts/generate-secrets.mjs` in the monorepo root, importing `web-push` from `apps/api/node_modules/web-push`. Add to root `package.json`: `"generate-secrets": "node scripts/generate-secrets.mjs"`. No new toolchain needed.

3. **App_config consumer refactoring scope**
   - What we know: OIDC_ISSUER, OIDC_CLIENT_ID, and OIDC_AUTH_EXTERNAL_URL are currently env-only. The householdTimezone module already reads from app_config. auth/middleware.ts exports from @hono/oidc-auth directly (no config logic visible in the file).
   - What's unclear: How deep does @hono/oidc-auth's config reading go? Is it read at import time or call time?
   - Recommendation: Research this at planning time by reading @hono/oidc-auth source. If config is read at middleware initialization (call to oidcAuthMiddleware()), a lazy-initialization pattern (initialize on first request, read app_config at that point) may be needed.

---

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | The .env fallback for kernel env vars requires an explicit dotenv.config() call — FamilySync may not currently call this | Env Kernel vs DB Config Split (D-03) | If dotenv is already wired, the fallback already works. If not, planner must add dotenv.config() call or document that .env fallback is Docker-only. |
| A2 | @hono/oidc-auth reads OIDC_ISSUER etc. at oidcAuthMiddleware() call time (not import time) | Pitfall 8 / Open Question 1 | If read at import time, every import of middleware.ts on a fresh instance would fail. If call-time, lazy initialization is possible. |
| A3 | VAPID "pairs with the public key" in SETUP-02 means structural validation only (both decode correctly), not cryptographic proof | Pattern 7 (VAPID validation) | If exact key-pair proof is required, a full ECDH derivation check is needed — more complex. The ROADMAP wording "pairs with the public key" is ambiguous; current interpretation is structural. |
| A4 | The migration backfill `UPDATE users SET claimed = true WHERE oidc_iss IS NOT NULL` correctly handles existing prod users | Runtime State Inventory | If prod has no users yet (fresh post-Phase-10 deploy), this is a no-op and safe. If somehow users exist with null oidc_iss for other reasons, they would stay unclaimed — unlikely given current schema. |

**If this table is empty:** All claims in this research were verified or cited. The four assumptions above are low-risk for a 2-person household app; the planner should confirm A1 and A2 by reading the relevant source/docs before finalizing Wave 1 tasks.

---

## Sources

### Primary (HIGH confidence — verified in codebase)
- `apps/api/src/auth/user.ts` — upsertUser implementation; first-login-wins comment naming Phase 12
- `apps/api/src/db/schema.ts` — current users/app_config/member_credentials schema
- `apps/api/src/routes/admin.ts` — validateEncryptAndStoreCredential usage + noEchoHook pattern
- `apps/api/src/broker/credentialSync.ts` — shared helper: signature, CredentialValidationError, flow
- `apps/api/src/broker/crypto.ts` — encryptPassword/decryptPassword (AES-256-GCM)
- `apps/api/src/broker/client.ts` — createFastmailClient
- `apps/api/src/index.ts` — route mounting order (pre-auth vs OIDC-guarded)
- `apps/api/src/lib/requireAdmin.ts` — requireAdmin pattern
- `apps/api/src/routes/me.ts` — noEchoHook on self-service credential; resolveUserId
- `apps/api/dist/lib/householdTimezone.js` — app_config read pattern
- `apps/api/src/db/migrations/0001_famous_mad_thinker.sql` — Phase 10 migration (app_config creation confirmed)
- `apps/pwa/src/App.tsx` — existing routing structure; SetupBanner/CredentialSheet usage
- `apps/pwa/src/components/SetupBanner.tsx` — self-service credential UX
- `apps/api/node_modules/web-push/src/vapid-helper.js` + live execution — generateVAPIDKeys() format, validatePrivateKey (32-byte), validatePublicKey (65-byte)
- `.planning/phases/12-initial-setup-wizard/12-CONTEXT.md` — locked decisions
- `.planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` — design system (valid) + steps (partially superseded)
- `.planning/phases/10-admin-role-settings/10-CONTEXT.md` — Phase 10 decisions this phase builds on
- `.planning/REQUIREMENTS.md` — SETUP-01..04 full wording
- `.planning/ROADMAP.md` — Phase 12 success criteria, pitfalls, constraints
- `.planning/STATE.md` — D-Task5-DDL (drizzle-kit push unsafe), D-10 identity model

### Secondary (MEDIUM confidence)
- Node.js 22 LTS built-in `fetch` — used for OIDC discovery validation [ASSUMED — confirmed by Node.js 22 docs]

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all packages verified in codebase; zero new packages needed
- Architecture: HIGH — grounded in direct source file inspection; patterns are established in the codebase
- Pitfalls: HIGH — drawn from ROADMAP.md explicitly-named pitfalls + codebase review
- Schema migration: HIGH — current schema.ts read directly; migration path clear
- oidcAuthMiddleware config-read timing: ASSUMED (A2) — requires @hono/oidc-auth source review to confirm

**Research date:** 2026-06-15
**Valid until:** 2026-07-15 (stable stack; Phase 12 is the only consumer of these patterns in this codebase)
