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
|
…l three 'not addressed here' clauses had been answered (objectstack-ai#18765) Fixes objectstack-ai#18652 `.changeset/client-get-session-envelope-and-refresh-read.md` is **pending release input**, not a note: `changeset version` copies it verbatim into `packages/client/CHANGELOG.md`, which is in `@objectstack/client`'s published `files[]` and ships in the npm tarball. AGENTS.md's release-owned table states the deadline in its own words — *"Your PR's input is its **changeset**, on a hard, unwatched deadline: the release that consumes it deletes that input and publishes the sentence."* ##⚠️ This PR corrects a pending changeset it did not author — read this first `check:empty-changeset`'s real scan **reds on this PR by design** and asks for exactly this paragraph. Its own text names the two classes and their opposite remedies; this is the **DELIBERATE CORRECTION** class, and the gate says *"this gate stays red either way, and staying red is what puts the decision in front of a person instead of routing around it."* - **The note:** `.changeset/client-get-session-envelope-and-refresh-read.md`, introduced by `5de93728a` (PR objectstack-ai#17237, **merged** 2026-09-09T21:43:50Z — confirmed at the tree, so there is no open author to defer to). - **What changed under it:** its closing paragraph was an **undated, present-tense** register of what that change left undone, and all three of its clauses had since been falsified on `origin/main` by other cards' landings (readings below). - **⛔ Not the collision class.** No changeset filename was drawn or overwritten here; this PR adds none. ⛔ Do not restore the file from the merge base — that republishes three false sentences. - ⛔ **I have deliberately NOT applied `skip-changeset`.** It would exempt the `changeset-check` job **wholesale** (`lint.yml`'s own note on the self-test split says so), which is the one label that would silence this refusal. Suppressing it is the routing-around the gate forbids, so the label decision is left to a human. ## Readings — measured on this checkout, ⛔ not relayed from the citing cards Each clause, the commit that falsified it, and its ancestry on `origin/main` (`git merge-base --is-ancestor SHA origin/main`, where **exit 0 is self-proving**): | clause, as it read | measured now | falsified by | ancestor? | |:--|:--|:--|:--| | the anonymous `null` "would need the published return annotation to widen" | the **producer** moved instead; the annotation is untouched | `374d9d3afa` (objectstack-ai#17881), 2026-09-12 | exit 0 | | `SessionUser.image` "declared `z.string().optional()`" | `image: z.string().nullish().describe('Avatar URL')` — `packages/spec/src/api/auth.zod.ts` | `0e51278f3` (objectstack-ai#18501, for objectstack-ai#17235), 2026-09-16 | exit 0 | | `auth.login` / `auth.register` "normalize into `data` but set no `success`" | both run `normalizeSessionResponse`, which returns `{ success: true, ...body, data }` | `01388fe81` (objectstack-ai#17791, for objectstack-ai#17234), 2026-09-12 | exit 0 | **Clause 1, at the definition rather than a call site.** `packages/plugins/plugin-auth/src/anonymous-session-refusal.ts:124` `refuseAnonymousSession` keys on the **answer shape** and on nothing else — three early returns: `isGetSessionPath(endpointPath)`, `response.status !== 200`, and `body.trim() !== ANONYMOUS_BODY` where `ANONYMOUS_BODY = 'null'` (compared as **text**, so `'0'` / `'""'` / `'false'` are left alone). It never reads how the caller became anonymous, so *never signed in*, *unknown cookie* and *revoked session* all convert alike. `ANONYMOUS_SESSION_REFUSAL_STATUS = 401`, and it is wired at `auth-manager.ts:5679`, the one seam every vendor route passes through. ⇒ the anonymous answer is not a `null` outside the declared type; it is a **rejection**, which a `Promise` of `SessionResponse` annotation already permits. ## What the diff does, and the one design choice in it Each clause is **kept as what it recorded**, anchored to when it was written (*"sat outside … when this change was written"*), with the landing that answered it beside it. Two reasons that shape rather than a straight fact-swap: 1. A fact-swap re-arms the same defect — the census's own header names the cause: *"prose does not re-measure itself"*. A dated clause cannot be falsified again. 2. It is the same rule the card's own fence applies one paragraph up: the better-auth **1.7.2** transcript is correct **because** it is dated and attributed. ⛔ That transcript is untouched — verified by needle (`better-auth 1.7.2` and `(anonymous) -> 200 null` both still present at lines 10 and 14). ## Scope note — clauses 2 and 3 were outside the card's fence Card objectstack-ai#18652 fenced scope to clause 1 and marked clause 2 **not re-measured, in either direction**; clause 3 it did not mention. I measured both and they are false too, so fixing clause 1 alone was not available: the sentence enumerates (*"**Two** answers stay outside the declared type"*), so a clause-1-only repair would have had to **newly author** the surviving false clauses into release input — strictly worse than what was there. Same defect class, same sentence, same file, no new verification surface. ⇒ folded in, declared here, and reported separately to the dispatching seat. Strip the last two rows if the seat disagrees; the diff is one paragraph. ## Tests / gates — the pin question, answered with three readings ⛔ **NOT MEASURED: nothing in this repo can pin changeset prose**, and it is not an untried idea — the repo has ruled against building it. Three measurements, none of them my own instrument: 1. **Step 49, `check:pm-changeset-deadline-census`** — its header: *"REPORT-ONLY: it fails nothing and gates nothing."* It measures **path presence**, never prose; its own blind-spot list says *"`window-open` means the FILE exists, never that the card's sentence about it is still correct."* Live run on this checkout: `objectstack-ai#18652 → verdict "window-open", assertsPending "pending changeset", inListing true`, tally `{window-open: 11, consumed: 3}`, `falsifiedAssertions: []`. The row is **identical before and after this diff** (I amend, not delete). ⇒ reachable, but structurally unable to fail. Not a pin. 2. **Step 122, `check:changeset-gate-self-tests`** — `lint.yml`'s own note: *"The SELF-TEST halves only — the real scans stay in pr-automation.yml's `changeset-check`."* `dispatch-gates` scores self-test-only families *"checker-health only … NOT a PR verdict."* Green here and green without this diff. Not a pin. 3. **Building one is forbidden right now.** `changeset-deadline-census.mjs`: *"⛔ This file is deliberately NOT the enforcement half … report-only first, the census is the deliverable, expansion only when the census reads zero including its blind spot."* The census reads **3 exposed**, not zero. Independently, `.changeset/**` is a ruled scan exemption — *"a changeset is that record before it is compiled into a CHANGELOG"* (`packages/objectql/src/action-owner-key-single-source.test.ts`, `NOT_A_STALE_MENTION`). And the file is consumed and deleted at the next release, so a pin reading that path becomes a phantom check by construction. ⭐ The one reachable instrument that **does** respond to this diff is a third the roster reading did not name — `check-empty-changeset.mjs`'s real scan — and its polarity is **inverted**: it is green without this change and red with it, on the **act**, not the prose. That is the human-confirmation gate above, not a pin. **Derived sweep** — `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `aefbf5927`, all 18 derived commands plus `check:changeset-fixed` (flagged ⛔ *roster under `.changeset`*) run locally: ``` adr0087 --base 0 no-major --base 0 closing-parity 0 comment-mask-corpus 0 adr0087 --self 0 no-major --self 0 closing-parity 0 driver-memory-census 0 empty-changeset --self 0 release-rehearsal --self 0 changeset-gate-self-tests 0 pm-changeset-deadline-census 0 nul-bytes 0 objectui-changeset 0 published-files 0 refd-timer-probe 0 watch-hint-literal 0 changeset-fixed 0 empty-changeset --base 1 <- THE DESIGNED RED, declared above ``` - **NOT MEASURED: `check:rerun-safety-verdict`.** `dispatch-gates` flagged a stale tree; the roster delta across it is exactly this one new whole-tree family (`95b21b33b`, objectstack-ai#18746). It does not exist in this checkout, so `pnpm` exited 254 (script-not-found) — ⛔ that is not a red gate and not a pass. It is self-test-only and reads only its own fixtures, so it cannot judge this diff either way. - **No control characters**: `grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'` over the changed file exits 1 (clean), beyond `check:nul-bytes`. - **No `pnpm lint` narrowing claimed, and no build/test**: this diff compiles nothing and is read by no test. `turbo`'s graph is not consulted because the path is in no package. ## Does this diff owe a changeset of its own? — measured, ⛔ not assumed **No, and adding one would be a defect.** `@objectstack/client`'s published `files[]` is `["dist","README.md","CHANGELOG.md"]`; `.changeset/**` is not in it, and this diff moves no `dist` byte. The published text that *does* move is this very entry — so the amended changeset **is** the release input. A second changeset would emit a second CHANGELOG bullet correcting the first, which is the shape AGENTS.md forbids by name: *"Factual error in a released entry → amend that entry in a dedicated docs-only PR, ⛔ never an erratum in a later entry."* Gate readings agree: `check-changeset-no-major --base` 0, `check-adr-0087-registration --base` 0, `check-empty-changeset --base` prints *"✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)"*. ⛔ `major` does not exist in this launch window and nothing here is breaking. Clause-②: no No key moves, no accept set widens or narrows, no export changes, no error code or `ERROR_CODE_LEDGER` entry moves, and no `packages/spec` path is touched. The diff is prose inside one pending changeset; zero lines of shipped code change. ## ⛔ Reported, not touched: the open Version Packages PR **objectstack-ai#17076 `chore: version packages` is OPEN and already renders this entry** — at its head `1c0ce1713` the changeset is **absent** (contents API `404`, against a `200` positive control on `.changeset/config.json`) and the paragraph is compiled into `packages/client/CHANGELOG.md:506`. ⛔ I did not touch that PR, ran no release, and merged nothing. ⭐ **But an open Version Packages PR does not mean the window has closed, and the dispatch order's reading that it does is falsified by the repo's own workflow.** That branch is a **derived artefact, regenerated from scratch**: - `release.yml`'s `version-pr` job is `if: github.event_name == 'schedule' || (… workflow_dispatch && inputs.refresh_version_pr)` — ⛔ **not** `push`; the file's own note: *"this job regenerates the PR from scratch, so the newest run's result is the one that was wanted anyway"*, and *"renders changesets that are already committed on main, so lateness costs nothing."* - The mechanism, quoted in that file from changesets/action v1: `git reset --hard SHA` → `pnpm run version` → `git push … --force`. - Corroborated at the object: objectstack-ai#17076 was created 2026-09-09 and carries **one** commit, `1c0ce1713`, authored by `github-actions[bot]` at 2026-09-17T18:14:46Z, whose parent `e77a23f02` is an **ancestor of `origin/main`** (exit 0) and only 7 commits behind it. Created eight days before the commit it holds ⇒ force-rebuilt. ⇒ The window closes when a **release consumes** the changeset (AGENTS.md's wording), i.e. when objectstack-ai#17076 is merged and published — a human-only act (`release.yml`: *"TWO LANES, ONE INVARIANT: ONLY A HUMAN PUBLISHES."*). Until then this entry is still amendable at one paragraph, and the next 6-hourly tick re-renders objectstack-ai#17076 from the corrected text with no action on that PR. The deadline is real and this PR is inside it. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 --- _Generated by [Claude Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_ --- ### Landing note added by the dispatching seat (objectstack-ai#6024) — clearing a red that was the seat's, not this PR's The earlier red on `The card this PR closes must claim this branch` was caused by the **claim comment's shape**, not by anything in this diff: the seat's claim on objectstack-ai#18652 opened `**CLAIM · …**`, and `scripts/check-closing-target-claim.mjs` selects a comment by a line beginning `Claim:` plus a **separate** `Branch:` directive line. Fixed on the card (`5720097499`), measured before and after — `check-clause2-carriers --pair 18765` went from `EXIT=4 / claim.selected: none` to `EXIT=0 / 1 comment(s) in the pool`, both carriers reading `no`. That guard is body- and comment-scoped and re-fires only on a `pull_request` or `merge_group` event, so **this edit is the event that re-judges it** — ⛔ not an empty commit, which the deliverer correctly refused, and ⛔ not a push, since the seat has ruled that all three clauses stay and no further code change is owed.⚠️ The two remaining reds are **by design** and are not touched by this edit: `check-empty-changeset` asks in its own words for a human to confirm the DELIBERATE CORRECTION class, and `pr-automation.yml`'s `changeset-check` counts only **added** changesets (this PR amends one and adds zero). The ruling request is on objectstack-ai#18652 (`5720153848`). ⛔ `skip-changeset` is deliberately NOT applied — it exempts that job wholesale and would silence the refusal that exists to put this decision in front of a person. --- _Generated by [Claude Code](https://claude.ai/code)_ Co-authored-by: Claude <noreply@anthropic.com>
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