Skip to content

Commit bfd705d

Browse files
committed
fix(server-timing): emit admin-gated per-request Server-Timing on the standard server (#3361)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHeWEHnvQHiN9f9P1t47Sj
1 parent 9a43e04 commit bfd705d

11 files changed

Lines changed: 360 additions & 22 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/observability": patch
3+
"@objectstack/rest": patch
4+
"@objectstack/plugin-hono-server": patch
5+
"@objectstack/runtime": patch
6+
---
7+
8+
fix(server-timing): emit the per-request, admin-gated `Server-Timing` header on the standard server (`os serve`/`dev`) (#3361)
9+
10+
The per-request `Server-Timing` path (#2408) — where an admin sends
11+
`X-OS-Debug-Timing: 1` (or `json`) and gets phase timings while an ordinary user
12+
gets nothing — never emitted on the shipped Hono server. The disclosure gate the
13+
Hono middleware opens is only ever flipped by the runtime dispatcher's
14+
`timedResolveExecutionContext`, but the data (`/api/v1/data/*`) and metadata
15+
(`/api/v1/meta/*`) routes on `os serve`/`dev` are served by `@objectstack/rest`'s
16+
`RestServer` (which shadows the Hono plugin's own CRUD), and its identity
17+
resolver never opened the gate. Only global mode (`OS_SERVER_TIMING=true`) — which
18+
discloses to *every* caller, not just admins — worked.
19+
20+
- **observability**: the disclosure predicate `isPerfDisclosurePrincipal(ec)` now
21+
lives here (the home of the gate), the single definition of "who may pull
22+
per-request timings" shared by every HTTP entry point. `@objectstack/runtime`
23+
re-exports it for back-compat.
24+
- **rest**: `RestServer.resolveExecCtx` opens the gate for an admin/service
25+
principal (via the carried `posture` rung), the REST-server analog of the
26+
dispatcher — this is the fix that makes `os serve`/`dev` emit.
27+
- **plugin-hono-server**: the standalone CRUD surface's self-contained
28+
`resolveCtx` opens the gate too (deriving the rung for the gate decision only,
29+
never writing it onto the enforcement context). Adds an e2e test that boots the
30+
Hono app and asserts an admin gets `Server-Timing` while a member/anon does not.

packages/observability/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export {
5757
allowPerfDisclosure,
5858
isPerfDisclosureAllowed,
5959
isPerfDisclosurePrivileged,
60+
isPerfDisclosurePrincipal,
6061
type ServerTimingMark,
6162
type ServerTimingDetail,
6263
type PerfDisclosureGate,

packages/observability/src/perf-timing.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ import {
1414
allowPerfDisclosure,
1515
isPerfDisclosureAllowed,
1616
isPerfDisclosurePrivileged,
17+
isPerfDisclosurePrincipal,
1718
recordServerTimingDetail,
1819
type PerfDisclosureGate,
1920
} from './perf-timing.js';
21+
import type { ExecutionContext } from '@objectstack/spec/kernel';
2022

2123
describe('formatServerTiming', () => {
2224
it('serializes name + duration', () => {
@@ -349,3 +351,24 @@ describe('disclosure gate', () => {
349351
});
350352
});
351353
});
354+
355+
describe('isPerfDisclosurePrincipal', () => {
356+
const ctx = (over: Partial<ExecutionContext>): ExecutionContext =>
357+
({ isSystem: false, positions: [], permissions: [], ...over }) as ExecutionContext;
358+
359+
it('denies an undefined / anonymous / ordinary principal', () => {
360+
expect(isPerfDisclosurePrincipal(undefined)).toBe(false);
361+
expect(isPerfDisclosurePrincipal(ctx({}))).toBe(false);
362+
expect(isPerfDisclosurePrincipal(ctx({ principalKind: 'human', posture: 'MEMBER' }))).toBe(false);
363+
expect(isPerfDisclosurePrincipal(ctx({ principalKind: 'guest' }))).toBe(false);
364+
expect(isPerfDisclosurePrincipal(ctx({ principalKind: 'agent' }))).toBe(false);
365+
});
366+
367+
it('allows system / service principals and the admin posture rungs', () => {
368+
expect(isPerfDisclosurePrincipal(ctx({ isSystem: true }))).toBe(true);
369+
expect(isPerfDisclosurePrincipal(ctx({ principalKind: 'service' }))).toBe(true);
370+
expect(isPerfDisclosurePrincipal(ctx({ principalKind: 'system' }))).toBe(true);
371+
expect(isPerfDisclosurePrincipal(ctx({ posture: 'PLATFORM_ADMIN' }))).toBe(true);
372+
expect(isPerfDisclosurePrincipal(ctx({ posture: 'TENANT_ADMIN' }))).toBe(true);
373+
});
374+
});

packages/observability/src/perf-timing.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
*/
3232

3333
import { AsyncLocalStorage } from 'node:async_hooks';
34+
import type { ExecutionContext } from '@objectstack/spec/kernel';
3435

3536
/**
3637
* One recorded phase of a request's server-side processing, serialized as a
@@ -447,3 +448,30 @@ export function isPerfDisclosureAllowed(): boolean {
447448
export function isPerfDisclosurePrivileged(): boolean {
448449
return gateStore.getStore()?.privileged ?? false;
449450
}
451+
452+
/**
453+
* Whether a resolved principal may see a PER-REQUEST `Server-Timing` header
454+
* (#2408 perf-tuning gating). The header exposes internal phase durations — a
455+
* mild backend-fingerprinting surface — so when timing is opened per-request via
456+
* `X-OS-Debug-Timing` it is disclosed only to an admin/service identity:
457+
*
458+
* - `isSystem` — internal/engine self-calls,
459+
* - `principalKind` `service` / `system` — service tokens & the system seed,
460+
* - `posture` `PLATFORM_ADMIN` / `TENANT_ADMIN` — the derived admin rungs.
461+
*
462+
* Ordinary human/guest/agent callers get `false`, so sending the debug header
463+
* yields no header for them. Global (env/option) perf mode bypasses this — it
464+
* opened the disclosure gate up front for the whole environment.
465+
*
466+
* This is the ONE definition of "who may pull per-request timings", shared by
467+
* every HTTP entry point that resolves a principal — the runtime dispatcher
468+
* (`timedResolveExecutionContext`), the REST server, and the standalone Hono
469+
* CRUD surface — so a new admin-serving path can never silently under- or
470+
* over-disclose by hand-rolling its own rule (#3361).
471+
*/
472+
export function isPerfDisclosurePrincipal(ec: ExecutionContext | undefined): boolean {
473+
if (!ec) return false;
474+
if (ec.isSystem === true) return true;
475+
if (ec.principalKind === 'service' || ec.principalKind === 'system') return true;
476+
return ec.posture === 'PLATFORM_ADMIN' || ec.posture === 'TENANT_ADMIN';
477+
}

packages/plugins/plugin-hono-server/src/hono-plugin.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
import {
44
Plugin, PluginContext, IDataEngine,
55
shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS,
6+
derivePosture,
67
} from '@objectstack/core';
78
import {
89
RestServerConfig,
910
} from '@objectstack/spec/api';
11+
import { ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN } from '@objectstack/spec';
12+
import type { ExecutionContext } from '@objectstack/spec/kernel';
1013
import { HonoHttpServer, HonoCorsOptions } from './adapter';
1114
import { cors } from 'hono/cors';
1215
import { serveStatic } from '@hono/node-server/serve-static';
@@ -18,6 +21,8 @@ import {
1821
PerfTiming,
1922
runWithPerfTiming,
2023
runWithPerfDisclosure,
24+
allowPerfDisclosure,
25+
isPerfDisclosurePrincipal,
2126
type PerfDisclosureGate,
2227
} from '@objectstack/observability';
2328

@@ -869,6 +874,23 @@ export class HonoServerPlugin implements Plugin {
869874
/* no ai_access column / query failed → no seat (safe) */
870875
}
871876
}
877+
// [#2408 / #3361] Open the per-request `Server-Timing` disclosure
878+
// gate for an admin/service principal — the standalone-surface analog
879+
// of the runtime dispatcher's `timedResolveExecutionContext`. This
880+
// self-contained resolver derives no posture rung, so derive one HERE,
881+
// for the gate decision ONLY, from the resolved permission-set grants,
882+
// and hand it to the shared `isPerfDisclosurePrincipal` predicate. The
883+
// rung is computed onto a THROW-AWAY object, never the returned
884+
// context: `ctx.posture` is an enforcement input (Layer 0 tier
885+
// adjudication, ADR-0099 D1) and only the authoritative resolver may
886+
// set it. A no-op when perf-tuning is off (no ambient gate).
887+
const disclosurePosture = derivePosture({
888+
isPlatformAdmin: permissions.includes(ADMIN_FULL_ACCESS),
889+
isTenantAdmin: permissions.includes(ORGANIZATION_ADMIN),
890+
});
891+
if (isPerfDisclosurePrincipal({ isSystem: false, posture: disclosurePosture } as ExecutionContext)) {
892+
allowPerfDisclosure();
893+
}
872894
return {
873895
userId,
874896
tenantId,
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// End-to-end regression for #3361 on the STANDALONE Hono CRUD surface (the
4+
// minimal server used when `@objectstack/rest` is not mounted). It drives a real
5+
// request through the perf middleware AND the real `/api/v1/data/:object`
6+
// handler — whose `resolveCtx` resolves the principal — instead of a handler
7+
// that calls `allowPerfDisclosure()` by hand "as the dispatcher would" (the gap
8+
// the existing unit tests left invisible). An admin sending `X-OS-Debug-Timing`
9+
// must get a `Server-Timing` header; a member and an anonymous caller must not.
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { HonoServerPlugin } from './hono-plugin';
13+
import type { PluginContext } from '@objectstack/core';
14+
15+
/**
16+
* Fake data engine: an UNSCOPED `admin_full_access` grant for `admin1` (the
17+
* seeded platform admin) vs. a `member_default` grant for `member1`, plus the
18+
* `widget` rows the data route returns. Every other read resolves empty.
19+
*/
20+
const makeQl = () => ({
21+
find: async (object: string, opts: any) => {
22+
const where = opts?.where ?? {};
23+
if (object === 'sys_user_permission_set') {
24+
if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', organization_id: null }];
25+
if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', organization_id: null }];
26+
return [];
27+
}
28+
if (object === 'sys_permission_set') {
29+
const ids: string[] = where.id?.$in ?? [];
30+
return [
31+
ids.includes('ps_admin') ? { id: 'ps_admin', name: 'admin_full_access' } : null,
32+
ids.includes('ps_member') ? { id: 'ps_member', name: 'member_default' } : null,
33+
].filter(Boolean);
34+
}
35+
if (object === 'widget') return [{ id: '1', name: 'w' }];
36+
// sys_member, sys_user — nothing to contribute.
37+
return [];
38+
},
39+
});
40+
41+
/** Fake auth service: session keyed off the request's `cookie` header. */
42+
const makeAuth = () => ({
43+
api: {
44+
getSession: async ({ headers }: { headers: any }) => {
45+
const cookie = headers?.get?.('cookie');
46+
if (cookie === 'admin') return { user: { id: 'admin1' } };
47+
if (cookie === 'member') return { user: { id: 'member1' } };
48+
return undefined;
49+
},
50+
},
51+
});
52+
53+
function fakeCtx(services: Record<string, unknown>): PluginContext {
54+
const map = new Map<string, unknown>(Object.entries(services));
55+
return {
56+
logger: { debug() {}, info() {}, warn() {}, error() {} },
57+
registerService: (name: string, svc: unknown) => map.set(name, svc),
58+
getService: (name: string) => map.get(name),
59+
hook: () => {},
60+
getKernel: () => ({}),
61+
} as unknown as PluginContext;
62+
}
63+
64+
async function setup() {
65+
// serverTiming left at its default (undefined): global mode OFF, the
66+
// admin-gated per-request path AVAILABLE — the exact `os serve` posture.
67+
const plugin = new HonoServerPlugin({ cors: false });
68+
const ctx = fakeCtx({ objectql: makeQl(), auth: makeAuth() });
69+
await (plugin as any).init(ctx);
70+
// Register the real standard CRUD/data endpoints (normally wired on
71+
// kernel:ready) so `/api/v1/data/:object` runs its real `resolveCtx`.
72+
(plugin as any).registerDiscoveryAndCrudEndpoints(ctx);
73+
const app = (plugin as any).server.getRawApp();
74+
return { app };
75+
}
76+
77+
describe('Hono standalone data route — admin-gated Server-Timing (#3361 e2e)', () => {
78+
it('emits Server-Timing for a platform admin sending X-OS-Debug-Timing', async () => {
79+
const { app } = await setup();
80+
const res = await app.request('/api/v1/data/widget', {
81+
headers: { cookie: 'admin', 'X-OS-Debug-Timing': '1' },
82+
});
83+
expect(res.status).toBe(200);
84+
const header = res.headers.get('Server-Timing');
85+
expect(header).toBeTruthy();
86+
expect(header).toMatch(/(^|, )total;dur=[\d.]+/);
87+
});
88+
89+
it('withholds Server-Timing from an ordinary member (same debug header)', async () => {
90+
const { app } = await setup();
91+
const res = await app.request('/api/v1/data/widget', {
92+
headers: { cookie: 'member', 'X-OS-Debug-Timing': '1' },
93+
});
94+
expect(res.status).toBe(200);
95+
expect(res.headers.get('Server-Timing')).toBeNull();
96+
});
97+
98+
it('withholds Server-Timing from an anonymous caller (401, no header)', async () => {
99+
const { app } = await setup();
100+
const res = await app.request('/api/v1/data/widget', {
101+
headers: { 'X-OS-Debug-Timing': '1' },
102+
});
103+
// requireAuth defaults on → anonymous is denied, and nothing opened the gate.
104+
expect(res.status).toBe(401);
105+
expect(res.headers.get('Server-Timing')).toBeNull();
106+
});
107+
108+
it('does NOT emit for an admin when no debug header is sent (opt-in only)', async () => {
109+
const { app } = await setup();
110+
const res = await app.request('/api/v1/data/widget', { headers: { cookie: 'admin' } });
111+
expect(res.status).toBe(200);
112+
expect(res.headers.get('Server-Timing')).toBeNull();
113+
});
114+
});

packages/rest/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
},
2121
"dependencies": {
2222
"@objectstack/core": "workspace:*",
23+
"@objectstack/observability": "workspace:*",
2324
"@objectstack/platform-objects": "workspace:*",
2425
"@objectstack/service-package": "workspace:*",
2526
"@objectstack/spec": "workspace:*",

0 commit comments

Comments
 (0)