Files
familysync/.planning/phases/10-admin-role-settings/10-01-PLAN.md
T
Lucas Berger b24fbbfde7 docs(10): create phase plan (4 plans, 4 waves) for admin-role-settings
- 10-01 v1.1 DB foundation migration + dev-bypass admin seed
- 10-02 requireAdmin guard + first-login-wins + /api/me extension (TDD)
- 10-03 adminRouter credentials/shared-calendar + member self-service (TDD)
- 10-04 PWA /admin route + nav gating + CredentialSheet + SetupBanner
- filled 10-VALIDATION Per-Task Verification Map (Nyquist compliant)
- finalized ROADMAP Phase 10 plan list
2026-06-13 13:57:51 -04:00

193 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 10-admin-role-settings
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/src/db/schema.ts
- apps/api/src/db/migrations/0001_v1_1_foundation.sql
- apps/api/src/db/migrations/meta/_journal.json
- apps/pwa/e2e/global-setup.ts
autonomous: true
requirements: [ADMIN-01, ADMIN-02, ADMIN-03]
must_haves:
truths:
- "The live dev MariaDB has users.is_admin (boolean, default false), member_credentials.provider_type (varchar, default 'caldav'), member_credentials UNIQUE(user_id), calendar_events.reminder_lead_minutes (int, nullable), and an app_config table"
- "Running db:generate then db:migrate applies the migration with no destructive (DROP/TRUNCATE) statement"
- "The e2e dev-bypass user (id=1) exists in the users table with is_admin=true so requireAdmin admits it"
artifacts:
- path: "apps/api/src/db/schema.ts"
provides: "v1.1 schema: users.isAdmin, memberCredentials.providerType + unique(user_id), calendarEvents.reminderLeadMinutes, appConfig table"
contains: "appConfig"
- path: "apps/api/src/db/migrations/0001_v1_1_foundation.sql"
provides: "generated ALTER/CREATE DDL for the v1.1 bundle"
contains: "is_admin"
- path: "apps/pwa/e2e/global-setup.ts"
provides: "seeds users row id=1 with is_admin=true for the dev-bypass admin UI verification path"
contains: "is_admin"
key_links:
- from: "apps/api/src/db/schema.ts"
to: "apps/api/src/db/migrations/0001_v1_1_foundation.sql"
via: "drizzle-kit generate"
pattern: "is_admin"
- from: "apps/pwa/e2e/global-setup.ts"
to: "users table"
via: "INSERT seed of id=1 is_admin=true"
pattern: "is_admin"
---
<objective>
Ship the v1.1 DB foundation migration that all of Phase 10 (and Phases 11/12 downstream) build on: add `users.is_admin`, `member_credentials.provider_type` (the generic provider discriminator, D-04) + a `UNIQUE(user_id)` constraint (D-05), `calendar_events.reminder_lead_minutes`, and a new `app_config` table — in one drizzle-kit `generate`+`migrate` migration (NEVER `push`). Seed the dev-bypass user (id=1) as an admin (D-01 dev note) so local/e2e admin-UI verification works.
Purpose: Every subsequent Phase 10 plan reads these columns (requireAdmin reads `is_admin`, the credential routes read `provider_type` and rely on the per-user UNIQUE for upsert, `/api/me` reads `is_admin`). `reminder_lead_minutes` is created-now / consumed by Phase 11; `app_config.setup_complete` is created-now / consumed by Phase 12. This plan is the head of the wave chain.
Output: Edited `schema.ts`, a generated `0001_v1_1_foundation.sql` migration file (committed artifact) + updated `_journal.json`, applied to the live dev DB, and a seeded admin row for the e2e dev-bypass user.
</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/10-admin-role-settings/10-CONTEXT.md
@.planning/phases/10-admin-role-settings/10-RESEARCH.md
@.planning/phases/10-admin-role-settings/10-PATTERNS.md
</context>
<tasks>
<task type="execute">
<name>Task 1: Extend schema.ts with the v1.1 column/table bundle</name>
<files>apps/api/src/db/schema.ts</files>
<read_first>
- apps/api/src/db/schema.ts (the file being modified — read the full file; `users` ~line 34, `memberCredentials` lines 5568, `calendarEvents` ~lines 120130 incl. `allDay` line 127, `calendars.isShared` line 89, `pushSubscriptions` lines 236257 for the single-table pattern, the `unique`/`index` import + usage)
- .planning/phases/10-admin-role-settings/10-PATTERNS.md §`apps/api/src/db/schema.ts` (concrete column excerpts + line numbers: copy `allDay` boolean pattern for `is_admin`, `fastmailEmail` varchar pattern for `provider_type`, `pushSubscriptions` table pattern for `app_config`, `calendars` unique pattern for `uniq_member_credential_user`)
- .planning/phases/10-admin-role-settings/10-RESEARCH.md §Pattern 6 (provider discriminator, D-04) + §Code Examples "Drizzle Upsert Pattern" schema note (UNIQUE(user_id) rationale)
</read_first>
<action>
In apps/api/src/db/schema.ts make exactly four additive changes, matching the existing column idiom (per 10-PATTERNS.md excerpts — do NOT inline new code styles):
1. `users` table: add `isAdmin: boolean('is_admin').default(false).notNull()` (copy the `allDay` boolean idiom).
2. `memberCredentials` table: add `providerType: varchar('provider_type', { length: 64 }).notNull().default('caldav')` — the generic provider discriminator per D-04 (generic provider shape, Fastmail/CalDAV the only implemented provider; default 'caldav' for existing rows; no second provider built here). And add `unique('uniq_member_credential_user').on(t.userId)` to the table's index array (keep the existing `idx_member_credentials_user_id` index) — this enforces one-credential-per-member per D-05 and enables `onDuplicateKeyUpdate` upsert.
3. `calendarEvents` table: add `reminderLeadMinutes: int('reminder_lead_minutes')` (nullable — no `.notNull()`; created-now / consumed by Phase 11).
4. New `appConfig` table (export `const appConfig`), following the single-table `pushSubscriptions` idiom: a single key/value config — `key: varchar('key', { length: 128 }).primaryKey()`, `value: text('value')` (nullable), `updatedAt: timestamp('updated_at').defaultNow().onUpdateNow()`. This holds `setup_complete` (created-now / consumed by Phase 12 — do NOT add setup_complete gating logic here, only the table). Add a `setup_complete` semantics comment so Phase 12 can read/write the `setup_complete` key without a reshape.
Do NOT touch `calendars.is_shared` (already exists, line 89). Do NOT change crypto, encrypted_password, or any existing column.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api exec tsc --noEmit 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- `apps/api/src/db/schema.ts` contains `is_admin`, `provider_type`, `uniq_member_credential_user`, `reminder_lead_minutes`, and an exported `appConfig` table with `key`/`value`/`updated_at`.
- `grep -c "export const appConfig" apps/api/src/db/schema.ts` returns 1.
- `pnpm --filter @familysync/api exec tsc --noEmit` exits 0 (types compile — note this passes WITHOUT the migration, because Drizzle types come from schema.ts; column existence is verified in Task 2).
- `is_admin` uses `.default(false).notNull()`; `provider_type` uses `.notNull().default('caldav')`; `reminder_lead_minutes` is nullable (no `.notNull()`).
</acceptance_criteria>
<done>schema.ts holds all four v1.1 additions, typechecks clean, no existing column altered.</done>
</task>
<task type="execute" gate="blocking">
<name>Task 2: [BLOCKING] Generate + migrate the v1.1 migration against the live dev DB</name>
<files>apps/api/src/db/migrations/0001_v1_1_foundation.sql, apps/api/src/db/migrations/meta/_journal.json</files>
<read_first>
- apps/api/src/db/migrations/0000_baseline.sql (existing migration format — `--> statement-breakpoint` between DDL statements; the format the generated file must follow)
- apps/api/drizzle.config.ts (migration output dir + env-driven DB credentials)
- apps/api/package.json (the `db:generate` / `db:migrate` scripts)
- .planning/phases/10-admin-role-settings/10-RESEARCH.md §Pattern 3 (generate+migrate workflow, exact commands, DB_HOST=127.0.0.1 dev override) + §Pitfall 4 (why never push)
- MEMORY note [[drizzle-mariadb-push-unsafe]] context in 10-CONTEXT.md Claude's Discretion (generate+migrate, never push)
</read_first>
<action>
BLOCKING — this must run AFTER Task 1 (schema.ts complete) and BEFORE any plan that reads the new columns. Bring up the dev MariaDB if not already bound on 3306 (per [[dev-stack-bringup]]: dev compose override exposes 3306). Then:
1. Run `pnpm --filter @familysync/api db:generate` to produce `apps/api/src/db/migrations/0001_v1_1_foundation.sql` (drizzle-kit names it; the actual filename may differ — commit whatever drizzle-kit emits as the next sequential migration) and update `meta/_journal.json`. DO NOT hand-write the SQL.
2. INSPECT the generated SQL: it MUST be only `ALTER TABLE ... ADD COLUMN` / `ADD UNIQUE` / `CREATE TABLE` statements (additive). If it contains any `DROP TABLE`, `DROP COLUMN`, or `TRUNCATE`, STOP — that is the false-destructive-diff trap; do NOT apply it, and do NOT fall back to `db:push`. Re-derive from schema.ts.
3. Apply with `DB_HOST=127.0.0.1` (+ dev DB_USER/DB_PASSWORD/DB_NAME from .env): `cd apps/api && set -a; source ../../.env; set +a; DB_HOST=127.0.0.1 pnpm db:migrate`. NEVER `db:push`.
4. Verify the live columns/table exist via a mysql2 query (not just tsc): assert `is_admin` on `users`, `provider_type` + the unique index on `member_credentials`, `reminder_lead_minutes` on `calendar_events`, and the `app_config` table.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && set -a; source .env 2>/dev/null; set +a; DB_HOST=127.0.0.1 node -e "const m=require('mysql2/promise');(async()=>{const c=await m.createConnection({host:'127.0.0.1',port:Number(process.env.DB_PORT||3306),user:process.env.DB_USER||'familysync',password:process.env.DB_PASSWORD||'',database:process.env.DB_NAME||'familysync'});const[u]=await c.query(\"SHOW COLUMNS FROM users LIKE 'is_admin'\");const[mc]=await c.query(\"SHOW COLUMNS FROM member_credentials LIKE 'provider_type'\");const[ce]=await c.query(\"SHOW COLUMNS FROM calendar_events LIKE 'reminder_lead_minutes'\");const[ac]=await c.query(\"SHOW TABLES LIKE 'app_config'\");if(u.length&&mc.length&&ce.length&&ac.length){console.log('MIGRATION OK');process.exit(0)}console.error('MISSING',{u:u.length,mc:mc.length,ce:ce.length,ac:ac.length});process.exit(1)})().catch(e=>{console.error(e.message);process.exit(1)})"</automated>
</verify>
<acceptance_criteria>
- A new migration `.sql` file exists under `apps/api/src/db/migrations/` (sequential after `0000_baseline.sql`) and `meta/_journal.json` references it.
- The generated SQL contains NO `DROP TABLE`, `DROP COLUMN`, or `TRUNCATE` statement: `grep -v '^--' <migration.sql> | grep -ciE 'drop (table|column)|truncate'` returns 0.
- The live dev MariaDB query above prints `MIGRATION OK` and exits 0 — `users.is_admin`, `member_credentials.provider_type`, the `member_credentials` unique on `user_id`, `calendar_events.reminder_lead_minutes`, and the `app_config` table all exist.
- `db:push` was NOT run (no push in command history for this task).
</acceptance_criteria>
<done>The v1.1 migration is generated (additive-only), committed, and applied to the live dev DB; all new columns/table verified present by a real DB query.</done>
</task>
<task type="execute">
<name>Task 3: Seed the dev-bypass user (id=1) as admin in the e2e global-setup</name>
<files>apps/pwa/e2e/global-setup.ts</files>
<read_first>
- apps/pwa/e2e/global-setup.ts (the file being modified — read the full file; the FK-checks-off TRUNCATE block, the `INSERT IGNORE INTO calendars (id, user_id, ...)` idempotent pattern lines 113117, the mysql2 connection setup)
- apps/api/src/auth/devBypass.ts (DEV_USER id=1, displayName 'Dev User', color '#4A90D9' — the seed row must match this identity so /api/me dev path and the seeded DB row agree)
- apps/api/src/db/schema.ts users table (oidc_iss / oidc_sub NOT NULL, color NOT NULL — the seed INSERT must supply non-null values for the required columns)
- .planning/phases/10-admin-role-settings/10-RESEARCH.md §Pattern 5 "DEV_AUTH_BYPASS user-1 admin acquisition" (seed approach recommended) + §Pitfall 3 (why requireAdmin 403s without this seed)
</read_first>
<action>
The dev-bypass path injects DEV_USER (id=1) WITHOUT a DB upsert, so the `users` table has no row for id=1 — `requireAdmin` (Plan 02) does a DB lookup and would 403 the bypass admin UI locally and in e2e. Fix the seed per the D-01 dev note: in `apps/pwa/e2e/global-setup.ts`, inside the seed block (after the FK-checks-on, mirroring the existing `INSERT IGNORE INTO calendars` idempotent idiom), add an idempotent seed of the dev user row: `INSERT INTO users (id, oidc_iss, oidc_sub, display_name, color, is_admin) VALUES (1, 'dev-bypass', 'dev-user-1', 'Dev User', '#4A90D9', true) ON DUPLICATE KEY UPDATE is_admin=true`. Supply placeholder non-null oidc_iss/oidc_sub values (the bypass path never reads them; the row only needs to satisfy the NOT NULL constraints and carry is_admin=true). Keep it idempotent so re-runs converge. Do not change the existing TRUNCATE/event/list seeds. Add a comment that this row gives the dev-bypass admin UI a real `is_admin=true` row for requireAdmin's DB lookup.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && grep -c "is_admin" apps/pwa/e2e/global-setup.ts</automated>
</verify>
<acceptance_criteria>
- `apps/pwa/e2e/global-setup.ts` contains an `INSERT INTO users` ... `is_admin` seed for id=1 with `ON DUPLICATE KEY UPDATE` (idempotent).
- `grep -c "is_admin" apps/pwa/e2e/global-setup.ts` returns >= 1.
- The seed supplies non-null `oidc_iss`, `oidc_sub`, and `color` (satisfies users NOT NULL constraints).
- The existing calendar/event/list seeds are unchanged (the `INSERT IGNORE INTO calendars` and `Seeded Test Event` anchors still present).
</acceptance_criteria>
<done>global-setup seeds users id=1 with is_admin=true idempotently; the dev-bypass admin UI path now has a DB row requireAdmin will admit.</done>
</task>
</tasks>
<artifacts_this_phase_produces>
This plan creates the following new symbols/files (excluded from drift verification — they do not exist before this plan):
- `users.is_admin` column (boolean, default false)
- `member_credentials.provider_type` column (varchar(64), default 'caldav') — D-04 generic provider discriminator
- `member_credentials` UNIQUE constraint `uniq_member_credential_user` on `user_id`
- `calendar_events.reminder_lead_minutes` column (int, nullable) — consumed by Phase 11
- `app_config` table (`key` PK, `value`, `updated_at`) — `setup_complete` key consumed by Phase 12
- exported `appConfig` Drizzle table in `apps/api/src/db/schema.ts`
- generated migration SQL file `apps/api/src/db/migrations/0001_v1_1_foundation.sql` (or drizzle-kit's emitted sequential name) + `_journal.json` entry
- seeded `users` row id=1 with `is_admin=true` in `apps/pwa/e2e/global-setup.ts`
</artifacts_this_phase_produces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| schema.ts → live MariaDB | DDL applied to a populated production-shaped DB; a wrong (destructive) migration could drop/truncate live data |
| e2e seed → DB | global-setup TRUNCATEs + INSERTs against whatever DB_* points at |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-10-01 | Tampering | drizzle-kit migration on populated MariaDB | mitigate | Task 2 uses generate+migrate (never push); blocks on any DROP/TRUNCATE in the generated SQL (acceptance grep == 0) and verifies columns via a live DB query, not just tsc (false-positive trap) |
| T-10-02 | Tampering | e2e global-setup TRUNCATE against wrong DB | accept | Pre-existing fail-closed guards (NODE_ENV=production refusal + DEV_AUTH_BYPASS=true requirement) already gate the seed; this plan only adds an idempotent INSERT, no new TRUNCATE surface |
| T-10-03 | Elevation of Privilege | dev-bypass user gains is_admin | accept | Seed grants is_admin=true ONLY to id=1 ONLY in the dev/e2e bypass DB (guarded by DEV_AUTH_BYPASS + non-production); production users acquire is_admin via first-login-wins (Plan 02), never via this seed |
| T-10-SC | Tampering | npm/pip/cargo installs | mitigate | No new packages installed this phase (RESEARCH Package Legitimacy Audit: all already in lockfile); no install task, so no [ASSUMED]/[SUS] checkpoint needed |
</threat_model>
<verification>
- `pnpm --filter @familysync/api exec tsc --noEmit` passes (schema typechecks).
- The live DB query in Task 2 prints `MIGRATION OK`.
- The generated migration SQL is additive-only (no DROP/TRUNCATE).
- `grep -c is_admin apps/pwa/e2e/global-setup.ts` >= 1.
</verification>
<success_criteria>
- All four v1.1 schema items exist in the live dev MariaDB (verified by query, not types).
- Migration applied via generate+migrate; SQL committed; journal updated; no push used.
- Dev-bypass user id=1 seeded as admin so downstream requireAdmin verification works.
- Supports phase Success Criterion 5 (DB migration in place for downstream phases).
</success_criteria>
<output>
Create `.planning/phases/10-admin-role-settings/10-01-SUMMARY.md` when done.
</output>