Skip to content

fix(plugin-auth): the invitation carve-out stopped admitting past 200 pending invitations - #12070

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-11770-invitation-probe-ceiling
Aug 25, 2026
Merged

fix(plugin-auth): the invitation carve-out stopped admitting past 200 pending invitations#12070
os-zhuang merged 2 commits into
mainfrom
claude/issue-11770-invitation-probe-ceiling

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes#11770

The defect

AuthManager.hasPendingInvitationFor(email) answered "does this address hold a
pending invitation?" by reading at most 200 rows filtered only on
status = 'pending' and scanning them in memory for a case-insensitive match:

const raw = await reader.find('sys_invitation', { where: { status: 'pending' }, limit: 200 });

Past 200 concurrently-pending invitations in one environment, an invitee outside
that first page was simply not found. Under the invite_only default 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 tail
of 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/main after #11739 merged as 4f24e9d2e:
the method is on main, unchanged, with the limit: 200 read 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:

wherewhat 1.7.1 does
dist/plugins/organization/routes/crud-invites.mjsconst email = ctx.body.email.toLowerCase(); — that value is what lands in invitationData.email and is carried into createInvitation (create path and resend path both)
dist/db/internal-adapter.mjscreateUserbuilds data with email: user.email?.toLowerCase() and passes that to validateUserInfo, so the address this gate is asked about is already normalized

The vendor's own reads agree: findPendingInvitation, listUserInvitations and
findMemberByEmail all query with email.toLowerCase(). A mixed-case
sys_invitation.email row is therefore unredeemable through
accept-invitation and invisible in the invitee's own inbox — the old
tolerance 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.com here and User+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 querysys_invitation.email carries a
declared 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.

  • Rejected: page the status-only read until exhausted. It removes the
    ceiling but puts a read of the environment's entire pending population on the
    self-serve sign-up path — an unauthenticated hot path.
  • Rejected: push a case-insensitive predicate into the query.$ilike is
    declared but staged ahead of its backendspackages/spec's
    filter-operator-vocabulary.test.ts pins STAGED_AHEAD_OF_BACKENDS = ['$ilike', '$like'], and driver-memory / driver-mongodb refuse them
    loudly. Under this method's fail-closed catch that refusal would become
    "no invitation" on those backends: the same defect, everywhere.
    $icontains is enforced but is a substring match, which would widen what
    counts as an invitation.

Security properties, unchanged

status = 'pending' is still in the where; expiry is still enforced; the
catch still fails closed (an unanswerable probe ⇒ no carve-out ⇒ the
posture 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_at keeps reading as live, which an
expires_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 offset and ignores it must not spin on the sign-up path); and a hard
page 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:

  1. The page boundary (the pin the card asks for, and the one the suite never
    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.
  2. The read is narrowed: every sys_invitation read carries both
    status and email predicates, and 301 pending rows are settled by a
    single read — so a future "fix" cannot re-introduce a population-sized read
    on the sign-up path.
  3. A case-folding collation: an engine that ignores the email predicate
    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 as Bob@Acme.com. Both invitation
test-support helpers (audience-gate-test-support.ts and packages/verify's
harness) now seed the normalized address for the same reason.

Ablation

Only auth-manager.ts was reverted to the branch base (be21955ba), tests
kept. 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:

=== BEFORE mutation (HEAD) === PENDING_INVITATION_PROBE_PAGE: 2 "status: 'pending' }, limit: 200": 0
=== AFTER mutation (base) === PENDING_INVITATION_PROBE_PAGE: 0 "status: 'pending' }, limit: 200": 1
porcelain: M packages/plugins/plugin-auth/src/auth-manager.ts

Under the mutation the new boundary test goes red with the reported symptom:

FAIL … invite_only: an invitation past the page boundary still admits — a 500-person rollout has no silent tail (#11770)
AssertionError: expected 403 to be less than 300
Tests 2 failed | 43 passed (45)

403 is SELF_REGISTRATION_CLOSED — the card's exact failure, reproduced. The
restore 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 applies
here
and none is claimed: the subject is imported as ./auth-manager, a
relative import inside the package, so vitest resolves it from source — no
dist/ sits between the mutation and the assertion. The mutation script
carries trap … EXIT INT TERM, so a cap-kill mid-run could not have left the
tree mutated.

Verification — commands and the verdict lines they printed

All at d29ebd2bb (the final commit; re-run after it, per the standing rule).

commandverdict
pnpm --filter '@objectstack/plugin-auth^...' buildVERDICT command-exit 0 · UNLOCKED (declared) · ran 1128s
pnpm --filter '@objectstack/plugin-auth...' --filter '@objectstack/verify...' buildVERDICT command-exit 0 · UNLOCKED (declared) · ran 101s
pnpm --filter @objectstack/plugin-auth exec vitest run … src/audience-posture.test.tsTests 45 passed (45) · VERDICT command-exit 0
pnpm --filter @objectstack/plugin-auth testTest Files 76 passed (76) · Tests 1570 passed (1570) · VERDICT command-exit 0
pnpm --filter @objectstack/plugin-auth --filter @objectstack/verify typecheckboth Done · VERDICT command-exit 0
pnpm lint (repo-wide eslint . --no-inline-config)VERDICT command-exit 0 · ran 32snot a narrowed run, the whole farm

Gate families derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (16 path-matched + the test-file conventions), all
green except one, each exit code captured before any pipe:

check:nul-bytes 0 · check:changeset-gate-self-tests 0 · check:cross-package-test-inputs 0
check:published-files 0 · check:slot-lookup 0 · check:test-source-alias 0
check:type-source-resolution 0 · check:engine-double-contract 0 · check:where-matcher 0
check:query-options-erasure 0 · check:type-check-coverage 0 · check-adr-0087-registration 0
check-changeset-no-major 0 · check-ci-filter-parity 0 · check-cross-package-test-inputs 0
check-empty-changeset 0 · check-plugin-teardown-shape 0 · release-rehearsal-clone --self-test 0
check:objectui-changeset 1 ← host limitation, see below

check:objectui-changeset fails on this host only: its --self-test shells
scripts/bump-objectui.sh, which uses mapfile (a bash 4 builtin) and this
host runs GNU bash, version 3.2.57. Every failing case reports
bump-objectui.sh: line 324: mapfile: command not found. Neither
bump-objectui.sh nor .objectui-sha is 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.sh has no usable flock on this macOS host
(flock is util-linux; a stock macOS does not ship it). Its own --status
reports NO USABLE flock … runs in DECLARED UNLOCKED MODE, with no mutual exclusion. Every heavy command above was routed through the entry point and
ran 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

os-zhuangand others added 2 commits August 25, 2026 15:18
…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>
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/verify, touching 7 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via bootStack (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7f6dc4ccf8b4f23eb2e0f224ddf5eff070d86a32packageMentionDocs.

Which tree this was computed on

This run read content/docs from 799c4234d7afd9bf4028017092258652187c36f5 — the merge of head d29ebd2bb20e74c11e70e087db972d161155aa25 into base 7f6dc4ccf8b4f23eb2e0f224ddf5eff070d86a32, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7f6dc4ccf8b4f23eb2e0f224ddf5eff070d86a32 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-zhuang
os-zhuang marked this pull request as ready for review August 25, 2026 08:26
@os-zhuang
os-zhuang added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit 0e0bf80Aug 25, 2026
35 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-11770-invitation-probe-ceiling branch August 25, 2026 08:44
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Invitation carve-out silently stops admitting past 200 pending invitations (in-memory scan of a capped read)

1 participant

@os-zhuang