/** * 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); } // CR-01: verify the target exists and flip the shared lane atomically. // Without the existence check + transaction, a bad/stale id would clear the // current shared calendar in step 1 and update 0 rows in step 2 — silently // leaving the household with NO shared calendar while still returning ok. const found = await db.transaction(async (tx) => { const [target] = await tx .select({ id: calendars.id }) .from(calendars) .where(eq(calendars.id, targetId)) .limit(1); if (!target) return false; // Step 1: Clear is_shared on any currently-shared calendar await tx.update(calendars).set({ isShared: false }).where(eq(calendars.isShared, true)); // Step 2: Set is_shared on the target calendar (D-06 single-select) await tx.update(calendars).set({ isShared: true }).where(eq(calendars.id, targetId)); return true; }); if (!found) { return c.json({ error: 'Calendar not found' }, 404); } return c.json({ ok: true }, 200); });