Skip to content

Commit 64f52f0

Browse files
committed
fix(client): auth.login / auth.register fill the SessionResponse envelope they declare
`login` and `register` annotate their return as `SessionResponse`, whose base `BaseResponseSchema` declares `success` as a REQUIRED boolean. Both carried an inline lift that filled `data` and never wrote `success`, so neither delivered the type it advertises and every consumer keying on the envelope flag -- `unwrapResponse` keys on exactly this -- read `undefined`. Route both through the existing `normalizeSessionResponse` instead of a second inline copy, extended to carry a body's own top-level `token` into `data.token` so the credential `login` arms `this.token` from survives byte-identical. The lift now copies only the members a body really has: `/get-session` answers `{ user, session }` and `/sign-in|sign-up/email` answer `{ token, user }`. `data.session` is NOT closed: measured against a real AuthManager, neither credential route serves a session object, id or expiry in body or header, so it is unobtainable without a second `/get-session` call. Nothing is synthesized; #17234 stays open for that shape decision. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
1 parent 15805ea commit 64f52f0

1 file changed

Lines changed: 88 additions & 39 deletions

File tree

packages/client/src/index.ts

Lines changed: 88 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1460,24 +1460,26 @@ const DEFAULT_META_PREFIX = '/meta';
14601460
const SET_AUTH_TOKEN_HEADER = 'set-auth-token';
14611461

14621462
/**
1463-
* Lift better-auth's bare `/get-session` answer into the `SessionResponse`
1464-
* envelope the two methods that call that route declare (#16760).
1463+
* Lift better-auth's bare answer into the `SessionResponse` envelope the four
1464+
* `auth.*` methods that read these routes declare (#16760, #17234).
14651465
*
14661466
* `/api/v1/auth/*` is better-auth's own byte stream — plugin-auth mounts one
14671467
* catch-all straight onto its handler — and better-auth does not use
14681468
* ObjectStack's REST envelope. Measured against a real `AuthManager`
1469-
* (better-auth 1.7.2, organization plugin) over a real driver:
1469+
* (better-auth 1.7.3, organization plugin) over a real `ObjectQL` on a real
1470+
* `SqliteWasmDriver`, these are the three bodies this helper is handed:
14701471
*
14711472
* ```
1472-
* GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}}
1473-
* GET /api/v1/auth/get-session (anonymous) -> 200 null
1473+
* GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}}
1474+
* GET /api/v1/auth/get-session (anonymous) -> 200 null
1475+
* POST /api/v1/auth/sign-up/email -> 200 {"token":"…","user":{…}}
1476+
* POST /api/v1/auth/sign-in/email -> 200 {"redirect":false,"token":"…","user":{…}}
14741477
* ```
14751478
*
1476-
* `auth.login` has carried the same lift for `/sign-in/email`'s own bare
1477-
* `{ token, user }` since long before this card; `auth.me` and
1478-
* `auth.refreshToken` never got it, so every caller writing to the declared
1479-
* `data.user` read `undefined` while the real payload sat on `.user` — which
1480-
* did not type-check.
1479+
* The two families carry DISJOINT payload members — `/get-session` has the
1480+
* session and no top-level token, the two credential routes have the token and
1481+
* no session — so the lift copies the members a body actually has instead of
1482+
* writing a fixed triple.
14811483
*
14821484
* Three properties this deliberately has:
14831485
*
@@ -1486,36 +1488,59 @@ const SET_AUTH_TOKEN_HEADER = 'set-auth-token';
14861488
* REQUIRED boolean, so a body carrying `data` alone still does not parse as
14871489
* the type the method advertises. A producer that sent its own `success`
14881490
* keeps it — the spread below runs after the default.
1489-
* - **The raw keys are kept, not replaced.** `{ …body, data }`, exactly as
1490-
* `login` does. `.user` is the read the field has been using all along while
1491-
* the declared `.data.user` was `undefined`, and dropping it would break
1492-
* those callers in order to fix a type they were already working around.
1493-
* - **`data.token` is NOT synthesized from `session.token`.** The declared key
1494-
* is optional, and the two spellings are not one string: `session.token` is
1495-
* the UNSIGNED session token, while the `token` `login` puts there is the
1496-
* SIGNED `token.signature` form `bearer()` hands out. Both authenticate, so
1497-
* populating it would file two different credentials under one key depending
1498-
* on which method produced the body.
1491+
* - **The raw keys are kept, not replaced.** `{ …body, data }`. `.user` is the
1492+
* read callers have been using all along while the declared `.data.user` was
1493+
* `undefined`, and dropping it would break those callers in order to fix a
1494+
* type they were already working around.
1495+
* - **`data.token` is carried from the body's OWN top-level `token`, and is
1496+
* never synthesized from `session.token`.** `login` / `register` are handed a
1497+
* credential in the body and put it here, which is where `login` reads it
1498+
* back from to arm `this.token`; `/get-session` is handed none and gets no
1499+
* `data.token` at all. The two spellings a session has are the UNSIGNED
1500+
* token and the SIGNED `token.signature` form, and the split is by CARRIER,
1501+
* not by method: measured on one sign-in, the response BODY's `token` and the
1502+
* `session.token` a following `/get-session` serves are the same unsigned
1503+
* string, while the SIGNED form is the one `bearer()` publishes in the
1504+
* `set-auth-token` RESPONSE HEADER. So synthesizing `data.token` from a
1505+
* session would not change which credential lands here — it would invent a
1506+
* `token` on a route that served none, which is a different lie.
14991507
*
1500-
* The `body &&` guard is what carries the anonymous answer: `null` is falsy and
1508+
* ⚠️ **Known residue — `data.session` on `/sign-in|sign-up/email` (#17234).**
1509+
* Those two routes serve no session object and no session id or expiry
1510+
* anywhere, body or header, so `login` and `register` return a `data` with no
1511+
* `session` and still do not parse as the full declared `SessionResponse`. The
1512+
* only place a session is obtainable is a SECOND call to `/get-session`
1513+
* (`auth.me`), and manufacturing one here would put a fabricated id and expiry
1514+
* under a declared type — the card stays open for that shape decision rather
1515+
* than being closed by an invention.
1516+
*
1517+
* The `!body` guard is what carries the anonymous answer: `null` is falsy and
15011518
* is returned untouched rather than wrapped into a signed-in-looking envelope
1502-
* that no session backs. That answer stays outside `SessionResponse`; closing
1503-
* it needs the published return annotation to widen, which is a different card.
1519+
* that no session backs. That answer stays outside `SessionResponse`, and
1520+
* closing it needs the published return annotation to widen, which is a
1521+
* different card.
15041522
*/
15051523
const normalizeSessionResponse = (raw: unknown): SessionResponse => {
1506-
const body = raw as { user?: unknown; session?: unknown; data?: unknown } | null;
1524+
const body = raw as
1525+
| { user?: unknown; session?: unknown; token?: unknown; data?: unknown }
1526+
| null;
15071527
// Already enveloped, or nothing recognisable to lift: hand it back untouched
15081528
// rather than inventing a `data` this response never carried.
15091529
if (!body || typeof body !== 'object') return body as unknown as SessionResponse;
15101530
if (body.data !== undefined) return body as unknown as SessionResponse;
1511-
if (body.user === undefined && body.session === undefined) {
1531+
if (body.user === undefined && body.session === undefined && body.token === undefined) {
15121532
return body as unknown as SessionResponse;
15131533
}
1514-
return {
1515-
success: true,
1516-
...body,
1517-
data: { user: body.user, session: body.session },
1518-
} as unknown as SessionResponse;
1534+
// Only the members this body really carries. The two route families answer
1535+
// disjoint sets (`{ user, session }` vs `{ token, user }`), so writing all
1536+
// three unconditionally would file an `undefined` under a key the route never
1537+
// served — and for `token` that is the difference between "this body carries
1538+
// no credential" and "this SDK dropped the credential it was handed".
1539+
const data: { user?: unknown; session?: unknown; token?: unknown } = {};
1540+
if (body.user !== undefined) data.user = body.user;
1541+
if (body.session !== undefined) data.session = body.session;
1542+
if (body.token !== undefined) data.token = body.token;
1543+
return { success: true, ...body, data } as unknown as SessionResponse;
15191544
};
15201545

15211546
export class ObjectStackClient {
@@ -4268,6 +4293,19 @@ export class ObjectStackClient {
42684293
/**
42694294
* Login with email and password
42704295
* Uses better-auth endpoint: POST /sign-in/email
4296+
*
4297+
* The route answers bare (`{ redirect, token, user }`), so the answer is
4298+
* lifted into the declared `SessionResponse` envelope by
4299+
* {@link normalizeSessionResponse} — one lift shared with `auth.me` /
4300+
* `auth.refreshToken`, so the family cannot deliver two envelopes again
4301+
* (#17234). The credential the route hands back stays at `data.token`
4302+
* byte-identical and is what arms `this.token` below.
4303+
*
4304+
* ⚠️ `data.session` is the one member of the declared type this route
4305+
* cannot deliver: `/sign-in/email` serves no session object, and no session
4306+
* id or expiry reaches the client on this call at all. Read the session
4307+
* from a following `auth.me()` (`GET /get-session`); nothing is fabricated
4308+
* here. #17234 stays open for that shape decision.
42714309
*/
42724310
login: async (request: LoginRequest): Promise<SessionResponse> => {
42734311
const route = this.getRoute('auth');
@@ -4288,10 +4326,12 @@ export class ObjectStackClient {
42884326
err.status = res.status;
42894327
throw err;
42904328
}
4291-
// Normalize: better-auth returns `{ token, user }` at top level,
4292-
// but our SessionResponse shape wraps them in `data`.
4293-
const data = raw && (raw.data ?? (raw.token || raw.user ? { token: raw.token, user: raw.user } : undefined));
4294-
const normalized = data ? { ...raw, data } : raw;
4329+
// Normalize: better-auth returns `{ redirect, token, user }` at top
4330+
// level, but the declared `SessionResponse` wraps the payload in `data`
4331+
// AND requires `success` — which the inline lift this replaced never
4332+
// wrote, so neither method delivered the type it advertises (#17234).
4333+
// One lift for both route families, so the two cannot drift again.
4334+
const normalized = normalizeSessionResponse(raw);
42954335
// Auto-set token if present in response
42964336
if (normalized.data?.token) {
42974337
this.token = normalized.data.token;
@@ -4319,8 +4359,8 @@ export class ObjectStackClient {
43194359
*
43204360
* The route answers bare (`{ user, session }`), so the answer is lifted
43214361
* into the declared `SessionResponse` envelope by
4322-
* {@link normalizeSessionResponse} — the same lift `login` has always
4323-
* carried. Read the payload off `data.user` / `data.session`; the raw
4362+
* {@link normalizeSessionResponse} — since #17234 the same lift `login`
4363+
* and `register` run. Read the payload off `data.user` / `data.session`; the raw
43244364
* `.user` / `.session` keys are kept alongside for callers written against
43254365
* the wire while the declared shape was unreachable.
43264366
*
@@ -4339,6 +4379,14 @@ export class ObjectStackClient {
43394379
/**
43404380
* Register a new user account
43414381
* Uses better-auth endpoint: POST /sign-up/email
4382+
*
4383+
* The route answers bare (`{ token, user }`) and is lifted into the
4384+
* declared `SessionResponse` envelope by the same
4385+
* {@link normalizeSessionResponse} `login` runs (#17234); the credential
4386+
* stays at `data.token` and arms `this.token`.
4387+
*
4388+
* ⚠️ `data.session` is undelivered here for the same measured reason as on
4389+
* `login`: `/sign-up/email` serves no session object. See that method.
43424390
*/
43434391
register: async (request: RegisterRequest): Promise<SessionResponse> => {
43444392
const route = this.getRoute('auth');
@@ -4347,9 +4395,10 @@ export class ObjectStackClient {
43474395
headers: { Origin: this.baseUrl },
43484396
body: JSON.stringify(request)
43494397
});
4350-
const raw = await res.json();
4351-
const data = raw && (raw.data ?? (raw.token || raw.user ? { token: raw.token, user: raw.user } : undefined));
4352-
const normalized = data ? { ...raw, data } : raw;
4398+
// Same lift as `login`, for the same reason (#17234): `/sign-up/email`
4399+
// answers the bare `{ token, user }` and the declared `SessionResponse`
4400+
// requires `success` as well as `data`.
4401+
const normalized = normalizeSessionResponse(await res.json());
43534402
if (normalized.data?.token) {
43544403
this.token = normalized.data.token;
43554404
}

0 commit comments

Comments
 (0)