Skip to content

Commit 5de9372

Browse files
claude[bot]claude
andauthored
fix(client): auth.me / auth.refreshToken deliver the SessionResponse envelope they declare, and refreshToken reads session.token (#17237)
* wip(client): normalize /get-session into the declared SessionResponse envelope Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 * fix(client): lift the bare /get-session answer into the SessionResponse envelope both methods declare Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 * chore(changeset): patch entry for the /get-session envelope fix Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 * docs(client): name the split-out findings #17234 / #17235 at their pins Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 559e531 commit 5de9372

4 files changed

Lines changed: 521 additions & 7 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
fix(client): `auth.me` / `auth.refreshToken` deliver the `SessionResponse` envelope they declare, and `refreshToken` reads the token the route actually serves (#16760)
6+
7+
Both methods annotate their return as `SessionResponse` — ObjectStack's REST
8+
`{ success, data }` envelope — for `GET /api/v1/auth/get-session`. better-auth
9+
owns those bytes and answers **bare**. Measured against a real `AuthManager`
10+
(better-auth 1.7.2, organization plugin) over a real driver:
11+
12+
```
13+
GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}}
14+
GET /api/v1/auth/get-session (anonymous) -> 200 null
15+
```
16+
17+
So `(await client.auth.me()).data.user` type-checked and was `undefined` at
18+
runtime, while `.user` — the real payload — did not type-check. The annotation
19+
pointed every caller at the wrong key.
20+
21+
## What changed
22+
23+
- The bare answer is now lifted into the declared envelope, the same lift
24+
`auth.login` has always carried for `/sign-in/email`. `SessionResponse` is
25+
**unchanged** and so is each method's published return annotation: the fix is
26+
in what the methods produce, not in what they promise.
27+
- The lift fills `success` as well as `data`. `SessionResponseSchema` is
28+
`BaseResponseSchema.extend(…)` and that base declares `success` as a required
29+
boolean, so a body carrying `data` alone still would not parse as the declared
30+
type.
31+
- The raw `.user` / `.session` keys are **kept** alongside `data`. They are what
32+
callers were pushed onto while the declared shape was unreachable; dropping
33+
them would trade one silent breakage for another.
34+
- `auth.refreshToken` now reads `data.session.token`. It used to read
35+
`data.data?.token` — a field this route does not produce at any nesting, so
36+
the method returned successfully having captured nothing. A bearer-mode client
37+
calling it to refresh kept whatever credential it already had, silently.
38+
39+
## The read was not a consequence of the envelope
40+
41+
Worth stating because the reverse is the natural assumption: enveloping the body
42+
does **not** put a token at `data.token`, because the route serves no top-level
43+
`token` to lift. The only credential in the body is `session.token`, and that is
44+
now the read. Fixing the shape alone would have left `refreshToken` exactly as
45+
inert as it was.
46+
47+
## FROM → TO
48+
49+
| you wrote | write instead |
50+
|:--|:--|
51+
| `(await client.auth.me()).user` | still works — kept deliberately |
52+
| `(await client.auth.me()).data.user` | now populated (was `undefined`) |
53+
| `(await client.auth.refreshToken(t)).data.token` | `.data.session.token` |
54+
55+
`refreshToken` stores the **unsigned** session token, which is the spelling
56+
`/get-session` serves; `bearer()` accepts it and the signed
57+
`token.signature` form interchangeably, so a client that held the signed form
58+
stays signed in across the call.
59+
60+
Two answers stay outside the declared type and are **not** addressed here: the
61+
anonymous `null`, which would need the published return annotation to widen, and
62+
`SessionUser.image`, declared `z.string().optional()` against a route that
63+
serves `null` (#17235). The sibling `auth.login` / `auth.register`, which
64+
normalize into `data` but set no `success`, are #17234.
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #16760 — `auth.me` and `auth.refreshToken` declare `SessionResponse` (the
4+
// REST `{ success, data }` envelope) for `GET /api/v1/auth/get-session`, a
5+
// route that answers bare; and `refreshToken` captured no credential at all.
6+
//
7+
// ## Why the server here is the real one
8+
//
9+
// Every claim in this file is a claim about BYTES BETTER-AUTH WRITES — which
10+
// keys `/get-session` puts at the top level, where in them the session
11+
// credential sits, and what the anonymous answer is. A hand-written double
12+
// would let this suite certify the SDK against a body this file invented, and
13+
// the card it closes exists precisely because the declared shape and the real
14+
// one had drifted apart with nobody measuring. So the arrangement is a real
15+
// `AuthManager` (better-auth 1.7.2, organization plugin on by its own default)
16+
// over a real `ObjectQL` on a real `SqliteWasmDriver`, with an
17+
// `ObjectStackClient` whose `fetch` hands the `Request` straight to
18+
// `AuthManager.handleRequest`: everything above that call is the SDK's real
19+
// request path, everything below it is better-auth's real pipeline.
20+
//
21+
// ## What each block is for
22+
//
23+
// - `① me() delivers the envelope it declares` — the card's first consequence.
24+
// The decisive assertion is a PARSE against the declared schema, not a key
25+
// spot-check: the defect is "the declared type is not delivered", so the
26+
// declaration itself has to be the judge. Its second case pins the ONE gap
27+
// the lift cannot close (`user.image`, declared string-or-absent, served
28+
// `null`) as an exhaustive issue list, so the residue cannot quietly grow.
29+
// - `② the raw keys survive` — `.user` is what the field reads today, while
30+
// the declared `.data.user` was `undefined`. The fix must not buy the
31+
// declared shape by breaking the workaround callers were pushed onto.
32+
// - `③ anonymous stays anonymous` — the route serves the literal `null` at
33+
// 200. Pinned as the KNOWN residue: it is still outside `SessionResponse`,
34+
// and this case exists so that stays a measured fact rather than a surprise.
35+
// - `④ refreshToken captures a credential that actually works` — the card's
36+
// second consequence, and the one that was NOT a consequence of the envelope
37+
// at all. The firing control is the credential's SPELLING: the client starts
38+
// on the signed `token.signature` form `bearer()` hands out, and a working
39+
// capture moves it to the unsigned one the session body carries.
40+
// - `⑤ the field the old read named does not exist` — the negative control.
41+
// `data.token` is absent from the normalized body too, so a regression back
42+
// to `data.data?.token` cannot pass by accident, and the "enveloping it
43+
// would have fixed refreshToken" reading stays refuted in code.
44+
45+
import { describe, it, expect, afterEach } from 'vitest';
46+
import { ObjectQL } from '@objectstack/objectql';
47+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
48+
import { AuthManager } from '@objectstack/plugin-auth';
49+
import * as identityObjects from '@objectstack/platform-objects/identity';
50+
import { BaseResponseSchema, SessionResponseSchema, SessionSchema } from '@objectstack/spec/api';
51+
import { ObjectStackClient } from './index';
52+
53+
const SECRET = 'test-secret-at-least-32-chars-long!!';
54+
const ORIGIN = 'http://localhost:3000';
55+
const PASSWORD = 'S3cure!Passw0rd-16760';
56+
57+
/**
58+
* The identity objects better-auth's ObjectQL adapter reads and writes on the
59+
* routes under test, plus every sibling the boot path touches. Read out of
60+
* `@objectstack/platform-objects/identity` BY SHAPE rather than transcribed:
61+
* plugin-auth's own `authIdentityObjects` is package-private, and a hand-copied
62+
* list would be a second declaration of the same set, drifting silently the day
63+
* the plugin registers one more.
64+
*/
65+
const IDENTITY_OBJECTS = Object.values(
66+
identityObjects as unknown as Record<string, unknown>,
67+
).filter(
68+
(o): o is Record<string, unknown> =>
69+
!!o &&
70+
typeof o === 'object' &&
71+
typeof (o as { name?: unknown }).name === 'string' &&
72+
typeof (o as { fields?: unknown }).fields === 'object',
73+
);
74+
75+
const engines: ObjectQL[] = [];
76+
77+
const makeEngine = async (): Promise<ObjectQL> => {
78+
const engine = new ObjectQL();
79+
engines.push(engine);
80+
engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as never, true);
81+
await engine.init();
82+
for (const object of IDENTITY_OBJECTS) {
83+
engine.registry.registerObject(object as never, '@objectstack/plugin-auth');
84+
}
85+
await engine.syncSchemas();
86+
return engine;
87+
};
88+
89+
const newClient = (manager: AuthManager, token?: string): ObjectStackClient =>
90+
new ObjectStackClient({
91+
baseUrl: ORIGIN,
92+
...(token ? { token } : {}),
93+
fetch: (input: RequestInfo | URL, init?: RequestInit) =>
94+
manager.handleRequest(new Request(String(input), init)),
95+
});
96+
97+
/** The credential the client is holding right now. */
98+
const storedToken = (client: ObjectStackClient): string | undefined =>
99+
(client as unknown as { token?: string }).token;
100+
101+
let emailSeq = 0;
102+
103+
/**
104+
* A real manager and a signed-in client, plus BOTH spellings of the session
105+
* credential the sign-up handed back.
106+
*
107+
* The sign-up runs through `manager.handleRequest` rather than through
108+
* `client.auth.register` for one reason: the SDK's `register` keeps only the
109+
* body, and the two spellings are what case ④ needs. Measured, they differ:
110+
*
111+
* ```
112+
* response header `set-auth-token` -> "<token>.<signature>" (SIGNED)
113+
* response body .token -> "<token>" (UNSIGNED)
114+
* ```
115+
*
116+
* `bearer()` accepts both, and `session.token` inside `/get-session` stores the
117+
* unsigned one.
118+
*/
119+
const signedIn = async () => {
120+
const engine = await makeEngine();
121+
const manager = new AuthManager({
122+
secret: SECRET,
123+
baseUrl: ORIGIN,
124+
dataEngine: engine,
125+
} as never);
126+
const email = `envelope-${++emailSeq}-${Date.now()}@example.com`;
127+
const res = await manager.handleRequest(
128+
new Request(`${ORIGIN}/api/v1/auth/sign-up/email`, {
129+
method: 'POST',
130+
headers: { 'Content-Type': 'application/json', Origin: ORIGIN },
131+
body: JSON.stringify({ email, password: PASSWORD, name: 'Envelope User' }),
132+
}),
133+
);
134+
const body = (await res.json()) as { token?: string };
135+
const signed = res.headers.get('set-auth-token') ?? '';
136+
const unsigned = body.token ?? '';
137+
expect(signed, 'sign-up emitted no set-auth-token — the premise of ④ is gone').toBeTruthy();
138+
expect(unsigned, 'sign-up returned no body token').toBeTruthy();
139+
// The client is handed the SIGNED spelling, which is what `bearer()`
140+
// advertises to a cross-origin caller through `Access-Control-Expose-Headers`.
141+
return { manager, client: newClient(manager, signed), signed, unsigned, email };
142+
};
143+
144+
/** Anonymous: a real manager, and a client that has never signed in. */
145+
const anonymous = async () => {
146+
const engine = await makeEngine();
147+
const manager = new AuthManager({
148+
secret: SECRET,
149+
baseUrl: ORIGIN,
150+
dataEngine: engine,
151+
} as never);
152+
return { manager, client: newClient(manager) };
153+
};
154+
155+
/** WHO a credential resolves to, asked through better-auth's own API. */
156+
const principalFor = async (manager: AuthManager, token: string | undefined) => {
157+
const auth = (await manager.getAuthInstance()) as unknown as {
158+
api: { getSession(a: { headers: Headers }): Promise<unknown> };
159+
};
160+
const session = (await auth.api
161+
.getSession({ headers: new Headers({ authorization: `Bearer ${token}` }) })
162+
.catch(() => null)) as { user?: { id?: string } } | null;
163+
return session?.user?.id ?? null;
164+
};
165+
166+
afterEach(async () => {
167+
while (engines.length) {
168+
const engine = engines.pop();
169+
await (engine as unknown as { close?: () => Promise<void> })?.close?.().catch(() => {});
170+
}
171+
});
172+
173+
describe('[#16760] /get-session is lifted into the SessionResponse envelope it declares', () => {
174+
describe('① me() delivers the envelope it declares', () => {
175+
it('parses as the declared envelope, with the payload under `data`', async () => {
176+
const { client } = await signedIn();
177+
const res = await client.auth.me();
178+
179+
// The decisive assertion: the DECLARATION judges the body. On the defect
180+
// the method returned better-auth's bare `{ user, session }`, which
181+
// carries no `success` at all, so this parse is red before the fix.
182+
const envelope = BaseResponseSchema.safeParse(res);
183+
expect(
184+
envelope.success,
185+
`me() did not parse as the declared envelope: ${JSON.stringify(envelope.error?.issues)}`,
186+
).toBe(true);
187+
expect(res.success).toBe(true);
188+
189+
// …and the payload really is under the declared keys, not merely a
190+
// `data` that exists. `.data.user` was `undefined` on the defect while
191+
// `.user` — which did not type-check — held the real payload.
192+
expect(res.data).toBeTruthy();
193+
expect(typeof res.data.user?.id).toBe('string');
194+
expect(res.data.user?.id).toBeTruthy();
195+
expect(res.data.user?.email).toContain('@');
196+
197+
// `data.session` is judged by its own declared schema for the same
198+
// reason: a `session` key that is present but not a session would pass a
199+
// truthiness check and fail every caller.
200+
const session = SessionSchema.safeParse(res.data.session);
201+
expect(
202+
session.success,
203+
`data.session did not parse as SessionSchema: ${JSON.stringify(session.error?.issues)}`,
204+
).toBe(true);
205+
expect(res.data.session?.userId).toBe(res.data.user?.id);
206+
});
207+
208+
it('leaves exactly one declared-type gap, and it is not the envelope', async () => {
209+
const { client } = await signedIn();
210+
const res = await client.auth.me();
211+
212+
// The FULL declared type still does not parse — for a reason that has
213+
// nothing to do with this card and that the lift cannot reach:
214+
// `SessionUserSchema.image` is declared `z.string().optional()`, which
215+
// does not admit `null`, and better-auth serves `"image": null` for a
216+
// user who never set one. Filed as #17235 — delete this case with it.
217+
//
218+
// Pinned as the exhaustive issue list rather than as "it fails": if the
219+
// envelope ever regresses, the missing `success` and `data` show up here
220+
// as extra issues and this case reddens. It is the residue's tripwire,
221+
// not an acceptance of it.
222+
const issues = SessionResponseSchema.safeParse(res).error?.issues ?? [];
223+
expect(issues.map((i) => i.path.join('.'))).toEqual(['data.user.image']);
224+
});
225+
});
226+
227+
describe('② the raw keys survive the lift', () => {
228+
it('keeps `.user` / `.session` alongside `data`', async () => {
229+
const { client } = await signedIn();
230+
const res = (await client.auth.me()) as unknown as {
231+
user?: { id?: string };
232+
session?: { id?: string };
233+
data: { user?: { id?: string }; session?: { id?: string } };
234+
};
235+
// Callers were pushed onto `.user` by the very misdeclaration this card
236+
// fixes. Buying the declared shape by breaking them would trade one
237+
// silent breakage for another.
238+
expect(res.user?.id).toBe(res.data.user?.id);
239+
expect(res.session?.id).toBe(res.data.session?.id);
240+
});
241+
});
242+
243+
describe('③ anonymous stays anonymous — the known residue', () => {
244+
it('answers the literal null rather than a signed-in-looking envelope', async () => {
245+
const { client } = await anonymous();
246+
const res = await client.auth.me();
247+
// ⚠️ Still outside `SessionResponse`, deliberately: there is no value of
248+
// that type meaning "nobody is signed in", and widening the published
249+
// return annotation is a contract-review change, not this card's. What
250+
// the lift must never do is manufacture `{ success: true, data: {} }`
251+
// here — an empty session that reads as a real one.
252+
expect(res).toBeNull();
253+
});
254+
});
255+
256+
describe('④ refreshToken captures a credential that actually works', () => {
257+
it('stores session.token, and that token authenticates', async () => {
258+
const { manager, client, signed, unsigned } = await signedIn();
259+
260+
// The firing control is the SPELLING, and it is a measured one: the
261+
// client starts on the SIGNED credential `bearer()` hands out, while the
262+
// token inside the session body is the UNSIGNED one. So a `refreshToken`
263+
// that really captures from the body moves the stored string, and one
264+
// that captures nothing leaves it exactly where it started.
265+
//
266+
// ⛔ Not "seed a deliberately wrong token": that unauthenticates the
267+
// client, `/get-session` then answers `null` for the anonymous reason,
268+
// and the case would fail against a CORRECT implementation.
269+
expect(signed, 'the two spellings coincide — this control cannot fire').not.toBe(unsigned);
270+
const before = storedToken(client);
271+
expect(before).toBe(signed);
272+
273+
const res = await client.auth.refreshToken('ignored-by-better-auth');
274+
275+
const stored = storedToken(client);
276+
expect(
277+
stored,
278+
'refreshToken captured nothing — it is still a silent no-op',
279+
).not.toBe(before);
280+
expect(stored).toBeTruthy();
281+
282+
// It is the credential the route actually serves, not one invented here.
283+
expect(stored).toBe(res.data.session?.token);
284+
// …and the two are the measured pair, not two unrelated strings.
285+
expect(String(before).startsWith(String(stored))).toBe(true);
286+
287+
// And it is a WORKING credential, not merely a non-empty string: the
288+
// whole point of the method is that the caller stays signed in. This is
289+
// what makes swapping the stored spelling safe rather than merely
290+
// observed — `bearer()` accepts both, and the server strips the
291+
// signature before it looks the session up.
292+
expect(await principalFor(manager, stored)).toBe(res.data.user?.id);
293+
expect(await principalFor(manager, before)).toBe(res.data.user?.id);
294+
// The control that must NOT resolve, so "resolves to the user" is a real
295+
// reading and not something this arrangement answers for any input.
296+
expect(await principalFor(manager, 'not-the-session-token-16760')).toBeNull();
297+
});
298+
});
299+
300+
describe('⑤ the field the old read named does not exist', () => {
301+
it('has no top-level token and no data.token, before or after the lift', async () => {
302+
const { client } = await signedIn();
303+
const res = (await client.auth.me()) as unknown as {
304+
token?: unknown;
305+
data: { token?: unknown; session?: { token?: unknown } };
306+
};
307+
// The card called `refreshToken`'s failure a CONSEQUENCE of the envelope
308+
// being misdeclared. It is not: enveloping the body puts nothing at
309+
// `data.token` either, because the route serves no top-level `token` to
310+
// lift. The only credential in the body is `data.session.token`.
311+
expect(res.token).toBeUndefined();
312+
expect(res.data.token).toBeUndefined();
313+
expect(typeof res.data.session?.token).toBe('string');
314+
});
315+
});
316+
});

0 commit comments

Comments
 (0)