fix(client): organizations.getActiveMember addresses the organisation the caller NAMES, not whichever one the session has active - #16761
Conversation
`organizations.getActiveMember(organizationId)` built `GET /organization/get-active-member?organizationId=...`, and better-auth 1.7.2's handler for that path reads `session.session.activeOrganizationId` and never looks at `ctx.query`. The query string was dead on arrival: a permission check for organisation B while A was active answered A's row, with a 200 and no diagnostic. The method now asks the question honestly, in two requests: `GET /get-session` for the caller's own user id, then `GET /organization/list-members?organizationId=...&filterField=userId&filterValue=<self>&limit=1`, unwrapping the one-entry page. `list-members` reads `ctx.query.organizationId` and its rows carry the identical shape, so the signature and the declared return type are unchanged. The `get-active-member` ledger row is rebooked `server-only`: no SDK method builds that URL any more, and `sdk` means "expressed by the SDK". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
check:doc-authoring — a runtime string reaches authors and generated surfaces, none of whom can resolve `#NNNN`; git history keeps the anchor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
…t-active-member-organization-id
📓 Docs Drift Check3 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 23 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 20764da8fb49e4eec53422fb52e3d9d641f66fd1 && git checkout 20764da8fb49e4eec53422fb52e3d9d641f66fd1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 73053ed27b8c1f63a5734f14b56d093cbe843a47 eb75819641d0e8906461985e9bb16930d705c625 && git checkout -B drift-repro 73053ed27b8c1f63a5734f14b56d093cbe843a47 && git merge --no-ff eb75819641d0e8906461985e9bb16930d705c625
node scripts/docs-audit/affected-docs.mjs --json 73053ed27b8c1f63a5734f14b56d093cbe843a47 |
Check Changeset: a PR declaring clause-② yes may not grade a package it grew `patch`. The maintainer's ruling of 2026-09-04 (decision batch #35) holds that a change to a published package's public surface takes at least `minor`; a commit type may raise a bump, never lower it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Contract review (
|
Fixes #16568
Clause-②: yes
The defect
organizations.getActiveMember(organizationId)builtGET /organization/get-active-member?organizationId=…. better-auth 1.7.2's handler for that path (plugins/organization/routes/crud-members.mjs) readssession.session.activeOrganizationIdand never looks atctx.query, so the query string was dead on arrival: a client doing a permission check for organisation B while A was active was told about A, at 200, with no diagnostic. The SDK's own JSDoc promised "the calling user's membership row in the given organisation" — a declared capability the runtime did not deliver.Zone 1's hard precondition, measured BEFORE any implementation
Triage recommended
list-membersbut said in writing it had not verified the query shape. It was driven first: a realAuthManager(better-auth 1.7.2, organization plugin, teams enabled) over a realSqlDriver(better-sqlite3:memory:), one user owning two organisations with A active, plus a second member seeded into B so the filter has something to exclude. Transcript, trimmed to the ids that matter:RA/RB/RC are the discriminating leg: with two rows in B, the self filter returns exactly one and the other-user filter returns the other, so
filterField=userIdreally narrows rather than merely not breaking. R3/R4 are the addressing leg. The precondition holds, so option 2 was implemented; nothing was improvised and the decision inbox was not needed.The vendor premise was re-confirmed on the same drive: the installed version is exactly
better-auth 1.7.2(pinned by PR #16634), and itsgetActiveMemberhandler still reads session state only. The card's premise stands.What changed
packages/client/src/index.ts,organizations.getActiveMember— the signature and the declared return type are byte-identical; only the addressing moved:GET /get-sessionfor the caller's own user id (bare{ user, session }for a signed-in caller, the literalnullfor an anonymous one — measured);GET /organization/list-members?organizationId=…&filterField=userId&filterValue=SELF_USER_ID&limit=1, unwrapping the one-entry page.list-membersrows carry the identical shape —{id, organizationId, userId, role, createdAt, user:{id,name,email,image}}— which is whyOrganizationMemberWithUserWiredoes not move.The JSDoc is corrected in the same stroke, as triage required. #14314's PR had changed it to say the argument is ignored; that sentence is now false, so it is replaced by what the method does, plus every behaviour an existing caller can observe change.
Does the request-byte change constitute a published behaviour change? Yes — declared, not argued away
Triage asked for this in writing, so here it is, item by item. The request bytes change, and so does the answer:
403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATIONwhere the old shape produced400 MEMBER_NOT_FOUND— and about a different organisation at that, since the old shape never asked about the named one. Two published error codes, and the input class that reaches each of them is re-chosen;400 NO_ACTIVE_ORGANIZATION.setActivehas stopped being a precondition;401 UNAUTHORIZED, thrown by the same session middleware that guarded the old route. Nothing client-side is substituted for the server's refusal;Clause-②: yes — re-declared from the delivered diff
The dispatch carried a
noas triage's reading, marked explicitly as not measured. Re-declared here, and it flips. The machine-read declaration is the standalone line at the top of this body, in the fixed spelling — this heading and the paragraphs under it are the argument, not the declaration.The mechanical floor is clean: no new exported symbol, no new key on a published payload, no signature change, no type change (
check:exported-any-returnsis untouched,check:dts-closureandcheck:type-source-resolutionboth green). But the floor is not the whole test, and the contract-review rule names this exact case as one that needs judgement rather than a mechanism: "在两个已发布码之间重选输入类". That is precisely what the second bullet above is — the input class that produces each of two published ADR-0112 codes is re-chosen — and the answer to which row an existing caller receives changes with it. Under "claim 拿不准 ⇒ 按yes" that is ayestwice over.needs:contract-reviewis hung on this PR at creation, and on the card, as the double carrier requires.Reverse verification
The fix was committed first, then the pre-fix
packages/client/src/index.tswas restored for one run.`organization/list-members`counted 2 before and 1 after (the surviving one is a pre-existing JSDoc occurrence at line 1358 — the printed "expect 0" label in the ablation script was wrong about that constant, the observation was not), and the blob hash movedef5fa760…to7fa9e129…;expected 'org_alpha' to be 'org_bravo'), ② the request bytes, ④ no-active-organisation, ⑤ the 403 envelope (expected undefined to be 'YOU_ARE_NOT_A_MEMBER…'), ⑥ the anonymous 401 request count. ③ (naming the active organisation) and ⑦ (the guard-the-guard leg that drives the dead route directly) stay green, as predicted — ③ is the one case the old shape got right by coincidence;git checkout HEAD -- …, thengit diff HEADempty and the on-disk blob hash back toef5fa760…, byte for byte. The script carried atrap … EXIT INT TERMwith absolute paths throughout.No
distis in the resolution path here: the suite imports./indexrelatively, i.e. the source in this checkout, so the ablation could not have been read against a stale build.Tests
New:
packages/client/src/organization-get-active-member-addressing.test.ts, 7 cases. Its fixture is not an approximation — every status, code and row shape in it is a transcript line from the drive above, and it keeps the defect alive onget-active-member(that arm still answers the active organisation whatever the query names), so a regression to the old route fails on the row value rather than on a URL string.pnpm --filter @objectstack/client testpnpm --filter @objectstack/client typechecktsc --noEmit+check:test-typecheck(0 files / 0 errors in the debt ledger)pnpm --filter @objectstack/plugin-auth testpnpm --filter @objectstack/plugin-auth typecheckpnpm --filter '@objectstack/client-react...' buildGates
Derived from the delivered diff with
node scripts/pm/dispatch-gates.mjs --commands, from a tree actually atorigin/main(noSTALE TREEbanner —origin/mainhad moved twice during the round and was merged in first), and reconciled:All 59 exit 0, each captured before any pipe. Three needed a second lap and none of the three is a NOT MEASURED in the final record:
pnpm check:doc-authoringwas genuinely red on this diff: the new ledgernotecarried#16568in a runtime string, against the maintainer's ruling 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」. The id is stripped; git history keeps the anchor. Green.pnpm --filter @objectstack/spec run check:skill-examplesandpnpm check:dual-build-cjs-loadsboth refused for want of built output (the second by its ownexit 3PREREQUISITE NOT MET). Both green after theclient-reactclosure build — 258 prose examples type-check across 3 surfaces.pnpm check:type-check-debtOOM-ed at--max-old-space-size=4096and answeredexit 3, its own PREREQUISITE-NOT-MET code. Re-run at 8192 (the gate itself runs tsc under a CI-shaped 6144 ceiling, so 4096 could never have held the wrapper): green, 5 ledger entries re-measured, none above its recorded number.Lint is the full repo-wide union, not a narrowing:
eslint . --no-inline-config --format jsonat012d430b— 6347 files, 0 errors, 0 warnings, exit 0.Declared scope extension: one ledger row outside the dispatched file surface
The dispatch named
packages/client/src/index.tsplus a test underpackages/client/. This PR also edits one row ofpackages/plugins/plugin-auth/src/auth-route-ledger.ts, and that is deliberate rather than drift:disposition: 'sdk'means "expressed by the SDK", and after this change no SDK method builds that URL, so leaving the row would ship a false statement in a truth ledger created by this diff. It is rebookedserver-onlywith the rationale the hygiene test demands,clientdropped, modelled on the neighbouringorganization/add-memberrow which carries exactly this shape.The bounded in-place exemption's four conditions, each checked rather than asserted: (i) same defect class as the card — a declared capability the runtime does not deliver; (ii) mechanical, with the target shape already pinned by
AuthRouteDispositionand the hygiene case that demands anoteon every non-sdkrow; (iii) zero holders — scanned per-ref against each open PR's own merge-base, positive control fired; (iv) same gate family, no new validation surface (auth-route-ledger.conformance.test.ts,auth-route-ledger-coverage.test.tsandpnpm check:auth-mount-ledgeralready read this file, and all three are green).Nothing published moves with it: the module has zero runtime importers in non-test source, and
tsupbuilds onlysrc/index.tsandsrc/rate-limit-storage.ts, so it cannot reachdist. Hence one changeset, for@objectstack/clientalone.Serial
packages/client/src/index.tsis the #12104 family's hard-serial hot file. Re-measured at claim time rather than inherited: zero holders across 11 of 11 open PRs, per-ref against each PR's own merge-base, with two positive controls firing (packages/cli/src/commands/validate.tsin #16727,packages/client/package.jsonin #15334). The same scan found zero holders onauth-route-ledger.ts.验收备注
getActiveMemberis now a slightly odd name for a method that takes an organisation id and no longer touches the "active" anything. Renaming is a published-surface decision and pure naming, so it is not filed and not done here — the signature was held fixed by the ruling on purpose, so callers need no edit.get-active-membercannot address an organisation, andget-active-member-roleanswers a role only. The SDK caches no session, so the caller's own id has to be read. Noted, not filed — it is a cost of the honest shape, not a defect.auth.me/auth.refreshTokendeclare the REST{ success, data }envelope for/get-session, which answers the bare{ user, session }— andrefreshTokennever captures a token because of it #16760 (out of scope, class (b), found on this drive):auth.meandauth.refreshTokenboth declareSessionResponse— the REST{ success, data }envelope — for/get-session, which answers the bare{ user, session }(and the literalnullwhen anonymous). It is not a type-only complaint:refreshTokenacts on that annotation, readingdata.data?.token, so it never captures a token and returns successfully anyway. Distinct from client SDKauth.*family: bind the 14return res.json()methods (auth 7 · sessions 3 · twoFactor 3 · accounts.unlink 1) to their better-auth wire shapes — #12104 family card 2 of 3 #14313, whose enumerated set is the methods carrying no annotation; same lane and same hot file, so scheduling them together would be natural.search_issues— a declared channel switch, because the proxy refuses/search/*by design (403,sessions are bound to their configured repositories) and REST list endpoints cannot match body text. It returned four related cards including client SDKauth.*family: bind the 14return res.json()methods (auth 7 · sessions 3 · twoFactor 3 · accounts.unlink 1) to their better-auth wire shapes — #12104 family card 2 of 3 #14313 and client SDKorganizations.*family: bind the 19return res.json()methods (organizations 11 · invitations 3 · teams 5) to their better-auth wire shapes — #12104 family card 3 of 3 #14314's neighbours, so the channel was live and the result is a reading rather than a silent zero.Generated by Claude Code