fix(client): auth.login / auth.register deliver the SessionResponse envelope they declare - #17791
Conversation
…lope they declare
`login` and `register` 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 --
`unwrapResponse` keys on exactly this -- read `undefined`.
Route both through the existing `normalizeSessionResponse` instead of a second
inline copy, extended to carry a body's own top-level `token` into `data.token`
so the credential `login` arms `this.token` from survives byte-identical. The
lift now copies only the members a body really has: `/get-session` answers
`{ user, session }` and `/sign-in|sign-up/email` answer `{ token, user }`.
`data.session` is NOT closed: measured against a real AuthManager, neither
credential route serves a session object, id or expiry in body or header, so it
is unobtainable without a second `/get-session` call. Nothing is synthesized;
#17234 stays open for that shape decision.
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
Co-authored-by: Claude <noreply@anthropic.com>
…in both departures Twelve driven cases over a real AuthManager (better-auth 1.7.3) on a real ObjectQL / SqliteWasmDriver, with the client's fetch keeping a clone of each wire Response so the bytes and the SDK return value come from one call. Closes the `success` half by PARSE against the declaration, pins the remaining issue list exhaustively so a regression reappears as an extra issue, and carries a negative control that takes `success` back out of the value the method really returned and watches the same parse report it again. Block ⑤ is the measurement for the half this does NOT close: no session in either body, none in any header, and the value reachable only on a second /get-session call. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
📓 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:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 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 a34f3de8fab3becf668276858d8ee3314d3373d7 && git checkout a34f3de8fab3becf668276858d8ee3314d3373d7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8fa3fe63d9e8dbbc507c0735d918c99db41df31d a0ab9789ef18cf475c828feb03dd3eda8d09b835 && git checkout -B drift-repro 8fa3fe63d9e8dbbc507c0735d918c99db41df31d && git merge --no-ff a0ab9789ef18cf475c828feb03dd3eda8d09b835
node scripts/docs-audit/affected-docs.mjs --json 8fa3fe63d9e8dbbc507c0735d918c99db41df31d
|
|
ACCEPT — Gate state
⭐ The docblock correction — re-examined, because the dispatch order quoted the OLD claimThe dispatch order handed the dev this sentence as a constraint, quoting the then-current docblock: " What the PR pins, at 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);
…
expect(res.data.token).toBe(wireBody.token);That is a falsifiable assertion against wire bytes, not an argument: it fires only if the header value is the body token followed by #16760 undisturbed — extracted and byte-compared, not taken on reportMethod bodies pulled by brace-walk from
The lift, read on this headif (body.user === undefined && body.session === undefined && body.token === undefined) return body …
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 } …Four properties, each load-bearing and each visible in those six lines:
Instrumentation — the parts that can fail
|
Refs #17234
Refs, not a closing keyword. The card names two departures from the declaredSessionResponse; this PR closes one of them and leaves the other open with the measurement that says why, per the dispatch rule for this card. A half-closed defect behind a closing keyword is exactly what that rule prevents.successis absentdata.sessionis absentPremise, re-measured against
origin/mainbefore writing codeAll three faces read the same as the dispatch recorded them, on
15805ea3:git log --oneline -12 origin/main -- packages/client/src/index.ts— tip7baf04ae(a docs-only OAuth change). Nothing toucheslogin/register.const data = raw && (raw.data ?? …); const normalized = data ? { ...raw, data } : raw;and neither wrotesuccess.AuthManager(better-auth 1.7.3, organization plugin) over a realObjectQLon a realSqliteWasmDriver:One correction to the card's own header, not to its finding: the family is on better-auth 1.7.3 (lifted by #17454), not 1.7.2.
The change
packages/client/src/index.tsonly.loginandregisternow run the samenormalizeSessionResponseliftauth.me/auth.refreshTokenuse, instead of a second inline copy of it. The helper is extended to carry a body's own top-leveltokenintodata.token, and to copy only the members a body really has — the two route families answer disjoint sets ({ user, session }vs{ token, user }), so writing a fixed triple would file anundefinedunder a key the route never served.Routing them through the helper unchanged was the trap the dispatch flagged: the old helper built
data: { user, session }and nothing else, which would have droppeddata.tokenand silently stoppedlogin's auto-set of the client bearer token. That is pinned now (block ④), against the wire bytes of the same call.⛔ No
packages/specedit.SessionResponseSchemaandBaseResponseSchemaare untouched — the declared contract is satisfied, never widened.Driven readings — after
Same instrument, same arrangement.
packages/client/src/auth-login-register-envelope.test.ts, 12 cases:The
successissue is gone from both. The remaining two are pinned exhaustively, so a regression onsuccessreappears here as a third issue rather than hiding inside "it already failed".data.user.imageis #17235 and is not specific to these methods.Credential fidelity (block ④), measured against the wire body of the same call, not against a remembered constant:
Negative control that can fail (block ③): the value the method really returned, with
successtaken back out, is fed to the same schema — it reports["success","data.session","data.user.image"]again. So a green reading above is a reading, not a broken assertion.Why
data.sessionis not deliveredMeasured on the same instrument (block ⑤), and this is the report the ruling asked for rather than an invention:
SessionSchemarequiresid,expiresAtanduserId.userIdis derivable fromuser.id;idandexpiresAtare nowhere on these two calls, body or header. So deliveringdata.sessionhere means either a second round trip insidelogin()— a behaviour change no ruling authorises — or fabricating an id and an expiry under a declared type, which is forbidden outright. The card stays open for that shape decision.Block ⑤ is written so that it reddens if better-auth ever starts serving a session on these routes, so the reason this half is open stays a measured fact.
Reverse verification
The fix was committed first, then mutated on disk and restored, so both legs ran from a real commit.
Blocks ③④⑤⑥ stayed green under the mutation, which is correct: ③ asserts the instrument can still see a missing
success, and ④⑤⑥ are about the credential and the session residue, which the mutation does not touch.Local verification
Every command below reports the verdict line the runner itself printed, with the exit code captured before any pipe.
pnpm --filter "@objectstack/client^..." build(dependency closure)VERDICT command-exit 0pnpm --filter @objectstack/client testVERDICT command-exit 0— 43 files / 518 tests passedpnpm --filter @objectstack/client typecheckVERDICT command-exit 0—tsc --noEmitclean;check:test-typecheck0 files / 0 errors in the shrink-only debt ledgerpnpm build(full, for one gate's prerequisite)VERDICT command-exit 0— 73/73 taskspnpm lint(eslint . --no-inline-config, repo-wide)a0ab9789e— the whole population, so no narrowing is claimed and none is neededGate families, derived from the real change set rather than from a hand-written list —
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, then reconciled with--ran:Three of the sixty were re-run because their first result was not a measurement, and are reported at their real verdict:
check:skill-examples— first runPREREQUISITE NOT MET(packages/client-react/distabsent). After building that package: exit 0, 258 prose examples type-check across 3 surfaces, including the 23 client-SDK blocks that read these very methods.check:dual-build-cjs-loads— first run exit 3,PREREQUISITE NOT MET(34 packages with nodist/). Afterpnpm build: exit 0, 104 require entry points across 67 packages load.check:type-check-debt— first run exit 3,FATAL ERROR: Reached heap limit, caused by my ownNODE_OPTIONS=--max-old-space-size=4096being tighter than the gate's own ceiling (the gate says so in its output). Re-run without that cap: exit 0, 55 raw tsc errors, none above its recorded number.⛔ None of those three was reported as a pass on its first result. An exit 3 here means nothing was measured, which is neither green nor red.
#16760's work is not disturbed
git showof both method bodies at the merge base15805ea32and atHEADis byte-identical:auth.me— 7 lines, unchanged.auth.refreshToken— 14 lines, unchanged.packages/client/src/auth-get-session-envelope.test.ts— not ingit diff --name-only, and green in the run above.The shared lift is changed, deliberately — and the ablation above shows it is load-bearing for both families, which is the point of there being one of it.
Docs
No documentation change is owed, measured rather than assumed. At
origin/main8fa3fe63:The only page that reads fields off these two calls is
content/docs/permissions/authentication.mdx, which readsresult.data.user,result.data.tokenandsession.data.user— all three preserved by this change. No published page tells a reader to reach fordata.session, so leaving that half undelivered falsifies no documentation. ⛔ Nothing undercontent/docs/releases/is touched: that tree is release-owned.Clause-②: no
This pulls an implementation back to a contract that is already declared. No published surface widens, no accepted set widens, and nothing in
packages/specis edited — the triage ruling on this card says the same in its own words.Acceptance notes
Found while measuring, not filed and not fixed here:
normalizeSessionResponsedocblock stated that the tokenloginputs atdata.tokenis the SIGNEDtoken.signatureform. Measured, it is the unsigned one: the response BODY'stokenand thesession.tokena following/get-sessionserves are the same string, while the signed form is whatbearer()publishes in theset-auth-tokenheader. Corrected in this PR — same file, same docblock, same subject, as the dispatch order directs — and the corrected reading is now pinned by a case in block ④ so it cannot rot again. The rule the sentence justified (never synthesizedata.tokenfrom a session) is unchanged and now rests on the right ground.auth.login"has carried the same lift … since long before this card". That was loose — login's inline copy filleddataand neversuccess, which is this defect. After this PR it is literally the same lift, and the sentence is rewritten to say so.login's ownif (!res.ok) { … throw … }block is unreachable.ObjectStackClient.fetchalready throws on every non-2xx beforelogincan inspectres.ok(observed directly: aSELF_REGISTRATION_CLOSEDsign-up threw fromfetch, never reaching the caller's branch). Dead code, not a defect — left untouched. Successor: whoever converges the SDK's two error envelopes (Envelope drift is not just service-storage: four more route modules emit bare bodies, two of them the pre-#3675{ error: '<string>' }#3843 is the line that would reach it).register()against the sameAuthManageris refused withSELF_REGISTRATION_CLOSED. Correct behaviour, and a real trap for anyone writing a driven auth test — recorded in the suite's own comments. Successor: the next author writing a multi-user driven auth test inpackages/client.auth.me()returns the literalnullfor an anonymous caller, which no value of its declaredSessionResponsecan express #17238 (the anonymous-caller case) is not addressed here and is out of scope: different defect,domain:serviceslane, unruled.SessionUser.imageis declaredz.string().optional(), but every/auth/*session route serves"image": null— no real session body parses asSessionResponse#17235 (data.user.imageserved asnull) is likewise out of scope and stays pinned as residue.Authored by an agent session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
Generated by Claude Code