fix(client): the anonymous /get-session statements say 401 UNAUTHENTICATED, and the test double stops modelling 200 null - #18642
Conversation
… the shipped 401 #17881 (374d9d3) moved the anonymous `GET /api/v1/auth/get-session` wire answer from `200` + the literal `null` to the declared ADR-0112 refusal envelope (401, `UNAUTHENTICATED`). Three present-tense statements in the SDK and one test double still described the retired shape. - `normalizeSessionResponse`'s `/auth/*` wire table no longer lists the anonymous `200 null` row among the bodies the helper is handed; the current answer is stated separately, with the reason it never reaches the lift. - The closing `!body`-guard paragraph no longer claims that guard carries the anonymous answer. - `auth.me()`'s docblock says the anonymous call REJECTS rather than resolving outside the declared type. - `organization-get-active-member-addressing.test.ts`: the `signedIn: false` leg modelled an answer the runtime stopped producing, so it could not fail for the reason it existed. The double now serves the refusal envelope and case ⑥ pins that the refusal arrives on request ONE, discriminating on the code (`UNAUTHENTICATED` vs `list-members`' `UNAUTHORIZED`). Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
…ssion statements The corrected `auth.me()` TSDoc is emitted into the shipped declarations (`dist/index.d.ts`, `dist/index.d.mts`) and into `dist/index.js` / `dist/index.mjs`, all under the package's published `files[]`, so this publishes and cannot take `skip-changeset`. Measured on the built artifact with `getActiveMember` as the lit control. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 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 efb84a03e18a2da1aaab70b88cf4b5419d699d74 && git checkout efb84a03e18a2da1aaab70b88cf4b5419d699d74
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 32be735e5bdfa3be0211fb07d9347ed618ad1881 81a05648d87e408f152efc9b67f7b164f7f6faf5 && git checkout -B drift-repro 32be735e5bdfa3be0211fb07d9347ed618ad1881 && git merge --no-ff 81a05648d87e408f152efc9b67f7b164f7f6faf5
node scripts/docs-audit/affected-docs.mjs --json 32be735e5bdfa3be0211fb07d9347ed618ad1881
|
Fixes #18139
#17881(374d9d3afa) moved the anonymousGET /api/v1/auth/get-sessionwire answer from200+ the literal JSONnullto the declared ADR-0112 refusal envelope — HTTP401,code: UNAUTHENTICATED.@objectstack/clientreaches the server over the wire, so that is exactly what it sees. This PR pulls the SDK's own statements and one test double back onto it. No behaviour moves:packages/client/src/index.tschanges comments only (verified mechanically — every changed line in that file is inside a comment).Half 1 — the SDK's statements
Three present-tense statements, none carrying a rev or a date, so none read as history:
/auth/*wire table onnormalizeSessionResponse(located by symbol, not by line). Its anonymous-> 200 nullrow is removed from the list of bodies the helper is handed — it is not handed that body at all now, because the sharedfetchwrapper throws on the non-2xx one frame out. The current answer is stated separately, anchored to the producer.!body-guard paragraph in the same docblock, which claimed that guard "carries the anonymous answer" and that closing the gap "needs the published return annotation to widen".#17238ruled the opposite: the producer moved andSessionResponseSchemais untouched. The guard's own code is byte-identical; it is now described as the defensive branch it is.auth.me()'s docblock, which said the route "serves the literalnullat 200 and it is returned as-is". It now says the anonymous call REJECTS, with the code and status a caller catches.Half 2 — the phantom, which is the half that mattered
Triage's grading is quoted verbatim and is what this half answers:
The disposition chosen: assert the refusal, not drop the leg — justified from the tree. The file's own header states its charter: "Only the socket is faked — every status, code and row shape below is a transcript line from the drive". Under that charter the anonymous row is not optional, it is stale, so it is re-anchored rather than deleted. Dropping the leg would delete the only coverage that
getActiveMemberdoes not invent a client-side diagnostic for an anonymous caller — a property that is still real and is now sharper, because the refusal arrives on request ONE instead of request TWO.What changed in the fixture:
/get-sessionarm serves401 {"success":false,"error":{"code":"UNAUTHENTICATED","message":"Sign in first"}}— shaped from the producer (refuseAnonymousSessionderives the code throughstandardErrorCodeForHttpStatus(401)and takes the message fromPLATFORM_ADMIN_REFUSAL_MESSAGES[401]), not restamped onto a drive that was never re-run. The header says so in as many words; every other row is still the 2026-09-08 drive, untouched.code: UNAUTHENTICATED+httpStatus: 401and pinsurlsas the WHOLE list[AUTH + '/get-session'], so a silent extra request cannot hide behind a length check.401 UNAUTHORIZEDarm is KEPT although the SDK no longer reaches it, and that is load-bearing: it answers a different code from the/get-sessionseam, so a regression that swallowed the first refusal and walked on fails on the VALUE, not merely on a request count. Case ⑥ drives it directly as a guard-the-guard, in the file's existing ⑦/⑧ style.Ablation — the proof it can now fail for the reason it exists
One-off, committed first, mutation proved on disk, restored and proved restored.
return json(200, null);. On-disk proof: refusal-arm grep count1 -> 0, retired-arm grep count0 -> 1, blob48459424a(HEAD) vs19eb96dcb(mutated) — not a no-op.Tests 1 failed | 7 passed (8)—⑥ ... AssertionError: expected 'UNAUTHORIZED' to be 'UNAUTHENTICATED'. It fails on the CODE, which is the discrimination this design is for.git checkout HEAD -- pathunder anEXIT INT TERMtrap; restored blob48459424aequals the HEAD blob,git diff HEADempty,git status --porcelainempty.Test Files 1 passed (1) · Tests 8 passed (8).The premise the dispatch asked me to falsify — FALSIFIED
The card's dedupe was a recent-window search and said so. Swept repo-wide for
get-session,200 null, "literalnull" and "literal JSONnull" with controls (a token known present returned 8 hits; a nonsense token returned 0 at exit 1). The:1474table was not the only present-tense statement left. Six more were found; three are fixed here (the two above plus the guard paragraph) and three are listed in Acceptance notes because they are out of this PR's face.The card's declared-unmeasured item — now MEASURED
The card wrote: "Whether
auth.me()'s rejection path is covered anywhere today. I did not measure it." Measured: it IS covered, twice.packages/client/src/client.test.tscarries "me() rejects the anonymous refusal rather than resolving outside its type" assertingcode: UNAUTHENTICATED/httpStatus: 401, andpackages/client/src/auth-get-session-envelope.test.tsblock ③ ("anonymous is REFUSED") carries the real-server twin. Both were reversed by#17238and say so in their own comments. Nothing in this PR depends on that coverage; the reading is reported because the card asked for it.Clause-②
Clause-②: no. Three readings, from the measured diff:
packages/client/src/index.tschanges comments only; the test file and the changeset export nothing. The package's public surface is byte-identical.ERROR_CODE_LEDGER/StandardErrorCode: untouched — the diff names neither, at zero occurrences.UNAUTHENTICATEDis an existing STANDARD member that#17881already derives viastandardErrorCodeForHttpStatus; nothing is minted here..zod.tsin the diff (zero paths), no schema key moves, nothing previously admitted is refused and nothing new is admitted.The expected direction held: this is a pull-back — the runtime already answers 401 and the SDK's self-description was lagging. Nothing widens.
Tests
All readings taken in a dedicated worktree cut from
origin/mainat681ebe424; the union below was re-run at final HEAD81a05648d.pnpm --filter @objectstack/client typecheck— exit 0. Note the two halves: the roottsc --noEmitprogram does NOT include the edited test file (--listFilescount 0, withsrc/index.tsat 1 as the lit control); the test layer is covered by the script's second half,check:test-typecheck, which reports "@objectstack/client's test layer compiles under packages/client/tsconfig.test.json; 0 file(s) / 0 error(s)".pnpm --filter @objectstack/client test—Test Files 43 passed (43) · Tests 524 passed (524).pnpm --filter '@objectstack/client^...' build— exit 0 (the dependency closure; the first test run was a false red from an unbuilt@objectstack/spec, not from this diff).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 59 families. Reconciled with--ran: 59 derived, 58 run, 1 NOT-MEASURED, 0 UNRUN.pnpm check:dual-build-cjs-loads— exit 3,PREREQUISITE NOT MET, 34 packages have nodist; it needs a fullpnpm build, which does not fit a foreground turn here. Its own text says "This is NOT a pass: nothing was measured." CI'sBuild Corecovers it.pnpm --filter @objectstack/spec run check:skill-examplesfirst exited 1 on the same prerequisite shape (packages/client-reactunbuilt). Built that package and re-ran: exit 0, 258 prose examples across 3 surfaces.pnpm check:cross-package-test-inputs— exit 1, and it is NOT from this diff. It flagspackages/cli/test/init-created-files-summary.e2e.test.tsdescending frompackages/spec/dist/. Control: that file andscripts/check-cross-package-test-inputs.mjsandturbo.jsonare all byte-identical toorigin/main(blob-for-blob), while a file this PR did change differs — so the verdict is a property oforigin/main, not of these two paths. The walk is a static source property (join(SPEC_PKG, 'dist')at that file's line 115). Filed below rather than fixed here.pnpm lintrepo-wide (eslint . --no-inline-config) — exit 0. Run whole, not narrowed, so no narrowing needs proving.81a05648d:check-empty-changeset,check-changeset-no-major,check-adr-0087-registration,check:objectui-changeset,check:pm-changeset-deadline-census,check:type-check-coverage,check:type-check-debt,check:published-files,check:tier-file-adoption,check:nul-bytes,check:test-source-alias— all exit 0.Changeset, measured rather than assumed
skip-changesetdoes not apply and was not requested.@objectstack/client's publishedfiles[]is["dist","README.md","CHANGELOG.md"], andauth.me()is a member of the exportedObjectStackClient, so its TSDoc is emitted into the shipped declarations. Measured on the built artifact: the corrected sentence is present indist/index.d.ts,dist/index.d.mts,dist/index.jsanddist/index.mjs; the retired sentence is absent fromdistafterwards;getActiveMembercarried as the lit control, found in the same four files. Hence apatchchangeset.Acceptance notes
Neighbour PR.
#18429(card#17274) also editspackages/client/src/index.tsand was open and unmerged when this branch was cut. Its measured hunks are at 3671-3689, 3962-3967, 3976-3983 (organizations.invitations.resend); this PR's edits are the/auth/*docblock near 1474 and theauth.me()docblock near 4437. The regions do not intersect — which guarantees textual mergeability and nothing more.Three same-defect statements deliberately NOT fixed here, because they sit in the
organizations.*region this PR is fenced out of. All three are ingetActiveMember's docblock and body inpackages/client/src/index.ts, and all three now misdescribe the runtime:{ user, session }envelope for a signed-in caller and the literalnullfor an anonymous one (measured)" — step 1 of the docblock's two-request explanation.401 UNAUTHORIZED, thrown from thelist-membersrequest by the same session middleware" — the refusal now comes from the/get-sessionrequest withUNAUTHENTICATED, one request earlier.null, and the request below is then refused 401 by the session middleware before the filter is ever read."They are the same defect class as this card and the fix is mechanical, but the in-place-fix criteria do not all hold:
packages/client/src/index.tsis held by another live claim (#18429), and the dispatch fences this PR out of that region by name. Handed back for filing with dedupe wordsgetActiveMember,get-session,anonymous,UNAUTHENTICATED,#17881. Reviewer, read this PR's case ⑥ together with that docblock: they now disagree, and the docblock is the one that is wrong.Two more, outside
packages/client, handed back for filing:docs/qa/platform-checklist/areas/identity-auth.json(two clauses) instructs a QA runner that "better-auth's no-session convention is HTTP 200 with a JSON null body, so a 401 expectation misdescribes a correct implementation" and thatget-sessionis not a valid oracle. Since#17881a 401 is exactly what a correct implementation answers, so a runner following this verbatim scores a correct platform as defective. It even citessession-of-record.test.tsas its authority — and that file was updated by#17238and now says the opposite in its own helper docblock. This is the highest-value item in the sweep. Dedupe words:identity-auth.json,get-session,oracle,JSON null body,revoke-user-sessions..changeset/client-get-session-envelope-and-refresh-read.md(pending, unreleased) closes with "Two answers stay outside the declared type and are not addressed here: the anonymousnull, which would need the published return annotation to widen" — present-tense, and it ships verbatim into@objectstack/client'sCHANGELOG.mdat the next release. Its transcript block above it is anchored to a dated better-auth 1.7.2 drive and reads as history; only the closing paragraph is the problem. Not touched here because it is another PR's release input. Dedupe words:changeset,get-session,anonymous null,return annotation,#16760.Noted, not filed —
packages/adapters/hono/src/hono-auth-mount-basepath.test.tscarries aGET /api/v1/auth/get-session -> 200 nullrow in an AFTER transcript. It is anchored to a dated maintainer ruling and a named drive, so it reads as a measurement record rather than a present-tense claim, and no assertion in that file depends on it. Who would carry it: that adapter's next#16025-area edit. If nobody touches that file, nobody is misled by it, because the file's own cases assert a relation and never that body.Cleanup: the worktree used for this work is removed; no dev server was started and no temporary artifact is left in the tree.
Generated by Claude Code