Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/client-get-active-member-names-the-organisation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@objectstack/client": minor
---

fix(client): `organizations.getActiveMember(organizationId)` answers the organisation the caller NAMES, not whichever one the session has active (#16568)

**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.

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.

It now asks the question honestly, in two requests:

1. `GET /get-session` — the caller's own user id;
2. `GET /organization/list-members?organizationId=…&filterField=userId&filterValue=<the caller>&limit=1` — the row, unwrapped from the one-entry page.

`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.

## What an existing call observes, before and after

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.

- **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`.
- **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.
- **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.
- **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`.

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.

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.

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.

<!-- adr-0087: not-required (no-migration-prescription) SDK call-site change, no metadata conversion -->
88 changes: 78 additions & 10 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3426,25 +3426,93 @@ export class ObjectStackClient {
},

/**
* Look up the calling user's membership row in the ACTIVE organisation.
* Look up the calling user's membership row in the GIVEN organisation.
* Useful for permission checks on the client without having to scan the
* full member list.
*
* better-auth: GET /organization/get-active-member?organizationId=…
* Two requests, because no single better-auth route answers this question:
*
* ⚠️ The server reads only the session's `activeOrganizationId` and
* ignores the `organizationId` query this method sends (measured: a query
* naming another organization answered the active one's row). Call
* `setActive` first if the organisation you mean is not the active one;
* with no active organisation the route is a thrown 400
* `NO_ACTIVE_ORGANIZATION`.
* 1. `GET /get-session` — who is calling. The body is the bare
* `{ user, session }` envelope for a signed-in caller and the literal
* `null` for an anonymous one (measured).
* 2. `GET /organization/list-members?organizationId=…&filterField=userId`
* `&filterValue=<the caller>&limit=1` — the row, unwrapped from the
* one-entry page.
*
* ⚠️ It is deliberately NOT `GET /organization/get-active-member`, which
* this method used to call. That handler reads only the session's
* `activeOrganizationId` and never looks at `ctx.query`, so it answered the
* ACTIVE organisation's row whatever id the caller named — the
* wrong-but-plausible answer, silently. `list-members` reads
* `ctx.query.organizationId` and its rows carry the identical shape
* ({@link OrganizationMemberWithUserWire}), so only the addressing moved.
* Measured against better-auth 1.7.2 over a real `AuthManager` + `SqlDriver`.
*
* What an existing caller sees change, all of it measured on the same drive:
*
* - naming a NON-active organisation now answers THAT organisation's row
* instead of the active one's — the defect this method carried;
* - a caller who is not a member of `organizationId` is refused
* `403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION`. 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;
* - a caller with no active organisation gets their row rather than
* `400 NO_ACTIVE_ORGANIZATION` — `setActive` is no longer a
* precondition, which is the point of naming the organisation;
* - an anonymous caller still gets `401 UNAUTHORIZED`, thrown from the
* `list-members` request by the same session middleware that guarded
* `get-active-member`;
* - a FALSY `organizationId` is refused here, before the wire. It used to
* answer the ACTIVE organisation's row at 200: better-auth resolves
* `ctx.query.organizationId || session.activeOrganizationId`, so an
* empty string fell through to session state — the same
* wrong-but-plausible answer this method was fixed to stop giving,
* surviving on one input while the contract above says "the GIVEN
* organisation". Naming the active organisation explicitly asks that
* question honestly; `auth.me()` carries the id, on
* `session.activeOrganizationId`.
*
* @param organizationId the organisation to ask about. Required and
* non-empty; there is no "whichever one is active" spelling, deliberately.
* @throws if `organizationId` is falsy, or if the server answers 200 with no
* membership row for the caller.
*/
getActiveMember: async (organizationId: string): Promise<OrganizationMemberWithUserWire> => {
// A falsy id is not "the active organisation", it is a caller bug: the
// route would silently substitute session state for the question asked.
// Loud beats a plausible answer about the wrong organisation (#16568).
if (!organizationId) {
throw new Error('[ObjectStack] organizations.getActiveMember: organizationId is required');
}
const route = this.getRoute('auth');
// Step 1 — the caller's own user id. Typed to the shape the route really
// serves rather than to `SessionResponse`, which declares the REST
// `{ success, data }` envelope this better-auth route does not use.
const sessionRes = await this.fetch(`${this.baseUrl}${route}/get-session`, {
headers: { Origin: this.baseUrl },
});
const session = (await sessionRes.json()) as { user?: { id?: string } } | null;
// Anonymous → `null`, and the request below is then refused 401 by the
// session middleware before the filter is ever read. The refusal stays
// the SERVER's; nothing is invented here to stand in for it.
const userId = session?.user?.id ?? '';
const res = await this.fetch(
`${this.baseUrl}${route}/organization/get-active-member?organizationId=${encodeURIComponent(organizationId)}`,
`${this.baseUrl}${route}/organization/list-members`
+ `?organizationId=${encodeURIComponent(organizationId)}`
+ `&filterField=userId&filterValue=${encodeURIComponent(userId)}&limit=1`,
);
return res.json();
const page = (await res.json()) as OrganizationMembersPage;
const [member] = page.members;
if (!member) {
// Unreachable through the route's own gate — `list-members` refuses a
// non-member 403 before it filters, so a 200 with no row means the
// membership vanished between the two requests. Loud beats a cast.
throw new Error(
`[ObjectStack] organizations.getActiveMember: no membership row for the calling user in organization "${organizationId}"`,
);
}
return member;
},

/**
Expand Down
Loading
Loading