Adding an Endpoint
Add a feature route with auth, role checks, zod validation, audit logging, sync emission, mounting, OpenAPI, and tests.
You add every backend endpoint as a feature folder under apps/server/src/features. You follow this recipe so your route gets auth, validation, audit, sync, docs, and tests in one pass.
Create the feature folder
You create apps/server/src/features/<name>/route.ts with a Hono router. You mount one router per feature so apps/server/src/app.ts stays a plain mount table.
import { Hono } from "hono";
import { authMiddleware, requireAdmin } from "../../core/auth/middleware";
import type { ApiVariables } from "../../core/common/http";
const widgetRoutes = new Hono<{ Variables: ApiVariables }>();
widgetRoutes.use(authMiddleware);
widgetRoutes.get("/", async (context) => context.json({ items: [] }));
export default widgetRoutes;
Add auth and role checks
You apply authMiddleware from apps/server/src/core/auth/middleware.ts to the whole router, then you layer requireRoles or requireAdmin per route. Remember that admin bypasses every role check, and that authMiddleware re-reads the user row so deactivated accounts fail immediately.
widgetRoutes.get("/", async (context) => { ... });
widgetRoutes.post("/", requireAdmin, async (context) => { ... });
Split the feature files by duty
You keep route.ts for HTTP only, and you put the work behind it in the sibling files: service.ts owns the business operation, queries.ts owns the reads, mutations.ts owns the writes once the feature has any, schemas.ts owns the zod schemas, and types.ts owns the exported interfaces. A feature that grows past one screen splits into domain directories that own their own reads and writes, such as sales/creation/ or prescriptions/lifecycle/. apps/server/src/features/currency/ is the live example: its top level keeps route.ts and service.ts, while the extra rates/ domain directory holds its own queries.ts, mutations.ts, and service.ts. Shared helpers live in the core/ partition that owns them, core/auth/, core/config/, core/database/, core/logging/, core/audit/, core/storage/, or core/common/.
Validate with zod
You parse the body with a zod schema so malformed input returns the standard 400 shape from the onError handler in apps/server/src/app.ts. You keep the schema next to the route or in a schemas.ts file, following apps/server/src/features/sessions/schemas.ts, and you parse through parseBody from apps/server/src/core/common/validate.ts so the shape stays identical.
import { z } from "zod";
const createWidgetSchema = z.object({ name: z.string().min(1) });
const body = createWidgetSchema.parse(await context.req.json());
Bad input returns { detail: "[AUTH] Invalid request body" } with status 400.
Log the mutation
You call logAction from apps/server/src/core/audit/audit.ts after every successful write. You pass the actor, branch, entity triplet, and small before/after snapshots without secrets.
import { logAction } from "../../core/audit/audit";
await logAction({
userId: currentUser.sub,
branchId: currentUser.branch_id,
entity: "widget",
action: "CREATE",
entityId: newId,
after: { name: body.name },
});
Emit a sync change
You call emitChange from apps/server/src/features/sync/protocol/emitter.ts when offline devices must see the write. You pick an entity already classified in apps/server/src/core/common/visibility.ts, or you add your entity to the global, branch, or directed set there first.
import { emitChange } from "../sync/protocol/emitter";
await emitChange({
originBranchId: currentUser.branch_id,
entity: "widgets",
entityId: newId,
operation: "UPSERT",
payload: { id: newId, name: body.name },
});
Mount the router
You register the router in apps/server/src/app.ts with one apiV1.route line. You keep the path plural and kebab-case to match the existing table.
apiV1.route("/widgets", widgetRoutes);
Document the route
You wrap each route in describeRoute from hono-openapi, before the handlers, so the operation describes itself next to the code that implements it. You declare the tag, a summary, a description that names the role and the behaviour, a stable operationId, and the responses.
import { describeRoute, resolver } from "hono-openapi";
import { errorResponse } from "../../core/openapi/components";
widgetRoutes.post(
"/",
describeRoute({
tags: ["Widgets"],
summary: "Create a widget",
description:
"Admin only. Registers a widget so a branch can sell it without a deploy. The name prints on every receipt line.",
operationId: "createWidget",
requestBody: {
required: true,
content: { "application/json": { schema: resolver(createWidgetSchema) } },
},
responses: {
"201": {
description: "Widget created",
content: { "application/json": { schema: resolver(widgetSchema) } },
},
"400": errorResponse("Validation failed"),
"403": errorResponse("Caller is not an admin"),
},
}),
requireAdmin,
async (context) => { ... },
);
You keep the request and response schemas in the feature’s schemas.ts and pass them through resolver, using the same object the handler parses. You reuse errorResponse from apps/server/src/core/openapi/components.ts for failures, and paginated for a paginated list, so no failure or list shape is described twice.
You then rebuild the reference from the repository root:
bun run docs:api
The reference is generated, never edited by hand. The gate in apps/server/__test__/openapi-docs.test.ts fails if a mounted route is undocumented or the committed file is stale, so forgetting the command is caught by the suite rather than by a reader who trusted the docs.
Write the test
You add apps/server/__test__/<name>.test.ts using the helpers in apps/server/__test__/helpers.ts. You log in with loginAs, attach authHeader(token), and send the x-test-bypass: pharmaflow-test header on login so the 5/min rate limit in apps/server/src/core/auth/login-attempts.ts does not flake under VITEST=true.
import { loginAs, authHeader, requestJson } from "./helpers";
const admin = await loginAs("admin@demo.pharmaflow", "Demo@1234");
const { status } = await requestJson("/widgets", { headers: authHeader(admin.token) });
Checklist
You verify the route is mounted, protected, validated, audited, synced, documented, and tested. You run bun run docs:api to rebuild the reference, then the suite with bun run --cwd apps/server test, you curl the endpoint with and without a token, you confirm a 400 for bad input, and you check the audit and sync rows exist.