Skip to content

Commit 27d7c2a

Browse files
fix(cli): a --no-server boot takes the datasource admin routes' own degrade line, not an ordering fault (#24132)
Fixes #24022 Clause-②: no ## What was wrong On a boot that composes no HTTP server, the boot diagnostics printed: ``` WARN [datasource-admin] route registration failed: [Kernel] Service 'http.server' not found (while plugin 'com.objectstack.cli.datasource-admin-routes' was initializing — a composition/ordering fault; registration order is not a contract.) ``` Nothing was at fault. `--no-server` composes no server on purpose. The plugin read the server with `ctx.getService?.('http.server') ?? ctx.getService?.('http-server')`. The kernel's `getService` throws on a name nothing registered (`packages/core/src/kernel.ts`, the `[Kernel] Service '...' not found` throw, with the init-order suffix from `describeInitOrderFault` in `plugin-order.ts`). So the `??` never ran. The throw landed in the registration `catch`, and the plugin's own degrade line was unreachable. ## The fix `packages/cli/src/commands/serve.ts` only, in two plugins: `com.objectstack.cli.datasource-admin-routes`, and its sibling `com.objectstack.cli.unknown-hostname-guard`: - The plugin moves out of `run()` into an exported factory, `createDatasourceAdminRoutesPlugin(registerRoutes)`, next to `createUnknownHostnameGuardPlugin`. That makes its `init` reachable from a test against a real kernel. `run()` now calls `kernel.use(createDatasourceAdminRoutesPlugin(registerDatasourceAdminRoutes))`. Name, version and `optionalDependencies` are unchanged. - `init` reads the server through the existing `readComposedHttpServer` (reused, not changed): `http.server` only, inside a `try`. No server: the plugin's own line, `[datasource-admin] http.server unavailable; REST routes not installed`. A server composed and registration throws: `route registration failed` with the error, as before. - The old fallback to the deprecated `http-server` alias was never reached: the canonical read threw first. Every in-repo producer of `http-server` (the Hono server plugin and the http-conformance node plugin) also registers `http.server`, the same instance. So every boot that mounted these routes before still mounts them. - The unknown-hostname guard (`createUnknownHostnameGuardPlugin`, composed when `OS_ROOT_DOMAIN` is set) had the same trap, so its `init` now reads the server through `readComposedHttpServer` too. It is a one-line hunk. The read stays inside the existing `try`, and `readComposedHttpServer` never throws. With no server, the boot now takes `[unknown-hostname-guard] http.server unavailable; guard not installed`. On a composed server whose install throws, it still warns `install failed` with the error. The claim's file surface was widened for this in comment `6228552643`. - `scripts/slot-lookup-baseline.json`: `serve.ts` 9 to 6, because both untyped two-lookup chains are gone (`check:slot-lookup` holds at 101 sites in 25 files). ## Pins - `packages/cli/src/commands/serve-datasource-admin-routes.test.ts` (unit tier, new). The plugin runs inside a real `ObjectKernel`, so the throw it has to survive is the kernel's own: 1. premise: the kernel throws for an unregistered `http.server`; 2. no server: no `route registration failed`, no `ordering fault`, exactly one `[datasource-admin]` line; 3. only `http.server` is asked for, never the alias; 4. CONTROL: a composed server gets the real routes, all under `/api/v1/datasources`, and no `[datasource-admin]` line; 5. CONTROL: a composed server whose registration throws still warns `route registration failed`, naming the error; 6. source pin, comments masked: `run()` mounts through the factory, and no second inline copy exists. - `packages/cli/src/commands/serve-no-server-banner.integration.test.ts` (integration tier), against the built CLI: - shapes 1 and 2 (production `--no-server --preset minimal`, and `--dev --no-server`) carry exactly one `[datasource-admin]` line, and it does not report a failed registration or an ordering fault; - shape 3 (a default boot) is the CONTROL: `/api/v1/datasources` is not 404, while an unmounted sibling path is 404, and no `[datasource-admin]` line prints; - shape 4 (`--no-server` with a config-mounted `HonoServerPlugin`) has the same control. It backs the changeset's claim that the routes still mount on that shape. This round adds it. - `packages/cli/src/commands/serve-unknown-hostname-guard.test.ts` (unit tier, the guard's existing file): 1. a real `ObjectKernel` boot with no server: no `install failed`, no `ordering fault`, and exactly one `[unknown-hostname-guard]` line, the degrade line; 2. CONTROL: a real kernel boot with a composed server, ordered ahead by `optionalDependencies`, logs `installed`, prints no `[unknown-hostname-guard]` warning, and refuses an unmapped host with 404 `ENVIRONMENT_NOT_FOUND`; 3. CONTROL: a usable server whose raw app refuses the middleware still warns `install failed`, naming the error; 4. the existing throwing service-resolution case now expects the degrade line instead of `install failed`. The kernel throws on an unregistered name, so a throwing read IS the no-server boot. ## Verification, all at head `6c33626a8` - Unit tier: `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` reads 309 files, 4591 tests, all passed, exit 0. - Integration, the touched file: `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 src/commands/serve-no-server-banner.integration.test.ts` reads 4 of 4 passed, exit 0. The other integration-tier files are declared to CI. - `pnpm --filter @objectstack/cli typecheck` exits 0, `check:test-typecheck` included. `tsc --noEmit --listFiles` compiles all three touched test files. - `pnpm exec eslint --no-inline-config` on the four changed `.ts` files exits 0 with no output. The `.md` and `.json` files are outside eslint's configured file set. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 75 commands on `6c33626a8`, and all 75 exit 0. `--ran` reads `75 derived, 75 run, 0 NOT-MEASURED, 0 UNRUN`. - The derivation warns that the tree is 3 commits behind `origin/main` `c1c0a9094`, where 3 family files changed: `check-route-envelope.mjs`, `engine-double-contract.pinned.json` and one `scripts/adr-anchors` file. Those families ran the branch's copies. CI's merge-ref run is the reading on main's copies. - Also run, outside the derived set: `check:startup-registry-verdict` (exit 0, 44 seams, none recording a verdict the boot can contradict). - At the door, against the built CLI, with `OS_ROOT_DOMAIN=example.test`: - `os serve objectstack.config.ts --no-server --preset minimal` prints `WARN [unknown-hostname-guard] http.server unavailable; guard not installed` and `WARN [datasource-admin] http.server unavailable; REST routes not installed`, and no ordering fault; - a default boot, and a `--no-server` boot whose config mounts its own `HonoServerPlugin`, each log `INFO [unknown-hostname-guard] installed`, with no guard warning. ### Ablation, round 1 (the datasource admin routes, at `a12bf2f9b`) The fix was committed first. The mutation, done with `scripts/ablation-replace.mjs`, puts the pre-fix read back: the throwing `getService` chain, inside the `try`. The anchor count went from 1 to 0, and the blob went from `a48fc3da93e4` to `95b1a98c5f93`. - Mutated leg: the CLI was rebuilt, and `ablation-dist-preflight @objectstack/cli 'readComposedHttpServer(ctx)' --absent` passed. - The unit file reads 1 failed, 5 passed. The failure is `takes its own degrade line`. - The integration file reads 2 failed, 2 passed. Shapes 1 and 2 failed, each showing the card's exact `route registration failed ... a composition/ordering fault` line. The two controls stayed green, as they should. - Restore leg: blob back to the HEAD blob, `git diff HEAD` empty, `git status --porcelain` empty. The CLI was rebuilt, and the preflight found the marker present again. Unit 6 of 6 and integration 4 of 4 passed. - Unit case 3 (only `http.server` asked) stays green under the mutation, because the old chain also never reached the alias once the canonical read threw. It guards against an alias read coming back. It does not discriminate this card's defect. ### Ablation, round 2 (the guard) The fix was committed first (`6c33626a8`). `scripts/ablation-replace.mjs` put the guard's pre-fix line back. The anchor count went from 1 to 0, and the blob went from `a080f01a90c5` to `a48fc3da93e4`, which is byte-identical to the previous head's `serve.ts`. The test imports its subject from source (`./serve.js`), so no rebuild sits in the path. - Mutated leg: the guard file reads 2 failed, 36 passed. Both failures are the cases that read a throwing service as no server: the real-kernel no-server boot, which received the exact `[unknown-hostname-guard] install failed ... a composition/ordering fault` line, and the throwing-resolution case. Both controls stayed green. - Restore leg: blob back to the HEAD blob, `git diff HEAD` empty, `git status --porcelain` empty. 38 of 38 passed. ## Handover This resumes the released claim's branch at `4b0a631575`. Every commit was re-read hunk by hunk: - kept `7177b4e8c` (the fix, and the new unit file, which landed in this commit, not in `2e52e010a`), `2e52e010a` (the integration cases) and `16be28cdd` (the baseline row, re-measured after this round's merge of `origin/main` `50ccfa669`); - corrected `acd285a2b`: the changeset's alias sentence now says the fallback never ran, instead of reading like a narrowing; - added the shape 4 control. All verification above was re-run in this round. ## Acceptance notes - The new unit file imports `serve.ts`, and on this shared box that import costs about 18.8 s (tests 14 ms). A sibling that imports the same module (`serve-unknown-hostname-guard.test.ts`) measured the same in one run, 36.6 s of import across the two files. No budget line is declared, since none of the three budget classes fits. This is a reading, not a gate. - `origin/main` moved to `c1c0a9094` after this branch's merge. The 3 commits touch `packages/rest`, `plugin-email`, `qa/dogfood`, `runtime` tests and `spec/liveness`, none of them under `packages/cli`. `git merge-tree` is clean, so they were not merged. - `packages/runtime/README.md` shows a server plugin registering only the deprecated `http-server` alias. Docs drift, noted here, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c1c0a90 commit 27d7c2a

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)