Writing rules
Return your own error instead of a bare 403, and lift a rule out of the collection file without breaking codegen.
Two things you will want once the first .access({ ... }) is in place: telling
the caller why they were turned away, and writing one rule that several
collections share.
Your own error
Returning false produces a generic 403 with no explanation. Throwing from the
rule body lets you pick the status and the message.
import { ApiError } from "questpie/errors";
.access({
update: ({ session, data }) => {
if (!session?.user) throw ApiError.unauthorized();
if (data.locked && session.user.role !== "admin") {
throw ApiError.forbidden({
operation: "update",
resource: "posts",
reason: "This post is locked",
});
}
return true;
},
})Reach for unauthorized when nobody is signed in and forbidden when someone
is signed in and still not allowed.
| Factory | Code | HTTP |
|---|---|---|
ApiError.unauthorized(message?) | UNAUTHORIZED | 401 |
ApiError.forbidden(context) | FORBIDDEN | 403 |
ApiError.notFound(resource, id?) | NOT_FOUND | 404 |
ApiError.conflict(message) | CONFLICT | 409 |
The context object forbidden takes is
{ operation, resource, reason, requiredRole?, userRole?, fieldPath? }. Its
reason reaches the client verbatim, unless it is the exact string
"Access denied", which selects the built-in localized message instead.
Each of the four also accepts a trailing messageKey and messageParams, so
the error arrives translated for the requesting user rather than in whatever
language you typed. The full code set and the serialized shape live in
questpie/errors.
Sharing a rule
Inline rules are typed for you. A helper in another file has to declare what it takes, and which type that is depends on where the helper lives.
A helper a collection imports
Take the package-level AccessContext from questpie. ctx.app,
ctx.collections and ctx.session stay fully typed through it.
import type { AccessContext } from "questpie";
/** Rush orders may only be cancelled by an authenticated user. */
export function canCancelOrder(
ctx: AccessContext<{ priority?: string | null }>,
) {
if (ctx.data?.priority === "rush") return !!ctx.session?.user;
return true;
}A helper that reaches back through ctx.collections needs an explicit return
annotation on top of that, and so does the rule that calls it.
A helper a collection does not import
Routes, services, jobs and scripts can use the generated AccessRuleContext<K>
alias instead, where K narrows ctx.data to that collection's row.
import type { AccessRuleContext } from "#questpie";
export function isOwner(ctx: AccessRuleContext<"posts">) {
return ctx.data?.authorId === ctx.session?.user?.id;
}Do not cross the two
Importing AccessRuleContext<K> into a file that a collection imports sends
the generated index back through itself and codegen fails with a type cycle.
The package-level AccessContext is the cycle-safe one.
The types
questpie/types exports the whole set, so you can annotate a rule map or a
factory that builds one.
import type {
AccessMode,
AccessRule,
AccessWhere,
CollectionAccess,
FieldAccess,
GlobalAccess,
RowAccessRule,
} from "questpie/types";AccessRule is the plain form. RowAccessRule is the one used by update,
delete, transition, serve and purge, where the row is always loaded and
ctx.data is therefore not optional. AccessMode is "user" | "system".
Related
- Access control for the rules themselves.
- Validation for the errors a schema produces, which travel the same way.
The block picker
`.admin()` is the only thing standing between an editor and thirty unlabelled block types. It sets the name, the icon and the group each block appears under.
Field-level access
The `fields` key inside `.access()` allows or denies one field at a time, independently of the rule that guards the row.