--- 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" --- 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. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.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 55–68, `calendarEvents` ~lines 120–130 incl. `allDay` line 127, `calendars.isShared` line 89, `pushSubscriptions` lines 236–257 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 113–117, 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. 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` ## 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 | - `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. - 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). Create `.planning/phases/10-admin-role-settings/10-01-SUMMARY.md` when done.