docs(10-03): complete admin-api-surface plan summary and state update
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
---
|
||||
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
|
||||
Reference in New Issue
Block a user