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..245794df04 --- /dev/null +++ b/.changeset/client-get-active-member-names-the-organisation.md @@ -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=&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. + + diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index f36c361269..1ba8bd165c 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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=&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 => { + // 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; }, /** 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..5e372337e7 --- /dev/null +++ b/packages/client/src/organization-get-active-member-addressing.test.ts @@ -0,0 +1,302 @@ +// 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' }); + }); + + 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' }] }); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts index e48823babc..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' }, @@ -253,13 +253,13 @@ 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' }, { 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' }, { 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' },