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
58 changes: 58 additions & 0 deletions .changeset/18139-client-anonymous-get-session-statements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
'@objectstack/client': patch
---

`auth.me()` and the `/auth/*` wire table say what `/get-session` answers an anonymous caller TODAY: `401 UNAUTHENTICATED`, not `200 null`

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 exactly what
it sees. Three present-tense statements in the SDK still described the retired
shape, none of them carrying a rev or a date, so none of them read as history.

**FROM → TO for a caller.** An anonymous `auth.me()` no longer RESOLVES with
the literal `null`; it REJECTS. The SDK's shared `fetch` wrapper throws on the
non-2xx, so:

| you wrote | write instead |
|:--|:--|
| `const s = await client.auth.me(); if (s === null) …` | `try { await client.auth.me() } catch (e) { if (e.code === 'UNAUTHENTICATED') … }` |

That is the behaviour objectstack#17881 shipped; what moves here is only the
SDK's description of it. A reader coding against the old table wrote a `null`
branch that can never be taken and omitted the rejection branch that now fires.

**What changed**

- `normalizeSessionResponse`'s `/auth/*` transcript no longer lists the
anonymous `200 null` row among the bodies that helper is handed — it is not
handed that body at all, because the rejection happens one frame out. The
current answer is stated separately, anchored to the producer.
- The closing `!body`-guard paragraph no longer claims that guard carries the
anonymous answer, and no longer says closing the gap needs the published
return annotation to widen. objectstack#17238 ruled the opposite: the
producer moved and `SessionResponseSchema` is untouched.
- `auth.me()`'s docblock says the anonymous call rejects rather than resolving
outside its declared type.

⛔ No behaviour changes. `SessionResponseSchema`, every published return
annotation and the `!body` guard's own code are byte-identical; only what the
SDK says about them moves.

**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 `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, and
`getActiveMember` was carried as the lit control, found in the same four files.

Clause-②: no — no schema key moves, no accept set widens or narrows, no export
changes, and `ERROR_CODE_LEDGER` / `StandardErrorCode` are untouched
(`UNAUTHENTICATED` is an existing standard member that objectstack#17881
already derives via `standardErrorCodeForHttpStatus`). The direction is a
pull-back: the runtime already answers 401 and the SDK's self-description was
lagging.
38 changes: 29 additions & 9 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1471,11 +1471,23 @@ const SET_AUTH_TOKEN_HEADER = 'set-auth-token';
*
* ```
* GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}}
* GET /api/v1/auth/get-session (anonymous) -> 200 null
* POST /api/v1/auth/sign-up/email -> 200 {"token":"…","user":{…}}
* POST /api/v1/auth/sign-in/email -> 200 {"redirect":false,"token":"…","user":{…}}
* ```
*
* ⚠️ The ANONYMOUS `/get-session` answer is not a fourth body this helper is
* handed, and has not been since #17881 (`374d9d3afa`). `plugin-auth`'s
* `refuseAnonymousSession` converts better-auth's `200` + literal `null` into
* the declared ADR-0112 refusal on the way out (#17238):
*
* ```
* GET /api/v1/auth/get-session (anonymous) -> 401 {"success":false,"error":{"code":"UNAUTHENTICATED",…}}
* ```
*
* The SDK's shared `fetch` wrapper throws on any non-2xx, so that answer
* reaches a caller as a REJECTION carrying `code: 'UNAUTHENTICATED'` and
* `httpStatus: 401` and never arrives at this lift at all.
*
* The two families carry DISJOINT payload members — `/get-session` has the
* session and no top-level token, the two credential routes have the token and
* no session — so the lift copies the members a body actually has instead of
Expand Down Expand Up @@ -1518,11 +1530,14 @@ const SET_AUTH_TOKEN_HEADER = 'set-auth-token';
* body's own `token` already carried, not a second credential, and
* `data.token` is still never synthesized FROM a session.
*
* The `!body` guard is what carries the anonymous answer: `null` is falsy and
* is returned untouched rather than wrapped into a signed-in-looking envelope
* that no session backs. That answer stays outside `SessionResponse`, and
* closing it needs the published return annotation to widen, which is a
* different card.
* The `!body` guard no longer carries the anonymous answer — since #17881 that
* answer is a rejection and never reaches this lift. The guard stays as the
* defensive branch it always was: a 2xx body that is `null`, or not an object,
* is handed back untouched rather than wrapped into a signed-in-looking
* envelope that no session backs. The anonymous case is closed at the
* PRODUCER, which is what #17238 ruled — `SessionResponseSchema` and every
* published return annotation in this family are UNTOUCHED, rather than
* widened to grow an arm meaning "nobody is signed in".
*/
const normalizeSessionResponse = (raw: unknown): SessionResponse => {
const body = raw as
Expand Down Expand Up @@ -4437,9 +4452,14 @@ export class ObjectStackClient {
* `.user` / `.session` keys are kept alongside for callers written against
* the wire while the declared shape was unreachable.
*
* ⚠️ Anonymous is the one answer still outside the declared type: the route
* serves the literal `null` at 200 and it is returned as-is, because there
* is no `SessionResponse` value that means "nobody is signed in".
* ⚠️ Anonymous REJECTS — it does not resolve. There is no `SessionResponse`
* value that means "nobody is signed in", so since #17881 (`374d9d3afa`)
* the route answers an anonymous caller the declared ADR-0112 envelope at
* `401` instead of the literal `null` at 200, and the shared `fetch`
* wrapper turns that into a thrown error carrying `code: 'UNAUTHENTICATED'`
* and `httpStatus: 401`. Every value this method RESOLVES with is inside
* its declared type; a logged-out caller is a `catch`, not a `null` check
* (#17238).
*/
me: async (): Promise<SessionResponse> => {
const route = this.getRoute('auth');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,39 @@
* GET /organization/list-members?organizationId=<foreign>&filterField=userId&filterValue=<self>
* -> 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 /get-session (anonymous) -> 200 null <- SUPERSEDED, see below
* 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.
*
* ## ⚠️ The anonymous `/get-session` row is RE-ANCHORED, not restamped
*
* It is the one line of the drive above the product no longer produces. #17881
* (`374d9d3afa`) landed `plugin-auth`'s `refuseAnonymousSession`, which converts
* better-auth's `200` + literal `null` on this ONE route into the declared
* ADR-0112 refusal envelope before it leaves the process (#17238):
*
* ```
* GET /get-session (anonymous) -> 401 {"success":false,"error":{"code":"UNAUTHENTICATED","message":"Sign in first"}}
* ```
*
* The drive is NOT re-run here, so that row is anchored to the PRODUCER rather
* than restamped onto a measurement that did not take it:
* `anonymous-session-refusal.ts` derives the code from
* `standardErrorCodeForHttpStatus(401)` and takes the message from
* `PLATFORM_ADMIN_REFUSAL_MESSAGES[401]`. Every other row above is still the
* 2026-09-08 drive, untouched — including the `list-members` 401, which is
* better-auth's own session middleware and answers `UNAUTHORIZED`, a DIFFERENT
* code from the seam above. That difference is load-bearing in case ⑥.
*
* Case ⑥ moved with the row. Before #17881 its `signedIn: false` leg modelled an
* answer the runtime had stopped producing: the double served `200 null`, the
* SDK walked on to `list-members`, and the 401 the case asserted came from a
* SECOND request a real anonymous caller never reaches — so the case could not
* fail for the reason it existed. The refusal now arrives on request ONE.
*
* ## Why this file cannot pass for the wrong reason
*
* The double keeps the DEFECT alive on `get-active-member`: it answers the
Expand All @@ -54,6 +80,10 @@
* 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.
*
* The anonymous leg (⑥) carries the same property on its own axis: the two
* refusals in play answer DIFFERENT codes, so the case discriminates on a value
* and not only on how many requests were made.
*/

import { describe, it, expect } from 'vitest';
Expand Down Expand Up @@ -85,7 +115,11 @@ const ROWS: Record<string, OrganizationMemberWithUserWire[]> = {
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`. */
/**
* `false` models an anonymous caller: since #17881 `/get-session` answers the
* declared ADR-0112 refusal envelope at 401, which is where such a caller now
* stops — it is no longer a `200` the SDK reads a missing user out of.
*/
signedIn?: boolean;
}

Expand Down Expand Up @@ -116,9 +150,16 @@ function betterAuthDouble(options: DoubleOptions): Drive {
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);
// Measured: the BARE `{ user, session }` body for a signed-in caller.
// The anonymous arm is the platform's own refusal seam rather than
// better-auth's retired `200 null` — shaped exactly like
// `refuseAnonymousSession`'s output, code and message included.
if (!signedIn) {
return json(401, {
success: false,
error: { code: 'UNAUTHENTICATED', message: 'Sign in first' },
});
}
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 },
Expand All @@ -127,7 +168,11 @@ function betterAuthDouble(options: DoubleOptions): Drive {

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

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

it('⑥ an anonymous caller is still refused 401 by the server, not by an invented client-side error', async () => {
it('⑥ an anonymous caller is refused by the server on the FIRST request, 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');
// The SERVER's own ADR-0112 envelope, propagated verbatim. Asserted as code
// + status rather than as a bare `toThrow()`: a method that threw a plain
// `Error` for its own reasons would satisfy `toThrow` and say nothing about
// who refused, which is the whole question here.
expect(error?.code).toBe('UNAUTHENTICATED');
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');
// Step 1 is terminal for an anonymous caller since #17881: `/get-session`
// refuses, the SDK's shared `fetch` wrapper throws on the non-2xx, and
// `list-members` never reaches the wire. Asserted as the WHOLE list so a
// silent extra request cannot hide behind a length check.
expect(urls).toEqual([`${AUTH}/get-session`]);

// Guard the guard: the walk-on this case rules out is REAL in the fixture.
// Drive `list-members` anonymously through the same double and watch it
// answer better-auth's own `UNAUTHORIZED` — a DIFFERENT code from the one
// asserted above — so "the SDK swallowed the first refusal and walked on"
// fails on the VALUE, not merely on the request count.
const raw = await (client as unknown as {
fetchImpl: (input: string) => Promise<Response>;
}).fetchImpl(`${AUTH}/organization/list-members?organizationId=${ORG_B}`);

expect(raw.status).toBe(401);
expect(await raw.json()).toMatchObject({ code: 'UNAUTHORIZED' });
});

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