Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/workos/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
38 changes: 38 additions & 0 deletions src/workos/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,44 @@ export function formatDirectory(d: WorkOSDirectory): Record<string, unknown> {
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<WorkOSDirectory['state'], string> = {
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<string, unknown> {
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<string, unknown> {
const { domain: _domain, external_key: _externalKey, ...rest } = formatEntity(d);
return { ...rest, state: 'deleting' };
}

export function formatDirectoryUser(u: WorkOSDirectoryUser): Record<string, unknown> {
return formatEntity(u);
}
Expand Down
12 changes: 8 additions & 4 deletions src/workos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ import {
formatInvitation,
formatRole,
formatPermission,
formatDirectory,
formatDirectoryActivatedEvent,
formatDirectoryDeletedEvent,
formatDirectoryUser,
formatDirectoryGroup,
formatDomain,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
36 changes: 36 additions & 0 deletions src/workos/seed-directories.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading