Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/server-timing-admin-gated-hono.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/observability/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export {
allowPerfDisclosure,
isPerfDisclosureAllowed,
isPerfDisclosurePrivileged,
isPerfDisclosurePrincipal,
type ServerTimingMark,
type ServerTimingDetail,
type PerfDisclosureGate,
Expand Down
23 changes: 23 additions & 0 deletions packages/observability/src/perf-timing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -349,3 +351,24 @@ describe('disclosure gate', () => {
});
});
});

describe('isPerfDisclosurePrincipal', () => {
const ctx = (over: Partial<ExecutionContext>): 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);
});
});
28 changes: 28 additions & 0 deletions packages/observability/src/perf-timing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';
}
22 changes: 22 additions & 0 deletions packages/plugins/plugin-hono-server/src/hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,6 +21,8 @@ import {
PerfTiming,
runWithPerfTiming,
runWithPerfDisclosure,
allowPerfDisclosure,
isPerfDisclosurePrincipal,
type PerfDisclosureGate,
} from '@objectstack/observability';

Expand Down Expand Up @@ -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,
Expand Down
114 changes: 114 additions & 0 deletions packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): PluginContext {
const map = new Map<string, unknown>(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();
});
});
1 change: 1 addition & 0 deletions packages/rest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
},
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/observability": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/service-package": "workspace:*",
"@objectstack/spec": "workspace:*",
Expand Down
Loading