Unreleased changes
These pages document main, which has changes 0.1.2 does not. npm install @evanion/react-acl gives you 0.1.2, documented here. Values on these pages are produced by running the current source on every build.
Which side decides
To enforce a permission, call access.can from @evanion/acl on the server,
where the page renders and again in the handler that writes. useCan runs in
the shopper’s browser and only shows or hides controls.
What a react-acl app calls on the server
A server module builds its evaluator once, with policy from @evanion/acl,
and imports nothing from @evanion/react-acl. The libraries repository keeps
it at libs/react-acl/examples/server.tsx, outside the published package.
ListingPage asks 'read' before it renders anything, and editListing, the
handler behind the Edit button, asks 'edit' before it writes:
import { } from '@evanion/acl';
type = { : string; : 'customer' | 'bookseller' | 'owner' };
type = { : string; : string; : 'draft' | 'published' };
// Built once per server process.
export const = <
,
{ : },
{ : 'read' | 'edit' }
>()
.('listing', () =>
.('read', .)
.('edit', .('object.sellerId', 'subject.id'))
.('edit', .('object.status', 'published')),
)
.();
// A server component. The loader of a React Router route asks the same way.
export function ({
,
,
}: {
: ;
: ;
}) {
const = .(, 'listing', 'read', );
if (!.) return <>No such listing.</>;
return <>{.}</>;
}
// The handler behind the Edit button. The caller passes the shopper its
// session resolved and the listing it read from the database, never either one
// from the posted form.
export function (
: ,
: ,
: string,
): Response {
const = .(, 'listing', 'edit', );
if (!.) return new (null, { : 403 });
return .({ ..., });
}The policy uses parts of @evanion/acl that acl’s own Getting started page
does not show:
- The third type argument, which declares each kind’s actions as in
react-acl’s Getting started, now lists
'read' | 'edit'. p.alwaysis a condition that always holds, so everybody may read a listing..denyrefuses where its condition holds, and a refusal wins over any allow, so nobody edits a published listing.
editListing refuses Jo, who did not list the game, and refuses Mika once the
listing is published, whatever the browser drew:
import { editListing } from '../examples/server';
const mika = { id: 'mika', role: 'bookseller' } as const;
const jo = { id: 'jo', role: 'owner' } as const;
const draft = {
id: 'brass-birmingham',
sellerId: 'mika',
status: 'draft',
} as const;
const published = { ...draft, status: 'published' } as const;
editListing(mika, draft, 'Unpunched, still in shrink').status; // -> 200
editListing(jo, draft, 'Unpunched, still in shrink').status; // -> 403
editListing(mika, published, 'Unpunched, still in shrink').status; // -> 403Each // -> comment is the HTTP status its line returns. The server hands
access.matrix to ShopAccess, the client component
Getting started mounts, so the browser evaluates the same
rules.
Where each layer of a react-acl app enforces
react-acl’s useCan('listing', 'edit', listing) in the browser and
access.can(shopper, 'listing', 'edit', listing) on the server return the same
Decision over the same rules, and neither call’s type says which one enforces.
Where each call runs separates them. The server’s call runs where the shopper
cannot change the code, the rules or the subject. The browser’s call runs over a
matrix the shopper can read, against a subject the shopper’s own page supplied.
| Where | Runs in | Enforces? |
|---|---|---|
| Middleware, the code a framework runs before a route | server | yes, for what it lets through |
| Loader or server component | server | yes, for what it returns or renders |
PolicyProvider and its tree | browser | never; it toggles what the shopper sees |
| Action, Server Action, route handler | server | yes, independently, for what it writes |
A caller reaches an action, the last row, directly. curl posts to the action
without rendering anything, so the handler resolves the subject from the
session, reads the listing from the database, and decides again. Every layer
runs its own evaluation and trusts no earlier one. If Baize splits its shop into
a storefront service and an order service, the order service decides again on
every call from the storefront, for the same reason.
access.can is synchronous and reads a matrix the server already holds in
memory, so no decision in the server rows waits on the network.
Why react-acl cannot run in a server component
@evanion/react-acl needs createContext and useContext. A React Server
Component (RSC) renders only on the server, against a build of React that
exports neither, so react-acl’s entry point carries 'use client'. An RSC that
imports useCan receives a reference to client code in place of the function,
and calling it during the server render throws.
An RSC renders a client component such as ShopAccess instead, and every prop
it passes is serialized on the way to the browser. For its own decisions an RSC
calls access.can from @evanion/acl directly.
What crosses the render in a react-acl app
In a react-acl app the server’s evaluator is an Access from @evanion/acl: a
set of closures over a frozen document. JSON.stringify drops every method on
it, so no loader return value and no server component prop can carry one. The
matrix crosses in its place, and ShopAccess calls hydratePolicy on it to
build the browser’s own evaluator.
| What crosses | The form it crosses in | What the browser does with it |
|---|---|---|
Access | nothing; closures do not serialize | rebuilds its own with hydratePolicy |
| the matrix | one JSON document, access.matrix | hydrates it into an evaluator |
| the subject | a plain object, and no credential | evaluates against it |
now | an ISO string | reads every time window against it |
The subject crosses because the browser needs it to evaluate. Nothing reads it back: on the next request the server resolves its own subject from the session.
now crosses as the string
Getting started
resolves on the server, so no time window in the policy is read against a clock
the shopper’s machine sets.
How fresh the browser’s copy of a react-acl matrix is
The matrix a react-acl provider holds is as old as the render that delivered
it, so a permission the server revokes keeps granting in the browser until a new
matrix replaces the copy. @evanion/acl can expire the copy:
- The server adds
maxStale, a lifetime in milliseconds, to the matrix it sends:{ ...access.matrix, maxStale: 300_000 }lasts five minutes. - The browser passes
fetchedAt, the instant it fetched the matrix, to thehydratePolicycall inShopAccess:hydratePolicy(matrix, { fetchedAt }). For a matrix that arrived with the server render,fetchedAtis thenowthe server sent with it.
Once the instant a decision reads is more than maxStale milliseconds past
fetchedAt, every decision answers allowed: false with
reason: 'stale-contract', acl’s reason for an expired matrix, and every control
gated on it hides until a refetch replaces the matrix. A react-acl decision reads
the provider’s context.now, and ShopAccess passes the server’s render
instant, which never moves. With ShopAccess as Getting started builds it,
maxStale never fires, and only a refetch, which brings a new matrix and a new
server now, replaces the copy.
maxStale matters to a react-acl client that keeps deciding between fetches.
That client has to pass a now it reads from its own clock, and the clock the
shopper’s machine sets then also decides every time window in the policy.
Refetch on the cadence your revocations need.
Follow the react-acl boundary into your framework
Two framework guides carry the react-acl boundary through a whole app.
React Router 8 puts these layers in a middleware, a loader,
an action and the provider in the shell. Next.js puts them in a
server component, a client boundary and the Server Action behind the button.
acl’s
publishing guide
covers maxStale in full, and the security contract covers the
rest of the trust boundary.