Skip to content
Draft
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
30 changes: 30 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,30 @@
---
"@objectstack/client": minor
---

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

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 has to be edited.

## What an existing caller can observe change

Everything here is measured against a real `AuthManager` (better-auth 1.7.2, organization plugin) over a real `SqlDriver`:

- naming a non-active organisation now answers that organisation's row instead of the active one's — the defect;
- a caller who is not a member of the named organisation is refused `403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION` by the server. The old shape could not report this at all: it never asked about the named organisation, so it answered about the active one instead;
- a caller with no active organisation gets their row rather than a thrown `400 NO_ACTIVE_ORGANIZATION`. `setActive` has stopped being a precondition, which is the point of naming the organisation;
- an anonymous caller still gets `401 UNAUTHORIZED`, thrown by the same session middleware that guarded the old route;
- the method now makes two HTTP requests where it made one.

Callers that relied on passing an arbitrary id to read the ACTIVE organisation's row should pass the active organisation's id (`auth.me()` carries `session.activeOrganizationId`).

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 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. Ledger-internal, nothing published moves with it.
66 changes: 56 additions & 10 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3417,25 +3417,71 @@ 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` (it was
* `400 MEMBER_NOT_FOUND`, and about the ACTIVE organisation at that);
* - 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`.
*/
getActiveMember: async (organizationId: string): Promise<OrganizationMemberWithUserWire> => {
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