BetterIAM

Adapters and plugins

How to use and write plugins (actions, resource types, authorized endpoints, operation hooks, policy context, lifecycle callbacks) and storage adapters (the IamStore contract and conformance suite).

Better IAM has two extension points, both trusted server code that you review like the rest of your server:

  • A adds product features that run inside IAM's transactional authorization envelope: new and , HTTP endpoints that are authorized and audited like the built-in API, hooks around every operation, and extra context. It is the equivalent of a plugin in other authentication libraries, and most products that extend Better IAM need only this.
  • A puts IAM data in a database the reference adapters (PostgreSQL, SQLite, libSQL) do not cover. See Adapter contract.

Plugin contract

Write a plugin when a product feature should behave like part of IAM. Typical reasons are permissions that tenants can grant in roles and policies, endpoints that are authorized and audited like the built-in API, and rules that must run inside every IAM change. A plugin is a plain object of type IamPlugin (from better-iam/core) with a unique id; everything else is optional.

You want toUse
Let administrators grant a new permission in roles and policiesactions
Protect a new kind of record with attributes, a parent, or relationsresourceTypes
Expose an authorized, audited, transactional HTTP endpointendpoints
Refuse or extend every IAM change, inside its transactionhooks.beforeOperation, hooks.afterOperation
Feed your own data into policy conditionsresolveContext
React to committed audit events (sync another system)afterAudit
Create or backfill plugin data on initialize()migrate
Delete plugin data when tenants are purgedpurge
Refuse to start with bad plugin settingsvalidateConfig

Prop

Type

Use a plugin

This walkthrough registers the reference plugin, @better-iam/projects, and calls one of its endpoints. Using a plugin you wrote yourself works the same way.

Install

The umbrella package includes it as the better-iam/projects subpath. To install it on its own:

npm i @better-iam/projects

Register it on the server

Pass plugins in the plugins option. They are validated when betterIam() runs, so a broken plugin stops the instance from starting with INVALID_CONFIG.

iam.ts
import { betterIam } from 'better-iam';
import { sqliteAdapter } from 'better-iam/adapter-sqlite';
import { createProjectsPlugin } from 'better-iam/projects';

export const iam = betterIam({
  database: sqliteAdapter({ filename: './iam.db' }),
  secret: process.env.BETTER_IAM_SECRET!,
  baseURL: process.env.BETTER_IAM_BASE_URL!,
  plugins: [createProjectsPlugin()],
});

If the CLI loads your configuration from better-iam.config.mjs (or .ts), register the plugin there, so migrate, purge, and outbox run its migrate, purge, and afterAudit too. defineConfig from better-iam/server types that file, plugins included.

Migrate

iam.initialize() and better-iam migrate create IAM's schema and then run every plugin's migrate in a transaction of its own. Plugin records live in the same iam_records table under their own collection names, so a plugin needs no tables of its own.

npx better-iam migrate --config better-iam.config.mjs

Grant the plugin's actions

A plugin's actions grant nothing until a role includes them. Owners hold every action in their tenant; give everyone else a role:

seed.ts
import { iam } from './iam';

const contributor = await iam.api.roles.create(adminCredential, {
  tenantId,
  name: 'Project contributor',
  permissions: ['projects:read', 'projects:write'],
});
await iam.api.bindings.create(adminCredential, {
  tenantId,
  roleId: contributor.id,
  subjectType: 'group',
  subjectId: engineeringGroupId,
});

Call its endpoints

app/projects.ts
import { iam } from './iam';

const project = await iam.callPlugin(credential, {
  pluginId: 'projects',
  path: 'create',
  tenantId,
  input: { name: 'Website relaunch' },
});

Route: POST /plugins/projects/create (every plugin endpoint is POST {basePath}/plugins/{id}/{path})

The request body is the endpoint input and must carry tenantId. Plugin endpoints are not part of the typed iam.api object, so call them with iam.callPlugin on the server and $request from the client.

Endpoints

Endpoints let a plugin expose its feature over the same HTTP handler and client SDK as the built-in API, with the same authorization, transactions, and audit trail. Each endpoint declares a path, the registered action it requires, a validate function, and a handler.

plugins/labels.ts
import { IamError, type IamPlugin } from 'better-iam/core';

export const labels: IamPlugin = {
  id: 'labels',
  actions: ['labels:create'],
  endpoints: [
    {
      method: 'POST',
      path: 'create',
      action: 'labels:create',
      validate(value) {
        const input = value as { tenantId?: unknown; name?: unknown } | null;
        if (!input || typeof input.tenantId !== 'string' || typeof input.name !== 'string') {
          throw new IamError('INVALID_INPUT', 'tenantId and name required');
        }
        return { tenantId: input.tenantId, name: input.name };
      },
      async handler({ store, tenantId }, input) {
        return store.insert('labels', { id: crypto.randomUUID(), tenantId, name: String(input.name) });
      },
    },
  ],
};

The endpoint mounts at POST /api/iam/plugins/labels/create. Before the handler runs:

  1. a body tenantId that differs from the request's tenant is refused (INVALID_INPUT);
  2. the plugin's validate runs on the body (it must reject unknown or invalid fields, and may not change the request's tenantId);
  3. the registered action is authorized for the caller on the tenant, with root override, tenant scope, ancestry boundaries, and policies applied unchanged.

The handler then receives { store, principal, tenantId, deliver }: the transaction, the verified , the tenant, and deliver, which queues an email or SMS through the host's in the same transaction (see email and SMS). Every hook of every plugin runs around the handler, and the call is audited with the endpoint's action as the event action, so labels:create appears in the audit log, webhooks, and subscribers. Call plugin routes from the browser with the client SDK's $request, or from the server with iam.callPlugin(credential, { pluginId, path, tenantId, input }).

Trusted plugins must not bypass scope checks by reading or writing another collection or tenant arbitrarily.

Per-record authorization

Without resource, an endpoint is authorized on the tenant itself (iam/{tenantId}), which suits tenant-wide operations such as "create" or "list". An endpoint that acts on one record should name it with resource, so policies about that record apply: a Deny on one project, a condition on its ID, or a permission boundary.

plugins/label-rename.ts
import { IamError, type IamPlugin, type StoredRecord } from 'better-iam/core';

interface Label extends StoredRecord {
  name: string;
}

export const labelRename: IamPlugin = {
  id: 'label-rename',
  actions: ['labels:write'],
  endpoints: [
    {
      method: 'POST',
      path: 'rename',
      action: 'labels:write',
      // Authorized on iam/label/{labelId}, so a Deny on resources ['iam/label/abc'] blocks it.
      resource: (input) => `label/${String(input.labelId)}`,
      validate(value) {
        const input = value as { tenantId?: unknown; labelId?: unknown; name?: unknown } | null;
        if (!input || typeof input.labelId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(input.labelId))
          throw new IamError('INVALID_INPUT', 'labelId is required');
        if (typeof input.name !== 'string') throw new IamError('INVALID_INPUT', 'name is required');
        return { tenantId: input.tenantId, labelId: input.labelId, name: input.name };
      },
      async handler({ store, tenantId }, input) {
        const label = await store.get<Label>('labels', String(input.labelId));
        if (!label || label.tenantId !== tenantId) throw new IamError('NOT_FOUND', 'Label not found', 404);
        return store.put<Label>('labels', { ...label, name: String(input.name) });
      },
    },
  ],
};

resource receives the validated input and returns at most 512 characters. Validate the ID strictly (as the example does) so wildcards or slashes can never reach the resource string the action is authorized on. The reference plugin authorizes get, update, archive, and restore on iam/project/{projectId} this way.

Storing plugin data

Handlers read and write through store, the operation's transaction, with the same IamStore methods adapters implement. Plugin records follow the adapter rules:

  • Use a collection name of your own (the reference plugin uses projects) and set tenantId on every record.
  • Always filter by tenantId and check a fetched record's tenantId before acting on it; get reads by ID across tenants.
  • Use uniqueKey for a natural key the database must keep unique per tenant, such as a name.
  • Records must be plain JSON (INVALID_RECORD otherwise), and put updates an existing record; it is not an upsert.
  • Do not catch and ignore storage errors inside the transaction: a failed statement dooms it, and it ends with TRANSACTION_ABORTED instead of committing partial work.

Hooks and context

Hooks let a plugin take part in every IAM change, not only its own endpoints: refuse a change that breaks a rule of yours, or update plugin records in the same transaction. resolveContext feeds plugin data into policy conditions. This example blocks role changes during a change freeze, counts every operation, and exposes the tenant's plan to policies:

plugins/change-freeze.ts
import { IamError, type IamPlugin } from 'better-iam/core';

export const changeFreeze: IamPlugin = {
  id: 'change-freeze',
  hooks: {
    // Runs after authorization, inside the transaction; throwing rolls the operation back.
    async beforeOperation({ tenantId, action }) {
      if (action.startsWith('iam:roles:') && (await freezes.isActive(tenantId)))
        throw new IamError('CHANGE_FREEZE', 'Role changes are frozen', 409);
    },
    async afterOperation({ action, resourceId, result }) {
      metrics.count(action, { resourceId });
    },
  },
  // Trusted, server-derived keys for policy conditions.
  async resolveContext(principal) {
    return { 'app.plan': await billing.planOf(principal.identity.tenantId) };
  },
};

Hook inputs carry store (the transaction), principal, tenantId, action, and resourceId; afterOperation adds result.

The order inside one operation is: authentication, the authorization decision, beforeOperation of each plugin (in the order of the plugins array), the mutation, separation-of-duties and invariant checks, afterOperation of each plugin, and finally the audit record, all in one transaction. A refusal by the authorization decision runs no hooks. Built-in features that are implemented as plugins (below) run after yours.

resolveContext results are merged in this order, later keys winning: the application's resolveContext, each plugin's resolveContext, the identity's declared attributes (principal.{name}), and finally the keys the server derives itself. Server-owned keys such as principal.mfa or principal.roles are removed from plugin output, so a plugin can never vouch for a fact the server owns.

Lifecycle rules

  • Migrations should be idempotent and use the provided transaction. Version migration records explicitly in the plugin's namespace.
  • afterAudit executes from the audit dispatcher after commit and must tolerate at-least-once invocation. It shares the queue that iam.events.dispatch() drains.
  • purge runs inside the tenant purge transaction, after the server has swept the purged tenants' built-in collections and before it records the tenants:purge audit event, so a failure rolls the whole purge back.
  • Validation at construction. Plugin ids must be unique, every endpoint action must be registered, and endpoints need unique paths, POST, a validator, and a handler.

Built-in plugins

Some optional server features register themselves through the same contract when you turn them on, which is how they add resource types and actions to the catalog. Avoid their ids: while the feature is on, a plugin of yours with the same id fails construction as a duplicate.

Plugin idEnabled byAdds
better-iam:inferenceinference optionThe model type with inference:invoke, and the model-tool type with inference:use-tool
better-iam:sshssh optionThe ssh-login type with ssh:login, and the ssh-host type with ssh:port-forward, ssh:agent-forward, and ssh:x11-forward
better-iam:verifiable-credentialsverifiableCredentials optionThe credential-type type with vc:request

Reference plugin

Read the reference plugin before writing your own: it shows validation, tenant scoping, and purge handling done the way the contracts expect. The @better-iam/projects package is a complete plugin built on these contracts. Registering createProjectsPlugin() adds the projects:read and projects:write actions and mounts create, list, get, update, archive, and restore endpoints for tenant-scoped project records. Its purge callback removes a purged tenant's project records in the same transaction as the tenant purge.

EndpointActionAuthorized onInput
createprojects:writeiam/{tenantId}name (1 to 100 characters), optional description (up to 500)
listprojects:readiam/{tenantId}optional status (active or archived)
getprojects:readiam/project/{projectId}projectId
updateprojects:writeiam/project/{projectId}projectId, and name or description (an empty description clears it)
archiveprojects:writeiam/project/{projectId}projectId; refuses a project that is not active (INVALID_TRANSITION)
restoreprojects:writeiam/project/{projectId}projectId; refuses a project that is not archived (INVALID_TRANSITION)
import { betterIam } from 'better-iam';
import { createProjectsPlugin } from 'better-iam/projects';

export const iam = betterIam({
  // ...
  plugins: [createProjectsPlugin()],
});

Options

The server option is plugins: IamPlugin[] (default []). Each endpoint in a plugin's endpoints takes:

FieldTypeDefaultDescription
method'POST'requiredThe only method plugin endpoints support.
pathstringrequiredSegments of letters, digits, _, and - separated by /; a leading / is ignored. Unique within the plugin.
actionstringrequiredThe action authorized for the caller. Must be registered by actions, a resource type, or permissions.
validate(input: unknown) => Record<string, unknown>requiredReturns the accepted input; throw INVALID_INPUT for unknown or invalid fields. Must not change tenantId.
resource(input) => stringtenantThe record the action is authorized on, as iam/{resource}, such as project/{id}.
handler(context, input) => Promise<unknown>requiredDoes the work inside the transaction. context is { store, principal, tenantId, deliver }; the return value is the response body.

deliver takes { kind: 'email' | 'sms', to, template, payload } (payload values are strings) and returns the outbox message ID.

Schema

IAM creates no tables for plugins. A plugin's records are rows of the shared iam_records table under the collection names the plugin chooses, with the standard id, tenantId, and optional uniqueKey columns; the rest of the record is JSON. The reference plugin stores one collection:

CollectionFieldTypeDescription
projectsidstringA generated UUID.
tenantIdstringThe owning tenant.
namestringUnique among the tenant's projects.
descriptionstringOptional.
status'active' | 'archived'Lifecycle state.
createdBystringThe identity that created it.
createdAt, updatedAtnumberEpoch milliseconds.

Errors

CodeWhen
INVALID_CONFIGAt construction: a duplicate plugin id, an endpoint action that is not registered, a reserved or invalid action name (iam:, tenant/, *, ?, whitespace), a resource type declared twice or reserved, a malformed or duplicate endpoint path, a method other than POST, or a missing validator or handler. A validateConfig that throws stops construction with its own error.
NOT_FOUNDNo plugin with that id has a POST endpoint at that path.
INVALID_INPUTThe body's tenantId differs from the request's, validate changed it, validate rejected the input, or resource returned an empty or overlong string.
ACCESS_DENIEDThe caller may not perform the endpoint's action on its resource. The denial is audited.
INVALID_RECORDA handler or hook wrote a record that is not plain JSON or breaks a storage limit.
TRANSACTION_ABORTEDA storage error inside the transaction was caught and ignored; the whole operation rolled back.
FEATURE_DISABLEDdeliver was called for a channel the deployment has no callback for.

Errors a hook or handler throws (such as CHANGE_FREEZE in the hooks example) reach the caller with their own code and status, and roll the operation back.

Adapter contract

Write an adapter when your data has to live in a database the reference adapters do not support, or behind a storage layer your organization mandates. IAM only ever talks to storage through the IamStore interface from better-iam/core, so an adapter that honors this contract gets every feature, including the security guarantees that depend on transactions.

MethodWhat it doesWhat IAM relies on it for
get(collection, id)Returns one record, or undefined.Loading a known record, such as a tenant or a session by id.
find(collection, filter?, { limit, offset, after }?)Returns records matching a strict equality filter, ordered by id.Every lookup: sessions by token hash, bindings by identity, members by group.
insert(collection, record)Creates a record; a duplicate id or natural key is a conflict.Creating anything, with uniqueness (one email per tenant) enforced by the database.
put(collection, record)Updates an existing record. It is not an upsert.Changing state without accidentally resurrecting a deleted record.
delete(collection, id)Removes a record.Revocations, purges, and the retention sweep.
transaction(fn)Runs fn as one serializable read-modify-write; nested calls join the outer transaction.Atomic operations: the change and its audit event commit together or not at all.
migrate()Creates or upgrades the schema.iam.initialize() and better-iam migrate.
close()Releases connections.Clean shutdown of the application and every CLI command.
findOrdered?, collections?, describe?Optional extras; see below.Fast audit paging, snapshots, and doctor.

Rules every adapter must keep

These rules make the adapters interchangeable, and IAM's security guarantees rest on them. An adapter that bends one can leak access or lose a revocation, so the conformance suite tests each of them.

  • Records have id, tenantId, optional uniqueKey, and JSON-compatible fields. Field values may hold any string. Identifiers (collection, id, tenantId, uniqueKey) containing an unpaired surrogate are refused with INVALID_RECORD, because drivers encode text as UTF-8 and would store U+FFFD instead, so two ids would share a row. Reads and deletes treat such identifiers as absent.
  • uniqueKey is unique within collection and tenant. Enforce this in the database.
  • put updates an existing record; it is not an upsert. Tenant ownership is immutable.
  • Filters use strict, typed equality on top-level fields. The string "1" never matches the number 1, null matches only a stored null, undefined matches only an absent field, and objects compare deeply with key order ignored and array order kept.
  • Ordering. Results are ordered by id in code-point order, which is the order of SQLite's BINARY and PostgreSQL's "C" collation. Pagination uses limit and offset over that order. after is a keyset cursor: only records whose id sorts after it, so each page costs the same however deep it is, and offset applies after the cursor.
  • Writes require transaction(). Nested transactions join the current transaction and propagate rollback. Do not expose transaction handles after completion.
  • Serialization. Concurrent check-and-write operations must serialize before reading mutable authorization state. Token consumption and last-owner protection depend on this guarantee.
  • Errors map to IamError without exposing SQL or serialized credentials.

Build on RecordStore

The PostgreSQL, SQLite, and libSQL adapters are reference implementations. All three share the RecordStore base from better-iam/core, so a new adapter only implements a row driver, transaction boundaries, migration, and close. RecordStore validates records, applies the filter semantics, and pages results.

import type { RecordDriver, StorageRow } from 'better-iam/core';

// Rows are { collection, id, tenant_id, unique_key, data } with data as a JSON string.
const driver: RecordDriver = {
  async select(collection, id, tenantId): Promise<StorageRow[]> {
    /* SELECT ... WHERE collection = ? [AND id = ?] [AND tenant_id = ?] */
  },
  async insert(row) {
    /* INSERT; map unique violations to a conflict */
  },
  async update(row) {
    /* UPDATE ... WHERE collection = ? AND id = ? AND tenant_id = ?; return whether a row changed */
  },
  async delete(collection, id) {
    /* DELETE ... WHERE collection = ? AND id = ? */
  },
  // Optional: evaluate filters in SQL.
  // query(query) { ... },
  // queryCapabilities: { json: true, order: true },
  // collections() { ... },
};

A driver that also implements the optional query(RecordQuery) method receives each filter as typed field conditions and evaluates it in SQL. RecordStore then pages in SQL whenever the whole filter could be expressed there, and filters the rest in memory.

  • Values a database cannot compare exactly stay in memory: strings with U+0000 or unpaired surrogates, sparse arrays, and filters beyond MAX_QUERY_CONDITIONS (24) keys.
  • If a driver returns a row the filter rejects, find repeats the query without paging and filters in memory, so a lenient driver is slow but never wrong. A driver that drops matching rows cannot be corrected this way, and the conformance suite is designed to catch it.
  • RecordStore passes the keyset cursor to drivers as RecordQuery.after, which SQL drivers evaluate as id > ?. When a driver ignores it, the extra rows are caught and the query is repeated without paging, so the result stays correct.
  • Drivers without query stay correct and read by collection, id, and tenant only.

planQuery, sqliteSelect, postgresSelect, and applyMigrations are exported for SQL adapters. applyMigrations records named schema steps in an iam_migrations table (see Database operations).

Optional methods

Three store methods are optional. Callers fall back when a store lacks them, so implement them when your database can do the work faster than the fallback, or when you need the feature that depends on them:

  • findOrdered(collection, filter, { field, direction, from, to, offset, limit }) returns records whose field holds a number, ordered by that field and then by id, within the inclusive from and to bounds. Records without a numeric value are left out. The audit log reads through it. A driver opts in with queryCapabilities: { order: true }. Callers use the exported findOrdered(store, ...) helper, which sorts in memory for stores without the method and accepts an extra where predicate that it applies page by page.
  • collections() lists the collections that hold records. Snapshots (exportStore, importStore, copyStore) rely on it; a driver provides it with collections() returning the distinct collection names.
  • describe() returns a StoreDescription for doctor: adapter name, schema version, applied migrations, record counts per collection, and adapter settings. describeRecords builds one from any SQL executor.

instrumentStore(store, onCall) wraps any store and reports each call, which helps verify that a new adapter's lookups stay selective. It forwards the optional methods only when the wrapped store has them.

Conformance suite

The conformance suite is how you prove a new adapter behaves exactly like the reference ones before trusting it with production data. @better-iam/core/conformance exports the behavioral contract as framework-agnostic cases, and every reference adapter runs it in tests/adapter-conformance.test.ts. Give each case a fresh, migrated store:

my-adapter.test.ts
import { describe, it } from 'vitest';
import { adapterConformanceCases } from '@better-iam/core/conformance';
import { myAdapter } from './my-adapter';

describe('my adapter', () => {
  for (const test of adapterConformanceCases())
    it(test.name, async () => {
      const store = myAdapter(options);
      await store.migrate();
      try {
        await test.run(store);
      } finally {
        await store.close();
      }
    });
});

runAdapterConformance(createStore) runs every case and returns { passed, failed } for runners without per-case reporting. The cases cover:

  • typed filters, including integers above 2^53 and extreme exponents;
  • strings with U+0000 and unpaired surrogates;
  • code-point ordering and selective pagination over hundreds of rows;
  • ordered and bounded reads, and reads of uncommitted writes;
  • natural-key uniqueness, transaction rollback and nesting, and serialization of concurrent read-modify-write;
  • record validation and error hygiene.

Run the service security tests against a new adapter as well.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page