Native invite acceptance
This package lifts the native hosted login restriction
that an organization-scoped callback requires a pre-existing kept membership.
The GET /accounts/auth/workos begin/callback pair now covers the invited
user’s first login: after WorkOS accepts the invitation and returns an
organization-scoped authentication, API2 provisions the local membership and
employee itself instead of failing with a generic auth error. Invitation
creation, revocation, WorkOS invitation emails, the invitation.accepted /
organization_membership.created webhooks, and the direct invite-email
callback remain owned by Rails.
Contract
- Membership resolution inside the hosted-login transaction mirrors Rails
Authentication#organization_from_workos_response→Membership.find_or_create_from_workosexactly:- A kept membership is reused. A pending one is healed like
#sync_status_from_workos!: read the user’s WorkOS organization membership (limit 1, three-second timeout, no retries), apply a valid status, fill a blankworkos_membership_id, and never overwrite a present one. Role and scopes are preserved; no second employee is created. - A discarded membership means no access. The login still succeeds —
tenantless session, no
organization_id, nodefault_organization_idwrite — and the membership is never revived. (The hosted-login stage rejected this case; Rails never did.) - With no local membership at all, read the WorkOS organization membership
for the authenticated user and provision: resolve the local role by
persisted WorkOS slug first and key second, preferring this organization’s
role over a global one; create the membership with the WorkOS status (a
valid status, else
pending),scope_type = 'organization',full_workspace_access = true, a Rails-format nanoidpublic_id, and the WorkOS membership ID; then run the#link_employee!equivalent — link the organization employee matching the account email (healingAnonymous/Unnamed Employeeplaceholder names from the account’s full name) or create one withevaluable = (role key == 'agent')andactor_type = 'human'.
- A kept membership is reused. A pending one is healed like
- All writes stay inside the existing hosted-login transaction under its
advisory lock, so a WorkOS failure or identity conflict rolls back account,
membership, employee and session changes together. A membership insert that
loses the race to the Rails
organization_membership.createdwebhook reuses the webhook’s row via the(account_id, organization_id)unique index. - Fail closed with the same generic auth error when WorkOS reports no membership for the user, when the WorkOS membership belongs to a different user or organization, or when the WorkOS role slug does not resolve to a local role. Logs keep the existing event names and carry no email, tokens or session data.
- Only
public.*legacy rows are written. Theapi2.users/api2.membershipsbridge stays lazily reconciled by session validation (upsertSessionRecords), the same pattern hosted login and organization switch use.
Focused pglite tests cover first-login provisioning (role, status, employee,
default organization, Rails-readable session), org-role preference and agent
evaluability, existing-employee linking with placeholder-name healing, pending
healing with and without a stored workos_membership_id, no WorkOS call for
active memberships, tenantless discarded logins, fail-closed rollback for
missing WorkOS memberships and unknown roles, and webhook-race reuse. Unit
tests pin the WorkOS directory mapping and Rails’ role-slug extraction shapes.
Explicit Rails dependencies (open gates)
- Direct invite-email callbacks. The link in the WorkOS invitation email
authenticates on WorkOS’s hosted screens and redirects with a code but no
API2-issued state or PKCE verifier, so the native callback cannot exchange
it. That redirect must keep targeting the Rails callback (which accepts a
nil state) until the invite email’s redirect flows through the native begin
endpoint. First-party UI callers accept invites natively by starting at
GET /accounts/auth/workos(optionally withorganization_id/login_hint). - Invitation records. The
invitationsrow’spending → acceptedtransition, audit events, revocation and WorkOS invitation API calls remain on Rails (invitation.acceptedwebhook,InvitationsController). - Scoped invitations. Group/partner/team invitation scopes are applied
only by the Rails
organization_membership.createdwebhook, exactly as in Rails today: the login path (here and in Rails) provisions organization-scoped memberships and relies on the webhook when it lands first. - WorkOS-only roles. A role that exists only in WorkOS (created in its dashboard, never synced locally) fails the native login closed instead of being imported mid-login. Rails webhooks and the API2 WorkOS membership reconciliation sync own role import; a retry converges once the role exists locally.
Rollout
No schema change, no new endpoint, no flag: the behavior ships inside the
already-deployed native hosted-login callback and uses the same WorkOS secret
key the reconciliation sync reads. Before relying on it, exercise in each
region: an actual invited-user first login landing with an active membership
and linked employee, a pending-membership login healing to active, and a
revoked-membership (discarded) login landing tenantless. Watch the existing
native_hosted_login_completed / native_hosted_login_failed events for a
failure-rate change after deploy.
Rollback is a code revert of this package; the hosted-login contract returns to requiring a pre-existing kept membership and Rails webhooks continue to provision memberships out of band. Provisioned rows are ordinary Rails-shaped memberships/employees and need no cleanup on rollback.