/** * 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 + isAdmin (UI-SPEC Surface 2) * POST /api/admin/members → create local member: users row + local_credentials (AUTH-LOCAL-07) * PATCH /api/admin/members/:id → update member profile: displayName and/or isAdmin (Plan 20-01) * POST /api/admin/members/:id/password → admin reset local member password (AUTH-LOCAL-08) * 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, sql } from 'drizzle-orm'; import { db } from '../db/client.js'; import { users, memberCredentials, calendars, appConfig, localCredentials } from '../db/schema.js'; import { requireAdmin } from '../lib/requireAdmin.js'; import { isValidIanaTimezone, resolveHouseholdTimezone } from '../lib/householdTimezone.js'; import { validateEncryptAndStoreCredential, CredentialValidationError, } from '../broker/credentialSync.js'; import { hashPassword } from '../auth/localCredentials.js'; import { resetLoginAttempts } from './localAuth.js'; import { COLOR_PALETTE } from '../auth/user.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), }); // Timezone schema — no noEchoHook needed (timezone strings are non-sensitive, T-18-06) const timezoneSchema = z.object({ timezone: z .string() .min(1) .max(64) .refine(isValidIanaTimezone, { message: 'Invalid IANA timezone identifier' }), }); /** * 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); } }; /** * WR-07: strictly parse a positive-integer route param. parseInt('12abc', 10) returns 12 * and passes an isNaN guard, silently accepting malformed ids. Number('12abc') is NaN, so * Number.isInteger(Number(raw)) rejects trailing garbage. Returns null for anything that is * not a whole positive integer (empty, '12abc', '1.5', '-3', '0', etc.) so the caller can 400. */ function parsePositiveIntParam(raw: string | undefined): number | null { if (raw === undefined || raw.trim() === '') return null; const n = Number(raw); if (!Number.isInteger(n) || n <= 0) return null; return n; } // --------------------------------------------------------------------------- // GET /api/admin/members // // Returns all household members with their credential status. // Feeds UI-SPEC Surface 2 (member list with rotate-credential affordance). // Includes hasLocalCredential (AUTH-LOCAL-17) alongside existing hasCredential. // --------------------------------------------------------------------------- adminRouter.get('/members', async (c) => { const rows = await db .select({ id: users.id, displayName: users.displayName, color: users.color, isAdmin: users.isAdmin, // Plan 20-01: feeds editor admin toggle initial state (D-02) credentialId: memberCredentials.id, localCredId: localCredentials.id, // LEFT JOIN — null when no local_credentials row }) .from(users) .leftJoin(memberCredentials, eq(memberCredentials.userId, users.id)) .leftJoin(localCredentials, eq(localCredentials.userId, users.id)); // AUTH-LOCAL-17 const members = rows.map((row) => ({ id: row.id, displayName: row.displayName, color: row.color, isAdmin: row.isAdmin, // Plan 20-01 hasCredential: row.credentialId !== null, hasLocalCredential: row.localCredId !== null, // AUTH-LOCAL-17 })); return c.json({ members }); }); // --------------------------------------------------------------------------- // POST /api/admin/members // // Admin creates a new local member: inserts a users row and a local_credentials // row with a hashed initial password in a single transaction (AUTH-LOCAL-07). // Security: // - noEchoHook: never echoes Zod errors containing the submitted password (T-19-06) // - requireAdmin: already enforced by adminRouter.use('*', requireAdmin) (T-19-05) // - db.transaction: rolls back both inserts on username conflict (T-19-10) // --------------------------------------------------------------------------- const createMemberSchema = z.object({ displayName: z.string().min(1).max(256), username: z.string().min(1).max(128), initialPassword: z.string().min(8), }); adminRouter.post('/members', zValidator('json', createMemberSchema, noEchoHook), async (c) => { const { displayName, username, initialPassword } = c.req.valid('json'); // T-19-06: NEVER log request body, displayName, username, or initialPassword here // Assign the first palette color not already in use (mirrors upsertUser color logic) const usedRows = await db.select({ color: users.color }).from(users); const usedColors = new Set(usedRows.map((r) => r.color)); const color = COLOR_PALETTE.find((c) => !usedColors.has(c)) ?? COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length]; // WR-03: hash the initial password BEFORE opening the transaction so the (now async, // threadpool) scrypt work does not hold the DB transaction open for its duration. const initialPasswordHash = await hashPassword(initialPassword); try { let newUserId: number; // T-19-10: atomic transaction — both inserts succeed or both roll back await db.transaction(async (tx) => { // Insert the new users row (no oidcIss/oidcSub — local-only member) const [inserted] = await tx .insert(users) .values({ displayName, color, isAdmin: false, claimed: false, // no OIDC identity bound yet }) .$returningId(); newUserId = inserted.id; // Insert local_credentials row with hashed initial password // If username is already in use, the UNIQUE constraint fires here and rolls back await tx.insert(localCredentials).values({ userId: newUserId, username, passwordHash: initialPasswordHash, }); }); // Return the new user's id (the PWA uses it to navigate to the member) return c.json({ id: newUserId! }, 201); } catch (err) { // Username uniqueness violation — UNIQUE constraint on local_credentials.username. // Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY. const isDup = (err instanceof Error && err.message.includes('ER_DUP_ENTRY')) || (err != null && typeof err === 'object' && 'code' in err && (err as { code?: string }).code === 'ER_DUP_ENTRY') || (err != null && typeof err === 'object' && 'cause' in err && (err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY'); if (isDup) { return c.json({ error: 'Username already in use' }, 409); } // Unexpected errors — log message only, never the body or password console.error( '[admin/POST /members] Unexpected error:', err instanceof Error ? err.message : String(err), ); return c.json({ error: 'Service unavailable' }, 503); } }); // --------------------------------------------------------------------------- // PATCH /api/admin/members/:id // // Updates a member's displayName and/or isAdmin flag (Plan 20-01, D-02, D-03). // Security: // - requireAdmin: inherited from adminRouter.use('*', requireAdmin) (D-02 — no second guard) // - noEchoHook: applied for consistency with other admin write routes (T-20-04) // - D-03 last-admin guard: rejects isAdmin=false when target is the sole remaining admin (T-20-02) // - parsePositiveIntParam: rejects malformed ids (T-20-03) // --------------------------------------------------------------------------- const updateMemberSchema = z.object({ displayName: z.string().min(1).max(256).optional(), isAdmin: z.boolean().optional(), }); adminRouter.patch('/members/:id', zValidator('json', updateMemberSchema, noEchoHook), async (c) => { const targetId = parsePositiveIntParam(c.req.param('id')); if (targetId === null) { return c.json({ error: 'Invalid member id' }, 400); } const { displayName, isAdmin } = c.req.valid('json'); // T-20-04: NEVER log request body // Verify the target user exists (404 if not) const [target] = await db .select({ id: users.id, isAdmin: users.isAdmin }) .from(users) .where(eq(users.id, targetId)) .limit(1); if (!target) { return c.json({ error: 'Member not found' }, 404); } // D-03 last-admin guard: reject demotion of the only remaining admin (T-20-02) if (isAdmin === false && target.isAdmin) { const [{ count }] = await db .select({ count: sql`COUNT(*)` }) .from(users) .where(eq(users.isAdmin, true)); if (Number(count) <= 1) { return c.json({ error: 'Cannot remove the last admin' }, 409); } } // Build a partial set() from whichever fields are present const updates: { displayName?: string; isAdmin?: boolean } = {}; if (displayName !== undefined) updates.displayName = displayName; if (isAdmin !== undefined) updates.isAdmin = isAdmin; try { await db.update(users).set(updates).where(eq(users.id, targetId)); return c.json({ ok: true }, 200); } catch (err) { console.error( '[admin/PATCH /members/:id] Unexpected error:', err instanceof Error ? err.message : String(err), ); return c.json({ error: 'Service unavailable' }, 503); } }); // --------------------------------------------------------------------------- // POST /api/admin/members/:id/password // // Admin resets a local member's password without knowing the current one (AUTH-LOCAL-08). // Security: // - noEchoHook: never echoes Zod errors (T-19-06) // - requireAdmin: enforced by adminRouter.use('*', requireAdmin) (T-19-05) // - No current password required — admin-only capability // --------------------------------------------------------------------------- const resetPasswordSchema = z.object({ newPassword: z.string().min(8), }); adminRouter.post( '/members/:id/password', zValidator('json', resetPasswordSchema, noEchoHook), async (c) => { const targetId = parsePositiveIntParam(c.req.param('id')); if (targetId === null) { return c.json({ error: 'Invalid member id' }, 400); } const { newPassword } = c.req.valid('json'); // T-19-06: NEVER log newPassword or the request body // Verify the target user has a local_credentials row (404 if not). // Also read the username so we can clear any login lockout for it (CR-04). const [credRow] = await db .select({ id: localCredentials.id, username: localCredentials.username }) .from(localCredentials) .where(eq(localCredentials.userId, targetId)) .limit(1); if (!credRow) { return c.json({ error: 'Member not found or has no local credential' }, 404); } try { await db .update(localCredentials) .set({ passwordHash: await hashPassword(newPassword) }) .where(eq(localCredentials.userId, targetId)); // CR-04: an admin password reset must immediately clear any rate-limit / lockout // state for this username, so a locked-out member regains access at once rather than // waiting for the TTL. The lockout is keyed on username (not member id). resetLoginAttempts(credRow.username); return c.json({ ok: true }, 200); } catch (err) { console.error( '[admin/POST /members/:id/password] Unexpected error:', err instanceof Error ? err.message : String(err), ); return c.json({ error: 'Service unavailable' }, 503); } }, ); // --------------------------------------------------------------------------- // 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 = parsePositiveIntParam(c.req.param('id')); if (targetId === null) { 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); }); // --------------------------------------------------------------------------- // GET /api/admin/config/timezone // // Returns the stored household timezone and whether it has been explicitly set // (D-01, D-04, D-06). isExplicitlySet: false when no row is in app_config // (the response still includes the D-06 fallback as the timezone value). // --------------------------------------------------------------------------- adminRouter.get('/config/timezone', async (c) => { const [row] = await db .select({ value: appConfig.value }) .from(appConfig) .where(eq(appConfig.key, 'household_timezone')) .limit(1); const isExplicitlySet = row?.value != null; // IN-01/IN-02: reuse the row we just SELECTed and let the centralized accessor apply // the D-06 fallback — no second app_config round-trip, single source for the policy. const timezone = resolveHouseholdTimezone(row?.value ?? null); return c.json({ timezone, isExplicitlySet }); }); // --------------------------------------------------------------------------- // PUT /api/admin/config/timezone // // Validates the IANA timezone string (server-side, T-18-04) and upserts the // household_timezone key in app_config (D-01, D-04). // Uses onDuplicateKeyUpdate — appConfig.key is the PK so this is a true upsert. // No noEchoHook: timezone strings are non-sensitive (T-18-06). // --------------------------------------------------------------------------- adminRouter.put('/config/timezone', zValidator('json', timezoneSchema), async (c) => { const { timezone } = c.req.valid('json'); await db .insert(appConfig) .values({ key: 'household_timezone', value: timezone }) .onDuplicateKeyUpdate({ set: { value: timezone } }); return c.json({ ok: true }, 200); }); // --------------------------------------------------------------------------- // POST /api/admin/config/timezone/seed // // Seeds household_timezone ONLY when currently unset (D-02 first-run / D-03 // no-overwrite). Used by the Phase 12 setup wizard and the Phase 18 auto-detect // flow to store the browser-detected IANA zone without clobbering an admin's // explicit choice. // // Always returns 200 with { ok: true, seeded: }. // Uses a single INSERT IGNORE: when the household_timezone row already exists the // insert is silently ignored (existing value untouched — D-03 no-overwrite) and // cannot 500 on the PK constraint under a concurrent seed/PUT. `seeded` is derived // from the result's affectedRows so it reflects what the DB actually did, accurately // even under a genuine concurrent race (WR-02). // --------------------------------------------------------------------------- adminRouter.post('/config/timezone/seed', zValidator('json', timezoneSchema), async (c) => { const { timezone } = c.req.valid('json'); // WR-02: always run the INSERT and let the DB be the source of truth, instead of a // pre-flight SELECT whose result could be stale under a concurrent race (two racers // both observing an empty table and both returning seeded:true). INSERT IGNORE on // MariaDB reports affectedRows === 1 for a real insert and 0 when the row already // exists (ignored, value preserved — D-03), so deriving `seeded` from affectedRows // is accurate: only the racer whose INSERT actually wrote the row gets seeded:true. // `timezone` is interpolated via drizzle's parameterized sql template (bound param, // not string concatenation) and is already validated as an IANA zone by timezoneSchema. const result = (await db.execute( sql`INSERT IGNORE INTO ${appConfig} (${sql.identifier('key')}, ${sql.identifier('value')}) VALUES ('household_timezone', ${timezone})`, )) as unknown as [{ affectedRows: number }, unknown]; const seeded = result[0].affectedRows === 1; return c.json({ ok: true, seeded }, 200); });