Files
familysync/.planning/milestones/v1.1-phases/12-initial-setup-wizard/12-01-PLAN.md
T
2026-06-18 22:21:38 -04:00

17 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
12-initial-setup-wizard 01 execute 1
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
true
SETUP-03
truths artifacts key_links
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
path provides contains
apps/api/src/db/migrations/0002_*.sql nullable oidc_iss/oidc_sub + claimed column + backfill UPDATE claimed
path provides contains
scripts/generate-secrets.mjs Bootstrap secret generation helper generateVAPIDKeys
path provides exports
apps/api/src/lib/setupGuard.ts isSetupLocked stub (real impl in plan 02)
isSetupLocked
path provides exports
apps/api/src/routes/setup.ts setupRouter stub Hono router
setupRouter
path provides
apps/api/tests/routes/setup.test.ts Wave-0 test scaffold for SETUP-01/02/03/04 + 423 guard
from to via pattern
apps/api/src/db/schema.ts apps/api/src/db/migrations/0002_*.sql drizzle-kit generate claimed
from to via pattern
package.json scripts/generate-secrets.mjs generate-secrets npm script 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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_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

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.

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

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