Skip to Content
AuthorizationReact Server Components

React Server Components

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.

React Server Components (RSC) is the model this page is about, and Next.js is the implementation it is shown in. apps/storefront-rsc in this repository is a Next 16 App Router app with no 'use client' file in it at all. It adopts apps/shop-api’s published document and gates its server components on it, and the fences citing it run under nx test storefront-rsc. The client half of this page is guidance for an app that has a client tree; that app is not this one.

The concept. A server component renders in the Node process, so a can call inside it enforces.

What you get. A widget that decides whether the subject may read a title before it fetches the title.

Why you want it. A caller posts to a Server Action directly, and the render that hid the button never ran for them.

How the library gets you there. parseMatrix adopts shop-api’s document once per version, so every can is a local call over frozen JSON.

What an RSC app decides, and where

A server component renders in a trusted runtime, so the render is a place a decision can be made. A Server Action is a second trusted entry point into the same route, reached without the render ever running, and a decision made at the render does not carry to it. Every framework implementing RSC puts the boundary in those two places.

apps/storefront-rsc runs four pieces:

PieceWhat it does
GET /api/policyfetched once per page view
parseMatrixturns what comes back into an evaluator
React’s cacheholds the result for the render
each server componentdecides before it fetches the data it would render

parseMatrix turns the fetched document into an evaluator in this process, so every can after it is a local function call however deep in the tree it sits. access.version is the revision the memo compares the next fetched document against.

Baize’s spotlight widget shows a staff price line and the activity widget shows the order trail. Each fetches its own data and answers “may this visitor see this” without a second network call.

The render and the write are two boundaries

A route has two trusted entry points, the render and each Server Action, and a caller reaches either one without the other.

A Server Action is a public endpoint the page did not guard

The server component that hid the button and the Server Action the button posts to are reached independently. A caller invokes the action’s endpoint directly, without rendering anything. The action re-resolves the subject and decides again, or it is unguarded.

WhereRuntimeDecides?
Server componenttrustedyes, for what it renders
'use client' componentbrowsernever; it toggles what the user sees
Server Actiontrustedyes, independently, for what it writes
Route handler / API routetrustedyes, independently
Two trusted entry points into one route, reached independently. A page view runs the server component, which decides what it renders and hands a client component a verdict that only toggles; a caller posting to the Server Action skips the render entirely, so the action resolves the subject and decides for itself.

Adopting a document another service owns

apps/storefront-rsc authors no rules and adopts shop-api’s contract. PolicyDocument is the { version, matrix } body the endpoint answers with, ShopAccess is the evaluator bound to this app’s subject and row types, and adopt is the memo:

let adopted: { version: string | number | undefined; access: ShopAccess } | undefined; /** * The evaluator over `document`, parsed only when the version moved. * * `parseMatrix` and not `hydratePolicy`: this document crossed the wire from * another service, so it is adopted in the fail-closed mode, where a key the * contract does not carry decides `unknown-action` and refuses rather than * throwing mid-render. `inventory.read` is such a key. shop-api keeps it * internal, and no widget here asks about it. * * The assertion names the subject and the rows this app passes. `parseMatrix` * is the foreign path and returns the erased instantiation: a document read at * runtime carries no TypeScript view of the shapes its owner wrote it over, and * the erased and the named instantiation share no overlap TypeScript can check, * which is what the hop through `unknown` says. What holds the two in step is * the document's own `schema`, which shop-api publishes and `parseMatrix` * checks every condition against. */ function adopt(document: PolicyDocument): ShopAccess { if (adopted && adopted.version === document.version) return adopted.access; const access = parseMatrix(document.matrix) as unknown as ShopAccess; adopted = { version: document.version, access }; return access; }

parseMatrix and never hydratePolicy. The document crossed the wire from another process, so this app adopts it in closed mode, where a key the contract does not carry decides unknown-action and refuses. Under hydratePolicy the same key throws mid-render.

An app that owns its own rules builds them with policy(…).build() at module scope, which NestJS and Express both show. Put that module behind import 'server-only', so a client component importing it is a build error.

One fetch and one subject for the whole render

fetchJson is this app’s typed client for shop-api, currentSubject reads the visitor’s cookie, and authorize returns an Authorized<ShopObjects> bound to that subject and one clock instant:

export const currentAccess = cache(async (): Promise<ShopAccess> => adopt(await fetchJson<PolicyDocument>('/policy')), ); /** * The matrix bound to this page view's subject and one clock instant. * * Bound once so every widget decides against the same actor at the same * instant. Two widgets settling their own `now` a few milliseconds apart would * be able to disagree about a rule with a time window in it. * * This is the handle the widgets call, and it is the app's whole enforcement * story on the render side. shop-api decides again on its own copy of the same * document for every request this app sends it, so a widget that decided wrong * is a rendering bug and not an access-control one. */ export const authorized = cache(async (): Promise<Authorized<ShopObjects>> => { const [access, subject] = await Promise.all([ currentAccess(), currentSubject(), ]); return access.authorize(subject); });

React’s cache scopes to one request. Three widgets render independently, one GET /api/policy answers all three, and two concurrent page views each get their own fetch, which a module-level promise does not give them.

The server component decides before it fetches

Spotlight takes a urn and loads the title and its stock as this app’s own Game and Stock shapes, through urnPath for the encoding. game.read gates the card and game.reprice the staff price line inside it:

const may = await authorized(); if (!may.can('game', 'read').allowed) return null; const [game, stock] = await Promise.all([ fetchJson<Game>(`/games/${urnPath(urn)}`), fetchJson<Stock>(`/inventory/${urnPath(urn)}`), ]); const mayReprice = may.can('game', 'reprice', game).allowed;

The reprice decision waits for game, because the matrix compares the title’s shop against the subject’s and nothing can answer that without the row.

Activity lists the TelemetryEvent rows shop-api recorded lately, and telemetry.read belongs to a manager alone:

// Decided before the fetch, not after it. A gate that renders nothing over // data it already asked for has still pulled the events into this process, // and the whole point of the section is that they never arrive. const may = await authorized(); if (!may.can('telemetry', 'read').allowed) return null; const events = await fetchJson<TelemetryEvent[]>('/telemetry');

The gate sits above the fetch. A widget that renders nothing over data it has already asked for has still pulled the events into this process.

Where a refusal has to be a whole page, notFound() is the right answer for a title that exists. A page saying “forbidden” for a real urn and “not found” for a made-up one is an existence oracle.

Freshness for a consumer

shop-api stamps a version on what it publishes and changes it when the rules change. adopt compares that string with !==, so a document republished under a new version replaces the held evaluator on the next page view, and a matching version skips the validate, deep-clone and deep-freeze.

What one page view does with the fetched document. React's cache scopes the GET to the render, and adopt compares the fetched version with the one this process holds: a matching version re-uses the held evaluator, and a new version is validated, deep-cloned and deep-frozen once before it replaces the held one.

apps/storefront-rsc asks once per page view, because cache scopes the fetch to the render, so a rule change reaches this app’s gates on the next view. An app that holds a document across requests needs a revalidation window, which Platforms sets out.

The hooks cannot gate a server tree

@evanion/react-acl ships 'use client' as the first line of its entry point, and the published build carries the directive too. A server component that imports useCan from it therefore imports a client reference, not a function, and calling that reference during the server render throws. The package is the browser half of the model and has no server half.

A server tree decides with @evanion/acl directly: parseMatrix builds the evaluator and access.can answers in the same process, with no hook and no context. The two halves read the same document and neither trusts the other’s verdict.

A client tree, where an app has one

apps/storefront-rsc has no client component, so nothing below runs in it. An app that does have a client tree hands it the document, because Access is a closure set over a frozen document and does not serialize. structuredClone throws on the functions and JSON.stringify silently drops every method:

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'

@evanion/react-acl is the client half. createPolicyContext returns a provider and the hooks bound to your subject and row types, and a server component mounts the provider because it is the side holding the document:

// app/access-boundary.tsx 'use client'; import { useMemo } from 'react'; import type { ReactNode } from 'react'; import { parseMatrix, type Matrix } from '@evanion/acl'; import { PolicyProvider } from './access-context'; import type { ShopSubject } from './subject'; export default function AccessBoundary({ matrix, subject, now, children, }: { matrix: Matrix; subject: ShopSubject; now: string; children: ReactNode; }) { // parseMatrix validates, deep-clones and deep-freezes. Once per document. const access = useMemo(() => parseMatrix(matrix), [matrix]); const context = useMemo(() => ({ now }), [now]); return ( <PolicyProvider access={access} subject={subject} context={context}> {children} </PolicyProvider> ); }
Not executed — nothing here runs it

now crosses as an ISO string, because the hooks key their memos on it by value and a Date is a fresh object every render. Resolve now on the server, so the server render and the client’s first render agree. A now reaching can from a client payload hands the client every time window in the matrix.

A useCan that returns null hides a button. The hidden button does not stop anyone posting to the action behind it, so the client decision enforces nothing.

Every Server Action decides again

A Server Action is a route a caller posts to directly, and the render that hid the button never ran for them. apps/storefront-rsc has no Server Action, and an app that adds one owes it a full decision of its own:

// app/games/actions.ts 'use server'; import { pickAllowedFields } from '@evanion/acl'; import { authorized } from '../access'; import { catalogue } from '../catalogue-store'; export async function declareAvailability(form: FormData) { const urn = String(form.get('urn')); const game = await catalogue.find(urn); if (!game) throw new Error('not found'); const proposed = Object.fromEntries(form); const may = await authorized(); const decision = may.canFields('game', 'declare', game, 'write', proposed); if (!decision.action.allowed) throw new Error('forbidden'); await catalogue.update(urn, pickAllowedFields(decision, proposed)); }
Not executed — nothing here runs it

The action re-resolves the subject, re-reads the title, and decides against both, because the page did not run for this caller. Between the render that showed the control and this action arriving, the title can move to another shop and the ownership rule can stop matching.

Where to go next

Last updated on