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

21 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 03 tdd 3
10-01
10-02
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
true
ADMIN-01
ADMIN-02
ADMIN-03
truths artifacts key_links
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
path provides exports min_lines
apps/api/src/routes/admin.ts adminRouter guarded by requireAdmin (.use('*', ...) first); GET /members, POST /credentials, GET /calendars, PUT /calendars/:id/shared
adminRouter
60
path provides contains
apps/api/src/routes/me.ts POST /api/me/credential member-scoped self-service (currentUserId only) credential
path provides contains
apps/api/src/index.ts app.route('/api/admin', adminRouter) mounted in the existing auth band adminRouter
from to via pattern
apps/api/src/routes/admin.ts requireAdmin adminRouter.use('*', requireAdmin) as the first statement (Pitfall 9) adminRouter.use('*', requireAdmin)
from to via pattern
apps/api/src/routes/admin.ts broker validate→encrypt→sync createFastmailClient + fetchCalendars, encryptPassword, exported triggerTargetedResync/loadClientForUser encryptPassword
from to via pattern
apps/api/src/index.ts adminRouter app.route('/api/admin', adminRouter) app.route('/api/admin'
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.

<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 @.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 Task 1: Promote broker resync helpers to exports apps/api/src/broker/outboxWorker.ts - 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 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. 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 - `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). Both broker resync helpers are exported, bodies unchanged, broker tests green. Task 2: adminRouter — guard + members + credentials + shared-calendar (RED→GREEN→REFACTOR) apps/api/src/routes/admin.ts, apps/api/src/index.ts, apps/api/tests/routes/admin.test.ts - 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) - 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. 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). cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- admin 2>&1 | tail -20 - `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. adminRouter exists, guard-first, mounted; members/credentials/calendars/shared routes behave per contract; no-echo + 403 hard checks green. Task 3: Member-scoped self-service credential endpoint POST /api/me/credential (RED→GREEN→REFACTOR) apps/api/src/routes/me.ts, apps/api/tests/routes/admin.test.ts - 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) - 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). 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. cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- credential 2>&1 | tail -15 - `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. Member self-service credential endpoint exists, member-scoped (no cross-member write), shares the admin validate→encrypt→sync path, tests green.

<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>
- `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.

<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>
Create `.planning/phases/10-admin-role-settings/10-03-SUMMARY.md` when done.