phase-6: the staff console, and a log that says who did what

Flow H, three screens behind a role check: find an account, work the
ingestion error queue, read and export the audit log. Superadmins can
change a role, never their own.

The error queue merges ingest errors and dead jobs into one table with a
cursor that pages both sources; only a job can be retried and only an
ingest row resolved, with a note that migration 003 gives it somewhere
to live.

writeAudit no longer defaults a missing subject to the actor, which had
been recording a user search as staff looking themselves up. Omitting
the subject still means acting on yourself; null now means the action
has no subject, which is what a search, a retry and an export are.

Reading the log is not audited. Exporting it is: a copy leaving the
building is a different act from looking.

e2e/global-setup.ts asks for every screen once before the suite starts,
so a dev server's first-request compile is paid before the first test
rather than by it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-09-04 21:55:59 +00:00
co-authored by Claude Opus 5
parent 6620650e9e
commit 4c39926483
39 changed files with 2767 additions and 11 deletions
+224
View File
@@ -0,0 +1,224 @@
import {
AdminAuditQuery,
AdminErrorListQuery,
ResolveErrorInput,
RoleChangeInput,
} from '@impuestos/contracts';
import { Hono } from 'hono';
import type { z } from 'zod';
import { ADMIN_ROLES } from '../../auth/options';
import {
auditRowsForExport,
changeRole,
listAdminErrors,
listAudit,
resolveIngestError,
retryJob,
searchUsers,
toCsv,
userOverview,
} from '../../modules/admin';
import { type AuditAction, writeAudit } from '../../modules/audit';
import type { AppDeps, AppEnv, SessionUser } from '../context';
import { HttpError } from '../errors';
import { requireRole } from '../middleware';
const SUPERADMIN_ONLY = ['superadmin'] as const;
/**
* FLOWS.md Flow H. Two rules hold for every handler here: the role is checked in the
* handler and not only at the router, and anything that reads or changes a user's data
* writes an audit row before it answers.
*/
export function adminRoutes(deps: AppDeps): Hono<AppEnv> {
const routes = new Hono<AppEnv>();
const db = deps.handle.db;
routes.get('/users/search', async (c) => {
const staff = requireRole(c, ADMIN_ROLES);
const term = c.req.query('q') ?? '';
const items = await searchUsers(db, term);
await audit(c, deps, staff, {
action: 'admin.user_search',
subjectUserId: null,
resource: 'user',
detail: { q: term, results: items.length },
});
return c.json({ items });
});
routes.get('/users/:id/overview', async (c) => {
const staff = requireRole(c, ADMIN_ROLES);
const id = c.req.param('id');
const overview = await userOverview(db, id);
if (!overview) throw new HttpError('not_found');
// CONTRACTS.md section 5.5 pins this: one view, exactly one `admin.user_lookup` row.
await audit(c, deps, staff, {
action: 'admin.user_lookup',
subjectUserId: id,
resource: `user/${id}`,
});
return c.json(overview);
});
routes.post('/users/:id/role', async (c) => {
const actor = requireRole(c, SUPERADMIN_ONLY);
const id = c.req.param('id');
const input = parse(RoleChangeInput, await body(c));
const result = await changeRole(db, {
actorUserId: actor.id,
targetUserId: id,
role: input.role,
});
if (!result.ok) {
if (result.reason === 'not_found') throw new HttpError('not_found');
throw new HttpError('conflict', { field: 'role', detail: { reason: result.reason } });
}
await audit(c, deps, actor, {
action: 'admin.role_change',
subjectUserId: id,
resource: `user/${id}`,
detail: { from: result.previous, to: input.role },
});
return c.json({ ok: true } as const);
});
routes.get('/errors', async (c) => {
requireRole(c, ADMIN_ROLES);
const query = parse(AdminErrorListQuery, {
stage: c.req.query('stage'),
status: c.req.query('status'),
cursor: c.req.query('cursor'),
});
return c.json(await listAdminErrors(db, query));
});
routes.post('/errors/:id/resolve', async (c) => {
const staff = requireRole(c, ADMIN_ROLES);
const id = c.req.param('id');
const input = parse(ResolveErrorInput, await body(c));
const result = await resolveIngestError(db, id, { userId: staff.id, note: input.note });
if (!result.ok) {
if (result.reason === 'not_found') throw new HttpError('not_found');
throw new HttpError('conflict', { detail: { reason: result.reason } });
}
await audit(c, deps, staff, {
action: 'admin.error_resolve',
subjectUserId: null,
resource: `ingest_errors/${id}`,
detail: { note: input.note },
});
return c.json({ ok: true } as const);
});
routes.post('/jobs/:id/retry', async (c) => {
const staff = requireRole(c, ADMIN_ROLES);
const id = c.req.param('id');
const result = await retryJob(db, id);
if (!result.ok) {
if (result.reason === 'not_found') throw new HttpError('not_found');
throw new HttpError('conflict', { detail: { reason: result.reason } });
}
await audit(c, deps, staff, {
action: 'admin.job_retry',
subjectUserId: null,
resource: `jobs/${id}`,
});
return c.json({ ok: true } as const);
});
/**
* Reading the log is not itself audited. A row for every scroll of the audit screen
* would bury the accesses that matter under the act of looking for them. Taking a copy
* out of the building is a different thing, so the CSV export below is audited.
*/
routes.get('/audit', async (c) => {
requireRole(c, ADMIN_ROLES);
return c.json(await listAudit(db, auditQuery(c)));
});
routes.get('/audit/export.csv', async (c) => {
const staff = requireRole(c, ADMIN_ROLES);
const query = auditQuery(c);
const rows = await auditRowsForExport(db, query);
await audit(c, deps, staff, {
action: 'admin.audit_export',
subjectUserId: null,
resource: 'audit_log',
detail: { rows: rows.length, ...query },
});
return new Response(toCsv(rows), {
headers: {
'content-type': 'text/csv; charset=utf-8',
'content-disposition': `attachment; filename="audit-${new Date().toISOString().slice(0, 10)}.csv"`,
},
});
});
return routes;
}
function auditQuery(c: { req: { query: (name: string) => string | undefined } }): AdminAuditQuery {
return parse(AdminAuditQuery, {
actor: c.req.query('actor'),
action: c.req.query('action'),
subject: c.req.query('subject'),
from: c.req.query('from'),
to: c.req.query('to'),
cursor: c.req.query('cursor'),
});
}
async function body(c: { req: { json: () => Promise<unknown> } }): Promise<unknown> {
try {
return await c.req.json();
} catch {
throw new HttpError('validation_error');
}
}
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
const result = schema.safeParse(value);
if (!result.success) {
const issue = result.error.issues[0];
throw new HttpError('validation_error', {
...(issue?.path.length ? { field: issue.path.join('.') } : {}),
detail: result.error.issues,
});
}
return result.data;
}
function audit(
c: { req: { header: (name: string) => string | undefined } },
deps: AppDeps,
actor: SessionUser,
entry: {
action: AuditAction;
subjectUserId: string | null;
resource: string;
detail?: Record<string, unknown>;
},
): Promise<void> {
return writeAudit(deps.handle.db, {
actorUserId: actor.id,
actorRole: actor.role,
ip: c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null,
...entry,
});
}