Skip to content

feat(organizations): implement IT contacts - #126

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

danielloader wants to merge 3 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: 5/5

[Medium risk] Adds IT contact management to organizations.

The PR appears safe to merge based on this review.

Summary

The PR adds organization IT-contact storage, routes, response-shape coverage, and documentation. Since the previous review, it changed the list route to use the shared cursor-pagination path and added pagination tests.

Reviews (2) · Last reviewed commit: "fix(organizations): paginate the IT cont..."

Comment thread src/workos/routes/it-contacts.ts Outdated
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) <noreply@anthropic.com>
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