Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .changeset/18651-getactivemember-anonymous-statements.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 23 additions & 8 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<the caller>&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
Expand All @@ -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
Expand Down Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
@@ -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<readonly [string, RegExp]> = [
['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');
});
});
});
Loading