diff --git a/.changeset/chilled-eagles-arrive.md b/.changeset/chilled-eagles-arrive.md new file mode 100644 index 0000000000..45c23504a2 --- /dev/null +++ b/.changeset/chilled-eagles-arrive.md @@ -0,0 +1,20 @@ +--- +"@objectstack/client": patch +--- + +`auth.login` and `auth.register` now deliver the `SessionResponse` envelope they declare. + +Both methods 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 — `ObjectStackClient.unwrapResponse` keys on exactly this — read `undefined` rather than +`true` or `false`. They now run the same lift `auth.me` / `auth.refreshToken` use, so the family +cannot deliver two different envelopes again. + +The credential is unchanged: `data.token` is still the token the route puts in the response body, +byte-identical, and `login` / `register` still arm the client's bearer token from it. + +Known residue, unchanged by this release: `data.session` is still absent from what these two +methods return. `POST /sign-in/email` and `POST /sign-up/email` serve no session object, id or +expiry in the body or in any header, so the member is not obtainable without a second +`GET /get-session` call — read it from `auth.me()`. Nothing is synthesized in its place. diff --git a/packages/client/src/auth-login-register-envelope.test.ts b/packages/client/src/auth-login-register-envelope.test.ts new file mode 100644 index 0000000000..848a3afd40 --- /dev/null +++ b/packages/client/src/auth-login-register-envelope.test.ts @@ -0,0 +1,374 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #17234 — `auth.login` and `auth.register` annotate their return as +// `SessionResponse` (`BaseResponseSchema.extend(…)`, so `success` is a REQUIRED +// boolean) and delivered a body that never carried `success` at all. Two +// departures were measured on the card; this suite closes one and PINS the +// other as a measurement rather than letting it be invented away. +// +// ## Why the server here is the real one +// +// Every claim below is a claim about BYTES BETTER-AUTH WRITES — whether +// `/sign-in/email` and `/sign-up/email` carry a session anywhere, and which +// spelling of the session credential reaches `data.token`. A hand-written +// double would let this suite certify the SDK against a body this file +// invented, which is exactly how the declared shape and the real one drifted +// apart in the first place. So the arrangement is a real `AuthManager` +// (better-auth 1.7.3, organization plugin on by its own default) over a real +// `ObjectQL` on a real `SqliteWasmDriver`, with an `ObjectStackClient` whose +// `fetch` hands the `Request` straight to `AuthManager.handleRequest`: +// everything above that call is the SDK's real request path, everything below +// it is better-auth's real pipeline. The client's `fetch` also keeps a CLONE of +// each `Response`, so the wire bytes and the SDK's return value come from ONE +// call rather than from two that could disagree. +// +// ## What each block is for +// +// - `① the declared envelope is delivered` — the defect proper. The judge is a +// PARSE against the declaration, not a key spot-check. +// - `② the residue is exhaustive` — `SessionResponseSchema` still does not +// parse, for two reasons that are NOT this card's `success`. Pinned as the +// complete issue list so a regression on `success` shows up here as an extra +// issue instead of hiding inside "it already failed". +// - `③ the instrument can still fail` — the negative control. The same parse, +// on the same returned value with `success` taken back out, must report +// `success` again. Without it, a green ① could equally mean the assertion +// broke. +// - `④ the credential survives byte-identical` — the regression this fix could +// most easily have caused. `data.token` is the body's own token, and +// `client.token` is still armed from it. +// - `⑤ data.session is not obtainable on these routes` — the card's second +// departure, left OPEN deliberately. This block is the measurement that says +// why: no session in the body, none in the headers, and the value only +// appears on a SECOND call. If better-auth ever starts serving one, this +// block reddens and #17234 can be closed properly. +// - `⑥ the raw keys survive the lift` — callers were pushed onto `.user` / +// `.token` by the very misdeclaration this card fixes. + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { AuthManager } from '@objectstack/plugin-auth'; +import * as identityObjects from '@objectstack/platform-objects/identity'; +import { BaseResponseSchema, SessionResponseSchema, SessionSchema } from '@objectstack/spec/api'; +import { ObjectStackClient } from './index'; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const ORIGIN = 'http://localhost:3000'; +const PASSWORD = 'S3cure!Passw0rd-17234'; + +/** + * The identity objects better-auth's ObjectQL adapter reads and writes on the + * routes under test. Read out of `@objectstack/platform-objects/identity` BY + * SHAPE rather than transcribed, for the reason + * `auth-get-session-envelope.test.ts` states: a hand-copied list is a second + * declaration of the same set, drifting silently the day the plugin registers + * one more. + */ +const IDENTITY_OBJECTS = Object.values( + identityObjects as unknown as Record, +).filter( + (o): o is Record => + !!o && + typeof o === 'object' && + typeof (o as { name?: unknown }).name === 'string' && + typeof (o as { fields?: unknown }).fields === 'object', +); + +const engines: ObjectQL[] = []; + +const makeEngine = async (): Promise => { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as never, true); + await engine.init(); + for (const object of IDENTITY_OBJECTS) { + engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + } + await engine.syncSchemas(); + return engine; +}; + +let emailSeq = 0; + +/** + * A real manager plus a client factory that records the wire `Response` of + * every call it makes. + * + * ⚠️ One manager per scenario, deliberately: the FIRST sign-up on a fresh + * environment provisions the owner and the audience posture then closes + * self-registration (`SELF_REGISTRATION_CLOSED`), so a second `register()` on + * the same manager is refused before it ever reaches the code under test. + */ +const scenario = async () => { + const engine = await makeEngine(); + const manager = new AuthManager({ + secret: SECRET, + baseUrl: ORIGIN, + dataEngine: engine, + } as never); + const wire: Response[] = []; + const mkClient = (): ObjectStackClient => + new ObjectStackClient({ + baseUrl: ORIGIN, + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const res = await manager.handleRequest(new Request(String(input), init)); + // Clone BEFORE the SDK reads it, so both halves of every assertion + // below come from the same single call. + wire.push(res.clone()); + return res; + }, + }); + return { + manager, + mkClient, + email: `envelope-17234-${++emailSeq}-${Date.now()}@example.com`, + /** The `Response` of the most recent call this scenario's clients made. */ + lastWire: () => wire[wire.length - 1], + }; +}; + +/** The credential the client is holding right now. */ +const storedToken = (client: ObjectStackClient): string | undefined => + (client as unknown as { token?: string }).token; + +/** The raw wire keys the lift keeps alongside `data`. */ +type RawKeys = { + success?: unknown; + redirect?: unknown; + token?: unknown; + user?: { id?: string }; + data?: { token?: unknown; session?: unknown; user?: { id?: string } }; +}; + +/** A registered first user, and both halves of that one call. */ +const registered = async () => { + const s = await scenario(); + const client = s.mkClient(); + const res = await client.auth.register({ + email: s.email, + password: PASSWORD, + name: 'Envelope 17234', + }); + const wire = s.lastWire(); + return { ...s, client, res, wireRes: wire, wireBody: (await wire.json()) as RawKeys }; +}; + +/** …and a SECOND client that signed that user back in. */ +const signedIn = async () => { + const base = await registered(); + const client = base.mkClient(); + const res = await client.auth.login({ email: base.email, password: PASSWORD }); + const wire = base.lastWire(); + return { ...base, client, res, wireRes: wire, wireBody: (await wire.json()) as RawKeys }; +}; + +/** WHO a credential resolves to, asked through better-auth's own API. */ +const principalFor = async (manager: AuthManager, token: string | undefined) => { + const auth = (await manager.getAuthInstance()) as unknown as { + api: { getSession(a: { headers: Headers }): Promise }; + }; + const session = (await auth.api + .getSession({ headers: new Headers({ authorization: `Bearer ${token}` }) }) + .catch(() => null)) as { user?: { id?: string } } | null; + return session?.user?.id ?? null; +}; + +afterEach(async () => { + while (engines.length) { + const engine = engines.pop(); + await (engine as unknown as { close?: () => Promise })?.close?.().catch(() => {}); + } +}); + +describe('[#17234] auth.login / auth.register deliver the SessionResponse envelope they declare', () => { + describe('① the declared envelope is delivered', () => { + it('register() parses as the declared envelope, with the payload under `data`', async () => { + const { res } = await registered(); + + // The decisive assertion: the DECLARATION judges the body. On the defect + // the method returned `{ …raw, data }` with no `success` at all, so this + // parse was red. + const envelope = BaseResponseSchema.safeParse(res); + expect( + envelope.success, + `register() did not parse as the declared envelope: ${JSON.stringify(envelope.error?.issues)}`, + ).toBe(true); + expect(res.success).toBe(true); + + expect(res.data).toBeTruthy(); + expect(typeof res.data.user?.id).toBe('string'); + expect(res.data.user?.email).toContain('@'); + }); + + it('login() parses as the declared envelope, with the payload under `data`', async () => { + const { res } = await signedIn(); + + const envelope = BaseResponseSchema.safeParse(res); + expect( + envelope.success, + `login() did not parse as the declared envelope: ${JSON.stringify(envelope.error?.issues)}`, + ).toBe(true); + expect(res.success).toBe(true); + + expect(res.data).toBeTruthy(); + expect(typeof res.data.user?.id).toBe('string'); + expect(res.data.user?.email).toContain('@'); + }); + }); + + describe('② the residue is exhaustive, and `success` is not in it', () => { + // Two issues remain on the FULL declared type, and neither is this card's: + // + // data.session — block ⑤: these routes serve none. #17234 stays open. + // data.user.image — `SessionUserSchema.image` is `z.string().optional()`, + // which does not admit `null`, and better-auth serves + // `"image": null` for a user who never set one. Filed + // as #17235, and NOT specific to these two methods. + // + // Pinned as the EXHAUSTIVE list rather than as "it still fails": if + // `success` ever regresses it reappears here as a third issue and these + // cases redden. It is the residue's tripwire, not an acceptance of it. + const RESIDUE = ['data.session', 'data.user.image']; + + it('register() reports exactly the two issues that are not `success`', async () => { + const { res } = await registered(); + const issues = SessionResponseSchema.safeParse(res).error?.issues ?? []; + expect(issues.map((i) => i.path.join('.'))).toEqual(RESIDUE); + }); + + it('login() reports exactly the two issues that are not `success`', async () => { + const { res } = await signedIn(); + const issues = SessionResponseSchema.safeParse(res).error?.issues ?? []; + expect(issues.map((i) => i.path.join('.'))).toEqual(RESIDUE); + }); + }); + + describe('③ the instrument can still fail — negative control', () => { + it('the same parse reports `success` again once it is taken back out', async () => { + const { res } = await signedIn(); + + // Not a fabricated body: the value the method REALLY returned, with the + // one member this card added removed again. So a green ① cannot be a + // broken assertion or a schema that stopped checking — this is the same + // schema, the same call, the same instrument, reporting the defect. + const { success: _dropped, ...withoutSuccess } = res as unknown as Record & { + success?: unknown; + }; + const issues = SessionResponseSchema.safeParse(withoutSuccess).error?.issues ?? []; + expect(issues.map((i) => i.path.join('.'))).toEqual([ + 'success', + 'data.session', + 'data.user.image', + ]); + }); + }); + + describe('④ the credential survives the lift byte-identical', () => { + it('login() keeps the body token at `data.token` and still arms `client.token`', async () => { + const { manager, client, res, wireBody } = await signedIn(); + + // Byte-identity against the WIRE, on the same call — not against a + // remembered constant. Routing these methods through the shared lift is + // exactly the change that could have dropped this member (the lift used + // to build `data: { user, session }` and nothing else), and dropping it + // would silently stop the auto-set below on the SDK's most-used path. + expect(typeof wireBody.token).toBe('string'); + expect(res.data.token).toBe(wireBody.token); + + // The auto-set still happens, and it is the same string. + expect(storedToken(client)).toBe(wireBody.token); + expect(storedToken(client)).toBe(res.data.token); + + // …and it is a WORKING credential, not merely a non-empty string. + expect(await principalFor(manager, storedToken(client))).toBe(res.data.user?.id); + // The control that must NOT resolve, so "resolves to the user" is a real + // reading and not something this arrangement answers for any input. + expect(await principalFor(manager, 'not-the-session-token-17234')).toBeNull(); + }); + + it('register() keeps the body token at `data.token` and still arms `client.token`', async () => { + const { client, res, wireBody } = await registered(); + expect(typeof wireBody.token).toBe('string'); + expect(res.data.token).toBe(wireBody.token); + expect(storedToken(client)).toBe(wireBody.token); + }); + + it('the token at `data.token` is the UNSIGNED spelling the body carries', async () => { + const { res, wireRes, wireBody } = await signedIn(); + + // The two spellings of one session credential, measured on ONE response: + // the body's `token` is UNSIGNED, and `bearer()` publishes the SIGNED + // `token.signature` form in the `set-auth-token` HEADER. The split is by + // CARRIER, not by method — which is what makes "never synthesize + // `data.token` from `session.token`" a rule about inventing a member on a + // route that served none, rather than about two different credentials. + const signed = wireRes.headers.get('set-auth-token') ?? ''; + expect(signed, 'sign-in emitted no set-auth-token — this control cannot fire').toBeTruthy(); + expect(signed).not.toBe(wireBody.token); + expect(signed.startsWith(`${String(wireBody.token)}.`)).toBe(true); + // `data.token` is the body half, untouched. + expect(res.data.token).toBe(wireBody.token); + }); + }); + + describe('⑤ `data.session` is not obtainable on these routes — the open half of #17234', () => { + it('neither route carries a session in its body', async () => { + const reg = await registered(); + expect(Object.keys(reg.wireBody as object)).toEqual(['token', 'user']); + expect(reg.res.data.session).toBeUndefined(); + + const log = await signedIn(); + expect(Object.keys(log.wireBody as object)).toEqual(['redirect', 'token', 'user']); + expect(log.res.data.session).toBeUndefined(); + }); + + it('nor in any response header — the only credential carrier is a bare token', async () => { + const { wireRes } = await signedIn(); + const names = [...wireRes.headers.keys()]; + // Nothing header-side is named for a session payload… + expect(names.filter((n) => /session/i.test(n))).toEqual([]); + // …and the one header that does carry a credential carries a STRING, not + // a session object: no `id`, no `expiresAt`, nothing `SessionSchema` + // would accept. So deriving `data.session` from the headers is not an + // option that was overlooked. + const signed = wireRes.headers.get('set-auth-token') ?? ''; + expect(signed).toBeTruthy(); + expect(signed.trimStart().startsWith('{')).toBe(false); + expect(SessionSchema.safeParse(signed).success).toBe(false); + }); + + it('the session exists only one NETWORK CALL later, via /get-session', async () => { + const { client, res } = await signedIn(); + + // The positive leg, and the whole reason this card stays open: the value + // the declared type names is real and reachable — just not on this route. + // Satisfying `data.session` here would mean either a second round trip + // inside `login()` (a behaviour change no ruling has authorised) or a + // fabricated id and expiry (forbidden outright). + const me = await client.auth.me(); + const session = SessionSchema.safeParse(me.data.session); + expect( + session.success, + `/get-session did not serve a parseable session: ${JSON.stringify(session.error?.issues)}`, + ).toBe(true); + expect(me.data.session?.userId).toBe(res.data.user?.id); + // …and it really is absent from the sign-in answer, so the two readings + // above are about one session and not two arrangements. + expect(res.data.session).toBeUndefined(); + }); + }); + + describe('⑥ the raw keys survive the lift', () => { + it('keeps `.user` / `.token` — and login keeps `.redirect` — alongside `data`', async () => { + const { res } = await signedIn(); + const raw = res as unknown as RawKeys; + // Callers were pushed onto the raw keys by the very misdeclaration this + // card fixes. Buying the declared shape by breaking them would trade one + // silent breakage for another. + expect(raw.user?.id).toBe(res.data.user?.id); + expect(raw.token).toBe(res.data.token); + expect(raw.redirect).toBe(false); + }); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 4a2030cf86..a4d0655f8f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1460,24 +1460,26 @@ const DEFAULT_META_PREFIX = '/meta'; const SET_AUTH_TOKEN_HEADER = 'set-auth-token'; /** - * Lift better-auth's bare `/get-session` answer into the `SessionResponse` - * envelope the two methods that call that route declare (#16760). + * Lift better-auth's bare answer into the `SessionResponse` envelope the four + * `auth.*` methods that read these routes declare (#16760, #17234). * * `/api/v1/auth/*` is better-auth's own byte stream — plugin-auth mounts one * catch-all straight onto its handler — and better-auth does not use * ObjectStack's REST envelope. Measured against a real `AuthManager` - * (better-auth 1.7.2, organization plugin) over a real driver: + * (better-auth 1.7.3, organization plugin) over a real `ObjectQL` on a real + * `SqliteWasmDriver`, these are the three bodies this helper is handed: * * ``` - * GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}} - * GET /api/v1/auth/get-session (anonymous) -> 200 null + * GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}} + * GET /api/v1/auth/get-session (anonymous) -> 200 null + * POST /api/v1/auth/sign-up/email -> 200 {"token":"…","user":{…}} + * POST /api/v1/auth/sign-in/email -> 200 {"redirect":false,"token":"…","user":{…}} * ``` * - * `auth.login` has carried the same lift for `/sign-in/email`'s own bare - * `{ token, user }` since long before this card; `auth.me` and - * `auth.refreshToken` never got it, so every caller writing to the declared - * `data.user` read `undefined` while the real payload sat on `.user` — which - * did not type-check. + * The two families carry DISJOINT payload members — `/get-session` has the + * session and no top-level token, the two credential routes have the token and + * no session — so the lift copies the members a body actually has instead of + * writing a fixed triple. * * Three properties this deliberately has: * @@ -1486,36 +1488,59 @@ const SET_AUTH_TOKEN_HEADER = 'set-auth-token'; * REQUIRED boolean, so a body carrying `data` alone still does not parse as * the type the method advertises. A producer that sent its own `success` * keeps it — the spread below runs after the default. - * - **The raw keys are kept, not replaced.** `{ …body, data }`, exactly as - * `login` does. `.user` is the read the field has been using all along while - * the declared `.data.user` was `undefined`, and dropping it would break - * those callers in order to fix a type they were already working around. - * - **`data.token` is NOT synthesized from `session.token`.** The declared key - * is optional, and the two spellings are not one string: `session.token` is - * the UNSIGNED session token, while the `token` `login` puts there is the - * SIGNED `token.signature` form `bearer()` hands out. Both authenticate, so - * populating it would file two different credentials under one key depending - * on which method produced the body. + * - **The raw keys are kept, not replaced.** `{ …body, data }`. `.user` is the + * read callers have been using all along while the declared `.data.user` was + * `undefined`, and dropping it would break those callers in order to fix a + * type they were already working around. + * - **`data.token` is carried from the body's OWN top-level `token`, and is + * never synthesized from `session.token`.** `login` / `register` are handed a + * credential in the body and put it here, which is where `login` reads it + * back from to arm `this.token`; `/get-session` is handed none and gets no + * `data.token` at all. The two spellings a session has are the UNSIGNED + * token and the SIGNED `token.signature` form, and the split is by CARRIER, + * not by method: measured on one sign-in, the response BODY's `token` and the + * `session.token` a following `/get-session` serves are the same unsigned + * string, while the SIGNED form is the one `bearer()` publishes in the + * `set-auth-token` RESPONSE HEADER. So synthesizing `data.token` from a + * session would not change which credential lands here — it would invent a + * `token` on a route that served none, which is a different lie. * - * The `body &&` guard is what carries the anonymous answer: `null` is falsy and + * ⚠️ **Known residue — `data.session` on `/sign-in|sign-up/email` (#17234).** + * Those two routes serve no session object and no session id or expiry + * anywhere, body or header, so `login` and `register` return a `data` with no + * `session` and still do not parse as the full declared `SessionResponse`. The + * only place a session is obtainable is a SECOND call to `/get-session` + * (`auth.me`), and manufacturing one here would put a fabricated id and expiry + * under a declared type — the card stays open for that shape decision rather + * than being closed by an invention. + * + * The `!body` guard is what carries the anonymous answer: `null` is falsy and * is returned untouched rather than wrapped into a signed-in-looking envelope - * that no session backs. That answer stays outside `SessionResponse`; closing - * it needs the published return annotation to widen, which is a different card. + * that no session backs. That answer stays outside `SessionResponse`, and + * closing it needs the published return annotation to widen, which is a + * different card. */ const normalizeSessionResponse = (raw: unknown): SessionResponse => { - const body = raw as { user?: unknown; session?: unknown; data?: unknown } | null; + const body = raw as + | { user?: unknown; session?: unknown; token?: unknown; data?: unknown } + | null; // Already enveloped, or nothing recognisable to lift: hand it back untouched // rather than inventing a `data` this response never carried. if (!body || typeof body !== 'object') return body as unknown as SessionResponse; if (body.data !== undefined) return body as unknown as SessionResponse; - if (body.user === undefined && body.session === undefined) { + if (body.user === undefined && body.session === undefined && body.token === undefined) { return body as unknown as SessionResponse; } - return { - success: true, - ...body, - data: { user: body.user, session: body.session }, - } as unknown as SessionResponse; + // Only the members this body really carries. The two route families answer + // disjoint sets (`{ user, session }` vs `{ token, user }`), so writing all + // three unconditionally would file an `undefined` under a key the route never + // served — and for `token` that is the difference between "this body carries + // no credential" and "this SDK dropped the credential it was handed". + const data: { user?: unknown; session?: unknown; token?: unknown } = {}; + if (body.user !== undefined) data.user = body.user; + if (body.session !== undefined) data.session = body.session; + if (body.token !== undefined) data.token = body.token; + return { success: true, ...body, data } as unknown as SessionResponse; }; export class ObjectStackClient { @@ -4268,6 +4293,19 @@ export class ObjectStackClient { /** * Login with email and password * Uses better-auth endpoint: POST /sign-in/email + * + * The route answers bare (`{ redirect, token, user }`), so the answer is + * lifted into the declared `SessionResponse` envelope by + * {@link normalizeSessionResponse} — one lift shared with `auth.me` / + * `auth.refreshToken`, so the family cannot deliver two envelopes again + * (#17234). The credential the route hands back stays at `data.token` + * byte-identical and is what arms `this.token` below. + * + * ⚠️ `data.session` is the one member of the declared type this route + * cannot deliver: `/sign-in/email` serves no session object, and no session + * id or expiry reaches the client on this call at all. Read the session + * from a following `auth.me()` (`GET /get-session`); nothing is fabricated + * here. #17234 stays open for that shape decision. */ login: async (request: LoginRequest): Promise => { const route = this.getRoute('auth'); @@ -4288,10 +4326,12 @@ export class ObjectStackClient { err.status = res.status; throw err; } - // Normalize: better-auth returns `{ token, user }` at top level, - // but our SessionResponse shape wraps them in `data`. - const data = raw && (raw.data ?? (raw.token || raw.user ? { token: raw.token, user: raw.user } : undefined)); - const normalized = data ? { ...raw, data } : raw; + // Normalize: better-auth returns `{ redirect, token, user }` at top + // level, but the declared `SessionResponse` wraps the payload in `data` + // AND requires `success` — which the inline lift this replaced never + // wrote, so neither method delivered the type it advertises (#17234). + // One lift for both route families, so the two cannot drift again. + const normalized = normalizeSessionResponse(raw); // Auto-set token if present in response if (normalized.data?.token) { this.token = normalized.data.token; @@ -4319,8 +4359,8 @@ export class ObjectStackClient { * * The route answers bare (`{ user, session }`), so the answer is lifted * into the declared `SessionResponse` envelope by - * {@link normalizeSessionResponse} — the same lift `login` has always - * carried. Read the payload off `data.user` / `data.session`; the raw + * {@link normalizeSessionResponse} — since #17234 the same lift `login` + * and `register` run. Read the payload off `data.user` / `data.session`; the raw * `.user` / `.session` keys are kept alongside for callers written against * the wire while the declared shape was unreachable. * @@ -4339,6 +4379,14 @@ export class ObjectStackClient { /** * Register a new user account * Uses better-auth endpoint: POST /sign-up/email + * + * The route answers bare (`{ token, user }`) and is lifted into the + * declared `SessionResponse` envelope by the same + * {@link normalizeSessionResponse} `login` runs (#17234); the credential + * stays at `data.token` and arms `this.token`. + * + * ⚠️ `data.session` is undelivered here for the same measured reason as on + * `login`: `/sign-up/email` serves no session object. See that method. */ register: async (request: RegisterRequest): Promise => { const route = this.getRoute('auth'); @@ -4347,9 +4395,10 @@ export class ObjectStackClient { headers: { Origin: this.baseUrl }, body: JSON.stringify(request) }); - const raw = await res.json(); - const data = raw && (raw.data ?? (raw.token || raw.user ? { token: raw.token, user: raw.user } : undefined)); - const normalized = data ? { ...raw, data } : raw; + // Same lift as `login`, for the same reason (#17234): `/sign-up/email` + // answers the bare `{ token, user }` and the declared `SessionResponse` + // requires `success` as well as `data`. + const normalized = normalizeSessionResponse(await res.json()); if (normalized.data?.token) { this.token = normalized.data.token; }