| name | new-route |
| description | Create a new backend route following project conventions |
Create a New Route
You are implementing a new route in the Talo backend. Follow all conventions and patterns exactly as described below.
Three-Tier Routing System
Choose the correct tier based on what's being built:
| Tier | Prefix | Auth | Config file | Routes dir |
|---|
| Protected | / | JWT (JWT_SECRET) | src/config/protected-routes.ts | src/routes/protected/ |
| API | /v1/ | JWT (game.apiSecret) | src/config/api-routes.ts | src/routes/api/ |
| Public | /public/ | None | src/config/public-routes.ts | src/routes/public/ |
If you are unsure, ask the user before proceeding.
Step-by-Step Checklist
Work through these steps in order:
1. Identify the tier and feature directory
Determine which tier applies and whether a feature directory already exists (e.g., src/routes/api/player/). If adding to an existing feature, read the existing index.ts and relevant files first to understand the current structure.
2. Create the route file
File placement:
- One route per file (e.g.,
get.ts, post.ts, update.ts, delete.ts)
- Exception: if the router has only one route, inline it in
index.ts
- If the feature is new, create the directory first
Route file structure:
import { apiRoute } from '../../../lib/routing/router.js'
const docs = {
description: '...',
samples: []
} satisfies RouteDocs
export const getRoute = apiRoute({
method: 'get',
path: '/:id',
docs,
handler: async (ctx) => {
return {
status: 200,
body: { ... }
}
}
})
Use the correct route helper and context for the tier:
- API:
apiRoute / APIRouteContext
- Protected:
protectedRoute / ProtectedRouteContext
- Public:
publicRoute / PublicRouteContext
3. Add middleware if needed
Authorization middleware (import from src/middleware/policy-middleware.ts):
middleware: withMiddleware(
requireScopes([APIKeyScope.READ_PLAYERS]),
loadAlias,
)
middleware: withMiddleware(
ownerGate('view settings'),
requireEmailConfirmed,
loadGame,
)
Ordering rules:
- API routes:
requireScopes() → resource loaders
- Protected routes:
ownerGate() / userTypeGate() → requireEmailConfirmed → resource loaders
- Never wrap middleware definitions with
withMiddleware() in common.ts
- Never use array spread like
[...middleware1, ...middleware2]
- Use
ownerGate() (not userTypeGate([])) for OWNER-only routes
Custom route middleware goes in common.ts:
import { APIRouteContext } from '../../../lib/routing/context.js'
import { Next } from 'koa'
export async function loadMyEntity(ctx: APIRouteContext<{ entity: MyEntity }>, next: Next) {
const entity = await ctx.em.repo(MyEntity).findOne({ id: ctx.params.id })
if (!entity) return ctx.throw(404, 'Entity not found')
ctx.state.entity = entity
await next()
}
Use return ctx.throw() (with return) for type narrowing after the throw.
4. Add Zod validation schema if needed
schema: (z) => ({
params: z.object({
id: z.coerce.number().meta({ description: 'The entity ID' }),
}),
query: z.object({
page: z.coerce.number().optional(),
}),
body: z.object({
name: z.string({ error: 'name is missing' }).min(1, { message: 'name is invalid' }),
}),
headers: z.looseObject({
'x-talo-alias': z.string({ error: 'x-talo-alias is missing' }),
}),
})
Access validated data via ctx.state.validated.body, .query, .params, .headers - NOT ctx.request.body.
Always wrap inline routes with the route helper when using schema:
route(apiRoute({ schema: ..., handler: ... }))
route({ schema: ..., handler: ... })
5. Create or update index.ts
import type Router from 'koa-tree-router'
import { apiRouter } from '../../../lib/routing/router.js'
import { getRoute } from './get.js'
import { postRoute } from './post.js'
export function myFeatureAPIRouter(router: Router) {
apiRouter(
'/v1/my-feature',
({ route }) => {
route(getRoute)
route(postRoute)
},
{ router, docsKey: 'MyFeatureAPI' },
)
}
6. Register the router
Add to the appropriate config file if it's a new router:
import { myFeatureAPIRouter } from '../routes/api/my-feature/index.js'
export function configureAPIRoutes(app: Koa) {
const mainRouter = new Router()
myFeatureAPIRouter(mainRouter)
app.use(mainRouter.routes())
}
7. Add docs (for API routes)
If docs exist for other routes in this folder, add to docs.ts. If this is the first route with docs, create docs.ts:
import { RouteDocs } from '../../../lib/docs/docs-registry.js'
export const getDocs = {
description: 'Get a my-feature by ID',
samples: [
{
title: 'Get my-feature',
request: {},
response: { status: 200, body: { myFeature: { id: 1 } } },
},
],
} satisfies RouteDocs
Key docs rules:
- For module-level exported routes: define
docs const BEFORE the route (avoid "Cannot access before initialization")
- For inline routes (inside router function body): docs can go at the bottom
docsKey on the router sets the service name for all routes
- Scopes are automatically extracted from
requireScopes() - do NOT add them manually
8. Write tests
Create tests in the matching location:
- API route at
src/routes/api/my-feature/ → tests at tests/routes/api/my-feature-api/
- Protected route at
src/routes/protected/my-feature/ → tests at tests/routes/protected/my-feature/
Follow the pattern of existing test files in the project. Run npm test path/to/test to verify.
Key Conventions Summary
- Router functions named:
[feature]Router or [feature]APIRouter
- Entity names are singular (Player, not Players)
- Use
ctx.em for all database access
- Use lazy loading for entity relationships
- Use
return ctx.throw(status, message) for type narrowing
- MikroORM Identity Map: don't re-query entities already loaded in the request
- TypeScript types are fully inferred - never pass explicit generics to route helpers