Platforms
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.
The five guides in this band are wiring, not API. Every call they make is on the API reference; what changes per platform is where the authoritative call sits and what leaves the trusted runtime.
@evanion/acl imports no framework, so every platform answers the same three
questions: where the matrix is built, where the subject comes from, and which
call decides.
Enforcement is server-side, and the browser copy toggles
In a browser, can toggles what the user sees. Real enforcement happens in
a trusted environment: a React Router 8 or Next.js server runtime, or the
server side of an API boundary. Every app in the chain does its own
evaluation and trusts no earlier layer. A page that hid the button does not
excuse the handler the button posts to. A gateway that allowed the request
does not excuse the service behind it. The security
contract is the long form.
Where the boundary sits
Five platforms put the authoritative call in five places. Which call decides is the only security question of the three, and the only one that changes from platform to platform.
| Platform | The call that decides | What is not a boundary |
|---|---|---|
| Express | the request handler | nothing crosses; the whole app is trusted |
| NestJS | the guard, then the service after the read | the guard alone, which runs before the row |
| Astro SSR | the page’s frontmatter, once per request | the rendered markup, and the form posting back |
| React Server Components | the server component, and each Server Action separately | the client component, and the action’s caller |
| React Router 8 | the loader, and each action separately | the component tree, and clientLoader |
The two server-only platforms have one boundary, the request handler. The two React platforms have two: the render path and the write path, reached independently by a caller who never renders anything.
Astro SSR is the fifth shape, and server-side rendering (SSR) is
the whole of its trusted runtime. A prerendered Astro site has no runtime at
request time, so it belongs in no row of this table. A server-rendered one runs
a page’s frontmatter per request in a process the operator controls, and that
frontmatter is the loader and the action in one module: apps/storefront’s
cart.astro reaches one order.create decision that its POST branch and its
checkout button both read.
Many services closes this band on a topology, where each service authors and evaluates only the objects it owns.
What crosses, on the platforms where something does
Access is a set of closures over a frozen document, and those closures do not
serialize. structuredClone throws on the functions, and JSON.stringify
silently drops every method. The document is what crosses a server/client
boundary, and the client rebuilds the evaluator from it:
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'The matrix is an envelope carrying its own version and schema, so nothing
travels beside it. A payload of { matrix, version, subject } states the
version twice; access.matrix carries the effective one.
The subject crosses too, because the client needs it to evaluate. The subject is no credential: the server resolves its own from its own session on the next request and never reads the client’s copy.
Which function rebuilds depends on where the document came from
hydratePolicy rebuilds a document the same process authored, which is the
round trip above. parseMatrix rebuilds one that came from somewhere else, and
it is hydratePolicy in closed mode: a key the document does not carry answers
unknown-action, where hydratePolicy throws UnknownPermissionError.
When the owner removes a permission, parseMatrix refuses the action and keeps
serving, and hydratePolicy throws. The three frontends here fetch
apps/shop-api’s matrix and adopt it closed.
Narrow a subject before you forward it
apps/storefront reads an unsigned cookie and sends X-Shop-Subject to
apps/shop-api, so it screens the roles it forwards down to the one a shopper
can hold:
const : readonly string[] = ['customer'];A cookie claiming manager reaches the API as nothing. That hop is the last
place that knows which roles this surface may assert.
Building the matrix once
hydratePolicy validates the document, deep-clones it and deep-freezes it, and
every decision after that is a local function call over the frozen copy. Build
the matrix once per process and hold it at module scope. apps/shop-api does
that at boot, over the document SHOP_MATRIX and the row map ShopObjects its
builder produced:
export function buildShopAccess(): Access<ShopSubject, ShopObjects> {
return hydratePolicy<ShopSubject, ShopObjects>(SHOP_MATRIX);
}Holding a document you fetched
HeldPolicy pairs a parsed document with its revision, and a consumer holds one
because it has no boot-time document. ShopApi is the typed client.
apps/storefront states the window as a constant and asks only when the window
has closed:
export const POLICY_REVALIDATE_MS = 60_000;
let held: HeldPolicy | undefined;
let checkedAt = 0;
let inFlight: Promise<Access | undefined> | undefined;
/**
* The document this process decides on.
*
* Module scope and not per request: construction validates, deep-clones and
* deep-freezes the document, every decision after that is a local function call
* over the frozen copy, and the document is the same for every visitor. Holding
* it here is what keeps a network call out of a decision -- a page render reads
* an already-adopted `Access` and calls `can` on it.
*
* Two things replace what is held: a revision the shop-api reports that differs
* from the held one, and the {@link POLICY_REVALIDATE_MS} window expiring, which
* is what makes this app ask at all. A matching revision re-uses the adopted
* object, so re-validating the same bytes on a poll is skipped.
*
* A failed or unreadable response leaves the held document in place and the
* window open, so the next request asks again. The alternative refuses every
* shopper for as long as the shop-api is unreachable, while the shop-api is the
* layer that would refuse a real write anyway.
*
* No `fetchedAt` is reported to `parseMatrix`: that option obliges the document
* to state `maxStale`, and the shop-api's does not.
*/
export async function policyAccess(api: ShopApi): Promise<Access | undefined> {
if (held && Date.now() - checkedAt < POLICY_REVALIDATE_MS) return held.access;
// One fetch for however many renders are in flight when the window expires.
inFlight ??= refresh(api).finally(() => {
inFlight = undefined;
});
return inFlight;
}POLICY_REVALIDATE_MS is the ceiling on how long a rule change takes to reach
this app’s gates. One minute is safe because shop-api re-decides every request
on its own copy. The in-flight promise holds every render that arrives on an
expired window to one fetch.
async function refresh(api: ShopApi): Promise<Access | undefined> {
const document = await api.policy();
if (!document.ok) return held?.access;
const { version, matrix } = document.value;
if (held && held.version === version) {
checkedAt = Date.now();
return held.access;
}
try {
const access = parseMatrix(matrix);
held = { version, access };
checkedAt = Date.now();
return access;
} catch {
// A document that fails validation is not a document. Nothing replaces what
// is held, and the window stays open so the next request asks again.
return held?.access;
}
}A version that matches re-uses the adopted object, so the validate, clone and freeze are skipped on a poll that changed nothing. A failed response leaves the held document in place and the window open, so the next request asks again. A document that fails validation replaces nothing.
No fetchedAt reaches parseMatrix here. Supplying it obliges the document to
state maxStale, and shop-api’s document states none, so adoption throws
MissingFreshnessBudgetError. Where an owner does state a budget, fetchedAt
makes every decision past fetchedAt + min(maxStale, options.maxStale) answer
stale-contract.
Resolving the subject
can(subject, …) authorizes the bag it is handed and cannot ask where the bag
came from. Each guide resolves it from that platform’s own verified session
primitive, server-side, before the first decision. A subject assembled from a
header, a query parameter or a posted field is the confused deputy, and no
evaluator detects it.
Deciding twice is the design
can is called twice on a platform with a browser half, once on the server and
once in the browser. Keep both. The browser copy answers “what should this page
look like”, for a person looking at it. The server copy answers “may this
happen”, for a script posting to the same URL with curl.
The write path
An action-level permission carries no field rules, so the whole write stands or
falls on one can call, which is apps/storefront’s checkout. A permission
carrying field rules needs the second call, and the value to write is what
pickAllowedFields returns:
import { , } from '@evanion/acl';
const = <
{ : string },
{ : { : string; : string; : string } }
>()
.('question', () =>
.('update', .('object.askedBy', 'subject.id'))
.(['*', '!status']),
)
.();
const = { : 'c1', : 'In stock?', : 'open' };
const = { : 'Wingspan in stock?', : 'locked' };
const = .(
{ : 'c1' },
'question',
'update',
,
'write',
,
);
.['status']; // -> 'denied'
.((, )); // -> '{"body":"Wingspan in stock?"}'Check decision.action.allowed first. pickAllowedFields throws
ActionNotAllowedError on a refused action, so the check keeps the refusal off
the exception path. What the call returns is only the keys the decision marked
allowed. Field permissions has the rest.