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

226 lines
21 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: 03
type: tdd
wave: 3
depends_on: ["10-01", "10-02"]
files_modified:
- apps/api/src/broker/outboxWorker.ts
- apps/api/src/routes/admin.ts
- apps/api/src/routes/me.ts
- apps/api/src/index.ts
- apps/api/tests/routes/admin.test.ts
autonomous: true
requirements: [ADMIN-01, ADMIN-02, ADMIN-03]
must_haves:
truths:
- "GET /api/admin/members returns 403 for a non-admin authenticated user and a member+credential-status list for an admin"
- "POST /api/admin/credentials validates against CalDAV (PROPFIND), 400 on bad credential with NO submitted password in the body, 200 + encrypted store on success; never logs/echoes the password"
- "PUT /api/admin/calendars/:id/shared sets exactly one calendar is_shared=1 and clears any prior shared calendar"
- "POST /api/me/credential sets only the current user's credential (ignores any userId in the body); a non-admin cannot POST /api/admin/credentials"
artifacts:
- path: "apps/api/src/routes/admin.ts"
provides: "adminRouter guarded by requireAdmin (.use('*', ...) first); GET /members, POST /credentials, GET /calendars, PUT /calendars/:id/shared"
exports: ["adminRouter"]
min_lines: 60
- path: "apps/api/src/routes/me.ts"
provides: "POST /api/me/credential member-scoped self-service (currentUserId only)"
contains: "credential"
- path: "apps/api/src/index.ts"
provides: "app.route('/api/admin', adminRouter) mounted in the existing auth band"
contains: "adminRouter"
key_links:
- from: "apps/api/src/routes/admin.ts"
to: "requireAdmin"
via: "adminRouter.use('*', requireAdmin) as the first statement (Pitfall 9)"
pattern: "adminRouter\\.use\\('\\*', requireAdmin\\)"
- from: "apps/api/src/routes/admin.ts"
to: "broker validate→encrypt→sync"
via: "createFastmailClient + fetchCalendars, encryptPassword, exported triggerTargetedResync/loadClientForUser"
pattern: "encryptPassword"
- from: "apps/api/src/index.ts"
to: "adminRouter"
via: "app.route('/api/admin', adminRouter)"
pattern: "app.route\\('/api/admin'"
---
<objective>
Build the admin API surface (ADMIN-01 credential rotation + ADMIN-02 shared-calendar designation), gated by `requireAdmin` (ADMIN-03), plus the member-scoped self-service credential endpoint (D-07), all sharing one validate→encrypt→initial-sync path. Promote the broker's private resync helpers to exports so both the admin and self-service paths reuse them. TDD: the credential and guard contracts have precise input→output behavior (403 / 400-no-echo / 200), so write the failing tests first.
Purpose: This is the single shared credential + shared-calendar surface (`/api/admin/credentials`, `/api/admin/calendars/:id/shared`) — Phase 12 MUST reuse it, not duplicate it into `/api/setup/*`. The self-service endpoint is the member-scoped counterpart of admin rotation.
Output: Exported broker helpers, new `admin.ts` router, the `/api/me/credential` self-service endpoint, the index.ts mount, and integration tests covering the Pitfall 7 (no-echo) and Pitfall 9 (403) hard checks.
</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
@.planning/phases/10-admin-role-settings/10-02-SUMMARY.md
@apps/api/src/lib/requireAdmin.ts
</context>
<tasks>
<task type="execute">
<name>Task 1: Promote broker resync helpers to exports</name>
<files>apps/api/src/broker/outboxWorker.ts</files>
<read_first>
- apps/api/src/broker/outboxWorker.ts (the file being modified — `loadClientForUser` lines 271288, `triggerTargetedResync` lines 302348 incl. the `client.fetchCalendars()` + `syncCalendar` loop ~line 331; confirm neither is exported yet)
- apps/api/src/broker/client.ts (createFastmailClient — the CalDAV client the helpers build on) + apps/api/src/broker/sync.ts (syncCalendar signature)
- .planning/phases/10-admin-role-settings/10-PATTERNS.md §`apps/api/src/broker/outboxWorker.ts` (add `export` to both functions; the post-credential-save full sync uses loadClientForUser → fetchCalendars → syncCalendar per davCal) + 10-RESEARCH.md §Pattern 4 + §Open Questions #2 (full per-member poll after a fresh credential save — no known calendarUrl yet) + Assumptions A1
</read_first>
<action>
Add the `export` keyword to `loadClientForUser` and `triggerTargetedResync` in `apps/api/src/broker/outboxWorker.ts` so the admin + self-service credential routes can reuse them (per 10-PATTERNS.md). Do NOT change their bodies or the outbox drain cycle (A1: standalone fetch+sync helpers, no coupling to the drain loop). After a FRESH credential save there is no known calendarUrl, so the credential routes will call `loadClientForUser(userId)``client.fetchCalendars()``syncCalendar(...)` per returned DAV calendar (the full per-member poll, mirroring poller.ts) rather than `triggerTargetedResync` — but export both for flexibility. Confirm existing broker tests still pass (no behavior change). NEVER reintroduce node-cron ([[node-cron-skips-in-long-running-process]]) — these helpers are setInterval-driven callers' utilities, untouched.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && grep -E "^export (async )?function (loadClientForUser|triggerTargetedResync)" apps/api/src/broker/outboxWorker.ts && pnpm --filter @familysync/api test -- outbox 2>&1 | tail -8</automated>
</verify>
<acceptance_criteria>
- `grep -cE "^export (async )?function (loadClientForUser|triggerTargetedResync)" apps/api/src/broker/outboxWorker.ts` returns 2.
- The function bodies are unchanged (only `export` prepended) — `git diff` shows only the two `export` keyword additions.
- `pnpm --filter @familysync/api test -- outbox` still passes (no regression to the drain cycle).
</acceptance_criteria>
<done>Both broker resync helpers are exported, bodies unchanged, broker tests green.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: adminRouter — guard + members + credentials + shared-calendar (RED→GREEN→REFACTOR)</name>
<files>apps/api/src/routes/admin.ts, apps/api/src/index.ts, apps/api/tests/routes/admin.test.ts</files>
<read_first>
- apps/api/src/routes/push.ts (closest analog: Hono sub-router, zValidator, resolveUserId, ContextVariableMap side-effect import; the subscribeSchema zValidator usage lines ~6067)
- apps/api/src/routes/events.ts (Drizzle leftJoin + where SELECT lines 165177; the 400-not-422 zValidator convention; onDuplicateKeyUpdate upsert idiom)
- apps/api/src/lib/requireAdmin.ts (from Plan 02 — the guard to apply .use('*', requireAdmin) FIRST)
- apps/api/src/broker/crypto.ts (encryptPassword — reuse verbatim, AES-256-GCM, never log the return) + apps/api/src/broker/client.ts (createFastmailClient + the fetchCalendars PROPFIND validation signal) + apps/api/src/broker/outboxWorker.ts (the helpers exported in Task 1)
- apps/api/src/db/schema.ts users / memberCredentials (provider_type + uniq_member_credential_user from Plan 01) / calendars.isShared (line 89)
- apps/api/src/index.ts (route mount block lines 6772; the devAuthBypass→oidcAuthMiddleware band already covers /api/*; mount adminRouter AFTER the existing routes)
- apps/api/tests/routes/push.test.ts (integration test idioms: import `app` (NOT adminRouter directly — Pitfall 9), how dev-bypass admin vs non-admin is exercised, the real-DB test setup per [[api-integration-test-db]])
- .planning/phases/10-admin-role-settings/10-PATTERNS.md §`apps/api/src/routes/admin.ts` + §`apps/api/src/index.ts` (router+guard pattern, credentialSchema, noEchoHook, SELECT/upsert/exclusive-is_shared excerpts) + 10-RESEARCH.md §Pattern 1 (Pitfall 9), §Pattern 2 (Pitfall 7 no-echo hook), §Pattern 7 (exclusive is_shared), §Pitfall 1/2/5/6, §UI-SPEC Surface 2/5 (member-row + picker shapes the GET responses must feed)
</read_first>
<behavior>
- Test (RED, Pitfall 9): GET /api/admin/members as a non-admin authenticated user → 403. As an admin → 200 with a list of members each carrying credential status (has credential / not). Integration test imports `app`, never adminRouter directly.
- Test (Pitfall 7): POST /api/admin/credentials with an INVALID app password (CalDAV PROPFIND fails) → 400, and the response body contains NONE of the submitted password value (assert the exact submitted string is absent from the body) and no Zod `received`/`issues`/`value` field.
- Test: POST /api/admin/credentials with a VALID credential (CalDAV PROPFIND succeeds) → 200; the stored member_credentials.encrypted_password is NOT the plaintext (encryptPassword applied); response never echoes the password; initial sync is triggered (fire-and-forget).
- Test (Pitfall 9): POST /api/admin/credentials as a non-admin → 403.
- Test (ADMIN-02, Pitfall 7-adjacent): PUT /api/admin/calendars/:id/shared as admin → exactly one calendar has is_shared=1 afterward (the target), any prior shared calendar cleared. As non-admin → 403.
- Test: GET /api/admin/calendars as admin → 200 list of synced calendars (id, name, is_shared). As non-admin → 403.
</behavior>
<action>
Create `apps/api/src/routes/admin.ts` exporting `adminRouter = new Hono()` with `adminRouter.use('*', requireAdmin)` as the VERY FIRST statement (Pitfall 9). Add the side-effect import `'../auth/devBypass.js'`. Routes (paths are planner's call per D — use these):
- `GET /members`: SELECT users LEFT JOIN member_credentials → return id, displayName, color, hasCredential (boolean). Feeds UI-SPEC Surface 2.
- `POST /credentials`: `zValidator('json', credentialSchema, noEchoHook)` where credentialSchema = `{ userId: number().int().positive(), providerType: literal('caldav'), fastmailEmail: string().email().max(256), appPassword: string().min(1).max(500) }` and noEchoHook returns `c.json({ error: 'Invalid request' }, 400)` (NEVER `c.json(result.error, ...)`). Handler: validate via `createFastmailClient(email, appPassword)` + `client.fetchCalendars()` (throws on auth failure → return 400 generic, NO password in body); on success `encryptPassword(appPassword)` → upsert member_credentials via `onDuplicateKeyUpdate` (uses the Plan-01 UNIQUE(user_id)) with providerType 'caldav'; then fire-and-forget the initial full per-member sync (loadClientForUser → fetchCalendars → syncCalendar per davCal) and return 200. NEVER `console.log` the body or `c.req.valid('json')`.
- `GET /calendars`: SELECT calendars (id, displayName, isShared). Feeds UI-SPEC Surface 5.
- `PUT /calendars/:id/shared`: exclusive update (Pattern 7) — `db.update(calendars).set({isShared:false}).where(eq(calendars.isShared,true))` then `db.update(calendars).set({isShared:true}).where(eq(calendars.id, targetId))` (D-06 single-select). Return 200.
Mount in `apps/api/src/index.ts`: `app.route('/api/admin', adminRouter)` after the existing route block (no extra app-level middleware — the guard lives inside the router). Write `apps/api/tests/routes/admin.test.ts` FIRST with all six behaviors (import `app`), confirm RED, implement to GREEN. Mock/stub CalDAV (createFastmailClient/fetchCalendars) for the validation outcomes to avoid live Fastmail calls in CI (per [[dev-data-user1-no-calendars]] — route-mocks for credential paths).
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- admin 2>&1 | tail -20</automated>
</verify>
<acceptance_criteria>
- `apps/api/src/routes/admin.ts` first statement after router creation is `adminRouter.use('*', requireAdmin)``grep -nA1 "new Hono()" apps/api/src/routes/admin.ts` shows the `.use('*', requireAdmin)` immediately after.
- `apps/api/src/index.ts` contains `app.route('/api/admin', adminRouter)`.
- GET /api/admin/members returns 403 for a non-admin authenticated user (integration test importing `app`).
- A 400 response from POST /api/admin/credentials with a bad credential contains NO submitted password value and no Zod `received`/`issues` field (test asserts the exact submitted string absent).
- On a valid credential, the persisted member_credentials.encrypted_password != the plaintext (encryptPassword applied) and 200 is returned.
- After PUT /api/admin/calendars/:id/shared, exactly one calendar row has is_shared=1.
- No `console.log`/`console.error` of request bodies in `apps/api/src/routes/admin.ts` (`grep -ciE "console\.(log|error)\(.*(body|valid|password)" apps/api/src/routes/admin.ts` returns 0).
- `pnpm --filter @familysync/api test -- admin` passes all six behaviors.
</acceptance_criteria>
<done>adminRouter exists, guard-first, mounted; members/credentials/calendars/shared routes behave per contract; no-echo + 403 hard checks green.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 3: Member-scoped self-service credential endpoint POST /api/me/credential (RED→GREEN→REFACTOR)</name>
<files>apps/api/src/routes/me.ts, apps/api/tests/routes/admin.test.ts</files>
<read_first>
- apps/api/src/routes/me.ts (the file being modified — the meRouter export, the resolveUserId/dev-bypass + OIDC user resolution already present; the isAdmin/needsProviderSetup response added in Plan 02)
- apps/api/src/routes/admin.ts (from Task 2 — reuse the SAME credentialSchema shape minus userId, the SAME noEchoHook, the SAME validate→encrypt→sync sequence; extract any shared helper rather than duplicating the validate/encrypt logic)
- apps/api/src/broker/crypto.ts + client.ts + outboxWorker.ts (the shared path)
- .planning/phases/10-admin-role-settings/10-PATTERNS.md §Shared Patterns "resolveUserId" + 10-RESEARCH.md §Pattern 4, §Pitfall 6 (cross-member write — endpoint MUST use currentUserId from session, NEVER a body userId), §Architectural Responsibility Map (needsProviderSetup) + §UI-SPEC Surface 4 (self-service onboarding)
</read_first>
<behavior>
- Test (RED, Pitfall 6): POST /api/me/credential as user A with a body that includes `userId` for user B → the credential is written to user A (currentUserId), NOT user B; the body userId is ignored.
- Test: POST /api/me/credential with a valid credential → 200, stored encrypted for the current user, needsProviderSetup becomes false on the next /api/me; initial sync triggered.
- Test (Pitfall 7): POST /api/me/credential with a bad credential → 400 generic, no password echoed.
- Test: the endpoint does NOT require admin (a normal member can set their own credential) but is still behind the auth guard (unauthenticated → 401 from the outer band).
</behavior>
<action>
Add `POST /credential` to the meRouter in `apps/api/src/routes/me.ts` (final path `/api/me/credential`), member-scoped. Schema = the admin credentialSchema WITHOUT `userId` (`{ providerType: literal('caldav'), fastmailEmail, appPassword }`) + the SAME `noEchoHook`. The handler resolves `currentUserId` via the existing resolveUserId/dev-bypass pattern and ALWAYS writes to that id — it MUST NOT read a userId from the body (Pitfall 6). Reuse the SAME validate→encrypt→initial-sync path as admin Task 2 (extract a shared helper, e.g. `validateEncryptAndStoreCredential(userId, email, password)`, to avoid divergence — D-07 "identical path"). Add the self-service test cases to `apps/api/tests/routes/admin.test.ts` (or a sibling me-credential test — planner's call; keep them with the credential-surface tests). Write tests FIRST, confirm RED, implement to GREEN.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- credential 2>&1 | tail -15</automated>
</verify>
<acceptance_criteria>
- `apps/api/src/routes/me.ts` adds a `POST /credential` route; the final mounted path is `/api/me/credential`.
- A POST to /api/me/credential with a body `userId` for another user writes ONLY to the current session user (test proves the other user's credential is untouched).
- The self-service path reuses the same validate→encrypt→store logic as the admin path (shared helper; no duplicated encrypt/PROPFIND block) — `grep` shows a single shared function called by both routes.
- A bad credential returns 400 generic with no echoed password.
- A normal (non-admin) member can succeed on /api/me/credential (no requireAdmin on this route).
- `pnpm --filter @familysync/api test -- credential` passes all cases.
</acceptance_criteria>
<done>Member self-service credential endpoint exists, member-scoped (no cross-member write), shares the admin validate→encrypt→sync path, tests green.</done>
</task>
</tasks>
<artifacts_this_phase_produces>
New symbols/files created by this plan (excluded from drift verification):
- `export` on `loadClientForUser` + `triggerTargetedResync` in `apps/api/src/broker/outboxWorker.ts`
- `apps/api/src/routes/admin.ts` exporting `adminRouter` with `GET /members`, `POST /credentials`, `GET /calendars`, `PUT /calendars/:id/shared`
- `requireAdmin` applied as `adminRouter.use('*', requireAdmin)` (consumes the Plan-02 guard)
- `app.route('/api/admin', adminRouter)` mount in `apps/api/src/index.ts`
- `POST /api/me/credential` member-scoped self-service endpoint in `apps/api/src/routes/me.ts`
- shared `validateEncryptAndStoreCredential` helper (admin + self-service)
- `apps/api/tests/routes/admin.test.ts` (+ self-service credential test cases)
</artifacts_this_phase_produces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → /api/admin/* | untrusted authenticated request; must pass requireAdmin before any handler |
| client → /api/me/credential | authenticated member request; must be confined to the caller's own credential row |
| API → Fastmail CalDAV | the submitted app password leaves the trust boundary only to validate (PROPFIND); it must never be logged or echoed back to the client |
| app password → MariaDB | plaintext must be AES-256-GCM encrypted before any DB write |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-10-08 | Elevation of Privilege | non-admin hitting /api/admin/* | mitigate | adminRouter.use('*', requireAdmin) FIRST (Pitfall 9); integration tests import `app` and assert 403 on every admin route for a non-admin |
| T-10-09 | Information Disclosure | app password echoed in a Zod/validation error | mitigate | noEchoHook returns `{ error: 'Invalid request' }` with no result.error; test asserts the submitted password string is absent from any 400 body (Pitfall 7) |
| T-10-10 | Information Disclosure | app password logged | mitigate | No console.log of body/valid()/password in admin.ts or the me credential route (acceptance grep == 0) |
| T-10-11 | Information Disclosure | plaintext credential at rest | mitigate | encryptPassword (AES-256-GCM via crypto.ts) applied before the DB write; test asserts stored value != plaintext; no new crypto written |
| T-10-12 | Elevation of Privilege / IDOR | member self-service writes another member's credential | mitigate | /api/me/credential always uses currentUserId from the session and ignores any body userId (Pitfall 6); test proves the other user's row is untouched |
| T-10-13 | IDOR | admin rotating an arbitrary member's credential | accept | D-05 explicitly allows an admin to rotate ANY member's credential; this is gated by requireAdmin and is the intended capability (the self-service path remains member-scoped) |
| 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 -- admin && pnpm --filter @familysync/api test -- credential && pnpm --filter @familysync/api test -- outbox` all pass.
- `pnpm --filter @familysync/api exec tsc --noEmit` passes (run tsc separately per [[vitest-passes-tsc-fails]]).
- `grep -A1 "new Hono()" apps/api/src/routes/admin.ts` shows `.use('*', requireAdmin)` first.
- `grep -ciE "console\.(log|error)\(.*(body|valid|password)" apps/api/src/routes/admin.ts` == 0.
</verification>
<success_criteria>
- ADMIN-01: admin can rotate any member's credential, CalDAV-validated, encrypted, never echoed/logged (Success Criterion 2).
- ADMIN-02: admin sets exactly one shared calendar via the API (Success Criterion 3).
- ADMIN-03: every /api/admin/* route 403s non-admins (Success Criterion 1); guard inside the sub-router (Pitfall 9).
- D-07: member self-service credential, member-scoped, same path.
- Single shared surface — no /api/setup/* duplication (Phase 12 reuses these routes).
</success_criteria>
<output>
Create `.planning/phases/10-admin-role-settings/10-03-SUMMARY.md` when done.
</output>