Skip to content

Commit 33888b3

Browse files
committed
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
1 parent bccf311 commit 33888b3

1 file changed

Lines changed: 99 additions & 4 deletions

File tree

packages/client/src/index.ts

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,6 +1459,65 @@ const DEFAULT_META_PREFIX = '/meta';
14591459
*/
14601460
const 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+
14621521
export 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

Comments
 (0)