From bfd705d4ef84a7b421ed0a001797a1813922ff43 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 13:50:26 +0000 Subject: [PATCH] fix(server-timing): emit admin-gated per-request Server-Timing on the standard server (#3361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-request, admin-gated `Server-Timing` path (#2408) — an admin sends `X-OS-Debug-Timing: 1`/`json` and sees auth/db/hooks/serialize spans while an ordinary user sees nothing — never emitted on the shipped `os serve`/`dev` server. The disclosure gate the Hono middleware opens is only ever flipped by the runtime dispatcher's `timedResolveExecutionContext`, but the data (`/api/v1/data/*`) and metadata (`/api/v1/meta/*`) routes there are served by `@objectstack/rest`'s `RestServer` (which shadows the Hono plugin's own CRUD), and its identity resolver never opened the gate. Only global mode (`OS_SERVER_TIMING=true`) — which discloses to every caller, not just admins — worked, so the documented admin-gated path was unavailable on the OSS server. - observability: home `isPerfDisclosurePrincipal(ec)` here (the gate's package), the ONE shared definition of "who may pull per-request timings" used by every HTTP entry point. `@objectstack/runtime` re-exports it for back-compat. - rest: `RestServer.resolveExecCtx` opens the gate for an admin/service principal via the carried `posture` rung — the fix that makes `os serve`/`dev` emit. - plugin-hono-server: the standalone CRUD surface's self-contained `resolveCtx` opens the gate too, deriving the rung for the gate decision ONLY (never writing an imprecise `posture` onto the returned context — `ctx.posture` is an enforcement input for Layer 0 tier adjudication, ADR-0099 D1). Tests: a rest integration test driving the real `resolveAuthzContext` → `derivePosture` pipeline (admin opens the gate; member/anon do not) and a Hono e2e test that boots the app and asserts an admin gets `Server-Timing` while a member/anonymous caller does not — the end-to-end coverage the issue calls out as the CI gap. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DHeWEHnvQHiN9f9P1t47Sj --- .changeset/server-timing-admin-gated-hono.md | 30 +++++ packages/observability/src/index.ts | 1 + .../observability/src/perf-timing.test.ts | 23 ++++ packages/observability/src/perf-timing.ts | 28 +++++ .../plugin-hono-server/src/hono-plugin.ts | 22 ++++ .../src/server-timing-e2e.test.ts | 114 +++++++++++++++++ packages/rest/package.json | 1 + packages/rest/src/rest-server-timing.test.ts | 116 ++++++++++++++++++ packages/rest/src/rest-server.ts | 15 ++- packages/runtime/src/http-dispatcher.ts | 29 ++--- pnpm-lock.yaml | 3 + 11 files changed, 360 insertions(+), 22 deletions(-) create mode 100644 .changeset/server-timing-admin-gated-hono.md create mode 100644 packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts create mode 100644 packages/rest/src/rest-server-timing.test.ts diff --git a/.changeset/server-timing-admin-gated-hono.md b/.changeset/server-timing-admin-gated-hono.md new file mode 100644 index 0000000000..602c0e5e63 --- /dev/null +++ b/.changeset/server-timing-admin-gated-hono.md @@ -0,0 +1,30 @@ +--- +"@objectstack/observability": patch +"@objectstack/rest": patch +"@objectstack/plugin-hono-server": patch +"@objectstack/runtime": patch +--- + +fix(server-timing): emit the per-request, admin-gated `Server-Timing` header on the standard server (`os serve`/`dev`) (#3361) + +The per-request `Server-Timing` path (#2408) — where an admin sends +`X-OS-Debug-Timing: 1` (or `json`) and gets phase timings while an ordinary user +gets nothing — never emitted on the shipped Hono server. The disclosure gate the +Hono middleware opens is only ever flipped by the runtime dispatcher's +`timedResolveExecutionContext`, but the data (`/api/v1/data/*`) and metadata +(`/api/v1/meta/*`) routes on `os serve`/`dev` are served by `@objectstack/rest`'s +`RestServer` (which shadows the Hono plugin's own CRUD), and its identity +resolver never opened the gate. Only global mode (`OS_SERVER_TIMING=true`) — which +discloses to *every* caller, not just admins — worked. + +- **observability**: the disclosure predicate `isPerfDisclosurePrincipal(ec)` now + lives here (the home of the gate), the single definition of "who may pull + per-request timings" shared by every HTTP entry point. `@objectstack/runtime` + re-exports it for back-compat. +- **rest**: `RestServer.resolveExecCtx` opens the gate for an admin/service + principal (via the carried `posture` rung), the REST-server analog of the + dispatcher — this is the fix that makes `os serve`/`dev` emit. +- **plugin-hono-server**: the standalone CRUD surface's self-contained + `resolveCtx` opens the gate too (deriving the rung for the gate decision only, + never writing it onto the enforcement context). Adds an e2e test that boots the + Hono app and asserts an admin gets `Server-Timing` while a member/anon does not. diff --git a/packages/observability/src/index.ts b/packages/observability/src/index.ts index 6d0dff81a0..b266cd084d 100644 --- a/packages/observability/src/index.ts +++ b/packages/observability/src/index.ts @@ -57,6 +57,7 @@ export { allowPerfDisclosure, isPerfDisclosureAllowed, isPerfDisclosurePrivileged, + isPerfDisclosurePrincipal, type ServerTimingMark, type ServerTimingDetail, type PerfDisclosureGate, diff --git a/packages/observability/src/perf-timing.test.ts b/packages/observability/src/perf-timing.test.ts index 7fb5b3eb71..e31611feff 100644 --- a/packages/observability/src/perf-timing.test.ts +++ b/packages/observability/src/perf-timing.test.ts @@ -14,9 +14,11 @@ import { allowPerfDisclosure, isPerfDisclosureAllowed, isPerfDisclosurePrivileged, + isPerfDisclosurePrincipal, recordServerTimingDetail, type PerfDisclosureGate, } from './perf-timing.js'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; describe('formatServerTiming', () => { it('serializes name + duration', () => { @@ -349,3 +351,24 @@ describe('disclosure gate', () => { }); }); }); + +describe('isPerfDisclosurePrincipal', () => { + const ctx = (over: Partial): ExecutionContext => + ({ isSystem: false, positions: [], permissions: [], ...over }) as ExecutionContext; + + it('denies an undefined / anonymous / ordinary principal', () => { + expect(isPerfDisclosurePrincipal(undefined)).toBe(false); + expect(isPerfDisclosurePrincipal(ctx({}))).toBe(false); + expect(isPerfDisclosurePrincipal(ctx({ principalKind: 'human', posture: 'MEMBER' }))).toBe(false); + expect(isPerfDisclosurePrincipal(ctx({ principalKind: 'guest' }))).toBe(false); + expect(isPerfDisclosurePrincipal(ctx({ principalKind: 'agent' }))).toBe(false); + }); + + it('allows system / service principals and the admin posture rungs', () => { + expect(isPerfDisclosurePrincipal(ctx({ isSystem: true }))).toBe(true); + expect(isPerfDisclosurePrincipal(ctx({ principalKind: 'service' }))).toBe(true); + expect(isPerfDisclosurePrincipal(ctx({ principalKind: 'system' }))).toBe(true); + expect(isPerfDisclosurePrincipal(ctx({ posture: 'PLATFORM_ADMIN' }))).toBe(true); + expect(isPerfDisclosurePrincipal(ctx({ posture: 'TENANT_ADMIN' }))).toBe(true); + }); +}); diff --git a/packages/observability/src/perf-timing.ts b/packages/observability/src/perf-timing.ts index f4cbf7dad3..d1adb3c1af 100644 --- a/packages/observability/src/perf-timing.ts +++ b/packages/observability/src/perf-timing.ts @@ -31,6 +31,7 @@ */ import { AsyncLocalStorage } from 'node:async_hooks'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; /** * One recorded phase of a request's server-side processing, serialized as a @@ -447,3 +448,30 @@ export function isPerfDisclosureAllowed(): boolean { export function isPerfDisclosurePrivileged(): boolean { return gateStore.getStore()?.privileged ?? false; } + +/** + * Whether a resolved principal may see a PER-REQUEST `Server-Timing` header + * (#2408 perf-tuning gating). The header exposes internal phase durations — a + * mild backend-fingerprinting surface — so when timing is opened per-request via + * `X-OS-Debug-Timing` it is disclosed only to an admin/service identity: + * + * - `isSystem` — internal/engine self-calls, + * - `principalKind` `service` / `system` — service tokens & the system seed, + * - `posture` `PLATFORM_ADMIN` / `TENANT_ADMIN` — the derived admin rungs. + * + * Ordinary human/guest/agent callers get `false`, so sending the debug header + * yields no header for them. Global (env/option) perf mode bypasses this — it + * opened the disclosure gate up front for the whole environment. + * + * This is the ONE definition of "who may pull per-request timings", shared by + * every HTTP entry point that resolves a principal — the runtime dispatcher + * (`timedResolveExecutionContext`), the REST server, and the standalone Hono + * CRUD surface — so a new admin-serving path can never silently under- or + * over-disclose by hand-rolling its own rule (#3361). + */ +export function isPerfDisclosurePrincipal(ec: ExecutionContext | undefined): boolean { + if (!ec) return false; + if (ec.isSystem === true) return true; + if (ec.principalKind === 'service' || ec.principalKind === 'system') return true; + return ec.posture === 'PLATFORM_ADMIN' || ec.posture === 'TENANT_ADMIN'; +} diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 84adcb3b9f..085d1c1675 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -3,10 +3,13 @@ import { Plugin, PluginContext, IDataEngine, shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS, + derivePosture, } from '@objectstack/core'; import { RestServerConfig, } from '@objectstack/spec/api'; +import { ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN } from '@objectstack/spec'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; import { HonoHttpServer, HonoCorsOptions } from './adapter'; import { cors } from 'hono/cors'; import { serveStatic } from '@hono/node-server/serve-static'; @@ -18,6 +21,8 @@ import { PerfTiming, runWithPerfTiming, runWithPerfDisclosure, + allowPerfDisclosure, + isPerfDisclosurePrincipal, type PerfDisclosureGate, } from '@objectstack/observability'; @@ -869,6 +874,23 @@ export class HonoServerPlugin implements Plugin { /* no ai_access column / query failed → no seat (safe) */ } } + // [#2408 / #3361] Open the per-request `Server-Timing` disclosure + // gate for an admin/service principal — the standalone-surface analog + // of the runtime dispatcher's `timedResolveExecutionContext`. This + // self-contained resolver derives no posture rung, so derive one HERE, + // for the gate decision ONLY, from the resolved permission-set grants, + // and hand it to the shared `isPerfDisclosurePrincipal` predicate. The + // rung is computed onto a THROW-AWAY object, never the returned + // context: `ctx.posture` is an enforcement input (Layer 0 tier + // adjudication, ADR-0099 D1) and only the authoritative resolver may + // set it. A no-op when perf-tuning is off (no ambient gate). + const disclosurePosture = derivePosture({ + isPlatformAdmin: permissions.includes(ADMIN_FULL_ACCESS), + isTenantAdmin: permissions.includes(ORGANIZATION_ADMIN), + }); + if (isPerfDisclosurePrincipal({ isSystem: false, posture: disclosurePosture } as ExecutionContext)) { + allowPerfDisclosure(); + } return { userId, tenantId, diff --git a/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts b/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts new file mode 100644 index 0000000000..da9f3f0757 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// End-to-end regression for #3361 on the STANDALONE Hono CRUD surface (the +// minimal server used when `@objectstack/rest` is not mounted). It drives a real +// request through the perf middleware AND the real `/api/v1/data/:object` +// handler — whose `resolveCtx` resolves the principal — instead of a handler +// that calls `allowPerfDisclosure()` by hand "as the dispatcher would" (the gap +// the existing unit tests left invisible). An admin sending `X-OS-Debug-Timing` +// must get a `Server-Timing` header; a member and an anonymous caller must not. + +import { describe, it, expect } from 'vitest'; +import { HonoServerPlugin } from './hono-plugin'; +import type { PluginContext } from '@objectstack/core'; + +/** + * Fake data engine: an UNSCOPED `admin_full_access` grant for `admin1` (the + * seeded platform admin) vs. a `member_default` grant for `member1`, plus the + * `widget` rows the data route returns. Every other read resolves empty. + */ +const makeQl = () => ({ + find: async (object: string, opts: any) => { + const where = opts?.where ?? {}; + if (object === 'sys_user_permission_set') { + if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', organization_id: null }]; + if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', organization_id: null }]; + return []; + } + if (object === 'sys_permission_set') { + const ids: string[] = where.id?.$in ?? []; + return [ + ids.includes('ps_admin') ? { id: 'ps_admin', name: 'admin_full_access' } : null, + ids.includes('ps_member') ? { id: 'ps_member', name: 'member_default' } : null, + ].filter(Boolean); + } + if (object === 'widget') return [{ id: '1', name: 'w' }]; + // sys_member, sys_user — nothing to contribute. + return []; + }, +}); + +/** Fake auth service: session keyed off the request's `cookie` header. */ +const makeAuth = () => ({ + api: { + getSession: async ({ headers }: { headers: any }) => { + const cookie = headers?.get?.('cookie'); + if (cookie === 'admin') return { user: { id: 'admin1' } }; + if (cookie === 'member') return { user: { id: 'member1' } }; + return undefined; + }, + }, +}); + +function fakeCtx(services: Record): PluginContext { + const map = new Map(Object.entries(services)); + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + registerService: (name: string, svc: unknown) => map.set(name, svc), + getService: (name: string) => map.get(name), + hook: () => {}, + getKernel: () => ({}), + } as unknown as PluginContext; +} + +async function setup() { + // serverTiming left at its default (undefined): global mode OFF, the + // admin-gated per-request path AVAILABLE — the exact `os serve` posture. + const plugin = new HonoServerPlugin({ cors: false }); + const ctx = fakeCtx({ objectql: makeQl(), auth: makeAuth() }); + await (plugin as any).init(ctx); + // Register the real standard CRUD/data endpoints (normally wired on + // kernel:ready) so `/api/v1/data/:object` runs its real `resolveCtx`. + (plugin as any).registerDiscoveryAndCrudEndpoints(ctx); + const app = (plugin as any).server.getRawApp(); + return { app }; +} + +describe('Hono standalone data route — admin-gated Server-Timing (#3361 e2e)', () => { + it('emits Server-Timing for a platform admin sending X-OS-Debug-Timing', async () => { + const { app } = await setup(); + const res = await app.request('/api/v1/data/widget', { + headers: { cookie: 'admin', 'X-OS-Debug-Timing': '1' }, + }); + expect(res.status).toBe(200); + const header = res.headers.get('Server-Timing'); + expect(header).toBeTruthy(); + expect(header).toMatch(/(^|, )total;dur=[\d.]+/); + }); + + it('withholds Server-Timing from an ordinary member (same debug header)', async () => { + const { app } = await setup(); + const res = await app.request('/api/v1/data/widget', { + headers: { cookie: 'member', 'X-OS-Debug-Timing': '1' }, + }); + expect(res.status).toBe(200); + expect(res.headers.get('Server-Timing')).toBeNull(); + }); + + it('withholds Server-Timing from an anonymous caller (401, no header)', async () => { + const { app } = await setup(); + const res = await app.request('/api/v1/data/widget', { + headers: { 'X-OS-Debug-Timing': '1' }, + }); + // requireAuth defaults on → anonymous is denied, and nothing opened the gate. + expect(res.status).toBe(401); + expect(res.headers.get('Server-Timing')).toBeNull(); + }); + + it('does NOT emit for an admin when no debug header is sent (opt-in only)', async () => { + const { app } = await setup(); + const res = await app.request('/api/v1/data/widget', { headers: { cookie: 'admin' } }); + expect(res.status).toBe(200); + expect(res.headers.get('Server-Timing')).toBeNull(); + }); +}); diff --git a/packages/rest/package.json b/packages/rest/package.json index 9b44667533..44f7c2cb40 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@objectstack/core": "workspace:*", + "@objectstack/observability": "workspace:*", "@objectstack/platform-objects": "workspace:*", "@objectstack/service-package": "workspace:*", "@objectstack/spec": "workspace:*", diff --git a/packages/rest/src/rest-server-timing.test.ts b/packages/rest/src/rest-server-timing.test.ts new file mode 100644 index 0000000000..bc09feb751 --- /dev/null +++ b/packages/rest/src/rest-server-timing.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// Regression coverage for #3361: the per-request, admin-gated `Server-Timing` +// path (#2408) never emitted on the standard `os serve`/`dev` server. The data +// and metadata routes there are owned by the RestServer (it shadows the Hono +// plugin's CRUD), and its identity resolver `resolveExecCtx` never opened the +// perf-disclosure gate — so an admin sending `X-OS-Debug-Timing` got no header. +// +// These tests drive the REAL resolution pipeline (`computeExecCtx` → +// `resolveAuthzContext` → `derivePosture`) inside an ambient disclosure gate and +// assert it opens for an admin/service principal and STAYS CLOSED for a member +// or an anonymous caller. That is the exact end-to-end wiring no single-layer +// unit test exercised (the CI gap the issue calls out). + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; +import { runWithPerfDisclosure, type PerfDisclosureGate } from '@objectstack/observability'; + +const makeServer = () => ({ + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn(), close: vi.fn(), +}); + +/** + * A fake data engine that returns just enough rows for `resolveAuthzContext` to + * derive a posture: an UNSCOPED `admin_full_access` grant for `admin1` (→ + * PLATFORM_ADMIN) and a plain `member_default` grant for `member1` (→ MEMBER). + * Every other object read resolves empty. + */ +const makeQl = () => ({ + find: async (object: string, opts: any) => { + const where = opts?.where ?? {}; + if (object === 'sys_user') return [{ id: where.id, email: `${where.id}@example.com` }]; + if (object === 'sys_user_permission_set') { + if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', organization_id: null }]; + if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', organization_id: null }]; + return []; + } + if (object === 'sys_permission_set') { + const ids: string[] = where.id?.$in ?? []; + return [ + ids.includes('ps_admin') ? { id: 'ps_admin', name: 'admin_full_access' } : null, + ids.includes('ps_member') ? { id: 'ps_member', name: 'member_default' } : null, + ].filter(Boolean); + } + // sys_member, sys_user_position, sys_position, sys_position_permission_set, + // sys_setting (localization) — nothing to contribute. + return []; + }, +}); + +/** A fake auth service whose session is keyed off the request's `cookie` header. */ +const makeAuth = () => ({ + api: { + getSession: async ({ headers }: { headers: any }) => { + const cookie = headers?.get?.('cookie'); + if (cookie === 'admin') return { user: { id: 'admin1' } }; + if (cookie === 'member') return { user: { id: 'member1' } }; + return undefined; + }, + }, +}); + +function makeRest() { + return new RestServer( + makeServer() as any, + {} as any, // protocol — unused by resolveExecCtx + { api: { requireAuth: true } } as any, // config + undefined, // kernelManager + undefined, // envRegistry + undefined, // defaultEnvironmentIdProvider + async () => makeAuth(), // authServiceProvider + async () => makeQl(), // objectQLProvider + ); +} + +/** Resolve the exec context for `cookie` inside a fresh disclosure gate. */ +async function resolveInGate(cookie?: string) { + const rest = makeRest(); + const gate: PerfDisclosureGate = { allowed: false }; + const req = { method: 'GET', headers: cookie ? { cookie } : {} }; + const ctx = await runWithPerfDisclosure(gate, () => (rest as any).resolveExecCtx(undefined, req)); + return { ctx, gate }; +} + +describe('RestServer resolveExecCtx — per-request Server-Timing disclosure gate (#3361)', () => { + it('opens the gate for a PLATFORM_ADMIN principal', async () => { + const { ctx, gate } = await resolveInGate('admin'); + expect(ctx?.userId).toBe('admin1'); + expect(ctx?.posture).toBe('PLATFORM_ADMIN'); + expect(gate.allowed).toBe(true); + expect(gate.privileged).toBe(true); + }); + + it('leaves the gate CLOSED for an ordinary member principal', async () => { + const { ctx, gate } = await resolveInGate('member'); + expect(ctx?.userId).toBe('member1'); + expect(ctx?.posture).toBe('MEMBER'); + expect(gate.allowed).toBe(false); + expect(gate.privileged).toBeFalsy(); + }); + + it('leaves the gate CLOSED for an anonymous caller', async () => { + const { ctx, gate } = await resolveInGate(undefined); + expect(ctx).toBeUndefined(); + expect(gate.allowed).toBe(false); + }); + + it('is a no-op when there is no ambient gate (perf-tuning off)', async () => { + // Resolving an admin OUTSIDE runWithPerfDisclosure must not throw — + // allowPerfDisclosure() no-ops when no gate is active. + const rest = makeRest(); + const ctx = await (rest as any).resolveExecCtx(undefined, { method: 'GET', headers: { cookie: 'admin' } }); + expect(ctx?.posture).toBe('PLATFORM_ADMIN'); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 258c9b641e..7586279b07 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -5,6 +5,7 @@ import { shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS, } from '@objectstack/core'; import { isMcpServerEnabled } from '@objectstack/types'; +import { allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; import { RouteManager } from './route-manager.js'; import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api'; import { DataProtocol, MetadataProtocol } from '@objectstack/spec/api'; @@ -1283,7 +1284,7 @@ export class RestServer { } } catch { /* gate is best-effort — never break context resolution */ } - return { + const execCtx = { userId: authz.userId, tenantId: authz.tenantId, email: authz.email, @@ -1306,6 +1307,18 @@ export class RestServer { // authorization input — never read by RLS/permission logic. __kernel: kernel, } as any; + + // [#2408 / #3361] Open the per-request `Server-Timing` disclosure gate + // for an admin/service principal — the REST-server analog of the runtime + // dispatcher's `timedResolveExecutionContext`. This is the SOLE gate-opener + // on the `os serve`/`dev` data + metadata routes (which the RestServer + // owns, shadowing the Hono plugin's CRUD): without it the documented + // admin-gated `X-OS-Debug-Timing` path never emits on the standard server. + // A no-op when perf-tuning is off or already global (no ambient gate), and + // the memoized resolve runs once per request so the gate opens exactly once. + if (isPerfDisclosurePrincipal(execCtx)) allowPerfDisclosure(); + + return execCtx; } catch { return undefined; } diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index e8a749d377..da1a15dc87 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -5,7 +5,7 @@ import { shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, } from '@objectstack/core'; import { isMcpServerEnabled } from '@objectstack/types'; -import { measureServerTiming, allowPerfDisclosure } from '@objectstack/observability'; +import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; import { CoreServiceName } from '@objectstack/spec/system'; import { readServiceSelfInfo } from '@objectstack/spec/api'; import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; @@ -41,26 +41,13 @@ function isSystemObjectName(name: string): boolean { return /^sys_/i.test(name); } -/** - * Whether a resolved principal may see a PER-REQUEST `Server-Timing` header - * (#2408 perf-tuning gating). The header exposes internal phase durations — a - * mild backend-fingerprinting surface — so when timing is opened per-request via - * `X-OS-Debug-Timing` it is disclosed only to an admin/service identity: - * - * - `isSystem` — internal/engine self-calls, - * - `principalKind` `service` / `system` — service tokens & the system seed, - * - `posture` `PLATFORM_ADMIN` / `TENANT_ADMIN` — the derived admin rungs. - * - * Ordinary human/guest/agent callers get `false`, so sending the debug header - * yields no header for them. Global (env/option) perf mode bypasses this — it - * opened the disclosure gate up front for the whole environment. - */ -export function isPerfDisclosurePrincipal(ec: ExecutionContext | undefined): boolean { - if (!ec) return false; - if (ec.isSystem === true) return true; - if (ec.principalKind === 'service' || ec.principalKind === 'system') return true; - return ec.posture === 'PLATFORM_ADMIN' || ec.posture === 'TENANT_ADMIN'; -} +// The per-request `Server-Timing` disclosure predicate (#2408) now lives in +// `@objectstack/observability` — the ONE definition shared by every HTTP entry +// point that resolves a principal (this dispatcher, the REST server, the +// standalone Hono CRUD surface), so an admin-serving path can never drift into +// under- or over-disclosing (#3361). Re-exported here for back-compat with the +// dispatcher's existing consumers/tests. +export { isPerfDisclosurePrincipal } from '@objectstack/observability'; export interface HttpProtocolContext { request: any; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f01e1d6f33..37480585bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1671,6 +1671,9 @@ importers: '@objectstack/core': specifier: workspace:* version: link:../core + '@objectstack/observability': + specifier: workspace:* + version: link:../observability '@objectstack/platform-objects': specifier: workspace:* version: link:../platform-objects