You need: a scaffolded project. That is all.
Where your routes live
The Worker contract is what mounts them, project structure is where the file goes, protecting a route is the next step, and errors is what a handler throws.
In your app capability — the one pithy init scaffolded for you:
defineCapability({
name: "app",
routes: (app) => {
app.get("/boards", requireAuth(), async (c) => {
const rows = await c.var.db.app.selectFrom("boards").selectAll().execute();
return c.json({ boards: rows });
});
},
});Your app is a capability like any other. It composes last, which is what makes the ordering rules below work in your favor.
Guards before validators, always
app.post("/boards", requireAuth(), zValidator("json", CreateBoard, validationHook), handler);The seams are already on the request
c.var.auth | The resolved identity, or null |
c.var.db | Your Kysely instances, by binding |
c.var.t | The translator — present whether or not you composed i18n |
c.var.emit | The audit seam — a no-op with no audit capability |
c.var.log | The request logger |
c.var.entitlements | The entitlement resolver |
None of them needs a conditional. An uncomposed seam is present and inert, so c.var.emit(...) from a project with no audit capability is a call that does nothing rather than a crash.
Gating
app.get("/reports", requireAuth(), requireEntitlement("pro"), handler);requireAuth() first. An entitlement gate on an anonymous caller should answer 401, not 403 — sign in and you may not are different instructions.
Validate at the boundary
One Zod schema, and the failing issues come back in the payload so a form can highlight the field:
zValidator("json", CreateBoard, validationHook)validationHook is what turns a parse failure into validation/invalid_input rather than a bare 400.
Throw, do not construct a response
throw new NotFoundError({ message: "That board does not exist.", params: { board: key } });Model a failure as a code plus params, never a message alone. A message with the value baked in is one string a client can only print; a code with params is a sentence any client can rewrite in any language — and the English still arrives on the wire for the ones that will not.
Middleware of your own
defineCapability({
name: "app",
middleware: [
(app) => {
app.use("/support/feedback", async (c, next) => { /* your rule */ await next(); });
},
],
});Every capability’s middleware mounts before any capability’s routes, and your app composes last — so yours runs after auth has resolved the session and before another capability’s own gate.
Write the path from the base path you configured. A mount point you moved and a middleware path you did not is a gate that silently stops covering anything.
Your route is a peer, not a wrapper
Nothing stops you reading a capability’s tables directly, or serving an object with your own authorization. The capability’s routes stay scoped to the caller; yours can be scoped to anything.