From 13e09a5e3c727a81269fbf9af4a1659c13c98f39 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:37:25 +0000 Subject: [PATCH 1/2] fix(client): getActiveMember's three anonymous statements say 401 UNAUTHENTICATED on request one, and a pin holds them there 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. Three present-tense statements in and around organizations.getActiveMember went on describing the retired shape, 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). packages/client/src/index.ts changes COMMENTS ONLY -- verified mechanically: every changed line in that file, comment leader stripped, is a comment line. The three, located by SYMBOL and not by line number (the filing card's line numbers were already 15 lines stale by dispatch): 1. Step 1 of the two-request list, which taught the retired VALUE ("the literal null for an anonymous one"). The signed-in arm keeps its (measured) tag; the anonymous arm is now stated separately and anchored to the producer, including that step 2 never reaches the wire. 2. The anonymous bullet of the 2026-09-09 drive's delta list, which taught the retired CODE ("still gets 401 UNAUTHORIZED, thrown from the list-members request"). RE-ANCHORED rather than restamped, the disposition the sibling delivery used on the same family: the drive's own row is kept in the past tense, and today's answer is stated from the producer. 3. The inline comment on the userId read, which taught the retired VALUE and the wrong request. It now says an anonymous caller never reaches that line, and says why the "| null" annotation and the "?? ''" fallback stay. The pin is a source-text assertion over the getActiveMember region, located by symbol, with its matchers shown FIRING against a verbatim pre-repair control corpus -- a matcher that has never fired cannot tell "absent" from "unmatchable", which is how this family's own defect was nearly written off. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 --- packages/client/src/index.ts | 31 +++- ...active-member-anonymous-statements.test.ts | 166 ++++++++++++++++++ 2 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 packages/client/src/organization-get-active-member-anonymous-statements.test.ts 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'); + }); + }); +}); From fdca8ef2be7f18e1386649f9fd7f3919154d3f19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:48:04 +0000 Subject: [PATCH 2/2] =?UTF-8?q?changeset(client):=20patch=20=E2=80=94=20th?= =?UTF-8?q?e=20anonymous=20getActiveMember=20statements,=20measured=20agai?= =?UTF-8?q?nst=20the=20published=20files[]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skip-changeset is refused here by measurement, not by instinct. Built dist at 13e09a5e3c: the corrected sentence is present 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; getActiveMember was carried as the lit control and found in every one of them. The package's files[] is ["dist","README.md", "CHANGELOG.md"], so that prose ships. Recorded because the dispatch's fence named the wrong second artifact: 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 probe here would have measured an absent file and reported a zero about nothing. Clause-② reads `no` through readClause2Line (kind=declared, value=no, arm=null). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 --- ...51-getactivemember-anonymous-statements.md | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .changeset/18651-getactivemember-anonymous-statements.md 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.