Authorization
@bext-stack/framework/authz answers one question everywhere it matters: can
this user do this thing to this resource? You declare abilities once with
defineGate, then check them in a loader (gate the page), an action (gate
the mutation), or a component (show or hide UI). It is the app-developer
counterpart to the Rust AuthzPolicy capability —
same idea, in-isolate TypeScript, user-shape-agnostic, zero dependencies.
If you know Laravel's Gates and Policies, this is the same model:
Gate::define → defineGate, $user->can('update', $post) →
gate.allows(user, 'post.update', post), $this->authorize(...) →
gate.authorize(...), and a before hook for a super-admin bypass.
When To Use It
- Page/route guards — deny a whole page in its
loader. - Mutation guards — check an
actionbefore it writes. - Conditional UI — render a button or section only when allowed.
- Menus — build a nav from
gate.abilities(user).
Always re-check server-side even when the UI already hides something — the UI is a convenience, the loader/action is the enforcement.
Abilities receive user | null — a guest is null. Use the null-safe helpers
(hasRole, hasPermission) so a guest never throws; a bare user.role would.
Unknown abilities fail closed (deny), so a typo in an ability name never
accidentally grants access.
Defining a gate
import { defineGate, hasRole, forbidden } from "@bext-stack/framework/authz";
interface User { id: string; role: "user" | "admin" | "superadmin" }
interface Post { authorId: string }
const gate = defineGate<User>(
{
"post.view": () => true, // public
"post.update": (u, post: Post) => hasRole(u, "admin") || post.authorId === u?.id,
"post.delete": (u) => hasRole(u, "admin"),
},
{ before: (u) => (hasRole(u, "superadmin") ? true : undefined) }, // god-mode bypass
);
Each ability receives the user (or null) plus any resource arguments, so
ownership checks live right in the ability. The optional before hook runs
first and can short-circuit every ability (true/false), or return
undefined to fall through.
Using it
// In a loader / action — enforce, and 403 if denied:
export async function loader({ request }) {
const user = await readSession(request);
if (gate.denies(user, "post.delete")) throw forbidden("admins only");
return { ... };
}
// In a component — show/hide UI (bind the user once with `for`):
const acl = gate.for(user);
acl.can("post.update", post) // boolean
acl.cannot("post.delete") // boolean
// Imperative (services, non-PRISM code) — throws AuthorizationError (403):
gate.authorize(user, "post.update", post);
throw forbidden() returns a 403 Response that PRISM renders as a forbidden
page. gate.authorize throws a typed AuthorizationError (with .status = 403
and .toResponse()) for code that prefers try/catch.
API
| Member | Returns | Use |
|---|---|---|
defineGate<U>(abilities?, { before? }) |
Gate<U> |
declare abilities + optional global hook |
gate.define(name, fn) / gate.policy(resource, actions) |
Gate<U> |
add abilities (policy keys them resource.action) |
gate.allows(user, ability, ...args) / .denies(...) |
boolean |
checks for UI & guards |
gate.authorize(user, ability, ...args) |
throws AuthorizationError |
imperative enforcement |
gate.for(user) |
{ can, cannot, authorize } |
bind a user for many checks |
gate.abilities(user, names?) |
{ ability: boolean } |
build a menu / nav |
hasRole(user, ...roles) / hasAllRoles(user, roles) / hasPermission(user, ...perms) |
boolean |
null-safe user predicates |
forbidden(message?) |
Response (403) |
throw from a PRISM loader/action |
Relationship to the AuthzPolicy capability
The Rust AuthzPolicy capability is a server-side policy seam evaluated in Rust at request boundaries — good for infrastructure-level rules that must hold regardless of the app framework in front of them. This module is the in-isolate TypeScript path for app-level authorization: the rules live next to your routes and read your own user shape. Use both — belt-and-braces at the edge, expressive checks in your code.
Try It
The live role-gating demo drives
its members/admin sections off a gate — flip roles and watch the UI and the
server-side re-check agree. Source in
sites/demo/src/app/examples/role-gating/page.tsx.
See Also
- AuthzPolicy — the Rust capability this complements.
- Validation — the other half of a safe action: validate, then authorize.
- Server Actions — where mutation guards live.
- Application Toolkit — the rest of the TypeScript app layer.