React
permdock/react gives client components a snapshot-backed PermDock through PermDockProvider, usePermDock, usePermission, usePermissions, useFilter, useTenant, useMemberships, useRoles, useAssignableRoles, useAssignablePermissions, useApproval, useSubject and Protected, with a batched decision endpoint for closure grants.
Purpose
permdock/react answers permission questions in the browser without duplicating rules on the client. The server serialises permdock.snapshot() (roles and grants, portable conditions included) and the provider evaluates it locally with the same core evaluator that runs on the server. Ownership checks such as usePermission(permissions.post.update, post) resolve synchronously from the snapshot; only grants that use closures or async context go to the decision endpoint. Nothing in this entry imports a policy, a subject resolver or a server module.
This replaces two permix patterns that failed in practice: hydration that collapsed function rules to booleans and forced a second client setup(), and a generic factory (createPermix()) whose only job was to carry types. Here the permission reference carries the types, so hooks are direct exports, like next-intl's useTranslations.
API
import {
PermDockProvider, usePermDock, usePermission, usePermissions, useFilter,
useTenant, useMemberships, useRoles, useAssignableRoles, useAssignablePermissions, useApproval, useSubject, Protected,
} from 'permdock/react'
import { describe } from 'permdock' // pure helper, client-safe
import { permissions } from '@/permissions' // definition only: client-safe
<PermDockProvider snapshot={snapshot} endpoint="/api/permdock" tenant={activeOrgId}>
// or, from a Server Component that must not block: snapshotPromise={loadSnapshot(org)}
// (add suspend to make the readers suspend until it resolves)
<App />
</PermDockProvider>
const permdock = usePermDock()
permdock.can(permissions.post.create) // boolean from the snapshot
permdock.decide(permissions.post.update, post) // Decision, same shape as on the server
permdock.status // 'ready' | 'pending' | 'stale' | 'server-only'
permdock.invalidate(permissions.post) // drop cached endpoint answers under a namespace
permdock.tenant('o_globex').can(permissions.post.read) // derived instance, local when the snapshot holds that tenant
const { allowed, status, decision } = usePermission(permissions.post.update, post)
const actions = usePermissions([permissions.post.update, permissions.post.publish, permissions.post.delete], post)
const editable = useFilter(permissions.post.update, posts)
const { tenant, tenants, switchTo } = useTenant()
const memberships = useMemberships() // Membership[] for org lists and role chips
const roles = useRoles({ tenant }) // roles held in the active tenant, custom roles resolved
const assignable = useAssignableRoles() // what this user may hand out here
const ceiling = useAssignablePermissions() // permissions a custom role may get from this user
const approval = useApproval(decision) // { state, request, token } for approval-required
const { principal, actor, simulated } = useSubject()
<Protected permission={permissions.post.update} data={post} pending={<Skeleton />} fallback={<Locked />}>
<EditButton />
</Protected>
<Button disabled={!allowed} title={allowed ? undefined : describe(decision).detail}>Publish</Button>| Export | Role |
|---|---|
PermDockProvider | Takes snapshot (from the server) or snapshotPromise (an unawaited Promise<Snapshot> from a Server Component), an optional endpoint (false never fetches) and an optional initial tenant. Holds one snapshot-backed PermDock in an external store and exposes it through context. With snapshotPromise, hooks never suspend: until it resolves they deny with status: 'pending', then re-render through the store, and a rejected promise fails closed with status: 'server-only'. A new promise replaces the snapshot, and hooks keep answering from the current one, marked pending, until it lands. A promise React has already settled (a prefetched RSC payload on a client navigation) hydrates before paint. Pass suspend to suspend readers through use() instead, so a server render waits for the snapshot and a rejection reaches the nearest error boundary. A resolved JWS goes through verifier like snapshot. Accepts fetch and headers options for the endpoint call. |
usePermDock | Returns the snapshot-backed instance: can, decide, filter, actions, status, invalidate, refresh, plus tenant(), team(), memberships(), tenants(), heldRoles(), assignableRoles(), assignablePermissions(). Subscribes with useSyncExternalStore, so every consumer re-renders when the snapshot or an endpoint answer changes. |
usePermission | Returns allowed, status and the full decision for one permission and, for instance actions, one resource. Refetches when the resource id changes, not only when the reference changes. |
usePermissions | Several references at once against one resource; one entry per reference from a single snapshot pass. The menu and toolbar hook. |
useFilter | filter against the snapshot, memoised on row identities; the rows the subject may act on. |
useTenant | { tenant, tenants, switchTo, status }. switchTo is local when the snapshot was issued with tenants: 'all', otherwise it calls refresh({ tenant }) and the server answers from the subject's memberships (tenancy). |
useMemberships, useRoles, useAssignableRoles, useAssignablePermissions | Read-only introspection from the snapshot: the membership list, Role[] held in a tenant (custom roles resolved to their name and meta.title), the assignable roles the subject may hand out, and Permission[], the custom-role ceiling the subject may hand out (useAssignablePermissions({ tenant? }), from the snapshot's assignable entry). Display and assignment data, never a substitute for a permission check (custom roles). |
useApproval | Drives the approval-required outcome: request() posts to approvalsHandler, state is not-needed for any other outcome and moves from required through pending to approved, rejected or expired, polling only while a component reads it, and token is ready for the PermDock-Approval retry (approvals). |
useSubject | The snapshot's subject summary: principal (id, kind, roles, tenant), actor, delegation, expiresAt, simulated. |
Protected | Component form of usePermission. pending renders while the answer or a snapshotPromise is in flight, and is also the Suspense fallback with suspend; fallback renders when denied or approval-required. Accepts tenant to render against a derived instance, which renders pending while the provider is. Children may be a render function receiving the granted Decision with a narrowed subject. |
describe(decision) is exported from permdock (core, framework-free) and turns a Decision into { kind, title, detail, alternatives } for tooltips and disabled states; the UI concept page covers the patterns.
status values: ready (answered from the snapshot or a cached endpoint answer), pending (endpoint request in flight), stale (a previous answer is shown while a revalidation runs), server-only (the grant depends on a closure or async context and no endpoint is configured; allowed is false and the denial reason is server-only). With endpoint={false} the provider never fetches and logs one console.info naming the first permission that turned into a server-only denial, so a snapshot-only app sees which check needs the server.
Request lifecycle
- The server creates a request-scoped
PermDock, callssnapshot()(optionally scoped withinclude) and passes the JSON toPermDockProvider. In Next.js this is done by thePermDockProviderfrompermdock/next; in Vite apps the snapshot arrives from your own session endpoint. PermDockProvidervalidates the snapshot against the snapshot wire schema and builds a clientPermDock. Portable grants evaluate locally;memberOfscope tests evaluate against the memberships in the snapshot.usePermission(reference, data)computes a cache key fromreference.keyplus the row id (the resource'sidfield). A row without an id is keyed by a digest of its content, so an in-place edit gets a new answer. If the matching grant is portable, the answer is synchronous andstatusisready. Menus and toolbars useusePermissions([...references], data)over the snapshot rather than the AuthZENsearch/actioncall; the endpoint is used only for non-portable grants.- If the grant is marked
portable: falsein the snapshot (closure or async context), the hook enqueues a request toendpoint. Requests issued within the same tick are batched into one AuthZENevaluationscall and deduplicated by cache key. - The endpoint (
permdockHandlerin Next.js, or any server adapter exposing the same route) rebuilds the subject from the session, validates the posted resource data at the boundary, evaluates, and returns oneDecisionper item. - Answers are cached per key.
invalidate(permissions.post)drops every key under that namespace;refresh()refetches the snapshot fromsnapshotUrl, or fromendpointwhensnapshotUrlis unset;refresh({ tenant })asks for a snapshot with another active tenant and the server answersno-membership(an empty snapshot for that tenant) when the subject does not hold it. - A snapshot with
simulated: true(a "view as" preview) renders like any other;useSubject().simulatedlets the app show a preview bar, and the endpoint refusesevaluationsand approval requests carrying a simulated snapshot.
During SSR the provider renders from the snapshot alone, so server and first client render agree. Endpoint answers are requested only after mount.
The hooks behave the same when the React Compiler compiles them. Expo compiles workspace packages with the app, and the package test suite runs the React and React Native hook tests a second time through babel-plugin-react-compiler.
What it validates
The shared adapter contract applies, with these client-side specifics:
- A malformed
snapshotyields a provider inserver-onlymode, never a crash and never an allow. - A
tenantprop orswitchTotarget that the snapshot'stenantslist does not contain leaves the instance inno-membershipfor that tenant: every tenant-scoped check isdenied, nothing is fetched for it.switchTochanges only the PermDock tenant; calling the auth provider's own switch (ClerksetActive, Better AuthsetActiveOrganization) is the application's job. - Nothing about
dataon the client. Client-side answers are UI hints. The decision endpoint validates the posted data at the boundary before evaluating, and every mutation is re-checked by the server adapter that performs it. - The decision endpoint is authenticated by the app's real session: the provider calls it with
credentials: 'include', and thefetchandheadersoptions adjust the call.headerscompare by value andfetchandverifieralways call the latest prop, so inline values keep the store; a changed header value rebuilds it. It never carries a shared public secret. Each endpoint request is aborted after 10 seconds and then counts as a failed call, so a check stays fail-closed. - That
usePermissionis called with the right arity: collection actions take nodata, instance actions require it. This is a type error, not a runtime check.
How denials surface
usePermissionreturnsallowed: falseand thedecision(deniedwithdenialsandalternatives, orapproval-requiredwithreason). The hook never throws and never suspends.Protectedrendersfallback. Whenfallbackis a function it receives theDecision, so a "request access" button can be shown forapproval-required.Protectedand its function-child form are the only components inpermdock/react; there is no CASL-styleCan. Next.js apps also get thePermissionBoundaryerror boundary frompermdock/next/client(Next.js).- Endpoint failures (network, 401 from an expired session) leave the hook in
pendingand thenserver-only;allowedstaysfalse.
Example app
apps/examples/react-vite: src/permissions.ts, a snapshot from snapshotFor(policy, memberUser), PermDockProvider, Protected guards and a usePermission delete button. Vite serves http://127.0.0.1:3480/. The page shows edit, locked for publish and ask to delete, because deleting needs approval. apps/examples/react-router loads the same snapshot in a React Router root loader with getSnapshot(request) and snapshotHeaders (snapshot loaders) on http://127.0.0.1:3484/.
Related standards
- AuthZEN: the decision endpoint uses the
evaluationsrequest and response shapes. - Problem Details: endpoint denial bodies.
- Standard Schema: boundary validation of posted resource data.
- Concepts: snapshots, decisions, wire formats, tenancy, UI.
Last updated on
Adapters
One core, one Fetch-first server kernel, and thin typed adapters for UI frameworks, HTTP servers, RPC layers, agent runtimes, the decision plane, databases and auth providers.
React Native
permdock/react-native adds a persisted snapshot so Expo Router Stack.Protected and Tabs.Protected guards answer synchronously on the first frame and revalidate in the background.