Skip to content

Commit f95356f

Browse files
committed
test(route-parity): declared===enforced gate + honest standalone hono discovery (#3369)
Rebased onto main after the #3361 Server-Timing fix landed independently (#3384) and the notifications mark-read e2e landed (#3388). This keeps only the net-new route-parity work; the redundant #3361 fix is dropped in favour of main's canonical version. - plugin-hono-server: make the STANDALONE discovery honest. The static `registerDiscoveryAndCrudEndpoints` list advertised metadata/packages/ analytics/workflow/automation/ai/notifications/i18n/storage/ui — none of which HonoServerPlugin mounts on its own — so a truly standalone deployment advertised a dozen routes that 404 (the literal `declared !== enforced` gap #3369 cites). It now advertises only what it serves (`/data` + `/auth/me/*`). Shadowed by the dispatcher/REST service-aware discovery under `os serve`, so real deployments are unaffected. - runtime: add the route-parity e2e gate. Boots the real hono app the way `os serve` mounts it (hono server + dispatcher plugin, services provisioned), reads `/api/v1/discovery`, and asserts every advertised/dispatcher route is reachable (never 404/405/501) for anonymous AND admin principals, and that discovery is service-aware in both directions (no dead advertisement). Closes the class of "works in the dispatcher, dead on os serve" bugs (#3361/#3362/ MCP 501) with one boot-the-real-server test. Server-Timing and notifications mark-read specifics are covered by #3384 / #3388; this gate does not duplicate them. - http-dispatcher: document why `routes.mcp` is advertised on the `isMcpServerEnabled()` flag (the #2698 auto-load lockstep) rather than gated on service presence — comment only, keeps the dispatcher symmetric with @objectstack/rest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015si15Q1KWMKpVQWYsEvgcS
1 parent 9e45b63 commit f95356f

5 files changed

Lines changed: 305 additions & 16 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+
Make the standalone hono server's discovery honest (`declared === enforced`, #3369). `registerDiscoveryAndCrudEndpoints` advertised `metadata`/`packages`/`analytics`/`workflow`/`automation`/`ai`/`notifications`/`i18n`/`storage`/`ui` — none of which `HonoServerPlugin` mounts on its own — so a truly standalone deployment advertised a dozen routes that 404. It now advertises only what it actually serves (`/data` + the `/auth/me/*` helpers). Under `os serve`/`os dev` this fallback is shadowed by the dispatcher / `@objectstack/rest` service-aware discovery, so real deployments are unaffected.

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,35 @@ describe('HonoServerPlugin', () => {
131131
expect(routes['POST /api/v1/batch']).toBeUndefined();
132132
});
133133

134+
it('standalone discovery advertises ONLY the routes it actually mounts (declared === enforced, #3369)', async () => {
135+
// Regression: this static discovery used to advertise metadata /
136+
// packages / analytics / workflow / automation / ai / notifications /
137+
// i18n / storage / ui — none of which HonoServerPlugin mounts on its own
138+
// — so a standalone deployment advertised a dozen routes that 404. It
139+
// must now list only the `/data` CRUD surface + the `/auth/me/*` helpers
140+
// this plugin actually serves. (Under `os serve` the dispatcher/REST
141+
// service-aware discovery shadows this fallback.)
142+
const plugin = new HonoServerPlugin({ registerStandardEndpoints: true });
143+
await plugin.init(context as PluginContext);
144+
const routes: Record<string, any> = {};
145+
const rawApp = {
146+
get: vi.fn((path: string, h: any) => { routes[`GET ${path}`] = h; }),
147+
post: vi.fn((path: string, h: any) => { routes[`POST ${path}`] = h; }),
148+
use: vi.fn(),
149+
};
150+
(plugin as any).server.getRawApp = () => rawApp;
151+
(plugin as any).registerDiscoveryAndCrudEndpoints(context);
152+
153+
const disc = routes['GET /api/v1/discovery']({ json: (x: any) => x }).data;
154+
// Advertised: exactly the namespaces with live handlers here.
155+
expect(disc.routes.data).toBe('/api/v1/data');
156+
expect(disc.routes.auth).toBe('/api/v1/auth');
157+
// NOT advertised: routes this standalone surface does not mount.
158+
for (const dead of ['metadata', 'packages', 'analytics', 'workflow', 'automation', 'ai', 'notifications', 'i18n', 'storage', 'ui', 'realtime']) {
159+
expect(disc.routes[dead], `discovery must not advertise ${dead} (not mounted here)`).toBeUndefined();
160+
}
161+
});
162+
134163
it('should configure static files and SPA fallback when enabled', async () => {
135164
const plugin = new HonoServerPlugin({
136165
staticRoot: './public',

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

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -674,26 +674,32 @@ export class HonoServerPlugin implements Plugin {
674674
const rawApp = this.server.getRawApp();
675675
const prefix = '/api/v1';
676676

677-
// Build the standard discovery response
677+
// Build the standard discovery response.
678+
//
679+
// `declared === enforced` (#3369): advertise ONLY the routes THIS plugin
680+
// actually mounts below (the `/data` CRUD surface + the `/auth/me/*`
681+
// helpers). This discovery is a STANDALONE FALLBACK — it is served only
682+
// when the HonoServerPlugin runs alone. Under `os serve` / `os dev` the
683+
// dispatcher (`@objectstack/runtime`) and `@objectstack/rest` register
684+
// `${prefix}/discovery` FIRST (in `start()`, before this plugin's
685+
// `kernel:ready` hook), so their SERVICE-AWARE discovery shadows this
686+
// one and reports the full, actually-mounted surface. The prior static
687+
// list here advertised `metadata`/`packages`/`analytics`/`workflow`/
688+
// `automation`/`ai`/`notifications`/`i18n`/`storage`/`ui` — none of
689+
// which this plugin mounts — so a truly standalone deployment
690+
// advertised a dozen routes that 404 (the exact `declared !== enforced`
691+
// gap #3369 closes). `realtime` was likewise (correctly) never listed
692+
// (ADR-0076 D12, #2462): no `/realtime` HTTP surface exists anywhere.
678693
const discovery = {
679694
version: 'v1',
680695
apiName: 'ObjectStack API',
681696
routes: {
682-
data: `${prefix}/data`,
683-
metadata: `${prefix}/meta`,
684-
auth: `${prefix}/auth`,
685-
packages: `${prefix}/packages`,
686-
analytics: `${prefix}/analytics`,
687-
// realtime deliberately absent (ADR-0076 D12, #2462): no
688-
// /realtime HTTP surface is mounted anywhere — advertising
689-
// it here made clients call a route that 404s.
690-
workflow: `${prefix}/workflow`,
691-
automation: `${prefix}/automation`,
692-
ai: `${prefix}/ai`,
693-
notifications: `${prefix}/notifications`,
694-
i18n: `${prefix}/i18n`,
695-
storage: `${prefix}/storage`,
696-
ui: `${prefix}/ui`,
697+
data: `${prefix}/data`,
698+
// Only the `/auth/me/*` read helpers are mounted here (not the
699+
// full better-auth `/auth` surface, which ships with the auth
700+
// plugin); the namespace is advertised because it carries live
701+
// handlers on this standalone server.
702+
auth: `${prefix}/auth`,
697703
},
698704
capabilities: {
699705
// This standalone Hono surface registers CRUD + auth only (see

packages/runtime/src/http-dispatcher.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1661,6 +1661,17 @@ export class HttpDispatcher {
16611661
// MCP (Streamable HTTP) is a default-on core capability —
16621662
// advertised unless OS_MCP_SERVER_ENABLED=false opts the env
16631663
// out. The objectui Integrations page reads this.
1664+
//
1665+
// `declared === enforced` here is guaranteed by a LOCKSTEP, not
1666+
// by service-presence gating like the routes above (#3369 /
1667+
// #2698): `os serve` auto-loads plugin-mcp from the SAME
1668+
// `isMcpServerEnabled()` flag that gates this advertisement, so
1669+
// whenever `/mcp` is advertised the handler is mounted (a key /
1670+
// token yields 401, never a 404/501). Kept flag-based on purpose
1671+
// — `@objectstack/rest` advertises `mcp` from the identical
1672+
// single source (rest-server.ts), so the two discovery producers
1673+
// stay symmetric. The route-parity gate asserts the lockstep
1674+
// holds (advertised ⇒ reachable, never 501).
16641675
mcp: HttpDispatcher.isMcpEnabled() ? `${prefix}/mcp` : undefined,
16651676
};
16661677

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4+
import { LiteKernel } from '@objectstack/core';
5+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
6+
7+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
8+
9+
/**
10+
* Route-parity gate — `declared === enforced` for HTTP routes (issue #3369).
11+
*
12+
* ObjectStack has TWO route-registration paths: the runtime `HttpDispatcher` /
13+
* `createDispatcherPlugin`, and the standalone hono server
14+
* (`@objectstack/plugin-hono-server`). `os serve` / `os dev` / `os start` mount
15+
* BOTH — the dispatcher registers its routes on the hono `http.server`. A class
16+
* of v16.0 bugs (#3361 Server-Timing, #3362 notifications, the MCP `501`) shared
17+
* one root cause: a handler was reachable on ONE path while the shipped server
18+
* ran the OTHER, so discovery advertised routes that 404/501'd on the real
19+
* listener — and every unit test exercised the two paths in isolation, so the
20+
* integration gap was invisible.
21+
*
22+
* This gate boots the REAL hono app the way `os serve` mounts it (hono server +
23+
* dispatcher plugin, services provisioned), opens a socket, reads
24+
* `/api/v1/discovery`, and asserts:
25+
* 1. every route advertised in discovery is REACHABLE (never 404 / 405 / 501)
26+
* — for an anonymous AND an authenticated (admin) principal;
27+
* 2. discovery is service-aware in BOTH directions — a route is advertised
28+
* IFF its backing service is present (no dead advertisement).
29+
*
30+
* Scope: this covers the dispatcher ↔ hono seam, where the #3361/#3362/MCP
31+
* regressions lived. The admin-gated `Server-Timing` behaviour is covered
32+
* end-to-end by `@objectstack/plugin-hono-server`'s `server-timing-e2e.test.ts`
33+
* and `@objectstack/rest`'s `rest-server-timing.test.ts` (#3384); the
34+
* notifications mark-read flow by `notifications.hono.integration.test.ts`
35+
* (#3388); and the REST-owned surface (`/data`, `/meta`, `/ui`) by the
36+
* `@objectstack/client` integration suite. This gate deliberately does not
37+
* duplicate those.
38+
*/
39+
40+
// ── Stub services provisioned exactly as a real `os serve` would ─────────────
41+
42+
/** A super-user permission set (the PLATFORM_ADMIN `admin_full_access` rung). */
43+
const ADMIN_SET = {
44+
id: 'ps-admin',
45+
name: 'admin_full_access',
46+
object_permissions: { '*': { viewAllRecords: true, modifyAllRecords: true } },
47+
};
48+
49+
/**
50+
* objectql stub answering the `find()` calls the identity resolvers make, plus
51+
* the data route's own list query. `admin1` → `admin_full_access`.
52+
*/
53+
function fakeObjectQL() {
54+
return {
55+
async find(object: string, opts: any) {
56+
const where = opts?.where ?? {};
57+
if (object === 'sys_user_permission_set') {
58+
const uid = where.user_id;
59+
return uid === 'admin1'
60+
? [{ user_id: uid, permission_set_id: 'ps-admin', organization_id: null }]
61+
: [];
62+
}
63+
if (object === 'sys_permission_set') {
64+
const ids: string[] = where?.id?.$in ?? [];
65+
return ids.includes('ps-admin') ? [ADMIN_SET] : [];
66+
}
67+
return [];
68+
},
69+
};
70+
}
71+
72+
/** better-auth-style session getter resolving the user from a test header. */
73+
function fakeAuth() {
74+
return {
75+
api: {
76+
async getSession({ headers }: { headers: Headers }) {
77+
const uid = headers.get('x-test-user');
78+
return uid ? { user: { id: uid } } : null;
79+
},
80+
},
81+
};
82+
}
83+
84+
function fakeNotification() {
85+
return {
86+
async listInbox() { return { items: [], unreadCount: 0 }; },
87+
async markRead() { return { updated: 0 }; },
88+
async markAllRead() { return { updated: 0 }; },
89+
};
90+
}
91+
92+
function fakeMcp() {
93+
return {
94+
async handleHttpRequest() {
95+
return { status: 200, headers: {}, body: { jsonrpc: '2.0', result: {} } };
96+
},
97+
};
98+
}
99+
100+
function stubServicesPlugin(opts: { notification?: boolean; mcp?: boolean } = {}) {
101+
return {
102+
name: 'com.objectstack.test.route-parity-stubs',
103+
version: '1.0.0',
104+
init: async (ctx: any) => {
105+
ctx.registerService('auth', fakeAuth());
106+
ctx.registerService('objectql', fakeObjectQL());
107+
if (opts.notification !== false) ctx.registerService('notification', fakeNotification());
108+
if (opts.mcp !== false) ctx.registerService('mcp', fakeMcp());
109+
},
110+
};
111+
}
112+
113+
async function bootServe(stubOpts: { notification?: boolean; mcp?: boolean } = {}) {
114+
const kernel = new LiteKernel();
115+
// Register stub services FIRST so identity + capability services are live
116+
// for both registration paths (mirrors a provisioned `os serve`).
117+
kernel.use(stubServicesPlugin(stubOpts));
118+
kernel.use(new HonoServerPlugin({ port: 0, registerStandardEndpoints: true, cors: false }));
119+
kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false, requireAuth: true }));
120+
await kernel.bootstrap();
121+
const httpServer = kernel.getService<any>('http.server');
122+
const baseUrl = `http://127.0.0.1:${httpServer.getPort()}`;
123+
return { kernel, baseUrl };
124+
}
125+
126+
async function shutdown(kernel: LiteKernel) {
127+
await Promise.race([kernel.shutdown(), new Promise<void>((r) => setTimeout(r, 10_000))]);
128+
}
129+
130+
describe('Route parity: discovery-advertised routes are reachable on os serve (#3369)', () => {
131+
let kernel: LiteKernel;
132+
let baseUrl: string;
133+
134+
beforeAll(async () => {
135+
({ kernel, baseUrl } = await bootServe());
136+
}, 30_000);
137+
138+
afterAll(async () => { if (kernel) await shutdown(kernel); }, 30_000);
139+
140+
it('serves discovery and advertises the provisioned capability routes', async () => {
141+
const res = await fetch(`${baseUrl}/api/v1/discovery`);
142+
expect(res.status).toBe(200);
143+
const routes = (await res.json())?.data?.routes ?? {};
144+
// Always-on kernel routes.
145+
expect(routes.data).toBeTruthy();
146+
expect(routes.metadata).toBeTruthy();
147+
// Provisioned optional capabilities → advertised (declared).
148+
expect(routes.notifications, 'notifications must be advertised when the service is present').toBeTruthy();
149+
expect(routes.mcp, 'mcp must be advertised when enabled').toBeTruthy();
150+
});
151+
152+
/**
153+
* The core gate: every route the server ADVERTISES must be ENFORCED —
154+
* reachable on the actual listener. 404 (route not mounted), 405 (wrong
155+
* method sink) and 501 (advertised but no backing handler/service) all mean
156+
* declared ≠ enforced. An anonymous caller legitimately gets 401/403; that
157+
* still proves the route is mounted.
158+
*/
159+
const DEAD_STATUSES = new Set([404, 405, 501]);
160+
const probes: Array<{ method: string; path: string; note: string }> = [
161+
{ method: 'GET', path: '/api/v1/health', note: 'liveness probe' },
162+
{ method: 'GET', path: '/api/v1/ready', note: 'readiness probe' },
163+
{ method: 'GET', path: '/api/v1/notifications', note: '#3362 inbox list' },
164+
{ method: 'POST', path: '/api/v1/notifications/read', note: '#3362 mark-read' },
165+
{ method: 'POST', path: '/api/v1/notifications/read/all', note: '#3362 mark-all-read' },
166+
{ method: 'POST', path: '/api/v1/mcp', note: 'MCP 501 regression' },
167+
];
168+
169+
it('every advertised/dispatcher route is reachable (not 404/405/501) for an anonymous caller', async () => {
170+
for (const { method, path, note } of probes) {
171+
const res = await fetch(`${baseUrl}${path}`, { method });
172+
expect(
173+
DEAD_STATUSES.has(res.status),
174+
`${method} ${path} (${note}) returned ${res.status} — declared but not enforced`,
175+
).toBe(false);
176+
}
177+
});
178+
179+
it('every advertised/dispatcher route is reachable (not 404/405/501) for an admin principal', async () => {
180+
for (const { method, path, note } of probes) {
181+
const res = await fetch(`${baseUrl}${path}`, { method, headers: { 'x-test-user': 'admin1' } });
182+
expect(
183+
DEAD_STATUSES.has(res.status),
184+
`${method} ${path} (${note}) returned ${res.status} for admin — declared but not enforced`,
185+
).toBe(false);
186+
}
187+
});
188+
189+
it('marks notifications read for an authenticated user (the #3362 end-to-end path)', async () => {
190+
// The exact call the Console makes (AppHeader mark-all-read). With the
191+
// notification service present it must reach the handler and succeed —
192+
// NOT 404 (the shipped-server regression #3354 set out to fix).
193+
const res = await fetch(`${baseUrl}/api/v1/notifications/read/all`, {
194+
method: 'POST',
195+
headers: { 'x-test-user': 'admin1', 'content-type': 'application/json' },
196+
body: '{}',
197+
});
198+
expect(res.status).toBe(200);
199+
});
200+
201+
it('MCP is advertised AND reachable — the discovery/route lockstep holds (not 501)', async () => {
202+
const disc = await (await fetch(`${baseUrl}/api/v1/discovery`)).json();
203+
expect(disc.data.routes.mcp).toBeTruthy();
204+
// With the mcp service provisioned, /mcp must NOT 501 ("not available").
205+
// Anonymous → 401 (a key/token is required) — reachable.
206+
const res = await fetch(`${baseUrl}/api/v1/mcp`, { method: 'POST', body: '{}' });
207+
expect(res.status).not.toBe(501);
208+
expect(res.status).not.toBe(404);
209+
});
210+
});
211+
212+
describe('Route parity: discovery is service-aware — no dead advertisement (#3369)', () => {
213+
let kernel: LiteKernel;
214+
let baseUrl: string;
215+
216+
beforeAll(async () => {
217+
// Boot WITHOUT the notification service.
218+
({ kernel, baseUrl } = await bootServe({ notification: false }));
219+
}, 30_000);
220+
221+
afterAll(async () => { if (kernel) await shutdown(kernel); }, 30_000);
222+
223+
it('does NOT advertise a capability whose backing service is absent', async () => {
224+
const disc = await (await fetch(`${baseUrl}/api/v1/discovery`)).json();
225+
expect(
226+
disc.data.routes.notifications,
227+
'notifications must NOT be advertised when the service is absent (declared === enforced)',
228+
).toBeFalsy();
229+
});
230+
231+
it('a request to the un-provisioned notifications route resolves to 404 (consistent with not advertising it)', async () => {
232+
// Not advertised AND 404 → declared === enforced (neither over- nor
233+
// under-promised). The failure mode #3369 forbids is the inverse:
234+
// advertised in discovery yet 404 on the listener.
235+
const res = await fetch(`${baseUrl}/api/v1/notifications`, { headers: { 'x-test-user': 'admin1' } });
236+
expect(res.status).toBe(404);
237+
});
238+
});

0 commit comments

Comments
 (0)