@@ -1460,24 +1460,26 @@ const DEFAULT_META_PREFIX = '/meta';
14601460const 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 */
15051523const 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
15211546export 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