From e291415844cbc98069c66a78c534efaca056f530 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 02:39:33 +0000 Subject: [PATCH 1/8] 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=&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 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- packages/client/src/index.ts | 66 ++++- ...ation-get-active-member-addressing.test.ts | 275 ++++++++++++++++++ .../plugin-auth/src/auth-route-ledger.ts | 2 +- 3 files changed, 332 insertions(+), 11 deletions(-) create mode 100644 packages/client/src/organization-get-active-member-addressing.test.ts diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7fa9e129ca..ef5fa76061 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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=&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 => { 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; }, /** diff --git a/packages/client/src/organization-get-active-member-addressing.test.ts b/packages/client/src/organization-get-active-member-addressing.test.ts new file mode 100644 index 0000000000..6cfdc21748 --- /dev/null +++ b/packages/client/src/organization-get-active-member-addressing.test.ts @@ -0,0 +1,275 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16568] `organizations.getActiveMember(organizationId)` addresses the + * organisation the CALLER NAMES — not whichever one the session happens to + * have active. + * + * ## The defect + * + * The method used to build + * `GET /organization/get-active-member?organizationId=…`. better-auth 1.7.2's + * handler for that path (`plugins/organization/routes/crud-members.mjs`) reads + * `session.session.activeOrganizationId` and never looks at `ctx.query`, so the + * query string was dead on arrival: a caller doing a permission check for + * organisation B while A was active was told about **A**, with a 200 and 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. + * + * ## The fixture is the vendor's behaviour, measured — not an approximation + * + * {@link betterAuthDouble} below is modelled on a drive of a REAL `AuthManager` + * (better-auth 1.7.2, organization plugin, teams enabled) over a REAL + * `SqlDriver` (better-sqlite3 `:memory:`), one user owning two organisations + * with A active. The transcript that fixes each arm: + * + * ``` + * GET /organization/get-active-member?organizationId= -> 200 {organizationId:, role:'owner', …} + * GET /organization/get-active-member?organizationId= -> 200 {organizationId:, role:'owner', …} <- same row + * GET /organization/get-active-member (no active org) -> 400 NO_ACTIVE_ORGANIZATION + * GET /organization/list-members?organizationId=&filterField=userId&filterValue=&limit=1 + * -> 200 {members:[{organizationId:, …}], total:1} + * GET /organization/list-members?organizationId=&filterField=userId&filterValue=&limit=1 + * -> 200 {members:[{organizationId:, …}], total:1} + * GET /organization/list-members?organizationId= -> 200 {members:[, ], total:2} + * GET /organization/list-members?organizationId=&filterField=userId&filterValue= -> the OTHER row + * GET /organization/list-members?organizationId=&filterField=userId&filterValue= + * -> 403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION + * GET /get-session (signed in) -> 200 {user:{id,…}, session:{…}} (BARE, no envelope) + * GET /get-session (anonymous) -> 200 null + * GET /organization/list-members (anonymous) -> 401 UNAUTHORIZED + * ``` + * + * The two rows the fixture serves differ in `id` **and** `organizationId`, so + * "answered the wrong organisation" is a value difference an assertion can see. + * + * ## Why this file cannot pass for the wrong reason + * + * The double keeps the DEFECT alive on `get-active-member`: it answers the + * ACTIVE organisation's row whatever id the query names, exactly as the vendor + * does. So a regression that routes the method back to that path returns A's + * row while the case asks for B's, and case ① goes red on the value — not + * merely on a URL string. The URL assertions in case ② are the second face: + * they pin the request BYTES, which is what the card's finding was ultimately + * about, and they fail on a filter that is dropped or misspelled even if some + * future double got lucky on the row. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectStackClient } from './index'; +import type { OrganizationMemberWithUserWire } from './index'; + +const BASE = 'http://localhost:9'; +const AUTH = `${BASE}/api/v1/auth`; + +const USER = { id: 'usr_self', name: 'Probe', email: 'probe@example.com', image: null } as const; +const OTHER = { id: 'usr_other', name: 'Second', email: 'second@example.com', image: null } as const; + +const ORG_A = 'org_alpha'; +const ORG_B = 'org_bravo'; +const ORG_FOREIGN = 'org_foreign'; + +/** The membership rows the fixture's store holds, in the vendor's own shape. */ +const ROWS: Record = { + [ORG_A]: [ + { id: 'mem_a_self', organizationId: ORG_A, userId: USER.id, role: 'owner', createdAt: '2026-09-08T02:34:14.706Z', user: { ...USER } }, + ], + [ORG_B]: [ + { id: 'mem_b_self', organizationId: ORG_B, userId: USER.id, role: 'owner', createdAt: '2026-09-08T02:34:14.707Z', user: { ...USER } }, + { id: 'mem_b_other', organizationId: ORG_B, userId: OTHER.id, role: 'member', createdAt: '2026-09-08T02:34:14.902Z', user: { ...OTHER } }, + ], + [ORG_FOREIGN]: [], +}; + +interface DoubleOptions { + /** `null` models a signed-in session with no active organisation. */ + activeOrganizationId: string | null; + /** `false` models an anonymous caller: `/get-session` answers the literal `null`. */ + signedIn?: boolean; +} + +interface Drive { + client: ObjectStackClient; + /** Every URL the client put on the wire, in order. */ + urls: string[]; +} + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +/** + * A stand-in for better-auth 1.7.2's organization routes, arm for arm as + * measured. Only the socket is faked — every status, code and row shape below + * is a transcript line from the drive quoted in this file's header. + */ +function betterAuthDouble(options: DoubleOptions): Drive { + const signedIn = options.signedIn !== false; + const urls: string[] = []; + + const client = new ObjectStackClient({ + baseUrl: BASE, + fetch: async (input) => { + const url = String(input); + urls.push(url); + const parsed = new URL(url); + const q = parsed.searchParams; + + if (parsed.pathname === '/api/v1/auth/get-session') { + // Measured: the BARE `{ user, session }` body for a signed-in caller, + // and the literal `null` — at 200, not 401 — for an anonymous one. + if (!signedIn) return json(200, null); + return json(200, { + user: { ...USER, emailVerified: false, createdAt: '2026-09-08T02:34:14.6Z', updatedAt: '2026-09-08T02:34:14.6Z' }, + session: { id: 'ses_1', userId: USER.id, token: 'tok', activeOrganizationId: options.activeOrganizationId, activeTeamId: null }, + }); + } + + if (!signedIn) { + // Every organisation route sits behind better-auth's session + // middleware, which refuses before any handler reads the query. + return json(401, { message: 'Unauthorized', code: 'UNAUTHORIZED' }); + } + + if (parsed.pathname === '/api/v1/auth/organization/get-active-member') { + // THE DEFECT, KEPT ALIVE: `organizationId` in the query is ignored and + // the session's active organisation answers. This arm exists so a + // regression to this route fails on the ROW, not on a URL string. + const active = options.activeOrganizationId; + if (!active) return json(400, { message: 'No active organization', code: 'NO_ACTIVE_ORGANIZATION' }); + const row = (ROWS[active] ?? []).find((m) => m.userId === USER.id); + if (!row) return json(400, { message: 'Member not found', code: 'MEMBER_NOT_FOUND' }); + return json(200, row); + } + + if (parsed.pathname === '/api/v1/auth/organization/list-members') { + const organizationId = q.get('organizationId') || options.activeOrganizationId; + if (!organizationId) return json(400, { message: 'No active organization', code: 'NO_ACTIVE_ORGANIZATION' }); + const table = ROWS[organizationId] ?? []; + // The vendor's own membership gate, which runs BEFORE the filter. + if (!table.some((m) => m.userId === USER.id)) { + return json(403, { + message: 'You are not a member of this organization', + code: 'YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION', + }); + } + let members = table; + const field = q.get('filterField'); + if (field) { + const value = q.get('filterValue'); + members = members.filter((m) => String((m as unknown as Record)[field]) === value); + } + const limit = q.get('limit'); + if (limit) members = members.slice(0, Number(limit)); + return json(200, { members, total: members.length }); + } + + throw new Error(`fixture: unexpected request ${url}`); + }, + }); + + return { client, urls }; +} + +describe('[#16568] organizations.getActiveMember addresses the organisation the caller names', () => { + it('① answers the NAMED organisation’s row while a DIFFERENT one is active — the reported defect', async () => { + const { client, urls } = betterAuthDouble({ activeOrganizationId: ORG_A }); + + const member = await client.organizations.getActiveMember(ORG_B); + + // The value that separates fixed from broken: before the fix this resolved + // to `mem_a_self` / `org_alpha` — a 200 carrying the wrong organisation. + expect(member.organizationId).toBe(ORG_B); + expect(member.id).toBe('mem_b_self'); + expect(member.userId).toBe(USER.id); + expect(member.role).toBe('owner'); + // The joined user projection survives the route change (the two routes + // serve the identical row shape — measured, and the reason the declared + // return type does not move). + expect(member.user).toEqual(USER); + + // ...and the dead route is not consulted at all. + expect(urls.some((u) => u.includes('/organization/get-active-member'))).toBe(false); + }); + + it('② the request bytes: who-am-I, then a self-filtered read of the NAMED organisation', async () => { + const { client, urls } = betterAuthDouble({ activeOrganizationId: ORG_A }); + + await client.organizations.getActiveMember(ORG_B); + + expect(urls).toHaveLength(2); + expect(urls[0]).toBe(`${AUTH}/get-session`); + + const second = new URL(urls[1]!); + expect(second.pathname).toBe('/api/v1/auth/organization/list-members'); + expect(second.searchParams.get('organizationId')).toBe(ORG_B); + expect(second.searchParams.get('filterField')).toBe('userId'); + expect(second.searchParams.get('filterValue')).toBe(USER.id); + expect(second.searchParams.get('limit')).toBe('1'); + }); + + it('③ still answers the ACTIVE organisation correctly when that is what the caller names', async () => { + const { client } = betterAuthDouble({ activeOrganizationId: ORG_A }); + + const member = await client.organizations.getActiveMember(ORG_A); + + expect(member.organizationId).toBe(ORG_A); + expect(member.id).toBe('mem_a_self'); + }); + + it('④ needs no active organisation — `setActive` stopped being a precondition', async () => { + // Against `get-active-member` this exact call was a thrown + // `400 NO_ACTIVE_ORGANIZATION`, which is what made "name the organisation + // you mean" impossible to express through this method at all. + const { client } = betterAuthDouble({ activeOrganizationId: null }); + + const member = await client.organizations.getActiveMember(ORG_B); + + expect(member.organizationId).toBe(ORG_B); + expect(member.id).toBe('mem_b_self'); + }); + + it('⑤ a NON-member is refused by the server, in the ADR-0112 envelope', async () => { + const { client } = betterAuthDouble({ activeOrganizationId: ORG_A }); + + // Asserted as an envelope, not as a bare `toThrow()`: a method that threw a + // plain `Error` for its own reasons would satisfy `toThrow` and tell us + // nothing about who refused. + const error = await client.organizations + .getActiveMember(ORG_FOREIGN) + .then(() => null, (e: unknown) => e as { code?: string; httpStatus?: number }); + + expect(error?.code).toBe('YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION'); + expect(error?.httpStatus).toBe(403); + }); + + it('⑥ an anonymous caller is still refused 401 by the server, not by an invented client-side error', async () => { + const { client, urls } = betterAuthDouble({ activeOrganizationId: ORG_A, signedIn: false }); + + const error = await client.organizations + .getActiveMember(ORG_B) + .then(() => null, (e: unknown) => e as { code?: string; httpStatus?: number }); + + expect(error?.code).toBe('UNAUTHORIZED'); + expect(error?.httpStatus).toBe(401); + // The refusal comes from the second request — the SDK does not short-circuit + // on the `null` session and substitute a diagnostic of its own. + expect(urls).toHaveLength(2); + expect(urls[1]).toContain('/organization/list-members'); + }); + + it('⑦ the double really can serve the wrong row — guard the guard', async () => { + // Cases ①/③/④ are value assertions, and a value assertion is only as good + // as the fixture's ability to produce the other value. This drives the + // dead route directly through the same double and pins that it STILL + // reproduces the defect: named B, answered A. If this ever goes red the + // vendor has changed and the arms above stopped discriminating. + const { client } = betterAuthDouble({ activeOrganizationId: ORG_A }); + const raw = await (client as unknown as { + fetchImpl: (input: string) => Promise; + }).fetchImpl(`${AUTH}/organization/get-active-member?organizationId=${ORG_B}`); + + expect(raw.status).toBe(200); + expect(await raw.json()).toMatchObject({ organizationId: ORG_A, id: 'mem_a_self' }); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts index e48823babc..8c3580356c 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.ts @@ -253,7 +253,7 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [ { route: 'POST /api/v1/auth/organization/create', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.create', requires: 'organization' }, { route: 'POST /api/v1/auth/organization/create-team', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.teams.create', requires: 'organization' }, { route: 'POST /api/v1/auth/organization/delete', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.delete', requires: 'organization' }, - { route: 'GET /api/v1/auth/organization/get-active-member', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.getActiveMember', requires: 'organization' }, + { route: 'GET /api/v1/auth/organization/get-active-member', family: 'organization', source: 'better-auth', disposition: 'server-only', requires: 'organization', note: 'no SDK method builds this URL — the handler reads only session.activeOrganizationId and never looks at ctx.query, so it cannot answer the per-organization question organizations.getActiveMember asks; that method addresses list-members with a userId filter instead, #16568' }, { route: 'GET /api/v1/auth/organization/get-full-organization', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.get', requires: 'organization' }, { route: 'POST /api/v1/auth/organization/invite-member', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.resend', requires: 'organization', note: 'organizations.invitations.resend and organizations.invite both target it' }, { route: 'POST /api/v1/auth/organization/leave', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.leave', requires: 'organization' }, From 540c19253bd3626430a58eb0e1c08b81c13c64d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 02:41:35 +0000 Subject: [PATCH 2/8] chore(changeset): declare the getActiveMember addressing fix Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- ...et-active-member-names-the-organisation.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .changeset/client-get-active-member-names-the-organisation.md diff --git a/.changeset/client-get-active-member-names-the-organisation.md b/.changeset/client-get-active-member-names-the-organisation.md new file mode 100644 index 0000000000..9ab805f7d3 --- /dev/null +++ b/.changeset/client-get-active-member-names-the-organisation.md @@ -0,0 +1,28 @@ +--- +"@objectstack/client": patch +--- + +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=&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`). + +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. From 89915fcfc62f642d6c979071b847510cf79207ff Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 03:24:40 +0000 Subject: [PATCH 3/8] chore(plugin-auth): keep the tracker id out of the ledger note string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- packages/plugins/plugin-auth/src/auth-route-ledger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts index 8c3580356c..95303f9b3b 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.ts @@ -253,7 +253,7 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [ { route: 'POST /api/v1/auth/organization/create', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.create', requires: 'organization' }, { route: 'POST /api/v1/auth/organization/create-team', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.teams.create', requires: 'organization' }, { route: 'POST /api/v1/auth/organization/delete', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.delete', requires: 'organization' }, - { route: 'GET /api/v1/auth/organization/get-active-member', family: 'organization', source: 'better-auth', disposition: 'server-only', requires: 'organization', note: 'no SDK method builds this URL — the handler reads only session.activeOrganizationId and never looks at ctx.query, so it cannot answer the per-organization question organizations.getActiveMember asks; that method addresses list-members with a userId filter instead, #16568' }, + { route: 'GET /api/v1/auth/organization/get-active-member', family: 'organization', source: 'better-auth', disposition: 'server-only', requires: 'organization', note: 'no SDK method builds this URL — the handler reads only session.activeOrganizationId and never looks at ctx.query, so it cannot answer the per-organization question organizations.getActiveMember asks; that method addresses list-members with a userId filter instead' }, { route: 'GET /api/v1/auth/organization/get-full-organization', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.get', requires: 'organization' }, { route: 'POST /api/v1/auth/organization/invite-member', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.resend', requires: 'organization', note: 'organizations.invitations.resend and organizations.invite both target it' }, { route: 'POST /api/v1/auth/organization/leave', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.leave', requires: 'organization' }, From eb75819641d0e8906461985e9bb16930d705c625 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 04:03:49 +0000 Subject: [PATCH 4/8] chore(changeset): grade @objectstack/client minor, not patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- .changeset/client-get-active-member-names-the-organisation.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/client-get-active-member-names-the-organisation.md b/.changeset/client-get-active-member-names-the-organisation.md index 9ab805f7d3..4af1ab704e 100644 --- a/.changeset/client-get-active-member-names-the-organisation.md +++ b/.changeset/client-get-active-member-names-the-organisation.md @@ -1,5 +1,5 @@ --- -"@objectstack/client": patch +"@objectstack/client": minor --- fix(client): `organizations.getActiveMember(organizationId)` answers the organisation the caller NAMES, not whichever one the session has active (#16568) @@ -25,4 +25,6 @@ Everything here is measured against a real `AuthManager` (better-auth 1.7.2, org 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. From d3ddfa1120ade7a828bd60961822cadde246b8c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 02:13:26 +0000 Subject: [PATCH 5/8] fix(client): refuse a falsy organizationId in getActiveMember MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5 --- packages/client/src/index.ts | 22 ++++++++++++++- ...ation-get-active-member-addressing.test.ts | 27 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index befc1cd5df..f111886643 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -3460,9 +3460,29 @@ export class ObjectStackClient { * 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`. + * `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 => { + // 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 diff --git a/packages/client/src/organization-get-active-member-addressing.test.ts b/packages/client/src/organization-get-active-member-addressing.test.ts index 6cfdc21748..5e372337e7 100644 --- a/packages/client/src/organization-get-active-member-addressing.test.ts +++ b/packages/client/src/organization-get-active-member-addressing.test.ts @@ -272,4 +272,31 @@ describe('[#16568] organizations.getActiveMember addresses the organisation the expect(raw.status).toBe(200); expect(await raw.json()).toMatchObject({ organizationId: ORG_A, id: 'mem_a_self' }); }); + + it('⑧ an EMPTY organizationId is refused before the wire, not answered with the ACTIVE row', async () => { + // The last input in the wrong-but-plausible class. better-auth resolves + // `ctx.query.organizationId || session.activeOrganizationId`, so an empty + // string reached `list-members` and came back 200 carrying ORG_A's row + // while the JSDoc said "the GIVEN organisation" — the same silent + // substitution this card is about, surviving on one argument. + const { client, urls } = betterAuthDouble({ activeOrganizationId: ORG_A }); + + await expect(client.organizations.getActiveMember('')).rejects.toThrow( + '[ObjectStack] organizations.getActiveMember: organizationId is required', + ); + // Refused CLIENT-side: nothing was put on the wire at all, so this cannot + // pass because some server happened to say no. + expect(urls).toEqual([]); + + // Guard the guard: the fallback the refusal prevents is real in this + // fixture, exactly as it is in the vendor. Drive `list-members` with an + // empty id through the same double and watch it answer the ACTIVE + // organisation at 200 — which is what case ⑧ would have returned. + const raw = await (client as unknown as { + fetchImpl: (input: string) => Promise; + }).fetchImpl(`${AUTH}/organization/list-members?organizationId=&filterField=userId&filterValue=${USER.id}&limit=1`); + + expect(raw.status).toBe(200); + expect(await raw.json()).toMatchObject({ members: [{ organizationId: ORG_A, id: 'mem_a_self' }] }); + }); }); From b4dc62586a42332214b80f31d24c41ef440a378a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 02:13:32 +0000 Subject: [PATCH 6/8] 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) Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5 --- packages/plugins/plugin-auth/src/auth-route-ledger.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts index 95303f9b3b..f88eb107ca 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.ts @@ -155,7 +155,7 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [ // does not move with it: the design question is the standing one, and a // future design starts from #7724's deletion semantics. { route: 'POST /api/v1/auth/delete-user', family: 'core-auth', source: 'better-auth', disposition: 'disabled', client: 'auth.deleteUser', note: 'better-auth publishes the endpoint but user.deleteUser is deliberately unconfigured, so it answers 404 (as does its GET /delete-user/callback half); self-service deletion needs a deliberate B2B design first — maintainer ruling 2026-08-12 on #7735' }, - { route: 'GET /api/v1/auth/get-session', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.me', note: 'auth.me and auth.refreshToken both target it' }, + { route: 'GET /api/v1/auth/get-session', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.me', note: 'auth.me, auth.refreshToken and organizations.getActiveMember all target it — the last one reads only the caller\'s own user id from it, as step 1 of its two-request self-membership lookup' }, { route: 'POST /api/v1/auth/link-social', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.accounts.linkSocial' }, { route: 'GET /api/v1/auth/list-accounts', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.accounts.list' }, { route: 'GET /api/v1/auth/list-sessions', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.sessions.list' }, @@ -259,7 +259,7 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [ { route: 'POST /api/v1/auth/organization/leave', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.leave', requires: 'organization' }, { route: 'GET /api/v1/auth/organization/list', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.list', requires: 'organization' }, { route: 'GET /api/v1/auth/organization/list-invitations', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.list', requires: 'organization' }, - { route: 'GET /api/v1/auth/organization/list-members', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.listMembers', requires: 'organization' }, + { route: 'GET /api/v1/auth/organization/list-members', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.listMembers', requires: 'organization', note: 'organizations.listMembers and organizations.getActiveMember both build it — the latter with filterField=userId&filterValue=&limit=1, because no better-auth route answers \'my membership row in the organisation I name\' (get-active-member reads only session.activeOrganizationId)' }, { route: 'GET /api/v1/auth/organization/list-team-members', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.teams.listMembers', requires: 'organization' }, { route: 'GET /api/v1/auth/organization/list-teams', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.teams.list', requires: 'organization' }, { route: 'GET /api/v1/auth/organization/list-user-invitations', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.listMine', requires: 'organization' }, From 4ebf8692d9c5cfb896c6d4d03c50a6af289b2278 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 02:13:40 +0000 Subject: [PATCH 7/8] 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) Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5 --- ...et-active-member-names-the-organisation.md | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/.changeset/client-get-active-member-names-the-organisation.md b/.changeset/client-get-active-member-names-the-organisation.md index 4af1ab704e..bda2737d30 100644 --- a/.changeset/client-get-active-member-names-the-organisation.md +++ b/.changeset/client-get-active-member-names-the-organisation.md @@ -4,6 +4,8 @@ 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: @@ -11,20 +13,21 @@ 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=&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. +`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 -## 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`. Each bullet is one input, with the response it drew before and the response it draws now. -Everything here is measured against a real `AuthManager` (better-auth 1.7.2, organization plugin) over a real `SqlDriver`: +- **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: `400 MEMBER_NOT_FOUND`, and about the active organisation at that — the named organisation was never consulted. 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`. -- 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. +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. -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 banner above carries the breaking-ness that the level cannot, per the ruling recorded on #16568 on 2026-09-08. -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. 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. -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. + From f6c694123d7deaf843623d1ccfcefecac651c6c4 Mon Sep 17 00:00:00 2001 From: huangyiirene Date: Wed, 9 Sep 2026 08:37:20 +0000 Subject: [PATCH 8/8] docs(client): correct the pre-fix answer stated for a non-member of the named organisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_017Js5kTpTtxieBjPyScgxJ3 --- .../client-get-active-member-names-the-organisation.md | 2 +- packages/client/src/index.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.changeset/client-get-active-member-names-the-organisation.md b/.changeset/client-get-active-member-names-the-organisation.md index bda2737d30..245794df04 100644 --- a/.changeset/client-get-active-member-names-the-organisation.md +++ b/.changeset/client-get-active-member-names-the-organisation.md @@ -20,7 +20,7 @@ It now asks the question honestly, in two requests: 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: `400 MEMBER_NOT_FOUND`, and about the active organisation at that — the named organisation was never consulted. After: `403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION`, the server's own refusal, about the organisation that was actually named. +- **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`. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index f111886643..1ba8bd165c 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -3453,8 +3453,10 @@ export class ObjectStackClient { * - 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); + * `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;