You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Problem (White Lotus POC tracker #3 — "Duplicates in Member")
Admin invites a user by email with a role (e.g. admin). The invitee signs up but membership gets created through an invitation-unaware path. Result: the admin sees BOTH the pending "invited" placeholder AND a separate active member for the same person, and the invited role is dropped to member.
Root cause
Single-org auto-join (ensureSingletonOrganizationForUser → insertMemberIfMissing, fired on every session create and /v1/me/orgs) inserts a fresh member row ignoring pending invitations; resolveSingleOrgMembershipRole hardcodes member once an owner exists.
@better-auth/sso JIT provisioning creates member rows via the raw adapter (ctx.context.adapter.create), bypassing databaseHooks entirely — also invitation-unaware.
acceptInvitation never merged an existing member row with the invitation placeholder, so the ghost "invited" record persisted forever.
Fix
Bootstrap insert path now adopts a matching pending, non-expired, same-org invitation (case-insensitive email): placeholder adopted, invited role applied, invitation marked accepted — instead of inserting a duplicate row.
acceptInvitation merge case: existing member + placeholder → invitation role applied (an existing owner is never downgraded), placeholder deleted, invitation accepted.
New reconcilePendingInvitationsForUser(userId) wired into the existing session.create.before hook — the one chokepoint that fires for every sign-in method incl. SSO, and runs after JIT creates the raw member row. Failure-isolated (catch + log): can never block sign-in.
Deliberately NOT changed: the explicit accept endpoint's email-verification gate; no cross-org auto-join introduced (comment documents the boundary per organization-join-verification.ts).
New coverage includes an SSO JIT simulation: raw member row (role member) + pending admin invitation + placeholder → reconcile → exactly one member row, role admin, invitation accepted, ghost gone.
Validation status
Fraimz posted:#2548 (comment) — frame proof with den-web Members page screenshots against a real den-api + MySQL stack (single_org): invite → sign-up boundary → simulated SSO JIT duplicate → sign-in reconcile (one member, role admin, invitation accepted, ghost gone). Reviewer repro: invite email X as admin → sign up as X without completing accept → open dashboard. Before: two records + wrong role. After: one member, invited role, invitation accepted.
Full frame proof with validated screenshots: evals/results/2026-07-08T00-22-31-937Z/fraimz.html (re-run: pnpm fraimz --flow invite-adoption-no-duplicates)
✅ invite-adoption-no-duplicates — Pending invitations are adopted without duplicate organization members
Internal demo
The admin invite creates one pending admin invitation and one invited placeholder
🎙 The first frame shows Alex's Members page after inviting Rashmi as an admin. Rashmi is visible as a pending invite, and the API output beside the screenshot confirms the invitation and invited placeholder both carry the admin role.
✅ Admin token can load the active organization
✅ Organization id is present
✅ Admin member id is present
✅ Seat setup has an organization and admin member id
Rashmi's first sign-in either stays outside the org or adopts the invite in single-org mode
🎙 The next frame shows Rashmi's account creation and first sign-in, then names the org mode the local stack is running. In single-org mode the first sign-in already becomes the headline proof: one active Rashmi member with role admin, the invite accepted, and no invited ghost; in multi-org mode Rashmi is still outside the workspace and the admin invite remains pending.
The pre-fix duplicate state is observable before the next session is created
🎙 The third frame is the customer-reported bad state on the Members page. One Rashmi row is an active member with the wrong member role, while the pending invited admin row is still visible beside it.
The next sign-in merges the active member with the invite and deletes the invited ghost
🎙 The final frame is the repaired Members page after Rashmi signs in again. The duplicate is gone: there is one Rashmi row, the role is admin, and the API output confirms the invitation is accepted with no invited placeholder left behind.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem (White Lotus POC tracker #3 — "Duplicates in Member")
Admin invites a user by email with a role (e.g. admin). The invitee signs up but membership gets created through an invitation-unaware path. Result: the admin sees BOTH the pending "invited" placeholder AND a separate active member for the same person, and the invited role is dropped to
member.Root cause
ensureSingletonOrganizationForUser→insertMemberIfMissing, fired on every session create and/v1/me/orgs) inserts a fresh member row ignoring pending invitations;resolveSingleOrgMembershipRolehardcodesmemberonce an owner exists.@better-auth/ssoJIT provisioning creates member rows via the raw adapter (ctx.context.adapter.create), bypassing databaseHooks entirely — also invitation-unaware.acceptInvitationnever merged an existing member row with the invitation placeholder, so the ghost "invited" record persisted forever.Fix
acceptInvitationmerge case: existing member + placeholder → invitation role applied (an existing owner is never downgraded), placeholder deleted, invitation accepted.reconcilePendingInvitationsForUser(userId)wired into the existingsession.create.beforehook — the one chokepoint that fires for every sign-in method incl. SSO, and runs after JIT creates the raw member row. Failure-isolated (catch + log): can never block sign-in.organization-join-verification.ts).Tests (ran, all pass)
New coverage includes an SSO JIT simulation: raw member row (role
member) + pending admin invitation + placeholder → reconcile → exactly one member row, role admin, invitation accepted, ghost gone.Validation status
Fraimz posted: #2548 (comment) — frame proof with den-web Members page screenshots against a real den-api + MySQL stack (single_org): invite → sign-up boundary → simulated SSO JIT duplicate → sign-in reconcile (one member, role admin, invitation accepted, ghost gone). Reviewer repro: invite email X as admin → sign up as X without completing accept → open dashboard. Before: two records + wrong role. After: one member, invited role, invitation accepted.