with one click
with one click
| name | new-route |
| description | Create a new backend route following project conventions |
You are implementing a new route in the Talo backend. Follow all conventions and patterns exactly as described below.
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.
Work through these steps in order:
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.
File placement:
get.ts, post.ts, update.ts, delete.ts)index.tsRoute file structure:
// src/routes/api/my-feature/get.ts
import { apiRoute } from '../../../lib/routing/router'
// If using docs AND this is a module-level export, define docs FIRST (before the route)
const docs = {
description: '...',
samples: []
} satisfies RouteDocs
export const getRoute = apiRoute({
method: 'get',
path: '/:id',
docs,
handler: async (ctx) => {
// Use ctx.em for database queries
// Use ctx.state.validated for validated input
return {
status: 200,
body: { ... }
}
}
})
Use the correct route helper and context for the tier:
apiRoute / APIRouteContextprotectedRoute / ProtectedRouteContextpublicRoute / PublicRouteContextAuthorization middleware (import from src/middleware/policy-middleware.ts):
middleware: withMiddleware(
requireScopes([APIKeyScope.READ_PLAYERS]), // API routes: ALWAYS first
loadAlias, // then resource loaders
)
// Protected routes:
middleware: withMiddleware(
ownerGate('view settings'), // or userTypeGate([...]) - ALWAYS first
requireEmailConfirmed, // then email check
loadGame, // then resource loaders
)
Ordering rules:
requireScopes() → resource loadersownerGate() / userTypeGate() → requireEmailConfirmed → resource loaderswithMiddleware() in common.ts[...middleware1, ...middleware2]ownerGate() (not userTypeGate([])) for OWNER-only routesCustom route middleware goes in common.ts:
// src/routes/api/my-feature/common.ts
import { APIRouteContext } from '../../../lib/routing/context'
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.
schema: (z) => ({
// For route params:
params: z.object({
id: z.coerce.number().meta({ description: 'The entity ID' }),
}),
// For query strings:
query: z.object({
page: z.coerce.number().optional(),
}),
// For request body:
body: z.object({
name: z.string({ error: 'name is missing' }).min(1, { message: 'name is invalid' }),
}),
// For headers (use looseObject):
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: ... }) // ❌ loses type inference
index.ts// src/routes/api/my-feature/index.ts
import { apiRouter } from '../../../lib/routing/router'
import { getRoute } from './get'
import { postRoute } from './post'
export function myFeatureAPIRouter() {
return apiRouter(
'/v1/my-feature',
({ route }) => {
route(getRoute)
route(postRoute)
},
{
docsKey: 'MyFeatureAPI', // set if this router has docs
},
)
}
Add to the appropriate config file if it's a new router:
// src/config/api-routes.ts
import { myFeatureAPIRouter } from '../routes/api/my-feature'
export default function apiRoutes(app: Koa) {
// ...existing routers...
app.use(myFeatureAPIRouter().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:
// src/routes/api/my-feature/docs.ts
import { RouteDocs } from '../../../lib/docs/docs-registry'
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:
docs const BEFORE the route (avoid "Cannot access before initialization")docsKey on the router sets the service name for all routesrequireScopes() - do NOT add them manuallyCreate tests in the matching location:
src/routes/api/my-feature/ → tests at tests/routes/api/my-feature-api/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.
[feature]Router or [feature]APIRouterctx.em for all database accessreturn ctx.throw(status, message) for type narrowing[HINT] Download the complete skill directory including SKILL.md and all related files