Skip to Content

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.

PlatformThe call that decidesWhat is not a boundary
Expressthe request handlernothing crosses; the whole app is trusted
NestJSthe guard, then the service after the readthe guard alone, which runs before the row
Astro SSRthe page’s frontmatter, once per requestthe rendered markup, and the form posting back
React Server Componentsthe server component, and each Server Action separatelythe client component, and the action’s caller
React Router 8the loader, and each action separatelythe 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.

Which rebuild call a fetched document gets. A document the same process authored rebuilds with hydratePolicy, where a key the document does not carry throws UnknownPermissionError; a document another service published rebuilds with parseMatrix in closed mode, where the same key answers unknown-action and the page keeps serving.

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 same permission decided twice, for two different questions. The browser copy decides what the page draws and enforces nothing; a script that never loads the page reaches the handler anyway, where the server copy decides whether the write happens.

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.

Last updated on