From 33888b39f1f3ece07a89ff5951ccc57bc5a07c8d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 19:38:10 +0000 Subject: [PATCH 1/4] wip(client): normalize /get-session into the declared SessionResponse envelope Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- packages/client/src/index.ts | 103 +++++++++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 4 deletions(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 06216277bf..ab4114f86c 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1459,6 +1459,65 @@ const DEFAULT_META_PREFIX = '/meta'; */ const SET_AUTH_TOKEN_HEADER = 'set-auth-token'; +/** + * Lift better-auth's bare `/get-session` answer into the `SessionResponse` + * envelope the two methods that call that route declare (#16760). + * + * `/api/v1/auth/*` is better-auth's own byte stream — plugin-auth mounts one + * catch-all straight onto its handler — and better-auth does not use + * ObjectStack's REST envelope. Measured against a real `AuthManager` + * (better-auth 1.7.2, organization plugin) over a real driver: + * + * ``` + * GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}} + * GET /api/v1/auth/get-session (anonymous) -> 200 null + * ``` + * + * `auth.login` has carried the same lift for `/sign-in/email`'s own bare + * `{ token, user }` since long before this card; `auth.me` and + * `auth.refreshToken` never got it, so every caller writing to the declared + * `data.user` read `undefined` while the real payload sat on `.user` — which + * did not type-check. + * + * Three properties this deliberately has: + * + * - **`success` is filled, not only `data`.** `SessionResponseSchema` is + * `BaseResponseSchema.extend(…)` and that base declares `success` as a + * REQUIRED boolean, so a body carrying `data` alone still does not parse as + * the type the method advertises. A producer that sent its own `success` + * keeps it — the spread below runs after the default. + * - **The raw keys are kept, not replaced.** `{ …body, data }`, exactly as + * `login` does. `.user` is the read the field has been using all along while + * the declared `.data.user` was `undefined`, and dropping it would break + * those callers in order to fix a type they were already working around. + * - **`data.token` is NOT synthesized from `session.token`.** The declared key + * is optional, and the two spellings are not one string: `session.token` is + * the UNSIGNED session token, while the `token` `login` puts there is the + * SIGNED `token.signature` form `bearer()` hands out. Both authenticate, so + * populating it would file two different credentials under one key depending + * on which method produced the body. + * + * 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`; closing + * it needs the published return annotation to widen, which is a different card. + */ +const normalizeSessionResponse = (raw: unknown): SessionResponse => { + const body = raw as { user?: unknown; session?: unknown; data?: unknown } | null; + // Already enveloped, or nothing recognisable to lift: hand it back untouched + // rather than inventing a `data` this response never carried. + if (!body || typeof body !== 'object') return body as unknown as SessionResponse; + if (body.data !== undefined) return body as unknown as SessionResponse; + if (body.user === undefined && body.session === undefined) { + return body as unknown as SessionResponse; + } + return { + success: true, + ...body, + data: { user: body.user, session: body.session }, + } as unknown as SessionResponse; +}; + export class ObjectStackClient { private baseUrl: string; private token?: string; @@ -4130,13 +4189,24 @@ export class ObjectStackClient { /** * Get current user session * Uses better-auth endpoint: GET /get-session + * + * The route answers bare (`{ user, session }`), so the answer is lifted + * into the declared `SessionResponse` envelope by + * {@link normalizeSessionResponse} — the same lift `login` has always + * carried. Read the payload off `data.user` / `data.session`; the raw + * `.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". */ me: async (): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/get-session`, { headers: { Origin: this.baseUrl }, }); - return res.json(); + return normalizeSessionResponse(await res.json()); }, /** @@ -4205,6 +4275,30 @@ export class ObjectStackClient { * Refresh an authentication token * Note: better-auth handles token refresh automatically via /get-session * @param _refreshToken - Not used (better-auth handles refresh automatically) + * + * ## Where the credential really is (#16760) + * + * This used to assign from `data.data?.token` — a read that could never + * resolve, on a route that has no top-level `token` at all. Measured + * signed-in against a real `AuthManager` (better-auth 1.7.2) over a real + * driver, the body's top level is exactly `user` and `session`, and the + * only credential in it is `session.token`: + * + * ``` + * -> 200 {"user":{…},"session":{…,"token":"","expiresAt":"…"}} + * ``` + * + * So the old read was not a consequence of the envelope being misdeclared + * — enveloping the body does not put a token at `data.token` either. It + * named a field this route does not produce, and the method returned + * successfully having captured nothing, which is the worst way for a + * credential call to fail. + * + * ⚠️ `session.token` is the UNSIGNED spelling, while `bearer()` hands + * clients the signed `token.signature` form. Both authenticate — the + * server strips the signature on the bearer branch before it looks the + * session up (`resolveActor`) — so storing this one keeps the caller + * signed in. */ refreshToken: async (_refreshToken: string): Promise => { const route = this.getRoute('auth'); @@ -4213,9 +4307,10 @@ export class ObjectStackClient { const res = await this.fetch(`${this.baseUrl}${route}/get-session`, { method: 'GET' }); - const data = await res.json(); - if (data.data?.token) { - this.token = data.data.token; + const data = normalizeSessionResponse(await res.json()); + const token = data?.data?.session?.token; + if (token) { + this.token = token; } return data; }, From dc00e02553521d40e09f4bf9fb439b70af56df2d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 19:46:56 +0000 Subject: [PATCH 2/4] fix(client): lift the bare /get-session answer into the SessionResponse envelope both methods declare Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- .../src/auth-get-session-envelope.test.ts | 316 ++++++++++++++++++ packages/client/src/client.test.ts | 45 ++- 2 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 packages/client/src/auth-get-session-envelope.test.ts diff --git a/packages/client/src/auth-get-session-envelope.test.ts b/packages/client/src/auth-get-session-envelope.test.ts new file mode 100644 index 0000000000..275116fc5c --- /dev/null +++ b/packages/client/src/auth-get-session-envelope.test.ts @@ -0,0 +1,316 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #16760 — `auth.me` and `auth.refreshToken` declare `SessionResponse` (the +// REST `{ success, data }` envelope) for `GET /api/v1/auth/get-session`, a +// route that answers bare; and `refreshToken` captured no credential at all. +// +// ## Why the server here is the real one +// +// Every claim in this file is a claim about BYTES BETTER-AUTH WRITES — which +// keys `/get-session` puts at the top level, where in them the session +// credential sits, and what the anonymous answer is. A hand-written double +// would let this suite certify the SDK against a body this file invented, and +// the card it closes exists precisely because the declared shape and the real +// one had drifted apart with nobody measuring. So the arrangement is a real +// `AuthManager` (better-auth 1.7.2, organization plugin on by its own default) +// over a real `ObjectQL` on a real `SqliteWasmDriver`, with an +// `ObjectStackClient` whose `fetch` hands the `Request` straight to +// `AuthManager.handleRequest`: everything above that call is the SDK's real +// request path, everything below it is better-auth's real pipeline. +// +// ## What each block is for +// +// - `① me() delivers the envelope it declares` — the card's first consequence. +// The decisive assertion is a PARSE against the declared schema, not a key +// spot-check: the defect is "the declared type is not delivered", so the +// declaration itself has to be the judge. Its second case pins the ONE gap +// the lift cannot close (`user.image`, declared string-or-absent, served +// `null`) as an exhaustive issue list, so the residue cannot quietly grow. +// - `② the raw keys survive` — `.user` is what the field reads today, while +// the declared `.data.user` was `undefined`. The fix must not buy the +// declared shape by breaking the workaround callers were pushed onto. +// - `③ anonymous stays anonymous` — the route serves the literal `null` at +// 200. Pinned as the KNOWN residue: it is still outside `SessionResponse`, +// and this case exists so that stays a measured fact rather than a surprise. +// - `④ refreshToken captures a credential that actually works` — the card's +// second consequence, and the one that was NOT a consequence of the envelope +// at all. The firing control is the credential's SPELLING: the client starts +// on the signed `token.signature` form `bearer()` hands out, and a working +// capture moves it to the unsigned one the session body carries. +// - `⑤ the field the old read named does not exist` — the negative control. +// `data.token` is absent from the normalized body too, so a regression back +// to `data.data?.token` cannot pass by accident, and the "enveloping it +// would have fixed refreshToken" reading stays refuted in code. + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { AuthManager } from '@objectstack/plugin-auth'; +import * as identityObjects from '@objectstack/platform-objects/identity'; +import { BaseResponseSchema, SessionResponseSchema, SessionSchema } from '@objectstack/spec/api'; +import { ObjectStackClient } from './index'; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const ORIGIN = 'http://localhost:3000'; +const PASSWORD = 'S3cure!Passw0rd-16760'; + +/** + * The identity objects better-auth's ObjectQL adapter reads and writes on the + * routes under test, plus every sibling the boot path touches. Read out of + * `@objectstack/platform-objects/identity` BY SHAPE rather than transcribed: + * plugin-auth's own `authIdentityObjects` is package-private, and a hand-copied + * list would be a second declaration of the same set, drifting silently the day + * the plugin registers one more. + */ +const IDENTITY_OBJECTS = Object.values( + identityObjects as unknown as Record, +).filter( + (o): o is Record => + !!o && + typeof o === 'object' && + typeof (o as { name?: unknown }).name === 'string' && + typeof (o as { fields?: unknown }).fields === 'object', +); + +const engines: ObjectQL[] = []; + +const makeEngine = async (): Promise => { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as never, true); + await engine.init(); + for (const object of IDENTITY_OBJECTS) { + engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + } + await engine.syncSchemas(); + return engine; +}; + +const newClient = (manager: AuthManager, token?: string): ObjectStackClient => + new ObjectStackClient({ + baseUrl: ORIGIN, + ...(token ? { token } : {}), + fetch: (input: RequestInfo | URL, init?: RequestInit) => + manager.handleRequest(new Request(String(input), init)), + }); + +/** The credential the client is holding right now. */ +const storedToken = (client: ObjectStackClient): string | undefined => + (client as unknown as { token?: string }).token; + +let emailSeq = 0; + +/** + * A real manager and a signed-in client, plus BOTH spellings of the session + * credential the sign-up handed back. + * + * The sign-up runs through `manager.handleRequest` rather than through + * `client.auth.register` for one reason: the SDK's `register` keeps only the + * body, and the two spellings are what case ④ needs. Measured, they differ: + * + * ``` + * response header `set-auth-token` -> "." (SIGNED) + * response body .token -> "" (UNSIGNED) + * ``` + * + * `bearer()` accepts both, and `session.token` inside `/get-session` stores the + * unsigned one. + */ +const signedIn = async () => { + const engine = await makeEngine(); + const manager = new AuthManager({ + secret: SECRET, + baseUrl: ORIGIN, + dataEngine: engine, + } as never); + const email = `envelope-${++emailSeq}-${Date.now()}@example.com`; + const res = await manager.handleRequest( + new Request(`${ORIGIN}/api/v1/auth/sign-up/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, + body: JSON.stringify({ email, password: PASSWORD, name: 'Envelope User' }), + }), + ); + const body = (await res.json()) as { token?: string }; + const signed = res.headers.get('set-auth-token') ?? ''; + const unsigned = body.token ?? ''; + expect(signed, 'sign-up emitted no set-auth-token — the premise of ④ is gone').toBeTruthy(); + expect(unsigned, 'sign-up returned no body token').toBeTruthy(); + // The client is handed the SIGNED spelling, which is what `bearer()` + // advertises to a cross-origin caller through `Access-Control-Expose-Headers`. + return { manager, client: newClient(manager, signed), signed, unsigned, email }; +}; + +/** Anonymous: a real manager, and a client that has never signed in. */ +const anonymous = async () => { + const engine = await makeEngine(); + const manager = new AuthManager({ + secret: SECRET, + baseUrl: ORIGIN, + dataEngine: engine, + } as never); + return { manager, client: newClient(manager) }; +}; + +/** WHO a credential resolves to, asked through better-auth's own API. */ +const principalFor = async (manager: AuthManager, token: string | undefined) => { + const auth = (await manager.getAuthInstance()) as unknown as { + api: { getSession(a: { headers: Headers }): Promise }; + }; + const session = (await auth.api + .getSession({ headers: new Headers({ authorization: `Bearer ${token}` }) }) + .catch(() => null)) as { user?: { id?: string } } | null; + return session?.user?.id ?? null; +}; + +afterEach(async () => { + while (engines.length) { + const engine = engines.pop(); + await (engine as unknown as { close?: () => Promise })?.close?.().catch(() => {}); + } +}); + +describe('[#16760] /get-session is lifted into the SessionResponse envelope it declares', () => { + describe('① me() delivers the envelope it declares', () => { + it('parses as the declared envelope, with the payload under `data`', async () => { + const { client } = await signedIn(); + const res = await client.auth.me(); + + // The decisive assertion: the DECLARATION judges the body. On the defect + // the method returned better-auth's bare `{ user, session }`, which + // carries no `success` at all, so this parse is red before the fix. + const envelope = BaseResponseSchema.safeParse(res); + expect( + envelope.success, + `me() did not parse as the declared envelope: ${JSON.stringify(envelope.error?.issues)}`, + ).toBe(true); + expect(res.success).toBe(true); + + // …and the payload really is under the declared keys, not merely a + // `data` that exists. `.data.user` was `undefined` on the defect while + // `.user` — which did not type-check — held the real payload. + expect(res.data).toBeTruthy(); + expect(typeof res.data.user?.id).toBe('string'); + expect(res.data.user?.id).toBeTruthy(); + expect(res.data.user?.email).toContain('@'); + + // `data.session` is judged by its own declared schema for the same + // reason: a `session` key that is present but not a session would pass a + // truthiness check and fail every caller. + const session = SessionSchema.safeParse(res.data.session); + expect( + session.success, + `data.session did not parse as SessionSchema: ${JSON.stringify(session.error?.issues)}`, + ).toBe(true); + expect(res.data.session?.userId).toBe(res.data.user?.id); + }); + + it('leaves exactly one declared-type gap, and it is not the envelope', async () => { + const { client } = await signedIn(); + const res = await client.auth.me(); + + // The FULL declared type still does not parse — for a reason that has + // nothing to do with this card and that the lift cannot reach: + // `SessionUserSchema.image` is declared `z.string().optional()`, which + // does not admit `null`, and better-auth serves `"image": null` for a + // user who never set one. + // + // Pinned as the exhaustive issue list rather than as "it fails": if the + // envelope ever regresses, the missing `success` and `data` show up here + // as extra issues and this case reddens. It is the residue's tripwire, + // not an acceptance of it. + const issues = SessionResponseSchema.safeParse(res).error?.issues ?? []; + expect(issues.map((i) => i.path.join('.'))).toEqual(['data.user.image']); + }); + }); + + describe('② the raw keys survive the lift', () => { + it('keeps `.user` / `.session` alongside `data`', async () => { + const { client } = await signedIn(); + const res = (await client.auth.me()) as unknown as { + user?: { id?: string }; + session?: { id?: string }; + data: { user?: { id?: string }; session?: { id?: string } }; + }; + // Callers were pushed onto `.user` by the very misdeclaration this card + // fixes. Buying the declared shape by breaking them would trade one + // silent breakage for another. + expect(res.user?.id).toBe(res.data.user?.id); + expect(res.session?.id).toBe(res.data.session?.id); + }); + }); + + describe('③ anonymous stays anonymous — the known residue', () => { + it('answers the literal null rather than a signed-in-looking envelope', async () => { + const { client } = await anonymous(); + const res = await client.auth.me(); + // ⚠️ Still outside `SessionResponse`, deliberately: there is no value of + // that type meaning "nobody is signed in", and widening the published + // return annotation is a contract-review change, not this card's. What + // the lift must never do is manufacture `{ success: true, data: {} }` + // here — an empty session that reads as a real one. + expect(res).toBeNull(); + }); + }); + + describe('④ refreshToken captures a credential that actually works', () => { + it('stores session.token, and that token authenticates', async () => { + const { manager, client, signed, unsigned } = await signedIn(); + + // The firing control is the SPELLING, and it is a measured one: the + // client starts on the SIGNED credential `bearer()` hands out, while the + // token inside the session body is the UNSIGNED one. So a `refreshToken` + // that really captures from the body moves the stored string, and one + // that captures nothing leaves it exactly where it started. + // + // ⛔ Not "seed a deliberately wrong token": that unauthenticates the + // client, `/get-session` then answers `null` for the anonymous reason, + // and the case would fail against a CORRECT implementation. + expect(signed, 'the two spellings coincide — this control cannot fire').not.toBe(unsigned); + const before = storedToken(client); + expect(before).toBe(signed); + + const res = await client.auth.refreshToken('ignored-by-better-auth'); + + const stored = storedToken(client); + expect( + stored, + 'refreshToken captured nothing — it is still a silent no-op', + ).not.toBe(before); + expect(stored).toBeTruthy(); + + // It is the credential the route actually serves, not one invented here. + expect(stored).toBe(res.data.session?.token); + // …and the two are the measured pair, not two unrelated strings. + expect(String(before).startsWith(String(stored))).toBe(true); + + // And it is a WORKING credential, not merely a non-empty string: the + // whole point of the method is that the caller stays signed in. This is + // what makes swapping the stored spelling safe rather than merely + // observed — `bearer()` accepts both, and the server strips the + // signature before it looks the session up. + expect(await principalFor(manager, stored)).toBe(res.data.user?.id); + expect(await principalFor(manager, before)).toBe(res.data.user?.id); + // The control that must NOT resolve, so "resolves to the user" is a real + // reading and not something this arrangement answers for any input. + expect(await principalFor(manager, 'not-the-session-token-16760')).toBeNull(); + }); + }); + + describe('⑤ the field the old read named does not exist', () => { + it('has no top-level token and no data.token, before or after the lift', async () => { + const { client } = await signedIn(); + const res = (await client.auth.me()) as unknown as { + token?: unknown; + data: { token?: unknown; session?: { token?: unknown } }; + }; + // The card called `refreshToken`'s failure a CONSEQUENCE of the envelope + // being misdeclared. It is not: enveloping the body puts nothing at + // `data.token` either, because the route serves no top-level `token` to + // lift. The only credential in the body is `data.session.token`. + expect(res.token).toBeUndefined(); + expect(res.data.token).toBeUndefined(); + expect(typeof res.data.session?.token).toBe('string'); + }); + }); +}); diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 231968c30b..fbf96f3084 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -832,12 +832,26 @@ describe('Auth enhancements', () => { expect((client as any).token).toBe('new-token'); }); - it('should refresh token', async () => { + // [#16760] REPLACED, not adjusted. The previous fixture served + // `{ data: { token } }` — a body `/get-session` has never produced — and so + // pinned the very read that made this method a silent no-op in the field. + // A fixture modelling the misdeclaration cannot witness the fix, so the + // shape below is the measured one: better-auth answers bare, and the only + // credential in it is `session.token`. The end-to-end proof against a real + // `AuthManager` is `auth-get-session-envelope.test.ts`; this is its unit + // half, kept because it pins the REQUEST too. + it('should refresh token from session.token, the only token /get-session serves', async () => { const { client, fetchMock } = createMockClient({ - data: { token: 'refreshed-token' } + user: { id: 'usr_1', email: 'test@example.com', name: 'Test User' }, + session: { id: 'ses_1', userId: 'usr_1', token: 'refreshed-token', expiresAt: '2026-09-16T19:33:50.074Z' }, }); const result = await client.auth.refreshToken('old-refresh-token'); - expect(result.data.token).toBe('refreshed-token'); + expect(result.data.session?.token).toBe('refreshed-token'); + // The bare answer is lifted into the envelope the method declares. + expect(result.success).toBe(true); + expect(result.data.user?.id).toBe('usr_1'); + // ⛔ and NOT synthesized onto `data.token` — the key the broken read named. + expect((result.data as { token?: unknown }).token).toBeUndefined(); const [url, opts] = fetchMock.mock.calls[0]; expect(url).toContain('/api/v1/auth/get-session'); // Updated: better-auth uses get-session for refresh expect(opts.method).toBe('GET'); // Updated: GET instead of POST @@ -845,6 +859,31 @@ describe('Auth enhancements', () => { expect((client as any).token).toBe('refreshed-token'); }); + // [#16760] `me()` lifts the same bare body into the declared envelope. + it('me() lifts the bare /get-session answer into the declared envelope', async () => { + const { client, fetchMock } = createMockClient({ + user: { id: 'usr_1', email: 'test@example.com', name: 'Test User' }, + session: { id: 'ses_1', userId: 'usr_1', token: 'tok_1', expiresAt: '2026-09-16T19:33:50.074Z' }, + }); + const result = await client.auth.me(); + expect(result.success).toBe(true); + expect(result.data.user?.id).toBe('usr_1'); + expect(result.data.session?.id).toBe('ses_1'); + // The raw keys stay reachable for callers written against the wire. + expect((result as unknown as { user?: { id?: string } }).user?.id).toBe('usr_1'); + const [url] = fetchMock.mock.calls[0]; + expect(url).toContain('/api/v1/auth/get-session'); + // ⛔ me() must never capture a credential — only refreshToken does. + expect((client as any).token).toBeUndefined(); + }); + + // [#16760] The anonymous answer is the literal `null` at 200. The lift must + // pass it through rather than manufacture a signed-in-looking envelope. + it('me() passes the anonymous null through untouched', async () => { + const { client } = createMockClient(null); + expect(await client.auth.me()).toBeNull(); + }); + it('signInWithProvider defaults callbackURL to the current page (base-path-correct)', async () => { const assign = vi.fn(); vi.stubGlobal('window', { From 80ad2256a6b69ec89df02fdbfc39118789716576 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 19:50:30 +0000 Subject: [PATCH 3/4] chore(changeset): patch entry for the /get-session envelope fix Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- ...t-get-session-envelope-and-refresh-read.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .changeset/client-get-session-envelope-and-refresh-read.md diff --git a/.changeset/client-get-session-envelope-and-refresh-read.md b/.changeset/client-get-session-envelope-and-refresh-read.md new file mode 100644 index 0000000000..d7a40029c1 --- /dev/null +++ b/.changeset/client-get-session-envelope-and-refresh-read.md @@ -0,0 +1,63 @@ +--- +"@objectstack/client": patch +--- + +fix(client): `auth.me` / `auth.refreshToken` deliver the `SessionResponse` envelope they declare, and `refreshToken` reads the token the route actually serves (#16760) + +Both methods annotate their return as `SessionResponse` — ObjectStack's REST +`{ success, data }` envelope — for `GET /api/v1/auth/get-session`. better-auth +owns those bytes and answers **bare**. Measured against a real `AuthManager` +(better-auth 1.7.2, organization plugin) over a real driver: + +``` +GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}} +GET /api/v1/auth/get-session (anonymous) -> 200 null +``` + +So `(await client.auth.me()).data.user` type-checked and was `undefined` at +runtime, while `.user` — the real payload — did not type-check. The annotation +pointed every caller at the wrong key. + +## What changed + +- The bare answer is now lifted into the declared envelope, the same lift + `auth.login` has always carried for `/sign-in/email`. `SessionResponse` is + **unchanged** and so is each method's published return annotation: the fix is + in what the methods produce, not in what they promise. +- The lift fills `success` as well as `data`. `SessionResponseSchema` is + `BaseResponseSchema.extend(…)` and that base declares `success` as a required + boolean, so a body carrying `data` alone still would not parse as the declared + type. +- The raw `.user` / `.session` keys are **kept** alongside `data`. They are what + callers were pushed onto while the declared shape was unreachable; dropping + them would trade one silent breakage for another. +- `auth.refreshToken` now reads `data.session.token`. It used to read + `data.data?.token` — a field this route does not produce at any nesting, so + the method returned successfully having captured nothing. A bearer-mode client + calling it to refresh kept whatever credential it already had, silently. + +## The read was not a consequence of the envelope + +Worth stating because the reverse is the natural assumption: enveloping the body +does **not** put a token at `data.token`, because the route serves no top-level +`token` to lift. The only credential in the body is `session.token`, and that is +now the read. Fixing the shape alone would have left `refreshToken` exactly as +inert as it was. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `(await client.auth.me()).user` | still works — kept deliberately | +| `(await client.auth.me()).data.user` | now populated (was `undefined`) | +| `(await client.auth.refreshToken(t)).data.token` | `.data.session.token` | + +`refreshToken` stores the **unsigned** session token, which is the spelling +`/get-session` serves; `bearer()` accepts it and the signed +`token.signature` form interchangeably, so a client that held the signed form +stays signed in across the call. + +Two answers stay outside the declared type and are **not** addressed here: the +anonymous `null`, which would need the published return annotation to widen, and +`SessionUser.image`, declared `z.string().optional()` against a route that +serves `null`. Both are filed separately. From c4cbdb0db6c01602bc1bd0e7df924a8ae27f443f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 20:19:37 +0000 Subject: [PATCH 4/4] docs(client): name the split-out findings #17234 / #17235 at their pins Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- .changeset/client-get-session-envelope-and-refresh-read.md | 3 ++- packages/client/src/auth-get-session-envelope.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/client-get-session-envelope-and-refresh-read.md b/.changeset/client-get-session-envelope-and-refresh-read.md index d7a40029c1..ba6704d205 100644 --- a/.changeset/client-get-session-envelope-and-refresh-read.md +++ b/.changeset/client-get-session-envelope-and-refresh-read.md @@ -60,4 +60,5 @@ stays signed in across the call. Two answers stay outside the declared type and are **not** addressed here: the anonymous `null`, which would need the published return annotation to widen, and `SessionUser.image`, declared `z.string().optional()` against a route that -serves `null`. Both are filed separately. +serves `null` (#17235). The sibling `auth.login` / `auth.register`, which +normalize into `data` but set no `success`, are #17234. diff --git a/packages/client/src/auth-get-session-envelope.test.ts b/packages/client/src/auth-get-session-envelope.test.ts index 275116fc5c..6c96ff56c3 100644 --- a/packages/client/src/auth-get-session-envelope.test.ts +++ b/packages/client/src/auth-get-session-envelope.test.ts @@ -213,7 +213,7 @@ describe('[#16760] /get-session is lifted into the SessionResponse envelope it d // nothing to do with this card and that the lift cannot reach: // `SessionUserSchema.image` is declared `z.string().optional()`, which // does not admit `null`, and better-auth serves `"image": null` for a - // user who never set one. + // user who never set one. Filed as #17235 — delete this case with it. // // Pinned as the exhaustive issue list rather than as "it fails": if the // envelope ever regresses, the missing `success` and `data` show up here