fix(plugin-auth): adopt the existing membership when accepting an invitation instead of colliding on the unique index - #7796
Conversation
…itation
better-auth's accept-invitation route inserts a sys_member row
unconditionally. The platform auto-binds every user to the default org at
sign-up (ADR-0093 D1/D2), so on a single-org deployment that insert collides
with the reconciler's row on sys_member's declared unique
{organization_id, user_id} index. The route's transaction catch rolls the
invitation back to `pending` and rethrows, which surfaced as a bodyless HTTP
500 and an invitation that could never be accepted (#7725).
Adopt that row instead of minting a second one, at the better-auth -> ObjectQL
adapter: the declared unique pair IS the identity of a membership, so a create
naming an existing pair is that membership. Adoption writes the invitation's
role onto the adopted row, but never lowers a grade -- demotion belongs to
update-member-role, where last-admin-guard stands.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd
…ons (#7725) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd
…ept-invitation-adopt-membership
…patch predicate check:engine-double-contract flagged the new engine double: it pinned delete but not update, and update is the verb the adoption write actually uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31506352658 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31506300231 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31507847145 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31511205705 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
Ejected from the merge queue: What happenedThis is the collision the seat's hot-file serial queue predicted, resolved in the opposite order from the one recorded. Both this PR and #7825 (#7732, session tombstoning) touch Recorded plainly because the serial-queue note had the order as Conflict scope, measured read-only (
|
…ept-invitation-adopt-membership Resolved packages/plugins/plugin-auth/src/objectql-adapter.ts: the only conflict was the import block, where #7825's session-tombstone helpers and this branch's adoptExistingMembership landed on the same line. Both imports are kept — the two features hook disjoint adapter methods (adoption on `create`, tombstoning on `findOne`/`findMany`/`delete`/`deleteMany`), so neither side may be taken wholesale.
Fixes #7725
The defect
POST /api/v1/auth/organization/accept-invitationreturned HTTP 500 with an empty body and left thesys_invitationrowpendingforever. On a single-organization deployment the documented flow — invite a fresh email, invitee signs up through the link, invitee accepts — could never complete, and the invitation was unrecoverable through the UI (re-inviting an address that is already a member is refused too).Premise re-verified on
origin/mainby symbol, and it holds exactly as filed. Two individually-correct platform decisions collide:reconcile-membership.ts, composed intouser.create.after, ADR-0093 D1/D2);sys_memberdeclares{ organization_id, user_id }unique.better-auth
1.7.0-rc.2's built-in accept-invitation route (plugins/organization/routes/crud-invites.mjs) assumes an invitee is never already a member: after flipping the invitation toacceptedit callsadapter.createMember(...)unconditionally, inside arunWithTransactionwhose.catchsets the invitation back topendingand rethrows. The invitee's auto-bound row made the insert fail, and the rollback erased the only evidence that acceptance had been attempted. The reconciler yields to a pre-existing row; better-auth's insert does not go through that seam.The seam, and why this one
The three seams the framework already owns on this route were each evaluated against better-auth's actual code and rejected — the reasoning is recorded in full in the new module's header, in short:
organizationHooks.beforeAcceptInvitationfires beforecreateMemberand can only throw or mutate. The one mutation that would let the insert succeed is deleting the pre-existing row, which destroys the membership'screated_atand history, opens a window in which the invitee belongs to nothing, and reaches intosys_member'sdeleteBehavior— another card's decision surface (admin-lifecycle:remove-useris non-atomic and can never succeed — 409 leaks as a bodyless HTTP 500, credential rows deleted without rollback #7724).hooks.before(the global route-boundary middleware inauth-manager.ts) has the endpoint ctx, butctx.contextis the sharedAuthContextsingleton (to-auth-endpoints.mjspasses the awaited instance-wide context), so per-request state parked there leaks across concurrent requests — an adapter swap most of all.The fix therefore lands in the better-auth to ObjectQL adapter's
createforsys_member— the layer that already reconciles better-auth's model with the platform's object (model and field names, dates, identifier normalisation). It is stated as the platform's own rule rather than as leniency: the declared unique pair IS the identity of a membership, so a create naming an existing pair is that membership.Blast radius measured against better-auth 1.7.0-rc.2 rather than assumed — accept-invitation is the only
membercreate that can reach an existing pair:POST /organization/add-memberpre-checks and refuses first (findMemberByEmailproducesUSER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION, a 400);POST /organization/create-organizationmints a fresh organization id, so no pair can pre-exist;engine.insertdirectly, not through this adapter, and already yield.Role and attribution on adoption
Not throwing is the smaller half. Adoption writes the invitation's role onto the adopted row, so an invitation's intent is not silently replaced by the reconciler's default
member— accepting anadmininvitation makes you an admin even if you signed up first. A grade-flat move is applied too (membertodelegated_admin), since dropping it is exactly the "silently keep the default" failure.One deliberate exception, pinned in both the unit and the end-to-end suites: adoption never lowers a grade. If the existing membership already outranks the invitation's role, the existing role is kept. Acceptance is an admission instrument —
invitation-role-cap.tsstates the same posture from the issuance side — while demotion has its own governed route,POST /organization/update-member-role, which is wherelast-admin-guard.tsstands. Without this exception, inviting an organization's soleowneras amemberand having them accept would have demoted them past that guard, taking the organization's last owner with it and reporting success.Attribution needs nothing extra, and that is deliberate: the adoption writes through the same
withSystemContext-wrapped engine as every other adapter write, whoseupdatecarriesattributedUserId(#4586) — here the invitee who accepted — andsys_memberistrackHistory: true, so the adoption lands in history attributed to the acceptor.created_atis not rewritten: the membership really did begin at sign-up.Verification
New end-to-end suite drives the real better-auth pipeline through a real
AuthManager(organization plugin, the framework'sbeforeCreateInvitationrole cap, the reconciler onuser.create.after, the ObjectQL adapter). The engine double enforcessys_member's declared unique index and throws in the driver's own words — without that, the defect is invisible on an in-memory fake and the whole suite would be theatre.Ablation (revert only the adapter wiring, keep the module and every test), direction predicted before running:
accepted/ exactly one membershipexpected 500 to be 200expected 500 to be 200expected 500 to be 200The ablated stack trace is the reported failure verbatim:
insert into sys_member ... UNIQUE constraint failed: sys_member.organization_id, sys_member.user_id, throughcreateMemberatcrud-invites.mjs:324, surfacing as[AuthManager] better-auth returned error: 500with an empty body.Suites, all on top of a merged
origin/mainand a fully built workspace:@objectstack/plugin-auth— 43 files / 1039 tests green after the merge described below. That total is exactly the union of the two parents: 1015 (this branch) + 18 (session-tombstone.test.ts, new from fix(plugin-auth): tombstone interactive session revocations instead of deleting the row #7825) + 6 (managed-extension-fields.test.ts, grown 6 to 12 by test(plugin-auth): fail the D7 collision guard on unmapped better-auth-managed objects #7822). The merged test-file set is the union of both parents' sets with nothing dropped — 40 shared + 2 from this branch + 1 frommain.typecheckgreen.@objectstack/rest(the HTTP surface the auth routes mount on) — 91 files / 1462 tests green.membership-actor-attribution.dogfood.test.ts(which exercises the realaccept-invitation) anddelegated-admin-invite.dogfood.test.ts— 10 tests green.pnpm --filter '...@objectstack/plugin-auth' typecheck, the prefix direction (the packages that DEPEND ON plugin-auth, not its dependencies). 14 consumers ran atypecheckscript, all Done;@objectstack/honoand@objectstack/cloud-connectionare in the set but declare notypecheckscript.check:nul-bytesOK,check:engine-double-contractOK. The latter earned its keep — it caught the new engine double pinningdeletebut notupdate, andupdateis the verb adoption actually writes through. Fixed in530b760e4rather than baselined.Merge with
main— coexistence with #7825The merge queue ejected this PR with
MERGE_CONFLICTafter #7825 (session tombstoning) landed as3f296bf. Resolved by mergingorigin/maininto the branch — no rebase, no force-push.Exactly one file conflicted, and only in its import block:
packages/plugins/plugin-auth/src/objectql-adapter.ts, where #7825's session-tombstone imports and this PR'sadoptExistingMembershipimport landed on the same line. Resolved as the union of both imports; neither side was taken wholesale. There was no semantic reconciliation to make, because the two features hook disjoint adapter methods — adoption oncreate, tombstoning onfindOne/findMany/delete/deleteMany.index.tsauto-merged carrying both exports.A clean merge is not a working merge, and both features hook the same adapter dispatch, so coexistence was proved rather than assumed. Both end-to-end suites drive the same merged adapter through
AuthManagercallingcreateObjectQLAdapterFactory, so an ablation on the merged file is visible to both:session-tombstone.test.ts(18)createreconcileSessionDeleteremoved fromdeleteEach feature's suite goes red only under its own ablation, so neither is passing vacuously on the merged dispatch, and both features still fire.
Also re-run after the merge:
pnpm --filter @objectstack/plugin-auth typecheckgreen, the prefix-direction consumer sweep green across all 16 selected packages,check:engine-double-contractOK (161 pinned / 133 DEBT / 2 exempt) andcheck:nul-bytesOK. Rebuildingpackages/specon the merged sources produced no artifact drift, so the merged generated files reproduce exactly. The@objectstack/restand dogfood numbers above are from the pre-merge round and were not re-run; nothing in the merge touches that surface.mainhas since advanced two further commits (3bb9340,bf8fd8a— docs, QA checklists, and apackages/specdoc comment).git merge-treeagainst them reports no conflict, and the merge queue rebuilds onto livemainregardless, so they are deliberately not chased here.Scope
Both family non-goals held — confirmed by the diff's file list, which touches neither:
packages/platform-objects/src/identity/sys-member.object.ts(FK /deleteBehavior/ indexes) is not in the diff — that stays admin-lifecycle:remove-useris non-atomic and can never succeed — 409 leaks as a bodyless HTTP 500, credential rows deleted without rollback #7724's decision surface. The unique index is honoured here, not changed.packages/rest/src/rest-server.ts) is not in the diff.Bodyless-500 residue, for #7724's benefit: the success path is fixed, but the mapping is not, and a fault on this path still surfaces bodyless. Anything that makes better-auth's accept-invitation transaction throw a non-
APIError— an engine outage, a driver error, a constraint this fix does not cover — still reaches the client as a 500 with an empty body and still rolls the invitation back topending. That is the shared mapping class, deliberately left alone here.Generated by Claude Code