Skip to content

Commit c9c2d92

Browse files
os-helpclaude
andauthored
fix(plugin-auth): adopt the existing membership when accepting an invitation instead of colliding on the unique index (#7796)
* fix(plugin-auth): adopt the existing membership when accepting an invitation 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 * test(plugin-auth): pin invitation acceptance adoption in both directions (#7725) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd * chore(changeset): patch @objectstack/plugin-auth for #7725 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd * test(plugin-auth): pin the adoption fake's update() to ObjectQL's dispatch 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1a19e9d commit c9c2d92

6 files changed

Lines changed: 931 additions & 1 deletion

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): invitations can be accepted again — adopt the existing membership instead of colliding on the unique index (#7725)
6+
7+
`POST /api/v1/auth/organization/accept-invitation` returned **HTTP 500 with an
8+
empty body** and left the `sys_invitation` row `pending` **forever**. It was not
9+
intermittent: on a single-organization deployment the flow in the docs — invite a
10+
fresh email, invitee signs up through the link, invitee accepts — could never
11+
complete at all, and the invitation was unrecoverable through the UI because
12+
re-inviting an address that is already a member is refused too.
13+
14+
Two correct platform decisions collided:
15+
16+
- every user is auto-bound to the deployment's default organization at sign-up,
17+
by the membership reconciler (ADR-0093 D1/D2), and
18+
- `sys_member` declares `{ organization_id, user_id }` unique.
19+
20+
better-auth's built-in accept-invitation route assumes an invitee is never
21+
already a member: after flipping the invitation to `accepted` it inserts a
22+
membership unconditionally, inside a transaction whose failure handler rolls the
23+
invitation **back to `pending`** and rethrows. So the invitee's auto-bound row
24+
made the insert fail, and the rollback erased the only evidence that acceptance
25+
had been attempted.
26+
27+
Acceptance now **adopts** that row rather than minting a second one. The declared
28+
unique pair is the identity of a membership, so a create naming a pair that
29+
already exists is that membership. The invitation ends `accepted`, and the
30+
invitee holds exactly one membership in the target organization.
31+
32+
**What adoption does to the role.** The invitation's role is written onto the
33+
adopted row, so an invitation's intent is not silently replaced by the
34+
reconciler's default `member` — accepting an `admin` invitation makes you an
35+
admin even if you signed up first. One deliberate exception: **adoption never
36+
lowers a grade.** If the existing membership already outranks the invitation's
37+
role, the existing role is kept. Acceptance admits a person; demotion belongs to
38+
`POST /organization/update-member-role`, which is the route the last-admin guard
39+
stands on — without this exception, an organization's sole owner accepting a
40+
`member` invitation would have been demoted past that guard, taking the
41+
organization's last owner with it.
42+
43+
The membership's `created_at` is not rewritten (the membership really did begin
44+
at sign-up), and the adoption is recorded in `sys_member` history attributed to
45+
the person who accepted.
46+
47+
Unaffected: an invitee who is not yet a member of the target organization still
48+
gets a membership created exactly as before, and the delegated-admin issuance
49+
scope (ADR-0090 D12 / ADR-0105 D8) is untouched.

0 commit comments

Comments
 (0)