diff --git a/src/workos/entities.ts b/src/workos/entities.ts index 512d111..c70560e 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -395,7 +395,7 @@ export interface WorkOSDirectory extends Entity { domain: string | null; type: string; state: 'linked' | 'unlinked' | 'deleting' | 'invalid_credentials'; - external_key: string | null; + external_key: string; } export interface WorkOSDirectoryUser extends Entity { diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 97f6585..0f5beef 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -1060,6 +1060,44 @@ export function formatDirectory(d: WorkOSDirectory): Record { return formatEntity(d); } +/** + * The directory states the event catalog uses. `GET /directories` reports `linked` and + * `unlinked`, but `dsync.*` event payloads keep the older `active` / `inactive` names + * (the Node SDK maps between them for the REST shape and passes the event state through). + */ +const DIRECTORY_EVENT_STATES: Record = { + linked: 'active', + unlinked: 'inactive', + deleting: 'deleting', + invalid_credentials: 'invalid_credentials', +}; + +/** + * The `data` of a `dsync.activated` event. Production documents it as the Directory object + * with the singular `domain` replaced by a `domains` array of Organization Domains + * (`object`, `id`, `domain`), and requires a string `external_key`. Strict SDK + * deserializers (e.g. workos-python's `DsyncActivatedData.from_dict`) raise on a missing + * `domains`, which fails the whole `GET /events` page it appears on. + */ +export function formatDirectoryActivatedEvent(d: WorkOSDirectory, ws: WorkOSStore): Record { + const { domain: _domain, ...rest } = formatEntity(d); + const domains = d.organization_id + ? ws.organizationDomains + .findBy('organization_id', d.organization_id) + .map((od) => ({ object: 'organization_domain', id: od.id, domain: od.domain })) + : []; + return { ...rest, state: DIRECTORY_EVENT_STATES[d.state] ?? d.state, domains }; +} + +/** + * The `data` of a `dsync.deleted` event: the Directory object without `domain`, `domains` + * or `external_key`, in the `deleting` state the directory is in when the event fires. + */ +export function formatDirectoryDeletedEvent(d: WorkOSDirectory): Record { + const { domain: _domain, external_key: _externalKey, ...rest } = formatEntity(d); + return { ...rest, state: 'deleting' }; +} + export function formatDirectoryUser(u: WorkOSDirectoryUser): Record { return formatEntity(u); } diff --git a/src/workos/index.ts b/src/workos/index.ts index 45262b4..6090f4d 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -62,7 +62,8 @@ import { formatInvitation, formatRole, formatPermission, - formatDirectory, + formatDirectoryActivatedEvent, + formatDirectoryDeletedEvent, formatDirectoryUser, formatDirectoryGroup, formatDomain, @@ -676,7 +677,9 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee domain: dirConfig.domain ?? null, type: dirConfig.type ?? 'generic scim v2.0', state: dirConfig.state ?? 'linked', - external_key: dirConfig.external_key ?? null, + // Production always assigns an external key, and the spec marks it required on both + // the Directory object and the dsync.activated payload. + external_key: dirConfig.external_key ?? randomBytes(12).toString('base64url'), }); // Groups are inserted first: a seeded user embeds the groups it belongs to, and the @@ -1263,9 +1266,10 @@ export const workosPlugin: ServicePlugin = { // `linked` means: a directory seeded `unlinked` or `invalid_credentials` has not // activated, the same way an inactive connection does not announce itself. onInsert: (d) => { - if (d.state === 'linked') eventBus.emit({ event: EVENTS.dsyncActivated, data: formatDirectory(d) }); + if (d.state === 'linked') + eventBus.emit({ event: EVENTS.dsyncActivated, data: formatDirectoryActivatedEvent(d, ws) }); }, - onDelete: (d) => eventBus.emit({ event: EVENTS.dsyncDeleted, data: formatDirectory(d) }), + onDelete: (d) => eventBus.emit({ event: EVENTS.dsyncDeleted, data: formatDirectoryDeletedEvent(d) }), }); const emitDirectoryGroupMembership = (user: WorkOSDirectoryUser, groupId: string, added: boolean) => { const group = ws.directoryGroups.get(groupId); diff --git a/src/workos/seed-directories.spec.ts b/src/workos/seed-directories.spec.ts index 8e86f97..aebfdc7 100644 --- a/src/workos/seed-directories.spec.ts +++ b/src/workos/seed-directories.spec.ts @@ -7,6 +7,7 @@ import { describe, it, expect, afterEach } from 'bun:test'; import { createEmulator, type Emulator } from '../index.js'; import { validateSeedConfig } from './config-validator.js'; +import { EVENT_DATA_REQUIREMENTS } from './generated/events.js'; describe('Seeding directories', () => { let emulator: Emulator | undefined; @@ -112,6 +113,34 @@ describe('Seeding directories', () => { expect(names).toContain('dsync.user.created'); }); + it('shapes dsync.activated as the spec event payload, with domains instead of domain', async () => { + emulator = await createEmulator({ + port: 0, + seed: { + organizations: [{ name: 'Acme Local', domains: [{ domain: 'acme.test', state: 'verified' }] }], + directories: [{ name: 'Acme SCIM', organization: 'Acme Local', domain: 'acme.test' }], + }, + }); + + const evts = await get(`${emulator.url}/events?events[]=dsync.activated`, emulator.apiKey); + expect(evts.data).toHaveLength(1); + const data = evts.data[0].data; + for (const field of EVENT_DATA_REQUIREMENTS['dsync.activated'].required) expect(data).toHaveProperty(field); + expect(data).not.toHaveProperty('domain'); + expect(typeof data.external_key).toBe('string'); + // Event payloads keep the legacy state names; `linked` is `active` there. + expect(data.state).toBe('active'); + + const org = (await get(`${emulator.url}/organizations`, emulator.apiKey)).data[0]; + expect(data.domains).toEqual([{ object: 'organization_domain', id: org.domains[0].id, domain: 'acme.test' }]); + + // The REST Directory object keeps its own shape. + const directory = (await get(`${emulator.url}/directories`, emulator.apiKey)).data[0]; + expect(directory.domain).toBe('acme.test'); + expect(directory.state).toBe('linked'); + expect(directory.external_key).toBe(data.external_key); + }); + it('emits dsync.activated only for a linked directory', async () => { emulator = await createEmulator({ port: 0, @@ -483,6 +512,13 @@ describe('Seeding directories', () => { const evts = await get(`${emulator.url}/events?events[]=dsync.deleted`, emulator.apiKey); expect(evts.data.map((e: any) => e.event)).toContain('dsync.deleted'); + const data = evts.data[0].data; + for (const field of EVENT_DATA_REQUIREMENTS['dsync.deleted'].required) expect(data).toHaveProperty(field); + expect(data.id).toBe(directory.id); + expect(data.state).toBe('deleting'); + expect(data).not.toHaveProperty('domain'); + expect(data).not.toHaveProperty('domains'); + expect(data).not.toHaveProperty('external_key'); }); it('maps a role for a directory user with no AuthKit membership', async () => {