Access control
One .access() call decides who may run a route. Leave it off and anyone can. That is the opposite of how a collection behaves, and it is the mistake worth checking for twice.
How do you require a signed-in caller? And how do you mark a route open on
purpose rather than open by accident? Both answers are .access().
Why the default is open
A collection you never wrote a rule for rejects anonymous reads. A route you
never wrote a rule for serves them. The reason is that a collection falls back
to your app's defaultAccess, and past that to requiring a session. Routes
have no such chain. An undefined rule resolves to true and the handler runs.
The HTTP handler adds no wrapper of its own.
export default route()
.post()
.schema(z.object({ title: z.string() }))
.access(({ session }) => !!session?.user)
.handler(async ({ input, session, collections }) => {
return collections.posts.create({
title: input.title,
authorId: session!.user.id,
});
});The shapes a rule can take
| Written as | Means |
|---|---|
.access(true) | Public, on purpose. Says so in the OpenAPI spec. |
.access(false) | Nobody. Useful for parking a route. |
.access((ctx) => …) | Your predicate. May return a promise. |
.access({ execute: fn }) | The same rule under its operation name. |
| No call at all | Public, by accident or otherwise. |
Routes have one operation, execute. There is no read/create/update/delete
split like a collection has, so the object form buys you nothing except
symmetry with the rest of the schema.
`.access(true)` is not the same as writing nothing
Both let everyone in. Only the explicit true marks the operation as public
in the generated OpenAPI spec, which opts it out of the spec's security
scheme. A route with no rule inherits that scheme in the docs while answering
everyone in reality.
What the rule receives
The full app context, so session, db, collections, globals and your own
services are on it. Three extras come with the request:
| Field | What it is |
|---|---|
locale | The resolved locale for this request. |
request | The Request, when the route ran over HTTP. |
params | The matched URL params, when the route ran over HTTP. |
The last two are absent when you run the route directly from code, so a rule
that reads request.headers has to handle undefined.
.access(async ({ session, collections }) => {
if (!session?.user) return false;
const member = await collections.members.findOne({
where: { user: session.user.id },
});
return member?.role === "admin";
})A rule that throws denies
The rule runs inside a try/catch and any exception resolves to false.
Nothing propagates to the caller. So a typo in your rule reads as a clean
403, not as a crash you can see. Keep rules small.
Getting a real message out
You cannot, from the rule. A denied route always answers the same 403, with
code: "FORBIDDEN" and a generic message. When the caller deserves to know
why, let the rule pass and throw from the handler instead. The reason you
pass becomes the message the client reads.
.access(({ session }) => !!session?.user)
.handler(async ({ session, collections }) => {
const member = await collections.members.findOne({
where: { user: session!.user.id },
});
if (member?.role !== "admin") {
throw ApiError.forbidden({
operation: "read",
resource: "route",
reason: "Admins only",
});
}
// …
});
// 403 { code: "FORBIDDEN", message: "Admins only" }Related
- Access control, the collection-side rules and
the
sessiontyping this reuses. - Input and output, why a bad payload is rejected before your rule ever runs.
Input and output
A route has two Zod seams. One parses what the caller sent before anything else runs, the other checks what your handler returned on the way out.
Metadata
One .meta() call names a route for the OpenAPI spec and can hand the same route to an agent as an MCP tool. It changes nothing about how the route runs.