Skip to content

feat(organizations): implement IT contacts - #126

Open
danielloader wants to merge 2 commits into
workos:mainfrom
danielloader:feat/organization-it-contacts
Open

danielloader wants to merge 2 commits into
workos:mainfrom
danielloader:feat/organization-it-contacts

Conversation

@danielloader

Copy link
Copy Markdown
Contributor

IT contacts were the whole of the Organizations shortfall — the feature sat at ⚠️ 5/6 read and ⚠️ 6/10 write, and all five missing endpoints were these. Organizations is now ✅ 6/6 read, ✅ 10/10 write. Repo coverage 184 → 189 of 261 (72.4%).

Method Path
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

The behaviour comes from the spec's prose, not just its schemas

The ItContact schema is nearly trivial, but the operation descriptions carry the real rules, and both are enforced:

  • "The email address is already an IT contact of the organization" — 409 it_contact_already_exists, scoped to the organization and matched case-insensitively, so the same address may serve several organizations.
  • "An organization can have at most one active invitation" — a second contact gets 409 it_contact_invitation_already_active. Re-inviting the contact who already holds it refreshes their link instead, because the count stays at one and that is the resend a caller reaches for when the first email goes astray.
  • "Remove an IT contact … and revoke the contact's active setup links" — delete drops the record and frees the slot.
  • "No Admin Portal invitation is sent" on create — creation leaves the invitation empty.

Both 409 code values are the ones the spec pins with const, not invented names.

Revoke clears the organization's invitation, through whichever contact you address

The spec's description is "Revoke the organization's active Admin Portal invitation" — the organization's, not the contact's. That distinction matters here because no endpoint reports which contact holds the invitation. A per-contact revoke would answer 204 to a caller who addressed the wrong one, change nothing, and leave them wedged behind a 409 on their next invite, with no way to discover whom to ask about. So revoking through any contact clears it.

Invitation state is stored but never serialized

ItContact has exactly five properties, all required, and invite and revoke both answer 204 — so there is nowhere in the spec for an invitation to surface. It is still stored (invited_at, invite_intents, and the setup link production would have emailed), because the one-invitation rule is unenforceable without it. Same shape as login_url on connect applications.

Stamping it uses updateSilent, like the emulator's other non-serialized stamps (last_used_at, last_sign_in_at): none of it reaches the wire, so updated_at must not move and claim the resource changed.

Nothing is emailed — the emulator has no mail outbox to wire into.

Not implemented, deliberately

403 and 503 are documented on all five operations and neither is implemented. The emulator's store is not environment-scoped, so the forbidden case cannot arise — no WorkOS route in the repo calls forbidden() — and 503 is a production infrastructure state. Both remain injectable per-route through the existing error hooks. This is recorded in the README section and the Organizations note in SUPPORTED.md.

One pre-existing, emulator-wide divergence is untouched: a missing or unparseable body answers 400 from the shared parseJsonBody, where these operations document 422.

Conformance and tests

it_contact joins the generated resource catalog and ItContactList the envelope catalog, so the wire shape is checked against the spec rather than by hand — the field-set assertion is what keeps the invitation state off the wire.

The tests were mutation-tested, and three of them turned out unfalsifiable before this was raised:

  • the updated_at no-op check compared two live timestamps taken inside the same millisecond — it passed with the guard deleted entirely
  • a revoke clearing only invited_at, leaving invite_intents and the setup link stale, passed the whole suite — and that clearing is exactly what the README promises
  • .trim() was unpinned, because the blank-address case 422s on shape whether or not the trim runs

Each now fails when the behaviour it names is broken; I re-ran the mutations to confirm. 404 coverage reached DELETE only, so invite, revoke and create-against-an-unknown-organization are covered too.

bun test: 1364 pass, 0 fail. typecheck, lint, fmt:check clean. gen:shapes and gen:supported re-run byte-identical, so there is no codegen drift.

🤖 Generated with Claude Code

danielloader and others added 2 commits September 26, 2026 22:22
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Medium risk] Adds IT contact management to organizations.

The PR should not merge until IT-contact listing supports paginated requests.

Findings

  1. P1 IT contact listing ignores pagination ▶
Fix with agent prompt
### Issue 1
src/workos/routes/it-contacts.ts:33-41
When a caller requests a page with `limit` or a cursor, this route ignores those parameters, returns every contact, and sets both cursors to null. The caller cannot fetch subsequent pages, and larger organizations receive the full list in one response. Use the existing cursor-pagination path instead of constructing a single-page response.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR adds IT-contact storage, serialization, five organization routes, conformance tests, and documentation. The list route needs to honor pagination before the endpoint can be treated as fully implemented.

Reviews (1) · Last reviewed commit: "refactor(organizations): address pre-PR ..."

Comment on lines +33 to +41
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 },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 IT contact listing ignores pagination When a caller requests a page with limit or a cursor, this route ignores those parameters, returns every contact, and sets both cursors to null. The caller cannot fetch subsequent pages, and larger organizations receive the full list in one response. Use the existing cursor-pagination path instead of constructing a single-page response.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/workos/routes/it-contacts.ts
Line: 33-41

Comment:
**IT contact listing ignores pagination** When a caller requests a page with `limit` or a cursor, this route ignores those parameters, returns every contact, and sets both cursors to null. The caller cannot fetch subsequent pages, and larger organizations receive the full list in one response. Use the existing cursor-pagination path instead of constructing a single-page response.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant