HTTP routes
A client that has called one module's API should be able to guess the next one: its URLs, its errors and the shape of its responses. That client may be your app, an AI agent over the Model Context Protocol (MCP) or a test. This page lists the conventions every module API follows, and the helpers that implement them.
The helpers are the server toolkit of Module Kit, @frigear-nu/module-kit/server.
URLs
- Module prefix. Everything lives under
/api/<module>. - Staff routes. They live under
/api/admin/<module>/..., with admin first, like the/admin/<module>pages. So a host app can guard all of them with onerouteRules['/api/admin/**']. - The main resource. It is the module path itself:
GET /api/admin/eventslists events, andGET /api/admin/events/:idreads one. - The caller's own things. Anything that is implicitly the caller's lives under
/api/<module>/me/.... - Ids and collections. Ids are path params, and collections are plural.
- Actions. An action that is not create, read, update or delete (CRUD) is
POST /<resource>/:id/<verb>. - Methods. A partial update is
PATCH, and replacing a value isPUT. A state change is neverGET. - Exceptions. Spec endpoints (
/.well-known/*,/oauth/*), webhooks and OAuth callbacks registered at a provider keep the paths they need. - One place for paths. Keep the paths as typed builders in
src/runtime/utils/api.ts. The app, tests and docs use them. See Naming.
Registering routes
A module registers every route from one table in module.ts, with addModuleRoutes from
@frigear-nu/module-kit/utils.
| Field | Meaning |
|---|---|
method | The HTTP method, in lower case. |
path | Joined onto base. '' is base itself, and a path that starts with / ignores base. |
file | The handler, relative to ./runtime/server/routes/. |
base (in the third argument) | Required. The prefix for every path in the table. |
import { addModuleRoutes } from '@frigear-nu/module-kit/utils'
addModuleRoutes(resolver, [
{ method: 'get', path: '', file: 'admin/events.get' },
{ method: 'post', path: '', file: 'admin/events.post' },
{ method: 'get', path: ':id', file: 'admin/event.get' },
{ method: 'patch', path: ':id', file: 'admin/event.patch' },
{ method: 'post', path: ':id/clone', file: 'admin/event-clone.post' },
{ method: 'get', path: '/api/events/me/tickets', file: 'me/tickets.get' },
], { base: '/api/admin/events' })
Handler order
Every handler does its work in the same order:
- Authorize, with
authorize(event, '<permission>'). It authenticates and checks the permission. - Parse the path params, then the body or query. Use h3's validated helpers with zod:
getValidatedRouterParams(event, idParams().parse),readValidatedBody(event, schema.parse)andgetValidatedQuery(event, schema.parse). - Load the target, then authorize again with the record when a policy may refine the permission, then act.
On JSON routes, use no raw readBody, getQuery or getRouterParam(...)!, and no wrappers around the validated
helpers.
Step 1: who may call the route
The first line of a handler decides who may call it, and it is almost always authorize:
| Route | First line | Comes from |
|---|---|---|
| Needs a permission (every route that reads or changes data) | await authorize(event, 'events:manage') | @frigear-nu/auth |
| Any signed-in caller, no permission | const { user } = await requireAuth(event) | @frigear-nu/auth |
| The caller's own user record | const user = await requireUser(event) | @frigear-nu/users |
authorize returns the principal (user, kind, scopes) and throws 401 unauthorized, or 403 with
session_required, insufficient_scope or forbidden. It is auto-imported into server code. The permission is
registered once with addPermission; staff routes use the module's own permissions (events:manage,
forms:manage), and the caller's own things use implicit permissions such as tickets:read. Who holds a permission is
decided by roles, policies and the host's before hook, never by the route. The whole model is on
Access control.
sessionOnly permission (events:refund, users:manage), so every route that checks it
refuses tokens, and a token can never hold it. There is no scopes option on requireAuth or requireUser: name the
permission and call authorize.The two-step route
A route that acts on one record calls authorize twice:
- Without the record, first. It answers 401 or 403 from the permission alone, so a visitor cannot tell which ids exist.
- With the record, after loading it. It runs the policies, such as "only the owner" or "not once archived".
await authorize(event, 'events:manage') // 401/403 before any 404
const ev = await loadEvent(id) // then 404
await authorize(event, 'events:manage', ev) // record-level policies
A route that has no policy on its permission may make the first call only. Use allows(event, permission, record?)
instead when you need a yes or no without throwing, for instance to choose which fields to return.
Example
A staff route that renames a thing. First, the permission, declared once in the module's src/runtime/access.ts and
registered from module.ts (Access control):
import { defineAccess } from '@frigear-nu/auth/access'
export const thingsAccess = defineAccess('things', {
permissions: { update: { label: 'Rename things', grantTo: ['admin'] } }, // things:update
})
registerAccess(nuxt, thingsAccess) // from @frigear-nu/auth/utils
Then the route, which does the steps in order:
import { db, schema } from '@nuxthub/db'
import { eq } from 'drizzle-orm'
import { idParams, notFound } from '@frigear-nu/module-kit/server'
import { z } from 'zod'
import { thingsAccess } from '../../../access'
const body = z.object({ name: z.string().trim().min(1).max(120) })
export default defineEventHandler(async (event) => {
// 1. Who may call: the permission, answered by roles, policies and the host's before hook.
await authorize(event, thingsAccess.permissions.update)
// 2. Parse the path params, then the body.
const { id } = await getValidatedRouterParams(event, idParams().parse)
const input = await readValidatedBody(event, body.parse)
// 3. Load the target and act. A missing record is a 404, only after the caller passed step 1.
const [row] = await db.update(schema.things).set(input).where(eq(schema.things.id, id)).returning()
if (!row) throw notFound('Thing not found.')
// toThingDto lives in src/runtime/server/utils too. See Responses.
return toThingDto(row)
})
defineEventHandler, getValidatedRouterParams and readValidatedBody are h3's, auto-imported by Nitro. The Events
module's admin/addon.patch.ts is a real route built this way.
Input
Two zod schemas validate ids the same way in every module. Both are for getValidatedRouterParams.
| Schema | Accepts |
|---|---|
idParams(name = 'id') | A non-empty string. |
intIdParams(name = 'id') | A positive integer, coerced from the path. |
const { id } = await getValidatedRouterParams(event, idParams().parse)
const { id: userId } = await getValidatedRouterParams(event, intIdParams().parse)
Errors
Errors go through apiError(status, code, message, data?) and its shortcuts. Every error has the same shape:
- The message is the
statusMessage, which the interface shows. data.codeis a snake_case code a client can test for.
| Helper | Status | data.code |
|---|---|---|
apiError(status, code, message, data?) | Any | code |
badRequest(message?, data?) | 400 | bad_request |
validationError(issues, message?) | 400 | validation_failed, with the issues in data.issues |
forbidden(message?, data?) | 403 | forbidden |
notFound(message?, data?) | 404 | not_found |
conflict(message?, data?) | 409 | conflict |
notConfigured(message?, data?) | 501 | not_configured |
Each helper returns an h3 error for you to throw. authorize and requireAuth answer in the same shape, with the codes
unauthorized, session_required, insufficient_scope and forbidden.
import { conflict, notFound } from '@frigear-nu/module-kit/server'
if (!form) throw notFound('Form not found.')
if (await slugTaken(body.slug)) throw conflict('That slug is taken.', { field: 'slug' })
Field issues
Use this when a client needs an issue per field, such as a form that marks each input.
h3's readValidatedBody(event, schema.parse) answers a failed parse with a 400. Its data carries the zod error only
as text. So parse the body yourself with schema.safeParse, and throw validationError(issues). The issues arrive in
data.issues. The Forms submit route is the example:
const result = definition.schema.safeParse(await readBody(event))
if (!result.success) throw validationError(result.error.issues)
Responses
| Route | Status | Return |
|---|---|---|
| Create | 201 | created(event, dto) |
| Update or action | 200 | The DTO |
| Delete or revoke | 204 | noContent(event) |
Every response is a data transfer object (DTO) that lists its fields explicitly, never a database row:
- A
to<Entity>Dto()function inserver/utilsbuilds it. - Dates are ISO strings, made with
isoDate. - The DTO types live in
src/types.ts.
| Helper | Purpose |
|---|---|
created(event, dto) | Sets 201 and returns dto. |
noContent(event) | Sets 204 and returns null, for deletes and revokes. |
isoDate(value) | Turns a Date, epoch milliseconds or a date string into an ISO string, and null or undefined into null. |
Pagination
Admin lists are paginated. They answer { items, total, page, pageSize }, built with pageQuery() and paginate().
- Query keys. They match the table composable
useTableQueryof UI:page,pageSize,q,sort,orderand the list'sfilterskeys. - Page size. The default is 20, and the most a client may ask for is 100.
- Small lists. Small bounded lists, such as tokens, roles and plans, stay plain arrays.
import { db, schema } from '@nuxthub/db'
import { and, count, eq } from 'drizzle-orm'
import { likeContains, orderByQuery, pageQuery, pageRange, paginate } from '@frigear-nu/module-kit/server'
import { z } from 'zod'
const listQuery = pageQuery({ sort: ['startsAt', 'slug'], filters: { status: z.enum(['draft', 'published']).optional() } })
export default defineEventHandler(async (event) => {
// Staff only: the host's roles decide who holds `events:manage`. See Staff access.
await authorize(event, 'events:manage')
const query = await getValidatedQuery(event, listQuery.parse)
const where = and(
query.q ? likeContains(schema.events.slug, query.q) : undefined,
query.status ? eq(schema.events.status, query.status) : undefined,
)
const orderBy = orderByQuery({ startsAt: schema.events.startsAt, slug: schema.events.slug }, query, { tiebreaker: schema.events.id })
const { limit, offset } = pageRange(query)
const [rows, [totals]] = await Promise.all([
db.select().from(schema.events).where(where).orderBy(...orderBy).limit(limit).offset(offset),
db.select({ total: count() }).from(schema.events).where(where),
])
return paginate({ items: rows.map(toEventDto), total: totals?.total ?? 0, query })
})
toEventDto is the Events module's DTO function, in its server/utils (see Responses).
likeContains searches case-insensitively on both dialects. A % or _ in the search
does not act as a wildcard.
Staff access
There is no separate staff check. A staff route is a route whose permission only staff roles hold, such as
events:manage or forms:manage. A module registers its permissions with addPermission and gives them to a role by
name with grantTo, and the host app gives them to its own roles in authorization.roles. Your host app decides who is
staff by who holds the role, and can refine with a policy or the
before hook.
| Route | Check |
|---|---|
| A read or an ordinary edit | await authorize(event, 'events:manage'). API tokens whose scopes cover it work. |
| Credentials, money and billing | await authorize(event, 'events:refund'), a sessionOnly permission. Tokens are refused with 403 session_required. |
| One record | The two-step route. |
| A yes or no, such as an MCP tool or a menu | await allows(event, 'events:manage'). Never throws. |
authorize returns the principal; load the full user with loadAuthUser(principal.user.id) when a route needs more
than the principal holds. The name of anything a module exports from server/utils must start with the module's name,
because every module's server utils share one namespace (see Naming).
Naming
Server utils. Every module's server/utils is auto-imported into one Nitro
namespace. So an exported name must be unique across all modules. Prefix it with the module: toFormTokenDto and
toAuthTokenDto, never a generic toTokenDto.
Path builders. Each module keeps typed path builders in src/runtime/utils/api.ts, such as authApi, eventsApi
and formsApi. It exports them as @frigear-nu/<pkg>/api.
- Import them from there in app code and tests.
- Do not import them from
./utils, which also holds build-time helpers that pull in@nuxt/kit. - Relative imports inside the package stay relative.
import { authApi } from '@frigear-nu/auth/api'
Testing routes
@frigear-nu/module-kit/testing has two helpers for end-to-end tests. They run against a
test fixture started with setup() from @nuxt/test-utils/e2e.
| Helper | What it does |
|---|---|
apiClient({ cookie?, bearer? }) | Acts as one caller. |
.json<T>(method, path, body?) | Resolves with the parsed body of a 2xx response, or null for 204. Rejects otherwise. |
.fetch(method, path, body?) | Resolves with the raw Response, whatever its status. |
expectApiError(promise, status, code?) | Asserts the request failed with status, and with data.code when given. Resolves with the error body. |
import { apiClient, expectApiError } from '@frigear-nu/module-kit/testing'
const staff = apiClient({ cookie: staffCookie })
const created = await staff.json<{ id: string }>('POST', '/api/admin/events', { slug: 'party' })
await expectApiError(apiClient({ cookie: memberCookie }).json('DELETE', `/api/admin/events/${created.id}`), 403, 'forbidden')
expect((await staff.fetch('DELETE', `/api/admin/events/${created.id}`)).status).toBe(204)
Next steps
- Protect a route – apply the first step of the handler order to your own route.
- Access control – permissions, roles, policies and what fails closed.
- Module Kit – the whole toolkit, including bearer CORS and same-origin URL helpers.
- Testing – set up a database-backed test fixture for these tests.