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..7880197 100644 --- a/scripts/gen-shapes-lib.ts +++ b/scripts/gen-shapes-lib.ts @@ -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' }, @@ -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(contact, { 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..18a6ff2 --- /dev/null +++ b/src/workos/routes/it-contacts.spec.ts @@ -0,0 +1,258 @@ +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 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.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 () => { + 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('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 () => { + 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, 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 () => { + 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('leaves updated_at alone: invitation state never reaches the wire', async () => { + const contact = await json(await create('it@acme.com')); + 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 () => { + 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, 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 () => { + 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..9beced2 --- /dev/null +++ b/src/workos/routes/it-contacts.ts @@ -0,0 +1,153 @@ +import { + type RouteContext, + WorkOSApiError, + notFound, + parseJsonBody, + parseListParams, + validationError, +} from '../../core/index.js'; +import type { WorkOSItContact } from '../entities.js'; +import { emailsMatch, formatItContact, formatListResponse, 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'] as const; + +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((contact) => contact.invited_at !== null); + + app.get('/organizations/:organizationId/it_contacts', (c) => { + const organizationId = c.req.param('organizationId'); + requireOrganization(organizationId); + // 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) => { + const organizationId = c.req.param('organizationId'); + 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: 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)); + 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, + }); + return c.json(formatItContact(contact), 201); + }); + + 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); + }); + + 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; + if (!Array.isArray(intents) || intents.length === 0) { + throw validationError('intents is required and must be a non-empty array', [ + { field: 'intents', code: 'required' }, + ]); + } + 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' }, + ]); + } + 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; + // 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}`, + }); + return c.body(null, 204); + }); + + 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); + if (active) { + ws.itContacts.updateSilent(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,