From ecfb2f5f24f26dbc2810cddf94a6b1d291f1cc1d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 05:29:02 +0000 Subject: [PATCH 1/4] fix(plugin-auth): a minted principal session is never refreshed by a read better-auth renewed a minted session on every read: the row's updatedAt moved, the session cookie was re-set, and bearer() answered set-auth-token on every request, which the console reads as a token rotation and re-resolves identity on, looping its boot. A request that carries a minted session now sets better-auth's own per-request skip-refresh switch from the global before-hook, so no endpoint renews it on either lane. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0138Lk1GC4xpkss8esKzWVFA --- .../plugins/plugin-auth/src/auth-manager.ts | 14 +- ...ncipal-session-mint.no-refresh.pin.test.ts | 139 ++++++++++++++++++ .../plugin-auth/src/principal-session-mint.ts | 48 ++++++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/plugin-auth/src/principal-session-mint.no-refresh.pin.test.ts diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 00e04f1c266..97e29613ed1 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -103,6 +103,7 @@ import { } from './admin-impersonate-endpoint.js'; import { judgeHeldSession } from './platform-admin-gate.js'; import { + carriesPrincipalSession, holdPrincipalSessionExpiry, mintPrincipalSession, type PrincipalSessionAuthContext, @@ -1528,7 +1529,7 @@ export class AuthManager { */ private async createAuthInstance(): Promise> { const { betterAuth } = await import('better-auth'); - const { createAuthMiddleware } = await import('better-auth/api'); + const { createAuthMiddleware, setShouldSkipSessionRefresh } = await import('better-auth/api'); const plugins = await this.buildPluginList(); const passwordHasher = await this.resolvePasswordHasher(); const betterAuthConfig: BetterAuthOptions = { @@ -2064,6 +2065,17 @@ export class AuthManager { return actor?.userId ?? null; }); + // ── #23813: a minted principal session is never refreshed ─────── + // Also FIRST: the switch must be set before anything in this + // request reads the session (the actor resolver above may, later). + // A renewal of a minted session writes the row and re-sets its + // cookie, so `bearer()` answers `set-auth-token` on every request + // and the console re-resolves identity forever. Request-scoped + // (better-auth's own state), so it reaches the nested session reads + // of every endpoint on both lanes and in-process — see + // `principal-session-mint.ts` (3b). + if (carriesPrincipalSession(ctx)) await setShouldSkipSessionRefresh(true); + // ── #2780: per-number OTP send guard (admission control) ───── // MUST run BEFORE the phone-number endpoints: better-auth's // send-otp handler stores a fresh code and only THEN invokes diff --git a/packages/plugins/plugin-auth/src/principal-session-mint.no-refresh.pin.test.ts b/packages/plugins/plugin-auth/src/principal-session-mint.no-refresh.pin.test.ts new file mode 100644 index 00000000000..811e305e435 --- /dev/null +++ b/packages/plugins/plugin-auth/src/principal-session-mint.no-refresh.pin.test.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23813] A minted principal session is not REFRESHED by a read — not merely + * held to its expiry (#23772's pins), but never renewed at all. + * + * The renewal #23772 clamps still wrote the row (`updatedAt`), re-set the + * session cookie and so made `bearer()` answer `set-auth-token` on every + * request. The console reads that header as a token rotation and re-resolves + * identity, then re-reads its organizations, whose session reads renew again: + * measured on the real console, it never left "Opening your console...". So + * the pin is the wire a client sees: on both lanes, `get-session` and an + * endpoint that reads the session through better-auth's middleware answer + * with no `set-auth-token`, no session `Set-Cookie`, and the row unwritten. + * The controls prove the same reads DO refresh an unbranded short session, + * so the pins cannot pass on a fixture that never renews. + * + * Real better-auth, real request seam (`AuthManager.handleRequest`), the + * `sys_session` row read straight off the engine's table. + */ + +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; +import { bootMintFixture, expiresAtMs, MINT_BASE, rawTokenOf, type MintFixture } from './principal-session-mint-test-support'; +import { carriesPrincipalSession, PRINCIPAL_SESSION_TOKEN_PREFIX } from './principal-session-mint'; + +let fx: MintFixture; +let cookieName: string; + +beforeAll(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + fx = await bootMintFixture(createMemoryEngine as any); + cookieName = (await fx.manager.getAuthContext()).authCookies.sessionToken.name; +}); + +afterAll(() => vi.restoreAllMocks()); + +/** What a client sees of one request: the status and whether it was handed a (re)issued session. */ +async function wire(path: string, headers: Record) { + const res = await fx.manager.handleRequest( + new Request(`${fx.origin}${MINT_BASE}${path}`, { method: 'GET', headers: { origin: fx.origin, ...headers } }), + ); + const setCookie = res.headers.get('set-cookie') ?? ''; + return { + status: res.status, + setAuthToken: res.headers.has('set-auth-token'), + setsSessionCookie: setCookie.split(/,(?=\s*[^;,=\s]+=)/).some((c) => c.trim().startsWith(`${cookieName}=`)), + }; +} + +const updatedAtMs = (token: string): number => { + const row = fx.row(token); + if (!row) throw new Error('pin: the session row is gone'); + return new Date(row.updated_at as string | Date).getTime(); +}; + +/** A short session WITHOUT the brand, signed as the cookie carries it. */ +async function plainShortSession(): Promise<{ token: string; signed: string }> { + const ctx: any = await fx.manager.getAuthContext(); + const plain = await ctx.internalAdapter.createSession( + fx.userId, false, { expiresAt: new Date(Date.now() + 120_000) }, true, + ); + const { makeSignature } = await import('better-auth/crypto'); + return { token: plain.token, signed: encodeURIComponent(`${plain.token}.${await makeSignature(plain.token, ctx.secret)}`) }; +} + +const LANES = { + cookie: (signed: string) => ({ cookie: `${cookieName}=${signed}` }), + bearer: (signed: string) => ({ authorization: `Bearer ${signed}` }), +} as const; + +// `/get-session` is the console's boot read; `/list-sessions` reads the session +// through better-auth's `sessionMiddleware`, as the organization reads do. +const PATHS = ['/get-session', '/list-sessions'] as const; + +describe('[#23813] a minted session is never refreshed by a read', () => { + for (const [lane, headersOf] of Object.entries(LANES)) { + for (const path of PATHS) { + it(`control (${lane}, ${path}): the same read refreshes a short session WITHOUT the brand`, async () => { + const plain = await plainShortSession(); + const before = updatedAtMs(plain.token); + await new Promise((r) => setTimeout(r, 5)); + + const seen = await wire(path, headersOf(plain.signed)); + + expect(seen.status).toBe(200); + expect( + { setAuthToken: seen.setAuthToken, setsSessionCookie: seen.setsSessionCookie }, + 'the fixture does not refresh — the pin below would pass vacuously', + ).toEqual({ setAuthToken: true, setsSessionCookie: true }); + expect(updatedAtMs(plain.token)).toBeGreaterThan(before); + }); + + it(`${lane}, ${path}: answers with no reissued session and leaves the row unwritten`, async () => { + const minted = await fx.manager.mintPrincipalSession(fx.human()); + const token = rawTokenOf(minted); + const updatedAt = updatedAtMs(token); + const expiresAt = expiresAtMs(fx.row(token)); + + for (let i = 0; i < 3; i++) { + await new Promise((r) => setTimeout(r, 5)); + const seen = await wire(path, headersOf(minted.token)); + expect(seen, `read ${i + 1} reissued the minted session`).toEqual({ + status: 200, + setAuthToken: false, + setsSessionCookie: false, + }); + expect(updatedAtMs(token), `read ${i + 1} wrote the minted session row`).toBe(updatedAt); + expect(expiresAtMs(fx.row(token)), `read ${i + 1} moved the minted expiry`).toBe(expiresAt); + } + await minted.revoke(); + }); + } + } +}); + +describe('[#23813] carriesPrincipalSession', () => { + const ctxOf = (headers: Record) => ({ + headers: new Headers(headers), + context: { authCookies: { sessionToken: { name: 'better-auth.session_token' } } }, + }); + const minted = `${PRINCIPAL_SESSION_TOKEN_PREFIX}abc123`; + + it('reads the brand on the cookie lane and on every bearer spelling', () => { + expect(carriesPrincipalSession(ctxOf({ cookie: `a=b; better-auth.session_token=${minted}.sig%3D` }))).toBe(true); + expect(carriesPrincipalSession(ctxOf({ authorization: `Bearer ${minted}.sig` }))).toBe(true); + expect(carriesPrincipalSession(ctxOf({ authorization: `Bearer ${minted}` }))).toBe(true); + expect(carriesPrincipalSession(ctxOf({ authorization: `Bearer ${encodeURIComponent(`${minted}.sig=`)}` }))).toBe(true); + }); + + it('answers false for every other session, and for the brand under another cookie name', () => { + expect(carriesPrincipalSession(ctxOf({ cookie: 'better-auth.session_token=AbC123.sig' }))).toBe(false); + expect(carriesPrincipalSession(ctxOf({ authorization: 'Bearer AbC123.sig' }))).toBe(false); + expect(carriesPrincipalSession(ctxOf({ cookie: `other_cookie=${minted}` }))).toBe(false); + expect(carriesPrincipalSession(ctxOf({}))).toBe(false); + expect(carriesPrincipalSession(undefined)).toBe(false); + }); +}); diff --git a/packages/plugins/plugin-auth/src/principal-session-mint.ts b/packages/plugins/plugin-auth/src/principal-session-mint.ts index 791dc5f1444..83d366a652f 100644 --- a/packages/plugins/plugin-auth/src/principal-session-mint.ts +++ b/packages/plugins/plugin-auth/src/principal-session-mint.ts @@ -54,6 +54,20 @@ * token, which carries {@link PRINCIPAL_SESSION_TOKEN_PREFIX} — set here, * by the server, and stored on the row, so every node agrees. * + * 3b. **No renewal at all on a read** (#23813). Holding `expiresAt` is not + * enough: the renewal still writes the row (`updatedAt`), re-sets the + * session cookie with a 7-day `Max-Age`, and so makes `bearer()` answer + * `set-auth-token` on EVERY request — which the console reads as a token + * rotation and re-resolves identity on, then re-reads its organizations, + * whose reads renew again: the console never leaves boot. So a request + * that carries a minted session ({@link carriesPrincipalSession}) is told + * to skip the refresh outright, through better-auth's own per-request + * switch (`setShouldSkipSessionRefresh`, the one its Next.js plugin sets + * for RSC reads), from the global before-hook that every endpoint passes + * first — `get-session` and every endpoint that reads the session, on the + * cookie lane, the bearer lane and in-process. The hold above stays as + * the backstop for any renewal that does not ask that switch. + * * 4. **The revoke.** Bound to the one session minted, idempotent, and a * plain `internalAdapter.deleteSession`: afterwards the token and the * cookie resolve no session on either lane. @@ -90,6 +104,40 @@ export function isPrincipalSessionToken(token: unknown): boolean { return typeof token === 'string' && token.startsWith(PRINCIPAL_SESSION_TOKEN_PREFIX); } +/** + * [#23813] True when a better-auth endpoint context carries a minted principal + * session — as the session cookie (its signed value starts with the raw + * token) or as `Authorization: Bearer` (signed, URL-encoded or raw; the brand + * leads every spelling). Read off the request before `bearer()` folds the + * header into the cookie, so both lanes answer the same. + * + * Unverified on purpose: the one thing it decides is that this request does + * not renew the session, which grants nothing. A forged brand resolves no + * session at all (no other token carries it), and the signature is still + * checked where the session is read. + */ +export function carriesPrincipalSession(ctx: unknown): boolean { + const c = ctx as { + headers?: { get?(name: string): string | null }; + request?: { headers?: { get?(name: string): string | null } }; + context?: { authCookies?: { sessionToken?: { name?: string } } }; + }; + const header = (name: string): string => c?.headers?.get?.(name) ?? c?.request?.headers?.get?.(name) ?? ''; + + const bearer = /^Bearer\s+(\S+)/i.exec(header('authorization')); + if (bearer && isPrincipalSessionToken(bearer[1])) return true; + + const cookieName = c?.context?.authCookies?.sessionToken?.name; + if (!cookieName) return false; + for (const pair of header('cookie').split(';')) { + const eq = pair.indexOf('='); + if (eq > 0 && pair.slice(0, eq).trim() === cookieName && isPrincipalSessionToken(pair.slice(eq + 1).trim())) { + return true; + } + } + return false; +} + /** * A refusal of the mint, thrown before anything is minted. `code` and * `status` follow the ADR-0112 envelope, so a door that relays it answers From 7b2be08919dcebbe41a1120559691f181cab28a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 05:50:54 +0000 Subject: [PATCH 2/4] test(dogfood): a minted principal session boots the real console Real Chromium, the built console composed into the showcase's plugins as os serve mounts it, and a session cookie minted by mintPrincipalSession, for an administrator and a member; a normal sign-in session is the control. Skips, and says so, without a built console or a Chromium. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0138Lk1GC4xpkss8esKzWVFA --- packages/qa/dogfood/package.json | 1 + ...cipal-session-console-boot.dogfood.test.ts | 227 ++++++++++++++++++ pnpm-lock.yaml | 60 ++++- 3 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 packages/qa/dogfood/test/principal-session-console-boot.dogfood.test.ts diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 38da7933f7c..3750eeee2ae 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -52,6 +52,7 @@ "@objectstack/setup": "workspace:*", "@objectstack/trigger-api": "workspace:*", "@types/node": "^26.6.3", + "playwright-core": "1.63.0", "typescript": "^6.0.3", "vitest": "^4.1.11" } diff --git a/packages/qa/dogfood/test/principal-session-console-boot.dogfood.test.ts b/packages/qa/dogfood/test/principal-session-console-boot.dogfood.test.ts new file mode 100644 index 00000000000..2cfb0794313 --- /dev/null +++ b/packages/qa/dogfood/test/principal-session-console-boot.dogfood.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23813] A session minted by `IAuthService.mintPrincipalSession` boots the + * REAL console — the door the print service (#22269) sends its headless + * browser through, under the requesting user's principal (#8346 B′, M2). + * + * ## The defect this pins + * + * better-auth refreshed a minted session on every read. #23772's guard held + * its `expiresAt`, but the refresh still wrote the row, re-set the session + * cookie and so made `bearer()` answer `set-auth-token` on every request. The + * console's auth client reads that header as a token rotation and re-resolves + * identity; the new identity re-runs its organization reads, whose session + * reads refresh again. Measured on this file's stack with the fix ablated: the + * console never leaves "Opening your console…" and `get-session` runs without + * end, for an administrator and a member alike, while a normal sign-in session + * for the same user boots. + * + * ## The stack + * + * The showcase booted in-process (`bootShowcase`), the console mounted on the + * same Hono app by the CLI's own `createConsoleStaticPlugin` (what `os serve` + * mounts), and a real Chromium whose every request is answered by that app. + * The minted cookie is set exactly as the mint hands it out, and nothing else + * is: no bearer, no dont-remember cookie. + * + * Two prerequisites live outside the test runner: the built console + * (`scripts/build-console.sh`, at `.objectui-sha`) and a Chromium executable + * (`OS_TEST_CHROMIUM_EXECUTABLE_PATH`, or `PLAYWRIGHT_BROWSERS_PATH/chromium`). + * Without either the file skips and says so; the wire half of the pin (no + * refresh, no `set-auth-token`, no row write, on both lanes) runs everywhere, + * in `plugin-auth`'s `principal-session-mint.no-refresh.pin.test.ts`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { VerifyStack } from '@objectstack/verify'; +import { hasConsoleDist, resolveConsolePath, createConsoleStaticPlugin } from '@objectstack/cli/console'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootShowcase } from './showcase-boot.js'; + +const ORIGIN = 'http://localhost:3000'; +const CONSOLE_URL = `${ORIGIN}/_console/`; +/** `bootStack`'s seeded dev admin (`@objectstack/verify`). */ +const ADMIN_EMAIL = 'admin@objectos.ai'; +const ADMIN_PASSWORD = 'admin123'; +const MEMBER_EMAIL = 'member.23813@example.com'; +const MEMBER_PASSWORD = 'Member!Pass23813'; +/** How long a boot may take before the splash counts as stuck. */ +const BOOT_BUDGET_MS = 30_000; +/** The quiet window after boot: a settled console reads no session in it. */ +const SETTLE_MS = 3_000; + +function chromiumExecutable(): string | null { + const explicit = process.env.OS_TEST_CHROMIUM_EXECUTABLE_PATH; + if (explicit) return fs.existsSync(explicit) ? explicit : null; + const root = process.env.PLAYWRIGHT_BROWSERS_PATH; + if (!root) return null; + const candidate = path.join(root, 'chromium'); + return fs.existsSync(candidate) ? candidate : null; +} + +const consoleDist = resolveConsolePath(); +const executable = chromiumExecutable(); +const ready = !!consoleDist && hasConsoleDist(consoleDist) && !!executable; +if (!ready) { + console.warn( + `[#23813] principal-session-console-boot SKIPPED: ${!consoleDist || !hasConsoleDist(consoleDist) ? 'no built console (run scripts/build-console.sh)' : ''}` + + `${!executable ? ' no Chromium (set OS_TEST_CHROMIUM_EXECUTABLE_PATH or PLAYWRIGHT_BROWSERS_PATH)' : ''}`, + ); +} + +/** What one console boot did, as the browser saw it. */ +interface BootReading { + /** The splash is gone, the signed-in shell is on screen, and no sign-in form is. */ + booted: boolean; + /** `get-session` answers that resolved the user, before the boot settled. */ + sessionsResolved: number; + getSessionDuringBoot: number; + /** `get-session` calls in the quiet window after boot — a settled console makes none. */ + getSessionAfterSettle: number; + /** Responses that handed the browser a reissued session (`bearer()`'s `set-auth-token`). */ + setAuthTokenResponses: number; +} + +describe.skipIf(!ready)('[#23813] a minted principal session boots the real console', () => { + let stack: VerifyStack; + let auth: any; + let browser: any; + let cookieName: string; + + beforeAll(async () => { + // The console is composed into the app's own plugins, as `os serve` mounts it beside them. + const plugins = [ + ...((showcaseStack as { plugins?: unknown[] }).plugins ?? []), + createConsoleStaticPlugin(path.join(consoleDist!, 'dist')), + ]; + stack = await bootShowcase({ orgContext: true }, { ...showcaseStack, plugins }); + auth = await stack.kernel.getServiceAsync('auth'); + cookieName = (await auth.getAuthContext()).authCookies.sessionToken.name; + await stack.signUp(MEMBER_EMAIL, MEMBER_PASSWORD, 'Member 23813'); + const { chromium } = await import('playwright-core'); + browser = await chromium.launch({ executablePath: executable!, headless: true }); + }, 180_000); + + afterAll(async () => { + await browser?.close(); + await stack?.stop(); + }); + + /** The user and organization a bearer session resolves to, as the HTTP entry reads them. */ + async function whoIs(bearer: string): Promise<{ userId: string; tenantId?: string }> { + const res = await stack.raw('/api/v1/auth/get-session', { headers: { authorization: `Bearer ${bearer}`, origin: ORIGIN } }); + const body = (await res.json()) as any; + return { userId: String(body.user.id), tenantId: body.session.activeOrganizationId ?? undefined }; + } + + /** A normal sign-in of the dev admin, on the cookie lane: the session cookie value the sign-in route sets. */ + async function signInCookie(): Promise { + const res = await stack.raw('/api/v1/auth/sign-in/email', { + method: 'POST', + headers: { 'content-type': 'application/json', origin: ORIGIN }, + body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }), + }); + const set = res.headers.getSetCookie().find((c) => c.startsWith(`${cookieName}=`)); + if (!set) throw new Error(`control: the sign-in route answered ${res.status} and set no session cookie`); + return set.slice(cookieName.length + 1).split(';')[0]; + } + + /** Open the console in a fresh browser context carrying exactly `cookieValue` as the session cookie. */ + async function bootConsole(cookieValue: string): Promise { + const context = await browser.newContext(); + const reading: BootReading = { + booted: false, + sessionsResolved: 0, + getSessionDuringBoot: 0, + getSessionAfterSettle: 0, + setAuthTokenResponses: 0, + }; + let settled = false; + try { + await context.addCookies([{ name: cookieName, value: cookieValue, url: ORIGIN, httpOnly: true, sameSite: 'Lax' }]); + await context.route('**/*', async (route: any) => { + const req = route.request(); + const url = new URL(req.url()); + if (url.origin !== ORIGIN) return route.abort(); + const isGetSession = url.pathname.endsWith('/auth/get-session'); + if (isGetSession) { + if (settled) reading.getSessionAfterSettle++; + else reading.getSessionDuringBoot++; + } + const body = req.postDataBuffer(); + const res = await stack.raw(`${url.pathname}${url.search}`, { + method: req.method(), + headers: await req.allHeaders(), + ...(body && req.method() !== 'GET' && req.method() !== 'HEAD' ? { body } : {}), + }); + if (res.headers.has('set-auth-token')) reading.setAuthTokenResponses++; + const payload = Buffer.from(await res.arrayBuffer()); + if (isGetSession && res.status === 200 && !settled) { + try { + if (JSON.parse(payload.toString('utf8'))?.user?.id) reading.sessionsResolved++; + } catch { /* not a session answer */ } + } + const out: Record = {}; + res.headers.forEach((v, k) => { if (k !== 'set-cookie') out[k] = v; }); + const cookies = res.headers.getSetCookie(); + if (cookies.length) out['set-cookie'] = cookies.join('\n'); + await route.fulfill({ status: res.status, headers: out, body: payload }); + }); + + const page = await context.newPage(); + await page.goto(CONSOLE_URL); + const deadline = Date.now() + BOOT_BUDGET_MS; + while (Date.now() < deadline) { + const text: string = await page.evaluate(() => document.body?.innerText ?? ''); + const onSplash = text.includes('Opening your console'); + const onSignIn = /sign in to your account/i.test(text) || /\/login\b/.test(page.url()); + if (!onSplash && !onSignIn && text.trim().length > 0 && reading.sessionsResolved > 0) { + reading.booted = true; + break; + } + await page.waitForTimeout(250); + } + settled = true; + await page.waitForTimeout(SETTLE_MS); + } finally { + await context.close(); + } + return reading; + } + + it('control: a normal sign-in session for the administrator boots the console', async () => { + const reading = await bootConsole(await signInCookie()); + expect(reading.booted, `the harness cannot boot the console at all: ${JSON.stringify(reading)}`).toBe(true); + expect(reading.getSessionAfterSettle).toBe(0); + }, 90_000); + + for (const who of ['administrator', 'member'] as const) { + it(`a minted session for the ${who} boots the console and then reads no session`, async () => { + const bearer = who === 'administrator' ? await stack.signIn() : await stack.signIn(MEMBER_EMAIL, MEMBER_PASSWORD); + const { userId, tenantId } = await whoIs(bearer); + const minted = await auth.mintPrincipalSession({ + userId, + ...(tenantId ? { tenantId } : {}), + positions: [], + permissions: [], + isSystem: false, + }); + try { + const reading = await bootConsole(minted.cookie.value); + expect( + { + booted: reading.booted, + getSessionAfterSettle: reading.getSessionAfterSettle, + setAuthTokenResponses: reading.setAuthTokenResponses, + }, + `the console did not settle on a minted session: ${JSON.stringify(reading)}`, + ).toEqual({ booted: true, getSessionAfterSettle: 0, setAuthTokenResponses: 0 }); + } finally { + await minted.revoke(); + } + }, 90_000); + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef069e88bab..14765ac91c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -402,7 +402,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)) packages/apps/setup: dependencies: @@ -2183,6 +2183,9 @@ importers: '@types/node': specifier: ^26.6.3 version: 26.6.3 + playwright-core: + specifier: 1.63.0 + version: 1.63.0 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -11925,6 +11928,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.11(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.14.6(@types/node@26.6.3)(typescript@6.0.3) + vite: 8.0.16(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1) + '@vitest/mocker@4.1.11(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1))': dependencies: '@vitest/spy': 4.1.11 @@ -16034,6 +16046,21 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.3 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6) + yaml: 2.9.1 + vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1): dependencies: lightningcss: 1.33.0 @@ -16049,6 +16076,37 @@ snapshots: tsx: 4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6) yaml: 2.9.1 + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.6.3 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + happy-dom: 20.10.2 + jsdom: 30.1.1(@noble/hashes@2.4.0) + transitivePeerDependencies: + - msw + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)): dependencies: '@vitest/expect': 4.1.11 From f4a1051e3cc1299788f0912024c95948c292099b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 05:54:10 +0000 Subject: [PATCH 3/4] chore(changeset): plugin-auth minted session is not refreshed by a read Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0138Lk1GC4xpkss8esKzWVFA --- .changeset/23813-minted-session-no-refresh.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/23813-minted-session-no-refresh.md diff --git a/.changeset/23813-minted-session-no-refresh.md b/.changeset/23813-minted-session-no-refresh.md new file mode 100644 index 00000000000..b100f4deab7 --- /dev/null +++ b/.changeset/23813-minted-session-no-refresh.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-auth': patch +--- + +Clause-②: no + +A session minted by `IAuthService.mintPrincipalSession` is no longer refreshed by a read. Before, +`get-session` (and every endpoint that reads the session) still renewed it on each request: #23772's +guard held its expiry, but the row's `updatedAt` was rewritten, the session cookie was re-set with a +7-day `Max-Age`, and `bearer()` answered `set-auth-token`. The console reads that header as a token +rotation, so a browser carrying a minted cookie stayed on "Opening your console…" and the print +service could not render under the requesting user. A request that carries a minted session now +sets better-auth's own per-request skip-refresh switch, on the cookie lane, the bearer lane and +in-process. The mint's contract, TTL, refusals and revoke are unchanged. From d4839df3ada8d8b73eaf404a54189047e998e8b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:18:53 +0000 Subject: [PATCH 4/4] test(dogfood): alias @objectstack/cli/console to source for the console-boot pin Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0138Lk1GC4xpkss8esKzWVFA --- packages/qa/dogfood/vitest.config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index 53dd5406012..6705b3105c7 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -262,6 +262,14 @@ export default defineConfig({ find: /^@objectstack\/trigger-record-change$/, replacement: path.resolve(__dirname, '../../triggers/trigger-record-change/src/index.ts'), }, + // [#23813] `principal-session-console-boot.dogfood.test.ts` mounts + // the console with the CLI's own `createConsoleStaticPlugin` — the + // pin must serve the console the way THIS checkout's `os serve` + // does, not the way the last `pnpm build` of the CLI did. + { + find: /^@objectstack\/cli\/console$/, + replacement: path.resolve(__dirname, '../../cli/src/console.ts'), + }, // [commit ecdfc9411] `schedule-acting-organization.dogfood.test.ts` and // `schedule-sweep-organization-scope.dogfood.test.ts` drive // `ScheduleTrigger` / `TimeRelativeTrigger` themselves: the pins'