Skip to content

Commit 839982e

Browse files
authored
fix(plugin-hono-server): compute the standalone discovery routes from real registrations (#4018) (#4063)
`registerStandardEndpoints` served a fully STATIC discovery: a hardcoded `routes` table listing auth/packages/analytics/workflow/automation/ai/ notifications/i18n/storage/ui regardless of what the host mounted. A standalone Hono deployment advertised ten route families and 404'd on every one no plugin bridged — the "advertise a route that doesn't exist" class ADR-0076 D12 exists to kill, and why this surface disagreed with the two real discovery builders (`HttpDispatcher.getDiscoveryInfo`, `metadata-protocol`'s `getDiscovery`), which both compute at runtime. Closed on both axes, without adding a third service-registry walk to keep in sync with the other two: - Single owner (D11 / OQ#9): when `@objectstack/rest` or the runtime dispatcher is on the kernel, this surface no longer registers `${prefix}/discovery`. Both register during plugin `start()` — before this `kernel:ready` hook — and Hono is first-registration-wins, so they already shadowed this handler in every composed deployment: the cede changes no served payload, it removes a third one nobody read. `/.well-known/objectstack` is ceded to the dispatcher only, since REST never registers it. - Computed, not hardcoded (D12): when this surface does own `/discovery`, `routes` is derived per request from the app's live Hono route table — a family is advertised iff a route is really registered at or under its base. Per request, because siblings keep mounting through the rest of `kernel:ready`; requiring the base or a `/`-separated child, so a wildcard ABOVE the base (global `/*`, `/api/v1/*`) never counts as a mount. Tests drive the real Hono app rather than a mocked route table: `hono-discovery.test.ts` covers the bare host advertising only what it mounts, late mounts and wildcards, and both cede paths. `client.hono.test.ts` now asserts the honest shape — `routes.metadata` absent on a host where `GET /api/v1/meta/objects` really is 404.
1 parent 53fbf49 commit 839982e

5 files changed

Lines changed: 352 additions & 34 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/plugin-hono-server": patch
3+
---
4+
5+
fix(plugin-hono-server): compute the standalone discovery `routes` from real registrations, and cede to the real owner (#4018)
6+
7+
`registerStandardEndpoints` served a **fully static** discovery: a hardcoded
8+
`routes` table listing `auth` / `packages` / `analytics` / `workflow` /
9+
`automation` / `ai` / `notifications` / `i18n` / `storage` / `ui` regardless of
10+
what the host actually mounted. A standalone Hono deployment therefore
11+
advertised ten route families and 404'd on every one no plugin bridged — the
12+
"advertise a route that doesn't exist" class ADR-0076 D12 exists to kill, and
13+
the reason this surface disagreed with the two real discovery builders
14+
(`HttpDispatcher.getDiscoveryInfo`, `metadata-protocol`'s `getDiscovery`), which
15+
both compute per service at runtime.
16+
17+
Two changes, no new discovery implementation to keep in sync:
18+
19+
- **Single owner (D11 / OQ#9).** When `@objectstack/rest` or the runtime
20+
dispatcher is on the kernel, this surface no longer registers
21+
`${prefix}/discovery` — that plugin owns it. Both register during plugin
22+
`start()`, i.e. before this `kernel:ready` hook, and Hono is
23+
first-registration-wins, so they already shadowed this handler in every
24+
composed deployment: the cede changes no served payload, it removes a third
25+
one nobody read. `/.well-known/objectstack` is ceded to the dispatcher only
26+
(REST never registers it), so a REST-without-dispatcher host keeps the
27+
redirect.
28+
29+
- **Computed, not hardcoded (D12).** When this surface does own `/discovery`,
30+
`routes` is derived per request from the app's live route table: a family is
31+
advertised iff a route is really registered at or under its base path. A
32+
wildcard mounted *above* the base (global `/*` middleware, `/api/v1/*`) does
33+
not count as a mount.
34+
35+
**What changes for you.** On a standalone `HonoServerPlugin` host (no REST, no
36+
dispatcher), `GET /api/v1/discovery` now omits every family nothing mounts —
37+
most visibly `routes.metadata`, since `/api/v1/meta` ships with
38+
`@objectstack/rest` / the dispatcher. Clients that read a route out of
39+
discovery and call it stop getting a 404; `@objectstack/client` falls back to
40+
the conventional path for any omitted key, so `client.connect()` is unaffected.
41+
Composed deployments (`os serve`, cloud) are unchanged — the dispatcher's
42+
service-aware discovery was already the one being served.

docs/adr/0076-objectql-core-tiering.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ Decision: each capability plugin registers its routes as a **normalized handler*
143143

144144
This fixes the **whole class at once — without deleting any fallback** (no `/analytics` 404 regression): the analytics fallback and the dev stubs simply stop *lying*; they keep serving but are honestly labelled. It is the runtime enforcement of the D9-refinement principle (capabilities = what is actually installed, computed at runtime).
145145

146+
*(Update #4018: the same honesty binds the `routes` table, not only `services` — and it had a third publisher. `plugin-hono-server`'s `registerStandardEndpoints` convenience block still served a fully **static** `routes` map listing auth/packages/analytics/workflow/automation/ai/notifications/i18n/storage/ui regardless of what was mounted, so a standalone Hono host advertised ten route families and 404'd on the ones no plugin bridged. Closed on both axes: it now **cedes** `/discovery` to `@objectstack/rest` or the runtime dispatcher whenever either is on the kernel (single owner — D11 / OQ#9 worklist item 2; both register during `start()`, so first-registration-wins already shadowed this handler and the cede is behaviour-preserving), and when it does own the route it computes `routes` from the app's **live route table** — a family is advertised iff a route is really registered at or under its base. That keeps the honest answer without adding a third service-registry walk to keep in sync with the other two.)*
147+
146148
**Supersedes the rev.9 analytics conclusion**: the fix for the analytics fallback is to **mark it honestly (this D12)**, not "preserve-or-delete". *(Update #3891: superseded in turn for the analytics fallback specifically — honest labelling was necessary but not sufficient. The fallback's `degraded` label was accurate about capability, yet nothing in it disclosed that aggregates ran WITHOUT the caller's RLS/tenant scoping and that the contract `where` filter was ignored; an authorized caller still got a 200 with wrong (over-broad) numbers. A fallback may degrade features, never security semantics — so it was retired, and the "no `/analytics` 404 regression" goal above is deliberately abandoned for this slot: the 404 IS the honest signal. D12's marker/discovery machinery stays, and is what makes the now-empty slot report `unavailable`.)*
147149

148150
**Execution**: framework (marker convention + `svcAvailable` respects it + discovery schema `stub` status) and console (read the honest status) land **together at the cross-repo window** — the console reads `discovery.services`, so this is a cross-repo contract change.

packages/client/src/client.hono.test.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,16 +143,23 @@ describe('ObjectStackClient (with Hono Server)', () => {
143143
it('should connect to hono server and discover endpoints', async () => {
144144
const client = new ObjectStackClient({ baseUrl });
145145
await client.connect();
146-
146+
147147
// Client should have populated discovery info
148148
expect(client['discoveryInfo']).toBeDefined();
149-
150-
// Verify endpoints from valid discovery response
151-
// Standard: /api/v1/data, /api/v1/meta, etc.
149+
150+
// The standalone hono surface advertises what it actually mounts
151+
// (#4018): /data CRUD and the /auth/me/* helpers are registered here,
152+
// so both are advertised and both answer.
152153
const endpoints = client['discoveryInfo']!.routes;
153154
expect(endpoints.data).toContain('/api/v1/data');
154-
expect(endpoints.metadata).toContain('/api/v1/meta');
155155
expect(endpoints.auth).toContain('/api/v1/auth');
156+
157+
// `metadata` is NOT advertised on this boot, and that is the point of
158+
// #4018: no plugin here mounts /api/v1/meta (it ships with
159+
// @objectstack/rest / the dispatcher), so the old hardcoded table was
160+
// promising a route that 404s. Proof the omission is honest:
161+
expect(endpoints.metadata).toBeUndefined();
162+
expect((await fetch(`${baseUrl}/api/v1/meta/objects`)).status).toBe(404);
156163
});
157164

158165
it('should create and retrieve data via hono', async () => {
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #4018 — the standard-endpoints convenience surface used to serve a fully
4+
// STATIC discovery: a hardcoded `routes` table listing auth/packages/analytics/
5+
// workflow/automation/ai/notifications/i18n/storage/ui whether or not anything
6+
// mounted them. That is the "advertise a route that 404s" class ADR-0076 D12
7+
// exists to kill, and it put this surface out of step with the two real
8+
// discovery builders (the dispatcher's `getDiscoveryInfo`, the protocol's
9+
// `getDiscovery`), which both compute per service.
10+
//
11+
// These tests drive the REAL Hono app the plugin builds — no mocked route
12+
// table — so what they assert about "is this route mounted" is what a request
13+
// would actually find.
14+
15+
import { describe, it, expect } from 'vitest';
16+
import { HonoServerPlugin } from './hono-plugin';
17+
18+
const REST_API_PLUGIN = 'com.objectstack.rest.api';
19+
const RUNTIME_DISPATCHER_PLUGIN = 'com.objectstack.runtime.dispatcher';
20+
21+
/**
22+
* Register the standard endpoints on a real Hono app and hand it back so tests
23+
* can drive HTTP requests directly. `installedPlugins` seeds `kernel.hasPlugin`,
24+
* which is how this surface decides whether a real discovery owner is present.
25+
*/
26+
function bootStandardEndpoints(installedPlugins: string[] = []) {
27+
const plugin = new HonoServerPlugin({ port: 0 });
28+
const ctx: any = {
29+
logger: { info() {}, debug() {}, warn() {}, error() {} },
30+
getKernel: () => ({
31+
hasPlugin: (name: string) => installedPlugins.includes(name),
32+
getService: () => undefined,
33+
}),
34+
registerService: () => {},
35+
hook: () => {},
36+
getService: () => undefined,
37+
};
38+
(plugin as any).registerDiscoveryAndCrudEndpoints(ctx);
39+
return (plugin as any).server.getRawApp();
40+
}
41+
42+
async function discoveryRoutes(app: any): Promise<Record<string, string>> {
43+
const res = await app.request('http://localhost/api/v1/discovery');
44+
expect(res.status).toBe(200);
45+
return (await res.json()).data.routes;
46+
}
47+
48+
describe('standalone discovery — routes are computed, never hardcoded (#4018)', () => {
49+
it('advertises nothing for services this host does not mount', async () => {
50+
const app = bootStandardEndpoints();
51+
const routes = await discoveryRoutes(app);
52+
53+
// Every one of these was advertised unconditionally by the old static
54+
// table; on a bare host each 404s, so none may be advertised now.
55+
for (const family of [
56+
'metadata', 'packages', 'analytics', 'workflow', 'automation',
57+
'ai', 'notifications', 'i18n', 'storage', 'ui',
58+
]) {
59+
expect(routes[family], `${family} advertised but nothing mounts it`).toBeUndefined();
60+
}
61+
});
62+
63+
it('advertises the families this surface really mounts, and they answer', async () => {
64+
const app = bootStandardEndpoints();
65+
const routes = await discoveryRoutes(app);
66+
67+
// The block mounts /data/:object CRUD and the /auth/me/* helpers, so
68+
// both bases genuinely carry routes. (`/auth` on a bare host is only
69+
// those helpers — the sign-in surface arrives with plugin-auth.)
70+
expect(routes.data).toBe('/api/v1/data');
71+
expect(routes.auth).toBe('/api/v1/auth');
72+
73+
// Not a 404: the advertised base really has a live endpoint under it.
74+
const res = await app.request('http://localhost/api/v1/data/thing');
75+
expect(res.status).not.toBe(404);
76+
});
77+
78+
it('picks up a family once another plugin mounts it — including a wildcard', async () => {
79+
const app = bootStandardEndpoints();
80+
// How plugin-auth / the dispatcher really mount: a wildcard under the
81+
// family base, and a concrete child route.
82+
app.all('/api/v1/i18n/*', (c: any) => c.json({}));
83+
app.post('/api/v1/analytics/query', (c: any) => c.json({}));
84+
85+
const routes = await discoveryRoutes(app);
86+
expect(routes.i18n).toBe('/api/v1/i18n');
87+
expect(routes.analytics).toBe('/api/v1/analytics');
88+
});
89+
90+
it('reflects a mount that lands AFTER discovery is wired (not snapshotted)', async () => {
91+
const app = bootStandardEndpoints();
92+
93+
// Sibling plugins keep registering through the rest of kernel:ready —
94+
// i.e. after this hook wired `/discovery` — and Hono seals its matcher
95+
// on the first request, so a mount can only ever arrive in this window.
96+
// A table built at wiring time would miss it; one built per request
97+
// does not.
98+
app.get('/api/v1/workflow/definitions', (c: any) => c.json({}));
99+
100+
expect((await discoveryRoutes(app)).workflow).toBe('/api/v1/workflow');
101+
});
102+
103+
it('does not count a wildcard mounted ABOVE the family base', async () => {
104+
const app = bootStandardEndpoints();
105+
// Global middleware and a prefix-wide wildcard match every path but
106+
// mount no family — treating them as a mount would re-advertise the
107+
// whole table, which is the bug.
108+
app.use('*', async (_c: any, next: any) => next());
109+
app.use('/api/v1/*', async (_c: any, next: any) => next());
110+
111+
const routes = await discoveryRoutes(app);
112+
expect(routes.storage).toBeUndefined();
113+
expect(routes.ai).toBeUndefined();
114+
});
115+
116+
it('never advertises realtime — no HTTP surface exists for it (D12, #2462)', async () => {
117+
const app = bootStandardEndpoints();
118+
expect((await discoveryRoutes(app)).realtime).toBeUndefined();
119+
});
120+
121+
it('still reports transactionalBatch=false — /batch ships with @objectstack/rest (#3298)', async () => {
122+
const app = bootStandardEndpoints();
123+
const res = await app.request('http://localhost/api/v1/discovery');
124+
const body = await res.json();
125+
126+
expect(body.data.capabilities.transactionalBatch).toEqual({ enabled: false });
127+
expect((await app.request('http://localhost/api/v1/batch', { method: 'POST' })).status).toBe(404);
128+
});
129+
});
130+
131+
describe('standalone discovery — single owner (ADR-0076 D11 / OQ#9)', () => {
132+
it('cedes /discovery AND /.well-known to the dispatcher when it is installed', async () => {
133+
const app = bootStandardEndpoints([RUNTIME_DISPATCHER_PLUGIN]);
134+
135+
// The dispatcher registers both during plugin start() — before this
136+
// kernel:ready hook — so it already served them; we must not publish a
137+
// third payload behind it.
138+
expect((await app.request('http://localhost/api/v1/discovery')).status).toBe(404);
139+
expect((await app.request('http://localhost/.well-known/objectstack')).status).toBe(404);
140+
});
141+
142+
it('cedes /discovery to @objectstack/rest but keeps /.well-known (REST never registers it)', async () => {
143+
const app = bootStandardEndpoints([REST_API_PLUGIN]);
144+
145+
expect((await app.request('http://localhost/api/v1/discovery')).status).toBe(404);
146+
147+
const wellKnown = await app.request('http://localhost/.well-known/objectstack');
148+
expect(wellKnown.status).toBe(302);
149+
expect(wellKnown.headers.get('location')).toBe('/api/v1/discovery');
150+
});
151+
152+
it('owns both when no real discovery plugin is on the kernel', async () => {
153+
const app = bootStandardEndpoints();
154+
155+
expect((await app.request('http://localhost/api/v1/discovery')).status).toBe(200);
156+
expect((await app.request('http://localhost/.well-known/objectstack')).status).toBe(302);
157+
});
158+
});

0 commit comments

Comments
 (0)