Skip to content
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1103,13 +1103,13 @@ Authentication events carry the spec payload `{ type, status, user_id, email, ip

The full catalog (including names the emulator never emits, like `authentication.passkey_*` and `vault.*`) lives in `src/workos/generated/events.ts`, generated from the [`@workos/openapi-spec`](https://www.npmjs.com/package/@workos/openapi-spec) package.

All events are also queryable at `GET /events` (filter with `?events[]=user.created`).
All events are also queryable at `GET /events` (filter with `?events[]=user.created`, or repeated `?events=`, which is what the Go SDK sends).

### Caveats

- Delivery is fire-and-forget with a 5-second timeout and no retries — poll your receiver in tests rather than asserting immediately.
- Resources defined in a seed file record events (visible at `GET /events`) but are not delivered to webhook endpoints from the same seed file — endpoints are registered last, mirroring real WorkOS, where pre-existing data never replays. Register endpoints via the API if you want deliveries for setup data.
- `dsync.group.user_added` / `dsync.group.user_removed` are catalogued but never emitted: the emulator has no directory group membership mutation surface.
- Seeding a directory user into a group emits `dsync.group.user_added`. Removing that membership emits `dsync.group.user_removed`. There is still no HTTP route to mutate a directory; production connects one in the dashboard.

## JWT Templates (custom claims)

Expand Down
56 changes: 28 additions & 28 deletions SUPPORTED.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion scripts/gen-supported-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export const FEATURES: FeatureDef[] = [
tags: ['directories', 'directory-users', 'directory-groups'],
seedKeys: ['directories'],
notes:
'Read-only over HTTP, as production is: a directory is connected in the dashboard, so there is no POST route to emulate. Seed `directories` to get one, with its groups, its users, and group-to-role mappings resolved onto the organization membership (which then reports `directory_managed`); seeding emits `dsync.activated` (for a `linked` directory), `dsync.group.created` and `dsync.user.created` (queryable at `GET /events`, not delivered — seeded endpoints register after), and `DELETE /directories/:id` emits a delivered `dsync.deleted`. `dsync.group.user_added` / `user_removed` are never emitted — there is no group membership mutation surface.',
'Read-only over HTTP, as production is: a directory is connected in the dashboard, so there is no POST route to emulate. Seed `directories` to get one, with its groups, its users, and group-to-role mappings resolved onto the organization membership (which then reports `directory_managed`); seeding emits `dsync.activated` (for a `linked` directory), `dsync.group.created`, `dsync.user.created`, and `dsync.group.user_added` for each seeded membership (queryable at `GET /events`, not delivered — seeded endpoints register after), and `DELETE /directories/:id` emits a delivered `dsync.deleted`. Removing a user from a group emits `dsync.group.user_removed`.',
},
{
name: 'Multi-Factor Auth',
Expand Down
8 changes: 8 additions & 0 deletions src/workos/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,14 @@ export interface WorkOSEvent extends Entity {
object: 'event';
event: string;
data: Record<string, unknown>;
/**
* The organization the event occurred within, which is what the list endpoint's
* `organization_id` filter selects on. Internal — the spec's Event has no such field — and
* kept apart from `data`, which cannot stand in for it: `group.member_added` carries only
* ids, and `organization.created` carries the organization itself. Resolved when the event
* is recorded, so it outlives the rows it refers to.
*/
organization_id: string | null;
environment_id: string | null;
/**
* The spec's per-event `context` envelope. Only flag events populate it so far — the
Expand Down
4 changes: 4 additions & 0 deletions src/workos/event-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { WorkOSEventName } from './constants.js';
export interface EventPayload {
event: WorkOSEventName | string;
data: Record<string, unknown>;
/** The organization the event occurred within, for payloads whose `data.organization_id` does not say. */
organization_id?: string | null;
environment_id?: string;
/** Spec `context` envelope, delivered alongside `data` to webhook endpoints. */
context?: Record<string, unknown>;
Expand Down Expand Up @@ -67,11 +69,13 @@ export class EventBus {

emit(payload: EventPayload): void {
const ws = getWorkOSStore(this.store);
const dataOrganizationId = payload.data.organization_id;

const event = ws.events.insert({
object: 'event',
event: payload.event,
data: payload.data,
organization_id: payload.organization_id ?? (typeof dataOrganizationId === 'string' ? dataOrganizationId : null),
environment_id: payload.environment_id ?? null,
...(payload.context ? { context: payload.context } : {}),
});
Expand Down
2 changes: 1 addition & 1 deletion src/workos/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1296,7 +1296,7 @@ export function formatApiKeyRecord(k: WorkOSApiKey): Record<string, unknown> {
};
}

const EVENT_EXCLUDE = new Set([...INTERNAL_FIELDS, 'updated_at']);
const EVENT_EXCLUDE = new Set([...INTERNAL_FIELDS, 'updated_at', 'organization_id']);

export function formatEvent(e: WorkOSEvent): Record<string, unknown> {
return formatEntity(e, { exclude: EVENT_EXCLUDE });
Expand Down
47 changes: 40 additions & 7 deletions src/workos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ import {
import type {
WorkOSConnectionType,
WorkOSDirectoryGroup,
WorkOSDirectoryUser,
WorkOSOrganization,
WorkOSOrganizationMembership,
PipeProvider,
PipeConnectionStatus,
Expand Down Expand Up @@ -1126,10 +1128,14 @@ export const workosPlugin: ServicePlugin = {
onUpdate: (u) => eventBus.emit({ event: EVENTS.userUpdated, data: formatUser(u) }),
onDelete: (u) => eventBus.emit({ event: EVENTS.userDeleted, data: formatUser(u) }),
});
// The organization's own events occur within it: `data` is the organization, so its id is
// the scope, not a `data.organization_id`.
const organizationEvent = (event: string) => (o: WorkOSOrganization) =>
eventBus.emit({ event, data: formatOrganization(o, ws), organization_id: o.id });
ws.organizations.setHooks({
onInsert: (o) => eventBus.emit({ event: EVENTS.organizationCreated, data: formatOrganization(o, ws) }),
onUpdate: (o) => eventBus.emit({ event: EVENTS.organizationUpdated, data: formatOrganization(o, ws) }),
onDelete: (o) => eventBus.emit({ event: EVENTS.organizationDeleted, data: formatOrganization(o, ws) }),
onInsert: organizationEvent(EVENTS.organizationCreated),
onUpdate: organizationEvent(EVENTS.organizationUpdated),
onDelete: organizationEvent(EVENTS.organizationDeleted),
});
ws.organizationDomains.setHooks({
onInsert: (d) => eventBus.emit({ event: EVENTS.organizationDomainCreated, data: formatDomain(d) }),
Expand All @@ -1147,7 +1153,8 @@ export const workosPlugin: ServicePlugin = {
});
// AuthKit groups. `group.created`/`updated`/`deleted` carry the full Group object the
// spec's event data requires; `group.member_added`/`member_removed` carry only the two
// ids. Hook-driven (not inline in the routes) so seeded groups fire the same events.
// ids, so the group's organization is recorded alongside for the events filter to scope
// on. Hook-driven (not inline in the routes) so seeded groups fire the same events.
ws.groups.setHooks({
onInsert: (g) => eventBus.emit({ event: EVENTS.groupCreated, data: formatGroup(g) }),
onUpdate: (g) => eventBus.emit({ event: EVENTS.groupUpdated, data: formatGroup(g) }),
Expand All @@ -1158,11 +1165,13 @@ export const workosPlugin: ServicePlugin = {
eventBus.emit({
event: EVENTS.groupMemberAdded,
data: { group_id: gm.group_id, organization_membership_id: gm.organization_membership_id },
organization_id: ws.groups.get(gm.group_id)?.organization_id ?? null,
}),
onDelete: (gm) =>
eventBus.emit({
event: EVENTS.groupMemberRemoved,
data: { group_id: gm.group_id, organization_membership_id: gm.organization_membership_id },
organization_id: ws.groups.get(gm.group_id)?.organization_id ?? null,
}),
});
// Pipes connected accounts. The event is named by the state the row lands in, so the
Expand Down Expand Up @@ -1258,10 +1267,34 @@ export const workosPlugin: ServicePlugin = {
},
onDelete: (d) => eventBus.emit({ event: EVENTS.dsyncDeleted, data: formatDirectory(d) }),
});
const emitDirectoryGroupMembership = (user: WorkOSDirectoryUser, groupId: string, added: boolean) => {
const group = ws.directoryGroups.get(groupId);
eventBus.emit({
event: added ? EVENTS.dsyncGroupUserAdded : EVENTS.dsyncGroupUserRemoved,
data: {
directory_id: user.directory_id,
user: formatDirectoryUser(user),
group: group ? formatDirectoryGroup(group) : { object: 'directory_group', id: groupId },
},
organization_id: user.organization_id,
});
};
ws.directoryUsers.setHooks({
onInsert: (u) => eventBus.emit({ event: EVENTS.dsyncUserCreated, data: formatDirectoryUser(u) }),
onUpdate: (u) => eventBus.emit({ event: EVENTS.dsyncUserUpdated, data: formatDirectoryUser(u) }),
onDelete: (u) => eventBus.emit({ event: EVENTS.dsyncUserDeleted, data: formatDirectoryUser(u) }),
onInsert: (u) => {
eventBus.emit({ event: EVENTS.dsyncUserCreated, data: formatDirectoryUser(u) });
for (const group of u.groups) emitDirectoryGroupMembership(u, group.id, true);
},
onUpdate: (u, previous) => {
eventBus.emit({ event: EVENTS.dsyncUserUpdated, data: formatDirectoryUser(u) });
const before = new Set(previous.groups.map((group) => group.id));
const after = new Set(u.groups.map((group) => group.id));
for (const groupId of after) if (!before.has(groupId)) emitDirectoryGroupMembership(u, groupId, true);
for (const groupId of before) if (!after.has(groupId)) emitDirectoryGroupMembership(u, groupId, false);
},
onDelete: (u) => {
for (const group of u.groups) emitDirectoryGroupMembership(u, group.id, false);
eventBus.emit({ event: EVENTS.dsyncUserDeleted, data: formatDirectoryUser(u) });
},
});
ws.directoryGroups.setHooks({
onInsert: (g) => eventBus.emit({ event: EVENTS.dsyncGroupCreated, data: formatDirectoryGroup(g) }),
Expand Down
14 changes: 14 additions & 0 deletions src/workos/routes/directories.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,4 +192,18 @@ describe('Directory Sync routes', () => {
expect(res.status).toBe(200);
expect((await json(res)).name).toBe('Engineering');
});

it('emits group membership events when a directory user joins and leaves a group', async () => {
const { user } = seedDirectory();
const added = await json(await req('/events?events=dsync.group.user_added'));
expect(added.data).toHaveLength(1);
expect(added.data[0].data.user.email).toBe('jane@acme.com');
expect(added.data[0].data.group.name).toBe('Engineering');
expect(added.data[0].data.directory_id).toBe(user.directory_id);

getWorkOSStore(store).directoryUsers.update(user.id, { groups: [] });
const removed = await json(await req('/events?events=dsync.group.user_removed'));
expect(removed.data).toHaveLength(1);
expect(removed.data[0].data.group.name).toBe('Engineering');
});
});
128 changes: 123 additions & 5 deletions src/workos/routes/events.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ describe('Events routes', () => {

const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init });
const json = (res: Response) => res.json() as Promise<any>;
const eventRow = { object: 'event', data: {}, organization_id: null, environment_id: null } as const;

it('lists events', async () => {
const ws = getWorkOSStore(store);
ws.events.insert({ object: 'event', event: 'user.created', data: { id: 'user_1' }, environment_id: null });
ws.events.insert({ object: 'event', event: 'organization.created', data: { id: 'org_1' }, environment_id: null });
ws.events.insert({ ...eventRow, event: 'user.created', data: { id: 'user_1' } });
ws.events.insert({ ...eventRow, event: 'organization.created', data: { id: 'org_1' }, organization_id: 'org_1' });

const res = await req('/events');
expect(res.status).toBe(200);
Expand All @@ -37,9 +38,9 @@ describe('Events routes', () => {

it('filters events by type', async () => {
const ws = getWorkOSStore(store);
ws.events.insert({ object: 'event', event: 'user.created', data: {}, environment_id: null });
ws.events.insert({ object: 'event', event: 'user.updated', data: {}, environment_id: null });
ws.events.insert({ object: 'event', event: 'organization.created', data: {}, environment_id: null });
ws.events.insert({ ...eventRow, event: 'user.created' });
ws.events.insert({ ...eventRow, event: 'user.updated' });
ws.events.insert({ ...eventRow, event: 'organization.created' });

const res = await req('/events?events[]=user.created&events[]=user.updated');
const list = await json(res);
Expand All @@ -53,6 +54,123 @@ describe('Events routes', () => {
expect(list.data).toHaveLength(0);
});

// Every wire form an SDK encodes the `events` array as. Production's `qs` parser plus a
// comma split accepts all of them; a poller whose form is not read here sees either every
// event or none.
it.each([
['comma-joined (spec form; python, kotlin, elixir, rust)', 'events=user.created,user.updated'],
['repeated (go, node, ruby)', 'events=user.created&events=user.updated'],
['bracketed (dotnet)', 'events[]=user.created&events[]=user.updated'],
['indexed (php)', 'events[0]=user.created&events[1]=user.updated'],
])('filters events by the %s events parameter', async (_form, query) => {
const ws = getWorkOSStore(store);
ws.events.insert({ ...eventRow, event: 'user.created' });
ws.events.insert({ ...eventRow, event: 'user.updated' });
ws.events.insert({ ...eventRow, event: 'organization.created' });

const list = await json(await req(`/events?${query}`));
expect(list.data).toHaveLength(2);
expect(list.data.every((e: any) => e.event.startsWith('user.'))).toBe(true);
});

it('filters events by organization and range', async () => {
const ws = getWorkOSStore(store);
ws.events.insert({
...eventRow,
event: 'dsync.user.created',
data: { organization_id: 'org_1' },
organization_id: 'org_1',
});
ws.events.insert({
...eventRow,
event: 'dsync.user.created',
data: { organization_id: 'org_2' },
organization_id: 'org_2',
});

const past = new Date(Date.now() - 60_000).toISOString();
const future = new Date(Date.now() + 60_000).toISOString();

const kept = await json(await req(`/events?organization_id=org_1&range_start=${encodeURIComponent(past)}`));
expect(kept.data).toHaveLength(1);
expect(kept.data[0].data.organization_id).toBe('org_1');

const later = await json(await req(`/events?range_start=${encodeURIComponent(future)}`));
expect(later.data).toHaveLength(0);

const ended = await json(await req(`/events?range_end=${encodeURIComponent(past)}`));
expect(ended.data).toHaveLength(0);
});

// Scope is the organization an event occurred within, not whether its payload names one:
// organization.* events carry the organization itself, group.member_* carry only ids, and
// dsync.group.user_* nest theirs. An organization-scoped poller must see all of them.
it('scopes organization and membership events to their organization', async () => {
const post = async (path: string, body: Record<string, unknown>) =>
json(await req(path, { method: 'POST', body: JSON.stringify(body) }));
const org = await post('/organizations', { name: 'Acme' });
const other = await post('/organizations', { name: 'Globex' });
const user = await post('/user_management/users', { email: 'jane@acme.com' });
const membership = await post('/user_management/organization_memberships', {
user_id: user.id,
organization_id: org.id,
});
const group = await post(`/organizations/${org.id}/groups`, { name: 'Engineering' });
await post(`/organizations/${org.id}/groups/${group.id}/organization-memberships`, {
organization_membership_id: membership.id,
});

const ws = getWorkOSStore(store);
const directory = ws.directories.insert({
object: 'directory',
name: 'Okta',
organization_id: org.id,
domain: 'acme.com',
type: 'okta scim v2.0',
state: 'linked',
external_key: 'ext_1',
});
const directoryGroup = ws.directoryGroups.insert({
object: 'directory_group',
directory_id: directory.id,
organization_id: org.id,
idp_id: 'idp_grp_1',
name: 'Engineering',
raw_attributes: {},
});
ws.directoryUsers.insert({
object: 'directory_user',
directory_id: directory.id,
organization_id: org.id,
idp_id: 'idp_usr_1',
first_name: 'Jane',
last_name: 'Doe',
email: 'jane@acme.com',
username: 'jdoe',
state: 'active',
role: null,
custom_attributes: {},
raw_attributes: {},
groups: [{ object: 'directory_group', id: directoryGroup.id, name: 'Engineering' }],
});
// Deleting the directory removes its users and groups; the removal it emits must still
// land in the organization's history once nothing it refers to exists.
await req(`/directories/${directory.id}`, { method: 'DELETE' });

const scoped = await json(await req(`/events?organization_id=${org.id}&limit=100`));
const types = scoped.data.map((e: any) => e.event);
expect(types).toContain('organization.created');
expect(types).toContain('group.member_added');
expect(types).toContain('dsync.group.user_added');
expect(types).toContain('dsync.group.user_removed');
expect(types).not.toContain('user.created');
// The scope is the emulator's, not the spec's: it never reaches the wire.
expect(scoped.data.some((e: any) => 'organization_id' in e)).toBe(false);

const others = await json(await req(`/events?organization_id=${other.id}`));
expect(others.data.map((e: any) => e.event)).toEqual(['organization.created']);
});

it('event from user creation appears in events list', async () => {
// Create a user which should trigger an event via collection hooks
await req('/user_management/users', {
Expand Down
Loading
Loading