React Router 8
Not on npm yet
npm install @evanion/acl does not resolve. The package is private: true, so a release run versions and tags it without publishing: npm cannot configure a trusted publisher for a package that does not exist on the registry, and the first version has to go up by hand.
apps/admin in this repository is Baize’s back office: a React Router 8
framework-mode app with ssr: true, stable route middleware and a
.server.ts convention. It adopts apps/shop-api’s document, enforces it
in loaders and actions, and toggles the tree with @evanion/react-acl.
Every fence below is a region of that app and runs under nx test admin.
The concept. A React Router route reaches the server at two entry points, the loader and the action, with a browser tree between them that is neither.
What you get. Middleware that resolves the subject once, and a loader and an action that each decide for themselves.
Why you want it. A <Form method="post"> reaches the action, and so does
curl, for whom the loader that decided what to render never ran.
How the library gets you there. parseMatrix adopts the document shop-api
published, and access.can runs on both entry points.
What apps/admin puts on a route
A React Router route has two server entry points, the loader and the action,
with a browser tree between them that is neither. A <Form method="post">
reaches the action, and so does curl, for whom the loader that decided what to
render never ran. apps/admin puts four things on that route:
- middleware answering who is asking and deferring the contract read,
- loaders deciding whether that subject may have the page,
- the action deciding again on the row,
useCanpicking the form or the sentence.
parseMatrix adopts the fetched document in this process, so the loader, the
action and the browser tree evaluate the same rules and none of them asks
shop-api a question.
Baize grants game.declare to an operator whose own shop lists the title. Ines
works in Stockholm, so the availability form is drawn on Wingspan and withheld
on Azul, which Gothenburg lists, and the URL resolves either way.
The loader and the action are two boundaries
A route has two server entry points, and a caller reaches either one without the other.
An action is reached without a loader ever running
The loader that decided what to render did not run for the caller reaching the action, and the action carries none of its conclusions. Every action re-resolves the subject and decides again.
| Where | Runtime | Decides? |
|---|---|---|
middleware | server | answers who is asking; decides nothing |
loader | server | yes, for what it returns |
| Route component | browser | never; it toggles what the user sees |
action | server | yes, independently, for what it writes |
clientLoader | browser | never |
A clientLoader runs in the browser and is no trust boundary, whatever it
fetches from. apps/admin has none and loads everything server-side.
Adopting the document shop-api published
apps/admin authors no rules. AdminSubject carries id, roles and shop,
AdminObjects maps each key to the row shape it decides over, and adoptMatrix
turns a fetched document into an evaluator:
let adopted:
| { version: string | number; access: Access<AdminSubject, AdminObjects> }
| undefined;
/**
* Adopts a matrix that arrived over the wire.
*
* `parseMatrix` and not `hydratePolicy`: the document is foreign input, and the
* closed mode is what makes an unknown permission a refusal rather than a throw
* inside a loader.
*/
export function adoptMatrix(
matrix: Matrix,
): Access<AdminSubject, AdminObjects> {
const { version } = matrix;
if (version !== undefined && adopted?.version === version) {
return adopted.access;
}
const access = parseMatrix<AdminSubject, AdminObjects, AdminPermission>(
matrix,
);
if (version !== undefined) adopted = { version, access };
return access;
}parseMatrix and never hydratePolicy: the document is foreign input, and the
closed mode makes an unknown permission a refusal where hydratePolicy throws
inside a loader. The version keys the memo, because parseMatrix validates,
deep-clones and deep-freezes.
access.ts is not a .server module, because the server evaluates the matrix
to enforce and the browser evaluates it to toggle. The fetch is the server’s
alone: it lives in access.server.ts, which the Vite plugin keeps out of the
browser bundle. A request that cannot reach shop-api falls back to NO_ACCESS,
a parsed document carrying no permissions, so every key answers
unknown-action and the page writes nothing.
Middleware answers who is asking, a loader answers whether
Middleware answers who is asking: it resolves the subject from the session and
binds it, so every loader and action on the route runs against one identity. A
loader or an action answers whether that subject may have what it came for,
which is a can call over the row it is about to return or write. Middleware
reaches no can call itself, because a decision needs the object being acted on
and a refusal needs somewhere to go, and middleware holds neither.
React Router 8’s stable middleware and createContext carry the subject.
demoSubject() is this app’s whole identity story, and readAccess and
readShelf are the two fetches the contexts below defer:
export const loadPageView: MiddlewareFunction<Response> = async ({
context,
}) => {
const correlationId = newCorrelationId();
context.set(correlationContext, correlationId);
const subject = demoSubject();
context.set(subjectContext, subject);
let contract: Promise<Awaited<ReturnType<typeof readAccess>>> | undefined;
context.set(
accessContext,
() => (contract ??= readAccess(correlationId, subject)),
);
let pending: Promise<ShelfSnapshot> | undefined;
context.set(
shelfContext,
() => (pending ??= readShelf(correlationId, subject)),
);
};subjectContext takes a value, and accessContext and shelfContext take
functions. React Router runs middleware, then the action, then the revalidating
loaders. A memo cell the first caller fills keeps a page view to one fetch, and
that fetch happens after any action.
export const subjectContext = createContext<AdminSubject>({
id: 'anonymous',
roles: [],
shop: '',
});The default is a subject with no roles and no shop, so a loader reached without the middleware having run decides against an actor Baize’s matrix refuses everywhere.
What a loader decides, with the subject already bound
The middleware bound who is asking, and this loader decides whether that
subject may read the orders page at all. allows reads one permission out of a
capability map, and telemetry.read belongs to a manager:
const subject = context.get(subjectContext);
const access = await context.get(accessContext)();
if (!allows(access.capabilities(subject), 'telemetry.read')) {
return {
refused: true,
unavailable: undefined,
figures: [],
items: defineLedgerItems([]),
};
}The nav entry pointing here is hidden from every other subject and is still a URL, so this loader decides before it reads anything.
A thrown Response is a genuine refusal, which the root ErrorBoundary renders
through isRouteErrorResponse. A degraded page returns a field:
unavailable?: string.
What crosses to the tree
Loader return values are the serialization boundary, and Access is a set of
closures that does not cross it. What crosses is the document:
import { } from '@evanion/acl';
const = ({
: 'orders@7',
: [
{ : 'question.read', : 'question', : 'read', : [] },
],
});
// The server sends `access.matrix`; the client rebuilds from it.
const = .(
.(.),
) as typeof .;
().; // -> 'orders@7'
// The construction site states what it is actually running. The option wins,
// and the frozen `matrix` carries the winner.
const = (, { : 'orders@7+veto@41' });
.; // -> 'orders@7+veto@41'
..; // -> 'orders@7+veto@41'apps/admin’s shell loader returns access.matrix, the subject and a now
string once for the whole tree. The matrix is an envelope carrying its own
version and schema, so nothing travels beside it. The subject is no
credential: the server resolves its own on the next request.
The shell component adopts that document with the same adoptMatrix the server
used, and composes the providers with ComposeProvider from @evanion/compose:
export default function Shell({ loaderData }: Route.ComponentProps) {
const { session, matrix, navItems, sidebarItems, now, unavailable } =
loaderData;
// Rebuilt when the document's version changes and not per render: the parse
// validates, deep-clones and deep-freezes. See `adoptMatrix`.
const access = useMemo(() => adoptMatrix(matrix), [matrix]);
const context = useMemo(() => ({ now }), [now]);
return (
<ComposeProvider
providers={[
provider(PolicyProvider, {
access,
subject: session.subject,
context,
}),
provider(SessionProvider, { session }),
provider(ThemeProvider, { initialDensity: 'comfortable' }),
CartProvider,
]}
>
<Chrome
navItems={navItems}
sidebarItems={sidebarItems}
unavailable={unavailable}
/>
</ComposeProvider>
);
}now crosses as a string, because the hooks key their memos on it by value and
a Date is a new object each render.
The tree toggles, and enforces nothing
useCan comes from createPolicyContext<AdminSubject, AdminObjects>, so the
key and the row are both checked where the call is written:
// A convenience, and the whole of what this decision does: it chooses between
// the form and a sentence. The action above decides again and is what stops a
// write. The shop slug is the only member the rule reads, so it is the only
// one stated.
const mayDeclare = useCan('game', 'declare', { shop: row.shop }).allowed;mayDeclare picks the availability form or a sentence naming the shop that
declares this title. The action behind the form is a URL, and the URL is still
there.
Every action decides again
The action re-resolves nothing from the loader and decides on the row it re-reads:
const decision = access.canFields(
subject,
'game',
'declare',
game,
'write',
proposed,
);
if (!decision.action.allowed) return refusal(decision.action.reason);
// Only what the decision marks allowed. Filtering the bag by hand on
// `!== 'denied'` would write the unevaluable fields and every key the decision
// does not carry, which is the mass-assignment shape a submitted form has.
const writable = pickAllowedFields(decision, proposed);
if (typeof writable['availability'] !== 'string') {
return {
refused: true,
error:
'The contract does not allow this title’s availability to be written.',
};
}
setShelfPolicy(urn, state as Availability);
return { declared: state as Availability };The action checks decision.action.allowed first, so a refusal returns the
union member the component narrows with in. pickAllowedFields throws
ActionNotAllowedError on a refused action, so the check keeps the refusal off
the exception path.
A formData bag is the mass-assignment shape: whatever the sender put a name
on arrives in it, including keys the title has never held. A hand-written filter
on !== 'denied' writes the unevaluable fields too.
Between the render that showed the button and this action arriving, the title can move to another shop and the ownership rule can stop matching.
Testing it
apps/admin/tests/access.spec.tsx calls the action directly, with a
RouterContextProvider holding what the middleware sets on a real request:
/** The request context the shell's middleware would have built. */
function contextFor(subject: AdminSubject): RouterContextProvider {
const context = new RouterContextProvider();
context.set(correlationContext, 'admin-test');
context.set(subjectContext, subject);
context.set(accessContext, async () => access);
context.set(shelfContext, async () => ({ rows: [] }));
return context;
}
/** A form submission at the availability form's URL, whoever sent it. */
function declaring(urn: string, fields: Record<string, string>): Request {
const body = new FormData();
for (const [name, value] of Object.entries(fields)) body.append(name, value);
return new Request(`http://admin.test/shelf/${urn}`, {
method: 'POST',
body,
});
}
/**
* Calls the route's action the way the router would, and the way anything else
* that reaches the URL does.
*
* The cast goes through `unknown` because the generated argument type carries
* the route's match tree, which the action itself reads none of. What it does
* read is stated in full: the request, the urn, and a context the middleware
* would have filled.
*/
function callAction(subject: AdminSubject, urn: string, request: Request) {
return action({
request,
params: { urn },
context: contextFor(subject),
} as unknown as Parameters<typeof action>[0]);
}A curl at that URL reaches the same function with the same arguments, so the
case to cover is where the tree and the action disagree:
it('refuses a title another shop lists, and writes nothing', async () => {
const result = await callAction(
operator,
GOTHENBURG_URN,
declaring(GOTHENBURG_URN, {
intent: 'declare',
availability: 'out of print',
}),
);
expect(result).toMatchObject({ refused: true });
expect(readShelfPolicy()[GOTHENBURG_URN]).toBeUndefined();
});Every case decides on a real document, because a stubbed decision only asserts that the action called something.
The browser half needs a data router, because <Form> reads its submit:
/**
* A stub router rather than a `StaticRouter`, because the availability form is
* a `<Form>` and that component reads the data router's submit.
*/
function titleFor(subject: AdminSubject, row: Game): string {
const Stub = createRoutesStub([
{
path: '/shelf/:urn',
Component: () => (
<PolicyProvider
access={access}
subject={subject}
context={{ now: '2026-09-17T09:00:00.000Z' }}
>
<SessionProvider
session={{
operator: 'Ines',
shop: 'Baize',
correlationId: 'admin-test',
subject,
}}
>
<CartProvider>
<Title
{...({
loaderData: {
row: {
urn: row.urn,
title: row.title,
mechanisms: row.mechanisms,
players: row.players,
playtime: row.playtime,
complexity: row.complexity,
quantity: 3,
availability: 'in stock',
declared: false,
shop: row.shop,
},
},
} as Parameters<typeof Title>[0])}
/>
</CartProvider>
</SessionProvider>
</PolicyProvider>
),
},
]);
return renderToStaticMarkup(<Stub initialEntries={['/shelf/x']} />);
}Where to go next
- React Server Components — the same split with a server component as the trusted side
- What can they do at all? — the map a loader reads
- Field permissions — the write axis an action decides on
- Security contract — time of check to time of use, and matrix freshness