feat(10-03): implement credentialSync helper, adminRouter, and self-service /api/me/credential

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
This commit is contained in:
Lucas Berger
2026-06-13 14:54:23 -04:00
parent 037a7ed4c1
commit d2f6d5d77b
4 changed files with 360 additions and 0 deletions
+163
View File
@@ -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);
});