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.

None of these clients contains an evaluator. They read a snapshot of decisions a server already made, serialised over the wire, held in a browser the user controls - every value in it can be edited from a devtools console. `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](/duck-iam/integrations/server).

## 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](#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](#type-safe-checks).

## Pick a client

| Framework | Page | Subpath | Entry export |
| --- | --- | --- | --- |
| React | [React client](/duck-iam/integrations/client/react) | `@gentleduck/iam/client/react` | `createIamAccessControl(React)` |
| Vue 3 | [Vue client](/duck-iam/integrations/client/vue) | `@gentleduck/iam/client/vue` | `createIamVueAccess(vue)` |
| Anything else | [Vanilla JS client](/duck-iam/integrations/client/vanilla) | `@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:

```ts
// 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' },
])
```

```ts
// 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))
```

```ts
// 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](/duck-iam/advanced/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.

```json
{
  "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](/duck-iam/integrations/client/permission-map).

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:

```ts
// Vanilla - replaces the map and notifies every subscriber.
const next = await fetch('/api/me/permissions').then((r) => r.json())
access.update(next)
```

```ts
// Vue - update() writes through the reactive ref, so templates re-render.
const { update } = useAccess()
update(await fetch('/api/me/permissions').then((r) => r.json()))
```

```tsx
// 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()
```

A user acting on a stale `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.

```ts
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](/duck-iam/integrations/client/permission-map#partial-maps-are-the-normal-case).

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](/duck-iam/integrations/client/react#ssr-and-hydration), [Vue SSR](/duck-iam/integrations/client/vue#ssr-and-hydration), [vanilla in non-browser runtimes](/duck-iam/integrations/client/vanilla#ssr-and-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](/duck-iam/integrations/server).
* 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

* [PermissionMap reference](/duck-iam/integrations/client/permission-map) - key formats, escaping, partial maps
* [React client](/duck-iam/integrations/client/react)
* [Vue client](/duck-iam/integrations/client/vue)
* [Vanilla JS client](/duck-iam/integrations/client/vanilla)
* [Server integrations](/duck-iam/integrations/server) - where the map is produced
* [Engine methods](/duck-iam/advanced/engine/methods) - `engine.permissions()` in full

## FAQ

Do the React, Vue, and vanilla clients evaluate roles and policies themselves?

No. All three are wrappers over a `PartialPermissionMap` and a boolean lookup. None of them imports the engine, an adapter, or the policy builder. The engine belongs on the server unless you deliberately embed it in a trusted client runtime such as an Electron main process.

Why generate a map on the server instead of checking in every component?

Policies and role assignments stay on the server, one batch replaces N round trips, hydration is deterministic because the server and the client read the identical object, and the client integration collapses to a property read.

Can a client map answer live attribute-sensitive questions?

Only for the exact checks that were batched. A map is a flattened result, not a rule set. If the answer depends on fresh resource attributes or request environment, evaluate it on the server - or batch a `resourceId`-scoped key per visible record.

What happens when the map is stale or missing a key?

Every client returns `false`. That is the right default for a render gate, and it is why a role change must be followed by a refresh - otherwise a newly granted capability stays invisible until the next full load.

Can I build a key by hand?

Use `iamBuildPermissionKey` from `@gentleduck/iam` - it is also re-exported from `@gentleduck/iam/client/react` so a React-only app needs no second import. String concatenation skips the escaping and breaks on any segment containing `:` or a backslash.