Skip to content

Commit 49ac8ef

Browse files
committed
fix(hono-server): emit admin-gated Server-Timing on os serve; add route-parity gate (#3369, #3361)
The shipped hono server (os serve/dev/start) resolves identity via its own self-contained resolveCtx and never opened the perf-disclosure gate, so the documented admin-gated per-request Server-Timing header (X-OS-Debug-Timing) never emitted on the standalone server — only the runtime dispatcher path called allowPerfDisclosure(). resolveCtx now opens the gate for a privileged principal (super-user '*' grant / admin_full_access / organization_admin / system), mirroring the dispatcher's isPerfDisclosurePrincipal without a cross-package dependency. Ordinary and anonymous callers still get no header. Adds a route-parity e2e gate (packages/runtime/route-parity.integration.test.ts) that boots the real hono app the way os serve mounts it (hono server + dispatcher plugin), reads /api/v1/discovery, and asserts: - every advertised / dispatcher-registered route is reachable (never 404/405/501) for both an anonymous and an admin principal; - discovery is service-aware in BOTH directions (no dead advertisement); - the admin-gated per-request Server-Timing header emits end-to-end. It would have caught #3361, #3362 and the MCP 501. Notifications (#3354) and the MCP auto-load (#2698) were already reconciled on the hono listener; the gate locks them so the "works in the dispatcher, dead on os serve" class can't silently regress. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015si15Q1KWMKpVQWYsEvgcS
1 parent 9a43e04 commit 49ac8ef

4 files changed

Lines changed: 528 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/plugin-hono-server": patch
3+
---
4+
5+
Fix admin-gated per-request `Server-Timing` on the shipped hono server (`os serve` / `os dev` / `os start`). The header is emitted per-request only after an admin/service identity is proven (via `X-OS-Debug-Timing`), but the hono server resolves identity through its own self-contained `resolveCtx` and never opened the disclosure gate — so an admin never saw the header on the standalone server (only the runtime dispatcher path did). `resolveCtx` now opens the gate for a privileged principal (super-user `'*'` grant / `admin_full_access` / `organization_admin` / system), mirroring the dispatcher's `isPerfDisclosurePrincipal` without a cross-package dependency. Ordinary and anonymous callers still get no header. Part of the route-parity `declared === enforced` work (#3369, #3361).

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

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
PerfTiming,
1919
runWithPerfTiming,
2020
runWithPerfDisclosure,
21+
allowPerfDisclosure,
2122
type PerfDisclosureGate,
2223
} from '@objectstack/observability';
2324

@@ -278,6 +279,73 @@ export function clampManagedObjectWrites(
278279
}
279280
}
280281

282+
/**
283+
* Platform permission sets that carry the `'*'` view/modify-all super-user
284+
* bypass — the PLATFORM_ADMIN (`admin_full_access`) and TENANT_ADMIN
285+
* (`organization_admin`) rungs (see plugin-security `default-permission-sets`).
286+
* Used to identify a privileged principal for per-request `Server-Timing`
287+
* disclosure (#3361).
288+
*/
289+
const PLATFORM_SUPERUSER_PERMISSION_SETS: ReadonlySet<string> = new Set([
290+
'admin_full_access',
291+
'organization_admin',
292+
]);
293+
294+
/**
295+
* Whether a permission-set row grants the `'*'` view-all / modify-all
296+
* super-user bypass — the same signal {@link foldWildcardSuperUser} and
297+
* `PermissionEvaluator.checkObjectPermission` key on. `object_permissions`
298+
* may arrive as a JSON string (SQLite/Turso drivers) or an object (memory
299+
* driver), so both shapes are handled.
300+
*/
301+
export function permissionSetGrantsSuperUser(
302+
row: { object_permissions?: unknown } | null | undefined,
303+
): boolean {
304+
if (!row) return false;
305+
let objs: unknown = row.object_permissions;
306+
if (typeof objs === 'string') {
307+
try { objs = JSON.parse(objs || '{}'); } catch { return false; }
308+
}
309+
const wild = objs && typeof objs === 'object' ? (objs as Record<string, any>)['*'] : undefined;
310+
return !!wild && (wild.viewAllRecords === true || wild.modifyAllRecords === true);
311+
}
312+
313+
/**
314+
* Whether a principal resolved by the hono server's self-contained
315+
* `resolveCtx` may be shown a PER-REQUEST `Server-Timing` header
316+
* (#2408 / #3361). Phase durations are a mild backend-fingerprinting surface,
317+
* so when timing is opened per-request via `X-OS-Debug-Timing` the header is
318+
* disclosed only to an admin/service identity — an ordinary caller who merely
319+
* sends the header never gets it.
320+
*
321+
* Privileged =
322+
* - an internal/system caller (`isSystem`), OR
323+
* - a super-user: holds a permission set granting the `'*'` view/modify-all
324+
* wildcard (checked from the resolved permission-set rows), or one of the
325+
* canonical platform-admin sets by name.
326+
*
327+
* This is the hono server's local equivalent of the runtime dispatcher's
328+
* `isPerfDisclosurePrincipal` (which keys on `ExecutionContext.posture` /
329+
* `principalKind`, fields this self-contained resolver does not compute) — kept
330+
* here to avoid a cross-package dependency on `@objectstack/runtime`, exactly
331+
* as `resolveCtx` itself is self-contained.
332+
*/
333+
export function isPerfDisclosurePrincipal(principal: {
334+
isSystem?: boolean;
335+
permissionSetNames?: readonly string[];
336+
permissionSetRows?: ReadonlyArray<{ object_permissions?: unknown }>;
337+
} | null | undefined): boolean {
338+
if (!principal) return false;
339+
if (principal.isSystem === true) return true;
340+
for (const name of principal.permissionSetNames ?? []) {
341+
if (PLATFORM_SUPERUSER_PERMISSION_SETS.has(name)) return true;
342+
}
343+
for (const row of principal.permissionSetRows ?? []) {
344+
if (permissionSetGrantsSuperUser(row)) return true;
345+
}
346+
return false;
347+
}
348+
281349
export class HonoServerPlugin implements Plugin {
282350
name = 'com.objectstack.server.hono';
283351
type = 'server';
@@ -768,6 +836,10 @@ export class HonoServerPlugin implements Plugin {
768836
const tenantId = session.session?.activeOrganizationId ?? undefined;
769837
const permissions: string[] = [];
770838
const roles: string[] = [];
839+
// Resolved permission-set rows (kept so the per-request
840+
// Server-Timing disclosure gate can tell an admin/super-user
841+
// from an ordinary caller — #3361).
842+
const permissionSetRows: Array<{ object_permissions?: unknown }> = [];
771843
try {
772844
const ql = getObjectQL();
773845
const sysCtx = { context: { isSystem: true } };
@@ -813,6 +885,7 @@ export class HonoServerPlugin implements Plugin {
813885
).catch(() => []);
814886
for (const ps of (psRows ?? []) as any[]) {
815887
if (ps.name && !permissions.includes(ps.name)) permissions.push(ps.name);
888+
permissionSetRows.push(ps);
816889
}
817890
}
818891
} catch {
@@ -869,6 +942,19 @@ export class HonoServerPlugin implements Plugin {
869942
/* no ai_access column / query failed → no seat (safe) */
870943
}
871944
}
945+
// ── Per-request Server-Timing disclosure gate (#2408 / #3361) ──
946+
// On `os serve`/`dev` the hono server owns HTTP and resolves
947+
// identity here — not through the runtime dispatcher's
948+
// `timedResolveExecutionContext`, which is where
949+
// `allowPerfDisclosure()` normally fires. So the admin-gated
950+
// per-request path (`X-OS-Debug-Timing`) never emitted a header
951+
// on the shipped server. Mirror the dispatcher: once we've
952+
// proven an admin/super-user identity, open the ambient gate.
953+
// A no-op when perf-tuning is off (no active gate) or already
954+
// global, so the normal request path is unaffected.
955+
if (isPerfDisclosurePrincipal({ permissionSetNames: permissions, permissionSetRows })) {
956+
allowPerfDisclosure();
957+
}
872958
return {
873959
userId,
874960
tenantId,
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
HonoServerPlugin,
6+
isPerfDisclosurePrincipal,
7+
permissionSetGrantsSuperUser,
8+
} from './hono-plugin';
9+
import type { PluginContext } from '@objectstack/core';
10+
11+
/**
12+
* Regression for #3361 (route parity #3369): on the shipped hono server
13+
* (`os serve` / `os dev` / `os start`) the ADMIN-GATED per-request
14+
* `Server-Timing` header never emitted. The only production caller of
15+
* `allowPerfDisclosure()` lived on the runtime dispatcher path
16+
* (`http-dispatcher.ts` `timedResolveExecutionContext`), but `os serve` mounts
17+
* the hono server, which resolves identity via its OWN self-contained
18+
* `resolveCtx` and never opened the disclosure gate — so an admin sending
19+
* `X-OS-Debug-Timing: 1` got no header on any route.
20+
*
21+
* These tests drive the REAL hono request pipeline (perf middleware + the
22+
* standard CRUD/auth routes that call `resolveCtx`) with a stubbed auth +
23+
* objectql, asserting the gate now opens for a super-user principal and stays
24+
* closed for an ordinary member — the exact behaviour the docs promise and no
25+
* single-layer unit test caught.
26+
*/
27+
28+
/** A permission-set row a driver would return (object_permissions as an object). */
29+
const ADMIN_SET = {
30+
id: 'ps-admin',
31+
name: 'admin_full_access',
32+
object_permissions: { '*': { viewAllRecords: true, modifyAllRecords: true } },
33+
};
34+
/** Same, but object_permissions arrives as a JSON string (SQLite/Turso shape). */
35+
const ORG_ADMIN_SET_STRING = {
36+
id: 'ps-org',
37+
name: 'organization_admin',
38+
object_permissions: JSON.stringify({ '*': { modifyAllRecords: true } }),
39+
};
40+
const MEMBER_SET = {
41+
id: 'ps-mem',
42+
name: 'member_default',
43+
object_permissions: { crm_lead: { allowRead: true } },
44+
};
45+
46+
/**
47+
* A minimal objectql stub that answers the exact `find()` calls `resolveCtx`
48+
* makes, keyed by the requesting user. `admin1` resolves `admin_full_access`;
49+
* `user1` resolves only `member_default`.
50+
*/
51+
function fakeObjectQL() {
52+
const assignments: Record<string, { permission_set_id: string }[]> = {
53+
admin1: [{ permission_set_id: 'ps-admin' }],
54+
user1: [{ permission_set_id: 'ps-mem' }],
55+
};
56+
const setsById: Record<string, any> = {
57+
'ps-admin': ADMIN_SET,
58+
'ps-mem': MEMBER_SET,
59+
};
60+
return {
61+
async find(object: string, opts: any) {
62+
const where = opts?.where ?? {};
63+
if (object === 'sys_member') return [];
64+
if (object === 'sys_user') return [];
65+
if (object === 'sys_user_permission_set') {
66+
const uid = where.user_id;
67+
return (assignments[uid] ?? []).map((a) => ({ ...a, user_id: uid, organization_id: null }));
68+
}
69+
if (object === 'sys_permission_set') {
70+
const ids: string[] = where?.id?.$in ?? [];
71+
return ids.map((id) => setsById[id]).filter(Boolean);
72+
}
73+
// The data route's own list query.
74+
return [];
75+
},
76+
};
77+
}
78+
79+
function fakeAuth() {
80+
return {
81+
api: {
82+
// Resolve the session from a test header the request sets.
83+
async getSession({ headers }: { headers: Headers }) {
84+
const uid = headers.get('x-test-user');
85+
return uid ? { user: { id: uid } } : null;
86+
},
87+
},
88+
};
89+
}
90+
91+
async function bootHono() {
92+
const services = new Map<string, unknown>();
93+
const ctx = {
94+
logger: { debug() {}, info() {}, warn() {}, error() {} },
95+
registerService: (name: string, svc: unknown) => services.set(name, svc),
96+
getService: (name: string) => services.get(name),
97+
} as unknown as PluginContext;
98+
99+
// serverTiming defaults to undefined → global OFF, admin-gated per-request ON.
100+
const plugin = new HonoServerPlugin({ cors: false, registerStandardEndpoints: true });
101+
await (plugin as any).init(ctx);
102+
services.set('auth', fakeAuth());
103+
services.set('objectql', fakeObjectQL());
104+
// Register the standard CRUD/auth routes (normally wired on kernel:ready).
105+
(plugin as any).registerDiscoveryAndCrudEndpoints(ctx);
106+
const app = (plugin as any).server.getRawApp();
107+
return { app };
108+
}
109+
110+
describe('isPerfDisclosurePrincipal / permissionSetGrantsSuperUser', () => {
111+
it('detects the wildcard super-user grant (object + JSON-string shapes)', () => {
112+
expect(permissionSetGrantsSuperUser(ADMIN_SET)).toBe(true);
113+
expect(permissionSetGrantsSuperUser(ORG_ADMIN_SET_STRING)).toBe(true);
114+
expect(permissionSetGrantsSuperUser(MEMBER_SET)).toBe(false);
115+
expect(permissionSetGrantsSuperUser(null)).toBe(false);
116+
expect(permissionSetGrantsSuperUser({ object_permissions: 'not json' })).toBe(false);
117+
});
118+
119+
it('treats system callers and platform-admin sets as privileged', () => {
120+
expect(isPerfDisclosurePrincipal({ isSystem: true })).toBe(true);
121+
expect(isPerfDisclosurePrincipal({ permissionSetNames: ['admin_full_access'] })).toBe(true);
122+
expect(isPerfDisclosurePrincipal({ permissionSetNames: ['organization_admin'] })).toBe(true);
123+
expect(isPerfDisclosurePrincipal({ permissionSetRows: [ADMIN_SET] })).toBe(true);
124+
});
125+
126+
it('treats an ordinary member as NOT privileged', () => {
127+
expect(isPerfDisclosurePrincipal({ permissionSetNames: ['member_default'], permissionSetRows: [MEMBER_SET] })).toBe(false);
128+
expect(isPerfDisclosurePrincipal(undefined)).toBe(false);
129+
expect(isPerfDisclosurePrincipal({})).toBe(false);
130+
});
131+
});
132+
133+
describe('hono Server-Timing per-request admin gate (#3361, drives the real resolveCtx)', () => {
134+
it('emits Server-Timing for an admin sending X-OS-Debug-Timing on a real route', async () => {
135+
const { app } = await bootHono();
136+
const res = await app.request('/api/v1/data/widget', {
137+
headers: { 'X-OS-Debug-Timing': '1', 'x-test-user': 'admin1' },
138+
});
139+
expect(res.status).toBe(200);
140+
const header = res.headers.get('Server-Timing');
141+
expect(header, `expected a Server-Timing header for the admin`).toBeTruthy();
142+
expect(header).toMatch(/(^|, )total;dur=/);
143+
});
144+
145+
it('withholds Server-Timing from an ordinary member (member_default)', async () => {
146+
const { app } = await bootHono();
147+
const res = await app.request('/api/v1/data/widget', {
148+
headers: { 'X-OS-Debug-Timing': '1', 'x-test-user': 'user1' },
149+
});
150+
expect(res.status).toBe(200);
151+
expect(res.headers.get('Server-Timing')).toBeNull();
152+
});
153+
154+
it('withholds Server-Timing from an anonymous caller sending the header', async () => {
155+
const { app } = await bootHono();
156+
// No x-test-user → resolveCtx returns undefined → anonymous.
157+
const res = await app.request('/api/v1/data/widget', {
158+
headers: { 'X-OS-Debug-Timing': '1' },
159+
});
160+
expect(res.headers.get('Server-Timing')).toBeNull();
161+
});
162+
163+
it('does not emit for an admin when no debug header is sent (opt-in only)', async () => {
164+
const { app } = await bootHono();
165+
const res = await app.request('/api/v1/data/widget', {
166+
headers: { 'x-test-user': 'admin1' },
167+
});
168+
expect(res.headers.get('Server-Timing')).toBeNull();
169+
});
170+
171+
it('also opens the gate on /auth/me/permissions for an admin', async () => {
172+
const { app } = await bootHono();
173+
const res = await app.request('/api/v1/auth/me/permissions', {
174+
headers: { 'X-OS-Debug-Timing': '1', 'x-test-user': 'admin1' },
175+
});
176+
expect(res.status).toBe(200);
177+
expect(res.headers.get('Server-Timing')).toMatch(/total;dur=/);
178+
});
179+
});

0 commit comments

Comments
 (0)