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;- The context value is memoised on the identity of
access,subjectandcontext, so all three need stable references. - With
context.nowomitted, each recompute readsnew 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;
}| 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 |
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;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;axisis'read'for which fields may be shown, and'write'for which may be set.proposedis 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>;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>;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 every kind, 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';
: 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>;
}| 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.
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;
}
A subject for a single call: the actor, a plain object of attributes.
PolicyProvider takes one as subject.
export type = <string, unknown>;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;The namespaced evaluation context.
interface EvaluationContext {
: <string, unknown>;
?: <string, unknown>;
?: ;
}
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[];
}
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';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, >;
}
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';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';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[];
}
One permission: allows and/or denies, plus optional field rules.
interface Permission {
: string;
: ;
: string;
?: readonly Rule[];
?: readonly Rule[];
?: FieldRules;
?: 'public' | 'internal';
}
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;
};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>>;
}
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, >>;
}
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 = | `${}[]` | `${}?` | `${}[]?`;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>>;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
- Which side decides: what react-acl’s answers are worth, and what has to happen on the other side of the render
- Core API reference: every type react-acl re-exports, and the functions a server calls
- The Decision object every check returns: the
Decisionshape, its reasons, and its explanation fields