---
phase: "10-admin-role-settings"
plan: "02"
subsystem: "api-auth"
tags: ["requireAdmin", "admin-role", "middleware", "upsertUser", "first-login-wins", "me-api", "tdd"]
dependency_graph:
  requires:
    - "users.is_admin column (10-01)"
    - "member_credentials table with UNIQUE(user_id) (10-01)"
    - "app_config table (10-01)"
  provides:
    - "requireAdmin MiddlewareHandler (DB-backed role enforcement, T-10-04/T-10-05)"
    - "first-login-wins is_admin bootstrap in upsertUser (D-01)"
    - "isAdmin + needsProviderSetup on /api/me response (D-03)"
  affects:
    - "Phase 10 Plan 03 (adminRouter mounts requireAdmin)"
    - "Phase 10 Plan 04 (PWA nav gating reads isAdmin from /api/me)"
    - "Phase 12 (first-login-wins hook point documented for setup_complete tightening)"
tech_stack:
  added: []
  patterns:
    - "MiddlewareHandler inline export (requireAdmin pattern, not factory function)"
    - "sql<number> COUNT(*) with .limit(1) for scalar aggregate in Drizzle"
    - "resolveAdminAndSetupStatus helper — two sequential DB selects in a route"
    - "TDD RED→GREEN: 6 RED commits → 3 GREEN commits"
key_files:
  created:
    - "apps/api/src/lib/requireAdmin.ts"
    - "apps/api/tests/lib/requireAdmin.test.ts"
  modified:
    - "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"
decisions:
  - "sql<number> COUNT(*) with .limit(1) — not .limit() on Drizzle aggregate; scalar aggregate needs explicit limit for mock-chain compatibility and Drizzle's select-where pattern"
  - "resolveAdminAndSetupStatus extracted as a shared helper in me.ts — used by both bypass and OIDC paths to avoid duplication"
  - "requireAdmin is an inline MiddlewareHandler constant, not a factory function — applied as adminRouter.use('*', requireAdmin)"
metrics:
  duration_seconds: 700
  completed_date: "2026-06-13"
  tasks_completed: 3
  files_modified: 6
---

# Phase 10 Plan 02: Admin Role Primitives Summary

**One-liner:** DB-backed `requireAdmin` MiddlewareHandler, first-login-wins `is_admin` bootstrap in `upsertUser`, and `/api/me` extended with `isAdmin` + `needsProviderSetup` — all TDD-verified with 22 tests.

## Tasks Completed

| Task | Name | Commits | Files |
|------|------|---------|-------|
| 1 | requireAdmin middleware (RED→GREEN) | 9217930 (RED), f9c70ab (GREEN) | requireAdmin.ts, requireAdmin.test.ts |
| 2 | First-login-wins is_admin bootstrap in upsertUser (RED→GREEN) | 9e1507f (RED), 72e0140 (GREEN) | user.ts, user.test.ts |
| 3 | Extend /api/me with isAdmin + needsProviderSetup (RED→GREEN) | e5889df (RED), 1adff61 (GREEN) | me.ts, me.test.ts |

## What Was Built

### Task 1: requireAdmin middleware

`apps/api/src/lib/requireAdmin.ts` exports `requireAdmin: MiddlewareHandler`:
- Reads `c.get('user')?.id`; if no id → 403 `{ error: 'Forbidden' }` immediately (no DB query)
- Queries `db.select({ isAdmin: users.isAdmin }).from(users).where(eq(users.id, userId)).limit(1)`
- If `!row?.isAdmin` → 403; else `await next()`
- Side-effect import of `../auth/devBypass.js` carries the ContextVariableMap augmentation
- Never reads `isAdmin` from the context user object — DB is the sole authority (T-10-04)
- The dev-auth bypass skips OIDC; requireAdmin still hits the DB for every request (T-10-05)
- No `console.log` of user object or credentials (T-10-07)

4 test cases covering: non-admin DB row → 403, admin DB row → next(), no user → 403 (no DB call), spoofed `isAdmin: true` on context but non-admin DB row → 403.

### Task 2: First-login-wins is_admin bootstrap in upsertUser

`apps/api/src/auth/user.ts` extended before the INSERT block:
- Added `import { sql } from 'drizzle-orm'`
- Zero-admin COUNT check: `db.select({ count: sql<number>\`COUNT(*)\` }).from(users).where(eq(users.isAdmin, true)).limit(1)`
- `shouldBeAdmin = Number(count) === 0`
- INSERT `.values({ ..., isAdmin: shouldBeAdmin })` — first user when zero admins → `is_admin=true`; subsequent users → `is_admin=false`
- Existing-user early-return path unchanged (no `is_admin` modification on re-upsert)
- Phase-12 hook comment: "Phase 12 tightens to: first user after app_config.setup_complete"

3 new test cases + existing tests updated for the new 4-select call sequence (identity lookup → used-colors → admin COUNT → re-fetch).

### Task 3: /api/me extended with isAdmin + needsProviderSetup

`apps/api/src/routes/me.ts` extended with:
- `resolveAdminAndSetupStatus(userId)` helper — two DB selects:
  1. `users.isAdmin` via `db.select({ isAdmin: users.isAdmin }).from(users).where(eq(users.id, userId)).limit(1)`
  2. `memberCredentials.id` via `db.select({ id: memberCredentials.id }).from(memberCredentials).where(eq(memberCredentials.userId, userId)).limit(1)`
  - Returns `{ isAdmin: row?.isAdmin ?? false, needsProviderSetup: !cred }`
- Dev-bypass path: now calls `resolveAdminAndSetupStatus(devUser.id)` — DB-backed, not hardcoded (T-10-05)
- OIDC path: calls `resolveAdminAndSetupStatus(user.id)` after `upsertUser`
- Response: `{ user: { id, displayName, color, isAdmin, needsProviderSetup } }` on both paths
- No `/api/me/credential` POST added (Plan 03)

3 new test cases: isAdmin from DB (not hardcoded), needsProviderSetup=true (no cred), needsProviderSetup=false (cred exists).

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 1 - Bug] Drizzle aggregate mock chaining — added .limit(1) to COUNT query**
- **Found during:** Task 2 (GREEN phase)
- **Issue:** The COUNT query `const [{ count }] = await db.select({...}).from(users).where(...)` was awaiting the `.where()` return directly. In mocked tests, `makeSelectChain.where()` returns the chain object (not a Promise), so destructuring `[{ count }]` failed with "is not iterable".
- **Fix:** Added `.limit(1)` to the COUNT query, making it terminate at `.limit()` which returns a Promise in the mock (consistent with all other select patterns in this codebase).
- **Files modified:** `apps/api/src/auth/user.ts` (`.limit(1)` on COUNT query)
- **Commit:** 72e0140

## Known Stubs

None. This plan is API-only (no UI components). All DB queries are real and fully implemented.

## Threat Flags

None new beyond the plan's threat model. All T-10-04/T-10-05/T-10-06/T-10-07 mitigations implemented:
- T-10-04: requireAdmin reads `users.is_admin` from DB, never trusts context user's `isAdmin`
- T-10-05: Both requireAdmin and /api/me do DB lookups even on the dev-bypass path
- T-10-06: isAdmin on /api/me is documented UX-only; Plan 03's requireAdmin is the server boundary
- T-10-07: No `console.log` of user object or credentials in any modified file

## Self-Check: PASSED

- `apps/api/src/lib/requireAdmin.ts` exists and exports `requireAdmin`: PASS
- `grep -q "users.isAdmin" apps/api/src/lib/requireAdmin.ts`: PASS
- `grep -q "isAdmin: shouldBeAdmin" apps/api/src/auth/user.ts`: PASS
- `grep -q "needsProviderSetup" apps/api/src/routes/me.ts`: PASS
- `grep -q "memberCredentials" apps/api/src/routes/me.ts`: PASS
- All 22 tests pass (requireAdmin: 4, user: 10, me: 8): PASS
- `pnpm --filter @familysync/api exec tsc --noEmit` exits 0: PASS
- Commits 9217930, f9c70ab, 9e1507f, 72e0140, e5889df, 1adff61 in git log: PASS
