@evanion/acl
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 implementation is complete and covered by the unit suite plus an
adversarial suite held against libs/acl/SECURITY.md, against the approved
spec at docs/superpowers/specs/2026-09-14-acl-design.md. Inside this
repository it resolves from source like any other workspace package.
The concept. @evanion/acl keeps every authorization rule in one JSON
document, called the matrix.
What you get. A server handler and a browser control that answer from the
same rules, through the same call.
Why you want it. A rule copied into a second codebase by hand diverges the
first time somebody edits one copy.
How the library gets you there. policy() writes the document, and can,
canMany, canFields and capabilities read it.
What the access object answers
policy() writes the JSON document, called the matrix, and hands back an access
object. Four of its methods carry most of the work.
candecides one action and carries the reasons beside the answer.capabilitiesdecides every action at once.authorizebinds one subject for the length of a request.canFieldsnames the fields of a write you may set.
Each method is a local function over the frozen document, so no call waits on a network round trip. The matrix answers one question: may this subject take this action on this object? Baize, a board game shop, states three rules that way. A customer may edit their own question, a bookseller may edit anybody’s, and a locked question is editable by nobody.
import { } from '@evanion/acl';
type = { : string; : string };
const = <{ : string }, { : }>()
.('question', () =>
.('update', .('object.askedBy', 'subject.id')),
)
.();
const = .({ : 's1' }, 'question', 'update', {
: 's1',
});
.; // -> truecan answers with a decision. allowed is the answer; reason, rule and
missing are why.
One .for() block declares one object kind, and a policy chains a block per
kind. The example declares only question because that is all it asks about.
The server that enforces the answer and the screen that renders it read the same rules, because both run over the same document. A rule copied by hand into a second place diverges from the first: you tighten the handler, forget the loader, and the UI greys out a button the API would have accepted.
A decision counts where it is made
The same can call is authoritative in a trusted environment and advisory
in a browser, with the same signature and the same return type in both.
Nothing in the types separates them. Client-side evaluation toggles what a
user sees; enforcement happens server-side, in every layer, each one
deciding for itself. The security contract is the rest of
that sentence, and it is not optional reading.
When to reach for acl
acl fits rules that read one of these:
- Permissions that turn on which object is in front of the user as well as on the role. “The customer who asked may edit their own question”, “an open question may be deleted and a locked one may not”.
- The same rules on a server and in a UI. The document crosses an SSR boundary
as
access.matrixand rebuilds on the other side. - A UI that explains a block as well as enforcing it: which rule refused, which field is read-only, and what is still missing.
- A field-level write, where the answer names which fields are yours.
When acl is the wrong tool
acl does not do these four jobs:
- Anything the document cannot state in the open. The matrix reaches the client in full, so every object kind, action, role string, field name and time boundary in it is public. Opaque predicates stay server-side, outside the matrix.
- Filtering a collection in the database. A decision is per instance, and
generating a
WHEREclause from the matrix is out of scope. - Rules that call a function. Conditions are declarative because the matrix must
pass through
JSON.stringifyunchanged. - A non-JS backend. Only the JSON crosses, and a .NET or Go service uses its own evaluator.
What the acl package exports
The entry point exports these beside policy() and can:
| Export | What it is |
|---|---|
policy<Subject, Objects, Verbs>() | the typed builder; one .for() block per kind, .build() at the end |
hydratePolicy(matrix, options?) | an evaluator over a document you already have |
parseMatrix(json, options?) | the same, for somebody else’s document; fails closed on a key it does not know |
access.can / canMany / canFields | one decision, a decision per instance, the field-level decision |
pickAllowedFields(decision, proposed) | the subset of a proposed write to actually write |
access.capabilities / authorize | every decision at once, and a subject bound for the request |
access.object / readsObject | one kind bound, and whether a rule reads the row |
access.matrix / version / schema | the frozen document and its two header fields |
serialize(access, mode, options?) | the document to hand a consumer |
federatedPolicies(policies) | one evaluator per origin, behind one handle |
applyDenyOverlay(matrix, overlay, …) | another team’s denies, merged before construction |
AclConfigError and its subclasses | every construction failure; see Typed authoring |
The @evanion/acl entry point imports no framework. The React provider and
hooks are a separate package, @evanion/react-acl.
Where to go next
Setup runs to four pages, separated by what a rule may read:
- Rules that read the subject. A handful of permissions,
canand done. - Rules that read the object. The typed builder, field permissions and the guarded save.
- Rules that another service wrote. The schema, versioning and the revalidate contract.
- One policy behind a screen. The fetched document driving an action bar, and where the wording of a refusal comes from.
Platforms follows those: one guide per stack, and Many services for a topology that spans them.
Then the question you arrived with:
- Can this user do this?.
can,canMany, and what the decision carries besideallowed. - What can they do at all?. The no-object contract, and how a menu draws itself from one call.
- Which fields may they write?.
canFieldsandpickAllowedFields. - Why was this refused?. The explanation fields, and the
repairable
unevaluablestate. - A policy from another service.
parseMatrixand the closed mode. - Giving my rules to another service.
serializeand the reduced document.
The reference is below those, and one page of it is not optional:
- Caveats and pitfalls. The mistakes this API makes easy, and the two gaps it does not defend.
- Security contract. What the library prevents, what it hands you, and what remains yours.
- The matrix document, Typed authoring, Decisions, Field permissions. The mechanisms.
- API reference. Every export and every type it names, down to the error classes.