From 7177b4e8cc51727feb5925d053985154fc8bdb17 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 21:49:54 +0000 Subject: [PATCH 1/6] fix(cli): a --no-server boot no longer reports a datasource-admin ordering fault The datasource admin routes plugin read the HTTP server with getService?.('http.server') ?? getService?.('http-server'). The kernel's getService throws on a name nothing registered, so on a boot with no server the ?? never ran and the throw landed in the registration catch, which printed "route registration failed" with the kernel's "composition/ordering fault" text when nothing was at fault. The plugin moves to an exported factory, createDatasourceAdminRoutesPlugin, and reads the server through readComposedHttpServer (http.server only, in a try). A boot with no server now takes the plugin's own degrade line; a composed boot whose registration genuinely throws still warns "route registration failed". Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude --- .../serve-datasource-admin-routes.test.ts | 178 ++++++++++++++++++ packages/cli/src/commands/serve.ts | 88 ++++++--- 2 files changed, 236 insertions(+), 30 deletions(-) create mode 100644 packages/cli/src/commands/serve-datasource-admin-routes.test.ts diff --git a/packages/cli/src/commands/serve-datasource-admin-routes.test.ts b/packages/cli/src/commands/serve-datasource-admin-routes.test.ts new file mode 100644 index 00000000000..903f185c22b --- /dev/null +++ b/packages/cli/src/commands/serve-datasource-admin-routes.test.ts @@ -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 --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> = [], +): 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); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index ee844f32bdf..187c71b9c48 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -5104,36 +5104,11 @@ export default class Serve extends Command { ); trackPlugin('DatasourceAdminServicePlugin'); - // REST routes under /api/v1/datasources. Registered via a tiny - // plugin so it resolves http.server during init (same pattern as - // the hostname guard above). - const adminRoutePlugin: any = { - name: 'com.objectstack.cli.datasource-admin-routes', - version: '1.0.0', - // init() resolves the `http.server` service the hono server plugin - // provides — order-if-present so route registration is - // deterministic (ADR-0116, #4471). Soft: without a server plugin - // the routes degrade on purpose (warn + not installed). - optionalDependencies: ['com.objectstack.server.hono'], - init: async (ctx: any) => { - try { - const httpServer: any = - ctx.getService?.('http.server') ?? ctx.getService?.('http-server'); - if (!httpServer || typeof httpServer.get !== 'function') { - ctx.logger?.warn?.( - '[datasource-admin] http.server unavailable; REST routes not installed', - ); - return; - } - registerDatasourceAdminRoutes(httpServer, ctx, '/api/v1'); - } catch (routeErr: any) { - ctx.logger?.warn?.( - `[datasource-admin] route registration failed: ${routeErr?.message ?? routeErr}`, - ); - } - }, - }; - await kernel.use(adminRoutePlugin); + // REST routes under /api/v1/datasources, mounted by a tiny plugin + // during its init (same pattern as the hostname guard above). What + // it reads, and which warning a boot without a server gets, lives + // on `createDatasourceAdminRoutesPlugin()` below. + await kernel.use(createDatasourceAdminRoutesPlugin(registerDatasourceAdminRoutes)); trackPlugin('DatasourceAdminRoutes'); if (isDev) { @@ -5790,6 +5765,59 @@ export default class Serve extends Command { } +/** + * The plugin that mounts the datasource admin REST routes (`/api/v1/datasources`, + * `registerDatasourceAdminRoutes` from `@objectstack/service-datasource`) onto + * the composed HTTP server, from its own `init()`. Exported (#24022) so that + * `init` is reachable from a test against a real kernel, as + * {@link createUnknownHostnameGuardPlugin}'s is. + * + * ## The read + * + * The server is read through {@link readComposedHttpServer}: `http.server` + * only, inside a `try`, because the kernel's `getService` THROWS on a name + * nothing registered. The `getService?.(…) ?? getService?.(…)` chain this + * replaced never reached its `??`: on a boot with no server (`--no-server`) + * the throw landed in the registration `catch`, which printed "route + * registration failed" with the kernel's "composition/ordering fault" text + * when nothing was at fault. + * + * ## Which warning prints, and when + * + * - No server composed: the plugin's own degrade line, once. It is kept under + * `--no-server` on purpose. The banner says why there is no server; this + * line names the surface that leaves unmounted, beside the same boot's + * other "not mounted" lines (REST API, runtime config, marketplace + * install). A config can still compose its own server under that flag, and + * then the routes mount. It never says "ordering fault". + * - A server composed, and registration throws: "route registration failed" + * with the error. That is a genuine fault and stays loud. + * + * `optionalDependencies` orders the Hono server plugin ahead when it is + * composed (ADR-0116), so an absent server at this `init` is final for it. + */ +export function createDatasourceAdminRoutesPlugin( + registerRoutes: (server: any, ctx: any, basePath: string) => void, +) { + return { + name: 'com.objectstack.cli.datasource-admin-routes', + version: '1.0.0', + optionalDependencies: ['com.objectstack.server.hono'], + init: async (ctx: any): Promise => { + const httpServer: any = readComposedHttpServer(ctx); + if (!httpServer || typeof httpServer.get !== 'function') { + ctx.logger?.warn?.('[datasource-admin] http.server unavailable; REST routes not installed'); + return; + } + try { + registerRoutes(httpServer, ctx, '/api/v1'); + } catch (routeErr: any) { + ctx.logger?.warn?.(`[datasource-admin] route registration failed: ${routeErr?.message ?? routeErr}`); + } + }, + }; +} + /** * What {@link createUnknownHostnameGuardPlugin} is constructed with. */ From 2e52e010a1497c9e0ccbf2993cc52f6aca7a90c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 21:53:07 +0000 Subject: [PATCH 2/6] test(cli): pin the datasource-admin degrade line on real --no-server boots The production and --dev --no-server boots of serve-no-server-banner carry exactly one [datasource-admin] line and none reporting a failed registration or an ordering fault; the default production boot (the control) still mounts /api/v1/datasources (not 404, while an unmounted sibling path is) and prints no [datasource-admin] line. Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude --- ...serve-no-server-banner.integration.test.ts | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/serve-no-server-banner.integration.test.ts b/packages/cli/src/commands/serve-no-server-banner.integration.test.ts index 8eb37ce7cd6..d5b86f70bdd 100644 --- a/packages/cli/src/commands/serve-no-server-banner.integration.test.ts +++ b/packages/cli/src/commands/serve-no-server-banner.integration.test.ts @@ -39,6 +39,16 @@ 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. Shape 3 pins + * the CONTROL: `/api/v1/datasources` is mounted (not 404, while an unmounted + * sibling path is) and the plugin prints nothing. 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 @@ -73,6 +83,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); @@ -247,6 +259,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( @@ -277,7 +301,9 @@ 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, ); @@ -285,7 +311,9 @@ describe('os serve — the ready banner names an address only when an HTTP serve 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, ); @@ -293,7 +321,7 @@ describe('os serve — the ready banner names an address only when an HTTP serve 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); @@ -301,6 +329,11 @@ describe('os serve — the ready banner names an address only when an HTTP serve 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(); } From acd285a2b9908a590b902c889cfb365ca0c4f1d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 21:53:59 +0000 Subject: [PATCH 3/6] chore(changeset): @objectstack/cli patch for the --no-server datasource-admin line Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude --- .changeset/24022-datasource-admin-no-server.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/24022-datasource-admin-no-server.md diff --git a/.changeset/24022-datasource-admin-no-server.md b/.changeset/24022-datasource-admin-no-server.md new file mode 100644 index 00000000000..84c46cba7bd --- /dev/null +++ b/.changeset/24022-datasource-admin-no-server.md @@ -0,0 +1,7 @@ +--- +'@objectstack/cli': patch +--- + +`os serve --no-server` no longer reports an ordering fault for the datasource admin routes. 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. It now prints the routes' own degrade line, `WARN [datasource-admin] http.server unavailable; REST routes not installed`, beside the same boot's other "not mounted" lines. + +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. A composed boot mounts `/api/v1/datasources` as before, including under `--no-server` when the config mounts its own `HonoServerPlugin`. The plugin now reads only the canonical `http.server` service, not the deprecated `http-server` alias; every HTTP server this repository composes registers `http.server`. From 16be28cdd4f3392e90f9ca442211313aee44d4a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 22:23:03 +0000 Subject: [PATCH 4/6] chore(slot-lookup): ratchet serve.ts down 9 -> 7 The datasource admin routes plugin no longer reads the server through the untyped getService?.() ?? getService?.() chain. Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude --- scripts/slot-lookup-baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/slot-lookup-baseline.json b/scripts/slot-lookup-baseline.json index 31159402e3c..5c0a9ff5199 100644 --- a/scripts/slot-lookup-baseline.json +++ b/scripts/slot-lookup-baseline.json @@ -1,7 +1,7 @@ { "packages/cli/src/commands/migrate/files-to-references.ts": 1, "packages/cli/src/commands/migrate/value-shapes.ts": 1, - "packages/cli/src/commands/serve.ts": 9, + "packages/cli/src/commands/serve.ts": 7, "packages/client/src/client.hono.test.ts": 2, "packages/cloud-connection/src/cloud-connection-plugin.ts": 5, "packages/cloud-connection/src/marketplace-install-local-plugin.ts": 14, From a12bf2f9b2704386626d7841b0b60abaf9f363f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 01:59:01 +0000 Subject: [PATCH 5/6] test(cli): pin the datasource admin routes on a config-mounted server under --no-server The changeset says a --no-server boot whose config mounts its own HonoServerPlugin still mounts /api/v1/datasources. Shape 4 of the banner integration file now measures it: the sibling path answers 404, the routes path does not, and no [datasource-admin] line prints. The changeset's alias sentence now says the old http-server fallback never ran, so no boot that mounted the routes before stops mounting them. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf --- .changeset/24022-datasource-admin-no-server.md | 2 +- .../serve-no-server-banner.integration.test.ts | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.changeset/24022-datasource-admin-no-server.md b/.changeset/24022-datasource-admin-no-server.md index 84c46cba7bd..85c6cdfd9e2 100644 --- a/.changeset/24022-datasource-admin-no-server.md +++ b/.changeset/24022-datasource-admin-no-server.md @@ -4,4 +4,4 @@ `os serve --no-server` no longer reports an ordering fault for the datasource admin routes. 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. It now prints the routes' own degrade line, `WARN [datasource-admin] http.server unavailable; REST routes not installed`, beside the same boot's other "not mounted" lines. -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. A composed boot mounts `/api/v1/datasources` as before, including under `--no-server` when the config mounts its own `HonoServerPlugin`. The plugin now reads only the canonical `http.server` service, not the deprecated `http-server` alias; every HTTP server this repository composes registers `http.server`. +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. A composed boot mounts `/api/v1/datasources` as before, including under `--no-server` when the config mounts its own `HonoServerPlugin`. The plugin now reads only the canonical `http.server` service. Its 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 before still mounts them. diff --git a/packages/cli/src/commands/serve-no-server-banner.integration.test.ts b/packages/cli/src/commands/serve-no-server-banner.integration.test.ts index d5b86f70bdd..887ae71d7e1 100644 --- a/packages/cli/src/commands/serve-no-server-banner.integration.test.ts +++ b/packages/cli/src/commands/serve-no-server-banner.integration.test.ts @@ -43,9 +43,10 @@ import { fileURLToPath } from 'node:url'; * * 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. Shape 3 pins - * the CONTROL: `/api/v1/datasources` is mounted (not 404, while an unmounted - * sibling path is) and the plugin prints nothing. The plugin's unit shapes, + * "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`. * @@ -344,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 @@ -354,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(); } From 6c33626a8df327db1be7cdf94c11ca8508fc5c87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 02:41:10 +0000 Subject: [PATCH 6/6] fix(cli): a --no-server boot no longer reports an unknown-hostname guard ordering fault The unknown-hostname guard read the HTTP server with getService?.('http.server') ?? getService?.('http-server'), the same trap as the datasource admin routes: the kernel's getService throws on a name nothing registered, so on a boot with no server (OS_ROOT_DOMAIN set, --no-server) the throw landed in the install catch and printed "install failed" with the kernel's "composition/ordering fault" text. init now reads the server through readComposedHttpServer, so that boot takes the guard's own degrade line, and an install that throws on a composed server still warns "install failed". Pins in serve-unknown-hostname-guard.test.ts: a real ObjectKernel boot with no server (the degrade line once, no "install failed", no "ordering fault"), its composed control (installed, and refusing an unmapped host), and an install failure on a usable server still warning "install failed". The throwing service-resolution case now expects the degrade line. slot-lookup: serve.ts 7 -> 6. The changeset names the guard's line too. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf --- .../24022-datasource-admin-no-server.md | 4 +- .../serve-unknown-hostname-guard.test.ts | 122 +++++++++++++++++- packages/cli/src/commands/serve.ts | 2 +- scripts/slot-lookup-baseline.json | 2 +- 4 files changed, 120 insertions(+), 10 deletions(-) diff --git a/.changeset/24022-datasource-admin-no-server.md b/.changeset/24022-datasource-admin-no-server.md index 85c6cdfd9e2..fc097506db6 100644 --- a/.changeset/24022-datasource-admin-no-server.md +++ b/.changeset/24022-datasource-admin-no-server.md @@ -2,6 +2,6 @@ '@objectstack/cli': patch --- -`os serve --no-server` no longer reports an ordering fault for the datasource admin routes. 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. It now prints the routes' own degrade line, `WARN [datasource-admin] http.server unavailable; REST routes not installed`, beside the same boot's other "not mounted" lines. +`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. A composed boot mounts `/api/v1/datasources` as before, including under `--no-server` when the config mounts its own `HonoServerPlugin`. The plugin now reads only the canonical `http.server` service. Its 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 before still mounts them. +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. diff --git a/packages/cli/src/commands/serve-unknown-hostname-guard.test.ts b/packages/cli/src/commands/serve-unknown-hostname-guard.test.ts index cb2e2505035..3499fe2cd7d 100644 --- a/packages/cli/src/commands/serve-unknown-hostname-guard.test.ts +++ b/packages/cli/src/commands/serve-unknown-hostname-guard.test.ts @@ -45,9 +45,21 @@ * exported constants the middleware itself branches on, so a subdomain or a * probe path added to the guard is covered the moment it is added rather than * the day someone remembers to copy it here. + * + * ## #24022 — a boot with no HTTP server takes the degrade line + * + * `OS_ROOT_DOMAIN=… os serve --no-server` printed `[unknown-hostname-guard] + * install failed` with the kernel's "composition/ordering fault" text, though + * nothing was at fault: `init` read the server with a `getService?.(…) ?? + * getService?.(…)` chain, and the kernel's `getService` THROWS on a name + * nothing registered, so the throw landed in the install `catch`. `init` now + * reads through `readComposedHttpServer`. The last describe block boots a REAL + * `ObjectKernel`, so the throw it must survive is the kernel's own, and pins + * the composed control beside it. */ import { describe, it, expect, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; import { HonoHttpServer } from '@objectstack/plugin-hono-server'; import { @@ -85,8 +97,11 @@ function mountGuard(opts: { knownHosts?: string[]; /** Replace the env-registry with something broken, to pin the fall-throughs. */ registryOverride?: unknown; - /** Force the `http.server` resolution to fail the way production tolerates. */ - httpServer?: 'missing' | 'no-raw-app' | 'throws'; + /** + * Force the `http.server` resolution to fail the way production tolerates. + * `install-throws`: a usable server whose raw app refuses the middleware. + */ + httpServer?: 'missing' | 'no-raw-app' | 'throws' | 'install-throws'; } = {}): Promise { const server = new HonoHttpServer(0); const rawApp = server.getRawApp(); @@ -110,7 +125,9 @@ function mountGuard(opts: { ? undefined : opts.httpServer === 'no-raw-app' ? { getRawApp: () => ({}) } - : { getRawApp: () => rawApp }, + : opts.httpServer === 'install-throws' + ? { getRawApp: () => ({ use: () => { throw new Error('middleware table sealed'); } }) } + : { getRawApp: () => rawApp }, 'env-registry': registry ?? undefined, }; @@ -120,7 +137,7 @@ function mountGuard(opts: { return services[name]; }, logger: { - warn: (msg: string) => { warnings.push(msg); }, + warn: (msg: string, meta?: unknown) => { warnings.push(meta === undefined ? msg : `${msg} ${JSON.stringify(meta)}`); }, info: (msg: string) => { infos.push(msg); }, }, }; @@ -379,10 +396,22 @@ describe('createUnknownHostnameGuardPlugin — install is soft, never fatal', () expect(h.warnings.join('\n')).toContain('http.server unavailable'); }); - it('never throws out of init when service resolution fails', async () => { + it('never throws out of init when service resolution throws, and reads the throw as no server (#24022)', async () => { + // The kernel's `getService` throws on a name nothing registered, so a + // throwing read IS the no-server boot: the degrade line, not "install failed". const h = await mountGuard({ httpServer: 'throws' }); - expect(h.warnings.join('\n')).toContain('install failed'); + expect(h.warnings.join('\n')).toContain('http.server unavailable'); + expect(h.warnings.join('\n')).not.toContain('install failed'); + expect((await h.call('/api/v1/objects', 'nobody.objectos.ai')).status).toBe(200); + }); + + it('CONTROL: a composed server whose raw app refuses the middleware still warns "install failed", naming the error', async () => { + const h = await mountGuard({ httpServer: 'install-throws' }); + + expect(h.warnings).toHaveLength(1); + expect(h.warnings[0]).toContain('install failed'); + expect(h.warnings[0]).toContain('middleware table sealed'); }); it('logs the install once the middleware is mounted', async () => { @@ -469,3 +498,84 @@ describe('createUnknownHostnameGuardPlugin — install order is the contract', ( expect(asked).toEqual(['nobody.objectos.ai']); }); }); + +/** + * #24022, on a REAL `ObjectKernel`: the throw `init` has to survive is the + * kernel's own, with the kernel's own "composition/ordering fault" wording. The + * plugin's `ctx.logger` IS the kernel's logger, so a spy on it sees the plugin's + * lines as well as the kernel's. + */ +describe('createUnknownHostnameGuardPlugin — a real kernel boot (#24022)', () => { + /** 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 () => {}, + }; + + /** Boot the guard (and `extra`, registered AFTER it); every line the kernel logger was asked to warn or info. */ + async function bootGuard(extra: Array = []) { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + const logger = (kernel as unknown as { logger: { warn: (...args: unknown[]) => void; info: (...args: unknown[]) => void } }).logger; + const warn = vi.spyOn(logger, 'warn'); + const info = vi.spyOn(logger, 'info'); + const lines = (spy: typeof warn) => spy.mock.calls.map((args) => args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')); + await kernel.use(DATA_STUB); + await kernel.use(createUnknownHostnameGuardPlugin({ rootDomain: ROOT_DOMAIN, readCloudUrl: () => '' })); + for (const plugin of extra) await kernel.use(plugin); + await kernel.bootstrap(); + return { + warnings: () => lines(warn), + infos: () => lines(info), + stop: async () => { await kernel.shutdown(); warn.mockRestore(); info.mockRestore(); }, + }; + } + + const ours = (lines: string[]) => lines.filter((l) => l.includes('[unknown-hostname-guard]')); + + it('no server composed: the degrade line, once — no "install failed", no "ordering fault"', async () => { + const boot = await bootGuard(); + try { + const warnings = boot.warnings(); + const seen = `\n--- warnings ---\n${warnings.join('\n')}`; + expect(warnings.filter((w) => w.includes('install failed')), seen).toEqual([]); + expect(warnings.filter((w) => w.includes('ordering fault')), seen).toEqual([]); + const said = ours(warnings); + expect(said, `not exactly one [unknown-hostname-guard] line${seen}`).toHaveLength(1); + expect(said[0], seen).toContain('http.server unavailable; guard not installed'); + } finally { + await boot.stop(); + } + }); + + it('CONTROL: a composed server, ordered ahead by optionalDependencies, gets the guard installed and refusing', async () => { + const server = new HonoHttpServer(0); + const rawApp = server.getRawApp(); + const serverPlugin = { + name: 'com.objectstack.server.hono', + version: '1.0.0', + init: async (ctx: { registerService(name: string, service: unknown): void }) => { + ctx.registerService('http.server', server); + ctx.registerService('env-registry', { resolveByHostname: async () => null }); + }, + start: async () => {}, + }; + const boot = await bootGuard([serverPlugin]); + try { + const seen = `\n--- warnings ---\n${boot.warnings().join('\n')}`; + expect(ours(boot.warnings()), `an [unknown-hostname-guard] warning on a composed boot${seen}`).toEqual([]); + expect(ours(boot.infos()).some((l) => l.includes('installed')), seen).toBe(true); + + // Registered after the guard, as every route-registering plugin is. + rawApp.all('*', (c: { text: (body: string, status: number) => Response }) => c.text(PASSED_THROUGH, 200)); + const res = await rawApp.fetch(new Request('http://placeholder/api/v1/objects', { + headers: { host: 'nobody.objectos.ai', accept: 'application/json' }, + })); + expect(res.status).toBe(404); + expect((await res.json() as RefusalBody).error?.code).toBe('ENVIRONMENT_NOT_FOUND'); + } finally { + await boot.stop(); + } + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index a48fc3da93e..a080f01a90c 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -5953,7 +5953,7 @@ export function createUnknownHostnameGuardPlugin( optionalDependencies: ['com.objectstack.server.hono'], init: async (ctx: any) => { try { - const httpServer: any = ctx.getService?.('http.server') ?? ctx.getService?.('http-server'); + const httpServer: any = readComposedHttpServer(ctx); const rawApp = httpServer?.getRawApp?.(); if (!rawApp || typeof rawApp.use !== 'function') { ctx.logger?.warn?.('[unknown-hostname-guard] http.server unavailable; guard not installed'); diff --git a/scripts/slot-lookup-baseline.json b/scripts/slot-lookup-baseline.json index 5c0a9ff5199..b4551ae94e0 100644 --- a/scripts/slot-lookup-baseline.json +++ b/scripts/slot-lookup-baseline.json @@ -1,7 +1,7 @@ { "packages/cli/src/commands/migrate/files-to-references.ts": 1, "packages/cli/src/commands/migrate/value-shapes.ts": 1, - "packages/cli/src/commands/serve.ts": 7, + "packages/cli/src/commands/serve.ts": 6, "packages/client/src/client.hono.test.ts": 2, "packages/cloud-connection/src/cloud-connection-plugin.ts": 5, "packages/cloud-connection/src/marketplace-install-local-plugin.ts": 14,