Uh oh!
There was an error while loading. Please reload this page.
fix(auth): teamId on organization/add-member has no active-team fallback - #10715
Conversation
…ack (#10532) Two source comments asserted that better-auth's `organization/add-member` defaults BOTH `organizationId` and `teamId` to the caller's active org/team. Measured on the installed better-auth 1.7.1 (`dist/plugins/organization/routes/crud-members.mjs`, inside `addMember`), only the organization half is true: const orgId = ctx.body.organizationId || session?.session.activeOrganizationId; const teamId = "teamId" in ctx.body ? ctx.body.teamId : void 0; `activeOrganizationId` is read 8 times in that module; `activeTeamId`, 0 times (same grep, so the zero is a measurement and not a broken search). Corrected at the origin (`sys_member`'s `add_member` action metadata) and at the citation that named it as authority (`organization-add-member.ts`, which cited it as the justification for forwarding request headers — forwarding buys the organization default only). No runtime behaviour changes and nothing was ever misled at runtime: the `add_member` action's `params` list carries no `teamId`, so the claim was never exercised. It was a wrong citation, not a live defect. The asymmetry that #10050's docs now publish is held by a new pin, `organization-add-member-team-fallback.test.ts`, which reads the fact out of the INSTALLED vendor artifact rather than out of our own comments, with a positive control on `activeOrganizationId` for every zero-hit leg. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
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 49930c1a4cf1194c20a438a5e5ce7d30ca5843d8 && git checkout 49930c1a4cf1194c20a438a5e5ce7d30ca5843d8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 53a48c93f4e4016eb1d23a8780c6207f279088da 7064ec8ba48cf413d3c5bb1ad3e77fec27d429ed && git checkout -B drift-repro 53a48c93f4e4016eb1d23a8780c6207f279088da && git merge --no-ff 7064ec8ba48cf413d3c5bb1ad3e77fec27d429ed
node scripts/docs-audit/affected-docs.mjs --json 53a48c93f4e4016eb1d23a8780c6207f279088da
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10532
What was wrong
Two source comments asserted that better-auth's
organization/add-memberdefaults both
organizationIdandteamIdto the caller's activeorg/team. Only the organization half is true.
Vendor re-measurement (done in this worktree, not taken from the card)
Installed version confirmed better-auth 1.7.1 — resolved from
packages/plugins/plugin-auth, realpathnode_modules/.pnpm/better-auth@1.7.1_…/node_modules/better-auth, whosepackage.jsonreports1.7.1. File:dist/plugins/organization/routes/crud-members.mjs, insideaddMember'shandler (
:41,:43):The finding is a zero-hit judgement, so it is reported with the
neighbouring positive control that proves the search works — same
grep,same file:
activeOrganizationId(control — known present)activeTeamId(the claim)An omitted
teamIdtherefore staysundefined, and everyif (teamId)branchdownstream (team lookup,
TEAM_NOT_FOUND, the per-team limit) is skipped. Thecard's reading reproduced exactly.
Scope correction — the claim lives at TWO sites, not three
The card and the dispatch both name three sites. Measured here, the false
sentence exists at two:
packages/platform-objects/src/identity/sys-member.object.ts— the origin(the
add_memberaction).packages/plugins/plugin-auth/src/organization-add-member.ts— the moduleheader, which cited the origin as the authority for forwarding headers.
packages/plugins/plugin-auth/src/auth-plugin.tsdoes cite the same actionmetadata above the mount, but it cites it for a different and correct fact —
that the
sys_memberaction has always targeted this URL, which is whatjustifies restoring the route. It carries no claim about defaults, so there is
nothing false there to correct and it is left untouched. Verified by an
exhaustive scan rather than by reading the one comment: repo-wide greps for
active org/team,default(s) to the callerandactive[Tt]eamreturn onlythe two sites above (plus
sys-team.object.ts, which asserts theorganization half only — that half is true — and one
plugin-authtestcomment likewise quoting only the org half).
The corrected sentence
Both sites now carry the same statement:
which is also the wording the #10050 docs already publish in
content/docs/permissions/authentication.mdx.Nothing was misled at runtime
The
add_memberaction'sparamslist isuserId,role,organizationId—no
teamId(checked in the file, not assumed from the card). The toolbartherefore never sent one and the claim was never exercised. This is a wrong
citation, not a defect that reached a deployment.
teamIdforwarding itselfstays correct: pass it and it works. No runtime behaviour changes in this PR.
The pin
packages/plugins/plugin-auth/src/organization-add-member-team-fallback.test.tsreads the fact out of the installed vendor artifact — it resolves
better-authwithcreateRequireseeded from this package, readscrud-members.mjs, slices the top-leveladdMemberdeclaration, and extractsthe
orgId/teamIdbinding initialisers as vendor bytes. It assertsnothing about our own comments or our own constants: an assertion whose two
sides both derive from this repo would prove only that we wrote what we wrote.
Every negative leg is paired with a positive control running the same
instrument over a neighbouring known-present fact:
SESSION_ACTIVE_FALLBACK, must match theorgIdbindingbefore its non-match on the
teamIdbinding is read as evidence;activeOrganizationIdmust occur in the module before the zero count foractive[Tt]eam*counts.A vendor upgrade that moves or renames the declaration throws with a message
naming the file and what to re-decide, rather than silently matching nothing
and passing.
Ablation — predicted, then observed
Prediction written down before running (mutate the vendor fact into the
has-fallback shape):
Observed, exactly:
This also answers "where does the pin read from". Nothing in this repo was
touched and nothing was rebuilt for the ablation — the only edit was to the file
under
node_modules, and the suite went red. That is a positive control fornode_modules-at-runtime, not a cached or derived copy. (The usualrebuild-each-leg rule does not apply here precisely because the mutated artifact
is the vendor's, not our
dist/.) Green again on the unmutated vendor:Test Files 1 passed (1) · Tests 4 passed (4).Hardlink safety. The vendor file had 6 hardlinks into the shared pnpm
store, so an in-place edit would have corrupted every sibling agent's
node_modulesand the store itself. The link was broken for this worktree only(
rmthe directory entry, then write a fresh inode), leaving the store intact,and restored by copy from a pre-mutation backup:
Verification — all at
7064ec8ba, clean treeGate union derived with
node scripts/pm/dispatch-gates.mjs(no patharguments) after the final commit on a clean tree; exit codes captured
before any pipe. Every line below is the gate's own verdict.
pnpm --filter @objectstack/plugin-auth typecheckpnpm --filter @objectstack/platform-objects typecheckpnpm --filter @objectstack/platform-objects testTest Files 25 passed (25) · Tests 419 passed (419)pnpm --filter @objectstack/plugin-auth testTest Files 62 passed (62) · Tests 1363 passed (1363)turbo run build --filter=./packages/* --filter=./packages/*/*Tasks: 70 successful, 70 totalcheck:changeset-gate-self-testscheck:objectui-changesetcheck:slot-lookupcheck:test-source-aliascheck:type-source-resolutioncheck-adr-0087-registration.mjscheck-changeset-no-major.mjscheck-empty-changeset.mjsdocs-audit/check-affected-docs.mjscheck:query-options-erasurequery-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new, and every file measured parsed.check:engine-double-contractcheck:where-matchercheck:i18ncheck-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).check:type-check-coveragecheck-type-check-coverage: OK — 64/77 workspace packages type-checked (plus the root), 13 in the DEBT ledger …check:type-check-debt --re-measurecheck-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 391.3s, 1913 raw tsc error(s) total, none above its recorded number.check:nul-bytescheck-nul-bytes: OK (scanned 6214 text file(s) … no raw ASCII control bytes).#10449 is fixed — asserted, not merely absent
The dispatch carried a warning that
check-query-options-erasure-ratchetmightbe red on
packages/spec/src/migrations/registry.ts:0:0 — Parsing error: Maximum call stack size exceeded, and to attribute that to #10449 rather thaninvestigate. The PM withdrew the warning mid-task (#10449 closed 2026-08-20 by
PR #10464). Independently confirmed here, and more strongly than "it did not
reproduce":
pnpm check:query-options-erasureis the exact command CI's Enginequery-options erasure ratchet step runs (
.github/workflows/lint.yml:109), itexits 0 on this branch, and its own self-test line names the withdrawn
failure directly —
The new test file also consumed no test-surface headroom
(
test surface: 240 site(s) in 47 file(s) — at the ceiling).class #10309 — the derivation was short, as warned. It did not name
check:route-envelopeorcheck:dispatcher-error-vocabularyeven though thisPR touches
plugin-authandplatform-objects. Both were run explicitly,including
--self-test:check:route-envelopecheck:route-envelope --self-testcheck:dispatcher-error-vocabularycheck:dispatcher-error-vocabulary --self-testObservation for #10615, deliberately NOT acted on here. The ratchet's
informational line reads: "@objectstack/plugin-auth: TEST_DEBT records 109, tsc
now reports 98 (-11) — the entry can be lowered." The gate is green (a count
may sit below its ceiling); the dispatch fenced this entry off, so it was not
edited and
--lowerwas never run. Flagging the −11 gap because it is a realchange to what #10615 records. The new test file contributes 0 errors — the
count moved down, not up.
Single-claim paths
scripts/check-single-claim-paths.mjscannot run here — it needsPR_NUMBER+a
GITHUB_TOKEN, and with only the former it failsGitHub API 401, which is awiring failure and not a verdict. CI runs it. Checked directly instead: of the
16 other open PRs, the two that could plausibly overlap were inspected file by
file — #10660 (
plugin-auth) touchesauth-route-ledger.ts,auth-route-ledger.conformance.test.tsand docs; #10633 (platform-objects)touches
sys-session.object.tsand two new tests. Neither touches any of thisPR's four files. No conflicting claim.
Not in scope
No
packages/specfile is touched.content/docs/releases/**is untouched —the release-notes input is the changeset,
.changeset/add-member-team-id-no-active-team-fallback.md.Generated by Claude Code