fix(client): auth.me / auth.refreshToken deliver the SessionResponse envelope they declare, and refreshToken reads session.token - #17237
Conversation
… envelope Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
…se envelope both methods declare Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e2fe2f76e4d8b0b02118d9ebf7c183eef4d6198d && git checkout e2fe2f76e4d8b0b02118d9ebf7c183eef4d6198d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 559e531a94dab4938d1ab54fd6631acbc5303d61 c4cbdb0db6c01602bc1bd0e7df924a8ae27f443f && git checkout -B drift-repro 559e531a94dab4938d1ab54fd6631acbc5303d61 && git merge --no-ff c4cbdb0db6c01602bc1bd0e7df924a8ae27f443f
node scripts/docs-audit/affected-docs.mjs --json 559e531a94dab4938d1ab54fd6631acbc5303d61
|
Fixes #16760
Clause-②: no
auth.meandauth.refreshTokenannotate their return asSessionResponse— ObjectStack's REST{ success, data }envelope — forGET /api/v1/auth/get-session, a route better-auth owns and answers bare. This lifts the bare answer into the envelope both methods declare, withSessionResponseand both published return annotations unchanged, and correctsrefreshTokento read the token the route actually serves.This is triage's route 3: the lift
auth.loginhas always carried, applied to the two methods that never got it. Not route 1 (rebinding to the wire shape) and not route 2 (enveloping/auth/*at the producer) — neither was needed, and neither is in this diff.The measurement this card turned on
The card claimed
refreshToken's brokendata.data?.tokenread was a consequence of the envelope misdeclaration. Triage judged that causal claim wrong but could not finish the reading, because the card's pasted body elides"session":{...}. That measurement is here, driven signed-in against a realAuthManager(better-auth 1.7.2, organization plugin on by its own default) over a realObjectQLon a realSqliteWasmDriver:session.tokenexists, so the dispatch's branch 2 applies:refreshTokenchanges its read and genuinely captures a credential. Triage's correction stands unchanged — there is no top-leveltoken, so enveloping the body would not have put one atdata.tokeneither. The old read named a field this route does not produce at any nesting, which is why fixing the shape alone would have left the method exactly as inert as it was.Two further facts the measurement turned up, neither of which triage had:
session.tokenis the UNSIGNED spelling;bearer()hands clients the signedtoken.signatureform. Both authenticate — plugin-auth'sresolveActorstrips the signature on the bearer branch, and the measurement confirms both resolve to the same principal — so storing it keeps the caller signed in. Case ④ asserts exactly that, so the spelling swap is proven safe rather than merely observed./get-sessionneither rotates the token nor movesexpiresAtacross two calls seconds apart.AuthManagerconfigures better-auth'ssession.updateAgeat a 1-day default, so a brand-new session is exactly the case where nothing is expected to move. The reading is inconclusive by construction and no claim is built on it.What changed
normalizeSessionResponse(module-private, no new export) lifts a bare{ user, session }into{ success: true, ...body, data: { user, session } }.successis filled, not onlydata.SessionResponseSchemaisBaseResponseSchema.extend(...)and that base declaressuccessas a required boolean, so a body carryingdataalone still does not parse as the declared type. This is what makes the fix closeable: without it the card's own class — declared contract not delivered — stays open onme..user/.sessionkeys are kept. They are the read the field was pushed onto while the declared shape was unreachable; dropping them would trade one silent breakage for another.data.tokenis deliberately NOT synthesized fromsession.token. The declared key is optional, and the two spellings are not one string —loginputs the signed form there. Populating it would file two different credentials under one key depending on which method produced the body.refreshTokenreadsdata.session.token.Tests
packages/client/src/auth-get-session-envelope.test.ts— a realAuthManagerover a real driver, transport stood in only so the client'sfetchhands theRequesttomanager.handleRequest. Five blocks: the envelope parses against the declared schema; the raw keys survive; the anonymousnullpasses through;refreshTokencaptures a credential that resolves to the right principal; and a negative control thatdata.tokenis absent before and after the lift, so a regression to the old read cannot pass by accident.The unit fixture in
client.test.tswas replaced, not adjusted: it served{ data: { token } }, a body/get-sessionhas never produced, so it pinned the very read that made the method a no-op. A fixture modelling the misdeclaration cannot witness the fix.Ablation — direction predicted in writing before any leg ran
success: truefrom the liftmetoreturn res.json()me()case reddensdata.data?.tokenreadEach leg proved the mutation reached disk by occurrence count and a blob hash differing from HEAD's, and each restore by a blob equal to HEAD's plus an empty
git diff HEAD; the whole script ran undertrap ... EXIT INT TERMwith absolute paths, and the finalgit status --porcelainwas empty.nullwith or without the lift. It pins the residue, not the fix, and is named that way in the file.A third correction, on the first attempt at case ④: the firing control was initially a deliberately-wrong seeded token. That unauthenticates the client, so
/get-sessionanswersnullfor the anonymous reason and the case fails against a correct implementation. It was rebuilt on the signed/unsigned spelling difference, which is a real measured difference that moves only when the capture works.Type-level face
The card's first consequence is a type-level one, so the pin is a parse against the declared schema, not a key spot-check.
me's annotation does not move in this diff, so the runtime assertions genuinely redden on the defect (A1/A2 above measure that they do). What could not have caught this: thesession.data.userexample incontent/docs/permissions/authentication.mdxis not markedos:check, and even marked it would type-check both before and after — the annotation was right all along, the body was wrong.Evidence
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, 58 commands, reconciled with--ran: 58 derived, 58 run, 0 NOT-MEASURED, 0 UNRUN. All 58 exit 0. Two needed a build first and exited PREREQUISITE-NOT-MET (check:dual-build-cjs-loadsexit 3,check:skill-examplesexit 1, both naming an unbuiltdist); afterpnpm --filter @objectstack/client buildboth measured green — 104 require entry points across 67 packages, and 258 prose examples type-checking. ⛔ Neither was read as a pass while unmeasured.eslint . --no-inline-configatc4cbdb0db6, exit 0, zero output lines.@objectstack/client: 40 files / 486 tests pass;typecheckclean, andtsc -p tsconfig.test.json --listFilesconfirmsindex.ts,client.test.tsand the new suite are all in the compiled program (not merely assumed covered).packages/cli'swhoamireadsauth.me()through aresponse.data || responsefallback written for this very defect; it now takes thedatabranch.@objectstack/cliunit tier: 190 files / 2635 tests pass. The integration tier is declared to CI — this diff touches no spawn entry point.$?, never through a pipe./pulls/{n}/files: no open PR touchespackages/client/src/index.ts. Positive controls fire on the same predicate —packages/client/package.jsonnames chore: version packages #17076,packages/spec/src/api/contract.zod.tsnames feat(spec): ADR-0112 error envelope gains a producer-siderefusaldeclaration so a deliberate 5xx refusal keeps its caller-authored message (#16335) #17090.content/docs/permissions/authentication.mdxalready documentedsession.data.user, which wasundefinedbefore this change. That page is independent evidence that the declared envelope was the intended contract, i.e. that route 3 fixes the body rather than the promise. Checked by hand for the two blind spots the tool has: the prose class (sso.mdx,error-catalog.mdx— general envelope prose, untouched;references/**is auto-generated from apackages/specthis diff does not touch) and thedocs/tree the tool never walks (only HTTP-level QA checklist rows, which already record better-auth's 200-with-null convention). ⛔ No edit undercontent/docs/releases/**.验收备注
Out of scope, filed rather than fixed here:
auth.login/auth.registernormalize intodatabut never setsuccess— neither satisfies theSessionResponsethey declare #17234 —auth.login/auth.registernormalize intodatabut never setsuccess. This is the finding the dispatch asked me to look for, and it is larger than triage suspected: measured, both failSessionResponseSchemaon three counts —successabsent,data.sessionabsent (the normalization builds{ token, user }and those routes serve no session object at all), anddata.user.image. Thedata.sessionhalf carries a real decision — satisfying the declared type there means either fetching the session or changing the declaration — which is why it is not a rider.SessionUser.imageis declaredz.string().optional(), but every/auth/*session route serves"image": null— no real session body parses asSessionResponse#17235 —SessionUser.imageis declaredz.string().optional(), which does not admitnull, while every session route serves"image": nullfor a user with no avatar. This is the entire residue onme()after this PR: one key, on every session body the platform produces. Case ①b pins it as an exhaustive issue list so it cannot silently grow, and that case is the row to delete whenSessionUser.imageis declaredz.string().optional(), but every/auth/*session route serves"image": null— no real session body parses asSessionResponse#17235 lands.Noted, not filed:
nullremains outsideSessionResponseand this PR does not close it. Closing it requires the published return annotation to widen (SessionResponse | null) — route 1,Clause-②: yes, contract-review tier — so it is left as a measured, pinned residue rather than re-declared here. Carrier: this isauth.*family work and client SDKauth.*family: bind the 14return res.json()methods (auth 7 · sessions 3 · twoFactor 3 · accounts.unlink 1) to their better-auth wire shapes — #12104 family card 2 of 3 #14313 is the queued same-file family card. Raised in the report as an open question rather than filed, because the disposition is a declaration decision, not a defect to grade.refreshToken's JSDoc says "better-auth handles token refresh automatically via /get-session". Whether that holds pastupdateAgeis unmeasured here, for the reason given above; it is not asserted either way. Carrier: whoever takes client SDKauth.*family: bind the 14return res.json()methods (auth 7 · sessions 3 · twoFactor 3 · accounts.unlink 1) to their better-auth wire shapes — #12104 family card 2 of 3 #14313 or client SDKauth.login/auth.registernormalize intodatabut never setsuccess— neither satisfies theSessionResponsethey declare #17234 on this file.user/sessionbesidesuccess/data, whichenvelopeViolations(a stricter check thanBaseResponseSchema, and one nothing applies to SDK returns) would count as extra top-level keys. Deliberate —loginandregisterdo the same, and removing them breaks the documented workaround. Carrier: client SDKauth.login/auth.registernormalize intodatabut never setsuccess— neither satisfies theSessionResponsethey declare #17234, which touches the same normalization shape.Generated by Claude Code