Add an API route
Routes stay thin: validate, authorise, call a service, audit, respond. Logic lives in apps/api/src/services, and anything the browser also computes lives in packages/core.
This walk-through adds PATCH /api/datasets/:id/notes to save a note on a dataset (assume a migration has added datasets.notes).
1. The schema and types in core
packages/core/src/schemas.ts: add a zod schema for the body and any response type. Both the API and the web client import them.
export const datasetNotesSchema = z.object({ notes: z.string().trim().max(4000) }).strict();
Use .strict() so unknown fields are rejected, and bound every string and array.
2. The handler
Add it to the relevant router in apps/api/src/routes (here datasets.ts):
.patch("/:id/notes", async (c) => {
const { notes } = await body(c, datasetNotesSchema); // size-capped, validated
const result = await withDataset(c, async (tx, d, a, me) => { // asUser + datasetAccess
requireWrite(a); // leads/analysts, active engagement
await tx.update(datasets).set({ notes }).where(eq(datasets.id, d.id));
await audit(tx, me.id, "dataset_notes_updated", { // same transaction
engagementId: a.engagement.id,
targetType: "dataset",
targetId: d.id,
meta: { length: notes.length }, // never names or emails
});
return { ok: true as const };
});
return c.json(result);
})
The building blocks:
| Helper | Where | Use |
|---|---|---|
body(c, schema, maxBytes?) | routes/helpers.ts | Reads JSON with a streaming size cap (256 KB default; pass more for bulk payloads) and validates it. |
idParam(c, name?) | routes/helpers.ts | A UUID route parameter; anything else is 404. |
paging(c, default, max) | routes/helpers.ts | offset and limit from the query string. |
requireAdmin(c) | routes/helpers.ts | Platform-admin endpoints. |
asUser(c, fn) | context.ts | Every query, so RLS applies. |
engagementAccess, datasetAccess | services/access.ts | Load the record under RLS (404 if unreadable) with the caller's role and flags. |
requireWrite, requireLead, requirePii | services/access.ts | Clear 403s that mirror the policies. |
audit(tx, actorId, action, entry) | services/audit.ts | Append to the audit log in the same transaction. |
badRequest, conflict, notFound, forbidden | errors.ts | ApiErrors with stable codes. |
Keep the handler thin: if there's any real logic, put it in a service function that takes a Db and plain arguments.
Handlers run through asUser. Only use asService for a genuinely cross-user operation, authorise under asUser first, never pass IDs from the request body straight into it, explain why in a comment, and add a security test.
3. The audit action
Add the new action to the AuditAction union in services/audit.ts and a human label to ACTION_LABEL in apps/web/src/components/AuditTable.tsx (and to PII_ACTIONS there if it touches personal data). If the details include a new key worth showing, add it to the list in describe().
4. A new router
If the route belongs to a new router, create it in routes/, then mount it in createApp() (app.ts) with .route("/prefix", router). Admin routers apply requireAdmin as middleware:
export const adminThingRoutes = new Hono<AppEnv>().use("*", async (c, next) => {
requireAdmin(c);
await next();
});
Order matters for overlapping prefixes: app.ts mounts /admin/reference and /admin/prompts before /admin.
5. The web client
-
Add a method to
apiinapps/web/src/lib/api.tswith the core types:saveDatasetNotes: (id: string, notes: string) => patch<{ ok: true }>(`/datasets/${id}/notes`, { notes }), -
Use it with TanStack Query (
useMutation), invalidating the affected keys fromkeysinlib/data.ts. -
Hide or disable the control for users who can't use it (
canWrite,canLead,isAdmin,myPiiAccess), and showerrorMessage(error)in aNoticeon failure.
6. Tests
In apps/api/test, using the harness:
it("saves notes for writers only", async () => {
const h = await createHarness();
await provision(h, LEAD, ANALYST, VIEWER, OUTSIDER);
const { engagementId } = await engagementFixture(h, {
members: [{ email: ANALYST, role: "analyst" }, { email: VIEWER, role: "viewer" }],
});
const datasetId = await ingest(h, engagementId, [{ employeeId: "1", jobTitle: "Claims Handler" }]);
expect((await h.call("PATCH", `/api/datasets/${datasetId}/notes`, { as: ANALYST, body: { notes: "ok" } })).status).toBe(200);
expect((await h.call("PATCH", `/api/datasets/${datasetId}/notes`, { as: VIEWER, body: { notes: "no" } })).status).toBe(403);
expect((await h.call("PATCH", `/api/datasets/${datasetId}/notes`, { as: OUTSIDER, body: { notes: "no" } })).status).toBe(404);
await h.close();
});
Cover the happy path, each role, closed engagements, invalid bodies (400), oversized bodies (413) where relevant, and the audit entry. Then run npm test and npm run typecheck.
7. Document it
Add the endpoint to the API reference, and to the user guide if it changes what people see.