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