Skip to content
PharmaFlow Knowledgebase
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.

Last updated on October 20, 2026