diff --git a/.changeset/18139-client-anonymous-get-session-statements.md b/.changeset/18139-client-anonymous-get-session-statements.md new file mode 100644 index 00000000000..5e53c63e64c --- /dev/null +++ b/.changeset/18139-client-anonymous-get-session-statements.md @@ -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. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index aef584f524c..5df659acd6b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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 @@ -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 @@ -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 => { const route = this.getRoute('auth'); diff --git a/packages/client/src/organization-get-active-member-addressing.test.ts b/packages/client/src/organization-get-active-member-addressing.test.ts index 5e372337e79..48459424a34 100644 --- a/packages/client/src/organization-get-active-member-addressing.test.ts +++ b/packages/client/src/organization-get-active-member-addressing.test.ts @@ -37,13 +37,39 @@ * GET /organization/list-members?organizationId=&filterField=userId&filterValue= * -> 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 @@ -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'; @@ -85,7 +115,11 @@ const ROWS: Record = { 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; } @@ -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 }, @@ -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' }); } @@ -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; + }).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 () => {