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

17 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
10-admin-role-settings 01 execute 1
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
true
ADMIN-01
ADMIN-02
ADMIN-03
truths artifacts key_links
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
path provides contains
apps/api/src/db/schema.ts v1.1 schema: users.isAdmin, memberCredentials.providerType + unique(user_id), calendarEvents.reminderLeadMinutes, appConfig table appConfig
path provides contains
apps/api/src/db/migrations/0001_v1_1_foundation.sql generated ALTER/CREATE DDL for the v1.1 bundle is_admin
path provides contains
apps/pwa/e2e/global-setup.ts seeds users row id=1 with is_admin=true for the dev-bypass admin UI verification path is_admin
from to via pattern
apps/api/src/db/schema.ts apps/api/src/db/migrations/0001_v1_1_foundation.sql drizzle-kit generate is_admin
from to via pattern
apps/pwa/e2e/global-setup.ts users table INSERT seed of id=1 is_admin=true is_admin
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.

<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/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 Task 1: Extend schema.ts with the v1.1 column/table bundle apps/api/src/db/schema.ts - 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) 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. cd /home/luc/Projects/familysync && pnpm --filter @familysync/api exec tsc --noEmit 2>&1 | tail -5 - `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()`). schema.ts holds all four v1.1 additions, typechecks clean, no existing column altered. Task 2: [BLOCKING] Generate + migrate the v1.1 migration against the live dev DB apps/api/src/db/migrations/0001_v1_1_foundation.sql, apps/api/src/db/migrations/meta/_journal.json - 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) 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. 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)})" - 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 '^--' | 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). 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. Task 3: Seed the dev-bypass user (id=1) as admin in the e2e global-setup apps/pwa/e2e/global-setup.ts - 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) 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. cd /home/luc/Projects/familysync && grep -c "is_admin" apps/pwa/e2e/global-setup.ts - `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). 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.

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

<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>
Create `.planning/phases/10-admin-role-settings/10-01-SUMMARY.md` when done.