---
title: Adding an Endpoint
description: 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.

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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:

```bash
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`.

```ts
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.
