Skip to main content

Installation

Install @gentleduck/iam, pick the export paths you need with their peer dependencies, and run your first permission check

@gentleduck/iam is one package with 27 export paths. The core engine, builders, and types come from the root; every adapter, server integration, client, invalidator, the metrics aggregator, and the devtools panels live behind their own subpath so you only bundle what you import.

Requirements

RequirementVersionWhy
Node.js18 or newerDeclared in engines. The engine uses AbortController for adapter timeouts.
TypeScript5.0 or newercreateIam() relies on const type parameters, which landed in TypeScript 5.0.
Module resolutionbundler or nodenextSubpath exports are declared through the exports map only.

The core engine has no runtime dependencies. The package's single dependencies entry is uuid, imported solely by the Drizzle schema helpers in adapters/drizzle/{pg,mysql,sqlite}; if you do not use Drizzle it never loads.

Install


npm i @gentleduck/iam

That is the whole install for the core engine, the memory adapter, the file adapter, the HTTP adapter, the Redis adapter, the Prisma adapter, and every server integration. Only Drizzle and the devtools panels need anything extra; see optional peer dependencies.

Your first check

Declare your permission schema

createIam() takes your actions, resources, and optionally scopes and role IDs as as const arrays, and returns builders constrained to them.

src/lib/access.ts
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'
import { createIam } from '@gentleduck/iam/core'

const access = createIam({
  actions: ['create', 'read', 'update', 'delete', 'manage'] as const,
  resources: ['post', 'comment', 'user', 'team'] as const,
  roles: ['viewer', 'editor', 'admin'] as const,
  scopes: ['org'] as const,
})

Define roles

Role builders are chainable and end in build(). grantRead() grants read on each resource; grantCRUD() grants create, read, update, and delete; grantAll() grants *.

src/lib/access.ts
const viewer = access.defineRole('viewer').grantRead('post', 'comment').build()

const editor = access
  .defineRole('editor')
  .inherits('viewer')
  .grant('create', 'post')
  .grant('update', 'post')
  .grant('create', 'comment')
  .build()

const admin = access
  .defineRole('admin')
  .inherits('editor')
  .grant('delete', 'post')
  .grant('delete', 'comment')
  .grantCRUD('user')
  .grant('manage', 'team')
  .build()

Create an adapter and the engine

IamMemoryAdapter seeds roles, policies, assignments, and subject attributes from a plain object. Pass mode: 'production' outside development; it defaults to 'development'.

src/lib/access.ts
const adapter = new IamMemoryAdapter({
  roles: [viewer, editor, admin],
  assignments: {
    'user-1': ['admin'],
    'user-2': ['editor'],
    'user-3': ['viewer'],
  },
})

export const engine = access.createEngine({
  adapter,
  mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
})

Ask the engine questions

// Plain boolean, in both modes.
await engine.can('user-2', 'create', { type: 'post', attributes: {} }) // true
await engine.can('user-2', 'delete', { type: 'post', attributes: {} }) // false

// Full decision in development mode, plain boolean in production mode.
const decision = await engine.check('user-2', 'delete', { type: 'post', attributes: {} })

// Development mode only; throws in production mode.
const trace = await engine.explain('user-2', 'delete', { type: 'post', attributes: {} })

Batch checks for a UI gate

permissions() resolves the subject and loads the catalog once for the whole batch. Keys are built by iamBuildPermissionKey, so they read [scope:]action:resource[:resourceId].

const map = await engine.permissions('user-2', [
  { action: 'create', resource: 'post' },
  { action: 'update', resource: 'post' },
  { action: 'delete', resource: 'post' },
  { action: 'manage', resource: 'team' },
])
// { 'create:post': true, 'update:post': true, 'delete:post': false, 'manage:team': false }

A batch of more than 1024 checks throws rather than failing closed: an oversized batch is a caller bug, not a denied request.

The export map

Subpaths group into six families. Nothing in one family pulls in another.

Loading diagram...

ROOT re-exports CORE plus the cache and permission-key helpers. COPT holds the four optional core chunks that the barrel deliberately keeps out of the hot path. AD, SRV, CLI, OPS, and DT are reachable only by their own specifier.

Every export path

Export pathMain exportsPeer dependencyRuntime notes
@gentleduck/iamEverything from core, plus IamLRUCache, iamLRUCache, iamBuildPermissionKey, iamParsePermissionKey, iamSplitPermissionKeynonePulls the whole core barrel; prefer core plus subpaths.
@gentleduck/iam/corecreateIam, IamEngine, iamEngine, iamFlushSharedCaches, defineRole, definePolicy, defineRule, when, evaluate, evaluateFast, evaluatePolicy, evaluatePolicyFast, indexPolicy, rolesToPolicy, resolveEffectiveRoles, MAX_INHERITANCE_DEPTH, resolve, matchesAction, matchesResource, explainEvaluation, POLICY_JSON_SCHEMA, all type namespacesnoneRuns anywhere with ES2022. core/validate is intentionally not re-exported here.
@gentleduck/iam/core/validatevalidatePolicy, validateRole, validateRoles, parsePolicyRow, parseRoleRow, detectCatastrophicRegex, POLICY_LIMITS, VALID_ALGORITHMS, VALID_EFFECTS, VALID_OPERATORS, IamValidatenoneRoughly 12 KB. Lazily loaded by engine.admin write paths; import directly only for standalone validation tooling.
@gentleduck/iam/core/builderPolicyBuilder, RoleBuilder, RuleBuilder, When, definePolicy, defineRole, defineRule, whennoneRoughly 9 KB, config-time only. Apps that store policies as JSON never need it.
@gentleduck/iam/core/explainexplainEvaluation, escapeHtml, ExplainnoneDevelopment-mode tracer, a separate chunk. escapeHtml exists because trace strings carry request-supplied values.
@gentleduck/iam/core/schemaPOLICY_JSON_SCHEMAnoneJSON Schema Draft 2020-12 document for AccessControl.IPolicy.
@gentleduck/iam/adapters/memoryIamMemoryAdapter, iamMemoryAdapter, IamMemorynoneIn-process. Tests, demos, small single-node apps.
@gentleduck/iam/adapters/fileIamFileAdapter, iamFileAdapter, IamFilenoneYou inject the filesystem driver, for example await import('node:fs/promises'). Statically imports node:path, so non-Node runtimes need that alias.
@gentleduck/iam/adapters/prismaIamPrismaAdapter, iamPrismaAdapter, IamPrismanone declaredTakes your Prisma client as a structural type (IamPrisma.ILike); @prisma/client is never imported by the package. Expects accessPolicy, accessRole, accessAssignment, and accessSubjectAttr models.
@gentleduck/iam/adapters/drizzleIamDrizzleAdapter, iamDrizzleAdapter, createIamDrizzleAdapter, IamDrizzledrizzle-ormType-only import of drizzle-orm, so the runtime cost is zero, but the types will not resolve without it installed.
@gentleduck/iam/adapters/drizzle/pgiamPolicies, iamRoles, iamAssignments, iamSubjectAttrs, combineAlgorithm, Pgdrizzle-ormRuntime import of drizzle-orm/pg-core and uuid. Postgres table definitions for drizzle-kit.
@gentleduck/iam/adapters/drizzle/mysqliamPolicies, iamRoles, iamAssignments, iamSubjectAttrs, Mysqldrizzle-ormRuntime import of drizzle-orm/mysql-core and uuid.
@gentleduck/iam/adapters/drizzle/sqliteiamPolicies, iamRoles, iamAssignments, iamSubjectAttrs, IAM_COMBINE_ALGORITHMS, Sqlitedrizzle-ormRuntime import of drizzle-orm/sqlite-core and uuid. Pass json: 'string' on the adapter for SQLite text columns.
@gentleduck/iam/adapters/redisIamRedisAdapter, iamRedisAdapter, IamRedisnone declaredTakes your Redis client as a structural type (IamRedis.ILike); ioredis and node-redis v4 both satisfy it.
@gentleduck/iam/adapters/httpIamHttpAdapter, iamHttpAdapter, IamHttpnoneUses globalThis.fetch, overridable through config.fetch. Ships retry, per-request timeout, and a circuit breaker.
@gentleduck/iam/invalidators/rediscreateIamRedisInvalidator, IamRedisInvalidatornone declaredTakes a Redis pub/sub client. Statically imports node:crypto for the HMAC on broadcast messages, so it is Node-only.
@gentleduck/iam/observability/metricsiamCreateMetricsAggregator, IamMetricsnoneAggregates p50, p95, and p99 over onMetrics events.
@gentleduck/iam/server/expressiamAccessMiddleware, iamGuard, iamAdminRouter, IamExpressnone declaredWorks against a minimal request shape; express is never imported. You pass the router factory in.
@gentleduck/iam/server/nestIamAuthorize, iamNestAccessGuard, createIamEngineProvider, createIamAdminOperations, IAM_ACCESS_METADATA_KEY, IAM_ACCESS_ENGINE_TOKEN, NestRequest, IamNestnone declaredDecorator and guard are plain functions; @nestjs/common is never imported.
@gentleduck/iam/server/honoiamAccessMiddleware, iamGuard, iamBindAdminRouter, IamHononone declaredWorks against a minimal context shape; hono is never imported.
@gentleduck/iam/server/nextwithIamAccess, createIamNextMiddleware, createIamAdminHandlers, IamNextnone declaredApp Router handlers and middleware; next is never imported.
@gentleduck/iam/server/genericcreateIamSubjectCan, iamExtractEnvironment, iamFireAdminMutation, iamDefaultCsrfCheck, iamNoticeCsrfDefaultIfNeeded, iamErrorToAuditString, IAM_METHOD_ACTION_MAP, IamAdminAuditnoneThe primitives the four framework wrappers are built on. Use it for a framework that has no wrapper.
@gentleduck/iam/client/reactcreateIamAccessControl, createIamPermissionChecker, iamBuildPermissionKey, IamReactClientreact (yours)Only a type-only import of ReactNode; you pass the React namespace into createIamAccessControl(React), so the package never pins a React copy.
@gentleduck/iam/client/vuecreateIamVueAccess, IAM_ACCESS_INJECTION_KEYvue (yours)You pass the Vue API into createIamVueAccess(vue); vue is never imported.
@gentleduck/iam/client/vanillaIamAccessClient, iamAccessClientnoneFramework-free permission-map consumer, including IamAccessClient.fromServer(url).
@gentleduck/iam/dtIamDevtools, IamDevtoolsInner, iamCreateFlowRecorder, iamEnsureDevtoolsStyles, IamDecisionInspector, IamFlowPanel, IamMetricsPanel, IamPoliciesPanel, IamRolesPanel, IamSubjectsPanel, IamTraceTreereactDevtools v1. Imports React for real, but nothing else: it injects the one stylesheet it owns. Development builds only.
@gentleduck/iam/dt/v2IamDevtoolsV2, IamDevtoolsInnerV2, iamCreateFlowRecorder, IamDecisionInspectorV2, IamFlowPanelV2, IamMetricsPanelV2, IamPoliciesPanelV2, IamRolesPanelV2, IamSubjectsPanelV2, IamTraceTreeV2react, @gentleduck/libs, @gentleduck/registry-ui, lucide-reactThe same six panels rebuilt on duck-ui, so they inherit the host app's theme. Needs Tailwind v4 configured to scan this package: @source "../node_modules/@gentleduck/iam/dist/dt/v2";

Every path also has a CommonJS build; require('@gentleduck/iam/core') resolves to dist/core/index.cjs with its own .d.cts types.

Optional peer dependencies

Every peer is marked optional in peerDependenciesMeta, so a plain install never warns about the ones you do not use.

PeerDeclared rangeNeeded by
drizzle-orm>=0.30.0adapters/drizzle (types) and the three dialect schema entries (runtime).
react^19.2.6dt and dt/v2. The React client only imports a type.
@gentleduck/libs>=0.2.0dt/v2, for cn().
@gentleduck/registry-ui>=0.5.0dt/v2, for the panel's UI components.
lucide-react>=0.400.0dt/v2, for the panel's icons.

Install them only alongside the entry points that need them.


npm i drizzle-orm

The v1 devtools panel needs only React:


npm i -D react

The v2 panel needs the duck-ui trio as well:


npm i -D react @gentleduck/libs @gentleduck/registry-ui lucide-react

Prisma and Redis are not peer dependencies at all. Both adapters accept your client through a structural interface, so you install @prisma/client or ioredis for your own reasons and hand the instance over:

import { IamPrismaAdapter } from '@gentleduck/iam/adapters/prisma'
import { IamRedisAdapter } from '@gentleduck/iam/adapters/redis'

const prismaAdapter = new IamPrismaAdapter({ prisma })
const redisAdapter = new IamRedisAdapter({ client: redis, keyPrefix: 'iam:' })

Import examples

// Core: engine, config, builders, evaluators, types.
import { createIam, IamEngine, defineRole, definePolicy, when } from '@gentleduck/iam/core'

// Optional core chunks.
import { validatePolicy } from '@gentleduck/iam/core/validate'
import { POLICY_JSON_SCHEMA } from '@gentleduck/iam/core/schema'

// Storage adapters.
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'
import { IamFileAdapter } from '@gentleduck/iam/adapters/file'
import { IamPrismaAdapter } from '@gentleduck/iam/adapters/prisma'
import { IamDrizzleAdapter } from '@gentleduck/iam/adapters/drizzle'
import { IamRedisAdapter } from '@gentleduck/iam/adapters/redis'
import { IamHttpAdapter } from '@gentleduck/iam/adapters/http'

// Drizzle schemas, one dialect per entry.
import * as schema from '@gentleduck/iam/adapters/drizzle/pg'

// Server integrations.
import { iamGuard, iamAdminRouter } from '@gentleduck/iam/server/express'
import { iamGuard as honoGuard, iamBindAdminRouter } from '@gentleduck/iam/server/hono'
import { IamAuthorize, iamNestAccessGuard } from '@gentleduck/iam/server/nest'
import { withIamAccess, createIamAdminHandlers } from '@gentleduck/iam/server/next'
import { createIamSubjectCan } from '@gentleduck/iam/server/generic'

// Clients.
import { createIamAccessControl } from '@gentleduck/iam/client/react'
import { createIamVueAccess } from '@gentleduck/iam/client/vue'
import { IamAccessClient } from '@gentleduck/iam/client/vanilla'

// Operability.
import { createIamRedisInvalidator } from '@gentleduck/iam/invalidators/redis'
import { iamCreateMetricsAggregator } from '@gentleduck/iam/observability/metrics'

Runtime notes

  • Node. Everything works. invalidators/redis and adapters/file are the two Node-coupled entries, through node:crypto and node:path.
  • Edge and workers. core, adapters/memory, adapters/http, adapters/redis, and every server integration run without Node built-ins. The HTTP adapter uses the platform fetch.
  • Browser. Ship only client/react, client/vue, or client/vanilla. They consume a PermissionMap the server produced; the engine, the adapters, and your policy catalog never enter the browser bundle.

TypeScript setup

tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "moduleResolution": "bundler",
    "target": "ES2022"
  }
}

strict keeps const type parameter inference working for the role and policy builders. bundler resolution reads the exports map; nodenext works too. With node10-style resolution the subpaths will not resolve at all.

Verify the install

scripts/verify-iam.ts
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'
import { createIam } from '@gentleduck/iam/core'

const access = createIam({ actions: ['read'] as const, resources: ['post'] as const, roles: ['viewer'] as const })
const viewer = access.defineRole('viewer').grant('read', 'post').build()
const engine = access.createEngine({
  adapter: new IamMemoryAdapter({ roles: [viewer], assignments: { u1: ['viewer'] } }),
  mode: 'production',
})

console.log(await engine.can('u1', 'read', { type: 'post', attributes: {} })) // true
console.log(await engine.healthCheck())

healthCheck() returns adapter latency and cache hit rate, which makes it a usable /healthz probe as well as an install check.

Gotchas

  • Barrel imports cost roughly 41 KB gzipped. Subpath imports plus tree-shaking land real deployments at 15 to 25 KB; see benchmarks.
  • mode defaults to 'development', which allocates a decision object per policy per request. Set it from your environment as shown above.
  • SQLite Drizzle deployments must pass json: 'string' to the adapter, because the SQLite schema stores JSON columns as text.
  • The devtools entries pull React for real, and dt/v2 pulls duck-ui and lucide-react on top. Keep either behind a development-only import so it cannot reach a production bundle.

See also

Installation FAQ