Skip to main content

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:

HelperWhereUse
body(c, schema, maxBytes?)routes/helpers.tsReads JSON with a streaming size cap (256 KB default; pass more for bulk payloads) and validates it.
idParam(c, name?)routes/helpers.tsA UUID route parameter; anything else is 404.
paging(c, default, max)routes/helpers.tsoffset and limit from the query string.
requireAdmin(c)routes/helpers.tsPlatform-admin endpoints.
asUser(c, fn)context.tsEvery query, so RLS applies.
engagementAccess, datasetAccessservices/access.tsLoad the record under RLS (404 if unreadable) with the caller's role and flags.
requireWrite, requireLead, requirePiiservices/access.tsClear 403s that mirror the policies.
audit(tx, actorId, action, entry)services/audit.tsAppend to the audit log in the same transaction.
badRequest, conflict, notFound, forbiddenerrors.tsApiErrors 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.

Don't reach for asService

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 api in apps/web/src/lib/api.ts with 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 from keys in lib/data.ts.

  • Hide or disable the control for users who can't use it (canWrite, canLead, isAdmin, myPiiAccess), and show errorMessage(error) in a Notice on 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.