API reference
Not on npm yet
npm install @evanion/react-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.
Every region on this page is a file under libs/react-acl/examples/ and
runs under nx test @evanion/react-acl.
@evanion/react-acl exports one provider, four hooks, a factory that binds them
to a policy, and three interfaces. Decision, FieldDecision, Access,
Subject, Matrix and Instant keep their @evanion/acl names here, and
the core’s reference documents them.
PolicyProvider
PolicyProvider holds one evaluator, one subject and one clock 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;- The context value is memoised on the identity of
access,subjectandcontext, so all three need stable references. context.nowdefaults tonew Date()when it is left off.- The memo recomputes whenever any of the three changes identity, so the clock moves under the tree. Pass the value 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;
}| Prop | What it is |
|---|---|
access | any Access, whether the core’s hydratePolicy returned it or policy<Subject, Objects>().build() did |
subject | the subject every hook under the provider decides against, held in the same useMemo |
context | only now is read off it, and an omitted context re-reads the wall clock whenever access, subject or context changes identity |
children | the tree the provider renders |
The types a typed policy carries stop here. The hooks below take key: string,
so a key this provider’s document never declared reaches them, and the engine
throws UnknownObjectKeyError at render unless the matrix was hydrated with
closed: true. 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;objectis optional for the create case, where there is no instance yet. A rule that reads the row and is asked without one answersunevaluable.useCanmemoises onaccess,subject,key,action,objectandnow, 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[];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;| Name | What it carries |
|---|---|
axis | 'read' for which fields may be shown, 'write' for which may be set |
proposed | the write the caller intends; on the write axis the map covers its keys too, including keys the row has never held |
action | the action-level decision the field maps hang off; the engine fills fields whatever that decision said |
fields | every field name mapped to 'allowed', 'denied' or 'unevaluable' |
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'}
/>
</>
</>
);
}useCapabilities
useCapabilities returns every action-level decision for the current subject,
keyed by `${object}.${action}`.
function useCapabilities(): Record<string, Decision>;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.
What can they do at all? 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>;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 have | What createPolicyContext gives |
|---|---|
| one policy holding every object kind | one call covering the listing and the stock report both, since you chain a .for() per kind |
| two separate policy documents | one context per call, so the two nest and each set of hooks reads its own provider |
| a component under the returned provider | the same decision from a useCan imported at the package root, which that provider also feeds |
| a key or a row the policy never declared | a 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' };
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' is a key of the policy. A typo is a compile error here, where the
// untyped useCan would pass it through and answer unknown-action at runtime.
const = ('listing', 'edit', );
if (!.) return null;
return < ="button">Edit listing</>;
}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>;
}| Type detail | What it means |
|---|---|
Partial<R[K]> on every row parameter | a 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 actions | narrows 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;
}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.
Where to go next
- Which side decides — what these answers are worth, and what has to happen on the other side of the render
- Core API reference — every type above, and the functions a server calls
- Decisions — the
Decisionshape, its reasons, and its explanation fields