Skip to content

Commit d6137fd

Browse files
fix(client): the anonymous /get-session statements say 401 UNAUTHENTICATED, and the test double stops modelling 200 null (#18642)
Fixes #18139 `#17881` (`374d9d3afa`) moved the anonymous `GET /api/v1/auth/get-session` wire answer from `200` + the literal JSON `null` to the declared ADR-0112 refusal envelope — HTTP `401`, `code: UNAUTHENTICATED`. `@objectstack/client` reaches the server over the wire, so that is exactly what it sees. This PR pulls the SDK's own statements and one test double back onto it. No behaviour moves: `packages/client/src/index.ts` changes **comments only** (verified mechanically — every changed line in that file is inside a comment). ## Half 1 — the SDK's statements Three present-tense statements, none carrying a rev or a date, so none read as history: 1. **The `/auth/*` wire table** on `normalizeSessionResponse` (located by symbol, not by line). Its anonymous `-> 200 null` row is removed from the list of bodies the helper is handed — it is not handed that body at all now, because the shared `fetch` wrapper throws on the non-2xx one frame out. The current answer is stated separately, anchored to the producer. 2. **The closing `!body`-guard paragraph** in the same docblock, which claimed that guard "carries the anonymous answer" and that closing the gap "needs the published return annotation to widen". `#17238` ruled the opposite: the producer moved and `SessionResponseSchema` is untouched. The guard's own code is byte-identical; it is now described as the defensive branch it is. 3. **`auth.me()`'s docblock**, which said the route "serves the literal `null` at 200 and it is returned as-is". It now says the anonymous call REJECTS, with the code and status a caller catches. ## Half 2 — the phantom, which is the half that mattered Triage's grading is quoted verbatim and is what this half answers: > ⭐ 更值钱的是第二半:`organization-get-active-member-addressing.test.ts` 的 `signedIn: false` 腿建模了一个**运行时已不再产生**的答案 ⇒ 它**不可能因为它存在的那个理由而失败**(AGENTS.md 的 phantom-check 形状)。**⛔ 只改 docblock 不算修完这张卡。** **The disposition chosen: assert the refusal, not drop the leg — justified from the tree.** The file's own header states its charter: *"Only the socket is faked — every status, code and row shape below is a transcript line from the drive"*. Under that charter the anonymous row is not optional, it is stale, so it is re-anchored rather than deleted. Dropping the leg would delete the only coverage that `getActiveMember` does not invent a client-side diagnostic for an anonymous caller — a property that is still real and is now sharper, because the refusal arrives on request ONE instead of request TWO. What changed in the fixture: - The anonymous `/get-session` arm serves `401 {"success":false,"error":{"code":"UNAUTHENTICATED","message":"Sign in first"}}` — shaped from the producer (`refuseAnonymousSession` derives the code through `standardErrorCodeForHttpStatus(401)` and takes the message from `PLATFORM_ADMIN_REFUSAL_MESSAGES[401]`), not restamped onto a drive that was never re-run. The header says so in as many words; every other row is still the 2026-09-08 drive, untouched. - Case ⑥ now asserts `code: UNAUTHENTICATED` + `httpStatus: 401` and pins `urls` as the WHOLE list `[AUTH + '/get-session']`, so a silent extra request cannot hide behind a length check. - The organisation-route `401 UNAUTHORIZED` arm is KEPT although the SDK no longer reaches it, and that is load-bearing: it answers a **different code** from the `/get-session` seam, so a regression that swallowed the first refusal and walked on fails on the VALUE, not merely on a request count. Case ⑥ drives it directly as a guard-the-guard, in the file's existing ⑦/⑧ style. ### Ablation — the proof it can now fail for the reason it exists One-off, committed first, mutation proved on disk, restored and proved restored. - Mutation: the fixture's anonymous arm reverted to `return json(200, null);`. On-disk proof: refusal-arm grep count `1 -> 0`, retired-arm grep count `0 -> 1`, blob `48459424a` (HEAD) vs `19eb96dcb` (mutated) — not a no-op. - RED leg: `Tests 1 failed | 7 passed (8)` — `⑥ ... AssertionError: expected 'UNAUTHORIZED' to be 'UNAUTHENTICATED'`. It fails on the CODE, which is the discrimination this design is for. - Restore leg: `git checkout HEAD -- path` under an `EXIT INT TERM` trap; restored blob `48459424a` equals the HEAD blob, `git diff HEAD` empty, `git status --porcelain` empty. - GREEN leg: `Test Files 1 passed (1) · Tests 8 passed (8)`. ## The premise the dispatch asked me to falsify — FALSIFIED The card's dedupe was a recent-window search and said so. Swept repo-wide for `get-session`, `200 null`, "literal `null`" and "literal JSON `null`" with controls (a token known present returned 8 hits; a nonsense token returned 0 at exit 1). The `:1474` table was **not** the only present-tense statement left. Six more were found; three are fixed here (the two above plus the guard paragraph) and three are listed in Acceptance notes because they are out of this PR's face. ## The card's declared-unmeasured item — now MEASURED The card wrote: *"Whether `auth.me()`'s rejection path is covered anywhere today. I did not measure it."* Measured: **it IS covered, twice.** `packages/client/src/client.test.ts` carries *"me() rejects the anonymous refusal rather than resolving outside its type"* asserting `code: UNAUTHENTICATED` / `httpStatus: 401`, and `packages/client/src/auth-get-session-envelope.test.ts` block ③ (*"anonymous is REFUSED"*) carries the real-server twin. Both were reversed by `#17238` and say so in their own comments. Nothing in this PR depends on that coverage; the reading is reported because the card asked for it. ## Clause-② **Clause-②: no.** Three readings, from the measured diff: - **New exports:** none. `packages/client/src/index.ts` changes comments only; the test file and the changeset export nothing. The package's public surface is byte-identical. - **`ERROR_CODE_LEDGER` / `StandardErrorCode`:** untouched — the diff names neither, at zero occurrences. `UNAUTHENTICATED` is an existing STANDARD member that `#17881` already derives via `standardErrorCodeForHttpStatus`; nothing is minted here. - **Accept-set changes:** none. No `.zod.ts` in the diff (zero paths), no schema key moves, nothing previously admitted is refused and nothing new is admitted. The expected direction held: this is a **pull-back** — the runtime already answers 401 and the SDK's self-description was lagging. Nothing widens. ## Tests All readings taken in a dedicated worktree cut from `origin/main` at `681ebe424`; the union below was re-run at final HEAD `81a05648d`. - `pnpm --filter @objectstack/client typecheck` — exit 0. Note the two halves: the root `tsc --noEmit` program does NOT include the edited test file (`--listFiles` count 0, with `src/index.ts` at 1 as the lit control); the test layer is covered by the script's second half, `check:test-typecheck`, which reports *"@objectstack/client's test layer compiles under packages/client/tsconfig.test.json; 0 file(s) / 0 error(s)"*. - `pnpm --filter @objectstack/client test` — `Test Files 43 passed (43) · Tests 524 passed (524)`. - `pnpm --filter '@objectstack/client^...' build` — exit 0 (the dependency closure; the first test run was a false red from an unbuilt `@objectstack/spec`, not from this diff). - **Gate derivation, rerun in this worktree** — `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 59 families. Reconciled with `--ran`: **59 derived, 58 run, 1 NOT-MEASURED, 0 UNRUN.** - NOT MEASURED: `pnpm check:dual-build-cjs-loads` — exit 3, `PREREQUISITE NOT MET`, 34 packages have no `dist`; it needs a full `pnpm build`, which does not fit a foreground turn here. Its own text says *"This is NOT a pass: nothing was measured."* CI's `Build Core` covers it. - `pnpm --filter @objectstack/spec run check:skill-examples` first exited 1 on the same prerequisite shape (`packages/client-react` unbuilt). Built that package and re-ran: **exit 0**, 258 prose examples across 3 surfaces. - `pnpm check:cross-package-test-inputs` — exit 1, and it is **NOT from this diff**. It flags `packages/cli/test/init-created-files-summary.e2e.test.ts` descending from `packages/spec/dist/`. Control: that file and `scripts/check-cross-package-test-inputs.mjs` and `turbo.json` are all byte-identical to `origin/main` (blob-for-blob), while a file this PR did change differs — so the verdict is a property of `origin/main`, not of these two paths. The walk is a static source property (`join(SPEC_PKG, 'dist')` at that file's line 115). Filed below rather than fixed here. - `pnpm lint` repo-wide (`eslint . --no-inline-config`) — **exit 0**. Run whole, not narrowed, so no narrowing needs proving. - Ratchet and changeset families re-run at final HEAD `81a05648d`: `check-empty-changeset`, `check-changeset-no-major`, `check-adr-0087-registration`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`, `check:type-check-coverage`, `check:type-check-debt`, `check:published-files`, `check:tier-file-adoption`, `check:nul-bytes`, `check:test-source-alias` — all exit 0. ### Changeset, measured rather than assumed `skip-changeset` does **not** apply and was not requested. `@objectstack/client`'s published `files[]` is `["dist","README.md","CHANGELOG.md"]`, and `auth.me()` is a member of the exported `ObjectStackClient`, so its TSDoc is emitted into the shipped declarations. Measured on the built artifact: the corrected sentence is present in `dist/index.d.ts`, `dist/index.d.mts`, `dist/index.js` and `dist/index.mjs`; the retired sentence is absent from `dist` afterwards; `getActiveMember` carried as the lit control, found in the same four files. Hence a `patch` changeset. ## Acceptance notes **Neighbour PR.** `#18429` (card `#17274`) also edits `packages/client/src/index.ts` and was open and unmerged when this branch was cut. Its measured hunks are at 3671-3689, 3962-3967, 3976-3983 (`organizations.invitations.resend`); this PR's edits are the `/auth/*` docblock near 1474 and the `auth.me()` docblock near 4437. The regions do not intersect — which guarantees textual mergeability and nothing more. **Three same-defect statements deliberately NOT fixed here, because they sit in the `organizations.*` region this PR is fenced out of.** All three are in `getActiveMember`'s docblock and body in `packages/client/src/index.ts`, and all three now misdescribe the runtime: - *"The body is the bare `{ user, session }` envelope for a signed-in caller and the literal `null` for an anonymous one (measured)"* — step 1 of the docblock's two-request explanation. - *"an anonymous caller still gets `401 UNAUTHORIZED`, thrown from the `list-members` request by the same session middleware"* — the refusal now comes from the `/get-session` request with `UNAUTHENTICATED`, one request earlier. - The inline comment *"Anonymous → `null`, and the request below is then refused 401 by the session middleware before the filter is ever read."* They are the same defect class as this card and the fix is mechanical, but the in-place-fix criteria do not all hold: `packages/client/src/index.ts` is held by another live claim (`#18429`), and the dispatch fences this PR out of that region by name. Handed back for filing with dedupe words `getActiveMember`, `get-session`, `anonymous`, `UNAUTHENTICATED`, `#17881`. **Reviewer, read this PR's case ⑥ together with that docblock: they now disagree, and the docblock is the one that is wrong.** **Two more, outside `packages/client`, handed back for filing:** - `docs/qa/platform-checklist/areas/identity-auth.json` (two clauses) instructs a QA runner that *"better-auth's no-session convention is HTTP 200 with a JSON null body, so a 401 expectation misdescribes a correct implementation"* and that `get-session` is not a valid oracle. Since `#17881` a 401 is exactly what a correct implementation answers, so a runner following this verbatim scores a correct platform as defective. It even cites `session-of-record.test.ts` as its authority — and that file was updated by `#17238` and now says the opposite in its own helper docblock. This is the highest-value item in the sweep. Dedupe words: `identity-auth.json`, `get-session`, `oracle`, `JSON null body`, `revoke-user-sessions`. - `.changeset/client-get-session-envelope-and-refresh-read.md` (pending, unreleased) closes with *"Two answers stay outside the declared type and are not addressed here: the anonymous `null`, which would need the published return annotation to widen"* — present-tense, and it ships verbatim into `@objectstack/client`'s `CHANGELOG.md` at the next release. Its transcript block above it is anchored to a dated better-auth 1.7.2 drive and reads as history; only the closing paragraph is the problem. Not touched here because it is another PR's release input. Dedupe words: `changeset`, `get-session`, `anonymous null`, `return annotation`, `#16760`. **Noted, not filed** — `packages/adapters/hono/src/hono-auth-mount-basepath.test.ts` carries a `GET /api/v1/auth/get-session -> 200 null` row in an AFTER transcript. It is anchored to a dated maintainer ruling and a named drive, so it reads as a measurement record rather than a present-tense claim, and no assertion in that file depends on it. Who would carry it: that adapter's next `#16025`-area edit. If nobody touches that file, nobody is misled by it, because the file's own cases assert a relation and never that body. **Cleanup:** the worktree used for this work is removed; no dev server was started and no temporary artifact is left in the tree. --- _Generated by [Claude Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e1f4f06 commit d6137fd

3 files changed

Lines changed: 161 additions & 21 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
'@objectstack/client': patch
3+
---
4+
5+
`auth.me()` and the `/auth/*` wire table say what `/get-session` answers an anonymous caller TODAY: `401 UNAUTHENTICATED`, not `200 null`
6+
7+
objectstack#17881 (`374d9d3afa`) landed `plugin-auth`'s
8+
`refuseAnonymousSession`, which converts better-auth's `200` + the literal JSON
9+
`null` on `GET /api/v1/auth/get-session` into the declared ADR-0112 refusal
10+
envelope — HTTP `401`, `code: UNAUTHENTICATED` — before it leaves the process.
11+
`@objectstack/client` reaches the server over the wire, so that is exactly what
12+
it sees. Three present-tense statements in the SDK still described the retired
13+
shape, none of them carrying a rev or a date, so none of them read as history.
14+
15+
**FROM → TO for a caller.** An anonymous `auth.me()` no longer RESOLVES with
16+
the literal `null`; it REJECTS. The SDK's shared `fetch` wrapper throws on the
17+
non-2xx, so:
18+
19+
| you wrote | write instead |
20+
|:--|:--|
21+
| `const s = await client.auth.me(); if (s === null) …` | `try { await client.auth.me() } catch (e) { if (e.code === 'UNAUTHENTICATED') … }` |
22+
23+
That is the behaviour objectstack#17881 shipped; what moves here is only the
24+
SDK's description of it. A reader coding against the old table wrote a `null`
25+
branch that can never be taken and omitted the rejection branch that now fires.
26+
27+
**What changed**
28+
29+
- `normalizeSessionResponse`'s `/auth/*` transcript no longer lists the
30+
anonymous `200 null` row among the bodies that helper is handed — it is not
31+
handed that body at all, because the rejection happens one frame out. The
32+
current answer is stated separately, anchored to the producer.
33+
- The closing `!body`-guard paragraph no longer claims that guard carries the
34+
anonymous answer, and no longer says closing the gap needs the published
35+
return annotation to widen. objectstack#17238 ruled the opposite: the
36+
producer moved and `SessionResponseSchema` is untouched.
37+
- `auth.me()`'s docblock says the anonymous call rejects rather than resolving
38+
outside its declared type.
39+
40+
⛔ No behaviour changes. `SessionResponseSchema`, every published return
41+
annotation and the `!body` guard's own code are byte-identical; only what the
42+
SDK says about them moves.
43+
44+
**This is shipped, which is why it carries a changeset rather than
45+
`skip-changeset`.** `@objectstack/client`'s published `files[]` is
46+
`["dist","README.md","CHANGELOG.md"]`, and `auth.me()` is a member of the
47+
exported `ObjectStackClient`, so its TSDoc is emitted into the shipped
48+
declarations — measured on the built artifact: the corrected sentence is
49+
present in `dist/index.d.ts`, `dist/index.d.mts`, `dist/index.js` and
50+
`dist/index.mjs`, the retired sentence is absent from `dist` afterwards, and
51+
`getActiveMember` was carried as the lit control, found in the same four files.
52+
53+
Clause-②: no — no schema key moves, no accept set widens or narrows, no export
54+
changes, and `ERROR_CODE_LEDGER` / `StandardErrorCode` are untouched
55+
(`UNAUTHENTICATED` is an existing standard member that objectstack#17881
56+
already derives via `standardErrorCodeForHttpStatus`). The direction is a
57+
pull-back: the runtime already answers 401 and the SDK's self-description was
58+
lagging.

‎packages/client/src/index.ts‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1471,11 +1471,23 @@ const SET_AUTH_TOKEN_HEADER = 'set-auth-token';
14711471
*
14721472
* ```
14731473
* GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}}
1474-
* GET /api/v1/auth/get-session (anonymous) -> 200 null
14751474
* POST /api/v1/auth/sign-up/email -> 200 {"token":"…","user":{…}}
14761475
* POST /api/v1/auth/sign-in/email -> 200 {"redirect":false,"token":"…","user":{…}}
14771476
* ```
14781477
*
1478+
* ⚠️ The ANONYMOUS `/get-session` answer is not a fourth body this helper is
1479+
* handed, and has not been since #17881 (`374d9d3afa`). `plugin-auth`'s
1480+
* `refuseAnonymousSession` converts better-auth's `200` + literal `null` into
1481+
* the declared ADR-0112 refusal on the way out (#17238):
1482+
*
1483+
* ```
1484+
* GET /api/v1/auth/get-session (anonymous) -> 401 {"success":false,"error":{"code":"UNAUTHENTICATED",…}}
1485+
* ```
1486+
*
1487+
* The SDK's shared `fetch` wrapper throws on any non-2xx, so that answer
1488+
* reaches a caller as a REJECTION carrying `code: 'UNAUTHENTICATED'` and
1489+
* `httpStatus: 401` and never arrives at this lift at all.
1490+
*
14791491
* The two families carry DISJOINT payload members — `/get-session` has the
14801492
* session and no top-level token, the two credential routes have the token and
14811493
* no session — so the lift copies the members a body actually has instead of
@@ -1518,11 +1530,14 @@ const SET_AUTH_TOKEN_HEADER = 'set-auth-token';
15181530
* body's own `token` already carried, not a second credential, and
15191531
* `data.token` is still never synthesized FROM a session.
15201532
*
1521-
* The `!body` guard is what carries the anonymous answer: `null` is falsy and
1522-
* is returned untouched rather than wrapped into a signed-in-looking envelope
1523-
* that no session backs. That answer stays outside `SessionResponse`, and
1524-
* closing it needs the published return annotation to widen, which is a
1525-
* different card.
1533+
* The `!body` guard no longer carries the anonymous answer — since #17881 that
1534+
* answer is a rejection and never reaches this lift. The guard stays as the
1535+
* defensive branch it always was: a 2xx body that is `null`, or not an object,
1536+
* is handed back untouched rather than wrapped into a signed-in-looking
1537+
* envelope that no session backs. The anonymous case is closed at the
1538+
* PRODUCER, which is what #17238 ruled — `SessionResponseSchema` and every
1539+
* published return annotation in this family are UNTOUCHED, rather than
1540+
* widened to grow an arm meaning "nobody is signed in".
15261541
*/
15271542
const normalizeSessionResponse = (raw: unknown): SessionResponse => {
15281543
const body = raw as
@@ -4491,9 +4506,14 @@ export class ObjectStackClient {
44914506
* `.user` / `.session` keys are kept alongside for callers written against
44924507
* the wire while the declared shape was unreachable.
44934508
*
4494-
* ⚠️ Anonymous is the one answer still outside the declared type: the route
4495-
* serves the literal `null` at 200 and it is returned as-is, because there
4496-
* is no `SessionResponse` value that means "nobody is signed in".
4509+
* ⚠️ Anonymous REJECTS — it does not resolve. There is no `SessionResponse`
4510+
* value that means "nobody is signed in", so since #17881 (`374d9d3afa`)
4511+
* the route answers an anonymous caller the declared ADR-0112 envelope at
4512+
* `401` instead of the literal `null` at 200, and the shared `fetch`
4513+
* wrapper turns that into a thrown error carrying `code: 'UNAUTHENTICATED'`
4514+
* and `httpStatus: 401`. Every value this method RESOLVES with is inside
4515+
* its declared type; a logged-out caller is a `catch`, not a `null` check
4516+
* (#17238).
44974517
*/
44984518
me: async (): Promise<SessionResponse> => {
44994519
const route = this.getRoute('auth');

‎packages/client/src/organization-get-active-member-addressing.test.ts‎

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,39 @@
3737
* GET /organization/list-members?organizationId=<foreign>&filterField=userId&filterValue=<self>
3838
* -> 403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION
3939
* GET /get-session (signed in) -> 200 {user:{id,…}, session:{…}} (BARE, no envelope)
40-
* GET /get-session (anonymous) -> 200 null
40+
* GET /get-session (anonymous) -> 200 null <- SUPERSEDED, see below
4141
* GET /organization/list-members (anonymous) -> 401 UNAUTHORIZED
4242
* ```
4343
*
4444
* The two rows the fixture serves differ in `id` **and** `organizationId`, so
4545
* "answered the wrong organisation" is a value difference an assertion can see.
4646
*
47+
* ## ⚠️ The anonymous `/get-session` row is RE-ANCHORED, not restamped
48+
*
49+
* It is the one line of the drive above the product no longer produces. #17881
50+
* (`374d9d3afa`) landed `plugin-auth`'s `refuseAnonymousSession`, which converts
51+
* better-auth's `200` + literal `null` on this ONE route into the declared
52+
* ADR-0112 refusal envelope before it leaves the process (#17238):
53+
*
54+
* ```
55+
* GET /get-session (anonymous) -> 401 {"success":false,"error":{"code":"UNAUTHENTICATED","message":"Sign in first"}}
56+
* ```
57+
*
58+
* The drive is NOT re-run here, so that row is anchored to the PRODUCER rather
59+
* than restamped onto a measurement that did not take it:
60+
* `anonymous-session-refusal.ts` derives the code from
61+
* `standardErrorCodeForHttpStatus(401)` and takes the message from
62+
* `PLATFORM_ADMIN_REFUSAL_MESSAGES[401]`. Every other row above is still the
63+
* 2026-09-08 drive, untouched — including the `list-members` 401, which is
64+
* better-auth's own session middleware and answers `UNAUTHORIZED`, a DIFFERENT
65+
* code from the seam above. That difference is load-bearing in case ⑥.
66+
*
67+
* Case ⑥ moved with the row. Before #17881 its `signedIn: false` leg modelled an
68+
* answer the runtime had stopped producing: the double served `200 null`, the
69+
* SDK walked on to `list-members`, and the 401 the case asserted came from a
70+
* SECOND request a real anonymous caller never reaches — so the case could not
71+
* fail for the reason it existed. The refusal now arrives on request ONE.
72+
*
4773
* ## Why this file cannot pass for the wrong reason
4874
*
4975
* The double keeps the DEFECT alive on `get-active-member`: it answers the
@@ -54,6 +80,10 @@
5480
* they pin the request BYTES, which is what the card's finding was ultimately
5581
* about, and they fail on a filter that is dropped or misspelled even if some
5682
* future double got lucky on the row.
83+
*
84+
* The anonymous leg (⑥) carries the same property on its own axis: the two
85+
* refusals in play answer DIFFERENT codes, so the case discriminates on a value
86+
* and not only on how many requests were made.
5787
*/
5888

5989
import { describe, it, expect } from 'vitest';
@@ -85,7 +115,11 @@ const ROWS: Record<string, OrganizationMemberWithUserWire[]> = {
85115
interface DoubleOptions {
86116
/** `null` models a signed-in session with no active organisation. */
87117
activeOrganizationId: string | null;
88-
/** `false` models an anonymous caller: `/get-session` answers the literal `null`. */
118+
/**
119+
* `false` models an anonymous caller: since #17881 `/get-session` answers the
120+
* declared ADR-0112 refusal envelope at 401, which is where such a caller now
121+
* stops — it is no longer a `200` the SDK reads a missing user out of.
122+
*/
89123
signedIn?: boolean;
90124
}
91125

@@ -116,9 +150,16 @@ function betterAuthDouble(options: DoubleOptions): Drive {
116150
const q = parsed.searchParams;
117151

118152
if (parsed.pathname === '/api/v1/auth/get-session') {
119-
// Measured: the BARE `{ user, session }` body for a signed-in caller,
120-
// and the literal `null` — at 200, not 401 — for an anonymous one.
121-
if (!signedIn) return json(200, null);
153+
// Measured: the BARE `{ user, session }` body for a signed-in caller.
154+
// The anonymous arm is the platform's own refusal seam rather than
155+
// better-auth's retired `200 null` — shaped exactly like
156+
// `refuseAnonymousSession`'s output, code and message included.
157+
if (!signedIn) {
158+
return json(401, {
159+
success: false,
160+
error: { code: 'UNAUTHENTICATED', message: 'Sign in first' },
161+
});
162+
}
122163
return json(200, {
123164
user: { ...USER, emailVerified: false, createdAt: '2026-09-08T02:34:14.6Z', updatedAt: '2026-09-08T02:34:14.6Z' },
124165
session: { id: 'ses_1', userId: USER.id, token: 'tok', activeOrganizationId: options.activeOrganizationId, activeTeamId: null },
@@ -127,7 +168,11 @@ function betterAuthDouble(options: DoubleOptions): Drive {
127168

128169
if (!signedIn) {
129170
// Every organisation route sits behind better-auth's session
130-
// middleware, which refuses before any handler reads the query.
171+
// middleware, which refuses before any handler reads the query. Kept
172+
// although an anonymous caller no longer gets this far through the SDK:
173+
// it answers `UNAUTHORIZED`, not the `/get-session` seam's
174+
// `UNAUTHENTICATED`, so a regression that swallowed the first refusal
175+
// and walked on is caught on the CODE in case ⑥, not only on a URL count.
131176
return json(401, { message: 'Unauthorized', code: 'UNAUTHORIZED' });
132177
}
133178

@@ -243,19 +288,36 @@ describe('[#16568] organizations.getActiveMember addresses the organisation the
243288
expect(error?.httpStatus).toBe(403);
244289
});
245290

246-
it('⑥ an anonymous caller is still refused 401 by the server, not by an invented client-side error', async () => {
291+
it('⑥ an anonymous caller is refused by the server on the FIRST request, not by an invented client-side error', async () => {
247292
const { client, urls } = betterAuthDouble({ activeOrganizationId: ORG_A, signedIn: false });
248293

249294
const error = await client.organizations
250295
.getActiveMember(ORG_B)
251296
.then(() => null, (e: unknown) => e as { code?: string; httpStatus?: number });
252297

253-
expect(error?.code).toBe('UNAUTHORIZED');
298+
// The SERVER's own ADR-0112 envelope, propagated verbatim. Asserted as code
299+
// + status rather than as a bare `toThrow()`: a method that threw a plain
300+
// `Error` for its own reasons would satisfy `toThrow` and say nothing about
301+
// who refused, which is the whole question here.
302+
expect(error?.code).toBe('UNAUTHENTICATED');
254303
expect(error?.httpStatus).toBe(401);
255-
// The refusal comes from the second request — the SDK does not short-circuit
256-
// on the `null` session and substitute a diagnostic of its own.
257-
expect(urls).toHaveLength(2);
258-
expect(urls[1]).toContain('/organization/list-members');
304+
// Step 1 is terminal for an anonymous caller since #17881: `/get-session`
305+
// refuses, the SDK's shared `fetch` wrapper throws on the non-2xx, and
306+
// `list-members` never reaches the wire. Asserted as the WHOLE list so a
307+
// silent extra request cannot hide behind a length check.
308+
expect(urls).toEqual([`${AUTH}/get-session`]);
309+
310+
// Guard the guard: the walk-on this case rules out is REAL in the fixture.
311+
// Drive `list-members` anonymously through the same double and watch it
312+
// answer better-auth's own `UNAUTHORIZED` — a DIFFERENT code from the one
313+
// asserted above — so "the SDK swallowed the first refusal and walked on"
314+
// fails on the VALUE, not merely on the request count.
315+
const raw = await (client as unknown as {
316+
fetchImpl: (input: string) => Promise<Response>;
317+
}).fetchImpl(`${AUTH}/organization/list-members?organizationId=${ORG_B}`);
318+
319+
expect(raw.status).toBe(401);
320+
expect(await raw.json()).toMatchObject({ code: 'UNAUTHORIZED' });
259321
});
260322

261323
it('⑦ the double really can serve the wrong row — guard the guard', async () => {

0 commit comments

Comments
 (0)