Skip to content

Commit 8faaa0d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24036-ledger-b
2 parents b79759d + 27d7c2a commit 8faaa0d

6 files changed

Lines changed: 404 additions & 42 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`os serve --no-server` no longer reports an ordering fault for the datasource admin routes or for the unknown-hostname guard. A boot that composes no HTTP server used to print, in its boot diagnostics, `WARN [datasource-admin] route registration failed: [Kernel] Service 'http.server' not found (… a composition/ordering fault; registration order is not a contract.)`, although nothing was at fault. With `OS_ROOT_DOMAIN` set, the same boot also printed `WARN [unknown-hostname-guard] install failed` with the same kernel text. Each now prints its own degrade line, beside the same boot's other "not mounted" lines: `WARN [datasource-admin] http.server unavailable; REST routes not installed`, and `WARN [unknown-hostname-guard] http.server unavailable; guard not installed`.
6+
7+
What still warns: on a boot that does compose an HTTP server, a route registration that throws still prints `[datasource-admin] route registration failed` with the error, and a guard install that throws still prints `[unknown-hostname-guard] install failed` with the error. A composed boot mounts `/api/v1/datasources` and installs the hostname guard as before, including under `--no-server` when the config mounts its own `HonoServerPlugin`. Both plugins now read only the canonical `http.server` service. Their old fallback to the deprecated `http-server` alias never ran: on a boot without `http.server` the canonical read threw first. So every boot that mounted these routes or installed the guard before still does.
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #24022 — a boot with no HTTP server takes the datasource admin routes
5+
* plugin's own degrade line, never "route registration failed" with the
6+
* kernel's "composition/ordering fault" text.
7+
*
8+
* ## The defect
9+
*
10+
* `os serve <host config> --no-server --preset minimal` printed, in its boot
11+
* diagnostics, `WARN [datasource-admin] route registration failed: [Kernel]
12+
* Service 'http.server' not found (while plugin
13+
* 'com.objectstack.cli.datasource-admin-routes' was initializing — a
14+
* composition/ordering fault; …)`. Nothing was at fault: `--no-server` composed
15+
* no server on purpose. The plugin read the server with
16+
* `getService?.('http.server') ?? getService?.('http-server')`, and the
17+
* kernel's `getService` THROWS on a name nothing registered, so the `??` never
18+
* ran and the throw landed in the registration `catch`.
19+
*
20+
* ## What this file pins
21+
*
22+
* The plugin's `init` runs inside a REAL `ObjectKernel`, so the throw it must
23+
* survive is the kernel's own, with the kernel's own wording:
24+
*
25+
* 1. no server composed: one `[datasource-admin]` warning, the degrade line,
26+
* and no warning anywhere in the boot says "route registration failed" or
27+
* "ordering fault";
28+
* 2. CONTROL: a composed server gets the real routes under
29+
* `/api/v1/datasources`, and no `[datasource-admin]` warning;
30+
* 3. CONTROL: a composed server whose registration throws still warns
31+
* "route registration failed".
32+
*
33+
* The wiring inside `run()` is a source pin with comments masked, as
34+
* `serve-no-transport-announcements.test.ts` pins its call site. A request to
35+
* the mounted route on a real boot is pinned in
36+
* `serve-no-server-banner.integration.test.ts`.
37+
*/
38+
39+
import { afterEach, describe, expect, it, vi } from 'vitest';
40+
import { readFileSync } from 'node:fs';
41+
import { resolve } from 'node:path';
42+
import { fileURLToPath } from 'node:url';
43+
44+
import { ObjectKernel } from '@objectstack/core';
45+
import { registerDatasourceAdminRoutes } from '@objectstack/service-datasource';
46+
47+
// The repo's ONE code/prose separator: a comment naming the factory must not
48+
// answer for code that does not call it.
49+
import { maskComments } from '../../../../scripts/js-comment-mask.mjs';
50+
51+
import { createDatasourceAdminRoutesPlugin } from './serve.js';
52+
53+
/** …/packages/cli/src/commands — seeded from `import.meta.url`. */
54+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
55+
/** `serve.ts`'s CODE, with every comment span blanked. */
56+
const SERVE = maskComments(readFileSync(resolve(HERE, 'serve.ts'), 'utf8'));
57+
58+
/** The one service a kernel refuses to start without. Nothing here reads data. */
59+
const DATA_STUB = {
60+
name: 'com.example.fx24022.data-stub',
61+
version: '1.0.0',
62+
init: async (ctx: { registerService(name: string, service: unknown): void }) => { ctx.registerService('data', {}); },
63+
start: async () => {},
64+
};
65+
66+
/** An `IHttpServer` stand-in that records every route registered on it. */
67+
function recordingServer(opts: { refuse?: boolean } = {}) {
68+
const routes: string[] = [];
69+
const add = (method: string) => (path: string) => {
70+
if (opts.refuse) throw new Error('route table sealed');
71+
routes.push(`${method} ${path}`);
72+
};
73+
return { routes, server: { get: add('GET'), post: add('POST'), patch: add('PATCH'), delete: add('DELETE') } };
74+
}
75+
76+
/** A stand-in for the composed transport, under the Hono plugin's name, registering `http.server`. */
77+
function serverPlugin(server: unknown) {
78+
return {
79+
name: 'com.objectstack.server.hono',
80+
version: '1.0.0',
81+
init: async (ctx: { registerService(name: string, service: unknown): void }) => { ctx.registerService('http.server', server); },
82+
start: async () => {},
83+
};
84+
}
85+
86+
/**
87+
* Boot a real kernel holding the routes plugin (and `extra`, registered AFTER
88+
* it), and return every string the kernel logger was asked to `warn`. The
89+
* plugin's `ctx.logger` IS the kernel's logger, so the spy sees the plugin's
90+
* own lines as well as the kernel's.
91+
*/
92+
async function bootWarnings(
93+
registerRoutes: (server: any, ctx: any, basePath: string) => void,
94+
extra: Array<ReturnType<typeof serverPlugin>> = [],
95+
): Promise<{ warnings: string[] }> {
96+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
97+
const logger = (kernel as unknown as { logger: { warn: (...args: unknown[]) => void } }).logger;
98+
const warn = vi.spyOn(logger, 'warn');
99+
await kernel.use(DATA_STUB);
100+
await kernel.use(createDatasourceAdminRoutesPlugin(registerRoutes));
101+
for (const plugin of extra) await kernel.use(plugin);
102+
await kernel.bootstrap();
103+
const warnings = warn.mock.calls.map((args) => args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '));
104+
await kernel.shutdown();
105+
return { warnings };
106+
}
107+
108+
const ours = (warnings: string[]) => warnings.filter((w) => w.includes('[datasource-admin]'));
109+
110+
afterEach(() => {
111+
vi.restoreAllMocks();
112+
});
113+
114+
describe('createDatasourceAdminRoutesPlugin — a boot with no HTTP server (#24022)', () => {
115+
it('the kernel this runs in THROWS for an unregistered http.server — the premise the read must survive', () => {
116+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
117+
expect(() => kernel.getService('http.server')).toThrow(/not found/);
118+
});
119+
120+
it('takes its own degrade line: no "route registration failed", no "ordering fault"', async () => {
121+
const register = vi.fn();
122+
const { warnings } = await bootWarnings(register);
123+
const seen = `\n--- warnings ---\n${warnings.join('\n')}`;
124+
125+
expect(register, `routes registered on a boot with no server${seen}`).not.toHaveBeenCalled();
126+
expect(warnings.filter((w) => w.includes('route registration failed')), seen).toEqual([]);
127+
expect(warnings.filter((w) => w.includes('ordering fault')), seen).toEqual([]);
128+
// Kept on purpose (see the factory's docblock): one line naming the
129+
// surface left unmounted, beside the boot's other "not mounted" lines.
130+
expect(ours(warnings), `not exactly one [datasource-admin] line${seen}`).toHaveLength(1);
131+
});
132+
133+
it('asks for the canonical service name only — never the deprecated http-server alias', async () => {
134+
const asked: string[] = [];
135+
const warnings: string[] = [];
136+
await createDatasourceAdminRoutesPlugin(vi.fn()).init({
137+
getService: (name: string) => {
138+
asked.push(name);
139+
throw new Error(`[Kernel] Service '${name}' not found`);
140+
},
141+
logger: { warn: (msg: string) => { warnings.push(msg); } },
142+
});
143+
expect(asked).toEqual(['http.server']);
144+
expect(warnings).toHaveLength(1);
145+
});
146+
});
147+
148+
describe('createDatasourceAdminRoutesPlugin — a composed HTTP server (controls)', () => {
149+
it('CONTROL: mounts the real routes under /api/v1/datasources, ordered after the server plugin', async () => {
150+
const { routes, server } = recordingServer();
151+
// Registered AFTER the routes plugin: `optionalDependencies` orders it ahead.
152+
const { warnings } = await bootWarnings(registerDatasourceAdminRoutes, [serverPlugin(server)]);
153+
const seen = `\n--- warnings ---\n${warnings.join('\n')}`;
154+
155+
expect(routes, 'the list route was not mounted').toContain('GET /api/v1/datasources');
156+
expect(routes.every((r) => r.includes(' /api/v1/datasources')), routes.join('\n')).toBe(true);
157+
expect(ours(warnings), `a [datasource-admin] warning on a composed boot${seen}`).toEqual([]);
158+
});
159+
160+
it('CONTROL: a registration that genuinely throws still warns "route registration failed"', async () => {
161+
const { routes, server } = recordingServer({ refuse: true });
162+
const { warnings } = await bootWarnings(registerDatasourceAdminRoutes, [serverPlugin(server)]);
163+
const seen = `\n--- warnings ---\n${warnings.join('\n')}`;
164+
165+
expect(routes).toEqual([]);
166+
const said = ours(warnings);
167+
expect(said, `not exactly one [datasource-admin] line${seen}`).toHaveLength(1);
168+
expect(said[0], seen).toContain('route registration failed');
169+
expect(said[0], `the error itself is not named${seen}`).toContain('route table sealed');
170+
});
171+
});
172+
173+
describe('the wiring inside `run()` (#24022)', () => {
174+
it('serve mounts the routes through the factory, handing it the real registrar', () => {
175+
expect(SERVE).toContain('await kernel.use(createDatasourceAdminRoutesPlugin(registerDatasourceAdminRoutes));');
176+
expect(SERVE.match(/'com\.objectstack\.cli\.datasource-admin-routes'/g) ?? [], 'a second inline copy of the plugin').toHaveLength(1);
177+
});
178+
});

‎packages/cli/src/commands/serve-no-server-banner.integration.test.ts‎

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ import { fileURLToPath } from 'node:url';
3939
* The banner's unit shapes live in `utils/format.server-ready-no-http-server.test.ts`;
4040
* this file pins the WIRING: that serve hands the banner the kernel's answer.
4141
*
42+
* ## #24022 — the datasource admin routes, on the same boots
43+
*
44+
* Shapes 1 and 2 also pin that the boot diagnostics carry the datasource admin
45+
* routes plugin's own degrade line and never "route registration failed" or
46+
* "ordering fault": `--no-server` composed no server on purpose. Shapes 3 and
47+
* 4 pin the CONTROL: `/api/v1/datasources` is mounted (not 404, while an
48+
* unmounted sibling path is) and the plugin prints nothing, shape 4 on the
49+
* config's own server under `--no-server`. The plugin's unit shapes,
50+
* including a registration that genuinely throws, live in
51+
* `serve-datasource-admin-routes.test.ts`.
52+
*
4253
* ## Why `bin/run.js` with `NODE_ENV` unset
4354
*
4455
* Shapes 1, 3 and 4 are production posture, which `bin/run-dev.js` cannot
@@ -73,6 +84,8 @@ const ADDRESS_ROW = /\b(?:API|Console|MCP):/;
7384
* lines, which are not it.
7485
*/
7586
const NO_SERVER_LINE = /➜\s+No HTTP server:/;
87+
/** The datasource admin routes plugin's log prefix (#24022). */
88+
const DATASOURCE_ADMIN = '[datasource-admin]';
7689

7790
const AUTH_SECRET = 'os-23623-auth-secret-not-a-real-credential';
7891
const SECRET_KEY = '0e2e'.repeat(16);
@@ -247,6 +260,18 @@ function expectNoServerShape({ banner, stop }: Boot): void {
247260
expect(said[0], `the line does not name the flag that caused it${seen}`).toContain('--no-server');
248261
}
249262

263+
/**
264+
* #24022: a boot with no server takes the datasource admin routes plugin's own
265+
* degrade line, once, and no `[datasource-admin]` line reports a failed
266+
* registration or an ordering fault.
267+
*/
268+
function expectDatasourceAdminDegradeLine(output: string): void {
269+
const seen = `\n--- output ---\n${output}`;
270+
const said = output.split('\n').filter((l) => l.includes(DATASOURCE_ADMIN));
271+
expect(said.filter((l) => /route registration failed|ordering fault/.test(l)), `an ordering fault reported${seen}`).toEqual([]);
272+
expect(said, `not exactly one [datasource-admin] line${seen}`).toHaveLength(1);
273+
}
274+
250275
beforeAll(() => {
251276
if (!existsSync(BUILT_SERVE)) {
252277
throw new Error(
@@ -277,30 +302,39 @@ describe('os serve — the ready banner names an address only when an HTTP serve
277302
it(
278303
'production --no-server (the card\'s command): no address row, one line naming --no-server',
279304
async () => {
280-
expectNoServerShape(await boot(plainDir, {}, ['--no-server', '--preset', 'minimal']));
305+
const booted = await boot(plainDir, {}, ['--no-server', '--preset', 'minimal']);
306+
expectNoServerShape(booted);
307+
expectDatasourceAdminDegradeLine(booted.output);
281308
},
282309
240_000,
283310
);
284311

285312
it(
286313
'--dev --no-server: the same shape',
287314
async () => {
288-
expectNoServerShape(await boot(plainDir, {}, ['--dev', '--no-server']));
315+
const booted = await boot(plainDir, {}, ['--dev', '--no-server']);
316+
expectNoServerShape(booted);
317+
expectDatasourceAdminDegradeLine(booted.output);
289318
},
290319
240_000,
291320
);
292321

293322
it(
294323
'control: a default production boot prints API: and MCP:, and the API: port answers',
295324
async () => {
296-
const { banner, stop } = await boot(plainDir, { OS_DATABASE_URL: 'file:./ctl.db', OS_AUTH_SECRET: AUTH_SECRET }, []);
325+
const { banner, output, stop } = await boot(plainDir, { OS_DATABASE_URL: 'file:./ctl.db', OS_AUTH_SECRET: AUTH_SECRET }, []);
297326
const seen = `\n--- banner ---\n${banner}`;
298327
try {
299328
expect(banner, `the no-server line printed on a boot with a server${seen}`).not.toMatch(NO_SERVER_LINE);
300329
expect(banner, `no MCP row${seen}`).toMatch(/MCP:\s+http:\/\/localhost:\d+\/api\/v1\/mcp/);
301330
const port = apiPort(banner);
302331
expect(port, `no API row naming a localhost port${seen}`).not.toBeNull();
303332
expect(await statusOf(port as number, '/api/v1/health'), `the API: port did not answer health${seen}`).toBe(200);
333+
// #24022 CONTROL: the datasource admin routes still mount on a composed
334+
// boot. An unmounted sibling path answers 404, so "not 404" is a mount.
335+
expect(await statusOf(port as number, '/api/v1/datasources-not-a-route'), `unmounted paths no longer 404${seen}`).toBe(404);
336+
expect(await statusOf(port as number, '/api/v1/datasources'), `the datasource admin routes are not mounted${seen}`).not.toBe(404);
337+
expect(output.split('\n').filter((l) => l.includes(DATASOURCE_ADMIN)), `a [datasource-admin] warning on a composed boot${seen}`).toEqual([]);
304338
} finally {
305339
stop();
306340
}
@@ -311,7 +345,7 @@ describe('os serve — the ready banner names an address only when an HTTP serve
311345
it(
312346
'control: --no-server with a config-mounted HonoServerPlugin prints API:, and that port answers',
313347
async () => {
314-
const { banner, stop } = await boot(honoDir, { OS_DATABASE_URL: 'file:./ctl.db' }, ['--no-server', '--preset', 'minimal']);
348+
const { banner, output, stop } = await boot(honoDir, { OS_DATABASE_URL: 'file:./ctl.db' }, ['--no-server', '--preset', 'minimal']);
315349
const seen = `\n--- banner ---\n${banner}`;
316350
try {
317351
// ⭐ The read is off the kernel, not off the flag: the flag skipped serve's
@@ -321,6 +355,11 @@ describe('os serve — the ready banner names an address only when an HTTP serve
321355
expect(port, `no API row naming a localhost port${seen}`).not.toBeNull();
322356
// Any HTTP status proves a server listens there; `/` is not mounted on this shape.
323357
expect(await statusOf(port as number, '/'), `nothing answered on the API: port${seen}`).toBeGreaterThanOrEqual(100);
358+
// #24022 CONTROL: the same read decides the datasource admin routes, so
359+
// they mount on the config's server under `--no-server` too.
360+
expect(await statusOf(port as number, '/api/v1/datasources-not-a-route'), `unmounted paths no longer 404${seen}`).toBe(404);
361+
expect(await statusOf(port as number, '/api/v1/datasources'), `the datasource admin routes are not mounted${seen}`).not.toBe(404);
362+
expect(output.split('\n').filter((l) => l.includes(DATASOURCE_ADMIN)), `a [datasource-admin] warning on a composed boot${seen}`).toEqual([]);
324363
} finally {
325364
stop();
326365
}

0 commit comments

Comments
 (0)