Skip to content

Commit a45d5d8

Browse files
fix(auth): in-process session reads no longer renew a browser session behind its cookie (#22367)
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.getSession` read renews a session past better-auth's `updateAge` and 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: - **A session cookie** (a browser, including the console, which sends its cookie beside its bearer): the read passes `query: { disableRefresh: true }`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie, so cookie and session expire together. - **No session cookie** (a bearer-only client): read exactly as before, renewal included. No cookie exists to fall behind, and the bearer is the session token, which renewal does not change. 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 `b7e01fbbd` found six. Four more use the optional-chained spelling `api?.getSession?.(`, which the `.getSession(` regex did not match: | package | reader (line on this branch) | in the PM census | |---|---|---| | rest | `rest-server.ts:3037` `computeExecCtx` getter | yes | | rest | `rest-server.ts:3224` auth-gate re-read | yes | | runtime | `http-dispatcher.ts:1362` `enforceAuthGate` | yes | | runtime | `http-dispatcher.ts:1442` `enforceProjectMembership` | **no** | | runtime | `security/resolve-session-principal.ts:57` (rate limiter, concrete route mounts) | yes | | runtime | `security/resolve-execution-context.ts:165` (dispatcher scope, MCP door) | **no** | | plugin-hono-server | `current-user-endpoints.ts:412` | yes | | cloud-connection | `marketplace-install-local-plugin.ts:2624` `resolveActiveOrgId` | yes | | cloud-connection | `marketplace-install-local-plugin.ts:2794` `resolveInstallPrincipal` getter | **no** | | cloud-connection | `cloud-connection-plugin.ts:209` session bridge | **no** | The 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/locales` go through `resolve-execution-context` (H1, ablation 2). This adds two files to the claim's file surface: `packages/runtime/src/security/resolve-execution-context.ts` and `packages/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: `rest` cannot import `runtime` (runtime depends on rest, so it would be a cycle), and `plugin-hono-server` does not depend on runtime. `@objectstack/types` is 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 why `Clause-②: yes` - **`Part 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 nine `domain:services` readers still split a session through public doors (H5), so the card stays open for them. - **`Clause-②: yes (widening)`, not the claim's `no`.** The claim's gloss on `no` was "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/types` gains three named exports: `inProcessSessionReadInput`, `carriesSessionCookie` and the `InProcessSessionReadInput` type. That is an additive widening of a published package's public surface, which the `Check Changeset` "WHICH LEVEL" ruling grades at least `minor`. The changeset is therefore `minor` for `@objectstack/types` and `patch` for `rest`, `runtime`, `plugin-hono-server` and `cloud-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 -- --fresh` stack, run on `b7e01fbbd` and again on this branch, with better-auth 1.7.3 as pinned. `expiresIn` (604800 s) is read from the fresh `sys_session` row; the pin reads both values off the running instance's `sessionConfig`, and `updateAge` is 86400 s. Each row ages the signed-in session in `sys_session` to `now + expiresIn − updateAge − 60 s`, sends one request, and reads `expires_at` back. | door | by | before: Δ `expires_at` · session `Set-Cookie` | after | |---|---|---|---| | `GET /api/v1/auth/get-session` (control) | cookie | +86460 s · `Max-Age=604800` | +86460 s · `Max-Age=604800` | | `GET /api/v1/data/sys_user` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/data/sys_user` | bearer | +86460 s · none | +86460 s · none | | `GET /api/v1/auth/me/permissions` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/auth/me/permissions` | bearer | +86460 s · none | +86460 s · none | | `GET /api/v1/meta/object` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/i18n/locales` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/packages` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/marketplace/install-local` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/mcp` (answers 406) | cookie | +86460 s · none | **0 · none** | 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 reaches `get-session`: - **`@objectstack/client`** sends `Authorization: Bearer` from its stored token on every request (`fetch`). It calls `get-session` only on an explicit `auth.me()` or `refreshToken()`. Outside a browser it holds no cookie jar, so it is bearer-only. - **The CLI** is bearer-only. It reaches `get-session` only at `os login` and `os cloud whoami`. Its data commands (`datasource list-tables`, `validate`, `introspect`, `package publish`, `plugin publish`) carry a bearer. - **MCP:** OAuth access tokens are verified separately and are not better-auth sessions, so they are unaffected. API keys are unaffected too. A session bearer presented there is bearer-only. - **objectui console:** NOT bearer-only. Its fetches send the cookie (`credentials: 'include'`) beside the stored bearer, and it calls `get-session` on mount and on every re-resolution (`AuthProvider.loadSession`). **Before:** every bearer-only data read past `updateAge` renewed, +86460 s on every door above. A blanket `disableRefresh` would end that, and a CLI or SDK session would die `expiresIn` (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 - **Forward the renewed `Set-Cookie` from every door: measured and not taken.** - Only three of the ten readers have a response in hand: the Hono `me/*` endpoints and two cloud-connection routes, all on a Hono `c`. - REST's `computeExecCtx(environmentId, req)` has no response object and is cached per request across many routes. - The dispatcher is transport-neutral and returns `{ status, body }`. A forward there would need a header channel through every dispatcher result and every adapter's `sendResult`. - The rate limiter reads before the route. - A request can pass two or three in-process reads (REST: 2, dispatcher: up to 3), and only the first renews. - A forward would still need this same cookie test, so that no cookie is ever set on a request that sent none. - So forwarding cannot be one rule for all ten. - **Taken: the PM's lean, unchanged.** A cookie request reads with `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:services` readers, measured and not edited Measured 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`. | reader | door | by cookie | by bearer | |---|---|---|---| | plugin-auth `auth-plugin.ts:2464` | `POST /api/v1/auth/admin/oauth2/toggle-disabled` | +86460 s · no cookie (**split**) | +86460 s | | plugin-auth `auth-plugin.ts:2527` (`gateAdmin`, every admin route behind it) | `POST /api/v1/auth/admin/sso/register` | +86460 s · no cookie (**split**) | +86460 s | | plugin-auth `auth-plugin.ts:2594` | `POST /api/v1/auth/admin/unlock-user` | +86460 s · no cookie (**split**) | +86460 s | | plugin-auth `auth-plugin.ts:2912` | `POST /api/v1/auth/admin/has-permission` | +86460 s · no cookie (**split**) | +86460 s | | plugin-webhooks `webhook-outbox-plugin.ts:482` | `POST /api/v1/webhooks/redeliver` (showcase stack) | +86460 s · no cookie (**split**) | +86460 s | | service-storage `storage-service-plugin.ts:843` | `GET /api/v1/storage/upload/chunked/:id/progress` | +86460 s · no cookie (**split**) | +86460 s | | plugin-sharing `sharing-plugin.ts:940` (not in the PM census) | `DELETE /api/v1/share-links/:id` | +86460 s · no cookie (**split**) | +86460 s | | service-settings `settings-service-plugin.ts:299` (not in the PM census) | `GET /api/settings` | +86460 s · no cookie (**split**) | +86460 s | | service-datasource `admin-routes.ts:212` (not in the PM census) | `GET /api/v1/datasources/drivers` | +86460 s · no cookie (**split**) | +86460 s | **The fix there is the same rule:** `api.getSession(inProcessSessionReadInput(headers))`. Five of the six packages already depend on `@objectstack/types`; `plugin-webhooks` would gain that one dependency. ## Pins, and the tier each runs in All pins run in each package's `local` vitest project (CI: `Test Core`). The runtime pin boots an in-process `ObjectKernel`, 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 custom `cookiePrefix`, `__Secure-`). Also: other cookies, empty values, plain header records, and bearer-only. The input builder passes the same headers object through and adds `query` only for a cookie. - `packages/runtime/src/in-process-session-renewal.pin.test.ts`, against real better-auth: - It reads `expiresIn` and `updateAge` off the running instance. A precondition proves the fixture renews: a bare in-process read without the rule moves `expires_at`. - Three doors, each by cookie and by bearer: `GET /data/:object` (rest), `GET /auth/me/permissions` (hono) and `GET /i18n/locales` (dispatcher `resolveExecutionContext`). - Pinned for each: a cookie request leaves cookie and session expiry aligned (no renewal, no cookie). A bearer-only request still renews to `now + expiresIn`, and no cookie is set on its response. - The rate limiter's reader (`resolveSessionPrincipalId`) by cookie and by bearer. After the limiter's read, `get-session` still renews AND re-issues the cookie. - Control: `get-session` renews and re-issues with `Max-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, `resolveActiveOrgId` and `resolveInstallPrincipal`. - `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.mjs` in wrap mode, inside a script with an absolute-path restore trap. In both suites the mutated reader resolves from `src` (rest: a relative import; runtime: the `@objectstack/rest` alias and a relative import), so no `dist` leg applies. 1. **rest `computeExecCtx` getter** reverted to `api.getSession({ headers: h })`. The anchor went 1 → 0 and the blob `89fae0b5e5f5` → `677bb44cb288`. - Runtime pin: **1 failed | 10 passed**. Exactly `GET /data/:object — by cookie` went red: "the session renewed (+86460 s) but its cookie was not re-issued". - rest pin: **2 failed | 1 passed** (both cookie cases red; bearer-only green). - Restored: blob == HEAD `89fae0b5e5f5`, and `git diff HEAD` is empty. 2. **runtime `resolve-execution-context` getter** reverted the same way. The blob went `c570e6e4cd84` → `2e86b8e71527`. - Runtime pin: **1 failed | 10 passed**. Exactly `GET /i18n/locales — by cookie` went red. - Restored: blob == HEAD `c570e6e4cd84`, and the diff is empty. ## Verification All at HEAD `8200f5778`, the last commit on this branch: - **Unit tests** (`pnpm --filter PKG test`, each package's `local` project): - `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`: `types` 11, `rest` 191 (1 skipped), `runtime` 751, all passed. - **Typecheck** for the five packages: 41 of 41 turbo tasks green. Each test layer compiles; runtime is at its existing ledger (27 files, 190 errors), unchanged. - **Gates**: the 67 commands that `node scripts/pm/dispatch-gates.mjs --commands` derives for this diff. That is the order's 61 plus `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. All exit 0. The `--ran` reconciliation reads 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. - **Lint**: the full `pnpm lint` (`eslint . --no-inline-config`) exits 0. - **Not merged with `origin/main`**, which is two commits ahead (#22351 cli, #22352 plugin-security and plugin-auth). Neither touches a file here, and CI tests the merge ref. ## Acceptance notes - **Residue the server cannot see.** Some browser requests carry the bearer without the cookie (a cross-origin fetch without credentials, or a blocked cookie). Those read as bearer-only and still renew in-process. If the same browser sends that session's cookie on other requests, that cookie can still fall behind. The rule decides from what each request carries. - **A behaviour change for a tab that never calls `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 calls `get-session` on mount and on each re-resolution. - **Declaration drift, for `domain:spec` (not edited here).** `AuthSessionApi` in `packages/spec/src/contracts/auth-service.ts` declares `getSession`'s input as `{ headers }`. Its doc says every reader "calls exactly `getSession({ 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: the `domain:spec` seat. - **Vendor behaviour, unchanged here.** better-auth's own `get-session` re-issues the session cookie on a bearer-only request too (measured: `Max-Age=604800` with a bearer). That route is better-auth's, and this PR does not touch it. - **Overlap.** Open PR #22357 edits `packages/runtime/src/http-dispatcher.ts` in two comment hunks (around lines 1241 and 1508), disjoint from this PR's hunks (around 1344-1362 and 1420-1442). - **Unread.** The cloud measurement the card cites (`objectstack-ai/cloud` issue 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](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8311650 commit a45d5d8

16 files changed

Lines changed: 907 additions & 14 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/types': minor
3+
'@objectstack/rest': patch
4+
'@objectstack/runtime': patch
5+
'@objectstack/plugin-hono-server': patch
6+
'@objectstack/cloud-connection': patch
7+
---
8+
9+
fix(auth): a server-side session read no longer renews a browser session behind its cookie (#22258)
10+
11+
**What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Ten doors read the session in-process (`auth.api.getSession`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, later `GET /api/v1/auth/get-session` calls found a fresh row and re-issued nothing, and the cookie expired first: a dead cookie beside a live bearer, and every cookie-only path then saw a signed-out user. Measured on a fresh dev stack (better-auth 1.7.3, `expiresIn` 604800 s, `updateAge` 86400 s, a session aged to `now + expiresIn − updateAge − 60 s`): `GET /api/v1/data/:object`, `GET /api/v1/auth/me/permissions`, `GET /api/v1/meta/object`, `GET /api/v1/i18n/locales`, `GET /api/v1/packages`, `GET /api/v1/marketplace/install-local` and the MCP door each moved `expires_at` by +86460 s and set no session cookie.
12+
13+
**The rule now, decided by what the request carries.**
14+
15+
- **A session cookie** (a browser, including a console that sends its cookie beside its bearer): the in-process read passes `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. Measured after the change: every door above leaves `expires_at` unchanged on a cookie request and sets no cookie.
16+
- **No session cookie** (a bearer-only client: `@objectstack/client` outside a browser, the `os` CLI): unchanged. A data read past `updateAge` still renews the session (+86460 s, measured on the same doors), so an active bearer client keeps sliding forward without calling `get-session`. No cookie is ever set on a response to a request that sent none.
17+
18+
**Upgrading.** Nothing to change. A browser session renews whenever the app calls `GET /api/v1/auth/get-session`; a tab that never calls it now signs out at the session's real expiry instead of keeping a live bearer beside a dead cookie.
19+
20+
`@objectstack/types` gains `inProcessSessionReadInput(headers)` (the `getSession` input for an in-process read: the request's own headers, plus `query: { disableRefresh: true }` when they carry a better-auth session cookie), `carriesSessionCookie(headers)` and the `InProcessSessionReadInput` type. A host that calls `auth.api.getSession` itself should read through `inProcessSessionReadInput` for the same reason.
21+
22+
Not changed here: the in-process readers in `@objectstack/plugin-auth`, `@objectstack/plugin-webhooks`, `@objectstack/plugin-sharing`, `@objectstack/service-storage`, `@objectstack/service-settings` and `@objectstack/service-datasource` still renew a cookie session without re-issuing its cookie.

‎packages/cloud-connection/src/cloud-connection-plugin.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ interface Plugin {
7878
}
7979

8080
import { hostname } from 'node:os';
81+
import { inProcessSessionReadInput } from '@objectstack/types';
8182
import { ConnectionCredentialStore } from './connection-credential-store.js';
8283
import { CLOUD_CONNECTION_UI_BUNDLE } from './cloud-connection-ui.js';
8384

@@ -202,7 +203,10 @@ export class CloudConnectionPlugin implements Plugin {
202203

203204
const sessionFromAuthService = async (authSvc: any, rawReq: Request): Promise<{ userId?: string } | null> => {
204205
const api = typeof authSvc?.getApi === 'function' ? await authSvc.getApi() : authSvc?.api ?? authSvc;
205-
const session = await api?.getSession?.({ headers: rawReq.headers });
206+
// [#22258] The in-process session-read rule: a request carrying a
207+
// session cookie reads without renewal, because this route's
208+
// response never carries a renewed cookie.
209+
const session = await api?.getSession?.(inProcessSessionReadInput(rawReq.headers));
206210
const userId = session?.user?.id ? String(session.user.id) : undefined;
207211
return userId ? { userId } : null;
208212
};
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22258] All three of this package's in-process `auth.api.getSession`
5+
* readers hand better-auth the in-process session-read rule's input
6+
* (`inProcessSessionReadInput`, `@objectstack/types`):
7+
*
8+
* ① `CloudConnectionPlugin`'s session bridge behind `/api/v1/cloud-connection/*`;
9+
* ② `MarketplaceInstallLocalPlugin.resolveActiveOrgId` (the scoping read);
10+
* ③ `MarketplaceInstallLocalPlugin.resolveInstallPrincipal`'s session getter,
11+
* handed to `resolveAuthzContext` (the admission read).
12+
*
13+
* A request carrying a session cookie reads with `query.disableRefresh`: these
14+
* routes answer with their own response, so a renewal here would move the
15+
* session's expiry while its renewed cookie is discarded. A bearer-only request
16+
* reads exactly as before, renewal included. What `disableRefresh` then DOES
17+
* against real better-auth is pinned end to end in
18+
* `packages/runtime/src/in-process-session-renewal.pin.test.ts`.
19+
*/
20+
21+
import { describe, it, expect, vi, afterEach } from 'vitest';
22+
import { CloudConnectionPlugin } from './cloud-connection-plugin.js';
23+
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
24+
25+
const SESSION_COOKIE = 'better-auth.session_token=tok_22258.c2lnbmF0dXJl';
26+
const BEARER = 'Bearer tok_22258.c2lnbmF0dXJl';
27+
const USER = 'usr_22258';
28+
29+
type Shape = { name: string; headers: Record<string, string> };
30+
const COOKIE: Shape = { name: 'a session cookie', headers: { cookie: SESSION_COOKIE } };
31+
const BOTH: Shape = { name: 'cookie AND bearer (the console)', headers: { cookie: SESSION_COOKIE, authorization: BEARER } };
32+
const BEARER_ONLY: Shape = { name: 'a bearer only', headers: { authorization: BEARER } };
33+
34+
/** An auth service whose session API records every input it is handed. */
35+
function recordingAuth() {
36+
const calls: any[] = [];
37+
return {
38+
calls,
39+
service: {
40+
api: {
41+
getSession: async (input: any) => {
42+
calls.push(input);
43+
return { user: { id: USER }, session: { activeOrganizationId: 'org_22258' } };
44+
},
45+
},
46+
},
47+
};
48+
}
49+
50+
/** The rule, stated once: a cookie request never renews in-process; a bearer-only one is untouched. */
51+
function expectTheRule(calls: any[], shape: Shape) {
52+
expect(calls.length, `${shape.name}: the reader never ran`).toBeGreaterThan(0);
53+
for (const input of calls) {
54+
if (shape.headers.cookie) {
55+
expect(input.query, `${shape.name}: a cookie request renewed in-process`).toEqual({ disableRefresh: true });
56+
} else {
57+
expect('query' in input, `${shape.name}: a bearer-only read lost its renewal`).toBe(false);
58+
}
59+
const h = input.headers;
60+
expect(h.get('authorization') ?? undefined).toBe(shape.headers.authorization);
61+
expect(h.get('cookie') ?? undefined).toBe(shape.headers.cookie);
62+
}
63+
}
64+
65+
afterEach(() => {
66+
vi.unstubAllGlobals();
67+
});
68+
69+
describe('[#22258] ① the cloud-connection routes read the session by the in-process rule', () => {
70+
async function readInstalled(shape: Shape) {
71+
const routes = new Map<string, (c: any) => Promise<any>>();
72+
const rawApp = {
73+
get: (path: string, h: any) => routes.set(`GET ${path}`, h),
74+
post: (path: string, h: any) => routes.set(`POST ${path}`, h),
75+
};
76+
const auth = recordingAuth();
77+
const hooks = new Map<string, (...args: any[]) => any>();
78+
const ctx = {
79+
hook: (event: string, handler: (...args: any[]) => any) => hooks.set(event, handler),
80+
getService: (name: string) => {
81+
if (name === 'http-server') return { getRawApp: () => rawApp };
82+
if (name === 'auth') return auth.service;
83+
throw new Error(`service ${name} not registered`);
84+
},
85+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
86+
};
87+
// No control-plane credential: the route answers locally after the
88+
// session read, so nothing leaves the process.
89+
await new CloudConnectionPlugin({ singleEnvironment: true, environmentId: 'env-22258', controlPlaneUrl: '' })
90+
.start(ctx as any);
91+
await hooks.get('kernel:ready')?.();
92+
const url = 'http://localhost:3000/api/v1/cloud-connection/installed';
93+
const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
94+
const res = await routes.get('GET /api/v1/cloud-connection/installed')!({
95+
req: { url, raw: new Request(url, { headers: shape.headers }), json: async () => ({}) },
96+
json,
97+
});
98+
return { res, calls: auth.calls };
99+
}
100+
101+
for (const shape of [COOKIE, BOTH, BEARER_ONLY]) {
102+
it(`${shape.name}`, async () => {
103+
const { res, calls } = await readInstalled(shape);
104+
expect(res.status, 'the session resolved — the route answered past its 401').toBe(200);
105+
expectTheRule(calls, shape);
106+
});
107+
}
108+
});
109+
110+
describe('[#22258] ② ③ the install-local doors read the session by the in-process rule', () => {
111+
function pluginWith(shape: Shape) {
112+
const auth = recordingAuth();
113+
const ctx: any = {
114+
getService: (name: string) => {
115+
if (name === 'auth') return auth.service;
116+
throw new Error(`service ${name} not registered`);
117+
},
118+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
119+
};
120+
const c = { req: { raw: new Request('http://localhost/api/v1/marketplace/install-local', { headers: shape.headers }) } };
121+
return { plugin: new MarketplaceInstallLocalPlugin() as any, ctx, c, calls: auth.calls };
122+
}
123+
124+
for (const shape of [COOKIE, BOTH, BEARER_ONLY]) {
125+
it(`② resolveActiveOrgId — ${shape.name}`, async () => {
126+
const { plugin, ctx, c, calls } = pluginWith(shape);
127+
expect(await plugin.resolveActiveOrgId(c, ctx), 'the scoping read resolved the session').toBe('org_22258');
128+
expectTheRule(calls, shape);
129+
});
130+
131+
it(`③ resolveInstallPrincipal — ${shape.name}`, async () => {
132+
const { plugin, ctx, c, calls } = pluginWith(shape);
133+
const principal = await plugin.resolveInstallPrincipal(c, ctx);
134+
expect(principal?.userId, 'the admission read resolved the session').toBe(USER);
135+
expectTheRule(calls, shape);
136+
});
137+
}
138+
});

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ import {
9898
postureGatesGlobalUniques,
9999
GLOBAL_UNIQUE_CONFIRMATION_REQUIRED,
100100
type GlobalUniqueFinding,
101+
// [#22258] The in-process session-read rule — both session reads below.
102+
inProcessSessionReadInput,
101103
} from '@objectstack/types';
102104
import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security';
103105
import { ManifestSchema, manifestIdRefusal } from '@objectstack/spec/kernel';
@@ -2617,7 +2619,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
26172619
let api: any = authService?.api;
26182620
if (!api && typeof authService?.getApi === 'function') api = await authService.getApi();
26192621
if (!api?.getSession) return null;
2620-
const session = await api.getSession({ headers: c.req.raw.headers });
2622+
// [#22258] A request carrying a session cookie reads without
2623+
// renewal: this route's response never carries a renewed cookie.
2624+
const session = await api.getSession(inProcessSessionReadInput(c.req.raw.headers));
26212625
const direct = session?.session?.activeOrganizationId ?? session?.activeOrganizationId ?? null;
26222626
if (direct) return String(direct);
26232627
} catch { /* ignore */ }
@@ -2785,7 +2789,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
27852789
if (!api && typeof authService?.getApi === 'function') {
27862790
api = await authService.getApi();
27872791
}
2788-
return await api?.getSession?.({ headers: h });
2792+
// [#22258] The in-process session-read rule, as in
2793+
// `resolveActiveOrgId` above.
2794+
return await api?.getSession?.(inProcessSessionReadInput(h));
27892795
} catch {
27902796
return undefined;
27912797
}

‎packages/plugins/plugin-hono-server/src/current-user-endpoints.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import type {
5757
Logger,
5858
} from '@objectstack/spec/contracts';
5959
import { allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability';
60+
import { inProcessSessionReadInput } from '@objectstack/types';
6061

6162
/** API prefix these endpoints mount under unless the host overrides it. */
6263
export const DEFAULT_CURRENT_USER_PREFIX = '/api/v1';
@@ -403,7 +404,12 @@ export function makeExecutionContextResolver(
403404
api = await authService.getApi();
404405
}
405406
if (!api?.getSession) return undefined;
406-
const session = await api.getSession({ headers: c.req.raw.headers });
407+
// [#22258] The in-process session-read rule: these routes answer
408+
// with their own response, so a renewal here would move the
409+
// session's expiry while its renewed cookie is discarded. A request
410+
// carrying a session cookie reads without renewal; a bearer-only
411+
// request renews as before.
412+
const session = await api.getSession(inProcessSessionReadInput(c.req.raw.headers));
407413
if (!session?.user?.id) return undefined;
408414
const userId = session.user.id;
409415
const tenantId = session.session?.activeOrganizationId ?? undefined;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22258] The current-user endpoints' in-process `auth.api.getSession` read
5+
* (`makeExecutionContextResolver`, behind `GET /api/v1/auth/me/permissions` and
6+
* its siblings) hands better-auth the in-process session-read rule's input
7+
* (`inProcessSessionReadInput`, `@objectstack/types`).
8+
*
9+
* A request carrying a session cookie reads with `query.disableRefresh`: these
10+
* routes answer with their own response, so a renewal here would move the
11+
* session's expiry while its renewed cookie is discarded. A bearer-only request
12+
* reads exactly as before, renewal included. What `disableRefresh` then DOES
13+
* against real better-auth is pinned end to end through this door in
14+
* `packages/runtime/src/in-process-session-renewal.pin.test.ts`.
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import { Hono } from 'hono';
19+
import { registerCurrentUserEndpoints } from './current-user-endpoints';
20+
21+
const ME_PERMISSIONS = '/api/v1/auth/me/permissions';
22+
const USER = 'usr_22258';
23+
const SESSION_COOKIE = 'better-auth.session_token=tok_22258.c2lnbmF0dXJl';
24+
const BEARER = 'Bearer tok_22258.c2lnbmF0dXJl';
25+
26+
function mount() {
27+
const calls: any[] = [];
28+
const services: Record<string, unknown> = {
29+
auth: {
30+
api: {
31+
getSession: async (input: any) => {
32+
calls.push(input);
33+
return { user: { id: USER }, session: {} };
34+
},
35+
},
36+
},
37+
objectql: { find: async () => [], registry: { getAllApps: () => [], getAllObjects: () => [] } },
38+
metadata: { list: async () => [] as unknown[] },
39+
security: { resolvePermissionSetsForContext: async () => [] },
40+
};
41+
const app = new Hono();
42+
registerCurrentUserEndpoints({
43+
rawApp: app,
44+
ctx: {
45+
logger: { debug() {}, warn() {} },
46+
getService: <T,>(name: string): T => {
47+
if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`);
48+
return services[name] as T;
49+
},
50+
},
51+
});
52+
return { app, calls };
53+
}
54+
55+
async function readMePermissions(headers: Record<string, string>) {
56+
const { app, calls } = mount();
57+
const res = await app.request(`http://localhost${ME_PERMISSIONS}`, { headers });
58+
return { calls, status: res.status, body: (await res.json()) as any };
59+
}
60+
61+
describe('[#22258] the current-user endpoints read the session by the in-process rule', () => {
62+
it('a request carrying a session cookie reads without renewal', async () => {
63+
const { calls, status } = await readMePermissions({ cookie: SESSION_COOKIE });
64+
expect(status, 'the fixture resolves the caller — the door really ran').toBe(200);
65+
expect(calls.length).toBeGreaterThan(0);
66+
for (const input of calls) {
67+
expect(input.query, 'a cookie request renewed in-process').toEqual({ disableRefresh: true });
68+
expect(input.headers.get('cookie')).toBe(SESSION_COOKIE);
69+
}
70+
});
71+
72+
it('the console sends cookie AND bearer — still no renewal in-process', async () => {
73+
const { calls } = await readMePermissions({ cookie: SESSION_COOKIE, authorization: BEARER });
74+
expect(calls.length).toBeGreaterThan(0);
75+
for (const input of calls) expect(input.query).toEqual({ disableRefresh: true });
76+
});
77+
78+
it('a bearer-only request reads exactly as before — renewal stays on, no query at all', async () => {
79+
const { calls, status } = await readMePermissions({ authorization: BEARER });
80+
expect(status).toBe(200);
81+
expect(calls.length).toBeGreaterThan(0);
82+
for (const input of calls) {
83+
expect('query' in input, 'a bearer-only read lost its renewal').toBe(false);
84+
expect(input.headers.get('authorization')).toBe(BEARER);
85+
}
86+
});
87+
});

‎packages/rest/src/execctx-authz-input-seam-reachability.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,10 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol',
257257
expect(body).toMatch(/isAuthGateActive === 'function'\s*\n?\s*&& authService\.isAuthGateActive\(\) === true/);
258258
// The re-read is loud, and it is the RAW api call — ⛔ not the swallowing
259259
// `getSession` closure, which would re-collapse the very same two facts.
260-
expect(body).toMatch(/gatedSession = await api\.getSession\(\{ headers \}\);/);
260+
// [#22258] Its argument is the in-process session-read rule's input (a
261+
// cookie request reads without renewal); the call itself is still the raw,
262+
// throwing one this pin exists for.
263+
expect(body).toMatch(/gatedSession = await api\.getSession\(inProcessSessionReadInput\(headers\)\);/);
261264
expect(body).toMatch(/throw new AuthzStoreUnavailableError\('auth_gate', err\);/);
262265
// ⛔ NARROWNESS CONTROL: the probe-throws leg must STAY absorbed — a host
263266
// whose probe faults never declared a gate. If this ever flips, the repair

0 commit comments

Comments
 (0)