Symptom
Accepting a valid org invitation never transitions the row from pending to accepted.
- Observed:
accept-invitation returns HTTP 500 with an empty body, and the sys_invitation row stays pending forever. Reproduced twice with fresh emails.
- Expected: 2xx, the invitation moves to
accepted, and the invitee holds exactly one membership in the target org.
Server log names the real cause:
insert into sys_member … UNIQUE constraint failed: sys_member.organization_id, sys_member.user_id
The delegated-admin scope itself is correct (a real delegated_admin can invite a member — 200, exactly one attributed row — but cannot invite an admin — 403 with the ADR-0090 D12 message — and leaves zero orphan rows). The break is purely in acceptance.
Root cause
Acceptance re-inserts a membership the sign-up reconciler already created. Every user is auto-bound to the default org at sign-up via the membership reconciler (packages/plugins/plugin-auth/src/reconcile-membership.ts, run as a user.create.after hook). better-auth's built-in /accept-invitation route then performs its own sys_member insert, which collides with the reconciler's row on the unique index { organization_id, user_id } (packages/platform-objects/src/identity/sys-member.object.ts — indexes: [{ fields: ['organization_id', 'user_id'], unique: true }]).
The reconciler yields to any pre-existing membership (its insertMembership path checks for an existing row first), but better-auth's built-in accept-invitation insert does not go through that seam, so it hits the constraint.
Suspected fix: acceptance should upsert/adopt the existing sys_member row (or skip the insert when the invitee is already a member of the target org).
This is the same bodyless-500 statusCode-leak class as the remove-user failure filed from this run — see #7724: an engine-level fault surfaces through the better-auth route as a bodyless 500 instead of a structured envelope.
Reproduction
- A
delegated_admin (or admin) invites a fresh email as a member → 200, one pending row.
- The invitee signs up / signs in through the invitation link (the reconciler binds them to the default org).
POST /api/v1/auth/accept-invitation {invitationId}.
- Observe HTTP 500 with an empty body; the invitation row is still
pending on re-read.
Source
Extracted from the QA run #7663 (framework 92f26f7, console 09987b680).
Symptom
Accepting a valid org invitation never transitions the row from
pendingtoaccepted.accept-invitationreturns HTTP 500 with an empty body, and thesys_invitationrow stayspendingforever. Reproduced twice with fresh emails.accepted, and the invitee holds exactly one membership in the target org.Server log names the real cause:
The delegated-admin scope itself is correct (a real
delegated_admincan invite a member — 200, exactly one attributed row — but cannot invite an admin — 403 with the ADR-0090 D12 message — and leaves zero orphan rows). The break is purely in acceptance.Root cause
Acceptance re-inserts a membership the sign-up reconciler already created. Every user is auto-bound to the default org at sign-up via the membership reconciler (
packages/plugins/plugin-auth/src/reconcile-membership.ts, run as auser.create.afterhook). better-auth's built-in/accept-invitationroute then performs its ownsys_memberinsert, which collides with the reconciler's row on the unique index{ organization_id, user_id }(packages/platform-objects/src/identity/sys-member.object.ts—indexes: [{ fields: ['organization_id', 'user_id'], unique: true }]).The reconciler yields to any pre-existing membership (its
insertMembershippath checks for an existing row first), but better-auth's built-in accept-invitation insert does not go through that seam, so it hits the constraint.Suspected fix: acceptance should upsert/adopt the existing
sys_memberrow (or skip the insert when the invitee is already a member of the target org).This is the same bodyless-500 statusCode-leak class as the
remove-userfailure filed from this run — see #7724: an engine-level fault surfaces through the better-auth route as a bodyless 500 instead of a structured envelope.Reproduction
delegated_admin(or admin) invites a fresh email as a member → 200, onependingrow.POST /api/v1/auth/accept-invitation {invitationId}.pendingon re-read.Source
Extracted from the QA run #7663 (framework 92f26f7, console 09987b680).