Unreleased changes
These pages document main, which has changes 0.1.2 does not. npm install @evanion/acl gives you 0.1.2, documented here. Values on these pages are produced by running the current source on every build.
Next.js (React Server Components)
In a Next.js application, authorization happens at the trust boundary. When a request hits the server, your server components and Server Actions act as the final gatekeepers. Because they run in a trusted Node.js process, they can call @evanion/acl’s access.can to enforce rules before any data is rendered or any write is performed.
Adopting a shared policy document
In a microservices architecture, your frontend doesn’t usually author the rules; it adopts a document published by a backend service.
In the Baize storefront (apps/storefront-rsc), the app fetches a PolicyDocument (containing a version and the matrix) from the shop-api. It then uses parseMatrix to create an evaluator.
let adopted:
{ version: string | number | undefined; access: ShopAccess } | undefined;
/**
* The evaluator over `document`, parsed only when the version moved.
*
* `parseMatrix` and not `hydratePolicy`: a key the document does not carry,
* such as the internal `inventory.read`, refuses with `unknown-action` where
* `hydratePolicy` would throw mid-render.
*
* The cast states this app's subject and row types. `parseMatrix` reads the
* document at runtime, so it returns an `Access` over plain records, and
* TypeScript cannot check that against `ShopAccess`. The document's `schema`,
* which `parseMatrix` checks every condition against, is what keeps shop-api's
* rows and these types in step.
*/
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;
}Why parseMatrix instead of hydratePolicy?
When you adopt a document from another process, you use parseMatrix to enter closed mode. In closed mode, if a request asks about a permission key that the document doesn’t carry, the engine returns unknown-action and refuses. Using hydratePolicy in this scenario would cause the app to throw an error mid-render, which would crash the page.
Efficiency through versioning
To avoid the cost of validating and deep-freezing the matrix on every single request, the app keys its evaluator by version. If the fetched version matches the held version, the app re-uses the existing evaluator, skipping the expensive setup process.
Request-scoped authorization
To ensure that every component on a page uses the same subject and the same instant in time, the app scopes the evaluator to the request.
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);
});By wrapping the logic in React’s cache, the app ensures that GET /api/policy is called only once per render, regardless of how many components (like Catalogue or Spotlight) need to check permissions.
Gating the render and the fetch
In a Server Component, you can decide whether to render a piece of UI before you even fetch the data it needs.
For example, the Activity component might list recent events, but only for managers. By calling can() before the fetch, you prevent the server from pulling sensitive data from the API if the user isn’t authorized to see it:
// Decided before the fetch. A gate that renders nothing over data it already
// asked for has still pulled the events into this process.
const may = await authorized();
if (!may.can('telemetry', 'read').allowed) return null;
const events = await fetchJson<TelemetryEvent[]>('/telemetry');Conversely, if a rule depends on the object’s data (like checking if a user owns a specific game), you must fetch the row first, then decide.
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 two boundaries: Render and Action
A Next.js route has two independent trusted entry points: the Render (Server Component) and the Server Action.
| Boundary | Runtime | Role |
|---|---|---|
| Server Component | Trusted | Decides what is rendered; hides buttons or sections. |
| Client Component | Browser | Advisory only; toggles UI based on the render’s verdict. |
| Server Action | Trusted | Decides independently if a write is allowed before committing to DB. |
Why Server Actions must decide again
Even if the render hid a “Delete” button from the user, a malicious actor can post directly to the Server Action endpoint using curl or a script. Therefore, the Server Action must never trust the render’s verdict; it must re-resolve the subject, re-read the row, and decide again.
import { , , type } from '@evanion/acl';
type = { : string; : string[]; : string };
type = { : string; : string; : string; : number };
const = <
,
{ : },
{ : | 'declare' }
>()
.('game', () =>
.(
'declare',
.('subject.roles', 'operator'),
.('object.shop', 'subject.shop'),
)
.({ : ['availability'] }),
)
.();
const = new <string, >([
[
'urn:game:wingspan',
{
: 'urn:game:wingspan',
: 'stockholm',
: 'in-stock',
: 59900,
},
],
[
'urn:game:gloomhaven',
{
: 'urn:game:gloomhaven',
: 'gothenburg',
: 'in-stock',
: 149900,
},
],
]);
// Stands in for reading the verified session, which Next.js reaches through
// `cookies()`.
const = async (): <> => ({
: 'staff:ines',
: ['operator'],
: 'stockholm',
});
async function (: FormData): <void> {
'use server';
const = await ();
const = .((.('urn')));
if (!) throw new ('not-found');
// Whatever the sender put a name on arrives here, `urn` and `price` too.
const = .() as <>;
const = .(
,
'game',
'declare',
,
'write',
,
);
if (!..) throw new ('forbidden');
.(., {
...,
...(, ),
});
}
// Two submissions straight to the action, with no render before either.
const = (: <string, string>) => {
const = new ();
for (const [, ] of .()) .(, );
return ().(
() => 'written',
(: Error) => .,
);
};
const = await ({
: 'urn:game:wingspan',
: 'preorder',
: '1',
});
; // -> 'written'
.('urn:game:wingspan')?.; // -> 59900
const = await ({
: 'urn:game:gloomhaven',
: 'preorder',
});
; // -> 'forbidden'Client-side UI toggles
To provide a snappy UI, you can pass the access.matrix (but never the access evaluator itself, as functions cannot be serialized) to the client.
The client rebuilds the evaluator using parseMatrix and wraps the tree in the @evanion/react-acl provider. This allows client components to use the useCan hook to hide or show elements instantly. However, this is purely advisory; the actual enforcement always happens on the server.
Where to go next
- React Router 8. Similar split between loaders and actions.
- The matrix: your rules as JSON. Understanding the envelope and versioning.
- Security contract. Why every layer must decide for itself.