--- phase: "10-admin-role-settings" plan: "03" subsystem: "api-admin" tags: ["admin", "credentials", "caldav", "encryption", "tdd", "requireAdmin", "self-service", "no-echo", "pitfall-7", "pitfall-9"] dependency_graph: requires: - "users.is_admin column (10-01)" - "member_credentials.UNIQUE(user_id) constraint (10-01)" - "requireAdmin MiddlewareHandler (10-02)" - "isAdmin + needsProviderSetup on /api/me (10-02)" - "loadClientForUser + triggerTargetedResync in outboxWorker (this plan, Task 1)" provides: - "export loadClientForUser from outboxWorker.ts" - "export triggerTargetedResync from outboxWorker.ts" - "validateEncryptAndStoreCredential(userId, email, appPassword, providerType) in credentialSync.ts" - "CredentialValidationError typed failure signal in credentialSync.ts" - "adminRouter with requireAdmin guard-first, GET /members, POST /credentials, GET /calendars, PUT /calendars/:id/shared" - "app.route('/api/admin', adminRouter) mount in index.ts" - "POST /api/me/credential member-scoped self-service endpoint" - "Integration tests: admin.test.ts (17 test cases)" affects: - "Phase 10 Plan 04 (PWA admin UI consumes these routes)" - "Phase 12 (Setup Wizard reuses /api/admin/credentials and validateEncryptAndStoreCredential)" tech_stack: added: [] patterns: - "Shared validate→encrypt→store→sync helper (credentialSync.ts) imported by both admin and me routes" - "noEchoHook on zValidator for credential routes — returns { error: 'Invalid request' } only" - "CredentialValidationError typed exception for all PROPFIND/auth failure modes" - "adminRouter.use('*', requireAdmin) as first statement (Pitfall 9)" - "exclusive is_shared update via two sequential Drizzle UPDATEs (Pattern 7)" - "fire-and-forget initial-sync via loadClientForUser + syncCalendar per davCal" - "TDD RED (test(10-03)) → GREEN (feat(10-03)) commit discipline" key_files: created: - "apps/api/src/broker/credentialSync.ts" - "apps/api/src/routes/admin.ts" - "apps/api/tests/routes/admin.test.ts" modified: - "apps/api/src/broker/outboxWorker.ts" - "apps/api/src/routes/me.ts" - "apps/api/src/index.ts" decisions: - "credentialSync.ts wraps BOTH createFastmailClient AND fetchCalendars in ONE try/catch — any failure from either is a CredentialValidationError; routes map to generic 400 (all failure modes indistinguishable per Pitfall 7)" - "fire-and-forget initial sync uses davCalendars from the PROPFIND step if available, falling back to loadClientForUser + fetchCalendars — avoids a second PROPFIND round-trip when cals are already known" - "noEchoHook returns { error: 'Invalid request' } 400 — never result.error (which contains .received = submitted password)" - "POST /api/me/credential meCredentialSchema excludes userId field (Pitfall 6 / T-10-12) — resolveUserId from session only" - "adminRouter.use('*', requireAdmin) is first executable statement after export const adminRouter = new Hono()" metrics: duration_seconds: 720 completed_date: "2026-06-13" tasks_completed: 3 files_modified: 6 --- # Phase 10 Plan 03: Admin API Surface + Shared Credential Helper Summary **One-liner:** Single shared `validateEncryptAndStoreCredential` helper (CalDAV PROPFIND + AES-256-GCM encrypt + upsert + fire-and-forget sync) consumed by `adminRouter` (requireAdmin-first, ADMIN-01/02/03) and `/api/me/credential` self-service (D-07, member-scoped). ## Tasks Completed | Task | Name | Commits | Files | |------|------|---------|-------| | 1 | Promote broker resync helpers to exports | ac36e10 | apps/api/src/broker/outboxWorker.ts | | 2 RED | Write failing tests for admin surface | 037a7ed | apps/api/tests/routes/admin.test.ts | | 2+3 GREEN | credentialSync helper + adminRouter + /api/me/credential | d2f6d5d | credentialSync.ts, admin.ts, index.ts, me.ts | ## What Was Built ### Task 1: Promote broker resync helpers to exports Added `export` keyword to `loadClientForUser` (line 271) and `triggerTargetedResync` (line 302) in `outboxWorker.ts`. Function bodies are byte-for-byte unchanged — only the visibility changed. The outbox drain cycle and `setInterval` scheduling are untouched. No `node-cron` reintroduced. ### Task 2+3: RED → GREEN **RED:** `apps/api/tests/routes/admin.test.ts` created with 17 test cases covering: - T-10-08 (Pitfall 9): GET /api/admin/members, POST /credentials, GET /calendars, PUT /calendars/:id/shared all return 403 for non-admin - T-10-09 (Pitfall 7): POST /api/admin/credentials with PROPFIND auth failure, createFastmailClient throw, network error → all return 400 `{ error: 'Invalid request' }` with the submitted password string absent from the response - T-10-09: zValidator schema failure → same generic 400, no Zod .received echo - T-10-11: valid credential → 200, stored AES-256-GCM encrypted (not plaintext) - ADMIN-02: PUT /api/admin/calendars/:id/shared → exactly one calendar has is_shared=1 - T-10-12 (Pitfall 6): POST /api/me/credential with body userId for another user → credential written only to session user - D-07: non-admin member can POST /api/me/credential (no requireAdmin required) All 17 tests confirmed RED (404/assertion failures) before implementation. **GREEN:** `apps/api/src/broker/credentialSync.ts` — shared helper: 1. `createFastmailClient(email, appPassword)` + `await client.fetchCalendars()` in ONE try/catch → any failure throws `CredentialValidationError` (typed; no password detail in the exception) 2. `encryptPassword(appPassword)` → AES-256-GCM JSON ciphertext 3. `db.insert(memberCredentials).onDuplicateKeyUpdate(...)` → upsert (UNIQUE(user_id) from 10-01) 4. Fire-and-forget: `loadClientForUser(userId)` → `syncCalendar(...)` per davCal `apps/api/src/routes/admin.ts`: - `export const adminRouter = new Hono()` immediately followed by `adminRouter.use('*', requireAdmin)` (Pitfall 9) - Side-effect import of `../auth/devBypass.js` for ContextVariableMap - `GET /members`: users LEFT JOIN member_credentials → `{ members: [{ id, displayName, color, hasCredential }] }` - `POST /credentials`: `zValidator('json', credentialSchema, noEchoHook)` → `validateEncryptAndStoreCredential(body.userId, ...)` → 200 or 400/503 - `GET /calendars`: `{ calendars: [{ id, displayName, isShared }] }` - `PUT /calendars/:id/shared`: clear all `is_shared=true`, set target → 200 `apps/api/src/index.ts`: `app.route('/api/admin', adminRouter)` added after existing route block. `apps/api/src/routes/me.ts`: - `POST /credential` added with `meCredentialSchema` (no userId field — Pitfall 6) - `meNoEchoHook` identical pattern to admin noEchoHook - Handler: `resolveUserId(c)` from session → `validateEncryptAndStoreCredential(currentUserId, ...)` — body userId ignored ## Deviations from Plan None — plan executed exactly as written. ## Known Stubs None. All routes are fully implemented with real DB and real CalDAV integration (mocked in tests). No placeholder data. ## Threat Flags No new threat surface beyond the plan's threat model. All T-10-08 through T-10-13 mitigations implemented: - T-10-08: adminRouter.use('*', requireAdmin) guard-first - T-10-09: noEchoHook + CredentialValidationError → generic 400 - T-10-10: no console.log of body/password in admin.ts, me.ts, or credentialSync.ts - T-10-11: encryptPassword applied before DB write; tests assert encrypted != plaintext - T-10-12: /api/me/credential uses currentUserId from session exclusively; test proves other user's row is untouched ## Self-Check: PASSED - `grep -n "adminRouter.use" apps/api/src/routes/admin.ts` shows `.use('*', requireAdmin)` at line 41 (first executable statement after router creation on line 37): PASS - `grep "app.route('/api/admin'" apps/api/src/index.ts` confirms mount: PASS - `grep "validateEncryptAndStoreCredential" apps/api/src/routes/admin.ts` shows import + call: PASS - `grep "validateEncryptAndStoreCredential" apps/api/src/routes/me.ts` shows import + call: PASS - `grep "createFastmailClient" apps/api/src/routes/admin.ts apps/api/src/routes/me.ts` — only in a comment (not in route executable code): PASS - 270/270 tests pass (27 test files): PASS - `pnpm --filter @familysync/api exec tsc --noEmit` exits 0: PASS - Commits ac36e10 (Task 1), 037a7ed (RED), d2f6d5d (GREEN) in git log: PASS