From ece41d9b0fca4441865e0ff340bc2b3dd38ea33a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:17:51 +0000 Subject: [PATCH 1/6] fix(auth,services): services-lane in-process session reads stop renewing a cookie session The nine in-process getSession readers in plugin-auth (x4), plugin-webhooks, service-storage, plugin-sharing, service-settings and service-datasource now hand better-auth inProcessSessionReadInput(headers) from @objectstack/types: a request carrying a session cookie reads with query.disableRefresh, a bearer-only request reads as before. plugin-webhooks gains a workspace dependency on @objectstack/types. Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude --- packages/plugins/plugin-auth/src/auth-plugin.ts | 10 +++++----- packages/plugins/plugin-sharing/src/sharing-plugin.ts | 3 ++- packages/plugins/plugin-webhooks/package.json | 3 ++- .../plugin-webhooks/src/webhook-outbox-plugin.ts | 3 ++- .../services/service-datasource/src/admin-routes.ts | 4 ++-- .../service-settings/src/settings-service-plugin.ts | 3 ++- .../service-storage/src/storage-service-plugin.ts | 3 ++- pnpm-lock.yaml | 3 +++ 8 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 6068aa6fc3b..846043c005f 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -28,7 +28,7 @@ import { SystemOverviewDatasets, } from '@objectstack/platform-objects/apps'; import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages'; -import { PLATFORM_OWNER_EMAIL_ENV, resolvePlatformOwnerEmail, resolveTenancyPosture } from '@objectstack/types'; +import { PLATFORM_OWNER_EMAIL_ENV, inProcessSessionReadInput, resolvePlatformOwnerEmail, resolveTenancyPosture } from '@objectstack/types'; import { postureEnforcesWall, type OrgScopingEntitlement } from '@objectstack/spec/security'; import type { IDataEngine, IEmailService, II18nService, IObjectQLEngine, ISmsService } from '@objectstack/spec/contracts'; import { @@ -2461,7 +2461,7 @@ export class AuthPlugin implements Plugin { // Platform-admin gate (ADR-0068 D2) — one shared judge for every // ObjectStack `/admin/*` mount; see platform-admin-gate.ts. const authApi = await this.authManager!.getApi(); - const session = await authApi.getSession({ headers: c.req.raw.headers }); + const session = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers)); const verdict = judgePlatformAdmin(session); if (!verdict.ok) return c.json(verdict.refusal.body, verdict.refusal.status); @@ -2524,7 +2524,7 @@ export class AuthPlugin implements Plugin { // spelling instead of accreting per-mount copies. const gateAdmin = async (c: any): Promise => { const authApi = await this.authManager!.getApi(); - const session = await (authApi as any).getSession({ headers: c.req.raw.headers }); + const session = await (authApi as any).getSession(inProcessSessionReadInput(c.req.raw.headers)); const verdict = judgePlatformAdmin(session); if (!verdict.ok) return c.json(verdict.refusal.body, verdict.refusal.status); return verdict.actor; @@ -2591,7 +2591,7 @@ export class AuthPlugin implements Plugin { // Platform-admin gate (ADR-0068 D2) — see platform-admin-gate.ts. const authApi = await this.authManager!.getApi(); - const session = await authApi.getSession({ headers: c.req.raw.headers }); + const session = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers)); const verdict = judgePlatformAdmin(session); if (!verdict.ok) return c.json(verdict.refusal.body, verdict.refusal.status); @@ -2909,7 +2909,7 @@ export class AuthPlugin implements Plugin { rawApp.post(`${basePath}/admin/has-permission`, async (c: any) => { try { const authApi = await this.authManager!.getApi(); - const session = await (authApi as any).getSession({ headers: c.req.raw.headers }); + const session = await (authApi as any).getSession(inProcessSessionReadInput(c.req.raw.headers)); const user = (session as { user?: { id?: unknown } } | null | undefined)?.user; if (user?.id && isPlatformAdminUser(user)) { const { readEvaluatedPermissionQuery, answerPermissionQueryAsAdmin } = await import( diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index 97da4ec4ee1..026ac1febf6 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -59,6 +59,7 @@ import { bindBusinessUnitTreeRecompute } from './bu-tree-recompute.js'; import { bindRecordShareCascade } from './record-share-cascade.js'; import { bootstrapDeclaredSharingRules } from './bootstrap-declared-sharing-rules.js'; import { normalizeTenancyPosture, postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; +import { inProcessSessionReadInput } from '@objectstack/types'; /** * How many organizations one boot-time sharing-rule seeding sweep enumerates. @@ -937,7 +938,7 @@ export class SharingServicePlugin implements Plugin { const authService = ctx.getService('auth'); let api: AuthSessionApi | undefined = authService?.api; if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); - return await api?.getSession?.({ headers: h }); + return await api?.getSession?.(inProcessSessionReadInput(h)); } catch { return undefined; } diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index 33098b5b40c..8ef671e4bcf 100644 --- a/packages/plugins/plugin-webhooks/package.json +++ b/packages/plugins/plugin-webhooks/package.json @@ -38,7 +38,8 @@ "@objectstack/core": "workspace:*", "@objectstack/platform-objects": "workspace:*", "@objectstack/service-messaging": "workspace:*", - "@objectstack/spec": "workspace:*" + "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*" }, "devDependencies": { "@objectstack/metadata-core": "workspace:*", diff --git a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts index 6ffa17308bb..60dc0b99b98 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -8,6 +8,7 @@ import type { IRealtimeService, } from '@objectstack/spec/contracts'; import type { EnqueueHttpInput } from '@objectstack/service-messaging'; +import { inProcessSessionReadInput } from '@objectstack/types'; import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js'; import { SysWebhook } from './sys-webhook.object.js'; import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js'; @@ -479,7 +480,7 @@ export class WebhookOutboxPlugin implements Plugin { api = await authService.getApi(); } if (!api?.getSession) return undefined; - return await api.getSession({ headers: c.req.raw.headers }); + return await api.getSession(inProcessSessionReadInput(c.req.raw.headers)); } catch { return undefined; } diff --git a/packages/services/service-datasource/src/admin-routes.ts b/packages/services/service-datasource/src/admin-routes.ts index c14e7b565d3..0868657715d 100644 --- a/packages/services/service-datasource/src/admin-routes.ts +++ b/packages/services/service-datasource/src/admin-routes.ts @@ -32,7 +32,7 @@ import type { ErrorCode } from '@objectstack/spec/api'; // message reports that erasure hiding, on the exact member this guard reads. import type { IAuthService, IDataEngine, IHttpServer } from '@objectstack/spec/contracts'; // The declared envelope is written in ONE place for the whole platform (#3973). -import { sendOk, sendError } from '@objectstack/types'; +import { inProcessSessionReadInput, sendOk, sendError } from '@objectstack/types'; import { DRIVER_CATALOG } from './driver-catalog.js'; /** @@ -209,7 +209,7 @@ function buildGetSession(ctx: PluginContext): ((headers: Headers) => Promise Prom let api: any = authService.api; if (!api && typeof authService.getApi === 'function') api = await authService.getApi(); if (!api?.getSession) return undefined; - return api.getSession({ headers }); + return api.getSession(inProcessSessionReadInput(headers)); }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e3d1796e74..ec05c06f590 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2010,6 +2010,9 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../../spec + '@objectstack/types': + specifier: workspace:* + version: link:../../types devDependencies: '@objectstack/metadata-core': specifier: workspace:* From 6f1222c3c2c8769cabd86abf468023489547222f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:25:28 +0000 Subject: [PATCH 2/6] test(auth,services): pin the in-process session-read rule at the nine services-lane readers plugin-auth: the four admin doors against real better-auth (real AuthManager, real route registration), by cookie (aligned, no renewal) and bearer-only (renews), with the bare-read precondition and the get-session control. plugin-webhooks, service-storage, plugin-sharing, service-settings and service-datasource: input pins on the getSession input each reader hands better-auth, through each package's real door. Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude --- .../in-process-session-renewal.pin.test.ts | 276 ++++++++++++++++++ .../src/in-process-session-read.pin.test.ts | 125 ++++++++ .../src/in-process-session-read.pin.test.ts | 101 +++++++ .../in-process-session-read.pin.test.ts | 85 ++++++ .../src/in-process-session-read.pin.test.ts | 129 ++++++++ .../src/in-process-session-read.pin.test.ts | 109 +++++++ 6 files changed, 825 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/in-process-session-renewal.pin.test.ts create mode 100644 packages/plugins/plugin-sharing/src/in-process-session-read.pin.test.ts create mode 100644 packages/plugins/plugin-webhooks/src/in-process-session-read.pin.test.ts create mode 100644 packages/services/service-datasource/src/__tests__/in-process-session-read.pin.test.ts create mode 100644 packages/services/service-settings/src/in-process-session-read.pin.test.ts create mode 100644 packages/services/service-storage/src/in-process-session-read.pin.test.ts diff --git a/packages/plugins/plugin-auth/src/in-process-session-renewal.pin.test.ts b/packages/plugins/plugin-auth/src/in-process-session-renewal.pin.test.ts new file mode 100644 index 00000000000..dadd644fd16 --- /dev/null +++ b/packages/plugins/plugin-auth/src/in-process-session-renewal.pin.test.ts @@ -0,0 +1,276 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22258] This plugin's admin doors read the session in-process without + * leaving the browser's cookie behind. + * + * better-auth's `getSession` renews a session older than `updateAge` — it + * moves `sys_session.expires_at` to `now + expiresIn` — and stages the renewed + * cookie on THAT call's response. The raw `/admin/*` mounts below read the + * session in-process and answer with their own response, so the renewed + * cookie was thrown away: the session lived on as a bearer while the browser's + * cookie died at its old `Max-Age` (a SPLIT session). Each mount now hands + * better-auth `inProcessSessionReadInput(headers)` (`@objectstack/types`): a + * request carrying a session cookie reads without renewal; a bearer-only + * request renews as before. + * + * Pinned against REAL better-auth — the installed version, its `expiresIn` / + * `updateAge` read off the live instance — behind the plugin's REAL route + * registration on a real Hono app (the `admin-remove-user-gate-ordering` + * harness: a real `AuthManager` over the shared in-memory engine). The session + * is aged to `now + expiresIn − updateAge − 60 s`, past `updateAge`, and its + * `sys_session` row is read straight off the engine's table after each request: + * + * - each door, by cookie: `expires_at` does not move and no session cookie + * is set — cookie and session stay aligned; + * - the same door, bearer only: `expires_at` renews to `now + expiresIn`, + * and no cookie is set on a response to a request that sent none (this + * half is also each door's positive control — it proves the door's reader + * ran); + * - the control, `GET /get-session`: renews AND re-issues the cookie with + * `Max-Age = expiresIn`. + * + * Doors and the reader each one reaches (`auth-plugin.ts`): + * `POST /admin/oauth2/toggle-disabled` → its own platform-admin gate read + * `POST /admin/set-user-manager` → `gateAdmin`, the shared gate of every + * `/admin/*` mount that calls it + * `POST /admin/unlock-user` → its own platform-admin gate read + * `POST /admin/has-permission` → its own platform-admin branch read + * Each body is one the door answers right after its read, so no second session + * read follows the one under test. + */ + +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { Hono } from 'hono'; +import { AuthManager } from './auth-manager'; +import { AuthPlugin } from './auth-plugin'; +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; +import { inviteForAudienceGate } from './audience-gate-test-support'; +import type { PluginContext } from '@objectstack/core'; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-22258'; +const ORIGIN = 'http://localhost:3000'; +const BASE = '/api/v1/auth'; +const ADMIN_EMAIL = 'admin.22258@example.com'; +const MEMBER_EMAIL = 'member.22258@example.com'; +/** Clock slack between the server's `now` and this file's, in ms. */ +const SLACK_MS = 5_000; + +const mockCtx = (): PluginContext => + ({ + registerService: vi.fn(), + getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(), + }) as any; + +let engine: ReturnType; +let manager: AuthManager; +let app: Hono; +let expiresInSec: number; +let updateAgeSec: number; +let memberId: string; +/** The admin's credentials, as a browser and as a bearer client hold them. */ +let cookiePair: string; +let bearer: string; +let sessionToken: string; + +/** The admin's `sys_session` row, read straight off the engine's table. */ +function sessionRow(): Record { + const row = ((engine.tables.get('sys_session') ?? []) as any[]).find((r) => r.token === sessionToken); + if (!row) throw new Error('pin: the signed-in session row is gone'); + return row; +} + +const storedExpiry = (): number => new Date(sessionRow().expires_at as any).getTime(); + +/** Age the session to just past `updateAge` — the card's `now + expiresIn − updateAge − 60 s`. */ +function ageSession(): number { + const target = Date.now() + (expiresInSec - updateAgeSec - 60) * 1000; + const row = sessionRow(); + row.expires_at = typeof row.expires_at === 'string' ? new Date(target).toISOString() : new Date(target); + const stored = storedExpiry(); + expect(Math.abs(stored - target), 'the aging write did not land').toBeLessThan(1_000); + return stored; +} + +/** The session-token cookie a response stages, or `null`. */ +function sessionCookieOf(res: Response): { maxAgeSec: number | null } | null { + const staged = res.headers.getSetCookie().find((c) => /(?:^|\.)session_token=/.test(c.split(';')[0])); + if (!staged) return null; + const m = /;\s*max-age=(\d+)/i.exec(staged); + return { maxAgeSec: m ? Number(m[1]) : null }; +} + +const asCookie = (): Record => ({ cookie: cookiePair }); +const asBearer = (): Record => ({ authorization: `Bearer ${bearer}` }); + +const fire = (path: string, body: unknown, credential: Record) => + app.request(`${ORIGIN}${BASE}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: ORIGIN, ...credential }, + body: JSON.stringify(body), + }); + +/** + * The pin: cookie and session expiry stay ALIGNED — either the session did not + * move and no cookie was staged, or it moved and its cookie was re-issued to + * expire with it. + */ +function expectAligned(label: string, aged: number, after: number, res: Response) { + const cookie = sessionCookieOf(res); + if (after !== aged) { + expect(cookie, `${label}: the session renewed (+${Math.round((after - aged) / 1000)} s) but its cookie was not re-issued`).not.toBeNull(); + const cookieExpiry = Date.now() + (cookie!.maxAgeSec ?? 0) * 1000; + expect(Math.abs(cookieExpiry - after), `${label}: re-issued cookie and session expire apart`).toBeLessThan(SLACK_MS); + } else { + expect(cookie, `${label}: a cookie was staged for a session that did not move`).toBeNull(); + } +} + +beforeAll(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + engine = createMemoryEngine(); + manager = new AuthManager({ + secret: SECRET, + baseUrl: ORIGIN, + dataEngine: engine, + plugins: { admin: true }, + } as any); + + const direct = (path: string, body: unknown) => + manager.handleRequest( + new Request(`${ORIGIN}${BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + ); + + for (const [email, name] of [ + [ADMIN_EMAIL, 'Platform Admin'], + [MEMBER_EMAIL, 'Plain Member'], + ]) { + // The default audience posture is invite_only: fixture users beyond the + // first enter through the invitation carve-out (audience-gate-test-support). + await inviteForAudienceGate(manager, email); + const res = await direct('/sign-up/email', { email, password: PASSWORD, name }); + expect(res.status, `sign-up ${email}: ${await res.clone().text()}`).toBe(200); + } + + const users = (engine.tables.get('sys_user') ?? []) as any[]; + memberId = String(users.find((r) => r.email === MEMBER_EMAIL)!.id); + // The legacy scalar `isPlatformAdminUser` accepts as its documented + // back-compat signal — every door below admits this caller. + users.find((r) => r.email === ADMIN_EMAIL)!.role = 'admin'; + + // The version this package pins, read off the running instance — never assumed. + const authContext: any = await manager.getAuthContext(); + expiresInSec = Number(authContext.sessionConfig.expiresIn); + updateAgeSec = Number(authContext.sessionConfig.updateAge); + + const res = await direct('/sign-in/email', { email: ADMIN_EMAIL, password: PASSWORD }); + expect(res.status, `sign-in: ${await res.clone().text()}`).toBe(200); + const staged = res.headers.getSetCookie().find((c) => /(?:^|\.)session_token=/.test(c.split(';')[0])); + if (!staged) throw new Error('pin sign-in staged no session cookie'); + cookiePair = staged.split(';')[0]; + bearer = String(res.headers.get('set-auth-token') ?? ''); + if (!bearer) throw new Error('pin sign-in emitted no set-auth-token'); + sessionToken = String(((await res.json()) as any).token); + + // The REAL route registration — raw mounts ahead of the catch-all — on a + // real Hono app in front of the real AuthManager. + app = new Hono(); + const ctx = mockCtx(); + const plugin = new AuthPlugin({ secret: SECRET }); + await plugin.init(ctx); + (plugin as any).authManager = manager; + (plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); +}); + +afterAll(() => vi.restoreAllMocks()); + +describe('[#22258] precondition — this stack renews, and the defect is better-auth\'s own behaviour', () => { + it('reads expiresIn / updateAge off the running better-auth', () => { + expect(expiresInSec).toBeGreaterThan(updateAgeSec); + expect(updateAgeSec).toBeGreaterThan(60); + }); + + it('a bare in-process getSession on an aged session renews it and stages a cookie nobody sends', async () => { + const aged = ageSession(); + const api: any = await manager.getApi(); + // No rule: the call every reader in this file used to make. + await api.getSession({ headers: new Headers({ cookie: cookiePair }) }); + const after = storedExpiry(); + expect(after - aged, 'the fixture does not renew — every pin below would pass vacuously') + .toBeGreaterThan((updateAgeSec - SLACK_MS / 1000) * 1000); + }); +}); + +const DOORS: Array<{ label: string; path: string; body: () => unknown }> = [ + { + label: 'POST /admin/oauth2/toggle-disabled', + path: '/admin/oauth2/toggle-disabled', + body: () => ({ client_id: 'cl_pin_22258_absent', disabled: true }), + }, + { + label: 'POST /admin/set-user-manager (gateAdmin)', + path: '/admin/set-user-manager', + body: () => ({}), + }, + { + label: 'POST /admin/unlock-user', + path: '/admin/unlock-user', + body: () => ({ userId: memberId }), + }, + { + label: 'POST /admin/has-permission (platform-admin branch)', + path: '/admin/has-permission', + body: () => ({ permissions: { user: ['list'] } }), + }, +]; + +describe('[#22258] each admin door leaves cookie and session expiry aligned', () => { + for (const door of DOORS) { + it(`${door.label} — by cookie: no renewal, no cookie`, async () => { + const aged = ageSession(); + const res = await fire(door.path, door.body(), asCookie()); + // Admitted: the read under test resolved the admin (a refusal would be 401/403). + expect([401, 403], `${door.label} refused the admin: ${res.status}`).not.toContain(res.status); + expect(res.status, `${door.label} answered ${res.status}`).toBeLessThan(500); + const after = storedExpiry(); + expectAligned(door.label, aged, after, res); + expect(after, `${door.label}: a cookie request renewed in-process`).toBe(aged); + }); + + it(`${door.label} — bearer only: renews as before, sets no cookie`, async () => { + const aged = ageSession(); + const res = await fire(door.path, door.body(), asBearer()); + expect([401, 403], `${door.label} refused the admin: ${res.status}`).not.toContain(res.status); + expect(res.status, `${door.label} answered ${res.status}`).toBeLessThan(500); + const after = storedExpiry(); + expect(after, `${door.label}: a bearer-only read no longer renews`).toBeGreaterThan(aged); + expect(Math.abs(after - (Date.now() + expiresInSec * 1000)), `${door.label}: the renewal is not to now + expiresIn`) + .toBeLessThan(SLACK_MS); + expect(sessionCookieOf(res), `${door.label}: a cookie was set on a response to a request that sent none`).toBeNull(); + }); + } +}); + +describe('[#22258] control — the browser-facing get-session still renews and re-issues', () => { + it('renews an aged session and re-issues the cookie with Max-Age = expiresIn', async () => { + const aged = ageSession(); + const res = await app.request(`${ORIGIN}${BASE}/get-session`, { headers: { origin: ORIGIN, ...asCookie() } }); + expect(res.status).toBe(200); + const after = storedExpiry(); + expect(Math.abs(after - (Date.now() + expiresInSec * 1000)), 'get-session did not renew').toBeLessThan(SLACK_MS); + expect(sessionCookieOf(res)?.maxAgeSec, 'get-session did not re-issue the cookie with Max-Age = expiresIn').toBe(expiresInSec); + expectAligned('get-session', aged, after, res); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/in-process-session-read.pin.test.ts b/packages/plugins/plugin-sharing/src/in-process-session-read.pin.test.ts new file mode 100644 index 00000000000..0658cfb2199 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/in-process-session-read.pin.test.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22258] The share-link management routes' in-process `auth.api.getSession` + * read (`verifiedContextFromRequest`'s `getSession`) hands better-auth the + * in-process session-read rule's input (`inProcessSessionReadInput`, + * `@objectstack/types`). + * + * A request carrying a session cookie reads with `query.disableRefresh`: these + * routes answer with their own response, so a renewal here would move the + * session's expiry while its renewed cookie is discarded — the browser's + * cookie would then die before its session (a split session). A bearer-only + * request reads exactly as before, renewal included. What that input DOES + * against real better-auth is pinned end to end in + * `packages/runtime/src/in-process-session-renewal.pin.test.ts` and + * `packages/plugins/plugin-auth/src/in-process-session-renewal.pin.test.ts`. + * + * The plugin is booted for real (`start` → `kernel:ready`), as + * `share-link-tenancy-posture-admission.test.ts` boots it, because the read + * under test is a closure inside that hook; the route is `GET /share-links`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import type { PluginContext } from '@objectstack/core'; +import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { SharingServicePlugin } from './sharing-plugin.js'; + +const BASE = '/api/v1/share-links'; +const SESSION_COOKIE = 'better-auth.session_token=tok_22258.c2lnbmF0dXJl'; +const BEARER = 'Bearer tok_22258.c2lnbmF0dXJl'; + +class MockHttp implements IHttpServer { + routes = new Map(); + private add(method: string, path: string, handler: RouteHandler) { + this.routes.set(`${method} ${path}`, handler); + } + get(path: string, h: RouteHandler) { this.add('GET', path, h); return this as any; } + post(path: string, h: RouteHandler) { this.add('POST', path, h); return this as any; } + put(path: string, h: RouteHandler) { this.add('PUT', path, h); return this as any; } + delete(path: string, h: RouteHandler) { this.add('DELETE', path, h); return this as any; } + patch(path: string, h: RouteHandler) { this.add('PATCH', path, h); return this as any; } + use() { return this as any; } + listen() { return Promise.resolve(); } + close() { return Promise.resolve(); } + getInstance() { return null; } +} + +async function listLinks(headers: Record) { + const calls: any[] = []; + const engine = { + async find() { return []; }, + async insert(_object: string, row: any) { return row; }, + getSchema(object: string) { return { name: object }; }, + }; + const http = new MockHttp(); + // No `tenancy` registered: the branded "never registered" arm, a quiet + // `undefined` posture — this file's subject is the session read alone. + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as any); + const hooks: Record Promise | void>> = {}; + const ctx = { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + hook: (event: string, handler: () => Promise | void) => { + (hooks[event] ??= []).push(handler); + }, + getService: (name: string): T => { + if (name === 'objectql') return engine as unknown as T; + if (name === 'http.server') return http as unknown as T; + if (name === 'auth') { + return { + api: { + getSession: async (input: any) => { + calls.push(input); + return { user: { id: 'usr_22258' }, session: { userId: 'usr_22258' } }; + }, + }, + } as unknown as T; + } + return kernel.getService(name); + }, + registerService: vi.fn(), + getKernel: () => kernel, + }; + const plugin = new SharingServicePlugin({ enforce: false }); + await plugin.start(ctx as unknown as PluginContext); + for (const handler of hooks['kernel:ready'] ?? []) await handler(); + + const handler = http.routes.get(`GET ${BASE}`); + if (!handler) throw new Error(`no handler for GET ${BASE}`); + const captured: { status: number; body: any } = { status: 200, body: undefined }; + const res: IHttpResponse = { + json: vi.fn((data: any) => { captured.body = data; }) as any, + send: vi.fn() as any, + status: vi.fn((code: number) => { captured.status = code; return res; }) as any, + header: vi.fn(() => res) as any, + }; + const req = { params: {}, query: {}, headers, method: 'GET', path: BASE } as unknown as IHttpRequest; + await handler(req, res); + return { calls, status: captured.status }; +} + +describe('[#22258] the share-link routes read the session by the in-process rule', () => { + it('a request carrying a session cookie reads without renewal', async () => { + const { calls, status } = await listLinks({ cookie: SESSION_COOKIE }); + // Admitted: the read resolved the caller (an unresolved one is refused 401). + expect(status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0].query, 'a cookie request renewed in-process').toEqual({ disableRefresh: true }); + expect(calls[0].headers.get('cookie')).toBe(SESSION_COOKIE); + }); + + it('the console sends cookie AND bearer — still no renewal in-process', async () => { + const { calls } = await listLinks({ cookie: SESSION_COOKIE, authorization: BEARER }); + expect(calls).toHaveLength(1); + expect(calls[0].query).toEqual({ disableRefresh: true }); + }); + + it('a bearer-only request reads exactly as before — renewal stays on, no query at all', async () => { + const { calls, status } = await listLinks({ authorization: BEARER }); + expect(status).toBe(200); + expect(calls).toHaveLength(1); + expect('query' in calls[0], 'a bearer-only read lost its renewal').toBe(false); + expect(calls[0].headers.get('authorization')).toBe(BEARER); + }); +}); diff --git a/packages/plugins/plugin-webhooks/src/in-process-session-read.pin.test.ts b/packages/plugins/plugin-webhooks/src/in-process-session-read.pin.test.ts new file mode 100644 index 00000000000..a95c206c0dd --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/in-process-session-read.pin.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22258] `POST /api/v1/webhooks/redeliver` reads the caller's session + * in-process (`resolveSession`) and hands better-auth the in-process + * session-read rule's input (`inProcessSessionReadInput`, `@objectstack/types`). + * + * A request carrying a session cookie reads with `query.disableRefresh`: this + * route answers with its own response, so a renewal here would move the + * session's expiry while its renewed cookie is discarded — the browser's + * cookie would then die before its session (a split session). A bearer-only + * request reads exactly as before, renewal included. What that input DOES + * against real better-auth is pinned end to end in + * `packages/runtime/src/in-process-session-renewal.pin.test.ts` and + * `packages/plugins/plugin-auth/src/in-process-session-renewal.pin.test.ts`. + * + * The harness is `webhook-redeliver-tenant-scope.test.ts`'s: the plugin's REAL + * `registerAdminRoutes`, its handler captured and called with a Hono-shaped + * context. + */ + +import { describe, it, expect } from 'vitest'; +import { WebhookOutboxPlugin } from './webhook-outbox-plugin.js'; + +const SESSION_COOKIE = 'better-auth.session_token=tok_22258.c2lnbmF0dXJl'; +const BEARER = 'Bearer tok_22258.c2lnbmF0dXJl'; + +/** Mount the real route over an `auth` service whose `getSession` records its input. */ +async function redeliver(headers: Record) { + const calls: any[] = []; + const redelivered: string[] = []; + let handler: ((c: any) => Promise) | undefined; + const rawApp = { + post(path: string, h: (c: any) => Promise) { + if (path === '/api/v1/webhooks/redeliver') handler = h; + }, + }; + const services: Record = { + 'http-server': { getRawApp: () => rawApp }, + messaging: { + enqueueHttp: async () => 'unused', + isHttpDeliveryReady: () => true, + registerRedeliverGuard: () => {}, + redeliverHttp: async (id: string) => { + redelivered.push(id); + return { id, status: 'pending' }; + }, + }, + auth: { + api: { + getSession: async (input: any) => { + calls.push(input); + return { user: { id: 'usr_22258' }, session: { userId: 'usr_22258', activeOrganizationId: 'org_22258' } }; + }, + }, + }, + }; + const ctx: any = { + getService: (n: string) => services[n], + logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, + }; + (new WebhookOutboxPlugin() as any).registerAdminRoutes(ctx); + if (!handler) throw new Error('route was not mounted'); + + let status = 200; + const c = { + req: { raw: { headers: new Headers(headers) }, json: async () => ({ deliveryId: 'del_22258' }) }, + json(_payload: any, s?: number) { + if (s !== undefined) status = s; + return { status }; + }, + }; + await handler(c); + return { calls, status, redelivered }; +} + +describe('[#22258] the redeliver route reads the session by the in-process rule', () => { + it('a request carrying a session cookie reads without renewal', async () => { + const { calls, status, redelivered } = await redeliver({ cookie: SESSION_COOKIE }); + // The fixture resolves the caller and the route really ran past its gate. + expect(status).toBe(200); + expect(redelivered).toEqual(['del_22258']); + expect(calls).toHaveLength(1); + expect(calls[0].query, 'a cookie request renewed in-process').toEqual({ disableRefresh: true }); + expect(calls[0].headers.get('cookie')).toBe(SESSION_COOKIE); + }); + + it('the console sends cookie AND bearer — still no renewal in-process', async () => { + const { calls } = await redeliver({ cookie: SESSION_COOKIE, authorization: BEARER }); + expect(calls).toHaveLength(1); + expect(calls[0].query).toEqual({ disableRefresh: true }); + }); + + it('a bearer-only request reads exactly as before — renewal stays on, no query at all', async () => { + const { calls, status } = await redeliver({ authorization: BEARER }); + expect(status).toBe(200); + expect(calls).toHaveLength(1); + expect('query' in calls[0], 'a bearer-only read lost its renewal').toBe(false); + expect(calls[0].headers.get('authorization')).toBe(BEARER); + }); +}); diff --git a/packages/services/service-datasource/src/__tests__/in-process-session-read.pin.test.ts b/packages/services/service-datasource/src/__tests__/in-process-session-read.pin.test.ts new file mode 100644 index 00000000000..895d0ebab59 --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/in-process-session-read.pin.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22258] The datasource-admin family's in-process `auth.api.getSession` read + * (`buildGetSession`, behind every route's `requireDatasourceAdmin`) hands + * better-auth the in-process session-read rule's input + * (`inProcessSessionReadInput`, `@objectstack/types`). + * + * A request carrying a session cookie reads with `query.disableRefresh`: these + * routes answer with their own response, so a renewal here would move the + * session's expiry while its renewed cookie is discarded — the browser's + * cookie would then die before its session (a split session). A bearer-only + * request reads exactly as before, renewal included. What that input DOES + * against real better-auth is pinned end to end in + * `packages/runtime/src/in-process-session-renewal.pin.test.ts` and + * `packages/plugins/plugin-auth/src/in-process-session-renewal.pin.test.ts`. + * + * Driven through the real registrar on a real Hono server, as + * `admin-routes-authz-outage-envelope.test.ts` drives it. The caller resolves + * to an identity holding no grant, so the answer is the capability refusal + * (403) — which is itself the proof the read resolved someone: an unresolved + * caller is refused 401 instead. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { PluginContext } from '@objectstack/core'; +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; +import { registerDatasourceAdminRoutes } from '../admin-routes.js'; + +const SESSION_COOKIE = 'better-auth.session_token=tok_22258.c2lnbmF0dXJl'; +const BEARER = 'Bearer tok_22258.c2lnbmF0dXJl'; + +async function readDrivers(headers: Record) { + const calls: any[] = []; + const ctx = { + getService: vi.fn((name: string) => { + if (name === 'auth') { + return { + api: { + getSession: async (input: any) => { + calls.push(input); + return { user: { id: 'usr_22258' }, session: {} }; + }, + }, + }; + } + if (name === 'objectql' || name === 'data') return { find: async () => [] }; + return undefined; + }), + getKernel: () => ({ getServiceAsync: async () => ({ getTenancyPosture: () => 'single' }) }), + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + } as unknown as PluginContext; + + const server = new HonoHttpServer(0); + server.setLogger({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() } as any); + registerDatasourceAdminRoutes(server, ctx, '/api/v1'); + const res = await server.getRawApp().fetch(new Request('http://local/api/v1/datasources/drivers', { headers })); + const body: any = await res.json().catch(() => undefined); + return { calls, status: res.status, code: body?.error?.code }; +} + +describe('[#22258] the datasource-admin family reads the session by the in-process rule', () => { + it('a request carrying a session cookie reads without renewal', async () => { + const { calls, status, code } = await readDrivers({ cookie: SESSION_COOKIE }); + expect(status).toBe(403); + expect(code).toBe('PERMISSION_DENIED'); + expect(calls).toHaveLength(1); + expect(calls[0].query, 'a cookie request renewed in-process').toEqual({ disableRefresh: true }); + expect(calls[0].headers.get('cookie')).toBe(SESSION_COOKIE); + }); + + it('the console sends cookie AND bearer — still no renewal in-process', async () => { + const { calls } = await readDrivers({ cookie: SESSION_COOKIE, authorization: BEARER }); + expect(calls).toHaveLength(1); + expect(calls[0].query).toEqual({ disableRefresh: true }); + }); + + it('a bearer-only request reads exactly as before — renewal stays on, no query at all', async () => { + const { calls, status } = await readDrivers({ authorization: BEARER }); + expect(status).toBe(403); + expect(calls).toHaveLength(1); + expect('query' in calls[0], 'a bearer-only read lost its renewal').toBe(false); + expect(calls[0].headers.get('authorization')).toBe(BEARER); + }); +}); diff --git a/packages/services/service-settings/src/in-process-session-read.pin.test.ts b/packages/services/service-settings/src/in-process-session-read.pin.test.ts new file mode 100644 index 00000000000..17c3270bd36 --- /dev/null +++ b/packages/services/service-settings/src/in-process-session-read.pin.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22258] The settings door's in-process `auth.api.getSession` read + * (`verifiedContextFromRequest`'s `getSession`, behind every `/api/settings` + * route) hands better-auth the in-process session-read rule's input + * (`inProcessSessionReadInput`, `@objectstack/types`). + * + * A request carrying a session cookie reads with `query.disableRefresh`: these + * routes answer with their own response, so a renewal here would move the + * session's expiry while its renewed cookie is discarded — the browser's + * cookie would then die before its session (a split session). A bearer-only + * request reads exactly as before, renewal included. What that input DOES + * against real better-auth is pinned end to end in + * `packages/runtime/src/in-process-session-renewal.pin.test.ts` and + * `packages/plugins/plugin-auth/src/in-process-session-renewal.pin.test.ts`. + * + * The plugin is booted for real (`init` → `start` → `kernel:ready`), and the + * seam is captured through a PASS-THROUGH of the real route registration, as + * `settings-admission-tenancy-posture.test.ts` captures it: the routes still + * mount, and the closure called here is the one every route calls. No engine + * is registered (the memory fallback), so the read under test is the only + * thing the seam does with the caller. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import type { IHttpRequest, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; +import type { SettingsContext } from './settings-service.types.js'; + +const captured = vi.hoisted(() => ({ contextFromRequest: undefined as + | ((req: IHttpRequest) => SettingsContext | Promise) + | undefined })); + +vi.mock('./settings-routes.js', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + registerSettingsRoutes: (http: any, service: any, opts: any = {}) => { + captured.contextFromRequest = opts.contextFromRequest; + return real.registerSettingsRoutes(http, service, opts); + }, + }; +}); + +const { SettingsServicePlugin } = await import('./settings-service-plugin.js'); + +const SESSION_COOKIE = 'better-auth.session_token=tok_22258.c2lnbmF0dXJl'; +const BEARER = 'Bearer tok_22258.c2lnbmF0dXJl'; + +class MockHttp implements IHttpServer { + routes = new Map(); + private add(method: string, path: string, handler: RouteHandler) { this.routes.set(`${method} ${path}`, handler); } + get(path: string, h: RouteHandler) { this.add('GET', path, h); return this as any; } + post(path: string, h: RouteHandler) { this.add('POST', path, h); return this as any; } + put(path: string, h: RouteHandler) { this.add('PUT', path, h); return this as any; } + delete(path: string, h: RouteHandler) { this.add('DELETE', path, h); return this as any; } + patch(path: string, h: RouteHandler) { this.add('PATCH', path, h); return this as any; } + use() { return this as any; } + listen() { return Promise.resolve(); } + close() { return Promise.resolve(); } + getInstance() { return null; } +} + +async function resolveContext(headers: Record) { + const calls: any[] = []; + const http = new MockHttp(); + // No `tenancy` registered: the branded "never registered" arm, a quiet + // `undefined` posture — this file's subject is the session read alone. + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as any); + const services: Record = { + 'http-server': http, + auth: { + api: { + getSession: async (input: any) => { + calls.push(input); + return { user: { id: 'usr_22258' }, session: { id: 'sess_22258' } }; + }, + }, + }, + }; + let readyHook: (() => Promise) | undefined; + const ctx: any = { + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + registerService: () => {}, + // An absent slot answers `undefined` — `objectql` among them, so the + // service settles on its memory fallback. + getService: (name: string) => services[name], + hook: (event: string, fn: () => Promise) => { if (event === 'kernel:ready') readyHook = fn; }, + getKernel: () => kernel, + }; + + captured.contextFromRequest = undefined; + const plugin = new SettingsServicePlugin({ manifests: [], env: {}, actionHandlers: {} }); + await plugin.init(ctx); + await plugin.start(ctx); + await readyHook!(); + expect(http.routes.size, 'the real routes mounted').toBeGreaterThan(0); + const contextFromRequest = captured.contextFromRequest; + if (!contextFromRequest) throw new Error('fixture: the settings routes were not registered'); + + const context = await contextFromRequest({ headers, method: 'GET', path: '/api/settings' } as unknown as IHttpRequest); + return { calls, context }; +} + +describe('[#22258] the settings door reads the session by the in-process rule', () => { + it('a request carrying a session cookie reads without renewal', async () => { + const { calls, context } = await resolveContext({ cookie: SESSION_COOKIE }); + // The read resolved the caller — the seam really ran past it. + expect(context.userId).toBe('usr_22258'); + expect(calls).toHaveLength(1); + expect(calls[0].query, 'a cookie request renewed in-process').toEqual({ disableRefresh: true }); + expect(calls[0].headers.get('cookie')).toBe(SESSION_COOKIE); + }); + + it('the console sends cookie AND bearer — still no renewal in-process', async () => { + const { calls } = await resolveContext({ cookie: SESSION_COOKIE, authorization: BEARER }); + expect(calls).toHaveLength(1); + expect(calls[0].query).toEqual({ disableRefresh: true }); + }); + + it('a bearer-only request reads exactly as before — renewal stays on, no query at all', async () => { + const { calls, context } = await resolveContext({ authorization: BEARER }); + expect(context.userId).toBe('usr_22258'); + expect(calls).toHaveLength(1); + expect('query' in calls[0], 'a bearer-only read lost its renewal').toBe(false); + expect(calls[0].headers.get('authorization')).toBe(BEARER); + }); +}); diff --git a/packages/services/service-storage/src/in-process-session-read.pin.test.ts b/packages/services/service-storage/src/in-process-session-read.pin.test.ts new file mode 100644 index 00000000000..262eb89500a --- /dev/null +++ b/packages/services/service-storage/src/in-process-session-read.pin.test.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22258] The storage door's in-process `auth.api.getSession` read + * (`buildGetSession`, behind the upload session resolver and the ADR-0104 D3 + * download authorizer alike) hands better-auth the in-process session-read + * rule's input (`inProcessSessionReadInput`, `@objectstack/types`). + * + * A request carrying a session cookie reads with `query.disableRefresh`: these + * routes answer with their own response, so a renewal here would move the + * session's expiry while its renewed cookie is discarded — the browser's + * cookie would then die before its session (a split session). A bearer-only + * request reads exactly as before, renewal included. What that input DOES + * against real better-auth is pinned end to end in + * `packages/runtime/src/in-process-session-renewal.pin.test.ts` and + * `packages/plugins/plugin-auth/src/in-process-session-renewal.pin.test.ts`. + * + * Driven through the public host door (`mountStorageRoutes`) on a real + * `ObjectKernel`, as `mount-storage-routes.test.ts` drives it; the route is + * `GET /upload/chunked/:uploadId/progress`, whose first act is the session + * read. No engine is registered, so `sys_file` metadata is in memory and the + * unknown upload id answers 404 right after the read. + */ + +import { describe, it, expect } from 'vitest'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { ObjectKernel } from '@objectstack/core'; +import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { LocalStorageAdapter } from './local-storage-adapter.js'; +import { mountStorageRoutes } from './mount-storage-routes.js'; + +const BASE = '/api/v1/storage'; +const PROGRESS = `GET:${BASE}/upload/chunked/:uploadId/progress`; +const SESSION_COOKIE = 'better-auth.session_token=tok_22258.c2lnbmF0dXJl'; +const BEARER = 'Bearer tok_22258.c2lnbmF0dXJl'; + +async function readProgress(headers: Record) { + const calls: any[] = []; + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as never); + // The progress route never touches the adapter: nothing is written here. + kernel.registerService('storage', new LocalStorageAdapter({ rootDir: join(tmpdir(), 'os-22258-never-written') })); + kernel.registerService('auth', { + api: { + getSession: async (input: any) => { + calls.push(input); + return { user: { id: 'usr_22258' }, session: {} }; + }, + }, + }); + const routes = new Map(); + const http: any = { + get: (path: string, h: RouteHandler) => { routes.set(`GET:${path}`, h); }, + post: (path: string, h: RouteHandler) => { routes.set(`POST:${path}`, h); }, + put: (path: string, h: RouteHandler) => { routes.set(`PUT:${path}`, h); }, + delete: () => {}, + patch: () => {}, + use: () => {}, + listen: async () => {}, + close: async () => {}, + }; + const report = mountStorageRoutes(http, kernel, { basePath: BASE, logger: { info: () => {}, warn: () => {} } }); + expect(report.sessionResolver, 'the upload gate is bound to the auth service').toBe(true); + + const handler = routes.get(PROGRESS); + if (!handler) throw new Error(`fixture: no handler registered for ${PROGRESS}`); + const state: { status: number; body?: any } = { status: 200 }; + const res: any = { + json(data: any) { state.body = data; return res; }, + send() { return res; }, + status(code: number) { state.status = code; return res; }, + header() { return res; }, + }; + const req = { + params: { uploadId: 'upl_22258_absent' }, + query: {}, + headers, + method: 'GET', + path: `${BASE}/upload/chunked/upl_22258_absent/progress`, + } as unknown as IHttpRequest; + await handler(req, res as IHttpResponse); + return { calls, status: state.status, code: state.body?.error?.code }; +} + +describe('[#22258] the storage door reads the session by the in-process rule', () => { + it('a request carrying a session cookie reads without renewal', async () => { + const { calls, status, code } = await readProgress({ cookie: SESSION_COOKIE }); + // Admitted past the gate: the read resolved the caller (a refusal would be 401). + expect(status).toBe(404); + expect(code).toBe('UPLOAD_SESSION_NOT_FOUND'); + expect(calls).toHaveLength(1); + expect(calls[0].query, 'a cookie request renewed in-process').toEqual({ disableRefresh: true }); + expect(calls[0].headers.get('cookie')).toBe(SESSION_COOKIE); + }); + + it('the console sends cookie AND bearer — still no renewal in-process', async () => { + const { calls } = await readProgress({ cookie: SESSION_COOKIE, authorization: BEARER }); + expect(calls).toHaveLength(1); + expect(calls[0].query).toEqual({ disableRefresh: true }); + }); + + it('a bearer-only request reads exactly as before — renewal stays on, no query at all', async () => { + const { calls, status } = await readProgress({ authorization: BEARER }); + expect(status).toBe(404); + expect(calls).toHaveLength(1); + expect('query' in calls[0], 'a bearer-only read lost its renewal').toBe(false); + expect(calls[0].headers.get('authorization')).toBe(BEARER); + }); +}); From 2fedc61fc0d338e252bc23d65be79d6c82e6e400 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:28:55 +0000 Subject: [PATCH 3/6] chore(changeset): patch for the six services-lane packages whose in-process session reads change Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude --- .../22258-services-in-process-session-read.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/22258-services-in-process-session-read.md diff --git a/.changeset/22258-services-in-process-session-read.md b/.changeset/22258-services-in-process-session-read.md new file mode 100644 index 00000000000..3d1c985619c --- /dev/null +++ b/.changeset/22258-services-in-process-session-read.md @@ -0,0 +1,19 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/plugin-webhooks': patch +'@objectstack/plugin-sharing': patch +'@objectstack/service-storage': patch +'@objectstack/service-settings': patch +'@objectstack/service-datasource': patch +--- + +fix(auth,services): the remaining in-process session reads no longer renew a browser session behind its cookie (#22258) + +**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. Nine doors in these packages read the session in-process (`auth.api.getSession({ headers })`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, which then expired before the session: a dead cookie beside a live bearer, after which every cookie-only path saw a signed-out user. The doors: `POST /api/v1/auth/admin/oauth2/toggle-disabled`, every `/api/v1/auth/admin/*` mount behind the shared platform-admin gate, `POST /api/v1/auth/admin/unlock-user` and `POST /api/v1/auth/admin/has-permission` (`@objectstack/plugin-auth`); `POST /api/v1/webhooks/redeliver`; the storage upload and download doors (`/api/v1/storage/*`); the share-link management routes (`/api/v1/share-links`); the settings routes (`/api/settings`); and the datasource-admin routes (`/api/v1/datasources/*`). + +**The rule now** is the one the REST, dispatcher, current-user and cloud-connection doors already follow. Each of these reads goes through `inProcessSessionReadInput(headers)` from `@objectstack/types`: + +- **A request carrying a session cookie** (a browser, including a console that sends its cookie beside its bearer) reads with `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. +- **A bearer-only request** (`@objectstack/client` outside a browser, the `os` CLI) is unchanged: a read past `updateAge` still renews the session, and no cookie is set on a response to a request that sent none. + +**Upgrading.** Nothing to change. `@objectstack/plugin-webhooks` now depends on `@objectstack/types` directly; it already reached it through `@objectstack/core`. From eed9d7a1b5eb636debb04c54a5979b78b5a5af91 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:43:08 +0000 Subject: [PATCH 4/6] fix(auth,settings): keep the owner-email census import line intact; type the settings pin's captured seam plugin-auth's platform-owner-email-reader-census pin lists the import line that names resolvePlatformOwnerEmail verbatim, so the session-read helper takes its own import line. The settings pin reads its captured seam through a getter so tsc does not narrow the reset slot to undefined. Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude --- packages/plugins/plugin-auth/src/auth-plugin.ts | 3 ++- .../src/in-process-session-read.pin.test.ts | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 846043c005f..32c004a7d80 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -28,7 +28,8 @@ import { SystemOverviewDatasets, } from '@objectstack/platform-objects/apps'; import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages'; -import { PLATFORM_OWNER_EMAIL_ENV, inProcessSessionReadInput, resolvePlatformOwnerEmail, resolveTenancyPosture } from '@objectstack/types'; +import { PLATFORM_OWNER_EMAIL_ENV, resolvePlatformOwnerEmail, resolveTenancyPosture } from '@objectstack/types'; +import { inProcessSessionReadInput } from '@objectstack/types'; import { postureEnforcesWall, type OrgScopingEntitlement } from '@objectstack/spec/security'; import type { IDataEngine, IEmailService, II18nService, IObjectQLEngine, ISmsService } from '@objectstack/spec/contracts'; import { diff --git a/packages/services/service-settings/src/in-process-session-read.pin.test.ts b/packages/services/service-settings/src/in-process-session-read.pin.test.ts index 17c3270bd36..332563b06fd 100644 --- a/packages/services/service-settings/src/in-process-session-read.pin.test.ts +++ b/packages/services/service-settings/src/in-process-session-read.pin.test.ts @@ -45,6 +45,8 @@ vi.mock('./settings-routes.js', async (importOriginal) => { const { SettingsServicePlugin } = await import('./settings-service-plugin.js'); +const capturedSeam = () => captured.contextFromRequest; + const SESSION_COOKIE = 'better-auth.session_token=tok_22258.c2lnbmF0dXJl'; const BEARER = 'Bearer tok_22258.c2lnbmF0dXJl'; @@ -96,7 +98,9 @@ async function resolveContext(headers: Record) { await plugin.start(ctx); await readyHook!(); expect(http.routes.size, 'the real routes mounted').toBeGreaterThan(0); - const contextFromRequest = captured.contextFromRequest; + // Read through a getter: the reset above narrows the slot to `undefined` + // for the type checker, which cannot see the plugin's boot write it back. + const contextFromRequest = capturedSeam(); if (!contextFromRequest) throw new Error('fixture: the settings routes were not registered'); const context = await contextFromRequest({ headers, method: 'GET', path: '/api/settings' } as unknown as IHttpRequest); From 13ecfa93ffe0df5ee9538fa6cc57d7cc36901ea5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:50:22 +0000 Subject: [PATCH 5/6] test(plugin-webhooks): alias @objectstack/types to source for the suite webhook-outbox-plugin.ts now takes a value import on @objectstack/types, so check:test-source-alias asks for an anchored alias rather than a wider KNOWN_UNALIASED_TEST_IMPORTS entry (shrink-only). Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude --- packages/plugins/plugin-webhooks/vitest.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/plugins/plugin-webhooks/vitest.config.ts b/packages/plugins/plugin-webhooks/vitest.config.ts index 8f1f0e8272c..aa5ded710d7 100644 --- a/packages/plugins/plugin-webhooks/vitest.config.ts +++ b/packages/plugins/plugin-webhooks/vitest.config.ts @@ -49,6 +49,11 @@ export default defineConfig({ // Anchored on the SUBPATH for the same reason the `core` entry // above is anchored on the root. { find: /^@objectstack\/platform-objects\/apps$/, replacement: path.resolve(__dirname, '../../platform-objects/src/apps/index.ts') }, + // [#22258] `webhook-outbox-plugin.ts` takes a VALUE import on + // `@objectstack/types` (`inProcessSessionReadInput`, the in-process + // session-read rule). Same reason and the same anchoring as the + // `core` entry above. + { find: /^@objectstack\/types$/, replacement: path.resolve(__dirname, '../../types/src/index.ts') }, ], }, }); From 8d9dcbb4066e5e8d593cdfcf44f2415a8e11b1df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:30:21 +0000 Subject: [PATCH 6/6] chore(changeset): the cli half's note on the services-lane readers is true at release The pending changeset ended by saying the services-lane in-process readers still renew a cookie session; this branch applies the same rule to their auth.api.getSession readers, so that sentence now names their own changeset instead. Frontmatter and every other sentence unchanged. Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude --- .../22258-in-process-session-read-no-renewal-behind-cookie.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/22258-in-process-session-read-no-renewal-behind-cookie.md b/.changeset/22258-in-process-session-read-no-renewal-behind-cookie.md index f7c03b9437d..54717417b23 100644 --- a/.changeset/22258-in-process-session-read-no-renewal-behind-cookie.md +++ b/.changeset/22258-in-process-session-read-no-renewal-behind-cookie.md @@ -19,4 +19,4 @@ fix(auth): a server-side session read no longer renews a browser session behind `@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. -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. +The same rule is applied to the in-process `auth.api.getSession` readers in `@objectstack/plugin-auth`, `@objectstack/plugin-webhooks`, `@objectstack/plugin-sharing`, `@objectstack/service-storage`, `@objectstack/service-settings` and `@objectstack/service-datasource` by their own changeset.