Repository navigation
fix(auth): in-process session reads no longer renew a browser session behind its cookie - #22367
Conversation
…ehind its cookie An in-process `auth.api.getSession` read renews a session past better-auth's `updateAge` and stages the renewed cookie on a response the door never sends, so the browser's cookie dies before its session. One rule in `@objectstack/types` (`inProcessSessionReadInput`): a request carrying a session cookie reads with `query.disableRefresh`, so a session renews only where its cookie is re-issued (`/get-session`); a bearer-only request renews as before. Applied at all ten in-process readers in rest, runtime, plugin-hono-server and cloud-connection. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
… and cloud-connection reader Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…earer-only, and the get-session control Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…h for the four reader packages Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…ward from the aged expiry Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…process session-read input Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
📓 Docs Drift CheckThis PR changes 5 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 38 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 7630364a8604b3ed1ce9ddf70b181a3ce7f28aa8 && git checkout 7630364a8604b3ed1ce9ddf70b181a3ce7f28aa8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 43fc50051cbde00b0da6b2d042df11b814a61d0c 8200f577855163b42b0b45470ca075308b353461 && git checkout -B drift-repro 43fc50051cbde00b0da6b2d042df11b814a61d0c && git merge --no-ff 8200f577855163b42b0b45470ca075308b353461
node scripts/docs-audit/affected-docs.mjs --json 43fc50051cbde00b0da6b2d042df11b814a61d0c
|
Contract reviewServed-tier: ① Derived judgmentsInputs read, 2026-10-08T23:02Z to 23:13Z. Card #22258 (body and all four comments: triage Public surface, Public surface, The behaviour, against the vendor. better-auth 1.7.3
The cookie test (
The ten readers. At the head, every non-test The edited source-text pin (
One comment-accuracy nit, no contract effect. The helper's docblock and the PR say better-auth applies "the same rule" to RSC reads at Docs-drift bot: advisory; ② Semver level
③ Boundary flagsThe dev's five
Implemented-by: VERDICT: PASS |
…ableRefresh its readers send (objectstack-ai#22406) Fixes objectstack-ai#22384 Clause-②: yes (widening) `AuthSessionApi.getSession` now declares the optional `query.disableRefresh` that the in-process readers send. A type-level pin in `packages/types` keeps the helper's return inside that declaration, key for key. ## What changes - **`packages/spec/src/contracts/auth-service.ts`.** - The declaration: `getSession?(input: { headers: unknown; query?: { disableRefresh?: boolean } })`. It was `{ headers: unknown }`. The return type and the rest of `AuthSessionApi` are unchanged. - The docblock now states the two read forms. A request carrying a better-auth session cookie reads with `{ headers, query: { disableRefresh: true } }`. A bearer-only request reads with `{ headers }` alone. - It names `inProcessSessionReadInput` (`packages/types/src/in-process-session-read.ts`) as the one source of both forms, and says what a hand-built `{ headers }` costs on a cookie request. - The old line "Widening this is for whoever needs more, with the call site to prove it" stays. The docblock now cites the ten call sites from PR objectstack-ai#22367 that send `query`: `rest-server.ts` (x2), `http-dispatcher.ts` (x2), `resolve-session-principal.ts`, `resolve-execution-context.ts`, `current-user-endpoints.ts`, `cloud-connection-plugin.ts` and `marketplace-install-local-plugin.ts` (x2). - **`packages/types/src/in-process-session-read.contract.test.ts`** (new). This is the pin, and it is the one file declared on objectstack-ai#6024 (comment `6072295794`). It is described below. - **`.changeset/22384-auth-session-api-getsession-input-query.md`.** `@objectstack/spec` `minor`. `@objectstack/types` gets no entry, because the only file changed there is a test, and `files[]` ships only `dist`, `README.md` and `CHANGELOG.md`. No consumer was touched. No reader changed in `domain:cli` or `domain:services`. ## The pin, and a dispatch assumption it falsified The dispatch asked for a type-level test that the helper's return is *assignable* to the declared input, and asked that narrowing the declaration back should make it fail. **Plain assignability cannot fail here.** TypeScript refuses an undeclared key only on an object literal. A non-literal value with an extra optional key is assignable to a type that omits that key. That is how all ten readers compiled against `{ headers: unknown }` while sending `query`. This was measured in the ablation below. With the declaration narrowed back, a throwaway probe compiled with **0 errors**. The probe held `const x: DeclaredInput = inProcessSessionReadInput(new Headers())` and the readers' own call shape, `api.getSession?.(inProcessSessionReadInput(...))`. So the pin checks assignability **key for key**. `FitsDeclared` applies the object-literal rule to a type: - the value is assignable; - it carries no key the declaration does not name; - this holds at every depth where the declaration names a shape (`headers` is `unknown`, so it is not looked into); - a union is judged one member at a time, never by its common keys. The file carries: - three `holds(true)` lines, each typed with `FitsDeclared` applied to `HelperInput` of a header type and `DeclaredInput`. There is one line per header shape the readers hand the helper: Web `Headers`, a Node header record, and `unknown`; - four `@ts-expect-error` controls that prove the instrument can fail at all. If `FitsDeclared` ever went vacuous, each directive would stop matching an error and tsc would report TS2578; - one runtime case: an implementer typed by the contract reads `input.query?.disableRefresh`. That read is itself a compile-time check, and it asserts a cookie read does not renew while a bearer read does. **Mechanism.** The test runs under the package's existing `typecheck` script (`tsc --noEmit`). `packages/types/tsconfig.json` includes `src/**/*`, tests included, and `--listFiles` lists the new file once. CI's `TypeScript Type Check` runs it, and it reads `@objectstack/spec` from its built `.d.ts`. This follows the `@ts-expect-error` compile-time pins already in `response-envelope.test.ts`. No new runner was added. ## Reverse verification (one-off; nothing kept) The run is at `769d9f4db`, after the fix was committed. It used `scripts/ablation-replace.mjs` in wrap mode and `scripts/ablation-dist-preflight.mjs`, under the shared verify lock. The predicted direction was red on the three `holds` and on the `query` read, with the controls unchanged. That is what happened. | leg | reading | |---|---| | pristine dist | marker `disableRefresh?: boolean` present in `dist/contracts/index.d.ts` and `.d.mts`; tree clean | | mutate | anchor `{ headers: unknown; query?: { disableRefresh?: boolean } }` x1 -> x0; blob `698dd54bd9e8` -> `2df53d7829bd`; spec rebuilt (exit 0); preflight `--absent`: marker absent from all 232 built files | | `tsc --noEmit` in `packages/types`, mutated | **exit 2**: `TS2344` at `:76`, `:77`, `:78` (the three `holds`), `TS2339` at `:96` (`Property 'query' does not exist on type '{ headers: unknown; }'`); **0** errors in the plain-assignability probe | | restore | blob after restore `698dd54bd9e8` == HEAD; `git diff HEAD` empty; spec rebuilt; preflight (present) green; tree clean | | `tsc --noEmit` in `packages/types`, restored | **exit 0** | The head after that run (`57d9d9a8c`) changes only docblock text in `auth-service.ts`: 6 lines added and 5 removed, all inside the comment. The declaration line is byte-identical. ## Consumers Every consumer that types against `AuthSessionApi` keeps compiling, and none was edited: - The ten readers pass `inProcessSessionReadInput(...)`. That input is now declared instead of tolerated. - The readers that still pass `{ headers }` by hand: `plugin-auth` (x4), `plugin-webhooks`, `plugin-sharing`, `service-storage`, `service-settings`, `service-datasource`, and the dogfood `armed.ts` harness. `query` is optional, so they type as before. Moving them to the helper is objectstack-ai#22258's remaining half and is not done here. - An implementer of `getSession` that declares `{ headers: unknown }` still satisfies the contract, because the wider input is assignable to it. The `typecheck` of both edited packages is green (below). The downstream consumer typecheck is left to CI's `TypeScript Type Check`. ## Verification At `57d9d9a8c` (final head): - **Build.** `pnpm exec turbo run build --filter='!@objectstack/docs' --concurrency=2`: 72 of 72 tasks successful, and `git status --porcelain` was empty afterwards. `packages/spec/dist/contracts/index.d.ts` carries the new declaration and docblock. - **`@objectstack/types`.** - `typecheck`: exit 0. `tsc --noEmit --listFiles` lists `in-process-session-read.contract.test.ts` once. - `test` (local): 26 files, 750 tests passed. - `test:repo`: 1 file, 11 tests passed. - The pin file run alone: 1 file, 1 test passed. - **`@objectstack/spec`.** - `typecheck`: exit 0. This covers `tsc --noEmit`, `check:scripts-typecheck` and `check:test-typecheck`. - `test:repo`: 54 files, 915 tests passed. - `test` (local, `--maxWorkers=2`): 626 files, 18742 tests passed, 1 todo. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 85 commands at `57d9d9a8c`. All 85 ran, each exit code captured before any pipe, and all 85 exited 0. The `--ran` reconciliation reads: "85 derived famil(ies) accounted for — 85 run, 0 NOT-MEASURED (a DERIVED zero — all 85 recorded an exit code and none of them is 3)". Selected verdict lines: - `check:api-surface`: "public API surface + factory signatures unchanged". - `check-adr-0087-registration`: "this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)". - `check:nul-bytes`: OK. - `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:doc-formula-expressions` were measured after the full build. Their earlier runs at `769d9f4db` exited 3 ("prerequisite not met"); those runs are not counted. - **`check-changeset-no-major --base origin/main`.** Run locally, it prints "LEVEL AXIS: NOT APPLICABLE" because there is no `pull_request` payload. The reading of `Clause-②: yes (widening)` against `minor` is CI's on this PR. - **Lint, narrowed (CI owns the full run).** I ran `pnpm exec eslint --no-inline-config --format json` on the two changed `.ts` files: 2 files, 0 errors, 0 warnings. The `.changeset` file falls outside eslint's config (eslint reports it as ignored). `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`; see its comment near `:326`), so this diff cannot change a verdict on any untouched file. - **Base.** The branch is 8 commits behind `origin/main` (`11d119ab1`). None of those commits touches `packages/spec/src/contracts/` or `packages/types/`, and the merge queue rebuilds the merged generation. ## Acceptance notes - **The declaration's value is `boolean`, but the helper only ever sends `true`.** This follows the card and the claim. `FitsDeclared` accepts `true` against `boolean`. A helper that started sending `false` would also fit, and `false` would mean renewal. - **The docblock names files rather than lines.** The call sites move often, so a line number would go stale on the next edit. The pin names the helper, not the readers, and nothing checks the list of ten. A reader added or moved later leaves the list stale but the declaration correct. - `check:entry-nameability` prints a standing `NOT MEASURED` for `@objectstack/spec/api-assembled` and `@objectstack/spec/qa` (no callable export). This is unrelated to `contracts`. The gate exits 0. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #22258
Clause-②: yes (widening)
Release: the domain:services half of this card stays open, carried by domain:services. Nine in-process readers there (plugin-auth, plugin-webhooks, plugin-sharing, service-storage, service-settings, service-datasource; table H5 below) still renew a cookie session without re-issuing its cookie.
What this changes
An in-process
auth.api.getSessionread renews a session past better-auth'supdateAgeand stages the renewed cookie on a response the door never sends. The browser's cookie then dies before its session: a split session, a dead cookie beside a live bearer.One rule now covers every in-process reader in this lane. It lives in one helper,
inProcessSessionReadInput(headers)in@objectstack/types, and is decided by what the request carries:query: { disableRefresh: true }. The session renews only throughGET /api/v1/auth/get-session, which re-issues the cookie, so cookie and session expire together.The rule only ever adds
disableRefresh. It sets no cookie, forwards none, and never changes which session a request resolves to.Applied at all ten readers in this lane. The census on
b7e01fbbdfound six. Four more use the optional-chained spellingapi?.getSession?.(, which the.getSession(regex did not match:rest-server.ts:3037computeExecCtxgetterrest-server.ts:3224auth-gate re-readhttp-dispatcher.ts:1362enforceAuthGatehttp-dispatcher.ts:1442enforceProjectMembershipsecurity/resolve-session-principal.ts:57(rate limiter, concrete route mounts)security/resolve-execution-context.ts:165(dispatcher scope, MCP door)current-user-endpoints.ts:412marketplace-install-local-plugin.ts:2624resolveActiveOrgIdmarketplace-install-local-plugin.ts:2794resolveInstallPrincipalgettercloud-connection-plugin.ts:209session bridgeThe four extra readers are fixed in place under the bounded in-place-fix rule: the same defect class, the same one-line call, no other claim on those files, and the same packages and gates. Their doors split measurably: the MCP door and
/i18n/localesgo throughresolve-execution-context(H1, ablation 2). This adds two files to the claim's file surface:packages/runtime/src/security/resolve-execution-context.tsandpackages/cloud-connection/src/cloud-connection-plugin.ts.Where the helper lives, and why there. The claim suggested a helper in
packages/runtime. That cannot serve all ten readers:restcannot importruntime(runtime depends on rest, so it would be a cycle), andplugin-hono-serverdoes not depend on runtime.@objectstack/typesis in this lane and is already a dependency of all four reader packages, which is the same "one home, no new edge" reasoning that file's barrel records for its other shared rules.Why
Part of, and whyClause-②: yesPart of. Triage's done-when reads "No framework door extends a session without forwarding its cookie". After this PR, every door in this lane holds (H1). The ninedomain:servicesreaders still split a session through public doors (H5), so the card stays open for them.Clause-②: yes (widening), not the claim'sno. The claim's gloss onnowas "No accepted input, export or published shape changes", written before the helper's home was chosen. The one shared helper has to be an export of a published package, so@objectstack/typesgains three named exports:inProcessSessionReadInput,carriesSessionCookieand theInProcessSessionReadInputtype. That is an additive widening of a published package's public surface, which theCheck Changeset"WHICH LEVEL" ruling grades at leastminor. The changeset is thereforeminorfor@objectstack/typesandpatchforrest,runtime,plugin-hono-serverandcloud-connection. Raised with the seat in the dev report; no accepted input and no wire shape changes.H1: reproduced through public doors, then measured on the fix
The probe is a fresh
pnpm dev:crm -- --freshstack, run onb7e01fbbdand again on this branch, with better-auth 1.7.3 as pinned.expiresIn(604800 s) is read from the freshsys_sessionrow; the pin reads both values off the running instance'ssessionConfig, andupdateAgeis 86400 s. Each row ages the signed-in session insys_sessiontonow + expiresIn − updateAge − 60 s, sends one request, and readsexpires_atback.expires_at· sessionSet-CookieGET /api/v1/auth/get-session(control)Max-Age=604800Max-Age=604800GET /api/v1/data/sys_userGET /api/v1/data/sys_userGET /api/v1/auth/me/permissionsGET /api/v1/auth/me/permissionsGET /api/v1/meta/objectGET /api/v1/i18n/localesGET /api/v1/packagesGET /api/v1/marketplace/install-localGET /api/v1/mcp(answers 406)Every door's bearer row is unchanged by the fix (+86460 s, no cookie); only the first two doors are shown here.
H3: who holds only a bearer
These clients would lose renewal under a blanket
disableRefresh. Measured by reading where each one sends its credential and when it reachesget-session:@objectstack/clientsendsAuthorization: Bearerfrom its stored token on every request (fetch). It callsget-sessiononly on an explicitauth.me()orrefreshToken(). Outside a browser it holds no cookie jar, so it is bearer-only.get-sessiononly atos loginandos cloud whoami. Its data commands (datasource list-tables,validate,introspect,package publish,plugin publish) carry a bearer.credentials: 'include') beside the stored bearer, and it callsget-sessionon mount and on every re-resolution (AuthProvider.loadSession).Before: every bearer-only data read past
updateAgerenewed, +86460 s on every door above. A blanketdisableRefreshwould end that, and a CLI or SDK session would dieexpiresIn(7 days) after sign-in however active. After: bearer-only reads still renew, +86460 s on every door (measured above, and pinned).H4: the rule, chosen on that measurement
Set-Cookiefrom every door: measured and not taken.me/*endpoints and two cloud-connection routes, all on a Honoc.computeExecCtx(environmentId, req)has no response object and is cached per request across many routes.{ status, body }. A forward there would need a header channel through every dispatcher result and every adapter'ssendResult.disableRefresh; a bearer-only request reads as before. better-auth applies the same rule to its own reads that cannot write a cookie (React Server Components,dist/integrations/next-js.mjs:62-69).H5: the
domain:servicesreaders, measured and not editedMeasured on this branch's build, so a remaining renewal belongs to the services reader and not to a lane reader that ran on the same request. Line numbers are at
b7e01fbbd.auth-plugin.ts:2464POST /api/v1/auth/admin/oauth2/toggle-disabledauth-plugin.ts:2527(gateAdmin, every admin route behind it)POST /api/v1/auth/admin/sso/registerauth-plugin.ts:2594POST /api/v1/auth/admin/unlock-userauth-plugin.ts:2912POST /api/v1/auth/admin/has-permissionwebhook-outbox-plugin.ts:482POST /api/v1/webhooks/redeliver(showcase stack)storage-service-plugin.ts:843GET /api/v1/storage/upload/chunked/:id/progresssharing-plugin.ts:940(not in the PM census)DELETE /api/v1/share-links/:idsettings-service-plugin.ts:299(not in the PM census)GET /api/settingsadmin-routes.ts:212(not in the PM census)GET /api/v1/datasources/driversThe fix there is the same rule:
api.getSession(inProcessSessionReadInput(headers)). Five of the six packages already depend on@objectstack/types;plugin-webhookswould gain that one dependency.Pins, and the tier each runs in
All pins run in each package's
localvitest project (CI:Test Core). The runtime pin boots an in-processObjectKernel, with no spawned process and no driver socket.packages/types/src/in-process-session-read.test.ts: the cookie test across every spelling better-auth writes (default and customcookiePrefix,__Secure-). Also: other cookies, empty values, plain header records, and bearer-only. The input builder passes the same headers object through and addsqueryonly for a cookie.packages/runtime/src/in-process-session-renewal.pin.test.ts, against real better-auth:expiresInandupdateAgeoff the running instance. A precondition proves the fixture renews: a bare in-process read without the rule movesexpires_at.GET /data/:object(rest),GET /auth/me/permissions(hono) andGET /i18n/locales(dispatcherresolveExecutionContext).now + expiresIn, and no cookie is set on its response.resolveSessionPrincipalId) by cookie and by bearer. After the limiter's read,get-sessionstill renews AND re-issues the cookie.get-sessionrenews and re-issues withMax-Age = expiresIn.packages/rest/src/in-process-session-read.pin.test.ts,packages/plugins/plugin-hono-server/src/in-process-session-read.pin.test.ts,packages/cloud-connection/src/in-process-session-read.pin.test.ts: every remaining reader hands better-auth the rule's input. The cases are cookie, cookie plus bearer, and bearer-only. Covered: REST's getter and gate re-read; the Hono resolver; the cloud-connection session bridge,resolveActiveOrgIdandresolveInstallPrincipal.packages/rest/src/execctx-authz-input-seam-reachability.test.ts: its source-text pin on the auth-gate re-read now reads the new argument. Its intent is unchanged: still the raw, throwing api call.Ablation, run twice: drop the rule from one reader
Both runs used
scripts/ablation-replace.mjsin wrap mode, inside a script with an absolute-path restore trap. In both suites the mutated reader resolves fromsrc(rest: a relative import; runtime: the@objectstack/restalias and a relative import), so nodistleg applies.computeExecCtxgetter reverted toapi.getSession({ headers: h }). The anchor went 1 → 0 and the blob89fae0b5e5f5→677bb44cb288.GET /data/:object — by cookiewent red: "the session renewed (+86460 s) but its cookie was not re-issued".89fae0b5e5f5, andgit diff HEADis empty.resolve-execution-contextgetter reverted the same way. The blob wentc570e6e4cd84→2e86b8e71527.GET /i18n/locales — by cookiewent red.c570e6e4cd84, and the diff is empty.Verification
All at HEAD
8200f5778, the last commit on this branch:pnpm --filter PKG test, each package'slocalproject):types: 25 files, 749 passed.rest: 264 files, 4954 passed, 326 skipped.runtime: 341 files, 4787 passed, 19 skipped.plugin-hono-server: 28 files, 329 passed.cloud-connection: 42 files, 514 passed.test:repo:types11,rest191 (1 skipped),runtime751, all passed.node scripts/pm/dispatch-gates.mjs --commandsderives for this diff. That is the order's 61 pluscheck:engine-double-contract,check:objectql-double-limit,check:query-options-erasure,check:type-check-coverage,check:type-check-debtandcheck:where-matcher. All exit 0. The--ranreconciliation reads 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.pnpm lint(eslint . --no-inline-config) exits 0.origin/main, which is two commits ahead (fix(cli):os migrate metalists and writes a conversion the load already applies, on a stack the schema accepts #22351 cli, feat(plugin-security,plugin-auth): the grant readers read the permission-set name column (ADR-0131 D4, C2 stage S5b) #22352 plugin-security and plugin-auth). Neither touches a file here, and CI tests the merge ref.Acceptance notes
get-session. Such a tab now signs out at the session's real expiry, instead of keeping a live bearer beside a dead cookie. The console callsget-sessionon mount and on each re-resolution.domain:spec(not edited here).AuthSessionApiinpackages/spec/src/contracts/auth-service.tsdeclaresgetSession's input as{ headers }. Its doc says every reader "calls exactlygetSession({ headers })", which is no longer true. The helper declares the wider slice it passes (query.disableRefresh) itself; that type-checks because the extra key reaches a non-fresh object. Carrier: thedomain:specseat.get-sessionre-issues the session cookie on a bearer-only request too (measured:Max-Age=604800with a bearer). That route is better-auth's, and this PR does not touch it.packages/runtime/src/http-dispatcher.tsin two comment hunks (around lines 1241 and 1508), disjoint from this PR's hunks (around 1344-1362 and 1420-1442).objectstack-ai/cloudissue 2699's report and PR 2708) was not readable from this session. H1 re-measured the defect independently on this repo's doors.Generated by Claude Code