Skip to content

Commit 0f12193

Browse files
os-zhuangclaude
andauthored
feat(runtime): mount /analytics routes only when the capability exists (#3891 follow-through, ADR-0076 D11) (#4019)
* feat(runtime): mount /analytics routes only when the capability exists (#3891 follow-through, ADR-0076 D11) The dispatcher plugin mounted POST /analytics/query, GET /analytics/meta and POST /analytics/sql unconditionally, so a deployment without @objectstack/service-analytics kept the routes in its table: PUT answered 405 + Allow: POST, advertising a method on an API that isn't there (the POST itself has answered 404 since #3989 emptied the slot). Mounting is now capability-conditional. Plugin start() runs after the kernel's Phase-1 init, so service presence is authoritative: - single-kernel, `analytics` absent: routes NOT mounted — every method answers the adapter's shared not-found contract, and a boot log names the fix (install @objectstack/service-analytics); - single-kernel, `analytics` registered: unchanged; - multi-tenant host (kernel-resolver wired): mounted unconditionally — mounts are host-global while the service lives per-project kernel, so presence is a per-request question answered by the analytics domain's existing handled:false → 404. New public HttpDispatcher.isMultiTenantHost() exposes the mode. Tests: three conditional-mount cases in dispatcher-plugin.routes.test.ts (absent / registered / multi-tenant); conformance boots gain an analytics stub so the 405 + parity contracts keep their subject, plus a capability-absent boot pinning "no mounts, shared 404 for every method, sibling routes unaffected". Route-ledger row annotated (a row is an SDK-expressibility claim, not a mount guarantee). Completes the #3891 arc: #3989 emptied the slot, #4010 made the body contract strict at the entry, this removes the last wire-level residue of the uninstalled API. The hono standalone static discovery that still hardcodes the full routes table is tracked as #4018. Refs #3891, #3989, #4010, #4018. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017WZBR9XyhXoqKCU6qQVyjx * docs(api): discovery sample reflects the capability-conditional analytics surface The /api/v1 discovery example still showed the pre-#3989 picture: analytics hardcoded in `routes` and reported available with provider objectql on a minimal install. Show the honest minimal-install shape instead — analytics unavailable with the install hint and omitted from routes — which also makes the sample demonstrate the omitted-when-uninstalled rule the paragraph below it explains. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017WZBR9XyhXoqKCU6qQVyjx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cbd5181 commit 0f12193

7 files changed

Lines changed: 226 additions & 31 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
feat(runtime): mount /analytics routes only when the capability exists (#3891 follow-through, ADR-0076 D11)
6+
7+
`createDispatcherPlugin` used to mount `POST /analytics/query`,
8+
`GET /analytics/meta` and `POST /analytics/sql` on the `IHttpServer`
9+
unconditionally — so a deployment without `@objectstack/service-analytics`
10+
still had the routes in its table: a `PUT` answered `405` with
11+
`Allow: POST`, advertising a method on an API that wasn't there (the `POST`
12+
itself answered 404 since #3989).
13+
14+
The mounts are now capability-conditional. Plugin `start()` runs after the
15+
kernel's Phase-1 init, so service presence is authoritative:
16+
17+
- **single-kernel mode, `analytics` not registered** — the three routes are
18+
NOT mounted; every method on `/api/v1/analytics/*` answers the adapter's
19+
shared not-found contract (`404 { "error": "Not found" }`), and a boot log
20+
names the fix (`Install @objectstack/service-analytics`);
21+
- **single-kernel mode, `analytics` registered** — unchanged;
22+
- **multi-tenant host** (a `kernel-resolver` is wired) — mounted
23+
unconditionally, because mounts are host-global while the analytics service
24+
lives in each per-project kernel: capability presence is a per-request
25+
question, answered by the analytics domain's existing `handled:false` → 404
26+
(new public `HttpDispatcher.isMultiTenantHost()` exposes the mode).
27+
28+
With this, the `/analytics` API surface exists exactly when the capability is
29+
installed — completing the #3891 arc: #3989 emptied the slot (no more
30+
unscoped-aggregate shim), #4010 made the body contract strict at the entry,
31+
and this change removes the last wire-level residue of the uninstalled API.

content/docs/api/index.mdx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,7 @@ Returns the full discovery manifest.
109109
"apiName": "ObjectStack API",
110110
"routes": {
111111
"data": "/api/v1/data",
112-
"metadata": "/api/v1/meta",
113-
"analytics": "/api/v1/analytics"
112+
"metadata": "/api/v1/meta"
114113
},
115114
"services": {
116115
"metadata": {
@@ -120,7 +119,7 @@ Returns the full discovery manifest.
120119
"provider": "objectql"
121120
},
122121
"data": { "enabled": true, "status": "available", "route": "/api/v1/data", "provider": "objectql" },
123-
"analytics": { "enabled": true, "status": "available", "route": "/api/v1/analytics", "provider": "objectql" },
122+
"analytics": { "enabled": false, "status": "unavailable", "message": "Install service-analytics to enable" },
124123
"auth": { "enabled": false, "status": "unavailable", "message": "Install plugin-auth to enable" }
125124
},
126125
"capabilities": {
@@ -131,7 +130,7 @@ Returns the full discovery manifest.
131130
}
132131
```
133132

134-
Disabled/uninstalled route keys (e.g. `auth`, `workflow`) are omitted from `routes` entirely rather than set to `null`; check `services` to tell "not installed" apart from "installed but not yet mounted here."
133+
Disabled/uninstalled route keys (e.g. `auth`, `analytics`, `workflow`) are omitted from `routes` entirely rather than set to `null`; check `services` to tell "not installed" apart from "installed but not yet mounted here." The sample above shows a minimal install: `analytics` reports `unavailable` and advertises no route until `@objectstack/service-analytics` registers the engine — the dispatcher then also mounts `/api/v1/analytics/*` (the routes are capability-conditional; an uninstalled capability answers 404 for every method).
135134

136135
### `GET /.well-known/objectstack`
137136

packages/qa/http-conformance/src/conformance.integration.test.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,29 @@ const ADAPTERS: AdapterCase[] = [
3636
/**
3737
* Boot the full HTTP stack (ObjectQL engine + REST generator + dispatcher
3838
* bridge) on the given adapter and return its base URL + kernel.
39+
*
40+
* `withAnalytics` (default true) registers a minimal in-memory `analytics`
41+
* service so the capability-conditional `/analytics` routes mount (#3891
42+
* follow-through) — the transport contracts under test (405 method mismatch,
43+
* parity) need the routes to exist. Pass false to exercise the
44+
* capability-absent posture: no mounts, shared 404.
3945
*/
40-
async function bootStack(makePlugin: () => any) {
46+
async function bootStack(makePlugin: () => any, opts: { withAnalytics?: boolean } = {}) {
4147
const kernel = new LiteKernel();
4248
kernel.use(new ObjectQLPlugin());
49+
if (opts.withAnalytics !== false) {
50+
kernel.use({
51+
name: 'com.test.analytics-stub',
52+
version: '0.0.0',
53+
init: (c: any) => {
54+
c.registerService('analytics', {
55+
query: async () => ({ rows: [], fields: [] }),
56+
getMeta: async () => ({ cubes: [] }),
57+
generateSql: async () => ({ sql: null, params: [] }),
58+
});
59+
},
60+
} as any);
61+
}
4362
kernel.use(makePlugin());
4463
kernel.use(createRestApiPlugin({
4564
api: {
@@ -161,14 +180,56 @@ describe.each(ADAPTERS)('IHttpServer conformance on $label adapter', ({ makePlug
161180
});
162181

163182
it('405s a method mismatch with an Allow header', async () => {
164-
// /api/v1/analytics/query is registered POST-only by the bridge.
183+
// /api/v1/analytics/query is registered POST-only by the bridge —
184+
// mounted here because bootStack registers the analytics stub
185+
// (capability-conditional since #3891 follow-through).
165186
const res = await fetch(`${base}/api/v1/analytics/query`, { method: 'PUT' });
166187
expect(res.status).toBe(405);
167188
expect(res.headers.get('allow')).toContain('POST');
168189
expect((await res.json()).code).toBe('METHOD_NOT_ALLOWED');
169190
});
170191
});
171192

193+
/**
194+
* [#3891 follow-through] Capability-conditional mounting: without an
195+
* `analytics` service the routes are NOT mounted, so the path answers the
196+
* adapter's shared not-found contract — for EVERY method. No 405 Allow hint
197+
* may leak for an API that isn't there. Single adapter suffices: the
198+
* conditional lives in the dispatcher plugin, above the adapter seam.
199+
*/
200+
describe('analytics capability-conditional mounting (no service installed)', () => {
201+
let stack: { kernel: LiteKernel; base: string };
202+
203+
beforeAll(async () => {
204+
stack = await bootStack(ADAPTERS[0].makePlugin, { withAnalytics: false });
205+
}, 30_000);
206+
207+
afterAll(async () => {
208+
if (stack?.kernel) {
209+
await Promise.race([
210+
stack.kernel.shutdown(),
211+
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
212+
]);
213+
}
214+
}, 30_000);
215+
216+
it.each(['POST', 'PUT', 'GET'])('%s /api/v1/analytics/query answers the shared 404', async (method) => {
217+
const res = await fetch(`${stack.base}/api/v1/analytics/query`, { method });
218+
expect(res.status).toBe(404);
219+
expect(await res.json()).toEqual({ error: 'Not found' });
220+
});
221+
222+
it('GET /api/v1/analytics/meta answers the shared 404 too', async () => {
223+
const res = await fetch(`${stack.base}/api/v1/analytics/meta`);
224+
expect(res.status).toBe(404);
225+
});
226+
227+
it('a sibling dispatcher route is still mounted (the absence is analytics-scoped)', async () => {
228+
const res = await fetch(`${stack.base}/api/v1/health`);
229+
expect(res.status).toBe(200);
230+
});
231+
});
232+
172233
/**
173234
* Cross-adapter parity: the SAME requests against both adapters must produce
174235
* the same status codes and (for JSON control-plane responses) the same body

packages/runtime/src/dispatcher-plugin.routes.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,62 @@ describe('createDispatcherPlugin — HTTP route registration', () => {
111111
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
112112
await plugin.start?.(makeCtx(server));
113113

114-
expect(routes).toContain('POST /api/v1/analytics/query');
114+
expect(routes).toContain('GET /api/v1/i18n/locales');
115+
});
116+
117+
// [#3891 follow-through] The /analytics wire surface exists only when the
118+
// capability does: with no `analytics` service registered (and no
119+
// kernel-resolver), the three routes are NOT mounted — the path answers the
120+
// adapter's shared 404, with no 405 Allow hint for an API that isn't there.
121+
describe('capability-conditional /analytics mounting', () => {
122+
const ANALYTICS_ROUTES = [
123+
'POST /api/v1/analytics/query',
124+
'GET /api/v1/analytics/meta',
125+
'POST /api/v1/analytics/sql',
126+
];
127+
128+
function ctxWithServices(fakeServer: any, services: Record<string, any>) {
129+
const kernel = {
130+
getService: (name: string) => services[name],
131+
getServiceAsync: async (name: string) => services[name],
132+
};
133+
return {
134+
getKernel: () => kernel,
135+
getService: (name: string) => (name === 'http.server' ? fakeServer : undefined),
136+
environmentId: undefined,
137+
logger: { info() {}, warn() {}, error() {}, debug() {} },
138+
hook: () => {}, on: () => {},
139+
} as any;
140+
}
141+
142+
it('does NOT mount /analytics when no analytics service is registered', async () => {
143+
const { server, routes } = makeFakeServer();
144+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
145+
await plugin.start?.(makeCtx(server));
146+
147+
for (const r of ANALYTICS_ROUTES) expect(routes).not.toContain(r);
148+
});
149+
150+
it('mounts /analytics when the analytics service is registered', async () => {
151+
const { server, routes } = makeFakeServer();
152+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
153+
await plugin.start?.(ctxWithServices(server, { analytics: { query: async () => ({ rows: [] }) } }));
154+
155+
for (const r of ANALYTICS_ROUTES) expect(routes).toContain(r);
156+
});
157+
158+
it('mounts /analytics unconditionally on a multi-tenant host (kernel-resolver wired)', async () => {
159+
// Host-global mounts, per-project services: presence is a per-request
160+
// question the analytics domain answers (`handled:false` → 404), so the
161+
// mount must not depend on the DEFAULT kernel's service registry.
162+
const { server, routes } = makeFakeServer();
163+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
164+
await plugin.start?.(ctxWithServices(server, {
165+
'kernel-resolver': { resolveKernel: async (_ctx: any, def: any) => def },
166+
}));
167+
168+
for (const r of ANALYTICS_ROUTES) expect(routes).toContain(r);
169+
});
115170
});
116171

117172
it('honours a custom prefix', async () => {

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -666,35 +666,67 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
666666

667667

668668
// ── Analytics ───────────────────────────────────────────────
669+
// [#3891 follow-through / ADR-0076 D11] The /analytics wire
670+
// surface exists only when the capability does. Phase-1 init has
671+
// completed by the time this start() runs, so in single-kernel
672+
// mode "is the `analytics` service registered" is authoritative:
673+
// absent ⇒ the routes are NOT mounted and the path answers the
674+
// adapter's shared not-found contract — no route-table entry, no
675+
// 405 Allow hint for an API that isn't there. A multi-tenant host
676+
// (kernel-resolver wired) mounts unconditionally: mounts are
677+
// host-global while the analytics service lives in the per-project
678+
// kernel, so presence is a per-request question — answered by the
679+
// analytics domain's existing `handled:false` → 404.
680+
//
669681
// Route via dispatch() (not handleAnalytics directly) so the host
670-
// dispatcher's project-aware kernel swap runs first — the per-project
671-
// kernel owns the `analytics` service (registered by ObjectQLPlugin).
672-
server.post(`${prefix}/analytics/query`, async (req: any, res: any) => {
682+
// dispatcher's project-aware kernel swap runs first — the
683+
// per-project kernel owns the `analytics` service.
684+
const analyticsInstalled = dispatcher.isMultiTenantHost() || await (async () => {
685+
const k: any = kernel;
673686
try {
674-
const result = await dispatcher.dispatch('POST', '/analytics/query', req.body, req.query, { request: req });
675-
sendResult(result, res);
676-
} catch (err: any) {
677-
errorResponse(err, res);
687+
if (k && typeof k.getServiceAsync === 'function') {
688+
const svc = await k.getServiceAsync('analytics').catch(() => undefined);
689+
if (svc) return true;
690+
}
691+
return !!k?.getService?.('analytics');
692+
} catch {
693+
return false;
678694
}
679-
});
695+
})();
680696

681-
server.get(`${prefix}/analytics/meta`, async (req: any, res: any) => {
682-
try {
683-
const result = await dispatcher.dispatch('GET', '/analytics/meta', undefined, req.query, { request: req });
684-
sendResult(result, res);
685-
} catch (err: any) {
686-
errorResponse(err, res);
687-
}
688-
});
697+
if (analyticsInstalled) {
698+
server.post(`${prefix}/analytics/query`, async (req: any, res: any) => {
699+
try {
700+
const result = await dispatcher.dispatch('POST', '/analytics/query', req.body, req.query, { request: req });
701+
sendResult(result, res);
702+
} catch (err: any) {
703+
errorResponse(err, res);
704+
}
705+
});
689706

690-
server.post(`${prefix}/analytics/sql`, async (req: any, res: any) => {
691-
try {
692-
const result = await dispatcher.dispatch('POST', '/analytics/sql', req.body, req.query, { request: req });
693-
sendResult(result, res);
694-
} catch (err: any) {
695-
errorResponse(err, res);
696-
}
697-
});
707+
server.get(`${prefix}/analytics/meta`, async (req: any, res: any) => {
708+
try {
709+
const result = await dispatcher.dispatch('GET', '/analytics/meta', undefined, req.query, { request: req });
710+
sendResult(result, res);
711+
} catch (err: any) {
712+
errorResponse(err, res);
713+
}
714+
});
715+
716+
server.post(`${prefix}/analytics/sql`, async (req: any, res: any) => {
717+
try {
718+
const result = await dispatcher.dispatch('POST', '/analytics/sql', req.body, req.query, { request: req });
719+
sendResult(result, res);
720+
} catch (err: any) {
721+
errorResponse(err, res);
722+
}
723+
});
724+
} else {
725+
ctx.logger?.info?.(
726+
'[dispatcher] /analytics not mounted — no `analytics` service is registered. ' +
727+
'Install @objectstack/service-analytics to enable the analytics API.',
728+
);
729+
}
698730

699731
// ── MCP (Streamable HTTP) + API keys (ADR-0036) ─────────────
700732
// Mounted explicitly (there is no catch-all) and routed through

packages/runtime/src/http-dispatcher.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,19 @@ export class HttpDispatcher {
282282
getRegisteredAiRoutes: () => (this.kernel as any)?.__aiRoutes,
283283
};
284284

285+
/**
286+
* Whether this dispatcher serves a multi-tenant host (a `kernel-resolver`
287+
* is wired, so each request may resolve to a different per-project
288+
* kernel). Consulted by the dispatcher plugin's capability-conditional
289+
* route mounting (#3891 follow-through): on a multi-tenant host, route
290+
* mounts are host-global while optional services live per project kernel,
291+
* so "is the capability installed" is a per-request question the domain
292+
* handler answers — never a mount-time one.
293+
*/
294+
isMultiTenantHost(): boolean {
295+
return !!this.kernelResolver;
296+
}
297+
285298
/**
286299
* ADR-0076 D11 step ③ — seed the domain registry with the domains lifted
287300
* out of the `dispatch()` if-chain. Bodies of the four service-backed

packages/runtime/src/route-ledger.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
109109
{ route: 'GET /discovery', domain: '/discovery', disposition: 'sdk', client: 'connect' },
110110

111111
// ── analytics ─────────────────────────────────────────────────────────────
112+
// Capability-conditional (#3891 follow-through): the plugin mounts these
113+
// only when an `analytics` service is registered (or on a multi-tenant
114+
// host). The ledger row is the SDK-expressibility claim, not a mount
115+
// guarantee — see the header's "A ROW HERE IS NOT A WIRE GUARANTEE".
112116
{ route: 'POST /analytics/query', domain: '/analytics', disposition: 'sdk', client: 'analytics.query' },
113117
{ route: 'GET /analytics/meta', domain: '/analytics', disposition: 'sdk', client: 'analytics.meta',
114118
note: 'optional ?cube= filter honored server-side; was mismatch — the client called /meta/:cube, a shape no server ever mounted (#3584)' },

0 commit comments

Comments
 (0)