Skip to Content
AuthorizationReact Router 8

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,
  • useCan picking 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.

WhereRuntimeDecides?
middlewareserveranswers who is asking; decides nothing
loaderserveryes, for what it returns
Route componentbrowsernever; it toggles what the user sees
actionserveryes, independently, for what it writes
clientLoaderbrowsernever
Two server entry points on one route, with the browser tree between them. Middleware answers who is asking and decides nothing further, the loader decides whether that subject may have the page, and a form post and a curl both reach the action, which decides again on the row it re-reads before the revalidating loaders run.

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

Last updated on