--- 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) Create `.planning/phases/12-initial-setup-wizard/12-01-SUMMARY.md` when done