PUT /api/admin/calendars/:id/shared cleared the current shared calendar then set the target in two non-transactional UPDATEs without checking the target exists — a bad/stale id wiped the family shared lane and still returned ok. Verify the target inside a transaction; return 404 when absent. Adds a regression test (RED→GREEN).
181 lines
7.0 KiB
TypeScript
181 lines
7.0 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|