Hooks API
Providers · Assignee resolvers · Relations · Webhooks · Startup
This page lists everything a hooks file can register, with signatures, limits and the errors the host reports. For a walkthrough, read Connect your identity provider.
How to read the TypeScript signatures
A signature like principals(account: Account): MaybePromise<readonly PrincipalRef[]>
reads as: a method principals that takes an Account and returns a list of
PrincipalRef, either directly or as a Promise. readonly means the callee
must not change the value; ? after a field name means the field is optional.
principals(account: Account): MaybePromise<readonly PrincipalRef[]>;- a value or a Promise
- do not change it
- a list of principals
Import it from @identity-flow/sdk:
export function defineHooks( name: string, register: (context: HookRegistrationContext) => undefined,): ExportedHookDefinition {register: (context: HookRegistrationContext) => undefined,- must return nothing
- The hooks file must
export defaultthe definition; other exports are not read. Every file that matches the discovery patterns must have one, so keep helper modules out ofhooks/directories. nameis 1–64 lowercase letters or digits, with single hyphens between them. It is the<hook>segment of webhook URLs and must be unique across all hooks files of a host. It is not a provider ID.registerruns once, synchronously, when the host starts, and never during workflow replay. Returning a Promise stops the start withHooks registration in <file> cannot be asynchronous; returning any other value stops it withHooks registration in <file> must return undefined.- An invalid default export stops the start with
Invalid hooks definition in <file>; a repeated name withDuplicate hook "<name>" in <file>; already registered by <file>.
The host finds hooks files with the patterns **/*.hooks.?(m)[jt]s and
**/hooks/*.?(m)[jt]s, in the workflow directory tree unless the hooks block
of the host configuration sets cwd, path, include or exclude. Hooks are
loaded once per start.
register receives one object with six members:
export interface HookRegistrationContext { readonly identity: IdentityRegistrationContext; readonly assignees: AssigneeRegistrationContext; readonly authorization: AuthorizationRegistrationContext; /** Canonical command/envelope factories; identical to `changeCommandFactories`. */ readonly commands: ChangeCommandFactories; readonly http: HttpHookRegistrationContext; readonly lifecycle: LifecycleRegistrationContext;}| Member | Registers |
|---|---|
identity.provider(id, provider) | The provider that returns an account’s principals. |
assignees.resolver(providerId, resolver) | The resolver for assignee sources of one provider. |
authorization.principalRelation(name, adapter) | A relation that access entries can target. |
http.webhook(name, definition) | A signed webhook that turns requests into change envelopes. |
lifecycle.startup(name, start) | A callback that starts an adapter when the host starts. |
commands | The factories for change commands and envelopes. |
export interface Account { readonly id: AccountId; readonly origin: string; readonly subject: Subject;}
/** * An opaque provider-owned identity, membership, or relationship key. * * IdentityFlow scopes equality by `providerId` and never interprets `subject`. */export interface PrincipalRef { readonly providerId: ProviderId; readonly subject: Subject;}export interface IdentityProvider { principals(account: Account): MaybePromise<readonly PrincipalRef[]>;}- A provider ID is 1–128 UTF-16 code units of valid Unicode without control
characters.
__legacy_unscoped__is reserved. Each provider ID may be registered once per host; a second registration stops the start withDuplicate identity provider "<id>" in <file>; already registered by <file>. - An origin in
identity.originProvidersthat routes to a provider ID no hooks file registers stops the start withOrigin "<origin>" routes to missing provider "<id>". principals(account)returns the complete current set. A subject is 1–512 UTF-16 code units. IdentityFlow compares subjects without regard to letter case everywhere it decides access (group:Adminsequalsgroup:admins) and provider IDs exactly. Every returned principal must carry the provider’s own ID; otherwise the lookup counts as failed.- IdentityFlow calls it at sign-in, for reads when the stored answer is older
than
principalCacheTtl(default 15 minutes), and fresh for every command. A failed fresh lookup refuses the command; see the figure in When principals are looked up. principalsmust answer within 5 seconds; a slower answer counts as a failed lookup. The limit is not configurable.- The default hard limit is 7,000 principals; a lookup above it is refused
with
AUTHORIZATION_PRINCIPAL_LIMIT_EXCEEDED. IdentityFlow never truncates the set. It logs a warning above the default review threshold of 5,000 principals.
export interface AssigneeSourceRef<Context extends JSONValue = JSONValue> { readonly providerId: ProviderId; readonly sourceKey: string; readonly context: Context; readonly sourceVersion?: string;}export interface AssigneeResolution { readonly assignees: readonly AssigneeRef[]; readonly sourceVersion?: string;}
export interface AssigneeResolverInput<Context extends JSONValue = JSONValue> { readonly source: AssigneeSourceRef<Context>; readonly signal: AbortSignal;}
/** Re-resolves a complete assignee set from one snapshotted source reference. */export interface AssigneeResolver<Context extends JSONValue = JSONValue> { readonly context: JSONStandardSchema<Context>; resolve(input: AssigneeResolverInput<Context>): MaybePromise<AssigneeResolution>;}- One resolver per provider ID. A second one stops the start with
Duplicate assignee resolver "<id>" in <file>; already registered by <file>. contextis a Standard Schema, for example a Valibot schema. IdentityFlow validates the stored context with it before callingresolve.sourceKeyandsourceVersionare 1–512 characters.contextis JSON of at most 8,192 bytes and nesting depth 64.resolvereturns the complete new set: non-empty, at most 1,024 principals, all with the resolver’s provider ID. IdentityFlow removes duplicates and replaces the dialog’s assignees with it.resolvemust answer within 3 seconds; the limit is not configurable.- It runs only in the background, after a change event or an operator’s replay from Failed work, never during workflow replay. See Assignee sources.
export interface WorkflowInstanceRecipientAnchor { readonly kind: 'workflowInstanceRecipient'; readonly instanceId: WorkflowInstanceId; readonly recipient: Subject;}
export interface PrincipalRelationProjectionContext { readonly signal: AbortSignal;}
/** Projects opaque provider-scoped Principals for one Engine-owned Resource anchor. */export interface PrincipalRelationAdapter { project( anchor: WorkflowInstanceRecipientAnchor, context: PrincipalRelationProjectionContext, ): MaybePromiseLike<readonly PrincipalRef[]>;}nameis 1–128 characters, without leading or trailing whitespace or control characters, and not*. Each name may be registered once per host.projectmust answer within 3 seconds.projectis called for each recipient of an instance and returns up to 1,024 principals. It receives no database handle and no actor.- An access entry
principalRelation(name)matches an account that holds one of the projected principals. A relation that fails or is not registered denies access. See Access control.
export interface WebhookCallbackContext { readonly signal: AbortSignal;}
export interface HmacSha256WebhookAuth { readonly type: 'hmac-sha256'; /** Resolves the secret at runtime; literal secrets are not part of hook definitions. */ readonly secret: (context: WebhookCallbackContext) => MaybePromise<string>; readonly signatureHeader: string; readonly timestampHeader: string; readonly maxClockSkew: Duration;}
export interface HmacWebhookLimits { readonly bodyBytes?: number; readonly eventsPerRequest?: number;}
/** A POST-only webhook mapped to one or more canonical change envelopes. */export interface HmacWebhookDefinition<Input extends JSONValue = JSONValue> { readonly body: JSONStandardSchema<Input>; readonly auth: HmacSha256WebhookAuth; readonly limits?: HmacWebhookLimits; map( input: Input, context: WebhookCallbackContext, ): MaybePromise<ChangeEnvelope | readonly ChangeEnvelope[]>;}namefollows the same rule as the hook name: 1–64 lowercase letters or digits with single hyphens. It is the<webhook>URL segment and must be unique within its hooks file.signatureHeaderandtimestampHeaderare HTTP header names and must differ.secretis called for each request; return the shared secret as a non-empty string. Literal secrets do not belong in a hooks file.maxClockSkewis a duration such as'5 minutes'. Keep it short: with theeventId, it is what stops a captured request from being replayed. Senders must sign every attempt, including retries, with the current time.limits.bodyBytesandlimits.eventsPerRequestare positive integers. They can only lower the host limits of 1,000,000 bytes and 1,000 envelopes.mapreturns one envelope or an array. It may only emit commands for a provider or resolver registered by the same hooks file.
| Item | Value |
|---|---|
| Endpoint | POST /-hooks/<hook>/<webhook> |
| Timestamp | Unix seconds as a decimal integer, in the timestamp header; must be within maxClockSkew of the host clock |
| Signature | sha256=<64 lowercase hex> in the signature header |
| Signed bytes | identity-flow-webhook-v1, timestamp, hook name, webhook name and raw body, joined with \n |
| Body | JSON, validated against body |
| Rate limit | Per client address: burst of 8, then 1 request per second |
| Callbacks | secret, schema validation and map share 5 seconds per request; at most 8 requests per webhook run them at once |
Checks run in this order: rate limit, hook and webhook names, headers, body
size, signature and timestamp, JSON and schema, map, envelope count, storage.
| Status | Body | Cause |
|---|---|---|
| 202 | { "results": [ { "source", "eventId", "status" } ] } | Stored; status is accepted or duplicate per envelope. |
| 400 | { "error": "invalid_request_body" } | Not JSON, fails the schema, no envelope, invalid envelope, or a command for a provider the hooks file does not own. |
| 401 | { "error": "authentication_required" } | A header is missing. The response carries WWW-Authenticate: HMAC-SHA256. |
| 403 | { "error": "authentication_failed" } | Signature mismatch or stale timestamp. |
| 404 | { "error": "webhook_not_found" } | Unknown hook or webhook. |
| 409 | { "error": "change_event_conflict" } | A source and eventId pair was stored before with different content. Nothing in the request is stored. |
| 413 | { "error": "request_too_large" } | Body or envelope count over the limit. |
| 429 | { "error": "rate_limit_exceeded" } | Rate limit. Retry-After gives the seconds to wait. |
| 503 | { "error": "change_ingress_unavailable" } | secret, the schema or map threw or took longer than 5 seconds, or the host is busy or stopping. |
The signed bytes are drawn, and the signing steps shown as a runnable script, in Endpoint and signature. How a stored change event is applied afterwards is drawn in After the 202.
export type LifecycleDisposer = () => MaybePromise<void>;
export interface LifecycleStartupContext { readonly signal: AbortSignal; submit(envelope: ChangeEnvelope): Promise<void>;}
export type LifecycleStartup = ( context: LifecycleStartupContext,) => MaybePromise<void | LifecycleDisposer>;
export interface LifecycleRegistrationContext { startup(name: string, start: LifecycleStartup): void;}namefollows the hook name rule and must be unique within its hooks file.startruns when the host starts, after change storage is ready. It must settle within 10 seconds and return nothing or a disposer function. Otherwise the start stops with one of:Hook lifecycle startup "<hook>/<name>" failed(it threw),Hook lifecycle startup "<hook>/<name>" timed out after 10000ms, orHook lifecycle startup "<hook>/<name>" must return undefined or a disposer function.submit(envelope)stores an envelope the same way the webhook does.- Disposers run when the host stops, in reverse order of registration.
signalis aborted when the host stops.
hooks.commands is the same object as the exported changeCommandFactories.
export interface IdentityInvalidateTarget { readonly providerId: ProviderId; readonly account: Pick<Account, 'origin' | 'subject'>;}
export interface IdentityInvalidateCommand extends IdentityInvalidateTarget { readonly type: 'identity.invalidate'; readonly version: 1;}
export interface AssigneesSourceChangedTarget { readonly providerId: ProviderId; readonly sourceKey: string; readonly sourceVersion?: string;}
export interface AssigneesSourceChangedCommand extends AssigneesSourceChangedTarget { readonly type: 'assignees.sourceChanged'; readonly version: 1;}
export type ChangeCommand = IdentityInvalidateCommand | AssigneesSourceChangedCommand;
/** One transport-neutral, idempotently admitted change command. */export interface ChangeEnvelope<Command extends ChangeCommand = ChangeCommand> { readonly source: string; readonly eventId: string; readonly occurredAt?: string; readonly command: Command;}
/** The only factories customer hooks can use to construct inbound commands. */export interface ChangeCommandFactories { readonly identity: { invalidate(target: IdentityInvalidateTarget): IdentityInvalidateCommand }; readonly assignees: { sourceChanged(target: AssigneesSourceChangedTarget): AssigneesSourceChangedCommand; }; envelope<Command extends ChangeCommand>(input: ChangeEnvelope<Command>): ChangeEnvelope<Command>;}identity.invalidatediscards the stored principals of one login, so the next lookup asks the provider.assignees.sourceChangedrefreshes every pending dialog whose assignee source has this provider ID and source key.envelopevalidates the envelope and returns it; it does not submit anything.sourceandeventIdare 1–512 characters and together identify the change;occurredAtis an optional ISO timestamp.origin,subjectandsourceKeyare 1–512 characters.- IdentityFlow applies a stored envelope in the background. A dialog refresh is tried up to 8 times, with waits from 1 second doubling to at most 5 minutes, before it is listed under Failed work.
Import it from @identity-flow/testing. It runs a hooks definition in memory,
under plain Vitest, without a database or the Workflow Test distribution.
export interface HookTestHost { load(definition: ExportedHookDefinition): void; readonly commands: ChangeCommandFactories; readonly identity: { readonly calls: readonly IdentityProviderCall[]; resolve(providerId: ProviderId, account: Account): Promise<readonly PrincipalRef[]>; }; readonly assignees: { readonly calls: readonly AssigneeResolverCall[]; resolve(providerId: ProviderId, input: AssigneeResolverInput): Promise<AssigneeResolution>; }; readonly authorization: { readonly calls: readonly PrincipalRelationCall[]; project( name: string, anchor: WorkflowInstanceRecipientAnchor, signal?: AbortSignal, ): Promise<readonly PrincipalRef[]>; }; readonly http: { readonly calls: readonly HttpWebhookCall[]; readonly webhooks: readonly RegisteredWebhook[]; map( hookName: string, name: string, input: JSONValue, ): Promise<ChangeEnvelope | readonly ChangeEnvelope[]>; }; readonly lifecycle: { readonly submissions: readonly ChangeEnvelope[]; start(signal?: AbortSignal): Promise<void>; stop(): Promise<void>; };}load(definition)runsregister. Calling it with more than one definition registers them side by side; duplicates throw.identity.resolve,assignees.resolveandauthorization.projectcall the registered callback and record the call incalls. The resolver’s context, the resolution, relation results and envelopes are validated as the host validates them;identity.resolvereturns the provider’s principals unchecked.http.map(hookName, name, input)validates the body against the schema and runsmap. It does not test HTTP, headers or the signature.http.webhookslists the registered webhooks.lifecycle.start(signal?)runs every startup callback; submitted envelopes collect inlifecycle.submissions.lifecycle.stop()runs the disposers in reverse order.
The package also exports createFakeIdentityProvider({ principals }) and
createFakeAssigneeResolver({ context, resolution }), which return a provider or
resolver that records its calls in calls. principals is a fixed list or a
function; resolution is a fixed result or a function.
A complete test is in Test the hooks file; a smaller one is in the identity-hook recipe.