Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/24022-datasource-admin-no-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@objectstack/cli': patch
---

`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`.

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.
178 changes: 178 additions & 0 deletions packages/cli/src/commands/serve-datasource-admin-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #24022 — a boot with no HTTP server takes the datasource admin routes
* plugin's own degrade line, never "route registration failed" with the
* kernel's "composition/ordering fault" text.
*
* ## The defect
*
* `os serve <host config> --no-server --preset minimal` printed, in its boot
* diagnostics, `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; …)`. Nothing was at fault: `--no-server` composed
* no server on purpose. The plugin read the server with
* `getService?.('http.server') ?? getService?.('http-server')`, and the
* kernel's `getService` THROWS on a name nothing registered, so the `??` never
* ran and the throw landed in the registration `catch`.
*
* ## What this file pins
*
* The plugin's `init` runs inside a REAL `ObjectKernel`, so the throw it must
* survive is the kernel's own, with the kernel's own wording:
*
* 1. no server composed: one `[datasource-admin]` warning, the degrade line,
* and no warning anywhere in the boot says "route registration failed" or
* "ordering fault";
* 2. CONTROL: a composed server gets the real routes under
* `/api/v1/datasources`, and no `[datasource-admin]` warning;
* 3. CONTROL: a composed server whose registration throws still warns
* "route registration failed".
*
* The wiring inside `run()` is a source pin with comments masked, as
* `serve-no-transport-announcements.test.ts` pins its call site. A request to
* the mounted route on a real boot is pinned in
* `serve-no-server-banner.integration.test.ts`.
*/

import { afterEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import { ObjectKernel } from '@objectstack/core';
import { registerDatasourceAdminRoutes } from '@objectstack/service-datasource';

// The repo's ONE code/prose separator: a comment naming the factory must not
// answer for code that does not call it.
import { maskComments } from '../../../../scripts/js-comment-mask.mjs';

import { createDatasourceAdminRoutesPlugin } from './serve.js';

/** …/packages/cli/src/commands — seeded from `import.meta.url`. */
const HERE = resolve(fileURLToPath(import.meta.url), '..');
/** `serve.ts`'s CODE, with every comment span blanked. */
const SERVE = maskComments(readFileSync(resolve(HERE, 'serve.ts'), 'utf8'));

/** The one service a kernel refuses to start without. Nothing here reads data. */
const DATA_STUB = {
name: 'com.example.fx24022.data-stub',
version: '1.0.0',
init: async (ctx: { registerService(name: string, service: unknown): void }) => { ctx.registerService('data', {}); },
start: async () => {},
};

/** An `IHttpServer` stand-in that records every route registered on it. */
function recordingServer(opts: { refuse?: boolean } = {}) {
const routes: string[] = [];
const add = (method: string) => (path: string) => {
if (opts.refuse) throw new Error('route table sealed');
routes.push(`${method} ${path}`);
};
return { routes, server: { get: add('GET'), post: add('POST'), patch: add('PATCH'), delete: add('DELETE') } };
}

/** A stand-in for the composed transport, under the Hono plugin's name, registering `http.server`. */
function serverPlugin(server: unknown) {
return {
name: 'com.objectstack.server.hono',
version: '1.0.0',
init: async (ctx: { registerService(name: string, service: unknown): void }) => { ctx.registerService('http.server', server); },
start: async () => {},
};
}

/**
* Boot a real kernel holding the routes plugin (and `extra`, registered AFTER
* it), and return every string the kernel logger was asked to `warn`. The
* plugin's `ctx.logger` IS the kernel's logger, so the spy sees the plugin's
* own lines as well as the kernel's.
*/
async function bootWarnings(
registerRoutes: (server: any, ctx: any, basePath: string) => void,
extra: Array<ReturnType<typeof serverPlugin>> = [],
): Promise<{ warnings: string[] }> {
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
const logger = (kernel as unknown as { logger: { warn: (...args: unknown[]) => void } }).logger;
const warn = vi.spyOn(logger, 'warn');
await kernel.use(DATA_STUB);
await kernel.use(createDatasourceAdminRoutesPlugin(registerRoutes));
for (const plugin of extra) await kernel.use(plugin);
await kernel.bootstrap();
const warnings = warn.mock.calls.map((args) => args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '));
await kernel.shutdown();
return { warnings };
}

const ours = (warnings: string[]) => warnings.filter((w) => w.includes('[datasource-admin]'));

afterEach(() => {
vi.restoreAllMocks();
});

describe('createDatasourceAdminRoutesPlugin — a boot with no HTTP server (#24022)', () => {
it('the kernel this runs in THROWS for an unregistered http.server — the premise the read must survive', () => {
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
expect(() => kernel.getService('http.server')).toThrow(/not found/);
});

it('takes its own degrade line: no "route registration failed", no "ordering fault"', async () => {
const register = vi.fn();
const { warnings } = await bootWarnings(register);
const seen = `\n--- warnings ---\n${warnings.join('\n')}`;

expect(register, `routes registered on a boot with no server${seen}`).not.toHaveBeenCalled();
expect(warnings.filter((w) => w.includes('route registration failed')), seen).toEqual([]);
expect(warnings.filter((w) => w.includes('ordering fault')), seen).toEqual([]);
// Kept on purpose (see the factory's docblock): one line naming the
// surface left unmounted, beside the boot's other "not mounted" lines.
expect(ours(warnings), `not exactly one [datasource-admin] line${seen}`).toHaveLength(1);
});

it('asks for the canonical service name only — never the deprecated http-server alias', async () => {
const asked: string[] = [];
const warnings: string[] = [];
await createDatasourceAdminRoutesPlugin(vi.fn()).init({
getService: (name: string) => {
asked.push(name);
throw new Error(`[Kernel] Service '${name}' not found`);
},
logger: { warn: (msg: string) => { warnings.push(msg); } },
});
expect(asked).toEqual(['http.server']);
expect(warnings).toHaveLength(1);
});
});

describe('createDatasourceAdminRoutesPlugin — a composed HTTP server (controls)', () => {
it('CONTROL: mounts the real routes under /api/v1/datasources, ordered after the server plugin', async () => {
const { routes, server } = recordingServer();
// Registered AFTER the routes plugin: `optionalDependencies` orders it ahead.
const { warnings } = await bootWarnings(registerDatasourceAdminRoutes, [serverPlugin(server)]);
const seen = `\n--- warnings ---\n${warnings.join('\n')}`;

expect(routes, 'the list route was not mounted').toContain('GET /api/v1/datasources');
expect(routes.every((r) => r.includes(' /api/v1/datasources')), routes.join('\n')).toBe(true);
expect(ours(warnings), `a [datasource-admin] warning on a composed boot${seen}`).toEqual([]);
});

it('CONTROL: a registration that genuinely throws still warns "route registration failed"', async () => {
const { routes, server } = recordingServer({ refuse: true });
const { warnings } = await bootWarnings(registerDatasourceAdminRoutes, [serverPlugin(server)]);
const seen = `\n--- warnings ---\n${warnings.join('\n')}`;

expect(routes).toEqual([]);
const said = ours(warnings);
expect(said, `not exactly one [datasource-admin] line${seen}`).toHaveLength(1);
expect(said[0], seen).toContain('route registration failed');
expect(said[0], `the error itself is not named${seen}`).toContain('route table sealed');
});
});

describe('the wiring inside `run()` (#24022)', () => {
it('serve mounts the routes through the factory, handing it the real registrar', () => {
expect(SERVE).toContain('await kernel.use(createDatasourceAdminRoutesPlugin(registerDatasourceAdminRoutes));');
expect(SERVE.match(/'com\.objectstack\.cli\.datasource-admin-routes'/g) ?? [], 'a second inline copy of the plugin').toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ import { fileURLToPath } from 'node:url';
* The banner's unit shapes live in `utils/format.server-ready-no-http-server.test.ts`;
* this file pins the WIRING: that serve hands the banner the kernel's answer.
*
* ## #24022 — the datasource admin routes, on the same boots
*
* Shapes 1 and 2 also pin that the boot diagnostics carry the datasource admin
* routes plugin's own degrade line and never "route registration failed" or
* "ordering fault": `--no-server` composed no server on purpose. Shapes 3 and
* 4 pin the CONTROL: `/api/v1/datasources` is mounted (not 404, while an
* unmounted sibling path is) and the plugin prints nothing, shape 4 on the
* config's own server under `--no-server`. The plugin's unit shapes,
* including a registration that genuinely throws, live in
* `serve-datasource-admin-routes.test.ts`.
*
* ## Why `bin/run.js` with `NODE_ENV` unset
*
* Shapes 1, 3 and 4 are production posture, which `bin/run-dev.js` cannot
Expand Down Expand Up @@ -73,6 +84,8 @@ const ADDRESS_ROW = /\b(?:API|Console|MCP):/;
* lines, which are not it.
*/
const NO_SERVER_LINE = /➜\s+No HTTP server:/;
/** The datasource admin routes plugin's log prefix (#24022). */
const DATASOURCE_ADMIN = '[datasource-admin]';

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

/**
* #24022: a boot with no server takes the datasource admin routes plugin's own
* degrade line, once, and no `[datasource-admin]` line reports a failed
* registration or an ordering fault.
*/
function expectDatasourceAdminDegradeLine(output: string): void {
const seen = `\n--- output ---\n${output}`;
const said = output.split('\n').filter((l) => l.includes(DATASOURCE_ADMIN));
expect(said.filter((l) => /route registration failed|ordering fault/.test(l)), `an ordering fault reported${seen}`).toEqual([]);
expect(said, `not exactly one [datasource-admin] line${seen}`).toHaveLength(1);
}

beforeAll(() => {
if (!existsSync(BUILT_SERVE)) {
throw new Error(
Expand Down Expand Up @@ -277,30 +302,39 @@ describe('os serve — the ready banner names an address only when an HTTP serve
it(
'production --no-server (the card\'s command): no address row, one line naming --no-server',
async () => {
expectNoServerShape(await boot(plainDir, {}, ['--no-server', '--preset', 'minimal']));
const booted = await boot(plainDir, {}, ['--no-server', '--preset', 'minimal']);
expectNoServerShape(booted);
expectDatasourceAdminDegradeLine(booted.output);
},
240_000,
);

it(
'--dev --no-server: the same shape',
async () => {
expectNoServerShape(await boot(plainDir, {}, ['--dev', '--no-server']));
const booted = await boot(plainDir, {}, ['--dev', '--no-server']);
expectNoServerShape(booted);
expectDatasourceAdminDegradeLine(booted.output);
},
240_000,
);

it(
'control: a default production boot prints API: and MCP:, and the API: port answers',
async () => {
const { banner, stop } = await boot(plainDir, { OS_DATABASE_URL: 'file:./ctl.db', OS_AUTH_SECRET: AUTH_SECRET }, []);
const { banner, output, stop } = await boot(plainDir, { OS_DATABASE_URL: 'file:./ctl.db', OS_AUTH_SECRET: AUTH_SECRET }, []);
const seen = `\n--- banner ---\n${banner}`;
try {
expect(banner, `the no-server line printed on a boot with a server${seen}`).not.toMatch(NO_SERVER_LINE);
expect(banner, `no MCP row${seen}`).toMatch(/MCP:\s+http:\/\/localhost:\d+\/api\/v1\/mcp/);
const port = apiPort(banner);
expect(port, `no API row naming a localhost port${seen}`).not.toBeNull();
expect(await statusOf(port as number, '/api/v1/health'), `the API: port did not answer health${seen}`).toBe(200);
// #24022 CONTROL: the datasource admin routes still mount on a composed
// boot. An unmounted sibling path answers 404, so "not 404" is a mount.
expect(await statusOf(port as number, '/api/v1/datasources-not-a-route'), `unmounted paths no longer 404${seen}`).toBe(404);
expect(await statusOf(port as number, '/api/v1/datasources'), `the datasource admin routes are not mounted${seen}`).not.toBe(404);
expect(output.split('\n').filter((l) => l.includes(DATASOURCE_ADMIN)), `a [datasource-admin] warning on a composed boot${seen}`).toEqual([]);
} finally {
stop();
}
Expand All @@ -311,7 +345,7 @@ describe('os serve — the ready banner names an address only when an HTTP serve
it(
'control: --no-server with a config-mounted HonoServerPlugin prints API:, and that port answers',
async () => {
const { banner, stop } = await boot(honoDir, { OS_DATABASE_URL: 'file:./ctl.db' }, ['--no-server', '--preset', 'minimal']);
const { banner, output, stop } = await boot(honoDir, { OS_DATABASE_URL: 'file:./ctl.db' }, ['--no-server', '--preset', 'minimal']);
const seen = `\n--- banner ---\n${banner}`;
try {
// ⭐ The read is off the kernel, not off the flag: the flag skipped serve's
Expand All @@ -321,6 +355,11 @@ describe('os serve — the ready banner names an address only when an HTTP serve
expect(port, `no API row naming a localhost port${seen}`).not.toBeNull();
// Any HTTP status proves a server listens there; `/` is not mounted on this shape.
expect(await statusOf(port as number, '/'), `nothing answered on the API: port${seen}`).toBeGreaterThanOrEqual(100);
// #24022 CONTROL: the same read decides the datasource admin routes, so
// they mount on the config's server under `--no-server` too.
expect(await statusOf(port as number, '/api/v1/datasources-not-a-route'), `unmounted paths no longer 404${seen}`).toBe(404);
expect(await statusOf(port as number, '/api/v1/datasources'), `the datasource admin routes are not mounted${seen}`).not.toBe(404);
expect(output.split('\n').filter((l) => l.includes(DATASOURCE_ADMIN)), `a [datasource-admin] warning on a composed boot${seen}`).toEqual([]);
} finally {
stop();
}
Expand Down
Loading
Loading