@@ -1459,6 +1459,65 @@ const DEFAULT_META_PREFIX = '/meta';
14591459 */
14601460const SET_AUTH_TOKEN_HEADER = 'set-auth-token' ;
14611461
1462+ /**
1463+ * Lift better-auth's bare `/get-session` answer into the `SessionResponse`
1464+ * envelope the two methods that call that route declare (#16760).
1465+ *
1466+ * `/api/v1/auth/*` is better-auth's own byte stream — plugin-auth mounts one
1467+ * catch-all straight onto its handler — and better-auth does not use
1468+ * ObjectStack's REST envelope. Measured against a real `AuthManager`
1469+ * (better-auth 1.7.2, organization plugin) over a real driver:
1470+ *
1471+ * ```
1472+ * GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}}
1473+ * GET /api/v1/auth/get-session (anonymous) -> 200 null
1474+ * ```
1475+ *
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.
1481+ *
1482+ * Three properties this deliberately has:
1483+ *
1484+ * - **`success` is filled, not only `data`.** `SessionResponseSchema` is
1485+ * `BaseResponseSchema.extend(…)` and that base declares `success` as a
1486+ * REQUIRED boolean, so a body carrying `data` alone still does not parse as
1487+ * the type the method advertises. A producer that sent its own `success`
1488+ * 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.
1499+ *
1500+ * The `body &&` guard is what carries the anonymous answer: `null` is falsy and
1501+ * 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.
1504+ */
1505+ const normalizeSessionResponse = ( raw : unknown ) : SessionResponse => {
1506+ const body = raw as { user ?: unknown ; session ?: unknown ; data ?: unknown } | null ;
1507+ // Already enveloped, or nothing recognisable to lift: hand it back untouched
1508+ // rather than inventing a `data` this response never carried.
1509+ if ( ! body || typeof body !== 'object' ) return body as unknown as SessionResponse ;
1510+ if ( body . data !== undefined ) return body as unknown as SessionResponse ;
1511+ if ( body . user === undefined && body . session === undefined ) {
1512+ return body as unknown as SessionResponse ;
1513+ }
1514+ return {
1515+ success : true ,
1516+ ...body ,
1517+ data : { user : body . user , session : body . session } ,
1518+ } as unknown as SessionResponse ;
1519+ } ;
1520+
14621521export class ObjectStackClient {
14631522 private baseUrl : string ;
14641523 private token ?: string ;
@@ -4130,13 +4189,24 @@ export class ObjectStackClient {
41304189 /**
41314190 * Get current user session
41324191 * Uses better-auth endpoint: GET /get-session
4192+ *
4193+ * The route answers bare (`{ user, session }`), so the answer is lifted
4194+ * into the declared `SessionResponse` envelope by
4195+ * {@link normalizeSessionResponse} — the same lift `login` has always
4196+ * carried. Read the payload off `data.user` / `data.session`; the raw
4197+ * `.user` / `.session` keys are kept alongside for callers written against
4198+ * the wire while the declared shape was unreachable.
4199+ *
4200+ * ⚠️ Anonymous is the one answer still outside the declared type: the route
4201+ * serves the literal `null` at 200 and it is returned as-is, because there
4202+ * is no `SessionResponse` value that means "nobody is signed in".
41334203 */
41344204 me : async ( ) : Promise < SessionResponse > => {
41354205 const route = this . getRoute ( 'auth' ) ;
41364206 const res = await this . fetch ( `${ this . baseUrl } ${ route } /get-session` , {
41374207 headers : { Origin : this . baseUrl } ,
41384208 } ) ;
4139- return res . json ( ) ;
4209+ return normalizeSessionResponse ( await res . json ( ) ) ;
41404210 } ,
41414211
41424212 /**
@@ -4205,6 +4275,30 @@ export class ObjectStackClient {
42054275 * Refresh an authentication token
42064276 * Note: better-auth handles token refresh automatically via /get-session
42074277 * @param _refreshToken - Not used (better-auth handles refresh automatically)
4278+ *
4279+ * ## Where the credential really is (#16760)
4280+ *
4281+ * This used to assign from `data.data?.token` — a read that could never
4282+ * resolve, on a route that has no top-level `token` at all. Measured
4283+ * signed-in against a real `AuthManager` (better-auth 1.7.2) over a real
4284+ * driver, the body's top level is exactly `user` and `session`, and the
4285+ * only credential in it is `session.token`:
4286+ *
4287+ * ```
4288+ * -> 200 {"user":{…},"session":{…,"token":"<unsigned>","expiresAt":"…"}}
4289+ * ```
4290+ *
4291+ * So the old read was not a consequence of the envelope being misdeclared
4292+ * — enveloping the body does not put a token at `data.token` either. It
4293+ * named a field this route does not produce, and the method returned
4294+ * successfully having captured nothing, which is the worst way for a
4295+ * credential call to fail.
4296+ *
4297+ * ⚠️ `session.token` is the UNSIGNED spelling, while `bearer()` hands
4298+ * clients the signed `token.signature` form. Both authenticate — the
4299+ * server strips the signature on the bearer branch before it looks the
4300+ * session up (`resolveActor`) — so storing this one keeps the caller
4301+ * signed in.
42084302 */
42094303 refreshToken : async ( _refreshToken : string ) : Promise < SessionResponse > => {
42104304 const route = this . getRoute ( 'auth' ) ;
@@ -4213,9 +4307,10 @@ export class ObjectStackClient {
42134307 const res = await this . fetch ( `${ this . baseUrl } ${ route } /get-session` , {
42144308 method : 'GET'
42154309 } ) ;
4216- const data = await res . json ( ) ;
4217- if ( data . data ?. token ) {
4218- this . token = data . data . token ;
4310+ const data = normalizeSessionResponse ( await res . json ( ) ) ;
4311+ const token = data ?. data ?. session ?. token ;
4312+ if ( token ) {
4313+ this . token = token ;
42194314 }
42204315 return data ;
42214316 } ,
0 commit comments