Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-auth): the invitation carve-out stopped admitting past 200 pending invitations - #12070
Conversation
…e-out probe hasPendingInvitationFor read at most 200 pending sys_invitation rows and scanned them in memory, so past 200 concurrently-pending invitations an invitee outside the first page was refused SELF_REGISTRATION_CLOSED under the invite_only default posture. The address now goes into the query (sys_invitation.email is indexed) and the page chain is exhausted. Measured on better-auth 1.7.1: the invitation route lowercases the address it stores and internalAdapter.createUser lowercases the registrant's before validateUserInfo, so both sides of the comparison are already normalized; the vendor's own findPendingInvitation / listUserInvitations / findMemberByEmail read with email.toLowerCase(). The row-side fold is kept so a case- or accent-folding collation cannot widen what counts as an invitation, expiry stays in JS so an unreadable expires_at keeps reading as live, and the catch still fails closed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…opulation The read count is >1 on the email sign-up route by design (the route pre-checks the same decision the validateUserInfo gate then makes), so the pin is invariance — 2 pending rows and 401 produce identical reads, each narrowed by email and each a single page — rather than a raw count. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 799c4234d7afd9bf4028017092258652187c36f5 && git checkout 799c4234d7afd9bf4028017092258652187c36f5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7f6dc4ccf8b4f23eb2e0f224ddf5eff070d86a32 d29ebd2bb20e74c11e70e087db972d161155aa25 && git checkout -B drift-repro 7f6dc4ccf8b4f23eb2e0f224ddf5eff070d86a32 && git merge --no-ff d29ebd2bb20e74c11e70e087db972d161155aa25
node scripts/docs-audit/affected-docs.mjs --json 7f6dc4ccf8b4f23eb2e0f224ddf5eff070d86a32
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11770
The defect
AuthManager.hasPendingInvitationFor(email)answered "does this address hold apending invitation?" by reading at most 200 rows filtered only on
status = 'pending'and scanning them in memory for a case-insensitive match:Past 200 concurrently-pending invitations in one environment, an invitee outside
that first page was simply not found. Under the
invite_onlydefault posture#11739 shipped, that means an administrator sends an invitation, the invitee
tries to create their account, and registration is refused with
SELF_REGISTRATION_CLOSED— the invitation lane silently failing for the tailof a large rollout, with no signal to either party. A 500-employee onboarding is
an ordinary way to reach it, and it is exactly the flow the audience-posture
epic exists to make work.
Premise re-verified against
origin/mainafter #11739 merged as4f24e9d2e:the method is on
main, unchanged, with thelimit: 200read intact.The measurement the fix stands on
The in-memory scan existed so the match could be case-insensitive — the
docblock said invitation addresses are stored as the inviter typed them while
better-auth lowercases the registrant's. I checked that against the installed
better-auth 1.7.1 dist, not the docs, and it is wrong on both halves. The
vendor normalizes each side before this gate ever runs:
dist/plugins/organization/routes/crud-invites.mjsconst email = ctx.body.email.toLowerCase();— that value is what lands ininvitationData.emailand is carried intocreateInvitation(create path and resend path both)dist/db/internal-adapter.mjs→createUserdatawithemail: user.email?.toLowerCase()and passes that tovalidateUserInfo, so the address this gate is asked about is already normalizedThe vendor's own reads agree:
findPendingInvitation,listUserInvitationsandfindMemberByEmailall query withemail.toLowerCase(). A mixed-casesys_invitation.emailrow is therefore unredeemable throughaccept-invitationand invisible in the invitee's own inbox — the oldtolerance admitted a registrant to an invitation they could never accept.
No fixture in the repo carried a mixed-case invitation address except the one
this PR re-points (measured: the only mixed-case email literals in plugin-auth
outside SCIM's own case tests were
Bob@Acme.comhere andUser+tag@ACME.com,which is a user address, not an invitation row).
The shape chosen, and the two that were rejected
The address now goes into the query —
sys_invitation.emailcarries adeclared index — and the page chain is exhausted, so no row count can hide a
live invitation. A page here is "pending invitations addressed to this one
person", which better-auth bounds by refusing a second pending invitation per
organization; in practice it is a single indexed lookup where the old code
always read 200 rows.
status-only read until exhausted. It removes theceiling but puts a read of the environment's entire pending population on the
self-serve sign-up path — an unauthenticated hot path.
$ilikeisdeclared but staged ahead of its backends —
packages/spec'sfilter-operator-vocabulary.test.tspinsSTAGED_AHEAD_OF_BACKENDS = ['$ilike', '$like'], anddriver-memory/driver-mongodbrefuse themloudly. Under this method's fail-closed
catchthat refusal would become"no invitation" on those backends: the same defect, everywhere.
$icontainsis enforced but is a substring match, which would widen whatcounts as an invitation.
Security properties, unchanged
status = 'pending'is still in thewhere; expiry is still enforced; thecatchstill fails closed (an unanswerable probe ⇒ no carve-out ⇒ theposture applies). Nothing widens what counts as an invitation.
The row-side comparison is kept, not deleted, and it is not dead code:
=folds case on some collations (MySQL's default) and folds accents with it, so
every returned row is re-checked against the normalized address — a case-only
difference still matches (identical to the old behaviour on such a store), an
accent-only difference does not. Expiry stays in JS for the reason it was
there: a row with no readable
expires_atkeeps reading as live, which anexpires_at: { $gt: … }predicate would have silently narrowed away.The loop's termination is guaranteed three ways — a short page ends the chain; a
page carrying no row id the loop had not already seen ends it too (a driver that
accepts
offsetand ignores it must not spin on the sign-up path); and a hardpage cap that is reported at error level rather than answered silently. The
cap is 200 pages of 50, i.e. 10k pending invitations addressed to one person —
it is a termination guarantee, not a ceiling on the answer.
Tests
New in
audience-posture.test.ts:had): 501 pending rows with the target seeded last, so it sits well
outside the old first page — asserted, not assumed
(
findIndex(...) > 200). The invitee is admitted and the user row lands.sys_invitationread carries bothstatusandemailpredicates, and 301 pending rows are settled by asingle read — so a future "fix" cannot re-introduce a population-sized read
on the sign-up path.
entirely (the widest a folding collation could plausibly be) still admits a
case-only difference and still refuses an accent-only one.
Changed: the existing case-insensitivity + expiry test keeps both assertions and
flips which side carries the case — the row is seeded as better-auth stores it
(
bob@acme.com) and the invitee signs up asBob@Acme.com. Both invitationtest-support helpers (
audience-gate-test-support.tsandpackages/verify'sharness) now seed the normalized address for the same reason.
Ablation
Only
auth-manager.tswas reverted to the branch base (be21955ba), testskept. The mutation was proven on disk before the run was read — marker
counts, anchored on the exact text on both sides, not a bare
--stat:Under the mutation the new boundary test goes red with the reported symptom:
403 is
SELF_REGISTRATION_CLOSED— the card's exact failure, reproduced. Therestore leg was proven on disk the same way (markers back to 2 / 0, porcelain
empty) and the suite returns to
45 passed (45). No rebuild leg applieshere and none is claimed: the subject is imported as
./auth-manager, arelative import inside the package, so vitest resolves it from source — no
dist/sits between the mutation and the assertion. The mutation scriptcarries
trap … EXIT INT TERM, so a cap-kill mid-run could not have left thetree mutated.
Verification — commands and the verdict lines they printed
All at
d29ebd2bb(the final commit; re-run after it, per the standing rule).pnpm --filter '@objectstack/plugin-auth^...' buildVERDICT command-exit 0 · UNLOCKED (declared) · ran 1128spnpm --filter '@objectstack/plugin-auth...' --filter '@objectstack/verify...' buildVERDICT command-exit 0 · UNLOCKED (declared) · ran 101spnpm --filter @objectstack/plugin-auth exec vitest run … src/audience-posture.test.tsTests 45 passed (45)·VERDICT command-exit 0pnpm --filter @objectstack/plugin-auth testTest Files 76 passed (76)·Tests 1570 passed (1570)·VERDICT command-exit 0pnpm --filter @objectstack/plugin-auth --filter @objectstack/verify typecheckDone·VERDICT command-exit 0pnpm lint(repo-wideeslint . --no-inline-config)VERDICT command-exit 0 · ran 32s— not a narrowed run, the whole farmGate families derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(16 path-matched + the test-file conventions), allgreen except one, each exit code captured before any pipe:
check:objectui-changesetfails on this host only: its--self-testshellsscripts/bump-objectui.sh, which usesmapfile(a bash 4 builtin) and thishost runs
GNU bash, version 3.2.57. Every failing case reportsbump-objectui.sh: line 324: mapfile: command not found. Neitherbump-objectui.shnor.objectui-shais in this diff — the diff is five files,all listed above — so this is an environment gap, not a regression; CI runs it
on Linux. Recorded separately as observation #12071 (not addressed here).
Declared narrowing: the shared verify lock
scripts/pm/os-verify-lock.shhas no usableflockon this macOS host(
flockis util-linux; a stock macOS does not ship it). Its own--statusreports
NO USABLE flock … runs in DECLARED UNLOCKED MODE, with no mutual exclusion. Every heavy command above was routed through the entry point andran in that declared unlocked mode — a declared narrowing, not a silent one:
nothing was serialized against sibling agents in this container while these
ran. The verdicts quoted are the lines the script itself printed.
Generated by Claude Code