Found while implementing #16568 (per-organization addressing for organizations.getActiveMember), which needed the caller's own user id off /get-session. Out of that card's ruled scope — it is runtime addressing on one organizations.* method — so recorded here rather than fixed there. No assignee, no label: left for triage.
The contract the SDK declares
packages/client/src/index.ts, both methods annotate their return as SessionResponse:
auth.me — GET /api/v1/auth/get-session
auth.refreshToken — the same route (the JSDoc says so: better-auth has no separate refresh endpoint)
SessionResponse is packages/spec/src/api/auth.zod.ts's SessionResponseSchema, which is BaseResponseSchema.extend({ data: { session, user, token? } }) — the REST envelope, i.e. a success flag with the payload parked under data.
What the route answers
better-auth owns the /api/v1/auth/* bytes (the plugin mounts one catch-all straight onto better-auth's handler), and better-auth does not use ObjectStack's REST envelope. Measured through a real AuthManager (better-auth 1.7.2, organization plugin, teams enabled) over a real SqlDriver (better-sqlite3 :memory:):
GET /api/v1/auth/get-session (signed in) -> 200 {"user":{...,"id":"..."},"session":{...,"activeOrganizationId":null}}
GET /api/v1/auth/get-session (anonymous) -> 200 null
Bare. No success, no data. And the anonymous answer — the literal null at 200 — is outside the declared type in a second, independent way.
Class
Declared contract not delivered (b). Two consequences, and the second is not a type-only complaint:
(await client.auth.me()).data.user type-checks and is undefined at runtime; .user, which is the real payload, does not type-check. The annotation points every caller at the wrong key.
auth.refreshToken acts on that annotation: its body reads data.data?.token and only then assigns this.token. Against the measured body that read is always undefined, so the method never captures a token — it is a no-op that returns successfully. A bearer-mode client calling it to refresh keeps whatever token it already had, with nothing said.
Not the same card as #14313
#14313 (auth.* family binding, card 2 of 3 of the #12104 family) binds the 14 methods that have no return annotation and end return res.json(), and its own text says to delete exactly that family's ledger entries and no others. These two methods are annotated already — annotated wrongly — so they are outside that enumerated set. They do sit in the same lane and the same hard-serial hot file (packages/client/src/index.ts), so scheduling them together is natural even though the scopes are distinct.
Options seen, not decided
- Bind both methods to the bare shape better-auth actually sends, and widen for the anonymous
null, the way the organizations.* family bound its wire types (a published return-type change, so contract review applies).
- Or decide that
/get-session should be enveloped like the rest of /api/v1 and fix it at the producer — a much larger call, since better-auth owns those bytes and every other /auth/* method is bare too.
Either way refreshToken's data.data?.token read is wrong today and can be corrected on its own.
Refs: #16568 (where it was measured), #14313, #12104 (family head).
Found while implementing #16568 (per-organization addressing for
organizations.getActiveMember), which needed the caller's own user id off/get-session. Out of that card's ruled scope — it is runtime addressing on oneorganizations.*method — so recorded here rather than fixed there. No assignee, no label: left for triage.The contract the SDK declares
packages/client/src/index.ts, both methods annotate their return asSessionResponse:auth.me—GET /api/v1/auth/get-sessionauth.refreshToken— the same route (the JSDoc says so: better-auth has no separate refresh endpoint)SessionResponseispackages/spec/src/api/auth.zod.ts'sSessionResponseSchema, which isBaseResponseSchema.extend({ data: { session, user, token? } })— the REST envelope, i.e. asuccessflag with the payload parked underdata.What the route answers
better-auth owns the
/api/v1/auth/*bytes (the plugin mounts one catch-all straight onto better-auth's handler), and better-auth does not use ObjectStack's REST envelope. Measured through a realAuthManager(better-auth 1.7.2, organization plugin, teams enabled) over a realSqlDriver(better-sqlite3:memory:):Bare. No
success, nodata. And the anonymous answer — the literalnullat 200 — is outside the declared type in a second, independent way.Class
Declared contract not delivered (b). Two consequences, and the second is not a type-only complaint:
(await client.auth.me()).data.usertype-checks and isundefinedat runtime;.user, which is the real payload, does not type-check. The annotation points every caller at the wrong key.auth.refreshTokenacts on that annotation: its body readsdata.data?.tokenand only then assignsthis.token. Against the measured body that read is alwaysundefined, so the method never captures a token — it is a no-op that returns successfully. A bearer-mode client calling it to refresh keeps whatever token it already had, with nothing said.Not the same card as #14313
#14313 (
auth.*family binding, card 2 of 3 of the #12104 family) binds the 14 methods that have no return annotation and endreturn res.json(), and its own text says to delete exactly that family's ledger entries and no others. These two methods are annotated already — annotated wrongly — so they are outside that enumerated set. They do sit in the same lane and the same hard-serial hot file (packages/client/src/index.ts), so scheduling them together is natural even though the scopes are distinct.Options seen, not decided
null, the way theorganizations.*family bound its wire types (a published return-type change, so contract review applies)./get-sessionshould be enveloped like the rest of/api/v1and fix it at the producer — a much larger call, since better-auth owns those bytes and every other/auth/*method is bare too.Either way
refreshToken'sdata.data?.tokenread is wrong today and can be corrected on its own.Refs: #16568 (where it was measured), #14313, #12104 (family head).