diff --git a/.changeset/18651-getactivemember-anonymous-statements.md b/.changeset/18651-getactivemember-anonymous-statements.md new file mode 100644 index 00000000000..8f75477d29e --- /dev/null +++ b/.changeset/18651-getactivemember-anonymous-statements.md @@ -0,0 +1,67 @@ +--- +'@objectstack/client': patch +--- + +`organizations.getActiveMember`'s own prose says what an anonymous caller gets TODAY: `401 UNAUTHENTICATED` on request ONE — not `200 null` and then a `401 UNAUTHORIZED` from `list-members` + +objectstack#17881 (`374d9d3afa`) landed `plugin-auth`'s +`refuseAnonymousSession`, which converts better-auth's `200` + the literal JSON +`null` on `GET /api/v1/auth/get-session` into the declared ADR-0112 refusal +envelope — HTTP `401`, `code: UNAUTHENTICATED` — before it leaves the process. +`@objectstack/client` reaches the server over the wire, so that is what it +sees. Three present-tense statements in and around `getActiveMember` still +described the retired shape, and they were wrong on two axes at once: the CODE +(`UNAUTHORIZED` vs `UNAUTHENTICATED`) and the REQUEST the refusal arrives on +(the second one, `list-members`, vs the first, `/get-session` itself). + +**FROM → TO for a caller.** `getActiveMember` makes two requests for a +signed-in caller. For an anonymous one it now makes ONE, and rejects: + +| you wrote | write instead | +|:--|:--| +| `try { await c.organizations.getActiveMember(id) } catch (e) { if (e.code === 'UNAUTHORIZED') … }` | `… catch (e) { if (e.code === 'UNAUTHENTICATED') … }` | + +The behaviour is objectstack#17881's and shipped then; what moves here is only +the SDK's description of it. A reader coding against the old prose caught the +wrong code, and expected the refusal on a request that is never put on the +wire. + +**What changed** + +- Step 1 of the two-request list no longer says `/get-session` serves "the + literal `null` for an anonymous one". The signed-in arm keeps its + `(measured)` tag, which is still the 2026-09-09 drive's; the anonymous + answer is stated separately and anchored to the producer, including that + step 2 never reaches the wire. +- The anonymous bullet of that drive's delta list no longer says an anonymous + caller "still gets `401 UNAUTHORIZED`, thrown from the `list-members` + request". It is RE-ANCHORED rather than restamped — the drive's own row is + kept in the past tense and today's answer is stated from the producer, the + same disposition objectstack#18642 used on this family's sibling statements. +- The inline comment on the `userId` read no longer says `Anonymous → null`. + It says an anonymous caller never reaches that line, and says why the + `| null` annotation and the `?? ''` fallback stay as the defensive branch + they always were. + +⛔ No behaviour changes. `packages/client/src/index.ts` changes COMMENTS ONLY — +verified mechanically: of every line the diff touches in that file, zero are +outside a comment. + +**This is shipped, which is why it carries a changeset rather than +`skip-changeset`.** `@objectstack/client`'s published `files[]` is +`["dist","README.md","CHANGELOG.md"]` and `getActiveMember` is a member of the +exported `ObjectStackClient`, so its TSDoc is emitted into the shipped +artifacts. Measured on the built `dist` at `13e09a5e3c`: the corrected sentence +is present exactly once in `dist/index.d.ts`, `dist/index.d.mts`, +`dist/index.js` and `dist/index.mjs`; the retired sentence is absent from all +four; and `getActiveMember` was carried as the lit control, found in every one +of them. ⚠️ This package emits no `.d.cts` and no `.cjs` — its CJS pair is +`index.js` + `index.d.ts` and its ESM pair is `index.mjs` + `index.d.mts`, so +a `*.d.cts` check here would have measured an absent file. + +Clause-②: no — no schema key moves, no closed set gains or loses a member, no +published export changes and no registry row is touched. `UNAUTHENTICATED` is +an existing `StandardErrorCode` that objectstack#17881 already derives through +`standardErrorCodeForHttpStatus(401)`; nothing is minted here. The direction is +a pull-back: the runtime has answered `401` since objectstack#17881 and the +SDK's self-description was lagging. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 2f920cf4fee..102b25a4dab 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -3829,12 +3829,19 @@ export class ObjectStackClient { * Two requests, because no single better-auth route answers this question: * * 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). + * `{ user, session }` envelope for a signed-in caller (measured). * 2. `GET /organization/list-members?organizationId=…&filterField=userId` * `&filterValue=&limit=1` — the row, unwrapped from the * one-entry page. * + * ⚠️ An ANONYMOUS caller never gets a second request. Since #17881 + * (`374d9d3afa`) `plugin-auth`'s `refuseAnonymousSession` answers + * `/get-session` the declared ADR-0112 envelope at `401` — + * `code: 'UNAUTHENTICATED'` — instead of better-auth's `200` + the literal + * `null`, and the SDK's shared `fetch` wrapper throws on the non-2xx. So + * step 1 is TERMINAL for such a caller: this method rejects with that code + * and `httpStatus: 401`, and step 2 never reaches the wire (#17238). + * * ⚠️ 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 @@ -3859,9 +3866,13 @@ export class ObjectStackClient { * - 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`; + * - an anonymous caller was unchanged BY THIS MOVE: both routes sat + * behind the same better-auth session middleware, which answered + * `401 UNAUTHORIZED` either way. ⚠️ That row is RE-ANCHORED rather + * than restamped — it is no longer what such a caller reaches. Since + * #17881 the refusal arrives one request EARLIER, from `/get-session` + * as `401 UNAUTHENTICATED` (see above), so `list-members` is never + * asked and its `UNAUTHORIZED` is unreachable through this method; * - 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 @@ -3892,9 +3903,13 @@ export class ObjectStackClient { 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. + // An anonymous caller never reaches this line: since #17881 the request + // above answers `401 UNAUTHENTICATED` and the shared `fetch` wrapper + // throws on that non-2xx, so the refusal is delivered before any filter + // is built. The `| null` above and the `?? ''` here stay as the + // defensive branch they always were — a 2xx body this SDK cannot read a + // user out of yields an EMPTY filter rather than a fabricated one. 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/list-members` diff --git a/packages/client/src/organization-get-active-member-anonymous-statements.test.ts b/packages/client/src/organization-get-active-member-anonymous-statements.test.ts new file mode 100644 index 00000000000..6532517113b --- /dev/null +++ b/packages/client/src/organization-get-active-member-anonymous-statements.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18651] What `organizations.getActiveMember`'s OWN prose says an anonymous + * caller gets — pinned against the producer, not against a memory of it. + * + * ## The defect this exists to prevent + * + * #17881 (`374d9d3afa`) landed `plugin-auth`'s `refuseAnonymousSession`, which + * converts better-auth's `200` + the literal JSON `null` on + * `GET /api/v1/auth/get-session` into the declared ADR-0112 refusal envelope — + * HTTP `401`, `code: UNAUTHENTICATED` — before it leaves the process. Three + * present-tense statements in and around `getActiveMember` went on describing + * the retired shape, and they were wrong twice over: the CODE (`UNAUTHORIZED` + * vs `UNAUTHENTICATED`) and the REQUEST the refusal arrives on (the second, + * `list-members`, vs the first, `/get-session` itself). + * + * That prose SHIPS. `@objectstack/client`'s published `files[]` carries + * `dist`, and `getActiveMember` is a member of the exported + * `ObjectStackClient`, so its TSDoc is emitted into the shipped declarations. + * A reader coding against it writes a `null` branch that can never be taken + * and omits the `catch` that now fires — which is why a comment here is a + * published contract statement and gets a pin like any other. + * + * ## ⭐ The distinction this pin is built around: NAMING is not TEACHING + * + * The repaired prose still contains the words `200`, `null` and + * `UNAUTHORIZED` — it has to, because it names the retired convention as the + * thing that was CONVERTED and as the row that was superseded. A crude "does + * the region mention both 200 and null" filter therefore reads the repaired + * file as defective. So this pin does not count mentions. It matches the three + * retired SENTENCES, each of which asserts the retired behaviour in the + * present tense, and it proves it can see them by running the same matchers + * over {@link RETIRED_STATEMENTS} — the pre-#18651 text, verbatim. + * + * ## ⛔ What this pin does NOT buy + * + * It is a sentence-level matcher, so a FOURTH statement that teaches the same + * retired convention in different words would pass it. Section 3 narrows that + * gap on the axis the card was about rather than closing it: the region must + * state today's answer on all three of the axes the statements got wrong — + * the code, the status, and the request the refusal arrives on. A region that + * says nothing about the anonymous caller at all fails section 3. + * + * The region is located by SYMBOL (`getActiveMember:`), never by line number: + * the card that filed this defect carried line numbers that were already + * 15 lines stale by the time it was dispatched. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const SDK = fileURLToPath(new URL('./index.ts', import.meta.url)); + +/** + * The three statements as they stood BEFORE #18651, verbatim from the source — + * this file's positive control corpus. ⛔ Not documentation: nothing here + * describes a runtime that exists. It is here so every matcher below can be + * shown FIRING, because a matcher that has never fired cannot tell "absent" + * from "unmatchable" — and this family's own card was nearly buried by exactly + * that (a grep for `Anonymous -> null` missed the file's Unicode arrow). + */ +const RETIRED_STATEMENTS = [ + '`{ user, session }` envelope for a signed-in caller and the literal', + '`null` for an anonymous one (measured).', + 'an anonymous caller still gets `401 UNAUTHORIZED`, thrown from the', + '`list-members` request by the same session middleware that guarded', + '`get-active-member`;', + 'Anonymous → `null`, and the request below is then refused 401 by the', + 'session middleware before the filter is ever read.', +].join('\n'); + +/** + * The docblock + body of the `getActiveMember` DEFINITION, as one string. + * + * Anchored on the definition's own spelling (`getActiveMember: async (`) and + * walked BACKWARDS to the opening `/**` of the docblock attached to it, then + * forwards to the next sibling member. ⛔ Never anchored on a call site and + * ⛔ never on a line number. + */ +function getActiveMemberRegion(source: string): string { + const def = source.indexOf('getActiveMember: async ('); + if (def < 0) throw new Error('getActiveMember definition not found in the SDK source'); + const docStart = source.lastIndexOf('/**', def); + if (docStart < 0) throw new Error('no docblock precedes the getActiveMember definition'); + // The body ends at the member separator this file uses: a `},` at the + // definition's own indentation, which is the first one after the throw. + const end = source.indexOf('\n },\n', def); + if (end < 0) throw new Error('could not find the end of the getActiveMember member'); + return source.slice(docStart, end); +} + +/** Comment leaders stripped and whitespace collapsed, so a re-wrap cannot hide a sentence. */ +function prose(block: string): string { + return block + .split('\n') + .map((line) => line.replace(/^\s*(?:\/\*\*|\*\/|\*|\/\/)\s?/, '')) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * The three retired statements, as matchers over collapsed prose. Each one + * asserts the retired behaviour in the PRESENT TENSE — which is what makes it + * a defect rather than a history note. + */ +const RETIRED_MATCHERS: ReadonlyArray = [ + ['step 1 teaches the retired VALUE', /the literal `null` for an anonymous one/], + ['the delta list teaches the retired CODE', /an anonymous caller still gets `401 UNAUTHORIZED`/], + ['the inline comment teaches the retired VALUE and the wrong REQUEST', /Anonymous → `null`, and the request below is then refused 401/], +]; + +const SOURCE = readFileSync(SDK, 'utf8'); +const REGION = prose(getActiveMemberRegion(SOURCE)); +const CONTROL = prose(RETIRED_STATEMENTS); + +describe('[#18651] organizations.getActiveMember states the anonymous answer the runtime serves', () => { + describe('1 — the region really is the one under test', () => { + it('is located by symbol and carries the definition plus its docblock', () => { + expect(REGION).toContain("Look up the calling user's membership row"); + expect(REGION).toContain('getActiveMember: async (organizationId: string)'); + // The second request is still made — this method is two requests for a + // signed-in caller, and a region that lost it is not the region. + expect(REGION).toContain('/organization/list-members'); + }); + }); + + describe('2 — the retired statements are gone, and the matchers are shown firing', () => { + for (const [name, matcher] of RETIRED_MATCHERS) { + it(`${name}: fires on the pre-#18651 text and does NOT fire on the file`, () => { + // ⭐ The control FIRST. If this half ever goes quiet the matcher has + // stopped being able to see the defect, and the half below is then + // vacuously green — the exact failure this card's instrument note + // warned about. + expect(CONTROL, `control corpus no longer matches: ${matcher}`).toMatch(matcher); + expect(REGION, `retired statement still present: ${matcher}`).not.toMatch(matcher); + }); + } + }); + + describe('3 — the region states TODAY’s answer on all three axes the statements got wrong', () => { + it('names the CODE the producer derives', () => { + expect(CONTROL).not.toContain('UNAUTHENTICATED'); + expect(REGION).toContain('UNAUTHENTICATED'); + }); + + it('names the STATUS the producer answers', () => { + expect(REGION).toMatch(/`401`|httpStatus: 401/); + }); + + it('names the REQUEST the refusal arrives on, and says the second one is not reached', () => { + // The whole second half of the defect: the refusal is on request ONE. + expect(REGION).toMatch(/`\/get-session`[^.]*(?:401|ADR-0112|envelope)/); + expect(REGION).toMatch(/never reaches the wire|is TERMINAL|never asked|never reaches this line/); + }); + + it('anchors the claim to the producer rather than restamping a drive', () => { + // `refuseAnonymousSession` / #17881 is the thing that made it true; the + // 2026-09-09 drive predates it and was NOT re-run. + expect(REGION).toContain('#17881'); + expect(REGION).toContain('refuseAnonymousSession'); + }); + }); +});