API reference
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.
@evanion/acl exports the evaluator, the typed builder, the document types and
the construction errors, one heading each below. Node 20 or newer, ESM only. The
React provider and hooks are @evanion/react-acl.
hydratePolicy
hydratePolicy builds an evaluator over a matrix document.
function hydratePolicy<
Sub = Subject,
R = AnyObjects,
Keys extends string = string,
>(matrix: Matrix, options?: AccessOptions): Access<Sub, R, Keys>;hydratePolicy copies the document, freezes and validates the copy, and throws
on an unknown key. All three type parameters default to the open forms JSON
carries.
parseMatrix
parseMatrix is hydratePolicy with closed: true, for a document another
service published.
function parseMatrix<
Sub = Subject,
R = AnyObjects,
Keys extends string = string,
>(matrix: Matrix, options?: AccessOptions): Access<Sub, R, Keys>;An unknown object kind or action answers unknown-action and nothing throws.
Keys is the union the consumer expects, unchecked against the document. See
Adopting a foreign matrix.
policy
policy returns the typed builder, one .for() per object kind. See
Typed authoring.
function policy<
Sub,
Objects,
Vocabs extends Partial<Record<keyof Objects, string>> = Record<never, never>,
>(
options?: PolicyOptions,
): Policy<Sub, Objects, Vocabs, Record<never, never>, never>;| Type parameter | What it is |
|---|---|
Sub | the subject type every subject.* path is checked against |
Objects | each object kind mapped to its row type |
Vocabs | each kind mapped to the actions it answers for |
Action | the vocabulary a kind Vocabs omits gets, carrying no wildcard member; docs/specs/2026-09-17-acl-wildcard-action.md records why |
VocabularyOf | what one kind’s block may declare from; string there opens the kind to any action |
Policy
interface Policy<
Sub,
Objects,
Vocabs extends Partial<Record<keyof Objects, string>>,
R,
Keys extends string = string,
> {
for<K extends keyof Objects & string, Act extends string = string>(
key: K,
build: (
p: Actions<Sub, Objects[K], never, VocabularyOf<Vocabs, K>>,
) => Actions<Sub, Objects[K], Act, VocabularyOf<Vocabs, K>> | void,
): Policy<
Sub,
Objects,
Vocabs,
R & Record<K, Objects[K]>,
Keys | PermissionKeys<K, Act>
>;
readonly matrix: Readonly<Matrix>;
build(options?: AccessOptions): Access<Sub, R, Keys>;
}| Member or type | What it is |
|---|---|
for | takes no type arguments, reading the object type out of Objects, the vocabulary out of Vocabs and Act off the chain its block returns |
matrix | flattens the blocks without building one |
build | validates every block once and returns the Access |
PermissionKeys | the keys one block contributes; an Act the compiler reads as string contributes string, widening the document’s whole key union |
KeysOf | lifts those keys out of an Access type; a producer exports KeysOf<typeof access> for a consumer compiled against its source |
ActionOf | the action half of the keys one object kind holds, checked against every query’s action parameter; open, empty and unmatched key unions answer string |
PolicyOptions
type PolicyOptions = Pick<Matrix, 'version' | 'schema'>;Both are document fields, so they land in the flattened JSON.
AccessOptions
interface AccessOptions {
version?: string | number;
closed?: boolean;
fetchedAt?: Instant;
maxStale?: number;
}| Option | What it is |
|---|---|
version | overrides the document’s own version |
closed | fails closed on an unknown key |
fetchedAt | when this holder last validated; a holder that supplies it obliges the document to state maxStale |
maxStale | a local ceiling, min-ed with the document’s |
Access
Access is the evaluator over one frozen document.
interface Access<Sub = Subject, R = AnyObjects, Keys extends string = string> {
readonly matrix: Readonly<Matrix>;
readonly version: string | number | undefined;
readonly schema: MatrixSchema | undefined;
can(subject, key, action, object?, now?): Decision;
canMany(subject, key, action, objects, now?): Decision[];
canFields(subject, key, action, object, axis, proposed?, now?): FieldDecision;
capabilities(subject, now?): Record<Keys, Decision>;
authorize(subject, options?: { now?: Instant }): Authorized<R, Keys>;
object(key): BoundKind<Sub, R[K], ActionOf<Keys, K>>;
readsObject(key, action): boolean;
}| Member | What it answers |
|---|---|
can | one decision for one action on one row |
canMany | one decision per row, settling one clock for the whole list |
canFields | the field-level decision on the read or write axis |
capabilities | every permission key, decided against one subject |
authorize | an Authorized handle carrying the subject |
object | a BoundKind handle carrying the object kind |
readsObject | whether the permission needs the row before it can decide |
matrix, version, schema | the frozen document, and the two envelope fields off it |
Every object parameter takes a partial: a projection is the
unevaluable case. An absent
subject.* path is a definite miss.
Authorized
Authorized is what authorize(subject) returns.
interface Authorized<R = AnyObjects, Keys extends string = string> {
can<K extends keyof R & string>(
key: K,
action: ActionOf<Keys, K>,
object?: Partial<R[K]>,
): Decision;
canMany<K extends keyof R & string>(
key: K,
action: ActionOf<Keys, K>,
objects: readonly Partial<R[K]>[],
): Decision[];
canFields<K extends keyof R & string>(
key: K,
action: ActionOf<Keys, K>,
object: Partial<R[K]>,
axis: 'read' | 'write',
proposed?: Partial<R[K]>,
): FieldDecision;
capabilities(): Record<Keys, Decision>;
}Authorized omits matrix, version, schema and readsObject, which are
not facts about one subject.
BoundKind
BoundKind is what object(key) returns.
interface BoundKind<Sub, Obj, Act extends string = string> {
can(
subject: Sub,
action: Act,
object?: Partial<Obj>,
now?: Instant,
): Decision;
canMany(
subject: Sub,
action: Act,
objects: readonly Partial<Obj>[],
now?: Instant,
): Decision[];
canFields(
subject: Sub,
action: Act,
object: Partial<Obj>,
axis: 'read' | 'write',
proposed?: Partial<Obj>,
now?: Instant,
): FieldDecision;
readsObject(action: Act): boolean;
}authorize binds the other axis; the two compose.
KeysOf
type KeysOf<A> = A extends Access<never, never, infer Keys> ? Keys : never;ActionOf
type ActionOf<Keys extends string, K extends string> = [Keys] extends [never]
? string
: string extends Keys
? string
: [Extract<Keys, `${K}.${string}`>] extends [never]
? string
: Keys extends `${K}.${infer Act}`
? Act
: never;CRUD_ACTIONS
const CRUD_ACTIONS: readonly ['create', 'read', 'update', 'delete'];CRUD_ACTIONS declares the four default verbs, and Action reads off it. Pass
it to allowEach.
pickAllowedFields
pickAllowedFields returns the subset of a proposed write the decision marked
allowed, and throws ActionNotAllowedError on a refused action. See
the write path.
function pickAllowedFields<T extends Record<string, unknown>>(
decision: FieldDecision,
proposed: T,
): Partial<T>;serialize
function serialize<Sub, R, Keys>(
access: Access<Sub, R, Keys>,
mode: 'full',
): Matrix;
function serialize<Sub, R, Keys>(
access: Access<Sub, R, Keys>,
mode: 'reduced',
options?: SerializeOptions<Keys>,
): Matrix;full hands back access.matrix. reduced keeps each permission marked
visibility: 'public' whole, strips the marking, and adds the schema entries
for the kinds they name. See
Many services.
SerializeMode
type SerializeMode = 'full' | 'reduced';SerializeOptions
interface SerializeOptions<Keys extends string = string> {
readonly vetoable?: readonly Keys[];
}A listed key the reduction would drop raises UnpublishedVetoableError. The
compiler refuses a key the document never held, where the builder authored the
policy.
federatedPolicies
federatedPolicies composes one Access per origin and merges no document. See
Many services.
function federatedPolicies(
policies: Readonly<Record<string, Access<Subject, AnyObjects, never>>>,
): FederatedAccess;FederatedAccess
interface FederatedAccess {
can(
subject: Subject,
key: string,
action: string,
object?: Record<string, unknown>,
now?: Instant,
): Decision;
capabilities(subject: Subject, now?: Instant): Record<string, Decision>;
get(origin: string): Access | undefined;
}can routes to the origin holding `${object}.${action}`; a key no member
holds answers unknown-action. capabilities settles one instant for all.
applyDenyOverlay
applyDenyOverlay appends another team’s deny rules to the keys this document
opens for veto. See
Many services.
function applyDenyOverlay(
matrix: Matrix,
overlay: DenyOverlay,
options: DenyOverlayOptions,
): Matrix;DenyOverlay
type DenyOverlay = Readonly<Record<string, readonly Rule[]>>;A Rule spells no allow and no field rule, so an overlay only subtracts.
DenyOverlayOptions
interface DenyOverlayOptions {
readonly vetoable: readonly string[];
}A key listed in vetoable obliges the matrix to declare that key’s object kind
in schema.objects.
Matrix
Matrix is a JSON envelope over a flat list of permissions. See
The matrix document.
interface Matrix {
readonly version?: string | number;
readonly maxStale?: number; // how long a holder may keep deciding on it
readonly schema?: MatrixSchema;
readonly permissions: readonly Permission[];
}| Type | What it holds |
|---|---|
Permission | one `${object}.${action}` key, its allow rules, its deny rules and its field rules |
Rule | an id and a list of AND-ed conditions |
Condition | one comparison; The matrix document has the operator table |
FieldRules | the fields list, then one key per field carrying a write-axis config, so a field named fields cannot be configured |
FieldConfig | targets allow-lists the proposed value and transitions is a state machine over the current one; see Fields |
MatrixSchema | optional for a producer, binding where present, per kind: a kind objects omits is unchecked |
ObjectSchema | a kind’s field types, and the relations naming the kinds it points at, one hop; no condition compares a relation |
FieldType | a flat string any language emits by reflection, where ? marks a field that may be absent |
Instant | an ISO 8601 string, epoch milliseconds, or a Date; the string and number forms survive JSON |
Permission
interface Permission {
key: string; // exactly `${object}.${action}`
object: ObjectKey;
action: string; // whatever the producer wrote
rules?: readonly Rule[]; // allows, OR-ed
denyRules?: readonly Rule[]; // denies, OR-ed; a matched deny wins
fields?: FieldRules;
visibility?: 'public' | 'internal'; // absent is internal
}Rule
interface Rule {
id?: string; // what a decision reports as `rule`; defaults to `#0`, `#1`, …
when?: readonly Condition[]; // AND-ed
}Condition
type Condition =
| { field: 'now'; op: 'before' | 'after'; value: Instant }
| {
field: string;
op: 'eq' | 'ne' | 'in' | 'not-in' | 'contains';
path?: string; // the `eq`/`ne` form only
value?: unknown;
};FieldRules
interface FieldRules {
fields?: readonly string[]; // ['*', '!status'] or ['body', 'title']
[field: string]: readonly string[] | FieldConfig | undefined;
}FieldConfig
type FieldConfig =
| { targets: readonly unknown[] }
| { transitions: Record<string, readonly unknown[]> };MatrixSchema
interface MatrixSchema {
readonly subject?: ObjectSchema;
readonly objects?: Readonly<Record<ObjectKey, ObjectSchema>>;
}ObjectSchema
interface ObjectSchema {
readonly fields?: Readonly<Record<string, FieldType>>;
readonly relations?: Readonly<Record<string, ObjectKey>>;
}FieldType
type FieldType =
| BaseFieldType
| `${BaseFieldType}[]`
| `${BaseFieldType}?`
| `${BaseFieldType}[]?`;BaseFieldType
type BaseFieldType = 'string' | 'number' | 'boolean' | 'instant';Instant
type Instant = string | number | Date;Decision
interface Decision {
key: string;
allowed: boolean;
reason: Reason;
rule?: string;
missing?: readonly string[];
}| Field | What it carries |
|---|---|
key | the permission key the query asked about |
allowed | the gate |
reason | output only; Decisions says which reason carries which |
rule | output only; the id of the rule that decided |
missing | output only; the paths that did not read |
Reason
type Reason =
| 'allow'
| 'no-rule-matched'
| 'denied'
| 'unknown-action'
| 'unevaluable'
| 'unusable-clock'
| 'stale-contract';FieldDecision
interface FieldDecision {
allowed: boolean; // the action is allowed AND every decided field is
action: Decision; // the action alone
fields: Record<string, FieldState>;
reasons: Record<string, FieldReason>;
}| Field | What it carries |
|---|---|
allowed | the action is allowed and every decided field is |
action | the action-level Decision alone |
fields | every field name mapped to a FieldState; compare against 'allowed', since comparing against 'denied' treats an unevaluable field as writable |
reasons | every field name mapped to the FieldReason that settled it |
FieldState
type FieldState = 'allowed' | 'denied' | 'unevaluable';See Pitfalls.
FieldReason
type FieldReason =
| 'allow'
| 'not-listed'
| 'targets-failed'
| 'transition-failed'
| 'missing-field'
| 'proposed-required';EvaluationContext
interface EvaluationContext {
subject: Record<string, unknown>;
object?: Record<string, unknown>;
now?: Instant;
}The entry points assemble an EvaluationContext, and now settles to one epoch
per call.
Subject
type Subject = Record<string, unknown>;AnyObjects
type AnyObjects = Record<string, Record<string, unknown>>;AnyObjects is the open key map Access defaults to.
ObjectKey
type ObjectKey = string;Actions
Actions is the block parameter .for() hands you.
interface Actions<
Sub,
Obj,
Act extends string = string,
Vocab extends string = string,
> extends Ops<Sub, Obj> {
allow<A extends Vocab>(
action: A,
...conditions: Cond[]
): Actions<Sub, Obj, Act | A, Vocab>;
deny<A extends Vocab>(
action: A,
...conditions: Cond[]
): Actions<Sub, Obj, Act | A, Vocab>;
allowEach<A extends Vocab>(
actions: readonly A[],
...conditions: Cond[]
): Actions<Sub, Obj, Act | A, Vocab>;
denyEach<A extends Vocab>(
actions: readonly A[],
...conditions: Cond[]
): Actions<Sub, Obj, Act | A, Vocab>;
fields(rules: readonly string[] | FieldRules): Actions<Sub, Obj, Act, Vocab>;
visibility(visibility: Visibility): Actions<Sub, Obj, Act, Vocab>;
}| Member or type | What it does |
|---|---|
allow, deny | add one rule to the action named |
allowEach, denyEach | write one rule across several actions, one ordinary permission each |
fields, visibility | mark the action last declared, and raise AclConfigError after a batch |
Vocab | what this kind may declare |
Act | what this chain declared; only Act reaches the permission keys |
Action
type Action = (typeof CRUD_ACTIONS)[number];VocabularyOf
type VocabularyOf<Vocabs, K extends string> = K extends keyof Vocabs
? Vocabs[K] extends string
? Vocabs[K]
: Action
: Action;Visibility
type Visibility = NonNullable<Permission['visibility']>;Visibility is the marking p.visibility() writes and
serialize(access, 'reduced') reads.
PermissionKeys
type PermissionKeys<K extends string, Act extends string> = string extends Act
? string
: `${K}.${Act}`;Ops
Ops holds the condition helpers, bound to the subject and object types.
interface Ops<Sub, Obj> {
eq<A extends string, B>(
field: Valid<A, Sub, Obj>,
operand: Operand<B, Sub, Obj>,
): Cond;
ne<A extends string, B>(
field: Valid<A, Sub, Obj>,
operand: Operand<B, Sub, Obj>,
): Cond;
in<A extends string>(
field: Valid<A, Sub, Obj>,
values: readonly unknown[],
): Cond;
notIn<A extends string>(
field: Valid<A, Sub, Obj>,
values: readonly unknown[],
): Cond;
contains<A extends string>(field: Valid<A, Sub, Obj>, value: unknown): Cond;
before(field: 'now', instant: Instant): Cond;
after(field: 'now', instant: Instant): Cond;
and(...conditions: Cond[]): Cond;
or(...conditions: Cond[]): Cond;
readonly always: Cond;
}not-in is not an identifier, so the helper is notIn.
| Type | What it is |
|---|---|
Cond | an opaque condition tree, which build() flattens to a rule per or branch |
Operand | a comparand shaped like a namespaced path is checked as one, and any other string is a literal |
Paths | the subject. and object. paths one bound type offers |
Valid | a path those types carry; the failure branch is the parameter type, so the compiler names the bad path |
Cond
interface Cond {
readonly node: Node;
}Operand
type Operand<B, Sub, Obj> = B extends
`subject.${string}` | `object.${string}` | 'now'
? Valid<B & string, Sub, Obj>
: B;Paths
type Paths<T, P extends string> = {
[K in keyof T & string]: `${P}.${K}`;
}[keyof T & string];Valid
type Valid<S extends string, Sub, Obj> = S extends
Paths<Sub, 'subject'> | Paths<Obj, 'object'> | 'now'
? S
: `unknown path '${S}' on this resource`;AclConfigError
class AclConfigError extends Error {}AclConfigError is the base of every class below. Typed
authoring tables the same set by authoring
step.
| Error | Raised when |
|---|---|
InvalidMatrixError | the envelope carries no permissions array, or a version of the wrong type |
InvalidSchemaError | the schema’s own shape is wrong; where locates it inside the document |
InvalidPermissionError | a permission’s object, action, rules or fields is malformed |
InvalidRuleError | a rule is not an object, or its when is not an array |
InvalidConditionError | a condition’s shape, namespace, path depth or operator pairing is unusable |
KeyMismatchError | key is not exactly `${object}.${action}` |
DuplicatePermissionError | two permissions share a key |
UnknownFieldError | a condition names a field the schema does not declare |
FieldTypeMismatchError | contains runs against a non-array, or a literal is of the wrong type |
BangInAllowListError | a !name entry sits inside an allow-list |
DenyWithoutBaselineError | a !name entry has no * baseline to subtract from |
TargetsTransitionsConflictError | one field configures both targets and transitions |
UnknownObjectKeyError | a query on the open path names an unknown object kind; parseMatrix fails closed |
UnknownPermissionError | a query on the open path names an unknown key; parseMatrix answers unknown-action |
OriginCollisionError | two origins claim one key, which federatedPolicies raises while composing |
UnvetoablePermissionError | an overlay names a key the target does not list as vetoable |
MissingVetoSchemaError | a vetoable key’s object kind has no schema.objects entry |
UnpublishedVetoableError | serialize reduces away a vetoable key the contract would not ship |
MissingFreshnessBudgetError | a holder reports fetchedAt against a document stating no maxStale |
InvalidFreshnessError | fetchedAt is not an instant, or maxStale is not a finite, non-negative number of milliseconds |
InvalidMatrixError
class InvalidMatrixError extends AclConfigError {}InvalidSchemaError
class InvalidSchemaError extends AclConfigError {
readonly where: string; // schema.objects.question.fields.status
}InvalidPermissionError
class InvalidPermissionError extends AclConfigError {
readonly key: string;
readonly field: string;
}InvalidRuleError
class InvalidRuleError extends AclConfigError {
readonly key: string;
readonly field: string; // rules[2]
}InvalidConditionError
class InvalidConditionError extends AclConfigError {
readonly key: string;
readonly field: string;
readonly where: string;
}KeyMismatchError
class KeyMismatchError extends AclConfigError {
readonly key: string;
readonly object: string;
readonly action: string;
}DuplicatePermissionError
class DuplicatePermissionError extends AclConfigError {
readonly key: string;
}UnknownFieldError
class UnknownFieldError extends AclConfigError {
readonly key: string;
readonly field: string;
readonly where: string;
}FieldTypeMismatchError
class FieldTypeMismatchError extends AclConfigError {
readonly key: string;
readonly field: string;
readonly where: string;
}BangInAllowListError
class BangInAllowListError extends AclConfigError {}DenyWithoutBaselineError
class DenyWithoutBaselineError extends AclConfigError {}TargetsTransitionsConflictError
class TargetsTransitionsConflictError extends AclConfigError {}UnknownObjectKeyError
class UnknownObjectKeyError extends AclConfigError {
readonly key: ObjectKey;
}UnknownPermissionError
class UnknownPermissionError extends AclConfigError {
readonly key: string;
}OriginCollisionError
class OriginCollisionError extends AclConfigError {
readonly key: string;
readonly origins: readonly [string, string];
}UnvetoablePermissionError
class UnvetoablePermissionError extends AclConfigError {
readonly key: string;
}MissingVetoSchemaError
class MissingVetoSchemaError extends AclConfigError {
readonly key: string;
readonly object: ObjectKey;
}UnpublishedVetoableError
class UnpublishedVetoableError extends AclConfigError {
readonly key: string;
}MissingFreshnessBudgetError
class MissingFreshnessBudgetError extends AclConfigError {}InvalidFreshnessError
class InvalidFreshnessError extends AclConfigError {
readonly field: string;
}ActionNotAllowedError
class ActionNotAllowedError extends Error {
readonly key: string;
readonly reason: Reason;
}ActionNotAllowedError is the only error the library raises outside
construction. pickAllowedFields throws it when the action is refused.
What is not exported
@evanion/acl exports no decide, decideFields or evaluateCondition. Each
answers a fragment and trusts its caller for the rest.