QUESTPIE
CodeRoutes

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.

View markdown

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.

Require a signed-in caller
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 asMeans
.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 allPublic, 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:

FieldWhat it is
localeThe resolved locale for this request.
requestThe Request, when the route ran over HTTP.
paramsThe 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" }
  • Access control, the collection-side rules and the session typing this reuses.
  • Input and output, why a bad payload is rejected before your rule ever runs.

On this page