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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
📓 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
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