Files
familysync/.planning/phases/10-admin-role-settings/10-01-PLAN.md
T

18 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 — `db:generate` = `drizzle-kit generate`, `db:migrate` = `drizzle-kit migrate`) - .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) + api-integration-test-db (DB_HOST=127.0.0.1 + .env creds for a reachable dev MariaDB) BLOCKING — this must run AFTER Task 1 (schema.ts complete) and BEFORE any plan that reads the new columns. You MUST actually RUN both commands in this task; describing them is not enough, and tsc/build passing is NOT sufficient proof that the migration ran (Drizzle types come from schema.ts regardless of whether the DB was migrated). 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 the next sequential migration `.sql` under `apps/api/src/db/migrations/` (drizzle-kit names it `0001_v1_1_foundation.sql` or a similar sequential name — commit whatever drizzle-kit emits) 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 (drizzle-mariadb-push-unsafe); do NOT apply it, and do NOT fall back to `db:push`. Re-derive from schema.ts. 3. RUN `pnpm --filter @familysync/api db:migrate` against the reachable dev DB with `DB_HOST=127.0.0.1` + the dev DB_USER/DB_PASSWORD/DB_NAME/DB_PORT from `.env` (per api-integration-test-db): `set -a; source .env; set +a; DB_HOST=127.0.0.1 pnpm --filter @familysync/api db:migrate`. NEVER `db:push` / `drizzle-kit 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 each return a row. 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)})" - Both commands were actually RUN this task: `pnpm --filter @familysync/api db:generate` produced a new migration `.sql` file under `apps/api/src/db/migrations/` (sequential after `0000_baseline.sql`) with a matching `meta/_journal.json` entry, and `pnpm --filter @familysync/api db:migrate` (with `DB_HOST=127.0.0.1` + `.env` creds) applied it to the live dev DB. tsc/build passing is explicitly NOT accepted as proof. - The generated migration `.sql` contains NO `DROP`/`TRUNCATE` statement: `grep -v '^--' | grep -ciE 'drop (table|column)|truncate'` returns 0 (guards the false-destructive-diff trap). - The live dev MariaDB now has the columns/table: the mysql2 query above prints `MIGRATION OK` and exits 0 — `SHOW COLUMNS FROM users LIKE 'is_admin'`, `SHOW COLUMNS FROM member_credentials LIKE 'provider_type'`, `SHOW COLUMNS FROM calendar_events LIKE 'reminder_lead_minutes'`, and `SHOW TABLES LIKE 'app_config'` each return a row, plus the `member_credentials` unique on `user_id` exists. - `db:push` / `drizzle-kit push` was NOT run (no push in command history for this task). The v1.1 migration is generated (additive-only) by an actual db:generate run, committed, and applied to the live dev DB by an actual db:migrate run; all new columns/table verified present by a real DB query (not tsc). 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` (proof the migration actually ran — tsc alone is NOT proof). - 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.