Skip to Content
AuthorizationOverview

@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.

One document, two evaluators. policy() writes the matrix in the process that owns the rules, access.matrix crosses to the browser as JSON, and hydratePolicy rebuilds an evaluator over it. Both sides call can over the same rules, and the service's own answer is the one that guards the write.

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.

  • can decides one action and carries the reasons beside the answer.
  • capabilities decides every action at once.
  • authorize binds one subject for the length of a request.
  • canFields names 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', }); .; // -> true

can 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.matrix and 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 WHERE clause from the matrix is out of scope.
  • Rules that call a function. Conditions are declarative because the matrix must pass through JSON.stringify unchanged.
  • 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:

ExportWhat 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 / canFieldsone decision, a decision per instance, the field-level decision
pickAllowedFields(decision, proposed)the subset of a proposed write to actually write
access.capabilities / authorizeevery decision at once, and a subject bound for the request
access.object / readsObjectone kind bound, and whether a rule reads the row
access.matrix / version / schemathe 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 subclassesevery 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:

Platforms follows those: one guide per stack, and Many services for a topology that spans them.

Then the question you arrived with:

The reference is below those, and one page of it is not optional:

Last updated on