@@ -48,7 +48,11 @@ import {
4848 removalBlockedByOwnerTarget ,
4949} from './remove-member-permission-guard.js' ;
5050import { isPlaceholderEmail } from './placeholder-email.js' ;
51- import { reconcileMembership , type MembershipPolicy } from './reconcile-membership.js' ;
51+ import {
52+ reconcileMembership ,
53+ type MembershipPolicy ,
54+ type ReconcileOutcome ,
55+ } from './reconcile-membership.js' ;
5256import type { TenancyService } from './tenancy-service.js' ;
5357import { OtpSendGuard , assertOtpCooldownSeconds } from './otp-send-guard.js' ;
5458import type { CounterStore } from './rate-limit-storage.js' ;
@@ -3019,6 +3023,58 @@ export class AuthManager {
30193023 return this . config . membershipPolicy ?? 'auto' ;
30203024 }
30213025
3026+ /**
3027+ * [ADR-0093 D2] Run the membership reconciler for one user — the ONE place
3028+ * this manager assembles its inputs.
3029+ *
3030+ * Two seams call it, and the whole point is that they cannot disagree:
3031+ *
3032+ * - `user.create.after`, the creation seam every path flows through (email
3033+ * signup, admin create-user, bulk import, SSO JIT);
3034+ * - `session.create.before`, which settles the membership before resolving
3035+ * the session's active organization so a user's FIRST session is not
3036+ * minted tenant-less (#8247 rule 2 / #8245).
3037+ *
3038+ * Assembling the deps at each call site instead would let the two drift on
3039+ * the axis that matters most: the POLICY. `getMembershipPolicy()` reads a
3040+ * live platform setting (#5152) — a captured constructor option would keep
3041+ * one seam auto-binding after an admin switched the deployment to
3042+ * `invite-only`, which is the exact defect that made the accessor exist. The
3043+ * target-org resolution is shared for the same reason: "which organization"
3044+ * must never be answered two ways.
3045+ *
3046+ * Never throws — `reconcileMembership` already guarantees that, and the guard
3047+ * stands anyway because both callers are hooks where a bookkeeping failure
3048+ * must not fail user creation or sign-in. The OUTCOME is returned (rather
3049+ * than swallowed) so the session seam can tell "a membership now exists" from
3050+ * "policy says there will never be one" and skip a pointless re-read;
3051+ * `undefined` means the reconciler could not be consulted at all.
3052+ */
3053+ private async settleMembership ( userId : unknown ) : Promise < ReconcileOutcome | undefined > {
3054+ try {
3055+ const result = await reconcileMembership (
3056+ this . config . dataEngine ,
3057+ typeof userId === 'string' && userId ? userId : undefined ,
3058+ {
3059+ // #5152 — read through the accessor, not `this.config` directly: it is
3060+ // the single source the backfill path reads too.
3061+ policy : this . getMembershipPolicy ( ) ,
3062+ resolveTargetOrg : async ( ) => {
3063+ const tenancy = this . config . getTenancy ?.( ) ;
3064+ // Single-org → default org; multi-org → none (invite/JIT own it).
3065+ return tenancy ? await tenancy . defaultOrgId ( ) : null ;
3066+ } ,
3067+ logger : this . config . logger ,
3068+ } ,
3069+ ) ;
3070+ return result . outcome ;
3071+ } catch {
3072+ // reconcileMembership never throws, but guard regardless — membership
3073+ // bookkeeping must never break user creation or session creation.
3074+ return undefined ;
3075+ }
3076+ }
3077+
30223078 /**
30233079 * Inject (or replace) the outbound email service used by better-auth
30243080 * callbacks. Safe to call after construction but BEFORE the first
@@ -4370,6 +4426,30 @@ export class AuthManager {
43704426 // never fails on this bookkeeping. Opt out via `autoActiveOrganization:
43714427 // false`.
43724428 const hostSessionBefore = ( host as any ) ?. session ?. create ?. before ;
4429+
4430+ /**
4431+ * The membership → active-org selection, in ONE place: owner-preferred,
4432+ * else the oldest row. It is called twice below and both calls must select
4433+ * identically — a second, "simpler" lookup after the settle would silently
4434+ * make a freshly-bound user's active org depend on which path found it.
4435+ */
4436+ const selectActiveOrg = async ( reader : any , userId : string ) : Promise < string | undefined > => {
4437+ let row : any ;
4438+ try {
4439+ row = await reader . findOne ( 'sys_member' , { where : { user_id : userId , role : 'owner' } } ) ;
4440+ } catch {
4441+ row = undefined ;
4442+ }
4443+ if ( ! row ?. organization_id ) {
4444+ try {
4445+ row = await reader . findOne ( 'sys_member' , { where : { user_id : userId } } ) ;
4446+ } catch {
4447+ row = undefined ;
4448+ }
4449+ }
4450+ return row ?. organization_id ;
4451+ } ;
4452+
43734453 const defaultActiveOrg = async ( session : any ) => {
43744454 try {
43754455 if ( ! session || session . activeOrganizationId ) return ;
@@ -4380,22 +4460,51 @@ export class AuthManager {
43804460 // sys_member is org/user-scoped in host stacks — read with the system
43814461 // context so the pre-session lookup (no org on the caller yet) works.
43824462 const reader = withSystemReadContext ( engine ) ;
4383- let row : any ;
4384- try {
4385- row = await reader . findOne ( 'sys_member' , {
4386- where : { user_id : userId , role : 'owner' } ,
4387- } ) ;
4388- } catch {
4389- row = undefined ;
4390- }
4391- if ( ! row ?. organization_id ) {
4392- try {
4393- row = await reader . findOne ( 'sys_member' , { where : { user_id : userId } } ) ;
4394- } catch {
4395- row = undefined ;
4463+ let orgId = await selectActiveOrg ( reader , userId ) ;
4464+
4465+ // [#8247 rule 2 / #8245] SETTLE THE MEMBERSHIP, THEN LOOK AGAIN.
4466+ //
4467+ // The ADR-0093 D2 reconciler is composed into `user.create.after`, and
4468+ // better-auth DEFERS that past the sign-up transaction. This hook runs
4469+ // inside it. So a user's very FIRST session is minted BEFORE the
4470+ // reconciler has bound them to anything, the lookup above finds no
4471+ // `sys_member` row, and the session carries no active organization —
4472+ // for every new user, on every deployment, structurally.
4473+ //
4474+ // That first session is not a harmless intermediate. Everything it
4475+ // writes is tenant-less: its `login` audit row is derived from
4476+ // `session.activeOrganizationId` (`auth-session-audit.ts`), so it lands
4477+ // with a NULL tenant and the SecurityPlugin's RLS predicate hides it
4478+ // from every reader FOREVER — nothing back-fills a written ledger row,
4479+ // and the rows lost this way are exactly the ones describing account
4480+ // creation (#8245).
4481+ //
4482+ // So the settle is hoisted HERE, to the seam that actually needs it,
4483+ // rather than the ordering being left to better-auth's hook scheduling.
4484+ //
4485+ // ⛔ THIS DOES NOT WIDEN WHO GETS BOUND, and that is the property to
4486+ // preserve if this is ever touched: it calls the SAME reconciler with
4487+ // the SAME policy and the SAME target-org resolution that
4488+ // `user.create.after` uses (one owner — `settleMembership`), so the
4489+ // outcome is byte-for-byte what would have happened a moment later.
4490+ // `invite-only` still binds nobody; multi-org still resolves no
4491+ // unambiguous target and binds nobody. Those users keep minting
4492+ // sessions with no active organization, which is the LEGAL state the
4493+ // #8247 ruling declares — this removes a race, never a policy.
4494+ //
4495+ // Cost is paid only where there is something to fix: a caller who
4496+ // already holds a membership never reaches this branch, and a
4497+ // deployment that binds nobody stops at the reconciler's own policy /
4498+ // target-org check without touching the store. The re-read is gated on
4499+ // an outcome that means a membership now EXISTS, so the common
4500+ // no-bind login costs no extra query at all.
4501+ if ( ! orgId ) {
4502+ const outcome = await this . settleMembership ( userId ) ;
4503+ if ( outcome === 'bound' || outcome === 'yielded' ) {
4504+ orgId = await selectActiveOrg ( reader , userId ) ;
43964505 }
43974506 }
4398- const orgId = row ?. organization_id ;
4507+
43994508 if ( ! orgId ) return ;
44004509 return { data : { ...session , activeOrganizationId : orgId } } ;
44014510 } catch {
@@ -4455,22 +4564,7 @@ export class AuthManager {
44554564 // double bind. Best-effort — never fails user creation.
44564565 const hostUserAfter = ( host as any ) ?. user ?. create ?. after ;
44574566 const membershipReconciler = async ( user : any ) => {
4458- try {
4459- await reconcileMembership ( this . config . dataEngine , user ?. id , {
4460- // #5152 — read through the accessor, not `this.config` directly: it is
4461- // the single source the backfill path reads too.
4462- policy : this . getMembershipPolicy ( ) ,
4463- resolveTargetOrg : async ( ) => {
4464- const tenancy = this . config . getTenancy ?.( ) ;
4465- // Single-org → default org; multi-org → none (invite/JIT own it).
4466- return tenancy ? await tenancy . defaultOrgId ( ) : null ;
4467- } ,
4468- logger : this . config . logger ,
4469- } ) ;
4470- } catch {
4471- // reconcileMembership never throws, but guard the hook regardless —
4472- // membership bookkeeping must never break user creation.
4473- }
4567+ await this . settleMembership ( user ?. id ) ;
44744568 } ;
44754569 const userAfter = hostUserAfter
44764570 ? async ( user : any , ctx : any ) => {
0 commit comments