271 lines
17 KiB
Markdown
271 lines
17 KiB
Markdown
---
|
|
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>
|