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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,38 @@ plus an `authentication_challenge`) instead of a session, completed with the
`urn:workos:oauth:grant-type:mfa-totp` grant — so MFA administration and step-up login flows
need no post-boot enrollment calls that in-memory state would lose on restart.

### Organization IT contacts

`/organizations/{id}/it_contacts` is implemented in full — list, create, delete, invite and
revoke:

```bash
curl -X POST http://localhost:4100/organizations/org_01K.../it_contacts \
-H "Authorization: Bearer sk_test_ci_key" -H "Content-Type: application/json" \
-d '{"email":"it@acme.com"}'

curl -X POST http://localhost:4100/organizations/org_01K.../it_contacts/it_contact_01K.../invite \
-H "Authorization: Bearer sk_test_ci_key" -H "Content-Type: application/json" \
-d '{"intents":["sso","directory_sync"]}'
```

Two production rules are enforced. An email may be an IT contact of a given organization only
once — `409 it_contact_already_exists` otherwise, though the same address may serve several
organizations. And an organization may hold **one active invitation at a time**: inviting a
_second_ contact is `409 it_contact_invitation_already_active`, while re-inviting the contact
who already holds it refreshes their link, since the count stays at one. Deleting a contact
revokes its invitation, and `revoke` clears the organization's active invitation whichever
contact you address it through — no endpoint reports which contact holds it.

Nothing is emailed. `invite` records the setup link production would have sent, and `revoke`
clears it; neither the link nor the invitation state appears in any response, because the
spec's `it_contact` object documents no such fields and both routes answer `204`.

The spec also documents `403` and `503` on all five routes. Neither is implemented: the
emulator's store is not environment-scoped, so the forbidden case cannot arise, and `503` is a
production infrastructure state. Both can still be injected per-route through the
[error hooks](#error-hooks) if you need to exercise them.

### Pipes connected accounts

`GET|POST|PUT|DELETE /user_management/users/{id}/connected_accounts/{slug}` serve a user's
Expand Down
4 changes: 2 additions & 2 deletions SUPPORTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# Supported Features

The emulator implements **184 of 261** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.98.0`) (**70.5%**).
The emulator implements **189 of 261** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.98.0`) (**72.4%**).

Endpoint coverage says whether a route exists, not whether a
feature is usable; for example, Directory Sync implements every endpoint the spec defines for it and is
Expand All @@ -19,7 +19,7 @@ answers "can I actually emulate this?".

| Feature | Read | Write | Set up | Notes |
| ------------------------ | -------- | -------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Organizations | ⚠️ 5/6 | ⚠️ 6/10 | ✅ seed `organizations` | IT contact endpoints are not implemented. |
| Organizations | ✅ 6/6 | ✅ 10/10 | ✅ seed `organizations` | IT contacts are stored and an organization is held to one active Admin Portal invitation, as production is, but nothing is emailed: the setup link an invitation would send is recorded on the contact and served by no route. Re-inviting the contact who already holds the invitation refreshes it rather than conflicting, and revoking clears the organization's invitation through any contact, since no route reports which one holds it. The `403` and `503` these routes document are not implemented — the store is not environment-scoped, so the forbidden case cannot arise — but both can be injected through the error hooks. |
| User Management | ⚠️ 8/11 | ⚠️ 7/13 | ✅ seed `users` | Email-change confirm/send and waitlist endpoints are not implemented. |
| Authentication | ⚠️ 3/4 | ⚠️ 4/5 | ⚠️ API only | All grant types are hand-written rather than generated from the spec. Refresh tokens always rotate, which is stricter than production. |
| Organization Memberships | ✅ 3/3 | ✅ 5/5 | ✅ seed `memberships` | Seeded via `memberships` nested under an organization. |
Expand Down
7 changes: 7 additions & 0 deletions scripts/gen-shapes-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const OBJECT_SCHEMA_MAP: readonly ShapeMapEntry[] = [
// depth — enrollment's secrets are pinned by the route tests instead.
{ objectType: 'authentication_factor', schemaName: 'AuthenticationFactor' },
{ objectType: 'authentication_challenge', schemaName: 'AuthenticationChallenge' },
{ objectType: 'it_contact', schemaName: 'ItContact' },
// The spec names only the secret-bearing creation shape; the list route's secretless
// variant is an inline schema, so `secret` is carried as a tracked gap on this entry.
{ objectType: 'connect_application_secret', schemaName: 'NewConnectApplicationSecret' },
Expand Down Expand Up @@ -162,6 +163,12 @@ export const ENVELOPE_SCHEMA_MAP: readonly EnvelopeMapEntry[] = [
{ method: 'GET', path: '/organizations', status: '200', schemaName: 'OrganizationList' },
{ method: 'GET', path: '/user_management/users', status: '200', schemaName: 'UserlandUserList' },
{ method: 'GET', path: '/connect/applications', status: '200', schemaName: 'ConnectApplicationList' },
{
method: 'GET',
path: '/organizations/{organization_id}/it_contacts',
status: '200',
schemaName: 'ItContactList',
},
{ method: 'GET', path: '/webhook_endpoints', status: '200', schemaName: 'WebhookEndpointList' },
{ method: 'GET', path: '/events', status: '200', schemaName: 'EventList' },
{
Expand Down
3 changes: 2 additions & 1 deletion scripts/gen-supported-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ export const FEATURES: FeatureDef[] = [
name: 'Organizations',
tags: ['organizations', 'organization-domains', 'organizations.it-contacts'],
seedKeys: ['organizations'],
notes: 'IT contact endpoints are not implemented.',
notes:
"IT contacts are stored and an organization is held to one active Admin Portal invitation, as production is, but nothing is emailed: the setup link an invitation would send is recorded on the contact and served by no route. Re-inviting the contact who already holds the invitation refreshes it rather than conflicting, and revoking clears the organization's invitation through any contact, since no route reports which one holds it. The `403` and `503` these routes document are not implemented — the store is not environment-scoped, so the forbidden case cannot arise — but both can be injected through the error hooks.",
},
{
name: 'User Management',
Expand Down
1 change: 1 addition & 0 deletions src/core/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const ID_PREFIXES = {
organization: 'org',
organization_membership: 'om',
organization_domain: 'org_domain',
it_contact: 'it_contact',
group: 'group',
group_membership: 'gm',
connection: 'conn',
Expand Down
16 changes: 16 additions & 0 deletions src/workos/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ export interface WorkOSOrganizationDomain extends Entity {
verification_prefix: string;
}

export interface WorkOSItContact extends Entity {
object: 'it_contact';
/** The owning organization. Not serialized: the spec's ItContact addresses it by route. */
organization_id: string;
email: string;
/**
* Admin Portal invitation state. None of it is serialized — the spec's invite and revoke
* routes answer 204 and `ItContact` documents no invitation fields — but it is what makes
* "an organization can have at most one active invitation" enforceable.
*/
invited_at: string | null;
invite_intents: string[] | null;
/** The setup link an invitation would have emailed. Emulator-only; nothing delivers it. */
invite_setup_link: string | null;
}

export interface WorkOSOrganizationMembership extends Entity {
object: 'organization_membership';
organization_id: string;
Expand Down
10 changes: 10 additions & 0 deletions src/workos/generated/response-shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,11 @@ export const RESPONSE_SHAPE_REQUIREMENTS: Record<string, ResponseShapeRequiremen
'updated_at',
],
},
it_contact: {
schema: 'ItContact',
properties: ['created_at', 'email', 'id', 'object', 'updated_at'],
required: ['created_at', 'email', 'id', 'object', 'updated_at'],
},
organization: {
schema: 'Organization',
properties: [
Expand Down Expand Up @@ -340,6 +345,11 @@ export const RESPONSE_ENVELOPE_REQUIREMENTS: Record<string, ResponseShapeRequire
properties: ['data', 'list_metadata', 'object'],
required: ['data', 'list_metadata', 'object'],
},
'GET /organizations/{organization_id}/it_contacts': {
schema: 'ItContactList',
properties: ['data', 'list_metadata', 'object'],
required: ['data', 'list_metadata', 'object'],
},
'GET /organizations/{organizationId}/api_keys': {
schema: 'OrganizationApiKeyList',
properties: ['data', 'list_metadata', 'object'],
Expand Down
15 changes: 15 additions & 0 deletions src/workos/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
WorkOSAgentInstanceSession,
WorkOSOrganization,
WorkOSOrganizationDomain,
WorkOSItContact,
WorkOSOrganizationMembership,
WorkOSGroup,
WorkOSUser,
Expand Down Expand Up @@ -121,6 +122,20 @@ export function formatDomain(domain: WorkOSOrganizationDomain): Record<string, u
return formatEntity(domain, { exclude: DOMAIN_EXCLUDE });
}

// The owning organization and the whole invitation state stay internal: the spec's ItContact
// documents neither, and an invitation is addressed through the organization's routes.
const IT_CONTACT_EXCLUDE = new Set([
...INTERNAL_FIELDS,
'organization_id',
'invited_at',
'invite_intents',
'invite_setup_link',
]);

export function formatItContact(contact: WorkOSItContact): Record<string, unknown> {
return formatEntity(contact, { exclude: IT_CONTACT_EXCLUDE });
}

export function formatMembership(m: WorkOSOrganizationMembership, ws: WorkOSStore): Record<string, unknown> {
// Real WorkOS `organization_membership` REST responses always carry `directory_managed`,
// `custom_attributes`, `roles`, and an embedded `user`. The emulator previously omitted
Expand Down
2 changes: 2 additions & 0 deletions src/workos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { generateId } from '../core/index.js';
import { syncOrganizationResource } from './organization-resource.js';
import { getWorkOSStore } from './store.js';
import { organizationRoutes } from './routes/organizations.js';
import { itContactRoutes } from './routes/it-contacts.js';
import { organizationDomainRoutes } from './routes/organization-domains.js';
import { membershipRoutes } from './routes/memberships.js';
import { groupRoutes } from './routes/groups.js';
Expand Down Expand Up @@ -1070,6 +1071,7 @@ export const workosPlugin: ServicePlugin = {
name: 'workos',
register(ctx: RouteContext): void {
organizationRoutes(ctx);
itContactRoutes(ctx);
organizationDomainRoutes(ctx);
membershipRoutes(ctx);
groupRoutes(ctx);
Expand Down
15 changes: 15 additions & 0 deletions src/workos/response-envelopes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ const CASES: readonly EnvelopeCase[] = [
{ operation: 'GET /organizations', request: get('/organizations') },
{ operation: 'GET /user_management/users', request: get('/user_management/users') },
{ operation: 'GET /connect/applications', request: get('/connect/applications') },
{
operation: 'GET /organizations/{organization_id}/it_contacts',
request: (app, f) => get(`/organizations/${f.organizationId}/it_contacts`)(app),
},
{ operation: 'GET /webhook_endpoints', request: get('/webhook_endpoints') },
{ operation: 'GET /events', request: get('/events') },
{
Expand Down Expand Up @@ -250,6 +254,17 @@ describe('response envelope conformance (route bodies vs OpenAPI spec)', () => {
code: '123456',
}).id;

// The IT contact list has no create-then-list case of its own, so the page it returns
// needs a record: an empty `data` array would satisfy the field assertions vacuously.
ws.itContacts.insert({
object: 'it_contact',
organization_id: organizationId,
email: 'it@acme.com',
invited_at: null,
invite_intents: null,
invite_setup_link: null,
});

const fixtures: Fixtures = {
organizationId,
userId,
Expand Down
15 changes: 15 additions & 0 deletions src/workos/response-shapes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
formatAuthFactor,
formatAuthChallenge,
formatClientSecret,
formatItContact,
} from './helpers.js';
import { RESPONSE_SHAPE_REQUIREMENTS } from './generated/response-shapes.js';
import type {
Expand All @@ -50,6 +51,7 @@ import type {
WorkOSAuthenticationFactor,
WorkOSAuthenticationChallenge,
WorkOSClientSecret,
WorkOSItContact,
} from './entities.js';

const TS = '2026-01-01T00:00:00.000Z';
Expand Down Expand Up @@ -240,6 +242,18 @@ const authChallenge: WorkOSAuthenticationChallenge = {
const store = new Store();
const ws = getWorkOSStore(store);

const itContact: WorkOSItContact = {
id: 'it_contact_01',
object: 'it_contact',
organization_id: 'org_01',
email: 'it@acme.com',
invited_at: TS,
invite_intents: ['sso'],
invite_setup_link: 'http://localhost:4100/portal/setup/it_contact_01',
created_at: TS,
updated_at: TS,
};

const clientSecret: WorkOSClientSecret = {
id: 'secret_01',
object: 'connect_application_secret',
Expand All @@ -266,6 +280,7 @@ const CASES: ReadonlyArray<{ objectType: string; output: Record<string, unknown>
{ objectType: 'authentication_factor', output: formatAuthFactor(authFactor) },
{ objectType: 'authentication_challenge', output: formatAuthChallenge(authChallenge) },
{ objectType: 'connect_application_secret', output: formatClientSecret(clientSecret) },
{ objectType: 'it_contact', output: formatItContact(itContact) },
];

/**
Expand Down
Loading
Loading