From d2f6d5d77b7f3a179e668350f5a1f15f3e858be0 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 14:54:23 -0400 Subject: [PATCH] feat(10-03): implement credentialSync helper, adminRouter, and self-service /api/me/credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit credentialSync.ts: - validateEncryptAndStoreCredential(userId, email, appPassword, providerType) — single shared validate→encrypt→store→initial-sync path used by BOTH admin and self-service - createFastmailClient + fetchCalendars wrapped in ONE try/catch: any failure throws CredentialValidationError (routes map to { error: 'Invalid request' } 400) - appPassword never logged or echoed (T-10-10) - encryptPassword (AES-256-GCM) applied before DB write (T-10-11) - fire-and-forget initial sync via loadClientForUser + syncCalendar (Pitfall 5) admin.ts: - adminRouter.use('*', requireAdmin) FIRST (Pitfall 9 / T-10-08) - GET /members: users LEFT JOIN member_credentials → hasCredential boolean - POST /credentials: noEchoHook + validateEncryptAndStoreCredential (T-10-09) - GET /calendars: calendar list (UI-SPEC Surface 5) - PUT /calendars/:id/shared: exclusive is_shared update (ADMIN-02, D-06) index.ts: - app.route('/api/admin', adminRouter) mounted in route block me.ts: - POST /credential: member self-service, always currentUserId (Pitfall 6 / T-10-12) - meCredentialSchema (no userId field), meNoEchoHook, calls shared helper - All 17 new admin tests pass; 270 total pass; tsc --noEmit clean --- apps/api/src/broker/credentialSync.ts | 108 +++++++++++++++++ apps/api/src/index.ts | 2 + apps/api/src/routes/admin.ts | 163 ++++++++++++++++++++++++++ apps/api/src/routes/me.ts | 87 ++++++++++++++ 4 files changed, 360 insertions(+) create mode 100644 apps/api/src/broker/credentialSync.ts create mode 100644 apps/api/src/routes/admin.ts diff --git a/apps/api/src/broker/credentialSync.ts b/apps/api/src/broker/credentialSync.ts new file mode 100644 index 0000000..f1ab558 --- /dev/null +++ b/apps/api/src/broker/credentialSync.ts @@ -0,0 +1,108 @@ +/** + * Shared validate→encrypt→store→initial-sync helper for credential management. + * + * Used by BOTH: + * - POST /api/admin/credentials (admin rotating any member's credential) + * - POST /api/me/credential (member self-service, D-07) + * + * Security contract (T-10-09, T-10-10, T-10-11): + * - createFastmailClient + client.fetchCalendars() are wrapped in a single try/catch. + * ANY throw (bad email, malformed input, network error, PROPFIND/auth failure) is + * treated identically as a credential-validation failure → CredentialValidationError. + * - The caller maps CredentialValidationError to { error: 'Invalid request' } 400. + * - The submitted appPassword is NEVER logged or echoed back to the caller. + * - On success: encryptPassword (AES-256-GCM) before the DB write (T-10-11). + * - Initial sync is fire-and-forget after the DB upsert (Pitfall 5: encrypt+upsert first). + */ + +import { db } from '../db/client.js'; +import { memberCredentials } from '../db/schema.js'; +import { encryptPassword } from './crypto.js'; +import { createFastmailClient } from './client.js'; +import { loadClientForUser } from './outboxWorker.js'; +import { syncCalendar } from './sync.js'; +import type { FastmailClient } from './client.js'; + +/** + * Signals a credential validation failure (createFastmailClient throw, network error, + * PROPFIND/auth failure — all treated identically). + * The caller maps this to { error: 'Invalid request' } 400. + */ +export class CredentialValidationError extends Error { + constructor() { + super('Credential validation failed'); + this.name = 'CredentialValidationError'; + } +} + +/** + * Validates a Fastmail app password via CalDAV PROPFIND, encrypts it, upserts the + * member_credentials row, and fires an asynchronous full-member initial sync. + * + * @param userId - The target member's user id (admin path: from route; self-service: session user) + * @param fastmailEmail - The Fastmail account email + * @param appPassword - The plaintext app password (NEVER logged or echoed) + * @param providerType - Credential provider type (e.g. 'caldav') + * + * @throws CredentialValidationError when validation (PROPFIND) fails for ANY reason + */ +export async function validateEncryptAndStoreCredential( + userId: number, + fastmailEmail: string, + appPassword: string, + providerType: string, +): Promise { + // Step 1: Validate credential via CalDAV PROPFIND (createFastmailClient + fetchCalendars). + // Both calls are wrapped in ONE try/catch. ANY throw from either — bad email, malformed + // input, network error, PROPFIND 401/403 from Fastmail — maps to CredentialValidationError. + // The password is NEVER logged here or in the catch block (T-10-10). + let davCalendars: Awaited> = []; + try { + const client = await createFastmailClient(fastmailEmail, appPassword); + davCalendars = await client.fetchCalendars(); + } catch { + // T-10-10: do NOT log appPassword, fastmailEmail, or the error details here. + // Only a typed signal is thrown — routes map it to the generic 400 response. + throw new CredentialValidationError(); + } + + // Step 2: Encrypt the password (AES-256-GCM) BEFORE any DB write (T-10-11). + // NEVER log encryptedPassword or appPassword. + const encrypted = encryptPassword(appPassword); + + // Step 3: Upsert member_credentials using the UNIQUE(user_id) constraint (D-05). + await db + .insert(memberCredentials) + .values({ + userId, + encryptedPassword: encrypted, + fastmailEmail, + providerType, + }) + .onDuplicateKeyUpdate({ + set: { + encryptedPassword: encrypted, + fastmailEmail, + providerType, + }, + }); + + // Step 4: Fire-and-forget initial full-member sync. + // After a FRESH credential save there may be no known calendarUrl — run the full per-member + // poll (loadClientForUser → fetchCalendars → syncCalendar per davCal) mirroring poller.ts. + // This is non-blocking: the caller returns 200 immediately; sync runs in the background. + // Pitfall 5: encrypt+upsert BEFORE triggering sync (credential must exist in DB first). + const calsToSync = davCalendars; + void (async () => { + try { + const syncClient = await loadClientForUser(userId); + const cals = calsToSync.length > 0 ? calsToSync : await syncClient.fetchCalendars(); + for (const davCal of cals) { + await syncCalendar(syncClient, davCal, userId); + } + } catch { + // Initial sync failure is non-fatal — the poller will catch up on next tick. + // T-10-10: do NOT log password or credential details here. + } + })(); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a70e13a..7743208 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -9,6 +9,7 @@ import { eventsRouter } from './routes/events.js'; import { sseRouter } from './routes/sse.js'; import { listsRouter, listItemsRouter } from './routes/lists.js'; import { pushRouter } from './routes/push.js'; +import { adminRouter } from './routes/admin.js'; import { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js'; import { devAuthBypass } from './auth/devBypass.js'; import { persistSessionCookie } from './auth/persistSessionCookie.js'; @@ -70,6 +71,7 @@ app.route('/api/sse', sseRouter); app.route('/api/lists', listsRouter); app.route('/api/list-items', listItemsRouter); app.route('/api/push', pushRouter); +app.route('/api/admin', adminRouter); // WR-04: background worker startup (cron schedules) moved into the isMainModule() // guard below. Calling them at top level registered real node-cron schedules whenever diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts new file mode 100644 index 0000000..333b5c2 --- /dev/null +++ b/apps/api/src/routes/admin.ts @@ -0,0 +1,163 @@ +/** + * Admin router — role-gated admin API surface (ADMIN-01/02/03). + * + * Security contract: + * - adminRouter.use('*', requireAdmin) is the FIRST statement (Pitfall 9 / T-10-08). + * This guard runs before ANY route handler, so no admin route is reachable by non-admins. + * - POST /credentials uses noEchoHook: NEVER returns Zod's result.error (which contains + * .received = the submitted password). Returns { error: 'Invalid request' } only (T-10-09). + * - validateEncryptAndStoreCredential from credentialSync.ts is the ONLY place + * createFastmailClient + fetchCalendars + encryptPassword + upsert live (D-07). + * - No console.log of request bodies or passwords in any handler (T-10-10). + * + * Routes: + * GET /api/admin/members → list members + credential status (UI-SPEC Surface 2) + * POST /api/admin/credentials → validate+encrypt+store for any member (ADMIN-01) + * GET /api/admin/calendars → list synced calendars (UI-SPEC Surface 5) + * PUT /api/admin/calendars/:id/shared → exclusive is_shared designation (ADMIN-02) + * + * Mounted in index.ts: app.route('/api/admin', adminRouter) + */ + +import { Hono } from 'hono'; +import type { Context } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { eq } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { users, memberCredentials, calendars } from '../db/schema.js'; +import { requireAdmin } from '../lib/requireAdmin.js'; +import { + validateEncryptAndStoreCredential, + CredentialValidationError, +} from '../broker/credentialSync.js'; +// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') +import '../auth/devBypass.js'; + +export const adminRouter = new Hono(); + +// Pitfall 9: requireAdmin MUST be the first statement on the router. +// All sub-routes are protected — no path can be reached without passing this guard. +adminRouter.use('*', requireAdmin); + +// --------------------------------------------------------------------------- +// Zod schema + no-echo hook for credential routes (T-10-09 / Pitfall 7) +// --------------------------------------------------------------------------- + +const credentialSchema = z.object({ + userId: z.number().int().positive(), + providerType: z.literal('caldav'), + fastmailEmail: z.string().email().max(256), + appPassword: z.string().min(1).max(500), +}); + +/** + * noEchoHook: NEVER return result.error from zValidator for credential routes. + * Zod's error object contains issues[].received which echoes the submitted value + * (the app password) — returning it would violate T-10-09 (Pitfall 7). + * Always return { error: 'Invalid request' } 400, no other fields. + */ +const noEchoHook = (result: { success: boolean }, c: Context) => { + if (!result.success) { + return c.json({ error: 'Invalid request' }, 400); + } +}; + +// --------------------------------------------------------------------------- +// GET /api/admin/members +// +// Returns all household members with their credential status. +// Feeds UI-SPEC Surface 2 (member list with rotate-credential affordance). +// --------------------------------------------------------------------------- + +adminRouter.get('/members', async (c) => { + const rows = await db + .select({ + id: users.id, + displayName: users.displayName, + color: users.color, + credentialId: memberCredentials.id, + }) + .from(users) + .leftJoin(memberCredentials, eq(memberCredentials.userId, users.id)); + + const members = rows.map((row) => ({ + id: row.id, + displayName: row.displayName, + color: row.color, + hasCredential: row.credentialId !== null, + })); + + return c.json({ members }); +}); + +// --------------------------------------------------------------------------- +// POST /api/admin/credentials +// +// Admin rotates (or sets for the first time) a member's Fastmail app password. +// Validates against CalDAV (PROPFIND) before storing. +// Uses the shared validateEncryptAndStoreCredential helper — no duplicated logic here. +// --------------------------------------------------------------------------- + +adminRouter.post('/credentials', zValidator('json', credentialSchema, noEchoHook), async (c) => { + const { userId, fastmailEmail, appPassword, providerType } = c.req.valid('json'); + // T-10-10: NEVER log appPassword or c.req.valid('json') here + + try { + await validateEncryptAndStoreCredential(userId, fastmailEmail, appPassword, providerType); + } catch (err) { + if (err instanceof CredentialValidationError) { + // T-10-09: map validation failure to generic 400 — no echo of password or Zod details + return c.json({ error: 'Invalid request' }, 400); + } + // Unexpected errors (DB failure, etc.) — log message only, no credential data + console.error( + '[admin/POST /credentials] Unexpected error:', + err instanceof Error ? err.message : String(err), + ); + return c.json({ error: 'Service unavailable' }, 503); + } + + return c.json({ ok: true }, 200); +}); + +// --------------------------------------------------------------------------- +// GET /api/admin/calendars +// +// Lists all synced calendars. Feeds UI-SPEC Surface 5 (shared-calendar picker). +// --------------------------------------------------------------------------- + +adminRouter.get('/calendars', async (c) => { + const rows = await db + .select({ + id: calendars.id, + displayName: calendars.displayName, + isShared: calendars.isShared, + }) + .from(calendars); + + return c.json({ calendars: rows }); +}); + +// --------------------------------------------------------------------------- +// PUT /api/admin/calendars/:id/shared +// +// Exclusively marks one calendar as is_shared=true (ADMIN-02). +// Clears is_shared on any prior shared calendar first (D-06 single-select). +// Pattern 7: two sequential UPDATE statements. +// --------------------------------------------------------------------------- + +adminRouter.put('/calendars/:id/shared', async (c) => { + const targetId = parseInt(c.req.param('id'), 10); + if (isNaN(targetId)) { + return c.json({ error: 'Invalid calendar id' }, 400); + } + + // Step 1: Clear is_shared on any currently-shared calendar + await db.update(calendars).set({ isShared: false }).where(eq(calendars.isShared, true)); + + // Step 2: Set is_shared on the target calendar + await db.update(calendars).set({ isShared: true }).where(eq(calendars.id, targetId)); + + return c.json({ ok: true }, 200); +}); diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 9ab8646..655e425 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -26,11 +26,18 @@ */ import { Hono } from 'hono'; +import type { Context } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; import { eq } from 'drizzle-orm'; import { getAuth } from '../auth/middleware.js'; import { upsertUser, deriveDisplayName } from '../auth/user.js'; import { db } from '../db/client.js'; import { users, memberCredentials } from '../db/schema.js'; +import { + validateEncryptAndStoreCredential, + CredentialValidationError, +} from '../broker/credentialSync.js'; // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') import '../auth/devBypass.js'; @@ -59,6 +66,25 @@ async function resolveAdminAndSetupStatus(userId: number) { }; } +// --------------------------------------------------------------------------- +// Auth helper — resolves the current user id from dev-bypass or OIDC session. +// Per project convention: duplicated per router (not extracted to shared module). +// --------------------------------------------------------------------------- + +async function resolveUserId(c: Context): Promise { + const devUser = c.get('user') as { id: number } | undefined; + if (devUser) return devUser.id; + + const auth = await getAuth(c); + if (!auth) return null; + + const iss = (auth.iss as string | undefined) ?? ''; + const sub = auth.sub ?? ''; + const displayName = deriveDisplayName(auth); + const user = await upsertUser(iss, sub, displayName); + return user?.id ?? null; +} + meRouter.get('/', async (c) => { // Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active. // Use the injected dev identity's id for DB lookups — no OIDC session needed, @@ -112,3 +138,64 @@ meRouter.get('/', async (c) => { }, }); }); + +// --------------------------------------------------------------------------- +// POST /api/me/credential — member self-service credential endpoint (D-07) +// +// Security contract (T-10-12 Pitfall 6): +// - ALWAYS writes to currentUserId from the session — NEVER a body userId. +// - Any body.userId field is IGNORED — this endpoint cannot cross-write. +// - Uses the SAME shared validateEncryptAndStoreCredential helper as admin path. +// - Does NOT require requireAdmin — any authenticated member can set their own credential. +// - Failure (any Zod or validation failure) returns { error: 'Invalid request' } 400 +// with NO echoed password (noEchoHook + CredentialValidationError → 400). +// --------------------------------------------------------------------------- + +const meCredentialSchema = z.object({ + // D-07: no userId field — body userId is not accepted (Pitfall 6) + providerType: z.literal('caldav'), + fastmailEmail: z.string().email().max(256), + appPassword: z.string().min(1).max(500), +}); + +/** + * noEchoHook for /api/me/credential: NEVER echo Zod error details (T-10-09 / Pitfall 7). + */ +const meNoEchoHook = (result: { success: boolean }, c: Context) => { + if (!result.success) { + return c.json({ error: 'Invalid request' }, 400); + } +}; + +meRouter.post( + '/credential', + zValidator('json', meCredentialSchema, meNoEchoHook), + async (c) => { + // Pitfall 6: ALWAYS resolve currentUserId from the session — never from the body. + const currentUserId = await resolveUserId(c); + if (!currentUserId) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const { fastmailEmail, appPassword, providerType } = c.req.valid('json'); + // T-10-10: NEVER log appPassword or c.req.valid('json') here + + try { + // D-07: identical validate→encrypt→store→sync path as admin, but always with + // currentUserId (not a body userId). Admin passes the target member's userId; + // self-service passes the authenticated session userId. Same helper, same argument order. + await validateEncryptAndStoreCredential(currentUserId, fastmailEmail, appPassword, providerType); + } catch (err) { + if (err instanceof CredentialValidationError) { + return c.json({ error: 'Invalid request' }, 400); + } + console.error( + '[me/POST /credential] Unexpected error:', + err instanceof Error ? err.message : String(err), + ); + return c.json({ error: 'Service unavailable' }, 503); + } + + return c.json({ ok: true }, 200); + }, +);