Skip to Content
React AuthorizationAPI Reference

Unreleased changes

These pages document main, which has changes 0.1.2 does not. npm install @evanion/react-acl gives you 0.1.2, documented here. Values on these pages are produced by running the current source on every build.

API reference

@evanion/react-acl exports one provider, four hooks, a factory that binds them to a policy, and three interfaces. It re-exports seventeen @evanion/acl types, names unchanged, from Access down. The core’s values, such as hydratePolicy and policy, come from @evanion/acl.

PolicyProvider

PolicyProvider holds one evaluator, one subject and one instant for the tree under it, and renders its children and nothing else. A hook called outside a PolicyProvider throws, and the message names the provider and the package.

function PolicyProvider<Keys extends string = string>( props: PolicyProviderProps<Keys>, ): ReactElement;
Signature — a shape, not a call
  • The context value is memoised on the identity of access, subject and context, so all three need stable references.
  • With context.now omitted, each recompute reads new Date(), so pass the instant the server rendered with.
'use client'; import { } from 'react'; import type { } from 'react'; import { } from '@evanion/acl'; import type { Matrix } from '@evanion/acl'; import { } from '@evanion/react-acl'; type = { : string; : 'customer' | 'bookseller' | 'owner' }; export function ({ , , , , }: { : Matrix; : ; : string; : ; }) { // hydratePolicy validates, deep-clones and deep-freezes the document, so it // runs once per matrix rather than once per render. const = (() => (), []); const = (() => ({ }), []); return ( < ={} ={} ={}> {} </> ); }

PolicyProviderProps

interface PolicyProviderProps<Keys extends string = string> { access: Access<Subject, AnyObjects, Keys>; subject: Subject; context?: { now?: Instant }; children?: ReactNode; }
Signature — a shape, not a call
PropWhat it is
accessany Access, whether the core’s hydratePolicy returned it or policy<Subject, Objects>().build() did
subjectthe subject every hook under the provider decides against, held in the same useMemo
contextonly now is read off it, and an omitted context re-reads the wall clock whenever access, subject or context changes identity
childrenthe tree the provider renders

PolicyProvider erases a typed policy’s key and row types. The hooks below take key: string, so useCan('lsiting', 'edit') compiles and fails only when the component renders. The evaluator then throws UnknownObjectKeyError for an object kind the document never declared, and UnknownPermissionError for an action that kind never declared. A matrix hydrated with closed: true, hydratePolicy’s option for a matrix another service produced, answers unknown-action for both instead of throwing. createPolicyContext keeps those types.

useCan

useCan returns one decision for one action on one row.

function useCan( key: string, action: string, object?: Record<string, unknown>, ): Decision;
Signature — a shape, not a call
  • object is optional for the create case, where there is no instance yet. A rule that reads the row and is asked without one answers unevaluable.
  • useCan memoises on access, subject, key, action, object and now, so a component handed a rebuilt row evaluates again and no stale answer reaches it.
'use client'; import { } from '@evanion/react-acl'; type = { : string; : string; : 'draft' | 'published' }; export function ({ }: { : }) { const = ('listing', 'edit', ); if (!.) return null; return < ="button">Edit listing</>; }

useCanMany

useCanMany returns one decision per row, in the order the rows were given.

function useCanMany( key: string, action: string, objects: readonly Record<string, unknown>[], ): Decision[];
Signature — a shape, not a call

The returned array is parallel to the input, so a row’s answer sits at the row’s index. React forbids a hook inside a loop, so the component holding the list cannot call useCan once per row.

'use client'; import { } from '@evanion/react-acl'; type = { : string; : string; : 'draft' | 'published' }; export function ({ }: { : [] }) { const = ('listing', 'edit', ); return ( <> {.((, ) => ( < ={.}> {.} {[]?. ? ( < ="button">Edit</> ) : null} </> ))} </> ); }

useCanFields

useCanFields returns the field-level decision for one action on one axis.

function useCanFields( key: string, action: string, object: Record<string, unknown>, axis: 'read' | 'write', proposed?: Record<string, unknown>, ): FieldDecision;
Signature — a shape, not a call
  • axis is 'read' for which fields may be shown, and 'write' for which may be set.
  • proposed is the write the caller intends. On the write axis the map covers its keys too, including keys the row has never held.

The returned FieldDecision carries action, the action-level decision, and fields, which the engine fills whatever action said. Read action.allowed before you read a field, or a shopper who may not edit the listing at all reaches an input the map called 'allowed'. Compare a field against 'allowed'; comparing against 'denied' treats the unevaluable fields as writable, which is the mistake this API most makes easy.

'use client'; import { } from '@evanion/react-acl'; type = { : string; : string; : number; : string; : 'draft' | 'published'; }; export function ({ }: { : }) { const = ('listing', 'edit', , 'write'); // The field map carries a state for every field whatever the action decided, // so a form that reads it without this gate offers a write the engine // refused. if (!..) { return <>You cannot edit this listing.</>; } return ( <> <> Blurb < ="blurb" ={.} ={.['blurb'] !== 'allowed'} /> </> <> Price < ="price" ={.} ={.['price'] !== 'allowed'} /> </> </> ); }

The map enforces nothing in the browser. The server applies the same map to the posted form with pickAllowedFields.

useCapabilities

useCapabilities returns every action-level decision for the current subject, keyed by `${object}.${action}`.

function useCapabilities(): Record<string, Decision>;
Signature — a shape, not a call

useCapabilities passes no object, so a rule that reads one cannot be decided here. A menu or a navigation bar asks which actions the shopper may reach at all, and a question about one particular row goes to useCan. Listing everything a user may do sets out the no-object contract.

'use client'; import { } from '@evanion/react-acl'; export function () { const = (); return ( < ="Shop"> {.() .(([, ]) => .) .(([]) => ( < ={} ={`/${.('.', '/')}`}> {} </> ))} </> ); }

createPolicyContext

createPolicyContext binds one policy’s subject and object types to a provider and a set of hooks.

function createPolicyContext< Sub = Subject, R = AnyObjects, Keys extends string = string, >(access: Access<Sub, R, Keys>): PolicyContext<Sub, R, Keys>;
Signature — a shape, not a call

policy names a subject type once and binds a row type per object kind, and build() returns an Access carrying both. The hooks that come back check their key against the kinds the policy declared, and their row against the type bound to the kind, both read off the argument.

What you haveWhat createPolicyContext gives
one policy holding every object kindone call covering every kind, since you chain a .for() per kind
two separate policy documentsone context per call, so the two nest and each set of hooks reads its own provider
a component under the returned providerthe same decision from a useCan imported at the package root, which that provider also feeds
a key or a row the policy never declareda compile error, since the hooks check both against the Access handed in
'use client'; import { } from '@evanion/acl'; import type { } from '@evanion/acl'; import { } from '@evanion/react-acl'; type = { : string; : 'customer' | 'bookseller' | 'owner' }; type = { : string; : string; : 'draft' | 'published'; : string; }; type = { : }; type = { : | 'edit' }; export const = <, , >() .('listing', () => .('read', .) .('edit', .('object.sellerId', 'subject.id')) .('edit', .('object.status', 'published')) .(['blurb']), ) .(); // Shopper and the object map are named once, above. Nothing here repeats them. export const { : , } = (); export function ({ }: { : }) { // 'listing' and 'edit' are checked against the policy. A typo in either is a // compile error here. The untyped useCan compiles it, and the evaluator // throws UnknownObjectKeyError or UnknownPermissionError at render. const = ('listing', 'edit', ); if (!.) return null; return < ="button">Edit listing</>; }

The policy module holds only the rules the browser already receives as the matrix, so a client module may import it. The server still enforces with its own access.can.

PolicyContext

PolicyContext is what createPolicyContext returns: the same provider and four hooks, at the types the policy carries.

interface PolicyContext<Sub, R, Keys extends string = string> { PolicyProvider(props: BoundPolicyProviderProps<Sub, R, Keys>): ReactElement; useCan<K extends keyof R & string>( key: K, action: ActionOf<Keys, K>, object?: Partial<R[K]>, ): Decision; useCanMany<K extends keyof R & string>( key: K, action: ActionOf<Keys, K>, objects: readonly Partial<R[K]>[], ): Decision[]; useCanFields<K extends keyof R & string>( key: K, action: ActionOf<Keys, K>, object: Partial<R[K]>, axis: 'read' | 'write', proposed?: Partial<R[K]>, ): FieldDecision; useCapabilities(): Record<Keys, Decision>; }
Signature — a shape, not a call
Type detailWhat it means
Partial<R[K]> on every row parametera list row carrying two of five fields is the unevaluable case, so the compiler lets it through
a row type declared extends Record<string, unknown>accepts every key and gives the check nothing to hold, so declare the row’s fields and nothing more
the keys useCapabilities answers by`${object}.${action}`, listing.edit, where R holds the object kinds
a policy whose blocks declare their actionsnarrows those keys to the ones it declares, so listing.edti is a compile error; a policy leaving one block’s actions open stays keyed by string

BoundPolicyProviderProps

interface BoundPolicyProviderProps<Sub, R, Keys extends string = string> { access?: Access<Sub, R, Keys>; subject: Sub; context?: { now?: Instant }; children?: ReactNode; }
Signature — a shape, not a call

access is optional here and required on PolicyProvider, because createPolicyContext was already handed a document. Pass one to decide against a different document of the same shape: a per-tenant matrix, or the copy the browser rebuilt with hydratePolicy, which carries no types of its own.

subject takes the type the policy was authored against, so a value missing a field that type declares is a compile error at the mount.

Access

interface

The evaluator over one frozen document.

Full documentation

Sub is the subject the matrix was written against, R is the key -> object-type map, and Keys is the permission keys the document declares. All three default to the open forms a JSON document carries, and at the defaults keyof R & string is string, Partial<R[K]> is a bag of unknowns and capabilities() answers under any key, so a foreign document accepts any key, any action and any object. A typed author names them through the builder, and every query checks its key, its action and its object against them.

PolicyProvider takes one as access, and createPolicyContext reads its three type parameters off the one it is handed.

interface < = , = , extends string = string> { readonly : <Matrix>; readonly : string | number | undefined; readonly : MatrixSchema | undefined; < extends keyof & string>(: , : , : <, >, ?: <[]>, ?: ): Decision; < extends keyof & string>(: , : , : <, >, : readonly <[]>[], ?: ): Decision[]; < extends keyof & string>(: readonly [], : , : <, >, ?: <[]>, ?: ): Decision[]; < extends keyof & string>(: , : , : <, >, : <[]>, : 'read' | 'write', ?: <[]>, ?: ): FieldDecision; < extends keyof & string>(: , : , : <, >, ?: <[]>, ?: ): Explanation; (: , ?: ): <, Decision>; (: , ?: { ?: ; }): <, >; < extends keyof & string>(: ): <, [], <, >>; < extends keyof & string>(: , : <, >): boolean; }

Subject

type

A subject for a single call: the actor, a plain object of attributes.

PolicyProvider takes one as subject.

export type = <string, unknown>;

Instant

type

An instant, for a now condition or a context clock. A string is parsed as ISO 8601, a number as epoch milliseconds. The string and number forms survive a JSON round trip, so an SSR payload carries one unchanged.

Full documentation

A condition boundary must parse: validateMatrix refuses a before/after value that does not. A context clock that does not parse is not an error either — it refuses. Every permission whose decision reads it lands on unusable-clock.

context.now takes one. The string form holds the hooks’ memos across renders, where a Date is a new object every render.

export type = string | number | Date;

EvaluationContext

interface

The namespaced evaluation context.

interface EvaluationContext { : <string, unknown>; ?: <string, unknown>; ?: ; }

Decision

interface

One action-level decision.

useCan returns one, and useCanMany returns one per row. A component file imports Decision from @evanion/react-acl or from @evanion/acl.

interface Decision { : string; : boolean; : ; ?: string; ?: readonly string[]; }

Reason

type

Why a decision landed where it did. Output only.

Full documentation

unevaluable and unusable-clock both answer allowed: false and both mean “the engine could not reach an answer”, so neither is a refusal. They are two reasons because the caller’s move differs: unevaluable names paths in missing and one refetch settles it, unusable-clock says the instant the call supplied does not parse and only a different argument settles it.

stale-contract is the third reason with a caller’s move attached, and the move is a fetch of the document itself. The holder is past the freshness budget Matrix.maxStale states, so the document carries no claim about the present and every key answers this, including a key it does not hold.

export type = 'allow' | 'no-rule-matched' | 'denied' | 'unknown-action' | 'unevaluable' | 'unusable-clock' | 'stale-contract';

FieldDecision

interface

A decision over every field of an action.

Full documentation

The field maps are computed whatever action says, so a caller blocked at the action level still learns which fields would be editable once it is unblocked.

useCanFields returns one.

interface FieldDecision { : boolean; : Decision; : <string, >; : <string, >; }

FieldState

type

The result of a field-level decision: allowed, denied, or not evaluable.

Full documentation

unevaluable is a write-axis state. Read is projection only — the name allow-list is the whole of it, and a name either is on the list or is not — so a read decision’s map holds allowed and denied and nothing else. The type stays three-valued because axis is a runtime argument: narrowing the read path would mean a type parameter on FieldDecision and an overload pair on every entry point, and it would still widen back wherever a caller passes an axis it computed.

export type = 'allowed' | 'denied' | 'unevaluable';

FieldReason

type

Why a field decision landed where it did. Output only.

Full documentation

Every member is emitted, so an exhaustive switch over this union has no unreachable arm. A refusal is not-listed, targets-failed or transition-failed; the reason names which rule refused, and FieldState carries the plain denied.

not-traversable marks a key the field rules need to look into and the engine cannot: a value that is not a plain object or an array (a Date, a Map, a class instance, a primitive, null), an array with a hole or a non-object element, an object that holds a getter or an empty-string key, a repeat in a cycle, or, on the write axis, an array or a replaced subtree. The key is denied whole and none of its children are decided.

export type = 'allow' | 'not-listed' | 'targets-failed' | 'transition-failed' | 'missing-field' | 'proposed-required' | 'not-traversable';

Matrix

interface

The canonical matrix document: an envelope over a flat list of permissions.

Full documentation

There is no bare-array form. version and schema belong to the document, so a foreign producer emitting JSON states both, and one access.matrix crosses an SSR boundary without a wrapper assembled at the call site.

version is what the fetch-and-revalidate contract compares with !==, so a string carries a content digest or a composite (orders@7+veto@41) where a number cannot.

The server sends one to the browser, and hydratePolicy turns it into the Access a provider takes.

interface Matrix { readonly ?: string | number; readonly ?: MatrixSchema; readonly ?: number; readonly : readonly Permission[]; }

Permission

interface

One permission: allows and/or denies, plus optional field rules.

interface Permission { : string; : ; : string; ?: readonly Rule[]; ?: readonly Rule[]; ?: FieldRules; ?: 'public' | 'internal'; }

Condition

type

A condition over the namespaced context.

Full documentation

now is a time window. Any other field is subject.* or object.*, compared against a literal value or — for eq/ne — against another path.

export type = { : 'now'; : 'before' | 'after'; : ; } | { : string; : 'eq' | 'ne' | 'in' | 'not-in' | 'contains'; ?: string; ?: unknown; };

MatrixSchema

interface

The shapes a matrix’s conditions are checked against.

Full documentation

Optional for a producer and binding when present. Granularity is per object kind: a kind objects does not declare is unchecked, and subject.* paths are unchecked unless subject is declared.

What a present schema checks, exactly:

  • a condition naming a field the declared kind does not declare is a construction error (UnknownFieldError);
  • a condition whose operator does not fit the declared type is a construction error (FieldTypeMismatchError).

What it does not check: the field names in FieldRules (the fields allow-list, and the targets/transitions keys), and whether a permission can decide unevaluable for a complete instance. Both are unchecked whether or not a schema is present.

interface MatrixSchema { readonly ?: ObjectSchema; readonly ?: <<, ObjectSchema>>; }

ObjectSchema

interface

One object kind’s declared shape.

Full documentation

relations names the kinds this one points at, one hop, without nesting. A condition reads one field of one scope, so a relation is not a value a condition compares; it is declared for the consumers that resolve it.

interface ObjectSchema { readonly ?: <<string, >>; readonly ?: <<string, >>; }

FieldType

type

One field’s declared type.

Full documentation

A flat string, so the whole schema is JSON a producer in any language emits by reflection. [] is an array of the base type. A trailing ? marks a field that may be absent from a complete instance; nullability is not an optionality axis, so a declared field that is present and null is present.

export type = | `${}[]` | `${}?` | `${}[]?`;

AnyObjects

type

The default object map: any key, any bag. What a document whose shapes nothing declared at the type level answers for.

PolicyProviderProps and createPolicyContext default their object map to it.

export type = <string, <string, unknown>>;

ActionOf

type

The action half of the keys Keys holds for one object kind.

Full documentation

A document whose keys are open answers string, which is the action parameter every query took before a builder declared a vocabulary. A document that declares 'comment.update' | 'comment.read' answers 'update' | 'read' for 'comment', so the action a query names is checked the same way its key is.

An empty Keys is a document that declares nothing, which a policy with no blocks produces, and it answers string for the same reason an open one does.

A kind the union holds no key for answers string as well. The builder cannot reach that branch, since every block contributes a kind and its keys together, and a caller naming R and Keys by hand over an adopted document can: parseMatrix<Sub, { device: Device }, 'telemetry.read'> states a row type for a kind it named no key for. string leaves that kind answerable and never would make every query on it uncallable, which is a refusal the document never asked for.

PolicyContext types every hook’s action parameter with it.

export type < extends string, extends string> = [ ] extends [ never ] ? string : string extends ? string : [ <, `${}.${string}`> ] extends [ never ] ? string : extends `${}.${infer }` ? : never;

Where to go next

Last updated on