Client overview
Server-driven permission maps for React, Vue, and vanilla JavaScript, with synchronous fail-closed lookups in the browser
duck-iam ships three browser clients that all consume the same artefact: a flat permission map produced on the server by engine.permissions(). This page covers the shared model - how the map is produced, sent, read, and refreshed - and points at the per-framework pages for the exact exports.
can() decides what to render. The request the button fires must be authorized again on the server, by the engine, against the live policy set. See server integrations.How it works
The engine never runs in the browser in the recommended setup. The server resolves the subject, evaluates policies, and flattens the result into a IamClient.PartialPermissionMap - a plain Record of string keys to boolean values. That object is serialised as JSON, handed to the client, and every client-side check becomes a single object lookup.
Steps 3 to 6 are the only place policy logic runs. Step 9 is a property read against the object produced in step 6, so a check costs one hash lookup. Step 12 is the only way a client learns that a decision changed - see staleness and refresh.
Every client exposes the same two primitives:
can(action, resource, resourceId?, scope?)returnstrueonly when the map holds that exact key with the valuetrue.cannot(action, resource, resourceId?, scope?)is the strict negation ofcan.
Fail-closed lookups
All three clients resolve a key through iamPermissionGranted, which requires the map's own key to be the literal boolean true. Nothing validates the map - it is JSON.parse output typed as PartialPermissionMap, and the type is erased at runtime - so a truthiness test would read {"read:post": "false"} as a grant. A key that is absent, a map that is empty, a map that never arrived, a key present but false, and a key present but non-boolean all deny. There is no throw, no undefined, and no "unknown" state on the check itself.
can(), allowedActions() and hasAnyOn() agree on that rule. A stale or truncated map therefore hides UI rather than exposing it - and a typo in an action name silently hides a button, so keep the action, resource, and scope unions typed. See type-safe checks.
Pick a client
| Framework | Page | Subpath | Entry export |
|---|---|---|---|
| React | React client | @gentleduck/iam/client/react | createIamAccessControl(React) |
| Vue 3 | Vue client | @gentleduck/iam/client/vue | createIamVueAccess(vue) |
| Anything else | Vanilla JS client | @gentleduck/iam/client/vanilla | IamAccessClient |
All three are factories or classes over the same map, not three implementations of a check.
The L node is shared code: every surface calls iamBuildPermissionKey from @gentleduck/iam and reads the map through iamPermissionGranted. All three also expose allowedActions and hasAnyOn, which walk the map through iamParsePermissionKey rather than splitting keys on :.
createIamAccessControl takes your React module and createIamVueAccess takes your Vue reactivity helpers. duck-iam never imports React or Vue itself, so there is no second copy of either in your bundle and no peer-version conflict. The vanilla client needs nothing beyond fetch for its fromServer helper.Producing the map on the server
Three call sites produce the same object:
// 1. Directly on the engine.
const permissions = await engine.permissions('user-1', [
{ action: 'create', resource: 'post' },
{ action: 'delete', resource: 'post', resourceId: 'post-42' },
{ action: 'manage', resource: 'billing', scope: 'org-1' },
])
// 2. Framework-agnostic helper, adds a shared environment.
import { generateIamPermissionMap, iamExtractEnvironment } from '@gentleduck/iam/server/generic'
const permissions = await generateIamPermissionMap(engine, userId, checks, iamExtractEnvironment(req))
// 3. Next.js helper, for a layout or Server Component.
import { getIamPermissions } from '@gentleduck/iam/server/next'
const permissions = await getIamPermissions(engine, userId, checks)
engine.permissions() validates its input and throws - it does not fail closed - when subjectId is not a non-empty string of at most 1024 characters, or when checks holds more than 1024 entries. An invalid batch is a caller bug. A failure to load the subject or policies is different: the engine synthesises an all-false map covering exactly the requested keys and fires the onError hook, so a database outage produces a locked-down UI rather than an exception in your layout.
See engine methods for the full signature, the telemetry option, and how batch checks interact with hooks.
Serialisation across the wire
The map is JSON-safe by construction: string keys, boolean values, no nesting.
{
"create:post": true,
"delete:post:post-42": false,
"@org-1:manage:billing": true
}
Two consequences worth knowing:
- Key order and identity are irrelevant. The client never iterates for a
cancheck, so re-serialising through a framework payload changes nothing. - Escapes survive JSON. A segment containing
:is stored escaped (read:doc\:42), which appears in a JSON document as"read:doc\\:42". BothJSON.stringifyandJSON.parseround-trip it, andcan('read', 'doc:42')rebuilds the same escaped key. The full rules are on the PermissionMap reference.
Do not hand-write map keys in application code. Build them with iamBuildPermissionKey or, better, let can() build them.
Staleness and refresh
The map is a snapshot taken at one instant, for one subject, for one list of checks. The client cannot detect that it went stale. Refresh it after anything that changes a decision:
- a role granted or revoked
- a scope change - the subject joined or left an org
- a policy or role edit that the engine invalidated
- an attribute change that a condition reads, such as a plan tier or a verified flag
Each client has its own refresh path:
// Vanilla - replaces the map and notifies every subscriber.
const next = await fetch('/api/me/permissions').then((r) => r.json())
access.update(next)
// Vue - update() writes through the reactive ref, so templates re-render.
const { update } = useAccess()
update(await fetch('/api/me/permissions').then((r) => r.json()))
// React - re-render the provider with a new map object.
// In the App Router, router.refresh() re-runs the layout that called getIamPermissions.
// In an SPA, usePermissions(fetchFn, [version]) refetches when `version` changes.
router.refresh()
true still reaches the server. The server denies it. Refreshing the map fixes the UI, not the authorisation - which is why the server check is not optional.Type-safe checks
Every client is generic over three string unions, in this order: TAction, TResource, TScope. Fix them once and a mistyped action becomes a compile error instead of a silently hidden button.
type Action = 'create' | 'read' | 'update' | 'delete' | 'manage'
type Resource = 'post' | 'comment' | 'team' | 'billing'
type Scope = 'org-1' | 'admin'
All three clients accept a partial map (IamClient.PartialPermissionMap), which is what engine.permissions() returns: only the keys you batched. No cast is needed to hand a three-key map to a client typed over a large union. See partial maps.
The unions constrain the call sites, not the data. allowedActions() returns string[] on every client rather than TAction[], because the keys it parses come from unvalidated server JSON and re-asserting the union there would hide a malformed map.
SSR and hydration
The rules are the same for all three clients; the mechanics differ per framework.
- Build the map per request, never at module scope. A module-level client, plugin, or provider value built once in a Node process is shared by every request in that process, which leaks one user's permissions to the next.
- Serialise, do not re-evaluate. Pass the map through props, a serialised payload, or a fetch. Never ship the engine, adapter credentials, or policy documents to the browser.
- Hydrate with the same object the server rendered from. A mismatch between the server render and the first client render produces a hydration warning and a flash of the wrong UI.
- Treat the empty map as the pre-hydration state. Before hydration, everything denies. If that flashes, render a skeleton while
loadingis true rather than rendering the denied branch.
Framework specifics: React SSR and hydration, Vue SSR, vanilla in non-browser runtimes.
When to use
- Hiding, disabling, or reordering UI a user cannot act on.
- Rendering navigation, toolbars, and menus from a coarse capability summary.
- Any check that has to be synchronous inside a render pass.
When not to use
- Authorising a mutation. That belongs to a server integration.
- Decisions that depend on live resource attributes the server did not see when it built the map. A flattened boolean cannot answer "can I edit this draft" unless you batched that
resourceId. - Decisions over an unbounded key space. Do not pre-generate every
(action, resource, id)triple; batch only what the current view renders.
Gotchas
- The check shape must match the key shape.
can('manage', 'billing')does not find@org-1:manage:billing. Pass the sameresourceIdandscopethe server batched. - An empty-string scope is a real segment.
iamBuildPermissionKeytests!== undefined, not truthiness, soscope: ''produces@:manage:billing, which is a different key frommanage:billing. cannotis not "explicitly denied". It is!can, so it is alsotruefor a key that was never in the batch.- Mixed-scope maps hide ambiguity. If a global and a scoped answer differ, keep them in separate maps or always pass the scope on the check.
See also
- PermissionMap reference - key formats, escaping, partial maps
- React client
- Vue client
- Vanilla JS client
- Server integrations - where the map is produced
- Engine methods -
engine.permissions()in full