Files
2026-06-18 22:21:38 -04:00

8.2 KiB

phase, plan, subsystem, tags, dependency_graph, tech_stack, key_files, decisions, metrics
phase plan subsystem tags dependency_graph tech_stack key_files decisions metrics
10-admin-role-settings 03 api-admin
admin
credentials
caldav
encryption
tdd
requireAdmin
self-service
no-echo
pitfall-7
pitfall-9
requires provides affects
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)
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)
Phase 10 Plan 04 (PWA admin UI consumes these routes)
Phase 12 (Setup Wizard reuses /api/admin/credentials and validateEncryptAndStoreCredential)
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
created modified
apps/api/src/broker/credentialSync.ts
apps/api/src/routes/admin.ts
apps/api/tests/routes/admin.test.ts
apps/api/src/broker/outboxWorker.ts
apps/api/src/routes/me.ts
apps/api/src/index.ts
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()
duration_seconds completed_date tasks_completed files_modified
720 2026-06-13 3 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