diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 8813fb4..78a0c51 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -181,7 +181,7 @@ Plans: **Wave 3** *(blocked on Wave 2 completion)* -- [ ] 10-03-PLAN.md — adminRouter (members/credentials/calendars/shared) + member self-service credential, validate→encrypt→sync (TDD) +- [x] 10-03-PLAN.md — adminRouter (members/credentials/calendars/shared) + member self-service credential, validate→encrypt→sync (TDD) **Wave 4** *(blocked on Wave 3 completion)* @@ -369,7 +369,7 @@ Plans: | 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 | | 8. Gitea CI | v1.1 | 4/4 | Complete | 2026-06-11 | | 9. Faster Write-Back | v1.1 | 2/2 | Complete | 2026-06-12 | -| 10. Admin Role & Settings | v1.1 | 2/4 | In Progress| | +| 10. Admin Role & Settings | v1.1 | 3/4 | In Progress| | | 11. Per-Event Reminders | v1.1 | 0/? | Not started | - | | 12. Initial Setup Wizard | v1.1 | 0/? | Not started | - | | 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 | diff --git a/.planning/STATE.md b/.planning/STATE.md index f4fc27e..be0f779 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,14 +3,14 @@ gsd_state_version: 1.0 milestone: v1.1 milestone_name: Operability & Polish status: executing -stopped_at: "Completed Phase 10 Plan 02 (admin role primitives: requireAdmin, upsertUser is_admin, /api/me isAdmin+needsProviderSetup)" -last_updated: "2026-06-13T18:39:38.129Z" -last_activity: 2026-06-13 -- Phase 10 execution started +stopped_at: "Completed Phase 10 Plan 03 (admin API surface: requireAdmin-gated adminRouter, shared validateEncryptAndStoreCredential, self-service /api/me/credential)" +last_updated: "2026-06-13T18:57:06.660Z" +last_activity: 2026-06-13 progress: total_phases: 20 completed_phases: 7 total_plans: 27 - completed_plans: 25 + completed_plans: 26 percent: 35 --- @@ -26,9 +26,9 @@ See: .planning/PROJECT.md (updated 2026-06-10) ## Current Position Phase: 10 (admin-role-settings) — EXECUTING -Plan: 3 of 4 +Plan: 4 of 4 Status: Ready to execute -Last activity: 2026-06-13 -- Phase 10 execution started +Last activity: 2026-06-13 ### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action) @@ -102,6 +102,7 @@ _Updated after each plan completion_ | Phase 16 P05 | 7 | 2 tasks | 1 files | | Phase 10-admin-role-settings P01 | 265 | - tasks | - files | | Phase 10-admin-role-settings P02 | 700 | 3 tasks | 6 files | +| Phase 10-admin-role-settings P03 | 720 | 3 tasks | 6 files | ## Accumulated Context @@ -239,7 +240,7 @@ Recent decisions affecting current work: ## Session Continuity -Last session: 2026-06-13T18:39:38.119Z +Last session: 2026-06-13T18:57:06.634Z Stopped at: Completed Phase 10 Plan 02 (admin role primitives: requireAdmin, upsertUser is_admin, /api/me isAdmin+needsProviderSetup) Resume file: None diff --git a/.planning/phases/10-admin-role-settings/10-03-SUMMARY.md b/.planning/phases/10-admin-role-settings/10-03-SUMMARY.md new file mode 100644 index 0000000..905ffd5 --- /dev/null +++ b/.planning/phases/10-admin-role-settings/10-03-SUMMARY.md @@ -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