React
permdock/react gives client components a snapshot-backed PermDock through PermDockProvider, usePermDock, usePermission, usePermissions, useFilter, useTenant, useMemberships, useRoles, useAssignableRoles, useApproval, useSubject and Protected, with a batched decision endpoint for closure grants.
Status: planned Phase: 1
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, 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}>
<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 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), an optional endpoint and an optional initial tenant. Holds one snapshot-backed PermDock in an external store and exposes it through context. Accepts fetch and headers options for the endpoint call. |
usePermDock | Returns the snapshot-backed instance: can, decide, filter, status, invalidate, refresh, plus tenant(), team(), memberships(), tenants(), roles(), assignable(). 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 | Read-only introspection from the snapshot: the membership list, roles held in a tenant (custom roles resolved to their name and meta.title), and the intersection of assignable roles with what the subject holds. Display and assignment data, never a substitute for a permission check. |
useApproval | Drives the approval-required outcome: request() posts to approvalsHandler, state moves through pending, approved, denied, expired, 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 is in flight, fallback when denied or approval-required. Accepts tenant to render against a derived instance. 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).
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 v2 schema (v1 still accepted) 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 resource id declared by the resource'sidfield. If the matching grant is portable, the answer is synchronous andstatusisready.- 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 itself;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.
What it validates
- The incoming
snapshotagainst the snapshot v2 wire schema. A malformed snapshot yields 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. - Nothing about
dataon the client. Client-side answers are UI hints. The decision endpoint validates the posted data against the resource schema (validate: 'boundary') before evaluating, and every mutation is re-checked by the server adapter that performs it. - 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.- Endpoint failures (network, 401 from an expired session) leave the hook in
pendingand thenserver-only;allowedstaysfalse. The decision endpoint itself answers denials as403 application/problem+json, described in Problem Details.
Example app
apps/examples/react-vite: a Vite app with a tiny Hono backend that serves the snapshot and mounts the decision endpoint from permdock/hono. It shows a portable ownership check answered offline, a closure grant answered through the batched endpoint, invalidate after a mutation, a tenant switcher built on useTenant with two organisations, a toolbar from usePermissions with describe(decision) tooltips, a request-access button on useApproval, and a Vitest browser-mode test asserting that the first render never flashes a denied state for a portable grant.
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.
Open questions
- Whether
Protectedshould have a sibling inline component in the style of CASL'sCanrender-prop, or whether the function-child form is enough (listed in the roadmap open questions). - Whether large policies ship full grants or per-permission booleans in the snapshot; the hook API does not change either way, but
statussemantics forserver-onlymight. - Cache key when a resource declares no
idfield: fall back to a stable hash of the validated data, or requireidfor endpoint-backed checks. - Decision-endpoint authentication: it must reuse the app's real session, never a shared public secret. How the React provider forwards credentials (
fetchoption withcredentials: 'include'by default) needs a decision. - Whether the provider should expose the AuthZEN
search/actioncall for menus: resolved asusePermissions([...references], data)over the snapshot, with the endpoint used only for non-portable grants; nousePermittedActions. - Whether
useTenant().switchToshould also call the provider's own switch (ClerksetActive, Better AuthsetActiveOrganization) through anonSwitchcallback onPermDockProvider, or leave that to the app.
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.