Uh oh!
There was an error while loading. Please reload this page.
fix(auth): settle the membership before the first session resolves its active organization, so its audit rows carry a tenant - #8473
Conversation
…s active organization (#8245) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
…rdering Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
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:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8245
Part of #8247 — ruling rule 2 (the signup-ordering half). The declared-semantics half (rules 1 and 3) is #8471, which carries ADR-0123 and waits for the maintainer.
Declaration note
The Duplicate Fix Guard was briefly red on this PR: #8471's body used a GitHub closing keyword in front of #8245 while describing this PR, so both PRs declared #8245 and neither declared what it actually fixes. #8471 has been corrected to declare #8208; this PR keeps #8245, and both are
Part ofthe parent decision card #8247, which neither closes alone.This PR carries no ADR document. Its only
adr-shaped file isscripts/adr-anchors/…auth-manager.ts.json, an anchor registry entry — not adocs/adr/**record. Confirmed both ways:ADR maintainer approvalreports success here, and the gate's governed surface is the literal path prefixdocs/adr/(ADR_PATH_PREFIXinscripts/check-adr-merge-approval.mjs), which an anchor file does not match. So this PR is eligible for the normal flow; only #8471 has to wait for a human merge.The ordering
session.create.beforeresolves a session'sactiveOrganizationIdfrom the caller'ssys_memberrow. The ADR-0093 D2 reconciler that writes that row is composed intouser.create.after, and better-auth defers it past the sign-up transaction. So the session sign-up mints runs first, finds no membership, and carries no active organization — structurally, for every new user, on every deployment.That first session is not a harmless intermediate. Its
loginaudit row takes its tenant fromsession.activeOrganizationId(auth-session-audit.tsloginEventFor), so the row lands with a NULL tenant and the SecurityPlugin's RLS predicate hides it from every reader permanently — nothing back-fills a written ledger row, and the rows lost this way are exactly the ones describing account creation.The premise is still live on
origin/main, verbatim, in the dogfood suite's own words:The fix
When the active-org lookup finds nothing, the reconciler runs and the lookup is repeated. The first session then mints with its organization.
This changes ordering, not policy — and that is the property to preserve. The session seam calls the same reconciler with the same policy and the same target-organization resolution
user.create.afteruses; both now share one assembly point (AuthManager.settleMembership). The outcome is byte-for-byte what would have happened a moment later:invite-onlybinds nobody, and those sessions still mint with no active organization;Those users keep minting sessions with no active organization, which is the legal state the #8247 ruling declares. This removes a race, never a policy.
One assembly point, deliberately. Assembling the reconciler's deps at each call site would let the two seams drift on the axis that matters most: the policy.
getMembershipPolicy()reads a live platform setting (#5152) — a captured constructor option leaves one seam auto-binding after an admin switches the deployment toinvite-only, which is the exact defect that made the accessor exist.Cost. A deployment that binds nobody stops at the reconciler's own policy check without touching the store, and the repeat lookup is gated on an outcome meaning a membership now exists — so an ordinary sign-in issues no extra query. Pinned as a test, not asserted in prose.
Scope note. Under a walled posture
tenancy.defaultOrgId()returnsnullby design (the framework never guesses a target org), so this fix binds nobody there — it helps the single-posture deployments where a default organization is unambiguous. A walled deployment's org-less callers are governed by #8471's declared semantics instead. The two halves are complementary, not overlapping.Tests —
first-session-membership-ordering.test.ts, 13 casesThe fix is an ordering change, so the vacuity trap is a test that passes on the broken build because it never establishes the membership was absent when the session was minted. Every case starts from a store with no
sys_memberrow and asserts on the first session; aPRECONDITIONcase pins that starting state rather than assuming it.The policy cases are not decoration — they are what makes this safe to land, and they assert on the store (
insertnever called, row count unchanged), not merely on the returned draft: a policy saying "no auto-bind" must not be satisfied by binding and then declining to stamp.#8245's chain is measured end to end: the session produced by the hook is fed to
loginEventFor, and the resulting audit event carries the tenant.Ablation (predictions written before the mutation, then measured)
Restoring
auth-manager.tstoorigin/mainand re-running: 2 red, 11 green — exactly the first-session pin and the login-audit-event pin, and nothing else. Every policy case, the already-bound case, the owner-preference case, the host-hook case and the broken-engine case stayed green, which is the evidence that the blast radius is the ordering and not the reconciler's semantics.Full package suite: 52 files, 1186 tests, all green.
check:adr-anchors,check:nul-bytes,check:adr-0087-registration,check:changeset-no-major,check:type-check-coverageall green locally (the ratchet's TEST_DEBT count is unmoved).Why this is a separate PR from #8471
Not stylistic — forced, and measurably:
check:adr-anchorsrequires everyADR-NNNNcited in a tracked file to name a real record underdocs/adr/, and Prime Directive [WIP] Add Chinese version of the documentation #13 requires code realizing an ADR's decision to carry its id. Rule 1's enforcement cites ADR-0123, so it cannot be separated from the ADR that defines it — and adocs/adr/**diff is the maintainer's to land (Prime Directive feat: Comprehensive CRM example demonstrating all ObjectStack protocol features #14).The landing order is the favourable one too: this PR reduces how often the no-active-organization state occurs at signup, before #8471 starts refusing writes in it.
Files
packages/plugins/plugin-auth/src/auth-manager.ts—settleMembership(the shared assembly point), theselectActiveOrgextraction, and the settle-then-re-read indefaultActiveOrg.packages/plugins/plugin-auth/src/first-session-membership-ordering.test.ts— new.scripts/adr-anchors/packages__plugins__plugin-auth__src__auth-manager.ts.json— ADR-0093 added, with an invariant naming the two seams and why they must not drift..changeset/first-session-membership-ordering.md.Generated by Claude Code