fix(plugin-auth)!: an anonymous get-session is refused with the declared 401 envelope, not answered 200 null - #17881
Conversation
…401 envelope
`GET /api/v1/auth/get-session` answered an unauthenticated caller with HTTP 200
and the literal JSON `null` — a value no `SessionResponse` can express, so
`ObjectStackClient.auth.me()` resolved outside its own declared return type on
the most ordinary call a logged-out caller makes.
The route now answers the platform's standard ADR-0112 failure envelope:
`401 { success: false, error: { code: 'UNAUTHENTICATED', message } }`.
`SessionResponseSchema` is untouched — the implementation is corrected to the
published contract, not the other way round.
The refusal lives at the one seam every vendor route passes through, beside the
`/admin/` envelope normalization, and is held to three narrowings: the
`/get-session` endpoint exactly, status 200 only, and a body that is exactly the
literal `null`. The signed-in answer and better-auth's JS API are untouched.
No error code is minted: `UNAUTHENTICATED` is an existing `StandardErrorCode`
member, derived from the status through ADR-0112's own map.
Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <[email protected]>
…e 401 refusal `get-session also survives a degraded optional plugin` used the anonymous `200` as its vehicle for "the route reached better-auth and came back". The request is anonymous, so that vehicle is now the declared 401 envelope. The claim under test is unchanged. The assertion is also strengthened rather than merely moved: it now pins `error.code === 'UNAUTHENTICATED'` and the recorded degradation, so it cannot pass on a 401 produced for some other reason by a degraded instance. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <[email protected]>
…DR-0087 disposition The first draft carried a `FROM -> TO` rewrite table, which the ADR-0087 gate reads as a migration prescription — correctly, by its own rules — and a prescription refuses the `no-migration-prescription` category. The category is nonetheless the right one: this change retires no metadata surface, so `objectstack migrate meta` has nothing to rewrite and no ledger entry can be written for it. What moves is an HTTP status plus an SDK method's promise contract, and the changeset is the only channel that reaches those consumers. So the consumer guidance stays — restated as prose plus a `try`/`catch` example, the shape the automation-trigger status unification used for the same class of change — and the disposition is recorded in writing. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <[email protected]>
…limit contracts `check:engine-double-contract` and `check:objectql-double-limit` both read this new double. Its `update()` now routes through `assertEngineUpdateDispatch` and reads the id back off the predicate, so the fake cannot be looser than the engine it stands in for; its `find()` applies `limit`/`offset` by PRESENCE, so `limit: 0` means no rows instead of every row. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <[email protected]>
…usal Block 3 of `auth-get-session-envelope.test.ts` pinned the anonymous `null` passing through. Its own comment recorded what it was: a pin of the RESIDUE, green with the lift and without it, unable to redden on the lift's ablation. The producer now closes that residue, so the block asserts the refusal instead and names the reversal in place, with a control that fails a `me()` which silently starts resolving a falsy session again. `client.test.ts`'s twin is fetch-MOCK driven and so would have stayed GREEN while pinning a wire shape no server produces any more — the more dangerous of the two, since it announces nothing. Re-pointed by hand. No runtime change in this package: `this.fetch` already throws on every non-2xx, so the anonymous answer arrives as a rejection carrying `code` and `httpStatus` with no edit to `auth.me()` itself. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <[email protected]>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 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 — 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 1d0dfb0957393d795bb013194a7960a833263afe && git checkout 1d0dfb0957393d795bb013194a7960a833263afe
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2266438ce0ba039634f9dcec06db7be12c0d58ee a249cc69081c4a0f7c17b9acbc2224a4a5dfc5e0 && git checkout -B drift-repro 2266438ce0ba039634f9dcec06db7be12c0d58ee && git merge --no-ff a249cc69081c4a0f7c17b9acbc2224a4a5dfc5e0
node scripts/docs-audit/affected-docs.mjs --json 2266438ce0ba039634f9dcec06db7be12c0d58ee
|
…onymous-session-401
…us get-session The packed-tarball release-candidate smoke asserted that an anonymous `GET /api/v1/auth/get-session` answers 200. Since #17881 the platform deliberately answers 401 with the ADR-0112 envelope (`code: UNAUTHENTICATED`), ruled by the director seat in decision batch #117 item 4 on card #17238: better-auth's `200` + literal JSON `null` is a value no `SessionResponse` can express, so `auth.me()` resolved outside its own declared type; the code moved and the published schema did not. The smoke was therefore asserting a contract the product deliberately stopped serving. This re-pins the probe to what the code declares — the same move #14000 made for `SELF_REGISTRATION_CLOSED` — and pairs the status with the envelope `code`, so a 401 from an origin check, a rate limiter or a later guard cannot keep the probe green while measuring nothing. The signed-in `get-session` probe is untouched and still asserts 200: the refusal seam converts only a 200 whose body is exactly `null`. Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU Co-authored-by: Claude <[email protected]>
…us get-session (objectstack-ai#18140) Closes objectstack-ai#18079 `Clause-②: no` — this neither loosens an accept set nor widens a published contract surface. It tightens one CI assertion onto the contract the code already declares. ## The question this card asked, and the answer objectstack-ai#18079 named two hypotheses and asserted neither: either the anonymous `GET /auth/get-session` behaviour regressed (fix the auth surface), or the smoke's expectation is stale (fix the probe). **The second is true, and it is established from the tree rather than inferred.** `packages/plugins/plugin-auth/src/anonymous-session-refusal.ts` exists for exactly this, and its header records the ruling: > Director seat, decision batch objectstack-ai#117 item 4 (2026-09-12), maintainer verbatim 「17238 B」 — the server answers the platform's standard ADR-0112 failure envelope with HTTP 401 instead of `200` + `null`, and `SessionResponseSchema` is UNTOUCHED. The charter rule quoted in that ruling: 「spec 与代码不一致默认改代码,改协议单独立卡非选项」. `AuthManager.handleRequest` calls it on the one seam every vendor route passes through, and the call site says in as many words that this one **is** the admission move: >⚠️ NOT the same kind of change: that one is forbidden to move admission and this one IS the admission move (`200` -> `401`). So the 401 is the declared contract. The smoke was asserting a shape the platform deliberately stopped serving. ⇒ **The auth surface is not touched by this PR.** Three independent legs agree on the exact envelope, so the assertion is pinned to a measurement and not to a guess: | leg | source | reading | |---|---|---| | the ruling | `anonymous-session-refusal.ts` header | 401 + ADR-0112 envelope | | the wire | run 34774426350 / 34728125950 job log | `{"success":false,"error":{"code":"UNAUTHENTICATED","message":"Sign in first"}}` | | the spec | `packages/spec/src/api/errors.zod.ts:174` | `401: 'UNAUTHENTICATED'` | ## Bisect — the true first failure, not the observed window The card cautioned that the observed window (back to `b06b2db5c4`) is not the start, because the runs on `a83dbb6124` and `6d647858b7` read `no-run` and `cancelled`. Correct — and the real boundary is a day earlier and is **not a `main` commit at all**. - Causal commit on `main`: **`374d9d3afa`** — `fix(plugin-auth)!: an anonymous get-session is refused with the declared 401 envelope, not answered 200 null (objectstack-ai#17881)`, 2026-09-12T19:15:14Z. `git log --diff-filter=A` names it as the commit that *adds* the refusal module; the shallow graft boundary here is `ca0a1f83d6` (2026-07-29), far older, so that add is genuine and not a graft artifact. - Last green RC smoke: run `34726616436`, 2026-09-12T23:54:27Z, status posted to RC head `a1effc8e44` (`chore: version packages`, 2026-09-12T18:12:26Z). - First red RC smoke: run `34728125950`, 2026-09-13T00:30:11Z, job `103645853837`, status posted to RC head `e612087feb` (`chore: version packages`, 2026-09-13T00:29:49Z). Its log carries the same assertion and the byte-identical body as the card's evidence run. Those two runs carry the **same** `main` head sha (`a9c6477904`) and opposite verdicts, which is the card's "NOT main-red" point showing up as data: the smoke tests the release candidate, not the commit the check attaches to. The decisive probe is therefore on content, not on timing — ``` anonymous-session-refusal.ts platform-admin-gate.ts (nonsense path) a1effc8 LAST GREEN RC ABSENT PRESENT ABSENT e612087 FIRST RED RC PRESENT PRESENT ABSENT ``` The firing control is present in both trees (the probe reaches them) and the nonsense control is absent in both (the probe can say no). `374d9d3afa` (19:15:14Z) falls inside the interval between those two `chore: version packages` commits (18:12:26Z → 00:29:49Z), so the two readings cross-validate. ## What changed One file, `scripts/publish-smoke.sh`: 1. The anonymous probe now expects **401** and is paired with `assert_body '.error.code == "UNAUTHENTICATED"'`. The status alone would be satisfied by an origin check, a rate limiter or any later guard while measuring nothing — the same reasoning the `SELF_REGISTRATION_CLOSED` probe below it is already written under, and what this file's header means by *"every assertion here is an HTTP status plus a `code` this repo owns and publishes"*. 2. A comment pinning **why**, naming objectstack-ai#17881, objectstack-ai#17238 and the ruling, so the next reader does not "fix" it back — the card asked for this explicitly. 3. The header's declared-contract table updated to match. The **signed-in** probe is deliberately untouched and still asserts 200: the refusal seam converts only a 200 whose body is exactly `null`, so that answer is byte-identical to before. This is the objectstack-ai#14000 move repeated — that card re-pinned this same script to the declared contract for `SELF_REGISTRATION_CLOSED` rather than touching auth runtime code, and left a standing ⛔ against relaxing an assertion *back toward 200*. This change runs the other way (200 → 401), which is the direction that prohibition protects. ## Verification - `bash -n scripts/publish-smoke.sh` → exit 0. - The new `jq` filter, tested against the byte-exact body from job `103645853837` and two controls: observed body → exit 0; a 401 carrying `INVALID_ORIGIN` → exit 1; the old `null` → exit 1. So the assertion accepts the real refusal and rejects both a foreign guard and the retired shape. - The full derived gate family for this diff — 26 commands from `node scripts/pm/dispatch-gates.mjs --commands` — all exit 0, including `check:bash32-floor` and `check:nul-bytes`. - Control-character self-scan over the edited file: no matches. -⚠️ The smoke itself is **NOT MEASURED** locally. It packs ~70 tarballs, installs and builds a project outside the workspace and boots a dev server; that is far past this container's foreground budget and its shared-verification discipline. The real verdict is the next `publish-smoke / packed-tarballs` status on the release-candidate head. ## Reverse-read, both directions **Which currently-true sentence does this make false?** Inside this file, the header's `→ 200 (anonymous)` row — updated in the same diff, so the file does not contradict itself. Outside it, none: I grepped `get-session` across `packages`, `scripts`, `docs`, `content` and `.github` and no other statement depends on the smoke asserting 200. **Which currently-false sentence does it make true?** Two. The file's own claim that it asserts *"the DECLARED first-run contract"* was false for this row and is now true. And the status text `Fresh install of the release candidate: auth + CRUD green`, which this gate has been unable to post since 2026-09-13T00:30Z, becomes reachable again — that is the release-blocking half. **Zero results reported as such:** no other consumer of the smoke's expectation exists; no docs page restates it. ## Noted separately The same grep found that objectstack-ai#17881 moved the wire answer but left the **client SDK** still documenting `-> 200 null` (`packages/client/src/index.ts:1474`) and a test double still modelling it. That is `domain:services`, not this lane, and it is filed on its own card — see objectstack-ai#18139. It is not addressed here. ## Merge channel⚠️ The dispatch brief expected this fix to land in `.github/workflows/publish-smoke.yml` and warned that such a PR cannot be armed by the PM seat (HTTP 422, token lacks `workflows`). **That premise does not hold** — the probe lives in `scripts/publish-smoke.sh`, the workflow's driver script, and the workflow YAML contains no assertion at all. This diff touches no path under `.github/workflows/`, so that caveat does not apply to it. It also touches no governed surface under Prime Directive objectstack-ai#14. No changeset: the changed file is a CI driver shipped by nothing. Measured rather than assumed — the root package is `private: true`, and of the 70 package manifests declaring `files[]`, zero name this path. The `skip-changeset` label carries that, not this sentence. --- _Generated by [Claude Code](https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU)_ Co-authored-by: Claude <[email protected]>
… 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 <[email protected]>
…usal envelope The five sites in `docs/qa/platform-checklist/areas/identity-auth.json` that taught better-auth's retired no-session convention (HTTP 200 + a JSON `null` body) were inverted by #17238/#17881: `refuseAnonymousSession` now converts that answer into `401` + `UNAUTHENTICATED` in the ADR-0112 refusal envelope. A runner following the file scored the correct implementation as defective, and the remedy the negative pointed at was undoing an auth tightening. Four instructional sites re-pointed (the step, clause 5's verify, the third negative, the evidence row). `revision 3` is left standing and unedited -- it records what the August judgement rested on -- and a new `revision 6` states that its cited authority was inverted afterwards. Measured on this checkout rather than relayed: live session 200 with { user, session }; after revoke-sessions, 401 UNAUTHENTICATED; fresh-manager control still 200. The refusal covers the REVOKED path, not only the never-signed-in one, because the seam keys on the answer shape. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <[email protected]>
Fixes #17238
Ruled B by the director seat (decision batch #117 item 4, comment 5642584682). Maintainer, verbatim: 「17238 B」. The seat had recommended A and was corrected, verbatim: 「我改推荐的都是从平台长远合理性角度考虑的」, under the charter rule 「spec 与代码不一致默认改代码,改协议单独立卡非选项」.
GET /api/v1/auth/get-sessionnow answers an anonymous caller the platform's standard ADR-0112 failure envelope at HTTP 401, instead of200+ the literal JSONnull.SessionResponseSchemais untouched.The defect, and why the code is what moves
ObjectStackClient.auth.me()declaresPromise<SessionResponse>, andSessionResponseSchemarequiresdata.sessionanddata.user— so no value of that type means "nobody is signed in", and the most ordinary call a logged-out caller can make resolved to something outside the method's own declared type.⇒ Every value
auth.me()returns is now insideSessionResponse. The anonymous case is delivered as a rejection instead: the SDK'sfetchwrapper already throws on every non-2xx, so the caller gets an error carryingcode: 'UNAUTHENTICATED'andhttpStatus: 401. No runtime edit inpackages/clientwas needed — that fell out of the measurement rather than being assumed.Measured, on the merge base and after
Driven through a real
AuthManager(better-auth, default plugin set, in-memory engine) viahandleRequest:The
POSTrow is why the rule is keyed on the 200-null answer and not on the path alone: a path-only rule would have converted that 404 into a 401 and claimed a route better-auth does not serve.Where it lives
packages/plugins/plugin-auth/src/anonymous-session-refusal.ts, wired intoAuthManager.handleRequestbeside the existing/admin/envelope normalization — the one seam every vendor route passes through./admin/seam is forbidden to move admission, and this one is the admission move.Three narrowings hold everything else still, each pinned by a
toBeidentity assertion:/get-sessionendpoint exactly — not a prefix, not the session family.null— compared as text, so a body that merely parses to something falsy (0,"",false,{},[]) is left alone.⛔ better-auth's JS API is untouched.
auth.api.getSession()still returnsnullfor an anonymous caller, so execution-context resolution, the platform-admin gates and the SSO bridges behave exactly as before. Only the wire moves. There is a negative-control test for precisely this.No error code is minted.
UNAUTHENTICATEDis an existingStandardErrorCodemember (packages/spec/src/api/errors.zod.ts:69), derived from the status through ADR-0112's ownstandardErrorCodeForHttpStatusmap — so there is no string literal here to drift,ERROR_CODE_LEDGERis unchanged, andpackages/specis not touched by this PR.Client consequences, enumerated (ruling note 3)
Note 3 requires these be listed here rather than discovered downstream. The two readings behind the list are posted on the card (comment 5647024737).
ObjectStackClient.auth.me()null. No source change —this.fetchalready throws on non-2xxpackages/client/src/auth-get-session-envelope.test.tsblock ③packages/client/src/client.test.tspackages/cli/src/commands/whoami.tsCurrent Session.requireAuth(token)already exits earlier when no token is storedpackages/plugins/plugin-auth/src/register-sso-provider.tsif (!resp.ok) return undefined;— before:ok, bodynull, org undefined; after: notok. Same value, best-effort by its own docblock../objectuinullbodypackages/clientwas originally fenced out of this card asdomain:cli's serial hot file. The dispatching seat lifted the fence for those two test files only, having measured that no open PR holds anypackages/clientpath and that this PR cannot go green without the block ③ flip. ⛔ No runtime edit was made in that package.Ablation — the negative pins are proven able to fail
This lane requires every negative pin carry one. The old
200 nullwas put back by neutering the guard inanonymous-session-refusal.ts, plugin-auth was rebuilt, and the marker was proven live in the artifact the client consumes before anything was read:packages/clientresolves@objectstack/plugin-auththroughdist, so a source-only mutation would have left block ③ green and certified a test that could never fail. A second trap the preflight caught: the first marker was written inside a/* */comment, which tsup strips — it never reacheddistat all.Ablated, server suite (4 of 11 red):
Ablated, client block ③ (2 red) — and this is the card's own loop closing:
The card records that block ③ "is green under [the ablation] and CANNOT redden on it … it pins the residue, not the fix." It reddens now. That is the difference between a pin that records a gap and a pin that protects a fix.
Restored, rebuilt, and re-run green:
✓ dist/: marker absent from all 12 built files,✓ tree: working tree clean against HEAD, 11/11 and 223/223.Verification
All at
b8c2b00f8, the final commit.plugin-authfull suiteplugin-authtypecheck (incl.check:test-typecheck)client— the 5 affected filesdispatch-gates --commands→ all run →--ranThe eslint narrowing is a measurement, not a skip: ① the population (6657) is eslint's own enumeration, ② the 11 is the
--format jsoncount, ③ this repo runs oneeslint.config.mjswhich never enables type-aware linting for any file (its own header, line 328, measured there with a positive control) ⇒ no verdict on an untouched file can have moved.One test outside the new file needed re-pointing:
auth-manager.optional-plugin-isolation.test.ts's "get-session also survives a degraded optional plugin" used the anonymous200as its vehicle for "the route reached better-auth and came back". The claim is unchanged; the assertion now pins401anderror.code, plus the recorded degradation, so it cannot pass on a 401 produced for some other reason.Changeset
minor, declared BREAKING, with the observable behaviour change stated in as many words (ruling note 6). Its ADR-0087 disposition isnot-required (no-migration-prescription): this retires no metadata surface, soobjectstack migrate metahas nothing to rewrite and no ledger entry can be written for it — what moves is an HTTP status plus an SDK method's promise contract. The same disposition and reasoning a previous HTTP-status unification used.FROM -> TOtable, which that gate reads as a migration prescription — correctly, by its own rules — and a prescription refuses this category. The consumer guidance is kept, restated as prose plus atry/catchexample. ⛔ The bang was not dropped to get around it; the gate names that move as the one it exists to stop.Acceptance notes
Noted, not filed — all three are the same class (a pin or a docblock describing a wire shape the server no longer serves), and none is in this card's face:
packages/client/src/organization-get-active-member-addressing.test.ts(lines 40, 88, 120) models the anonymous answer as200 nullin a socket-level double. It stays green — the double serves itself — so it is stale rather than broken. Same hazard asclient.test.ts, which was in scope and fixed; this one is a thirdpackages/clientfile and outside the two-file fence lift.packages/client/src/index.tsdocblocks at:1474and:4389still describe the anonymous answer as200 null. ⛔ Deliberately untouched:index.tsis the runtime file the fence lift explicitly excluded, 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 owns published-annotation moves on it.auth.login/auth.registernormalize intodatabut never setsuccess— neither satisfies theSessionResponsethey declare #17234 is untouched. Ruling note 5 offered it as a possible round-mate, but its live state isneeds-user-decision+domain:cli— in the decision box and another lane's.Two stale card references found while re-verifying the ruling's premises: #14313 (named in the issue body as the
auth.*family card) returns HTTP 404 — it does not exist in this repository; control cards read with the same credential in the same batch resolved normally. And #17234 as above.Scope declaration
Clause-②: no, and the delivery is consistent with it: the accept set narrows (a 401 replaces a 200) rather than widening,UNAUTHENTICATEDis reused rather than minted,packages/specis not touched, and the new module is package-internal — it is not re-exported fromindex.tsand appears zero times in the published.d.ts.🤖 Generated with Claude Code
https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Generated by Claude Code