Skip to content

fix(den): adopt pending invitations in membership bootstrap paths - #2548

Merged
benjaminshafii merged 4 commits into
devfrom
fix/invite-duplicate-members
Jul 8, 2026
Merged

fix(den): adopt pending invitations in membership bootstrap paths#2548
benjaminshafii merged 4 commits into
devfrom
fix/invite-duplicate-members

Conversation

@benjaminshafii

@benjaminshafii benjaminshafii commented Jul 7, 2026

Copy link
Copy Markdown
Member

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 (ensureSingletonOrganizationForUserinsertMemberIfMissing, 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).

Tests (ran, all pass)

pnpm --filter @openwork-ee/den-api exec bun test \
  test/invite-duplicate-members.test.ts test/single-org-mode.test.ts \
  test/sso-jit.test.ts test/organization-join-verification.test.ts \
  test/org-invitations.test.ts        # 24 pass / 0 fail
pnpm --filter @openwork-ee/den-api build          # pass
pnpm --filter @openwork-ee/den-api exec tsc -p tsconfig.json  # 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.

@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openwork-app Ready Ready Preview, Comment Jul 8, 2026 12:29am
openwork-den Ready Ready Preview, Comment Jul 8, 2026 12:29am
openwork-den-worker-proxy Ready Ready Preview, Comment Jul 8, 2026 12:29am
openwork-landing Ready Ready Preview, Comment, Open in v0 Jul 8, 2026 12:29am

@benjaminshafii

Copy link
Copy Markdown
Member Author

fraimz — ✅ PASSED

1 passed · 0 failed · 0 skipped — run 2026-07-08T00-22-31-937Z

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

  1. 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.

    invite-adoption-no-duplicates-01-rashmi-pending-admin-invite.png
  2. 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.

  3. 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.

    invite-adoption-no-duplicates-02-rashmi-duplicate-raw-member-and-pending-invite.png
  4. 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.

    invite-adoption-no-duplicates-03-rashmi-reconciled-single-admin-row.png
    • ✅ Script frame 1 narrated: "The first frame shows Alex's Members page after inviting Rashmi as an admin. Rashmi is v"
    • ✅ Script frame 2 narrated: "The next frame shows Rashmi's account creation and first sign-in, then names the org mod"
    • ✅ Script frame 3 narrated: "The third frame is the customer-reported bad state on the Members page. One Rashmi row i"
    • ✅ Script frame 4 narrated: "The final frame is the repaired Members page after Rashmi signs in again. The duplicate "

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant