Skip to main content

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.

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.

Loading diagram...

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?) returns true only when the map holds that exact key with the value true.
  • cannot(action, resource, resourceId?, scope?) is the strict negation of can.

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

FrameworkPageSubpathEntry export
ReactReact client@gentleduck/iam/client/reactcreateIamAccessControl(React)
Vue 3Vue client@gentleduck/iam/client/vuecreateIamVueAccess(vue)
Anything elseVanilla JS client@gentleduck/iam/client/vanillaIamAccessClient

All three are factories or classes over the same map, not three implementations of a check.

Loading diagram...

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

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 can check, 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". Both JSON.stringify and JSON.parse round-trip it, and can('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()

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.

  1. 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.
  2. 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.
  3. 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.
  4. Treat the empty map as the pre-hydration state. Before hydration, everything denies. If that flashes, render a skeleton while loading is 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 same resourceId and scope the server batched.
  • An empty-string scope is a real segment. iamBuildPermissionKey tests !== undefined, not truthiness, so scope: '' produces @:manage:billing, which is a different key from manage:billing.
  • cannot is not "explicitly denied". It is !can, so it is also true for 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

FAQ