Files
familysync/.planning/phases/10-admin-role-settings/10-02-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

205 lines
16 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: 02
type: tdd
wave: 2
depends_on: ["10-01"]
files_modified:
- apps/api/src/lib/requireAdmin.ts
- apps/api/tests/lib/requireAdmin.test.ts
- apps/api/src/auth/user.ts
- apps/api/tests/auth/user.test.ts
- apps/api/src/routes/me.ts
- apps/api/tests/routes/me.test.ts
autonomous: true
requirements: [ADMIN-03]
must_haves:
truths:
- "requireAdmin returns 403 for an authenticated non-admin user and calls next() for an admin user (role read from the DB, never a client flag)"
- "On first login when zero admins exist, upsertUser flags the new user is_admin=true; subsequent users are normal members"
- "GET /api/me returns isAdmin and needsProviderSetup for both the dev-bypass path and the OIDC path"
artifacts:
- path: "apps/api/src/lib/requireAdmin.ts"
provides: "MiddlewareHandler that reads c.get('user').id, looks up users.is_admin in the DB, 403s non-admins"
exports: ["requireAdmin"]
min_lines: 15
- path: "apps/api/src/auth/user.ts"
provides: "upsertUser extended with first-login-wins is_admin bootstrap (zero-admins → first user is admin)"
contains: "isAdmin"
- path: "apps/api/src/routes/me.ts"
provides: "/api/me response extended with isAdmin + needsProviderSetup (both dev-bypass and OIDC paths)"
contains: "needsProviderSetup"
key_links:
- from: "apps/api/src/lib/requireAdmin.ts"
to: "users.is_admin"
via: "Drizzle select where eq(users.id, userId)"
pattern: "users\\.isAdmin"
- from: "apps/api/src/routes/me.ts"
to: "member_credentials"
via: "needsProviderSetup = no member_credentials row for the user"
pattern: "memberCredentials"
---
<objective>
Build the server-side admin role primitives that ADMIN-03 depends on: the `requireAdmin` MiddlewareHandler (DB-backed role check, always server-enforced), the first-login-wins `is_admin` bootstrap in `upsertUser` (D-01), and the `/api/me` extension exposing `isAdmin` + `needsProviderSetup` (D-03) for both the dev-bypass and OIDC code paths. TDD: each behavior has a defined input→output contract, so write the failing test first.
Purpose: `requireAdmin` is the single security boundary for every `/api/admin/*` route (Plan 03 mounts it). `isAdmin` on `/api/me` drives PWA nav gating (Plan 04, UX-only). `needsProviderSetup` drives the member self-service banner (Plan 04). First-login-wins is written so Phase 12 can later tighten it to "first login after setup_complete" without a rewrite.
Output: New `requireAdmin.ts` + tests, extended `user.ts` + tests, extended `me.ts` + tests.
</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
@.planning/phases/10-admin-role-settings/10-01-SUMMARY.md
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: requireAdmin middleware (RED→GREEN→REFACTOR)</name>
<files>apps/api/src/lib/requireAdmin.ts, apps/api/tests/lib/requireAdmin.test.ts</files>
<read_first>
- apps/api/src/auth/devBypass.ts (MiddlewareHandler signature + the `c.get('user')`/`c.set('user', DEV_USER)` pattern + the `ContextVariableMap` augmentation, line ~46; the side-effect import idiom)
- apps/api/src/lib/ (sibling lib modules — e.g. listAccess.ts — for the lib-file import/style conventions)
- apps/api/tests/lib/ + apps/api/tests/auth/devBypass.test.ts (existing test idioms: how a Hono app/middleware is exercised, how c.get('user') is stubbed, how DB is reached in tests per [[api-integration-test-db]])
- apps/api/src/db/schema.ts users.isAdmin (from Plan 01) + apps/api/src/db/client.ts (the `db` export)
- .planning/phases/10-admin-role-settings/10-PATTERNS.md §`apps/api/src/lib/requireAdmin.ts` (the exact MiddlewareHandler shape, the DB lookup excerpt, the `import '../auth/devBypass.js'` side-effect import) + 10-RESEARCH.md §Pattern 1 (Pitfall 9) + §Pitfall 3
</read_first>
<behavior>
- Test (RED): an authenticated user whose DB row has is_admin=false → requireAdmin responds 403 `{ error: 'Forbidden' }` and does NOT call next().
- Test: an authenticated user whose DB row has is_admin=true → requireAdmin calls next() (request proceeds).
- Test: no resolved user on context (c.get('user') undefined) → 403 (never throws).
- Test: the role is read from the DB (users.is_admin), NOT from any value on c.get('user') — a context user object claiming isAdmin=true but with a non-admin DB row is still 403 (defence: bypass only skips OIDC, not the DB check).
</behavior>
<action>
Create `apps/api/src/lib/requireAdmin.ts` exporting `requireAdmin: MiddlewareHandler` (per 10-PATTERNS.md excerpt): read `c.get('user')?.id`; if no id → `c.json({ error: 'Forbidden' }, 403)`; else `db.select({ isAdmin: users.isAdmin }).from(users).where(eq(users.id, userId)).limit(1)`; if `!row?.isAdmin` → 403; else `await next()`. Include the `import '../auth/devBypass.js'` side-effect import for the ContextVariableMap augmentation. NEVER log the user object or any credential. Write `apps/api/tests/lib/requireAdmin.test.ts` FIRST (the four behaviors above), confirm RED, then implement to GREEN. Follow the real-DB test conventions in [[api-integration-test-db]] (tests live in tests/, DB_HOST=127.0.0.1 override) if the test exercises the live DB; otherwise stub the db module.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- requireAdmin 2>&1 | tail -15</automated>
</verify>
<acceptance_criteria>
- `apps/api/src/lib/requireAdmin.ts` exports `requireAdmin` typed as `MiddlewareHandler`.
- The 403 response body is `{ error: 'Forbidden' }` (HTTP 403) for a non-admin authenticated user.
- The role decision reads `users.isAdmin` from the DB (`grep -q "users.isAdmin" apps/api/src/lib/requireAdmin.ts`); it does NOT branch on a property of `c.get('user')` other than `.id`.
- No `console.log`/`console.error` of the user object or credentials in the file.
- `pnpm --filter @familysync/api test -- requireAdmin` passes all four cases.
</acceptance_criteria>
<done>requireAdmin guard exists, DB-backed, 403s non-admins, tests green.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: First-login-wins is_admin bootstrap in upsertUser (RED→GREEN→REFACTOR)</name>
<files>apps/api/src/auth/user.ts, apps/api/tests/auth/user.test.ts</files>
<read_first>
- apps/api/src/auth/user.ts (the file being modified — the full `upsertUser` function lines 76128: the existing-row early-return path, the color assignment, the INSERT `.values({...}).$returningId()` block lines 112122)
- apps/api/tests/auth/user.test.ts (existing upsertUser test idioms — how it seeds/asserts DB state, the real-DB test setup)
- apps/api/src/db/schema.ts users.isAdmin (from Plan 01)
- .planning/phases/10-admin-role-settings/10-PATTERNS.md §`apps/api/src/auth/user.ts` (the zero-admin COUNT check + `isAdmin: shouldBeAdmin` in `.values()`, the `import { sql }` addition) + 10-RESEARCH.md §Pattern 5 (Phase-12-safe first-login-wins, the "zero admins exist" check that P12 tightens to "after setup_complete")
</read_first>
<behavior>
- Test (RED): upsertUser inserting a brand-new user when the users table has ZERO admins → the inserted row has is_admin=true.
- Test: upsertUser inserting a new user when an admin already exists → the inserted row has is_admin=false.
- Test: upsertUser for an EXISTING user (oidc_iss+oidc_sub already present) → is_admin is NOT changed by the upsert (the early-return path is untouched; promotion/demotion is not this function's job).
</behavior>
<action>
In `apps/api/src/auth/user.ts`, before the INSERT in `upsertUser` (after color assignment), add a zero-admin check (per 10-PATTERNS.md excerpt): `db.select({ count: sql<number>\`COUNT(*)\` }).from(users).where(eq(users.isAdmin, true))`; `shouldBeAdmin = Number(count) === 0`; pass `isAdmin: shouldBeAdmin` in the INSERT `.values({...})`. Add `import { sql } from 'drizzle-orm'` if absent. Leave the existing-user early-return path unchanged (do NOT toggle is_admin for existing users). Add a comment marking this as the Phase-12 hook point: "first user when zero admins exist (D-01); Phase 12 tightens to first user after app_config.setup_complete". Write the three test cases in `apps/api/tests/auth/user.test.ts` FIRST, confirm RED, implement to GREEN.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- user 2>&1 | tail -15</automated>
</verify>
<acceptance_criteria>
- A new user inserted with zero pre-existing admins has `is_admin=true`; with an existing admin, `is_admin=false`.
- The existing-user early-return path does not modify is_admin (test asserts unchanged).
- `grep -q "isAdmin" apps/api/src/auth/user.ts` and the INSERT `.values()` includes `isAdmin`.
- A comment in `user.ts` names the Phase-12 tightening hook (first login after setup_complete).
- `pnpm --filter @familysync/api test -- user` passes all cases.
</acceptance_criteria>
<done>First-login-wins bootstrap writes is_admin on first insert, member-count-agnostic, Phase-12-safe, tests green.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 3: Extend /api/me with isAdmin + needsProviderSetup (RED→GREEN→REFACTOR)</name>
<files>apps/api/src/routes/me.ts, apps/api/tests/routes/me.test.ts</files>
<read_first>
- apps/api/src/routes/me.ts (the file being modified — the dev-bypass short-circuit lines 3143 returning `{ user: { id, displayName, color } }`, the OIDC path lines 6075 returning the resolved user; both must add isAdmin + needsProviderSetup)
- apps/api/tests/routes/me.test.ts (existing /api/me test idioms — both dev-bypass and OIDC response assertions)
- apps/api/src/db/schema.ts users.isAdmin + memberCredentials (from Plan 01) + apps/api/src/db/client.ts (`db`)
- apps/api/src/auth/user.ts (the resolved `user` shape returned by upsertUser — confirm it now carries isAdmin after Task 2; if not selected, me.ts must select users.isAdmin itself)
- .planning/phases/10-admin-role-settings/10-PATTERNS.md §`apps/api/src/routes/me.ts` (the needsProviderSetup lookup excerpt: `db.select({id: memberCredentials.id}).from(memberCredentials).where(eq(memberCredentials.userId, userId)).limit(1)` → `needsProviderSetup = !cred`) + 10-RESEARCH.md §Code Examples "/api/me Response Extension" + §Open Questions #3 (needsProviderSetup lives on /api/me)
</read_first>
<behavior>
- Test (RED): dev-bypass path (DEV_USER id=1) → response `user` includes `isAdmin` (looked up from the DB row for id=1, NOT hardcoded) and `needsProviderSetup` (true iff no member_credentials row for id=1).
- Test: OIDC path → response `user` includes `isAdmin` (from the resolved users row) and `needsProviderSetup` (member_credentials existence for that user id).
- Test: a user WITH a member_credentials row → needsProviderSetup=false; a user WITHOUT one → needsProviderSetup=true.
</behavior>
<action>
In `apps/api/src/routes/me.ts`, extend BOTH response paths to include `isAdmin` and `needsProviderSetup`. The dev-bypass path currently short-circuits without a DB lookup — it MUST now query `users.isAdmin` for id=1 (same lookup as requireAdmin) rather than hardcoding, and compute `needsProviderSetup` via the member_credentials existence check (per 10-PATTERNS.md excerpt). The OIDC path uses the resolved user's isAdmin + the same member_credentials existence check. Add the `eq`/`db`/`users`/`memberCredentials` imports as needed. Do NOT add the self-service `/api/me/credential` POST endpoint here — that belongs to Plan 03. Write the three test cases in `apps/api/tests/routes/me.test.ts` FIRST, confirm RED, implement to GREEN.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- me 2>&1 | tail -15</automated>
</verify>
<acceptance_criteria>
- `GET /api/me` response `user` object contains `isAdmin` (boolean) and `needsProviderSetup` (boolean) on BOTH the dev-bypass and OIDC paths.
- The dev-bypass path's isAdmin is read from the DB (`grep -q "memberCredentials" apps/api/src/routes/me.ts` and isAdmin not hardcoded `true`/`false` in the dev path).
- needsProviderSetup is true exactly when no member_credentials row exists for the user.
- No `/api/me/credential` POST route added in this plan.
- `pnpm --filter @familysync/api test -- me` passes all cases.
</acceptance_criteria>
<done>/api/me exposes isAdmin + needsProviderSetup on both paths, DB-backed, tests green.</done>
</task>
</tasks>
<artifacts_this_phase_produces>
New symbols/files created by this plan (excluded from drift verification):
- `apps/api/src/lib/requireAdmin.ts` exporting `requireAdmin` (MiddlewareHandler)
- `apps/api/tests/lib/requireAdmin.test.ts`
- first-login-wins `is_admin` bootstrap branch in `upsertUser` (`apps/api/src/auth/user.ts`)
- `isAdmin` + `needsProviderSetup` fields on the `/api/me` response (`apps/api/src/routes/me.ts`)
- test additions in `apps/api/tests/auth/user.test.ts` and `apps/api/tests/routes/me.test.ts`
</artifacts_this_phase_produces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → /api/admin/* (via requireAdmin) | untrusted authenticated request must be proven admin server-side before any admin handler runs |
| /api/me → browser | the isAdmin flag crosses to the client for UX gating only; never the security boundary |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-10-04 | Elevation of Privilege | non-admin invoking admin-gated logic | mitigate | requireAdmin reads users.is_admin from the DB and 403s non-admins (Task 1); decision never trusts a client-supplied or context-attached isAdmin claim, only the DB row |
| T-10-05 | Elevation of Privilege | DEV_AUTH_BYPASS bypasses the role check | mitigate | requireAdmin and /api/me both DB-lookup users.is_admin even on the bypass path (the bypass skips OIDC, not the DB check); the bypass admin row comes only from the guarded dev seed (Plan 01 Task 3) |
| T-10-06 | Spoofing/EoP | client trusting its own isAdmin to reach admin features | mitigate | isAdmin on /api/me is documented and used as UX-only; the server-side 403 (requireAdmin, Plan 03) is the real boundary on every /api/admin/* request |
| T-10-07 | Information Disclosure | logging the resolved user / claims | mitigate | requireAdmin and me.ts must not console.log the user object or any credential (acceptance grep) |
| T-10-SC | Tampering | npm/pip/cargo installs | mitigate | No new packages this phase (RESEARCH Package Legitimacy Audit); no install task |
</threat_model>
<verification>
- `pnpm --filter @familysync/api test -- requireAdmin && pnpm --filter @familysync/api test -- user && pnpm --filter @familysync/api test -- me` all pass.
- `pnpm --filter @familysync/api exec tsc --noEmit` passes (per [[vitest-passes-tsc-fails]], run tsc separately — vitest stays green on type errors).
- `grep -q "users.isAdmin" apps/api/src/lib/requireAdmin.ts`.
</verification>
<success_criteria>
- requireAdmin 403s authenticated non-admins, admits admins, DB-backed (ADMIN-03 server enforcement; supports Success Criteria 1 & 4).
- First-login-wins writes is_admin on first insert, role-agnostic / member-count-agnostic (Success Criterion 4).
- /api/me exposes isAdmin + needsProviderSetup on both paths for downstream PWA gating + self-service.
</success_criteria>
<output>
Create `.planning/phases/10-admin-role-settings/10-02-SUMMARY.md` when done.
</output>