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::definedefineGate, $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 action before 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.

Tip

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