PermDock
Adapters

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>
ExportRole
PermDockProviderTakes 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.
usePermDockReturns 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.
usePermissionReturns 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.
usePermissionsSeveral references at once against one resource; one entry per reference from a single snapshot pass. The menu and toolbar hook.
useFilterfilter 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, useAssignablePermissionsRead-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).
useApprovalDrives 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).
useSubjectThe snapshot's subject summary: principal (id, kind, roles, tenant), actor, delegation, expiresAt, simulated.
ProtectedComponent 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

  1. The server creates a request-scoped PermDock, calls snapshot() (optionally scoped with include) and passes the JSON to PermDockProvider. In Next.js this is done by the PermDockProvider from permdock/next; in Vite apps the snapshot arrives from your own session endpoint.
  2. PermDockProvider validates the snapshot against the snapshot wire schema and builds a client PermDock. Portable grants evaluate locally; memberOf scope tests evaluate against the memberships in the snapshot.
  3. usePermission(reference, data) computes a cache key from reference.key plus the row id (the resource's id field). 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 and status is ready. Menus and toolbars use usePermissions([...references], data) over the snapshot rather than the AuthZEN search/action call; the endpoint is used only for non-portable grants.
  4. If the grant is marked portable: false in the snapshot (closure or async context), the hook enqueues a request to endpoint. Requests issued within the same tick are batched into one AuthZEN evaluations call and deduplicated by cache key.
  5. The endpoint (permdockHandler in 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 one Decision per item.
  6. Answers are cached per key. invalidate(permissions.post) drops every key under that namespace; refresh() refetches the snapshot from snapshotUrl, or from endpoint when snapshotUrl is unset; refresh({ tenant }) asks for a snapshot with another active tenant and the server answers no-membership (an empty snapshot for that tenant) when the subject does not hold it.
  7. A snapshot with simulated: true (a "view as" preview) renders like any other; useSubject().simulated lets the app show a preview bar, and the endpoint refuses evaluations and 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 snapshot yields a provider in server-only mode, never a crash and never an allow.
  • A tenant prop or switchTo target that the snapshot's tenants list does not contain leaves the instance in no-membership for that tenant: every tenant-scoped check is denied, nothing is fetched for it. switchTo changes only the PermDock tenant; calling the auth provider's own switch (Clerk setActive, Better Auth setActiveOrganization) is the application's job.
  • Nothing about data on 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 the fetch and headers options adjust the call. headers compare by value and fetch and verifier always 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 usePermission is called with the right arity: collection actions take no data, instance actions require it. This is a type error, not a runtime check.

How denials surface

  • usePermission returns allowed: false and the decision (denied with denials and alternatives, or approval-required with reason). The hook never throws and never suspends.
  • Protected renders fallback. When fallback is a function it receives the Decision, so a "request access" button can be shown for approval-required. Protected and its function-child form are the only components in permdock/react; there is no CASL-style Can. Next.js apps also get the PermissionBoundary error boundary from permdock/next/client (Next.js).
  • Endpoint failures (network, 401 from an expired session) leave the hook in pending and then server-only; allowed stays false.

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/.

Last updated on

On this page