From 9b862f1ebaa42c0514bb1633b1a45b9a2b332974 Mon Sep 17 00:00:00 2001 From: Daniel Loader Date: Sat, 26 Sep 2026 22:22:02 +0100 Subject: [PATCH 1/3] feat(organizations): implement IT contacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five IT contact endpoints were the whole of the Organizations shortfall, leaving the feature at 5/6 read and 6/10 write. Organizations is now complete. GET /organizations/:organization_id/it_contacts POST /organizations/:organization_id/it_contacts DELETE /organizations/:organization_id/it_contacts/:contact_id POST /organizations/:organization_id/it_contacts/:contact_id/invite POST /organizations/:organization_id/it_contacts/:contact_id/revoke Two production rules come from the spec's prose rather than its schemas, and both are enforced: an address may be an IT contact of a given organization only once, and an organization holds at most one active Admin Portal invitation. Re-inviting the contact who already holds it refreshes their link, since the count stays at one, and revoking clears the organization's invitation through any contact — no route reports which one holds it, so addressing a contact that does not would otherwise answer 204 and leave the caller wedged behind the next 409. Nothing is emailed. The invitation state, including the setup link production would have sent, is stored but never serialized: `ItContact` documents no such fields and both routes answer 204. It is what makes the one-invitation rule enforceable. `403` and `503` are not implemented. The store is not environment-scoped, so the forbidden case cannot arise, and 503 is a production infrastructure state; both remain injectable through the error hooks. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 32 ++++ SUPPORTED.md | 4 +- scripts/gen-shapes-lib.ts | 7 + scripts/gen-supported-lib.ts | 3 +- src/core/id.ts | 1 + src/workos/entities.ts | 16 ++ src/workos/generated/response-shapes.ts | 10 ++ src/workos/helpers.ts | 15 ++ src/workos/index.ts | 2 + src/workos/response-envelopes.spec.ts | 15 ++ src/workos/response-shapes.spec.ts | 15 ++ src/workos/routes/it-contacts.spec.ts | 193 ++++++++++++++++++++++++ src/workos/routes/it-contacts.ts | 150 ++++++++++++++++++ src/workos/routes/organizations.ts | 1 + src/workos/store.ts | 6 + 15 files changed, 467 insertions(+), 3 deletions(-) create mode 100644 src/workos/routes/it-contacts.spec.ts create mode 100644 src/workos/routes/it-contacts.ts diff --git a/README.md b/README.md index ba57238..e616881 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SUPPORTED.md b/SUPPORTED.md index 2ac265e..251f5f4 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -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 @@ -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. | diff --git a/scripts/gen-shapes-lib.ts b/scripts/gen-shapes-lib.ts index 31fef0b..07646e8 100644 --- a/scripts/gen-shapes-lib.ts +++ b/scripts/gen-shapes-lib.ts @@ -68,6 +68,7 @@ export const OBJECT_SCHEMA_MAP: readonly ShapeMapEntry[] = [ { objectType: 'authentication_challenge', schemaName: 'AuthenticationChallenge' }, // 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: 'it_contact', schemaName: 'ItContact' }, { objectType: 'connect_application_secret', schemaName: 'NewConnectApplicationSecret' }, // `connect_application` is deliberately absent: `ConnectApplication` is an allOf over a // four-way oneOf (first-party / dynamically registered / third-party oauth, and m2m), and @@ -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' }, { diff --git a/scripts/gen-supported-lib.ts b/scripts/gen-supported-lib.ts index e691f03..01ad9f4 100644 --- a/scripts/gen-supported-lib.ts +++ b/scripts/gen-supported-lib.ts @@ -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', diff --git a/src/core/id.ts b/src/core/id.ts index 39c357d..672de19 100644 --- a/src/core/id.ts +++ b/src/core/id.ts @@ -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', diff --git a/src/workos/entities.ts b/src/workos/entities.ts index 512d111..393856f 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -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; diff --git a/src/workos/generated/response-shapes.ts b/src/workos/generated/response-shapes.ts index efe219f..9084e3d 100644 --- a/src/workos/generated/response-shapes.ts +++ b/src/workos/generated/response-shapes.ts @@ -184,6 +184,11 @@ export const RESPONSE_SHAPE_REQUIREMENTS: Record { + return formatEntity(c, { exclude: IT_CONTACT_EXCLUDE }); +} + export function formatMembership(m: WorkOSOrganizationMembership, ws: WorkOSStore): Record { // Real WorkOS `organization_membership` REST responses always carry `directory_managed`, // `custom_attributes`, `roles`, and an embedded `user`. The emulator previously omitted diff --git a/src/workos/index.ts b/src/workos/index.ts index 45262b4..1a80366 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -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'; @@ -1070,6 +1071,7 @@ export const workosPlugin: ServicePlugin = { name: 'workos', register(ctx: RouteContext): void { organizationRoutes(ctx); + itContactRoutes(ctx); organizationDomainRoutes(ctx); membershipRoutes(ctx); groupRoutes(ctx); diff --git a/src/workos/response-envelopes.spec.ts b/src/workos/response-envelopes.spec.ts index cb03714..53213f9 100644 --- a/src/workos/response-envelopes.spec.ts +++ b/src/workos/response-envelopes.spec.ts @@ -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') }, { @@ -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, diff --git a/src/workos/response-shapes.spec.ts b/src/workos/response-shapes.spec.ts index 02aa2cf..f541803 100644 --- a/src/workos/response-shapes.spec.ts +++ b/src/workos/response-shapes.spec.ts @@ -33,6 +33,7 @@ import { formatAuthFactor, formatAuthChallenge, formatClientSecret, + formatItContact, } from './helpers.js'; import { RESPONSE_SHAPE_REQUIREMENTS } from './generated/response-shapes.js'; import type { @@ -50,6 +51,7 @@ import type { WorkOSAuthenticationFactor, WorkOSAuthenticationChallenge, WorkOSClientSecret, + WorkOSItContact, } from './entities.js'; const TS = '2026-01-01T00:00:00.000Z'; @@ -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', @@ -266,6 +280,7 @@ const CASES: ReadonlyArray<{ objectType: string; output: Record { 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) }, ]; /** diff --git a/src/workos/routes/it-contacts.spec.ts b/src/workos/routes/it-contacts.spec.ts new file mode 100644 index 0000000..21fa6ea --- /dev/null +++ b/src/workos/routes/it-contacts.spec.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { createServer, type ApiKeyMap } from '../../core/index.js'; +import { workosPlugin } from '../index.js'; +import { getWorkOSStore } from '../store.js'; + +const apiKeys: ApiKeyMap = { sk_test_org: { environment: 'test' } }; +const headers = { Authorization: 'Bearer sk_test_org', 'Content-Type': 'application/json' }; + +function createTestApp() { + return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); +} + +describe('IT contacts', () => { + let app: ReturnType['app']; + let store: ReturnType['store']; + let organizationId: string; + + const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); + const json = (res: Response) => res.json() as Promise; + + beforeEach(async () => { + const testApp = createTestApp(); + app = testApp.app; + store = testApp.store; + const org = await json(await req('/organizations', { method: 'POST', body: JSON.stringify({ name: 'Acme' }) })); + organizationId = org.id; + }); + + const create = (email: string) => + req(`/organizations/${organizationId}/it_contacts`, { method: 'POST', body: JSON.stringify({ email }) }); + const invite = (contactId: string, intents: unknown = ['sso']) => + req(`/organizations/${organizationId}/it_contacts/${contactId}/invite`, { + method: 'POST', + body: JSON.stringify({ intents }), + }); + + it('creates a contact with the spec shape', async () => { + const res = await create('it@acme.com'); + expect(res.status).toBe(201); + const contact = await json(res); + expect(contact.object).toBe('it_contact'); + expect(contact.id).toMatch(/^it_contact_/); + expect(contact.email).toBe('it@acme.com'); + // The owning organization is addressed by route, not carried on the resource. + expect(contact.organization_id).toBeUndefined(); + }); + + it('lists contacts oldest first in a list envelope', async () => { + const first = await json(await create('first@acme.com')); + const second = await json(await create('second@acme.com')); + getWorkOSStore(store).itContacts.updateSilent(second.id, { created_at: '2020-01-01T00:00:00.000Z' }); + + const res = await req(`/organizations/${organizationId}/it_contacts`); + expect(res.status).toBe(200); + const body = await json(res); + expect(body.object).toBe('list'); + expect(body.list_metadata).toEqual({ before: null, after: null }); + expect(body.data.map((c: any) => c.id)).toEqual([second.id, first.id]); + }); + + it('rejects a duplicate email within the organization, case-insensitively', async () => { + expect((await create('dupe@acme.com')).status).toBe(201); + const conflict = await create('DUPE@acme.com'); + expect(conflict.status).toBe(409); + expect((await json(conflict)).code).toBe('it_contact_already_exists'); + }); + + it('allows the same email in a different organization', async () => { + await create('shared@acme.com'); + const other = await json(await req('/organizations', { method: 'POST', body: JSON.stringify({ name: 'Other' }) })); + const res = await req(`/organizations/${other.id}/it_contacts`, { + method: 'POST', + body: JSON.stringify({ email: 'shared@acme.com' }), + }); + expect(res.status).toBe(201); + }); + + it('rejects a missing or malformed email', async () => { + const post = (body: unknown) => + req(`/organizations/${organizationId}/it_contacts`, { method: 'POST', body: JSON.stringify(body) }); + expect((await post({})).status).toBe(422); + expect((await post({ email: ' ' })).status).toBe(422); + expect((await post({ email: 'not-an-email' })).status).toBe(422); + }); + + it('invites a contact and records the invitation', async () => { + const contact = await json(await create('it@acme.com')); + const res = await invite(contact.id, ['sso', 'directory_sync']); + expect(res.status).toBe(204); + + // None of the invitation state is on the wire; the spec documents no such fields. + const stored = getWorkOSStore(store).itContacts.get(contact.id)!; + expect(stored.invited_at).not.toBeNull(); + expect(stored.invite_intents).toEqual(['sso', 'directory_sync']); + expect(stored.invite_setup_link).toContain(contact.id); + const listed = (await json(await req(`/organizations/${organizationId}/it_contacts`))).data[0]; + expect(Object.keys(listed).sort()).toEqual(['created_at', 'email', 'id', 'object', 'updated_at']); + }); + + it('allows only one active invitation per organization', async () => { + const first = await json(await create('first@acme.com')); + const second = await json(await create('second@acme.com')); + + expect((await invite(first.id)).status).toBe(204); + // Another contact, and the same contact again, are the same conflict. + const conflict = await invite(second.id); + expect(conflict.status).toBe(409); + expect((await json(conflict)).code).toBe('it_contact_invitation_already_active'); + + // Revoking frees the slot. + expect( + (await req(`/organizations/${organizationId}/it_contacts/${first.id}/revoke`, { method: 'POST' })).status, + ).toBe(204); + expect((await invite(second.id)).status).toBe(204); + }); + + it('re-invites the holder to refresh their link, leaving the count at one', async () => { + const first = await json(await create('first@acme.com')); + const second = await json(await create('second@acme.com')); + await invite(first.id, ['sso']); + const before = getWorkOSStore(store).itContacts.get(first.id)!.invite_setup_link; + + // The organization still has exactly one invitation, so this is a resend, not a conflict. + expect((await invite(first.id, ['directory_sync'])).status).toBe(204); + const after = getWorkOSStore(store).itContacts.get(first.id)!; + expect(after.invite_intents).toEqual(['directory_sync']); + expect(after.invite_setup_link).toBe(before); + // And the slot is still taken against anyone else. + expect((await invite(second.id)).status).toBe(409); + }); + + it("revokes the organization's invitation through a contact that does not hold it", async () => { + const holder = await json(await create('holder@acme.com')); + const other = await json(await create('other@acme.com')); + await invite(holder.id); + + // No route reports which contact holds the invitation, so revoking through any of them + // has to clear it — otherwise the caller gets a 204 and stays wedged. + const res = await req(`/organizations/${organizationId}/it_contacts/${other.id}/revoke`, { method: 'POST' }); + expect(res.status).toBe(204); + expect(getWorkOSStore(store).itContacts.get(holder.id)?.invited_at).toBeNull(); + expect((await invite(other.id)).status).toBe(204); + }); + + it('revokes with no active invitation as a no-op, without touching the record', async () => { + const contact = await json(await create('it@acme.com')); + const before = getWorkOSStore(store).itContacts.get(contact.id)!.updated_at; + + const res = await req(`/organizations/${organizationId}/it_contacts/${contact.id}/revoke`, { method: 'POST' }); + expect(res.status).toBe(204); + const after = getWorkOSStore(store).itContacts.get(contact.id)!; + expect(after.invited_at).toBeNull(); + // Nothing changed, so nothing was written. + expect(after.updated_at).toBe(before); + }); + + it('rejects invalid intents', async () => { + const contact = await json(await create('it@acme.com')); + expect((await invite(contact.id, [])).status).toBe(422); + expect((await invite(contact.id, 'sso')).status).toBe(422); + expect((await invite(contact.id, ['nonsense'])).status).toBe(422); + expect((await invite(contact.id, ['sso', 'sso'])).status).toBe(422); + }); + + it('deleting a contact frees the organization invitation slot', async () => { + const first = await json(await create('first@acme.com')); + const second = await json(await create('second@acme.com')); + await invite(first.id); + + const del = await req(`/organizations/${organizationId}/it_contacts/${first.id}`, { method: 'DELETE' }); + expect(del.status).toBe(204); + expect(getWorkOSStore(store).itContacts.get(first.id)).toBeUndefined(); + expect((await invite(second.id)).status).toBe(204); + }); + + it('404s an unknown organization, contact, or a contact from another organization', async () => { + const contact = await json(await create('it@acme.com')); + const other = await json(await req('/organizations', { method: 'POST', body: JSON.stringify({ name: 'Other' }) })); + + expect((await req('/organizations/org_nope/it_contacts')).status).toBe(404); + expect( + (await req(`/organizations/${organizationId}/it_contacts/it_contact_nope`, { method: 'DELETE' })).status, + ).toBe(404); + // Addressable only through its own organization. + expect((await req(`/organizations/${other.id}/it_contacts/${contact.id}`, { method: 'DELETE' })).status).toBe(404); + }); + + it('drops contacts with the organization', async () => { + const contact = await json(await create('it@acme.com')); + expect((await req(`/organizations/${organizationId}`, { method: 'DELETE' })).status).toBe(204); + expect(getWorkOSStore(store).itContacts.get(contact.id)).toBeUndefined(); + }); +}); diff --git a/src/workos/routes/it-contacts.ts b/src/workos/routes/it-contacts.ts new file mode 100644 index 0000000..ebbd311 --- /dev/null +++ b/src/workos/routes/it-contacts.ts @@ -0,0 +1,150 @@ +import { type RouteContext, WorkOSApiError, notFound, parseJsonBody, validationError } from '../../core/index.js'; +import type { WorkOSItContact } from '../entities.js'; +import { emailsMatch, formatItContact, requireEmailField } from '../helpers.js'; +import { getWorkOSStore } from '../store.js'; + +/** The Admin Portal features an invitation may grant, per `InviteItContactDto`. */ +const INVITE_INTENTS = ['sso', 'directory_sync', 'log_streams', 'domain_verification', 'bring_your_own_key']; + +export function itContactRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + const ws = getWorkOSStore(store); + + const requireOrganization = (organizationId: string) => { + const org = ws.organizations.get(organizationId); + if (!org) throw notFound('Organization'); + return org; + }; + + /** A contact is only addressable through its own organization; another org's is not found. */ + const requireContact = (organizationId: string, contactId: string): WorkOSItContact => { + requireOrganization(organizationId); + const contact = ws.itContacts.get(contactId); + if (!contact || contact.organization_id !== organizationId) throw notFound('ItContact'); + return contact; + }; + + const activeInvitation = (organizationId: string) => + ws.itContacts.findBy('organization_id', organizationId).find((c) => c.invited_at !== null); + + // List IT contacts + app.get('/organizations/:organization_id/it_contacts', (c) => { + const organizationId = c.req.param('organization_id'); + requireOrganization(organizationId); + const contacts = ws.itContacts + .findBy('organization_id', organizationId) + .sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id)); + // The spec gives this route no pagination parameters, so the whole set is one page and + // both cursors are null — the envelope is still `list`, which the SDKs deserialize. + return c.json({ + object: 'list', + data: contacts.map(formatItContact), + list_metadata: { before: null, after: null }, + }); + }); + + // Create an IT contact + app.post('/organizations/:organization_id/it_contacts', async (c) => { + const organizationId = c.req.param('organization_id'); + requireOrganization(organizationId); + const body = await parseJsonBody(c); + + // `requireShape`, because this creates a contact from the address rather than looking one + // up: a stored typo is an invitation that can never reach anyone. + const email = requireEmailField(body.email, { requireShape: true }); + + // Scoped to the organization, not global: the same address may be an IT contact of + // several organizations, which is why the index is on both fields. + const taken = ws.itContacts + .findBy('organization_id', organizationId) + .some((existing) => emailsMatch(existing.email, email)); + if (taken) { + throw new WorkOSApiError( + 409, + 'The email address is already an IT contact of the organization.', + 'it_contact_already_exists', + ); + } + + const contact = ws.itContacts.insert({ + object: 'it_contact', + organization_id: organizationId, + email, + invited_at: null, + invite_intents: null, + invite_setup_link: null, + }); + // No invitation is sent on create, per the spec: an invitation is a separate call. + return c.json(formatItContact(contact), 201); + }); + + // Delete an IT contact + app.delete('/organizations/:organization_id/it_contacts/:contact_id', (c) => { + const contact = requireContact(c.req.param('organization_id'), c.req.param('contact_id')); + // "Remove an IT contact ... and revoke the contact's active setup links" — deleting the + // record drops its invitation with it, which frees the organization's single active slot. + ws.itContacts.delete(contact.id); + return c.body(null, 204); + }); + + // Invite an IT contact to the Admin Portal + app.post('/organizations/:organization_id/it_contacts/:contact_id/invite', async (c) => { + const organizationId = c.req.param('organization_id'); + const contact = requireContact(organizationId, c.req.param('contact_id')); + const body = await parseJsonBody(c); + + const intents = body.intents; + if (!Array.isArray(intents) || intents.length === 0) { + throw validationError('intents is required and must be a non-empty array', [ + { field: 'intents', code: 'required' }, + ]); + } + const unknown = intents.filter((i) => typeof i !== 'string' || !INVITE_INTENTS.includes(i)); + if (unknown.length > 0) { + throw validationError(`intents must be one of: ${INVITE_INTENTS.join(', ')}`, [ + { field: 'intents', code: 'invalid' }, + ]); + } + if (new Set(intents).size !== intents.length) { + throw validationError('intents must not contain duplicates', [{ field: 'intents', code: 'invalid' }]); + } + + // "An organization can have at most one active invitation" — so a second contact conflicts, + // but re-inviting the one that already holds it refreshes their link and leaves the count + // at one, which is the resend a caller reaches for when the first email goes astray. + const active = activeInvitation(organizationId); + if (active && active.id !== contact.id) { + throw new WorkOSApiError( + 409, + 'Another IT contact invitation is already active for the organization.', + 'it_contact_invitation_already_active', + ); + } + + // The setup link the invitation would have emailed. Nothing delivers it and no route + // serves it; it exists so the invitation has the artifact production would have created. + const baseUrl = new URL(c.req.url).origin; + ws.itContacts.update(contact.id, { + invited_at: new Date().toISOString(), + invite_intents: intents as string[], + invite_setup_link: `${baseUrl}/portal/setup/${contact.id}`, + }); + return c.body(null, 204); + }); + + // Revoke the organization's active invitation + app.post('/organizations/:organization_id/it_contacts/:contact_id/revoke', (c) => { + const organizationId = c.req.param('organization_id'); + requireContact(organizationId, c.req.param('contact_id')); + // The spec revokes "the organization's active Admin Portal invitation", not this contact's: + // there is at most one, and no route reports which contact holds it, so revoking through a + // contact that does not hold it still has to clear it — otherwise a caller who cannot know + // whom to address gets a 204 and stays wedged behind a 409 on the next invite. + const active = activeInvitation(organizationId); + // With none active there is nothing to write: a no-op must not bump `updated_at`. + if (active) { + ws.itContacts.update(active.id, { invited_at: null, invite_intents: null, invite_setup_link: null }); + } + return c.body(null, 204); + }); +} diff --git a/src/workos/routes/organizations.ts b/src/workos/routes/organizations.ts index 7766694..bd5d8d4 100644 --- a/src/workos/routes/organizations.ts +++ b/src/workos/routes/organizations.ts @@ -174,6 +174,7 @@ export function organizationRoutes(ctx: RouteContext): void { if (!org) throw notFound('Organization'); ws.organizationDomains.deleteBy('organization_id', org.id); + ws.itContacts.deleteBy('organization_id', org.id); for (const membership of ws.organizationMemberships.findBy('organization_id', org.id)) { ws.roleAssignments.deleteBy('organization_membership_id', membership.id); } diff --git a/src/workos/store.ts b/src/workos/store.ts index 917cb5e..6b6612e 100644 --- a/src/workos/store.ts +++ b/src/workos/store.ts @@ -3,6 +3,7 @@ import { STORE_KEYS } from './constants.js'; import type { WorkOSOrganization, WorkOSOrganizationDomain, + WorkOSItContact, WorkOSOrganizationMembership, WorkOSGroup, WorkOSGroupMembership, @@ -56,6 +57,7 @@ import type { export interface WorkOSStore { organizations: Collection; organizationDomains: Collection; + itContacts: Collection; organizationMemberships: Collection; groups: Collection; groupMemberships: Collection; @@ -120,6 +122,10 @@ export function getWorkOSStore(store: Store): WorkOSStore { ID_PREFIXES.organization_domain, ['organization_id', 'domain'], ), + itContacts: store.collection('workos.it_contacts', ID_PREFIXES.it_contact, [ + 'organization_id', + 'email', + ]), organizationMemberships: store.collection( 'workos.organization_memberships', ID_PREFIXES.organization_membership, From 5e8250cffc7707c622783e545f6b1e445fc117f8 Mon Sep 17 00:00:00 2001 From: Daniel Loader Date: Sat, 26 Sep 2026 22:25:19 +0100 Subject: [PATCH 2/3] refactor(organizations): address pre-PR review of IT contacts Invitation state is stamped with updateSilent, matching the emulator's other non-serialized stamps: none of it reaches the wire, so `updated_at` must not move and claim the resource changed. Three assertions could not fail, found by mutating the implementation against them: the `updated_at` no-op check compared two live timestamps taken inside the same millisecond, a revoke that cleared only `invited_at` passed, and the `.trim()` was unpinned because the blank-address case 422s on shape either way. Each now fails when the behaviour it names is broken. 404 coverage reached DELETE only; invite, revoke and create against an unknown organization are covered too. Also: the catalog entry was inserted between a comment and the entry that comment documents, the duplicate-email comment claimed an index half that is never used (and cannot be, since uniqueness is case-insensitive while the address is stored as spelled), route params were snake_case where every sibling is camelCase, and five route-header comments restated the line below them. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/gen-shapes-lib.ts | 2 +- src/workos/helpers.ts | 4 +- src/workos/routes/it-contacts.spec.ts | 67 ++++++++++++++++++++++----- src/workos/routes/it-contacts.ts | 51 ++++++++++---------- 4 files changed, 82 insertions(+), 42 deletions(-) diff --git a/scripts/gen-shapes-lib.ts b/scripts/gen-shapes-lib.ts index 07646e8..7880197 100644 --- a/scripts/gen-shapes-lib.ts +++ b/scripts/gen-shapes-lib.ts @@ -66,9 +66,9 @@ 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: 'it_contact', schemaName: 'ItContact' }, { objectType: 'connect_application_secret', schemaName: 'NewConnectApplicationSecret' }, // `connect_application` is deliberately absent: `ConnectApplication` is an allOf over a // four-way oneOf (first-party / dynamically registered / third-party oauth, and m2m), and diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index f603f9f..84ecf64 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -132,8 +132,8 @@ const IT_CONTACT_EXCLUDE = new Set([ 'invite_setup_link', ]); -export function formatItContact(c: WorkOSItContact): Record { - return formatEntity(c, { exclude: IT_CONTACT_EXCLUDE }); +export function formatItContact(contact: WorkOSItContact): Record { + return formatEntity(contact, { exclude: IT_CONTACT_EXCLUDE }); } export function formatMembership(m: WorkOSOrganizationMembership, ws: WorkOSStore): Record { diff --git a/src/workos/routes/it-contacts.spec.ts b/src/workos/routes/it-contacts.spec.ts index 21fa6ea..c312597 100644 --- a/src/workos/routes/it-contacts.spec.ts +++ b/src/workos/routes/it-contacts.spec.ts @@ -75,12 +75,25 @@ describe('IT contacts', () => { expect(res.status).toBe(201); }); + it('trims the address, and a padded copy is the same contact', async () => { + const contact = await json(await create(' it@acme.com ')); + expect(contact.email).toBe('it@acme.com'); + expect((await create('it@acme.com')).status).toBe(409); + }); + it('rejects a missing or malformed email', async () => { const post = (body: unknown) => req(`/organizations/${organizationId}/it_contacts`, { method: 'POST', body: JSON.stringify(body) }); expect((await post({})).status).toBe(422); expect((await post({ email: ' ' })).status).toBe(422); expect((await post({ email: 'not-an-email' })).status).toBe(422); + for (const malformed of ['@acme.com', 'it@', 'a@b@c', 'it there@acme.com']) { + expect((await post({ email: malformed })).status, malformed).toBe(422); + } + // A non-string names the type, as every other CRUD route does. + const typed = await post({ email: 123 }); + expect(typed.status).toBe(422); + expect((await json(typed)).errors[0].code).toBe('invalid_type'); }); it('invites a contact and records the invitation', async () => { @@ -107,11 +120,19 @@ describe('IT contacts', () => { expect(conflict.status).toBe(409); expect((await json(conflict)).code).toBe('it_contact_invitation_already_active'); - // Revoking frees the slot. + // Revoking frees the slot, and clears the whole invitation rather than just the flag. expect( (await req(`/organizations/${organizationId}/it_contacts/${first.id}/revoke`, { method: 'POST' })).status, ).toBe(204); + const revoked = getWorkOSStore(store).itContacts.get(first.id)!; + expect(revoked.invited_at).toBeNull(); + expect(revoked.invite_intents).toBeNull(); + expect(revoked.invite_setup_link).toBeNull(); + expect((await invite(second.id)).status).toBe(204); + // The slot moved rather than being held by both. + expect(getWorkOSStore(store).itContacts.get(first.id)!.invited_at).toBeNull(); + expect(getWorkOSStore(store).itContacts.get(second.id)!.invited_at).not.toBeNull(); }); it('re-invites the holder to refresh their link, leaving the count at one', async () => { @@ -142,16 +163,23 @@ describe('IT contacts', () => { expect((await invite(other.id)).status).toBe(204); }); - it('revokes with no active invitation as a no-op, without touching the record', async () => { + it('leaves updated_at alone: invitation state never reaches the wire', async () => { const contact = await json(await create('it@acme.com')); - const before = getWorkOSStore(store).itContacts.get(contact.id)!.updated_at; - - const res = await req(`/organizations/${organizationId}/it_contacts/${contact.id}/revoke`, { method: 'POST' }); - expect(res.status).toBe(204); - const after = getWorkOSStore(store).itContacts.get(contact.id)!; - expect(after.invited_at).toBeNull(); - // Nothing changed, so nothing was written. - expect(after.updated_at).toBe(before); + const contacts = getWorkOSStore(store).itContacts; + // Pinned to a known value, because create and revoke otherwise land in the same + // millisecond and comparing live timestamps would pass however the route writes. + const STAMP = '2020-01-01T00:00:00.000Z'; + contacts.updateSilent(contact.id, { updated_at: STAMP }); + + const revoke = () => req(`/organizations/${organizationId}/it_contacts/${contact.id}/revoke`, { method: 'POST' }); + expect((await revoke()).status).toBe(204); + expect(contacts.get(contact.id)!.updated_at).toBe(STAMP); + + // A real invitation, and revoking it, are equally invisible on the resource. + expect((await invite(contact.id)).status).toBe(204); + expect(contacts.get(contact.id)!.updated_at).toBe(STAMP); + expect((await revoke()).status).toBe(204); + expect(contacts.get(contact.id)!.updated_at).toBe(STAMP); }); it('rejects invalid intents', async () => { @@ -181,8 +209,23 @@ describe('IT contacts', () => { expect( (await req(`/organizations/${organizationId}/it_contacts/it_contact_nope`, { method: 'DELETE' })).status, ).toBe(404); - // Addressable only through its own organization. - expect((await req(`/organizations/${other.id}/it_contacts/${contact.id}`, { method: 'DELETE' })).status).toBe(404); + // Addressable only through its own organization, on every sub-route — not just DELETE. + for (const path of [ + `/organizations/${other.id}/it_contacts/${contact.id}`, + `/organizations/${organizationId}/it_contacts/it_contact_nope`, + ]) { + expect((await req(path, { method: 'DELETE' })).status, path).toBe(404); + expect( + (await req(`${path}/invite`, { method: 'POST', body: JSON.stringify({ intents: ['sso'] }) })).status, + path, + ).toBe(404); + expect((await req(`${path}/revoke`, { method: 'POST' })).status, path).toBe(404); + } + // And creating against an organization that does not exist. + expect( + (await req('/organizations/org_nope/it_contacts', { method: 'POST', body: JSON.stringify({ email: 'a@b.com' }) })) + .status, + ).toBe(404); }); it('drops contacts with the organization', async () => { diff --git a/src/workos/routes/it-contacts.ts b/src/workos/routes/it-contacts.ts index ebbd311..9fd6b5e 100644 --- a/src/workos/routes/it-contacts.ts +++ b/src/workos/routes/it-contacts.ts @@ -4,7 +4,7 @@ import { emailsMatch, formatItContact, requireEmailField } from '../helpers.js'; import { getWorkOSStore } from '../store.js'; /** The Admin Portal features an invitation may grant, per `InviteItContactDto`. */ -const INVITE_INTENTS = ['sso', 'directory_sync', 'log_streams', 'domain_verification', 'bring_your_own_key']; +const INVITE_INTENTS = ['sso', 'directory_sync', 'log_streams', 'domain_verification', 'bring_your_own_key'] as const; export function itContactRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -25,11 +25,10 @@ export function itContactRoutes(ctx: RouteContext): void { }; const activeInvitation = (organizationId: string) => - ws.itContacts.findBy('organization_id', organizationId).find((c) => c.invited_at !== null); + ws.itContacts.findBy('organization_id', organizationId).find((contact) => contact.invited_at !== null); - // List IT contacts - app.get('/organizations/:organization_id/it_contacts', (c) => { - const organizationId = c.req.param('organization_id'); + app.get('/organizations/:organizationId/it_contacts', (c) => { + const organizationId = c.req.param('organizationId'); requireOrganization(organizationId); const contacts = ws.itContacts .findBy('organization_id', organizationId) @@ -43,9 +42,8 @@ export function itContactRoutes(ctx: RouteContext): void { }); }); - // Create an IT contact - app.post('/organizations/:organization_id/it_contacts', async (c) => { - const organizationId = c.req.param('organization_id'); + app.post('/organizations/:organizationId/it_contacts', async (c) => { + const organizationId = c.req.param('organizationId'); requireOrganization(organizationId); const body = await parseJsonBody(c); @@ -53,8 +51,9 @@ export function itContactRoutes(ctx: RouteContext): void { // up: a stored typo is an invitation that can never reach anyone. const email = requireEmailField(body.email, { requireShape: true }); - // Scoped to the organization, not global: the same address may be an IT contact of - // several organizations, which is why the index is on both fields. + // Scoped to the organization: the same address may be an IT contact of several. Matched + // through `emailsMatch` rather than the collection's email index, because uniqueness is + // case-insensitive while the address is stored as the caller spelled it. const taken = ws.itContacts .findBy('organization_id', organizationId) .some((existing) => emailsMatch(existing.email, email)); @@ -74,23 +73,20 @@ export function itContactRoutes(ctx: RouteContext): void { invite_intents: null, invite_setup_link: null, }); - // No invitation is sent on create, per the spec: an invitation is a separate call. return c.json(formatItContact(contact), 201); }); - // Delete an IT contact - app.delete('/organizations/:organization_id/it_contacts/:contact_id', (c) => { - const contact = requireContact(c.req.param('organization_id'), c.req.param('contact_id')); + app.delete('/organizations/:organizationId/it_contacts/:contactId', (c) => { + const contact = requireContact(c.req.param('organizationId'), c.req.param('contactId')); // "Remove an IT contact ... and revoke the contact's active setup links" — deleting the // record drops its invitation with it, which frees the organization's single active slot. ws.itContacts.delete(contact.id); return c.body(null, 204); }); - // Invite an IT contact to the Admin Portal - app.post('/organizations/:organization_id/it_contacts/:contact_id/invite', async (c) => { - const organizationId = c.req.param('organization_id'); - const contact = requireContact(organizationId, c.req.param('contact_id')); + app.post('/organizations/:organizationId/it_contacts/:contactId/invite', async (c) => { + const organizationId = c.req.param('organizationId'); + const contact = requireContact(organizationId, c.req.param('contactId')); const body = await parseJsonBody(c); const intents = body.intents; @@ -99,8 +95,8 @@ export function itContactRoutes(ctx: RouteContext): void { { field: 'intents', code: 'required' }, ]); } - const unknown = intents.filter((i) => typeof i !== 'string' || !INVITE_INTENTS.includes(i)); - if (unknown.length > 0) { + const invalid = intents.filter((i) => typeof i !== 'string' || !INVITE_INTENTS.includes(i as never)); + if (invalid.length > 0) { throw validationError(`intents must be one of: ${INVITE_INTENTS.join(', ')}`, [ { field: 'intents', code: 'invalid' }, ]); @@ -124,7 +120,10 @@ export function itContactRoutes(ctx: RouteContext): void { // The setup link the invitation would have emailed. Nothing delivers it and no route // serves it; it exists so the invitation has the artifact production would have created. const baseUrl = new URL(c.req.url).origin; - ws.itContacts.update(contact.id, { + // Silent, like the emulator's other non-serialized stamps (`last_used_at`, + // `last_sign_in_at`): none of this reaches the wire, so `updated_at` must not move and + // claim the resource changed. + ws.itContacts.updateSilent(contact.id, { invited_at: new Date().toISOString(), invite_intents: intents as string[], invite_setup_link: `${baseUrl}/portal/setup/${contact.id}`, @@ -132,18 +131,16 @@ export function itContactRoutes(ctx: RouteContext): void { return c.body(null, 204); }); - // Revoke the organization's active invitation - app.post('/organizations/:organization_id/it_contacts/:contact_id/revoke', (c) => { - const organizationId = c.req.param('organization_id'); - requireContact(organizationId, c.req.param('contact_id')); + app.post('/organizations/:organizationId/it_contacts/:contactId/revoke', (c) => { + const organizationId = c.req.param('organizationId'); + requireContact(organizationId, c.req.param('contactId')); // The spec revokes "the organization's active Admin Portal invitation", not this contact's: // there is at most one, and no route reports which contact holds it, so revoking through a // contact that does not hold it still has to clear it — otherwise a caller who cannot know // whom to address gets a 204 and stays wedged behind a 409 on the next invite. const active = activeInvitation(organizationId); - // With none active there is nothing to write: a no-op must not bump `updated_at`. if (active) { - ws.itContacts.update(active.id, { invited_at: null, invite_intents: null, invite_setup_link: null }); + ws.itContacts.updateSilent(active.id, { invited_at: null, invite_intents: null, invite_setup_link: null }); } return c.body(null, 204); }); From ec8a20c256bdb4277109e532b10ffbe971b3c721 Mon Sep 17 00:00:00 2001 From: Daniel Loader Date: Sun, 27 Sep 2026 22:07:00 +0100 Subject: [PATCH 3/3] fix(organizations): paginate the IT contact listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route served every contact with both cursors null. The spec documents no query parameters here, but it returns a cursor-bearing `ItContactList` all the same, and `/authorization/roles` — the same shape, also without documented parameters — is paginated through the shared path. Serving the whole set made the envelope a lie the moment an organization had more contacts than a page, and left the caller no way to ask for the rest. Ordering follows the repo default of newest first rather than the hand-rolled oldest first it had. Co-Authored-By: Claude Opus 5 (1M context) --- src/workos/routes/it-contacts.spec.ts | 28 ++++++++++++++++++++++++--- src/workos/routes/it-contacts.ts | 28 ++++++++++++++++----------- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/workos/routes/it-contacts.spec.ts b/src/workos/routes/it-contacts.spec.ts index c312597..18a6ff2 100644 --- a/src/workos/routes/it-contacts.spec.ts +++ b/src/workos/routes/it-contacts.spec.ts @@ -45,17 +45,39 @@ describe('IT contacts', () => { expect(contact.organization_id).toBeUndefined(); }); - it('lists contacts oldest first in a list envelope', async () => { + it('lists contacts newest first, as every other list route does', async () => { const first = await json(await create('first@acme.com')); const second = await json(await create('second@acme.com')); + // Back-dated so creation order and insertion order disagree: without the sort the ids + // alone would already come back in the asserted order. getWorkOSStore(store).itContacts.updateSilent(second.id, { created_at: '2020-01-01T00:00:00.000Z' }); const res = await req(`/organizations/${organizationId}/it_contacts`); expect(res.status).toBe(200); const body = await json(res); expect(body.object).toBe('list'); - expect(body.list_metadata).toEqual({ before: null, after: null }); - expect(body.data.map((c: any) => c.id)).toEqual([second.id, first.id]); + expect(body.data.map((c: any) => c.id)).toEqual([first.id, second.id]); + }); + + it('paginates, and scopes the page to the organization', async () => { + const ids: string[] = []; + for (const n of [1, 2, 3]) ids.push((await json(await create(`c${n}@acme.com`))).id); + // Another organization's contacts must not leak into the page. + const other = await json(await req('/organizations', { method: 'POST', body: JSON.stringify({ name: 'Other' }) })); + await req(`/organizations/${other.id}/it_contacts`, { + method: 'POST', + body: JSON.stringify({ email: 'elsewhere@other.com' }), + }); + + const firstPage = await json(await req(`/organizations/${organizationId}/it_contacts?limit=2`)); + expect(firstPage.data).toHaveLength(2); + expect(firstPage.list_metadata.after).not.toBeNull(); + + const nextPage = await json( + await req(`/organizations/${organizationId}/it_contacts?limit=2&after=${firstPage.list_metadata.after}`), + ); + const paged = [...firstPage.data, ...nextPage.data].map((c: any) => c.id); + expect(paged.sort()).toEqual([...ids].sort()); }); it('rejects a duplicate email within the organization, case-insensitively', async () => { diff --git a/src/workos/routes/it-contacts.ts b/src/workos/routes/it-contacts.ts index 9fd6b5e..9beced2 100644 --- a/src/workos/routes/it-contacts.ts +++ b/src/workos/routes/it-contacts.ts @@ -1,6 +1,13 @@ -import { type RouteContext, WorkOSApiError, notFound, parseJsonBody, validationError } from '../../core/index.js'; +import { + type RouteContext, + WorkOSApiError, + notFound, + parseJsonBody, + parseListParams, + validationError, +} from '../../core/index.js'; import type { WorkOSItContact } from '../entities.js'; -import { emailsMatch, formatItContact, requireEmailField } from '../helpers.js'; +import { emailsMatch, formatItContact, formatListResponse, requireEmailField } from '../helpers.js'; import { getWorkOSStore } from '../store.js'; /** The Admin Portal features an invitation may grant, per `InviteItContactDto`. */ @@ -30,16 +37,15 @@ export function itContactRoutes(ctx: RouteContext): void { app.get('/organizations/:organizationId/it_contacts', (c) => { const organizationId = c.req.param('organizationId'); requireOrganization(organizationId); - const contacts = ws.itContacts - .findBy('organization_id', organizationId) - .sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id)); - // The spec gives this route no pagination parameters, so the whole set is one page and - // both cursors are null — the envelope is still `list`, which the SDKs deserialize. - return c.json({ - object: 'list', - data: contacts.map(formatItContact), - list_metadata: { before: null, after: null }, + // The spec documents no query parameters here, but it returns a cursor-bearing + // `ItContactList` all the same — and `/authorization/roles` is the same shape and is + // paginated. Serving the whole set with null cursors would make the envelope a lie the + // moment an organization has more contacts than a page. + const result = ws.itContacts.list({ + ...parseListParams(new URL(c.req.url)), + filter: (contact) => contact.organization_id === organizationId, }); + return c.json(formatListResponse(result, formatItContact)); }); app.post('/organizations/:organizationId/it_contacts', async (c) => {