Skip to content

Commit f904e61

Browse files
claude[bot]claudehuangyiirene
authored
fix(client): organizations.getActiveMember addresses the organisation the caller NAMES, not whichever one the session has active (#16761)
* fix(client): getActiveMember addresses the organisation the caller names `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 * chore(changeset): declare the getActiveMember addressing fix Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 * chore(plugin-auth): keep the tracker id out of the ledger note string 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 * chore(changeset): grade @objectstack/client minor, not patch 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 * fix(client): refuse a falsy organizationId in getActiveMember better-auth resolves `ctx.query.organizationId || session.activeOrganizationId` on `list-members`, so an empty string fell through to session state and came back 200 carrying the ACTIVE organisation's row — the same silent substitution this method was fixed to stop making, surviving on one argument while the JSDoc says "the GIVEN organisation". The SDK now refuses it before the wire, in the shape `environment(id)` already uses. The pinned case asserts nothing reaches the wire at all, and drives `list-members` with an empty id through the same double to show the fallback the refusal prevents is real in the fixture, not assumed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5 * docs(plugin-auth): the ledger notes name every SDK method that builds each URL `get-active-member` was rebooked `server-only` because a truth ledger must not ship a false statement; by the same standard two rows were left incomplete. `get-session` named only `auth.me` and `auth.refreshToken`, and `list-members` named only `organizations.listMembers`, while `organizations.getActiveMember` now builds both. The `invite-member` row is the precedent for exactly this. Also restores a by-name anchor for the method: after the rebooking it was pinned by URL through `client-url-conformance.test.ts` but by no `client:` or `note:` string anywhere in the ledger. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5 * chore(changeset): carry the breaking-ness and its ADR-0087 disposition The changeset now carries the `**BREAKING**` banner, one before/after pair per moved input, and an ADR-0087 `not-required (no-migration-prescription)` disposition. The level stays `minor`: under the launch-window convention the level cannot carry breaking-ness, so the banner and the disposition are the carriers. Four inputs move, each stated as the response it drew before and the response it draws now: an id other than the active organisation; an organisation the caller is not a member of; any id on a session with no active organisation; and an empty id, which this round refuses client-side. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5 * docs(client): correct the pre-fix answer stated for a non-member of the named organisation The changeset bullet and the `getActiveMember` docblock both said a caller who was not a member of the NAMED organisation used to draw `400 MEMBER_NOT_FOUND`. better-auth 1.7.2's `get-active-member` handler reads `session.session.activeOrganizationId` and never `ctx.query`, so the named organisation was never consulted at all: such a caller drew a 200 carrying the ACTIVE organisation's row, and `MEMBER_NOT_FOUND` fired only when the caller had no row in the active organisation either. The PR's own ablation agrees — case ⑤ went red as "expected undefined to be 'YOU_ARE_NOT_A_MEMBER…'", i.e. the old shape resolved rather than throwing. Both sentences now state that before-state. The `after` (403) was already right, and the neighbouring bullets already stated it for every other input. Prose only: the changeset body ships as CHANGELOG text and the docblock is a comment. No executable line, no test and no behaviour moves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Js5kTpTtxieBjPyScgxJ3 --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: huangyiirene <huangyi@hotoa.com>
1 parent 91f65c4 commit f904e61

4 files changed

Lines changed: 416 additions & 13 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
fix(client): `organizations.getActiveMember(organizationId)` answers the organisation the caller NAMES, not whichever one the session has active (#16568)
6+
7+
**BREAKING** — the answer this published method gives moves for existing inputs. The signature, the declared return type and the export are byte-identical; what changes is the response an existing call observes, stated below as a before/after pair per input.
8+
9+
The method 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 client doing a permission check for organisation B while A was active got **A's** membership row back, with a 200 and no diagnostic — the wrong-but-plausible answer, silently. The SDK's own JSDoc promised "the calling user's membership row in the given organisation", so this was a declared capability the runtime did not deliver.
10+
11+
It now asks the question honestly, in two requests:
12+
13+
1. `GET /get-session` — the caller's own user id;
14+
2. `GET /organization/list-members?organizationId=…&filterField=userId&filterValue=<the caller>&limit=1` — the row, unwrapped from the one-entry page.
15+
16+
`list-members` reads `ctx.query.organizationId`, and its rows carry the identical shape (`OrganizationMemberWithUserWire`, user projection included), so the signature and the declared return type are unchanged and no caller's types move.
17+
18+
## What an existing call observes, before and after
19+
20+
Everything here is measured against a real `AuthManager` (better-auth 1.7.2, organization plugin) over a real `SqlDriver`. Each bullet is one input, with the response it drew before and the response it draws now.
21+
22+
- **An organisation id other than the session's active one.** Before: a 200 carrying the **active** organisation's membership row, whatever id was named. After: a 200 carrying the **named** organisation's row. An input that named the active organisation's own id drew that organisation's row before and draws the same row after — `auth.me()` is where that id is readable, on `session.activeOrganizationId`.
23+
- **An organisation the caller is not a member of.** Before: the named organisation was never consulted, so the answer was about the **active** one — a 200 carrying the active organisation's row, or `400 MEMBER_NOT_FOUND` when the caller had no row there either. After: `403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION`, the server's own refusal, about the organisation that was actually named.
24+
- **Any id, on a session with no active organisation.** Before: `400 NO_ACTIVE_ORGANIZATION`. After: a 200 carrying the caller's row in the named organisation. `setActive` has stopped being a precondition, which is the point of naming the organisation.
25+
- **An empty `organizationId`.** Before: a 200 carrying the **active** organisation's row — better-auth resolves `ctx.query.organizationId || session.activeOrganizationId`, so an empty string fell through to session state and the wrong-but-plausible answer survived on that one input. After: the SDK refuses it before the wire, with a thrown `[ObjectStack] organizations.getActiveMember: organizationId is required`.
26+
27+
Two things do not move: an anonymous caller still draws `401 UNAUTHORIZED`, thrown by the same session middleware that guarded the old route; and the row's shape is the same on both sides. The method now makes two HTTP requests where it made one.
28+
29+
Graded `minor` rather than `patch`: the method's published behaviour moves for existing callers, which is the same clause-② judgement this PR declares, and 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 below what the act requires. The banner above carries the breaking-ness that the level cannot, per the ruling recorded on #16568 on 2026-09-08.
30+
31+
The auth route ledger's `GET /api/v1/auth/organization/get-active-member` row is rebooked from `sdk` to `server-only` in the same change: `sdk` means "expressed by the SDK", and no SDK method builds that URL any more. The `get-session` and `list-members` rows gain the method in their notes, since it now builds both. Ledger-internal, nothing published moves with it.
32+
33+
<!-- adr-0087: not-required (no-migration-prescription) SDK call-site change, no metadata conversion -->

‎packages/client/src/index.ts‎

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3426,25 +3426,93 @@ export class ObjectStackClient {
34263426
},
34273427

34283428
/**
3429-
* Look up the calling user's membership row in the ACTIVE organisation.
3429+
* Look up the calling user's membership row in the GIVEN organisation.
34303430
* Useful for permission checks on the client without having to scan the
34313431
* full member list.
34323432
*
3433-
* better-auth: GET /organization/get-active-member?organizationId=…
3433+
* Two requests, because no single better-auth route answers this question:
34343434
*
3435-
* ⚠️ The server reads only the session's `activeOrganizationId` and
3436-
* ignores the `organizationId` query this method sends (measured: a query
3437-
* naming another organization answered the active one's row). Call
3438-
* `setActive` first if the organisation you mean is not the active one;
3439-
* with no active organisation the route is a thrown 400
3440-
* `NO_ACTIVE_ORGANIZATION`.
3435+
* 1. `GET /get-session` — who is calling. The body is the bare
3436+
* `{ user, session }` envelope for a signed-in caller and the literal
3437+
* `null` for an anonymous one (measured).
3438+
* 2. `GET /organization/list-members?organizationId=…&filterField=userId`
3439+
* `&filterValue=<the caller>&limit=1` — the row, unwrapped from the
3440+
* one-entry page.
3441+
*
3442+
* ⚠️ It is deliberately NOT `GET /organization/get-active-member`, which
3443+
* this method used to call. That handler reads only the session's
3444+
* `activeOrganizationId` and never looks at `ctx.query`, so it answered the
3445+
* ACTIVE organisation's row whatever id the caller named — the
3446+
* wrong-but-plausible answer, silently. `list-members` reads
3447+
* `ctx.query.organizationId` and its rows carry the identical shape
3448+
* ({@link OrganizationMemberWithUserWire}), so only the addressing moved.
3449+
* Measured against better-auth 1.7.2 over a real `AuthManager` + `SqlDriver`.
3450+
*
3451+
* What an existing caller sees change, all of it measured on the same drive:
3452+
*
3453+
* - naming a NON-active organisation now answers THAT organisation's row
3454+
* instead of the active one's — the defect this method carried;
3455+
* - a caller who is not a member of `organizationId` is refused
3456+
* `403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION`. Before, the named
3457+
* organisation was never consulted, so the answer was about the
3458+
* ACTIVE one: a 200 carrying the active organisation's row, or
3459+
* `400 MEMBER_NOT_FOUND` when the caller had no row there either;
3460+
* - a caller with no active organisation gets their row rather than
3461+
* `400 NO_ACTIVE_ORGANIZATION` — `setActive` is no longer a
3462+
* precondition, which is the point of naming the organisation;
3463+
* - an anonymous caller still gets `401 UNAUTHORIZED`, thrown from the
3464+
* `list-members` request by the same session middleware that guarded
3465+
* `get-active-member`;
3466+
* - a FALSY `organizationId` is refused here, before the wire. It used to
3467+
* answer the ACTIVE organisation's row at 200: better-auth resolves
3468+
* `ctx.query.organizationId || session.activeOrganizationId`, so an
3469+
* empty string fell through to session state — the same
3470+
* wrong-but-plausible answer this method was fixed to stop giving,
3471+
* surviving on one input while the contract above says "the GIVEN
3472+
* organisation". Naming the active organisation explicitly asks that
3473+
* question honestly; `auth.me()` carries the id, on
3474+
* `session.activeOrganizationId`.
3475+
*
3476+
* @param organizationId the organisation to ask about. Required and
3477+
* non-empty; there is no "whichever one is active" spelling, deliberately.
3478+
* @throws if `organizationId` is falsy, or if the server answers 200 with no
3479+
* membership row for the caller.
34413480
*/
34423481
getActiveMember: async (organizationId: string): Promise<OrganizationMemberWithUserWire> => {
3482+
// A falsy id is not "the active organisation", it is a caller bug: the
3483+
// route would silently substitute session state for the question asked.
3484+
// Loud beats a plausible answer about the wrong organisation (#16568).
3485+
if (!organizationId) {
3486+
throw new Error('[ObjectStack] organizations.getActiveMember: organizationId is required');
3487+
}
34433488
const route = this.getRoute('auth');
3489+
// Step 1 — the caller's own user id. Typed to the shape the route really
3490+
// serves rather than to `SessionResponse`, which declares the REST
3491+
// `{ success, data }` envelope this better-auth route does not use.
3492+
const sessionRes = await this.fetch(`${this.baseUrl}${route}/get-session`, {
3493+
headers: { Origin: this.baseUrl },
3494+
});
3495+
const session = (await sessionRes.json()) as { user?: { id?: string } } | null;
3496+
// Anonymous → `null`, and the request below is then refused 401 by the
3497+
// session middleware before the filter is ever read. The refusal stays
3498+
// the SERVER's; nothing is invented here to stand in for it.
3499+
const userId = session?.user?.id ?? '';
34443500
const res = await this.fetch(
3445-
`${this.baseUrl}${route}/organization/get-active-member?organizationId=${encodeURIComponent(organizationId)}`,
3501+
`${this.baseUrl}${route}/organization/list-members`
3502+
+ `?organizationId=${encodeURIComponent(organizationId)}`
3503+
+ `&filterField=userId&filterValue=${encodeURIComponent(userId)}&limit=1`,
34463504
);
3447-
return res.json();
3505+
const page = (await res.json()) as OrganizationMembersPage;
3506+
const [member] = page.members;
3507+
if (!member) {
3508+
// Unreachable through the route's own gate — `list-members` refuses a
3509+
// non-member 403 before it filters, so a 200 with no row means the
3510+
// membership vanished between the two requests. Loud beats a cast.
3511+
throw new Error(
3512+
`[ObjectStack] organizations.getActiveMember: no membership row for the calling user in organization "${organizationId}"`,
3513+
);
3514+
}
3515+
return member;
34483516
},
34493517

34503518
/**

0 commit comments

Comments
 (0)