Connect your identity provider
This page connects IdentityFlow to the system that knows your people. Afterwards, a person who signs in gets principals from your own code, and dialogs and access entries can name them.
Before you start: a running IdentityFlow with an OIDC login, and a workflow project, such as the starter. For a small, complete hooks file with its test, see the recipe Map a login to principals first.
The main path has four steps:
- Write the hooks file with your provider.
- Let the host find the file.
- Route the login to your provider.
- Test the hooks file.
Change events from your directory and switching between a test directory and a live one are optional; add them when you need them.
The examples use the provider ID acme, a hook named acme-directory and a
login origin named company-sso. Replace all three with names for your own
system. The terms account, principal and provider are explained on
Identity and access and in
Concepts.
A hooks file is a TypeScript module that default-exports one
defineHooks(name, register) definition. The host reads only export default;
a file that matches the discovery patterns without a valid default export stops
the start with Invalid hooks definition in <file>.
nameidentifies this hooks file. It becomes the<hook>segment of webhook URLs, so it must be 1–64 lowercase letters or digits with single hyphens between them, for exampleacme-directory. It is not the provider ID.register(hooks)runs once when the host starts, never during workflow replay. It must be synchronous and return nothing. The callbacks you register inside it may beasync(they return a Promise: a value that arrives later, which the host waits for) and may read your directory.
Inside register, hooks.identity.provider(id, provider) names your provider
and says how to find the principals of a signed-in account:
hooks.identity.provider(PROVIDER_ID, { async principals(account) { const subjects = await directory.principalsFor(account); // An unknown login is not an error: it simply holds no principal from this provider. return (subjects ?? []).map((subject) => ({ providerId: PROVIDER_ID, subject })); },});principals(account) receives the verified Account: its IdentityFlow id,
the login origin, and the OIDC subject. It returns the complete current set
of principals for that account, each as { providerId, subject }:
- Return the whole set every time. It replaces the previous answer; it is not a patch.
- Every principal must carry this provider’s ID. A principal for another provider makes the lookup fail.
- A subject is your own vocabulary, 1–512 characters. IdentityFlow never
parses a subject, but it compares subjects without regard to letter case:
group:Adminsandgroup:adminsare the same principal. Provider IDs are compared exactly. If your system distinguishes subjects only by case, encode them so that case does not matter, for example in lower-case hex. - An unknown login is not an error. Return an empty list, and the account holds no principal from this provider.
IdentityFlow asks the provider when someone signs in, again for reads when its
stored answer is older than principalCacheTtl (15 minutes by default), and
always, fresh, when someone acts: starts a workflow, answers a dialog, or runs
an operator command. If that fresh lookup fails, or takes longer than 5
seconds, the action is refused; see
When principals are looked up.
A changed hooks file takes effect when the host restarts. Hooks run outside workflow replay: events already recorded keep what they recorded, and running instances are not invalidated. Pending dialogs are checked against the new principals the next time someone tries to answer them.
By default the host searches the same directory tree as for Workflow
Definitions: the directory that contains the configuration file, unless
workflows.cwd or workflows.path say otherwise. It loads every file that
matches one of these patterns:
**/*.hooks.?(m)[jt]s, for examplehooks/acme-directory.hooks.ts;**/hooks/*.?(m)[jt]s, any.ts,.mts,.jsor.mjsfile directly inside ahooks/directory.
Every file directly inside a hooks/ directory is loaded as a hooks file, so
keep helper modules elsewhere. *.test.ts and *.spec.ts files are skipped.
Hooks files are never deployed as workflows. To look elsewhere, set the hooks
block in the host configuration. It accepts cwd, path, include and
exclude. Setting include replaces both default patterns, and include is a
HOCON keyword, so the key must be quoted:
hooks { # Defaults: the workflow directory tree, files named *.hooks.ts or placed in hooks/. path = ["."] "include" = ["hooks/*.hooks.ts"]}"include" = ["hooks/*.hooks.ts"]- quoted: a HOCON keyword
- replaces both defaults
Each file is checked when the host starts. These problems stop the start:
| Problem | Start-up error |
|---|---|
The default export is not a valid defineHooks definition | Invalid hooks definition in <file> |
| Two files use the same hook name | Duplicate hook "<name>" in <file>; already registered by <file> |
| Two files register the same provider ID | Duplicate identity provider "<id>" in <file>; already registered by <file> |
register returns a Promise | Hooks registration in <file> cannot be asynchronous |
| A webhook uses the same header for signature and timestamp | Webhook "<name>" in <file> must use distinct signature and timestamp headers |
The auth.oidc block names an origin: a label you choose for this login
configuration. It is not the issuer URL. identity.originProviders maps that
label to the provider ID your hooks file registers:
auth { oidc { issuer = ${IDENTITY_FLOW_OIDC_ISSUER} clientId = identity-flow clientSecret = ${IDENTITY_FLOW_OIDC_CLIENT_SECRET} redirectUri = "https://flow.example.com/auth/callback" scopes = [openid, profile, email] authorizationTtl = "10 minutes" sessionTtl = "8 hours"
# A name you choose for this login. It is not the issuer URL. origin = company-sso }}
identity { originProviders { # Logins through company-sso get their principals from the provider "acme". company-sso = acme }}Each entry in originProviders maps one origin to one provider ID:
company-sso = acme- the auth.oidc.origin
- ID your hooks file registers
The host configuration is written in HOCON, a JSON superset that allows
comments, unquoted strings and ${ENV_VAR} references. The other auth.oidc
keys are described in Configuration.
An origin is a routing input, not a substitute for the provider ID. The host refuses an origin that maps to nothing rather than guessing, and it checks both ends when it starts:
identity.originProviders must contain auth.oidc.origin "company-sso"means the configured origin has no entry inoriginProviders.Origin "company-sso" routes to missing provider "acme"means no loaded hooks file registers the provider ID the origin points at. Check the file name against the discovery patterns and the ID passed tohooks.identity.provider().
Start the host once with your configuration before you try a browser login. A missing mapping fails at startup with one of the messages above; a mapping to the wrong provider only shows up later, as an account that can see nothing. Troubleshooting lists further symptoms.
To let IdentityFlow act on a membership change before the next sign-in, your
directory reports the change by calling a webhook that the hooks file registers
with hooks.http.webhook(name, definition). IdentityFlow
turns each call into a change event: a small command identified by source and
eventId.
You need change events when dialogs use
assignee sources, or when a stored
login must be looked up again before principalCacheTtl ends. The webhook
body, endpoint, signature protocol and processing are in the collapsed section
below.
Webhook body, endpoint, signature and processing
First, the JSON body your directory sends, as a Valibot schema (Valibot is the
validation library the SDK re-exports as @identity-flow/sdk/valibot):
const EventId = v.pipe(v.string(), v.minLength(1), v.maxLength(512));
/** The JSON body your directory posts when something changes. */const DirectoryChange = v.variant('type', [ v.strictObject({ type: v.literal('deputies-changed'), eventId: EventId, personId: v.string(), version: v.string(), }), v.strictObject({ type: v.literal('login-changed'), eventId: EventId, origin: v.string(), subject: v.string(), }),]);Then the webhook itself:
hooks.http.webhook('directory-changed', { body: DirectoryChange, auth: { type: 'hmac-sha256', secret: () => requiredEnv('ACME_WEBHOOK_SECRET'), signatureHeader: 'x-acme-signature', timestampHeader: 'x-acme-timestamp', maxClockSkew: '5 minutes', }, map(change) { const command = change.type === 'deputies-changed' ? hooks.commands.assignees.sourceChanged({ providerId: PROVIDER_ID, sourceKey: `deputies-of:${change.personId}`, sourceVersion: change.version, }) : hooks.commands.identity.invalidate({ providerId: PROVIDER_ID, account: { origin: change.origin, subject: change.subject }, }); const envelope = { source: 'acme-directory', eventId: change.eventId, command }; return hooks.commands.envelope(envelope); },});map turns the validated body into one change envelope, or an array of them.
Build envelopes only with hooks.commands; there are two commands:
hooks.commands.assignees.sourceChanged({ providerId, sourceKey, sourceVersion? })refreshes every pending dialog whose assignee source has this provider and source key. See Assignee sources.hooks.commands.identity.invalidate({ providerId, account: { origin, subject } })discards the stored principals of that login, so the next lookup asks your provider again.
A webhook may only send commands for a provider or resolver that the same hooks
file registers. hooks.commands.envelope({ source, eventId, occurredAt?, command })
checks the envelope; occurredAt is an optional ISO timestamp.
The host serves the webhook at:
POST https://<your IdentityFlow host>/-hooks/<hook>/<webhook>
For the example that is /-hooks/acme-directory/directory-changed. Your
directory must send two headers, named by the definition:
- the timestamp header (
x-acme-timestamphere): the current time in Unix seconds, as a decimal number; - the signature header (
x-acme-signaturehere):sha256=followed by 64 lowercase hex characters.
The signature is HMAC-SHA256 with the shared secret, computed over this exact
byte sequence, with a newline between the parts: identity-flow-webhook-v1,
the timestamp, the hook name, the webhook name, and the raw request body.
The request is refused when the timestamp is further from the host’s clock
than maxClockSkew. The host asks the secret callback for the secret on every
request, so keep the secret in the environment or a secret store, never in the
hooks file. This script signs and sends one event with openssl and curl:
#!/bin/sh# Signs one change event and posts it to IdentityFlow.# Usage: ACME_WEBHOOK_SECRET=… ./send-change-event.sh https://flow.example.com '<json body>'set -eu
base_url=$1body=$2hook=acme-directorywebhook=directory-changedtimestamp=$(date +%s)
signature=$( printf 'identity-flow-webhook-v1\n%s\n%s\n%s\n%s' "$timestamp" "$hook" "$webhook" "$body" | openssl dgst -sha256 -hmac "$ACME_WEBHOOK_SECRET" -r | cut -d ' ' -f 1)
curl --silent --show-error --request POST "$base_url/-hooks/$hook/$webhook" \ --header 'content-type: application/json' \ --header "x-acme-signature: sha256=$signature" \ --header "x-acme-timestamp: $timestamp" \ --data-binary "$body"Use this script to test the endpoint. In production, compute the signature inside your directory’s own code, so the secret never appears in a process list or shell history.
IdentityFlow checks a call in this order and stops at the first failure:
- The caller’s address is within the rate limit: a burst of 8 requests, then 1 per second.
- The URL names a registered hook and webhook.
- Both headers are present.
- The body is at most 1,000,000 bytes, or the lower
limits.bodyBytesof the definition. - The signature matches and the timestamp is fresh.
- The body is JSON and matches the
bodyschema. mapreturns at least one envelope, and no more than 1,000, or the lowerlimits.eventsPerRequest.- Every envelope is stored. An envelope whose
sourceandeventIdwere already stored with the same content counts as a duplicate; with different content, the whole request is refused.
The secret lookup, schema validation and map share a budget of 5 seconds per
request. Each webhook handles at most 8 requests at the same time; further
requests get 503.
| Status | error in the body | Meaning | What the sender does |
|---|---|---|---|
| 202 | none | Stored. The body is { results: [{ source, eventId, status }] }, with status accepted or duplicate for each envelope. | Nothing. |
| 400 | invalid_request_body | Not JSON, fails the schema, maps to no envelope, or names a provider this hooks file does not register. | Fix the body or the hooks file. |
| 401 | authentication_required | A header is missing. | Send both headers. |
| 403 | authentication_failed | Wrong signature, or a stale timestamp. | Check the secret, the signed bytes and the clock; sign each attempt afresh. |
| 404 | webhook_not_found | No such hook or webhook. | Check the URL. |
| 409 | change_event_conflict | An eventId was reused for different content. | Use a new eventId for new content. |
| 413 | request_too_large | Too many bytes or too many envelopes. | Split the request. |
| 429 | rate_limit_exceeded | Rate limit reached. Retry-After says how many seconds to wait. | Retry later with the same body and eventId, signed again with the current time. |
| 503 | change_ingress_unavailable | The secret callback, the schema or map threw or timed out, or the host is busy. | Retry later with the same body and eventId, signed again with the current time. |
Because a repeated eventId with the same content is a harmless duplicate, your
directory can retry freely, as long as each attempt is signed with the current
time. Give every new change a new eventId.
Sign every attempt with a fresh timestamp; a stored, already signed request is
refused with 403 once it is older than maxClockSkew. Keep maxClockSkew
short, a few minutes at most: together with the eventId, it is what stops a
captured request from being replayed later.
maxClockSkew: '5 minutes',- with eventId, stops replays
The 202 means the change event is stored in PostgreSQL. IdentityFlow applies it
afterwards, in the background, and never during workflow replay. For a source
change, that means refreshing each affected dialog. If a refresh fails, for
example because your directory is unreachable, IdentityFlow tries up to 8
times, waiting 1 second before the first retry and doubling the wait up to 5
minutes. After the last attempt the refresh appears under Failed work for that
dialog’s instance, where an account with operator access can replay it; see
Operate running workflows. The changes block of the
host configuration adjusts attempts and polling.
A failed identity.invalidate is retried the same way. After the last attempt
0.3.0 shows it nowhere. Reads keep using the stored principals until they are
older than principalCacheTtl, then ask the provider again; commands always
ask the provider.
If your directory cannot call a webhook, hooks.lifecycle.startup(name, start)
lets the hooks file start its own adapter when the host starts, for example one
that polls or subscribes, and submit the same envelopes. See
Hooks API.
Switch between a test directory and a live one
Section titled “Switch between a test directory and a live one”Keep everything that talks to your directory behind one small interface, with two implementations: a fixed test directory for local runs and tests, and the live one. The hooks call only the interface, and the environment decides which implementation the host gets:
/** Everything the hooks need from your directory. */export interface Directory { /** Principal subjects for one login, or `null` when the directory does not know it. */ principalsFor(account: Account): Promise<readonly string[] | null>; /** The people currently standing in for one person, and the directory's version of that list. */ deputiesOf( personId: string, signal: AbortSignal, ): Promise<{ readonly personIds: readonly string[]; readonly version: string }>;}
/** A fixed in-memory directory for tests and local runs. */export function createTestDirectory( people: Readonly<Record<string, readonly string[]>>,): Directory { return { principalsFor: (account) => Promise.resolve(people[account.subject] ?? null), deputiesOf: () => Promise.resolve({ personIds: [], version: 'test' }), };}
/** The live directory, reached over its HTTP API. `baseUrl` must end with a slash. */export function createHttpDirectory(baseUrl: string, token: string): Directory { async function get<T>(path: string, signal?: AbortSignal): Promise<T | null> { const response = await fetch(new URL(path, baseUrl), { headers: { authorization: `Bearer ${token}` }, signal, }); if (response.status === 404) return null; if (!response.ok) throw new Error(`Directory answered ${response.status} for ${path}`); return (await response.json()) as T; }
return { async principalsFor(account) { const login = await get<{ subjects: string[] }>( `logins/${encodeURIComponent(account.subject)}`, ); return login?.subjects ?? null; }, async deputiesOf(personId, signal) { const deputies = await get<{ personIds: string[]; version: string }>( `people/${encodeURIComponent(personId)}/deputies`, signal, ); if (!deputies) throw new Error(`Directory does not know person ${personId}`); return deputies; }, };}
/** Chooses the directory when the host loads this file. Anything but `test` or `live` stops the start. */function directoryFromEnvironment(): Directory { const mode = process.env.ACME_DIRECTORY; if (mode === 'test') return createTestDirectory({ 'login-alex': ['person:p-2041'] }); if (mode === 'live') { return createHttpDirectory( requiredEnv('ACME_DIRECTORY_URL'), requiredEnv('ACME_DIRECTORY_TOKEN'), ); } throw new Error(`ACME_DIRECTORY must be "test" or "live", not ${JSON.stringify(mode)}`);}An unset or misspelled ACME_DIRECTORY stops the host at startup instead of
silently picking one. Credentials come from the environment, as with every
secret in the host configuration.
Before you point the hooks at a live directory:
- run the hooks tests against the test directory;
- get credentials for a non-production directory from its administrator;
- start the host with
ACME_DIRECTORY=liveand sign in with one test account; - check My access for the principals the directory reported.
Log only what you need to diagnose a problem: provider ID, source version, event ID, the resulting step state and timing. Keep credentials and full directory payloads out of logs and out of change envelopes.
createHookTestHost() from @identity-flow/testing runs your registrations in
memory. It needs neither the Workflow Test distribution nor a database, so hook
tests run under plain Vitest. This test covers the provider, the resolver, the
relation, the webhook mapping, the live adapter with a stubbed fetch, and the
refusal to start without a directory choice:
import type { Account } from '@identity-flow/sdk';
import { createHookTestHost } from '@identity-flow/testing';import { afterEach, expect, test, vi } from 'vitest';
import hooks, { type Directory, createDirectoryHooks, createHttpDirectory, createTestDirectory,} from './w2-directory.hooks';
const alex: Account = { id: 'AZlKqAAAeACAAAAAAAAAAQ' as Account['id'], origin: 'company-sso', subject: 'login-alex',};
afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals();});
test('maps a login to the principals the directory reports', async () => { const host = createHookTestHost(); host.load( createDirectoryHooks(() => createTestDirectory({ 'login-alex': ['person:p-2041', 'manager-of:p-2042'] }), ), );
await expect(host.identity.resolve('acme', alex)).resolves.toEqual([ { providerId: 'acme', subject: 'person:p-2041' }, { providerId: 'acme', subject: 'manager-of:p-2042' }, ]); await expect( host.identity.resolve('acme', { ...alex, subject: 'login-unknown' }), ).resolves.toEqual([]);});
test('resolves the complete assignee set of a deputies source', async () => { const directory: Directory = { principalsFor: () => Promise.resolve(null), deputiesOf: (personId) => Promise.resolve({ personIds: personId === 'p-1001' ? ['p-2050'] : [], version: '17' }), }; const host = createHookTestHost(); host.load(createDirectoryHooks(() => directory));
await expect( host.assignees.resolve('acme', { source: { providerId: 'acme', sourceKey: 'deputies-of:p-1001', context: { employeeId: 'p-2041', managerId: 'p-1001' }, }, signal: new AbortController().signal, }), ).resolves.toEqual({ assignees: [ { providerId: 'acme', subject: 'manager-of:p-2041' }, { providerId: 'acme', subject: 'person:p-2050' }, ], sourceVersion: '17', });});
test('projects the manager of an instance recipient', async () => { const host = createHookTestHost(); host.load(createDirectoryHooks(() => createTestDirectory({})));
await expect( host.authorization.project('manager-of-recipient', { kind: 'workflowInstanceRecipient', instanceId: 'AZlKqAAAeACAAAAAAAAAAg' as never, recipient: 'p-2041', }), ).resolves.toEqual([{ providerId: 'acme', subject: 'manager-of:p-2041' }]);});
test('maps each directory change to one change envelope', async () => { const host = createHookTestHost(); host.load(createDirectoryHooks(() => createTestDirectory({})));
await expect( host.http.map('acme-directory', 'directory-changed', { type: 'deputies-changed', eventId: 'evt-1001', personId: 'p-1001', version: '18', }), ).resolves.toEqual({ source: 'acme-directory', eventId: 'evt-1001', command: { type: 'assignees.sourceChanged', version: 1, providerId: 'acme', sourceKey: 'deputies-of:p-1001', sourceVersion: '18', }, }); await expect( host.http.map('acme-directory', 'directory-changed', { type: 'login-changed', eventId: 'evt-1002', origin: 'company-sso', subject: 'login-alex', }), ).resolves.toMatchObject({ eventId: 'evt-1002', command: { type: 'identity.invalidate', account: { origin: 'company-sso', subject: 'login-alex' }, }, }); await expect( host.http.map('acme-directory', 'directory-changed', { type: 'unknown', eventId: 'evt-1003' }), ).rejects.toThrow();});
test('reads the live directory over HTTP', async () => { const fetch = vi.fn(() => Promise.resolve(Response.json({ subjects: ['person:p-2041'] }, { status: 200 })), ); vi.stubGlobal('fetch', fetch); const host = createHookTestHost(); host.load( createDirectoryHooks(() => createHttpDirectory('https://directory.example.com/api/', 'token')), );
await expect(host.identity.resolve('acme', alex)).resolves.toEqual([ { providerId: 'acme', subject: 'person:p-2041' }, ]); expect(fetch).toHaveBeenCalledWith( new URL('https://directory.example.com/api/logins/login-alex'), expect.objectContaining({ headers: { authorization: 'Bearer token' } }), );});
test('refuses to load without an explicit directory choice', () => { vi.stubEnv('ACME_DIRECTORY', ''); expect(() => createHookTestHost().load(hooks)).toThrow( 'ACME_DIRECTORY must be "test" or "live", not ""', );
vi.stubEnv('ACME_DIRECTORY', 'test'); expect(() => createHookTestHost().load(hooks)).not.toThrow();});The test host checks names, webhook definitions, resolver answers, relation
answers and envelopes the way the host does. It returns a provider’s principals
as the provider returned them, and it does not test HTTP, headers or the HMAC
signature: host.http.map starts after those checks. It also does not check
that map only emits commands for providers this file registers, or that the
two header names differ. Name hook tests
*.test.ts and make sure your Vitest include pattern selects them;
Test workflows describes the project setup.
The complete hooks file
import process from 'node:process';
import * as v from '@identity-flow/sdk/valibot';import { type Account, type PrincipalRef, defineHooks } from '@identity-flow/sdk';
const PROVIDER_ID = 'acme';
// docs:adapter:start/** Everything the hooks need from your directory. */export interface Directory { /** Principal subjects for one login, or `null` when the directory does not know it. */ principalsFor(account: Account): Promise<readonly string[] | null>; /** The people currently standing in for one person, and the directory's version of that list. */ deputiesOf( personId: string, signal: AbortSignal, ): Promise<{ readonly personIds: readonly string[]; readonly version: string }>;}
/** A fixed in-memory directory for tests and local runs. */export function createTestDirectory( people: Readonly<Record<string, readonly string[]>>,): Directory { return { principalsFor: (account) => Promise.resolve(people[account.subject] ?? null), deputiesOf: () => Promise.resolve({ personIds: [], version: 'test' }), };}
/** The live directory, reached over its HTTP API. `baseUrl` must end with a slash. */export function createHttpDirectory(baseUrl: string, token: string): Directory { async function get<T>(path: string, signal?: AbortSignal): Promise<T | null> { const response = await fetch(new URL(path, baseUrl), { headers: { authorization: `Bearer ${token}` }, signal, }); if (response.status === 404) return null; if (!response.ok) throw new Error(`Directory answered ${response.status} for ${path}`); return (await response.json()) as T; }
return { async principalsFor(account) { const login = await get<{ subjects: string[] }>( `logins/${encodeURIComponent(account.subject)}`, ); return login?.subjects ?? null; }, async deputiesOf(personId, signal) { const deputies = await get<{ personIds: string[]; version: string }>( `people/${encodeURIComponent(personId)}/deputies`, signal, ); if (!deputies) throw new Error(`Directory does not know person ${personId}`); return deputies; }, };}
/** Chooses the directory when the host loads this file. Anything but `test` or `live` stops the start. */function directoryFromEnvironment(): Directory { const mode = process.env.ACME_DIRECTORY; if (mode === 'test') return createTestDirectory({ 'login-alex': ['person:p-2041'] }); if (mode === 'live') { return createHttpDirectory( requiredEnv('ACME_DIRECTORY_URL'), requiredEnv('ACME_DIRECTORY_TOKEN'), ); } throw new Error(`ACME_DIRECTORY must be "test" or "live", not ${JSON.stringify(mode)}`);}// docs:adapter:end
const person = (personId: string): PrincipalRef => ({ providerId: PROVIDER_ID, subject: `person:${personId}`,});const managerOf = (personId: string): PrincipalRef => ({ providerId: PROVIDER_ID, subject: `manager-of:${personId}`,});
// docs:webhook-body:startconst EventId = v.pipe(v.string(), v.minLength(1), v.maxLength(512));
/** The JSON body your directory posts when something changes. */const DirectoryChange = v.variant('type', [ v.strictObject({ type: v.literal('deputies-changed'), eventId: EventId, personId: v.string(), version: v.string(), }), v.strictObject({ type: v.literal('login-changed'), eventId: EventId, origin: v.string(), subject: v.string(), }),]);// docs:webhook-body:end
export function createDirectoryHooks(selectDirectory: () => Directory) { return defineHooks('acme-directory', (hooks) => { const directory = selectDirectory();
// docs:provider:start hooks.identity.provider(PROVIDER_ID, { async principals(account) { const subjects = await directory.principalsFor(account); // An unknown login is not an error: it simply holds no principal from this provider. return (subjects ?? []).map((subject) => ({ providerId: PROVIDER_ID, subject })); }, }); // docs:provider:end
// docs:resolver:start hooks.assignees.resolver(PROVIDER_ID, { context: v.strictObject({ employeeId: v.string(), managerId: v.string() }), async resolve({ source, signal }) { const { employeeId, managerId } = source.context; const { personIds, version } = await directory.deputiesOf(managerId, signal); // The answer replaces the whole assignee set, so it names the manager as well. const assignees = [managerOf(employeeId), ...personIds.map(person)]; return { assignees, sourceVersion: version }; }, }); // docs:resolver:end
// docs:relation:start hooks.authorization.principalRelation('manager-of-recipient', { project: (anchor) => [managerOf(anchor.recipient)], }); // docs:relation:end
// docs:webhook:start hooks.http.webhook('directory-changed', { body: DirectoryChange, auth: { type: 'hmac-sha256', secret: () => requiredEnv('ACME_WEBHOOK_SECRET'), signatureHeader: 'x-acme-signature', timestampHeader: 'x-acme-timestamp', maxClockSkew: '5 minutes', }, map(change) { const command = change.type === 'deputies-changed' ? hooks.commands.assignees.sourceChanged({ providerId: PROVIDER_ID, sourceKey: `deputies-of:${change.personId}`, sourceVersion: change.version, }) : hooks.commands.identity.invalidate({ providerId: PROVIDER_ID, account: { origin: change.origin, subject: change.subject }, }); const envelope = { source: 'acme-directory', eventId: change.eventId, command }; return hooks.commands.envelope(envelope); }, }); // docs:webhook:end });}
export default createDirectoryHooks(directoryFromEnvironment);
function requiredEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is required`); return value;}The full registration surface, with every limit, is on Hooks API.