Skip to content

Commit c2819f4

Browse files
committed
refactor(route-parity): honest standalone hono discovery + document MCP lockstep; broaden gate
Follow-ups on the route-parity work (#3369): - hono-server: the standalone `registerDiscoveryAndCrudEndpoints` discovery 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 mounts (the `/data` CRUD surface + the `/auth/me/*` helpers). Under `os serve` this fallback is shadowed by the dispatcher/REST service-aware discovery, so real deployments are unaffected; the fix closes the literal `declared !== enforced` gap the issue cited (hono-plugin.ts). - http-dispatcher: document WHY `routes.mcp` is advertised on the `isMcpServerEnabled()` flag rather than gated on service presence like every other route — it is a deliberate LOCKSTEP (#2698): `os serve` auto-loads plugin-mcp from the same flag, and `@objectstack/rest` advertises mcp from the identical single source, so the two discovery producers stay symmetric and an advertised `/mcp` always has a mounted handler. Comment-only; no behaviour change (avoids forking the dispatcher from REST). - route-parity gate: assert the standalone-hono honesty and note that REST-owned routes (/data,/meta,/ui) are covered by the @objectstack/client integration suite (booting a real hono server with the full dependency graph), so this gate stays focused on the dispatcher↔hono seam where the #3361/#3362/MCP regressions lived. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015si15Q1KWMKpVQWYsEvgcS
1 parent 49ac8ef commit c2819f4

4 files changed

Lines changed: 74 additions & 16 deletions

File tree

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,38 @@ 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+
// Every advertised route must have a matching registered handler.
162+
const registered = new Set(Object.keys(routes).map((k) => k.split(' ')[1]));
163+
expect([...registered].some((p) => p.startsWith('/api/v1/data'))).toBe(true);
164+
});
165+
134166
it('should configure static files and SPA fallback when enabled', async () => {
135167
const plugin = new HonoServerPlugin({
136168
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
@@ -737,26 +737,32 @@ export class HonoServerPlugin implements Plugin {
737737
const rawApp = this.server.getRawApp();
738738
const prefix = '/api/v1';
739739

740-
// Build the standard discovery response
740+
// Build the standard discovery response.
741+
//
742+
// `declared === enforced` (#3369): advertise ONLY the routes THIS plugin
743+
// actually mounts below (the `/data` CRUD surface + the `/auth/me/*`
744+
// helpers). This discovery is a STANDALONE FALLBACK — it is served only
745+
// when the HonoServerPlugin runs alone. Under `os serve` / `os dev` the
746+
// dispatcher (`@objectstack/runtime`) and `@objectstack/rest` register
747+
// `${prefix}/discovery` FIRST (in `start()`, before this plugin's
748+
// `kernel:ready` hook), so their SERVICE-AWARE discovery shadows this
749+
// one and reports the full, actually-mounted surface. The prior static
750+
// list here advertised `metadata`/`packages`/`analytics`/`workflow`/
751+
// `automation`/`ai`/`notifications`/`i18n`/`storage`/`ui` — none of
752+
// which this plugin mounts — so a truly standalone deployment
753+
// advertised a dozen routes that 404 (the exact `declared !== enforced`
754+
// gap #3369 closes). `realtime` was likewise (correctly) never listed
755+
// (ADR-0076 D12, #2462): no `/realtime` HTTP surface exists anywhere.
741756
const discovery = {
742757
version: 'v1',
743758
apiName: 'ObjectStack API',
744759
routes: {
745-
data: `${prefix}/data`,
746-
metadata: `${prefix}/meta`,
747-
auth: `${prefix}/auth`,
748-
packages: `${prefix}/packages`,
749-
analytics: `${prefix}/analytics`,
750-
// realtime deliberately absent (ADR-0076 D12, #2462): no
751-
// /realtime HTTP surface is mounted anywhere — advertising
752-
// it here made clients call a route that 404s.
753-
workflow: `${prefix}/workflow`,
754-
automation: `${prefix}/automation`,
755-
ai: `${prefix}/ai`,
756-
notifications: `${prefix}/notifications`,
757-
i18n: `${prefix}/i18n`,
758-
storage: `${prefix}/storage`,
759-
ui: `${prefix}/ui`,
760+
data: `${prefix}/data`,
761+
// Only the `/auth/me/*` read helpers are mounted here (not the
762+
// full better-auth `/auth` surface, which ships with the auth
763+
// plugin); the namespace is advertised because it carries live
764+
// handlers on this standalone server.
765+
auth: `${prefix}/auth`,
760766
},
761767
capabilities: {
762768
// 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
@@ -1674,6 +1674,17 @@ export class HttpDispatcher {
16741674
// MCP (Streamable HTTP) is a default-on core capability —
16751675
// advertised unless OS_MCP_SERVER_ENABLED=false opts the env
16761676
// out. The objectui Integrations page reads this.
1677+
//
1678+
// `declared === enforced` here is guaranteed by a LOCKSTEP, not
1679+
// by service-presence gating like the routes above (#3369 /
1680+
// #2698): `os serve` auto-loads plugin-mcp from the SAME
1681+
// `isMcpServerEnabled()` flag that gates this advertisement, so
1682+
// whenever `/mcp` is advertised the handler is mounted (a key /
1683+
// token yields 401, never a 404/501). Kept flag-based on purpose
1684+
// — `@objectstack/rest` advertises `mcp` from the identical
1685+
// single source (rest-server.ts), so the two discovery producers
1686+
// stay symmetric. The route-parity gate asserts the lockstep
1687+
// holds (advertised ⇒ reachable, never 501).
16771688
mcp: HttpDispatcher.isMcpEnabled() ? `${prefix}/mcp` : undefined,
16781689
};
16791690

packages/runtime/src/route-parity.integration.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,12 @@ describe('Route parity: discovery is service-aware — no dead advertisement (#3
256256
expect(res.status).toBe(404);
257257
});
258258
});
259+
260+
// NOTE ON SCOPE: this gate boots the dispatcher ↔ hono seam — where the
261+
// #3361 / #3362 / MCP-501 regressions lived (dispatcher-registered routes on
262+
// the hono listener). The REST-owned surface (`/data`, `/meta`, `/ui`) served
263+
// by `@objectstack/rest` is exercised against a real booted hono server with
264+
// its full dependency graph in the `@objectstack/client` integration suite
265+
// (`client.hono.test.ts`, `client.batch-transaction.test.ts`) — booting REST
266+
// from source here would require re-aliasing that entire graph and would only
267+
// duplicate that coverage.

0 commit comments

Comments
 (0)