docs(12): create initial-setup-wizard phase plan (4 plans, 3 waves)
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user