Add a route

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.authThe resolved identity, or null
c.var.dbYour Kysely instances, by binding
c.var.tThe translator — present whether or not you composed i18n
c.var.emitThe audit seam — a no-op with no audit capability
c.var.logThe request logger
c.var.entitlementsThe 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.

ESC