Create your first extension
Build a change-freeze extension with the IamPlugin contract that adds authorized API methods, a check that runs inside every IAM change, and a handler for committed audit events.
In this guide you build a change freeze: an extension that lets an organization stop all access changes for a
while, for example during a quarter-end close or a release. An extension in Better IAM is a plugin, a plain object
that implements the IamPlugin interface from better-iam/core and is passed to betterIam({ plugins }). Its
code runs inside the same transactional, authorized, and audited envelope as the built-in API, so the freeze
cannot be bypassed and every use of it lands in the audit log. You will build:
- three new actions (
freeze:read,freeze:start,freeze:end) that administrators grant in roles like any built-in permission; - three API methods, mounted at
POST /api/iam/plugins/change-freeze/{start,end,status}and callable from the server and the browser client; - a check (
hooks.beforeOperation) that refuses role, policy, and binding changes in a frozen organization; - an event handler (
afterAudit) that tells your code when a freeze starts or ends, after the change commits; - a purge callback that deletes the extension's records when a tenant is purged.
Prerequisites
- A working Better IAM instance, as in the Quickstart:
better-iaminstalled, a configuration module, and aniam.tsthat exportsbetterIam(config). - Node.js 22.12 or newer and an ESM project (
"type": "module"). - For the test in the last step: Vitest and the test fixture from Test your integration.
Build the extension
Write the extension module
The whole extension is one module that returns an IamPlugin. Every part of it is optional except id; this one
uses actions, endpoints, hooks, afterAudit, and purge.
import { randomUUID } from 'node:crypto';
import { IamError, type AuditEvent, type IamPlugin, type StoredRecord } from 'better-iam/core';
/** The one active freeze of a tenant, stored in the extension's own collection. */
export interface ChangeFreeze extends StoredRecord {
reason: string;
startedBy: string;
startedAt: number;
}
export interface ChangeFreezeOptions {
/** Called after a freeze starts or ends, once the change has committed. */
onChange?(event: AuditEvent): Promise<void> | void;
}
const COLLECTION = 'changeFreezes';
/** The natural key of the active freeze: at most one per tenant, enforced by the database. */
const ACTIVE = 'active';
/** Access changes the freeze blocks: creating, updating, and deleting roles, policies, and bindings. */
const FROZEN_ACTIONS = /^iam:(roles|policies|bindings):(create|update|delete)$/;
/** Rejects anything but a JSON object with only the listed fields. */
function fields(value: unknown, allowed: string[]): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new IamError('INVALID_INPUT', 'A JSON object is required');
const input = value as Record<string, unknown>;
for (const key of Object.keys(input))
if (!allowed.includes(key)) throw new IamError('INVALID_INPUT', `Unexpected field ${key}`);
return input;
}
export function changeFreeze(options: ChangeFreezeOptions = {}): IamPlugin {
return {
id: 'change-freeze',
actions: ['freeze:read', 'freeze:start', 'freeze:end'],
endpoints: [
{
method: 'POST',
path: 'start',
action: 'freeze:start',
validate(value) {
const input = fields(value, ['tenantId', 'reason']);
const reason = typeof input.reason === 'string' ? input.reason.trim() : '';
if (!reason || reason.length > 200)
throw new IamError('INVALID_INPUT', 'reason must be 1-200 characters');
return { tenantId: input.tenantId, reason };
},
async handler({ store, principal, tenantId }, input) {
const [current] = await store.find<ChangeFreeze>(COLLECTION, { tenantId, uniqueKey: ACTIVE });
if (current) throw new IamError('CONFLICT', 'A change freeze is already active', 409);
return store.insert<ChangeFreeze>(COLLECTION, {
id: randomUUID(),
tenantId,
uniqueKey: ACTIVE,
reason: String(input.reason),
startedBy: principal.identity.id,
startedAt: Date.now(),
});
},
},
{
method: 'POST',
path: 'end',
action: 'freeze:end',
validate(value) {
const input = fields(value, ['tenantId']);
return { tenantId: input.tenantId };
},
async handler({ store, tenantId }) {
const [current] = await store.find<ChangeFreeze>(COLLECTION, { tenantId, uniqueKey: ACTIVE });
if (!current) throw new IamError('NOT_FOUND', 'No change freeze is active', 404);
await store.delete(COLLECTION, current.id);
return { ended: current };
},
},
{
method: 'POST',
path: 'status',
action: 'freeze:read',
validate(value) {
const input = fields(value, ['tenantId']);
return { tenantId: input.tenantId };
},
async handler({ store, tenantId }) {
const [current] = await store.find<ChangeFreeze>(COLLECTION, { tenantId, uniqueKey: ACTIVE });
return { freeze: current ?? null };
},
},
],
hooks: {
// Runs inside every operation's transaction, after authorization and before the change.
async beforeOperation({ store, tenantId, action }) {
if (!FROZEN_ACTIONS.test(action)) return;
const [current] = await store.find<ChangeFreeze>(COLLECTION, { tenantId, uniqueKey: ACTIVE });
if (current)
throw new IamError('CHANGE_FREEZE', `Access changes are frozen: ${current.reason}`, 409);
},
},
// Runs after commit from the audit dispatcher, at least once per event.
async afterAudit(event) {
if (event.outcome !== 'allow') return;
if (event.action === 'freeze:start' || event.action === 'freeze:end')
await options.onChange?.(event);
},
// Removes the extension's records when a retention purge deletes tenants.
async purge(store, tenantIds) {
for (const tenantId of tenantIds)
for (const freeze of await store.find<ChangeFreeze>(COLLECTION, { tenantId }))
await store.delete(COLLECTION, freeze.id);
},
};
}What each part does:
idnames the extension and its URL segment (/api/iam/plugins/change-freeze/...). It must be unique among the instance's plugins.actionsjoin the permission catalog, so roles and policies can grant them. Names must not start withiam:ortenant/and must not contain*,?, or whitespace. The{area}:{verb}shape matches the built-in actions.endpointsare the API methods. Each one ismethod: 'POST', has a uniquepath, names the registeredactionthe caller needs, and suppliesvalidateandhandler.validatereceives the request body with the request'stenantIdmerged in; it must reject unknown fields and must not return a differenttenantId. The handler receives{ store, principal, tenantId, deliver }: the open transaction, the verified caller, the tenant, and a function that queues an email or SMS through the outbox in the same transaction.hooks.beforeOperationruns for every operation in every tenant, built-in or from a plugin, after authorization and before the mutation, inside the operation's transaction. Throwing anIamErrorrolls the operation back and the caller receives that error. Here it reads the freeze through the same transaction, so a freeze that starts concurrently is seen consistently. Reads such asiam:roles:readare not matched byFROZEN_ACTIONS, and neither are the extension's own actions, so a frozen tenant can still end its freeze. The hook sees only the operation's own action: a binding created by approving an access request (iam:access-requests:review) or byconfig.apply(iam:config:apply) does not match the pattern, so add those actions to it if they must stop too.afterAuditreceives every committed audit event of the instance, including refusals (outcome: 'deny'), so filter onoutcomeandaction. It runs when the audit dispatcher drains its queue, not inside the request.purgeruns inside the tenant purge transaction, after the server has deleted the purged tenants' own records and before it writes the purge audit record.
The records live in a collection of their own (changeFreezes). Collections hold JSON documents, so an extension
needs no schema migration. uniqueKey: 'active' makes the database refuse a second active freeze in the same
tenant even if two requests race past the find.
Register the extension
Pass the extension to plugins. The instance validates it at construction: duplicate ids, endpoint actions that
are not registered, and endpoints without POST, a unique path, validate, or handler fail with
INVALID_CONFIG before the server starts.
import { betterIam } from 'better-iam';
import { changeFreeze } from './iam/change-freeze';
import config from './better-iam.config.mjs';
export const iam = betterIam({
...config,
plugins: [
changeFreeze({
onChange: (event) => {
console.log(`${event.action} in tenant ${event.tenantId} by ${event.actorId}`);
},
}),
],
});The better-iam CLI loads only the configuration module, so if you run migrate or scheduled jobs through it,
put plugins in better-iam.config.mjs instead. An extension's optional migrate(store) callback runs from
iam.initialize() and from better-iam migrate; this extension has nothing to migrate.
Grant the new actions
The organization's owner can call every action, including the extension's. Everyone else needs a role that grants
them. Create the roles with roles.create (requires iam:roles:create) and bind them with bindings.create:
import { iam } from '../iam';
const owner = { token: process.env.OWNER_TOKEN! };
const tenantId = process.env.TENANT_ID!;
const releaseManager = await iam.api.roles.create(owner, {
tenantId,
name: 'Release manager',
permissions: ['freeze:read', 'freeze:start', 'freeze:end'],
});
await iam.api.bindings.create(owner, {
tenantId,
roleId: releaseManager.id,
subjectType: 'identity',
subjectId: process.env.RELEASE_MANAGER_ID!,
});Routes: POST /api/iam/roles/create, POST /api/iam/bindings/create
Create the roles before you start a freeze: while a freeze is active, roles.create and bindings.create are
refused with CHANGE_FREEZE.
Call the API methods
Server code calls an extension endpoint with iam.callPlugin(credential, { pluginId, path, tenantId, input }).
Browser code calls it with the client's $request(path, input), where path is relative to the IAM base path. Both
go through validation, authorization against the endpoint's action, the transaction, and the audit log.
import { iam } from './iam';
import type { ChangeFreeze } from './iam/change-freeze';
/** Starts a freeze as the caller of `request` (its session cookie or bearer token). */
export async function startFreeze(request: Request, tenantId: string, reason: string) {
return (await iam.callPlugin(
{ headers: request.headers },
{ pluginId: 'change-freeze', path: 'start', tenantId, input: { reason } },
)) as ChangeFreeze;
}
export async function freezeStatus(request: Request, tenantId: string) {
return (await iam.callPlugin(
{ headers: request.headers },
{ pluginId: 'change-freeze', path: 'status', tenantId },
)) as { freeze: ChangeFreeze | null };
}
export async function endFreeze(request: Request, tenantId: string) {
await iam.callPlugin({ headers: request.headers }, { pluginId: 'change-freeze', path: 'end', tenantId });
}callPlugin returns Promise<unknown>; cast the result to the type your handler returns. The credential is a
CredentialInput: { headers } from an incoming request, or { token }.
| Method | Route | Action | Input | Result |
|---|---|---|---|---|
| start | POST /api/iam/plugins/change-freeze/start | freeze:start | { tenantId, reason } | The stored ChangeFreeze |
| end | POST /api/iam/plugins/change-freeze/end | freeze:end | { tenantId } | { ended: ChangeFreeze } |
| status | POST /api/iam/plugins/change-freeze/status | freeze:read | { tenantId } | { freeze: ChangeFreeze | null } |
An endpoint without resource is authorized on the tenant itself (iam/{tenantId}), which suits tenant-wide
operations like these. An endpoint that acts on one record should also set resource, a function of the
validated input that names the record, such as (input) => 'project/' + String(input.projectId). The action is
then authorized on iam/project/{id}, so policies about that one record apply. The @better-iam/projects package
does this for its per-project endpoints.
Deliver the events
The operation's audit record is queued in the same transaction as the change. afterAudit (and so onChange)
runs only when the audit dispatcher drains that queue with iam.events.dispatch(). Run it on a schedule next to
the outbox, as described in Scheduled jobs:
import { iam } from '../iam';
// Every few seconds: hand committed audit events to plugins, events.onEvent, and subscribers.
setInterval(() => {
iam.events.dispatch().catch((error) => console.error('audit dispatch failed', error));
}, 5_000);Two properties to design for:
- At least once. A dispatcher that fails after calling your handler delivers the event again. Deduplicate on
event.idif the side effect is not idempotent. - No ordering promise. A batch is not delivered in commit order; a
freeze:endcan reachonChangebefore thefreeze:startit follows. Order byevent.timestampif it matters.
Test the extension
This Vitest test uses createIamFixture from Test your integration,
which builds an in-memory instance with an organization and a signed-in owner.
import { afterEach, describe, expect, it } from 'vitest';
import type { AuditEvent } from 'better-iam/core';
import { changeFreeze, type ChangeFreeze } from '../iam/change-freeze';
import { createIamFixture, type IamFixture } from './iam-fixture';
let f: IamFixture | undefined;
afterEach(async () => {
await f?.close();
});
describe('change-freeze extension', () => {
it('blocks access changes while a freeze is active', async () => {
const changes: AuditEvent[] = [];
f = await createIamFixture({
plugins: [changeFreeze({ onChange: (event) => void changes.push(event) })],
});
const { iam, ownerCredential, tenantId } = f;
const call = (path: string, input: Record<string, unknown> = {}) =>
iam.callPlugin(ownerCredential, { pluginId: 'change-freeze', path, tenantId, input });
const started = (await call('start', { reason: 'Quarter-end close' })) as ChangeFreeze;
expect(started).toMatchObject({ tenantId, reason: 'Quarter-end close' });
await expect(call('start', { reason: 'again' })).rejects.toMatchObject({ code: 'CONFLICT' });
await expect(call('start', { reason: 'x', extra: 1 })).rejects.toMatchObject({
code: 'INVALID_INPUT',
});
// The check refuses writes and lets reads through.
await expect(
iam.api.roles.create(ownerCredential, { tenantId, name: 'Auditor', permissions: ['freeze:read'] }),
).rejects.toMatchObject({ code: 'CHANGE_FREEZE', status: 409 });
await expect(iam.api.roles.list(ownerCredential, { tenantId })).resolves.toBeDefined();
await call('end');
await expect(
iam.api.roles.create(ownerCredential, { tenantId, name: 'Auditor', permissions: ['freeze:read'] }),
).resolves.toMatchObject({ name: 'Auditor' });
// People need a role that grants the action.
const bob = await f.member('bob');
await expect(
iam.callPlugin(bob.credential, { pluginId: 'change-freeze', path: 'status', tenantId }),
).rejects.toMatchObject({ code: 'ACCESS_DENIED' });
const ada = await f.member('ada', ['freeze:read']);
await expect(
iam.callPlugin(ada.credential, { pluginId: 'change-freeze', path: 'status', tenantId }),
).resolves.toEqual({ freeze: null });
// Events reach afterAudit only when the dispatcher runs.
expect(changes).toHaveLength(0);
await iam.events.dispatch();
expect(changes.map((event) => event.action).sort()).toEqual(['freeze:end', 'freeze:start']);
});
});Verify it works
Run the test:
npx vitest run test/change-freeze.test.tsIt passes when the freeze blocks roles.create, lets roles.list through, refuses a member without a role, and
delivers both events after iam.events.dispatch().
To check the HTTP route against a running server, call it with a session token in the Authorization header. The
handler requires content-type: application/json and x-better-iam: 1 on every POST:
curl -X POST http://localhost:3000/api/iam/plugins/change-freeze/status \
-H 'content-type: application/json' \
-H 'x-better-iam: 1' \
-H "authorization: Bearer $TOKEN" \
-d '{"tenantId":"'"$TENANT_ID"'"}'A successful call answers {"data":{"freeze":null}}. A refused one answers with the error's status and
{"error":{"code":"...","message":"..."}}.
Errors
| Code | When |
|---|---|
INVALID_CONFIG | At construction: a duplicate plugin id, an endpoint whose action is not in actions, a reserved action name (iam:, tenant/, *, ?, whitespace), or an endpoint without POST, a unique path, validate, or handler. |
NOT_FOUND | Endpoint not found: no plugin with that pluginId or no endpoint at that path. This extension also throws it from end when no freeze is active. |
INVALID_INPUT | Thrown by validate, or by the server when the body's tenantId differs from the request's (Plugin input tenant does not match request scope) or validate returns another tenant (Plugin validation changed tenant scope). |
ACCESS_DENIED | The caller holds no grant for the endpoint's action in the tenant. |
UNAUTHENTICATED | The credential is missing, expired, or revoked. |
CONFLICT | Thrown by start when a freeze is active, or by the database when two requests insert the same uniqueKey. |
CHANGE_FREEZE | Defined by this extension: a role, policy, or binding change in a frozen tenant. Your own codes can be any string; give them an HTTP status as the third IamError argument. |
Troubleshooting
Next steps
The complete IamPlugin contract, including resourceTypes and resolveContext.
Test your integrationThe in-memory fixture this guide's test uses, plus handler and end-to-end tests.
Events and auditThe event shape afterAudit receives and the other ways to consume events.
Call a plugin endpointUsing $request from the browser client.
Better IAM is created by Sean Filimon
Last updated
Usage-based billing
Meter API usage per organization, price it with a graduated rate card and a subscription plan, enforce budgets, close the month into invoices, and record payments from a payment processor webhook.
Write a database adapter
Implement the public IamStore contract on top of the RecordStore base class from @better-iam/core, prove it with the 23-case conformance suite, and run a Better IAM instance on it.