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

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

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/12-initial-setup-wizard/12-CONTEXT.md
@.planning/phases/12-initial-setup-wizard/12-RESEARCH.md
@.planning/phases/12-initial-setup-wizard/12-PATTERNS.md
@apps/api/src/db/schema.ts
@apps/api/src/db/migrations/0001_famous_mad_thinker.sql
</context>

## 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)

<tasks>

<task type="execute">
  <name>Task 1: [BLOCKING] Schema change + generate+migrate (nullable OIDC identity, claimed marker, backfill)</name>
  <files>apps/api/src/db/schema.ts, apps/api/src/db/migrations/0002_*.sql, apps/api/src/db/migrations/meta/_journal.json</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    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.
  </action>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <verify>
    <automated>cd apps/api && pnpm exec drizzle-kit migrate && pnpm typecheck</automated>
  </verify>
  <done>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.</done>
</task>

<task type="execute">
  <name>Task 2: generate-secrets repo helper (SETUP-03 / D-05)</name>
  <files>scripts/generate-secrets.mjs, package.json</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    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"`.
  </action>
  <acceptance_criteria>
    - 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)
  </acceptance_criteria>
  <verify>
    <automated>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,}$"</automated>
  </verify>
  <done>`node scripts/generate-secrets.mjs` prints all four correctly-shaped values; nothing is written to disk or DB; root package.json wires the script.</done>
</task>

<task type="execute">
  <name>Task 3: Stub setupGuard.ts + setup.ts router (Wave-0 import targets)</name>
  <files>apps/api/src/lib/setupGuard.ts, apps/api/src/routes/setup.ts</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    Create apps/api/src/lib/setupGuard.ts exporting an async `isSetupLocked(): Promise<boolean>`. 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).
  </action>
  <acceptance_criteria>
    - 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`)
  </acceptance_criteria>
  <verify>
    <automated>cd apps/api && pnpm typecheck</automated>
  </verify>
  <done>setupGuard.ts exports isSetupLocked (stub returns false); setup.ts exports an empty setupRouter; typecheck green.</done>
</task>

<task type="execute">
  <name>Task 4: Wave-0 test scaffolds (setup.test.ts + user.test.ts claim placeholder)</name>
  <files>apps/api/tests/routes/setup.test.ts, apps/api/tests/auth/user.test.ts</files>
  <read_first>
    - 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
  </read_first>
  <action>
    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.
  </action>
  <acceptance_criteria>
    - 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)
  </acceptance_criteria>
  <verify>
    <automated>cd apps/api && pnpm test -- setup 2>&1 | grep -Eq "Tests|todo|passed|failed"</automated>
  </verify>
  <done>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.</done>
</task>

</tasks>

<threat_model>
## 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 |
</threat_model>

<verification>
- `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)
</verification>

<success_criteria>
- 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)
</success_criteria>

<output>
Create `.planning/phases/12-initial-setup-wizard/12-01-SUMMARY.md` when done
</output>
