Skip to content

Commit 6c33626

Browse files
committed
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
1 parent a12bf2f commit 6c33626

4 files changed

Lines changed: 120 additions & 10 deletions

File tree

‎.changeset/24022-datasource-admin-no-server.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
'@objectstack/cli': patch
33
---
44

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

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

‎packages/cli/src/commands/serve-unknown-hostname-guard.test.ts‎

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,21 @@
4545
* exported constants the middleware itself branches on, so a subdomain or a
4646
* probe path added to the guard is covered the moment it is added rather than
4747
* the day someone remembers to copy it here.
48+
*
49+
* ## #24022 — a boot with no HTTP server takes the degrade line
50+
*
51+
* `OS_ROOT_DOMAIN=… os serve <config> --no-server` printed `[unknown-hostname-guard]
52+
* install failed` with the kernel's "composition/ordering fault" text, though
53+
* nothing was at fault: `init` read the server with a `getService?.(…) ??
54+
* getService?.(…)` chain, and the kernel's `getService` THROWS on a name
55+
* nothing registered, so the throw landed in the install `catch`. `init` now
56+
* reads through `readComposedHttpServer`. The last describe block boots a REAL
57+
* `ObjectKernel`, so the throw it must survive is the kernel's own, and pins
58+
* the composed control beside it.
4859
*/
4960

5061
import { describe, it, expect, vi } from 'vitest';
62+
import { ObjectKernel } from '@objectstack/core';
5163
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
5264

5365
import {
@@ -85,8 +97,11 @@ function mountGuard(opts: {
8597
knownHosts?: string[];
8698
/** Replace the env-registry with something broken, to pin the fall-throughs. */
8799
registryOverride?: unknown;
88-
/** Force the `http.server` resolution to fail the way production tolerates. */
89-
httpServer?: 'missing' | 'no-raw-app' | 'throws';
100+
/**
101+
* Force the `http.server` resolution to fail the way production tolerates.
102+
* `install-throws`: a usable server whose raw app refuses the middleware.
103+
*/
104+
httpServer?: 'missing' | 'no-raw-app' | 'throws' | 'install-throws';
90105
} = {}): Promise<Harness> {
91106
const server = new HonoHttpServer(0);
92107
const rawApp = server.getRawApp();
@@ -110,7 +125,9 @@ function mountGuard(opts: {
110125
? undefined
111126
: opts.httpServer === 'no-raw-app'
112127
? { getRawApp: () => ({}) }
113-
: { getRawApp: () => rawApp },
128+
: opts.httpServer === 'install-throws'
129+
? { getRawApp: () => ({ use: () => { throw new Error('middleware table sealed'); } }) }
130+
: { getRawApp: () => rawApp },
114131
'env-registry': registry ?? undefined,
115132
};
116133

@@ -120,7 +137,7 @@ function mountGuard(opts: {
120137
return services[name];
121138
},
122139
logger: {
123-
warn: (msg: string) => { warnings.push(msg); },
140+
warn: (msg: string, meta?: unknown) => { warnings.push(meta === undefined ? msg : `${msg} ${JSON.stringify(meta)}`); },
124141
info: (msg: string) => { infos.push(msg); },
125142
},
126143
};
@@ -379,10 +396,22 @@ describe('createUnknownHostnameGuardPlugin — install is soft, never fatal', ()
379396
expect(h.warnings.join('\n')).toContain('http.server unavailable');
380397
});
381398

382-
it('never throws out of init when service resolution fails', async () => {
399+
it('never throws out of init when service resolution throws, and reads the throw as no server (#24022)', async () => {
400+
// The kernel's `getService` throws on a name nothing registered, so a
401+
// throwing read IS the no-server boot: the degrade line, not "install failed".
383402
const h = await mountGuard({ httpServer: 'throws' });
384403

385-
expect(h.warnings.join('\n')).toContain('install failed');
404+
expect(h.warnings.join('\n')).toContain('http.server unavailable');
405+
expect(h.warnings.join('\n')).not.toContain('install failed');
406+
expect((await h.call('/api/v1/objects', 'nobody.objectos.ai')).status).toBe(200);
407+
});
408+
409+
it('CONTROL: a composed server whose raw app refuses the middleware still warns "install failed", naming the error', async () => {
410+
const h = await mountGuard({ httpServer: 'install-throws' });
411+
412+
expect(h.warnings).toHaveLength(1);
413+
expect(h.warnings[0]).toContain('install failed');
414+
expect(h.warnings[0]).toContain('middleware table sealed');
386415
});
387416

388417
it('logs the install once the middleware is mounted', async () => {
@@ -469,3 +498,84 @@ describe('createUnknownHostnameGuardPlugin — install order is the contract', (
469498
expect(asked).toEqual(['nobody.objectos.ai']);
470499
});
471500
});
501+
502+
/**
503+
* #24022, on a REAL `ObjectKernel`: the throw `init` has to survive is the
504+
* kernel's own, with the kernel's own "composition/ordering fault" wording. The
505+
* plugin's `ctx.logger` IS the kernel's logger, so a spy on it sees the plugin's
506+
* lines as well as the kernel's.
507+
*/
508+
describe('createUnknownHostnameGuardPlugin — a real kernel boot (#24022)', () => {
509+
/** The one service a kernel refuses to start without. Nothing here reads data. */
510+
const DATA_STUB = {
511+
name: 'com.example.fx24022.data-stub',
512+
version: '1.0.0',
513+
init: async (ctx: { registerService(name: string, service: unknown): void }) => { ctx.registerService('data', {}); },
514+
start: async () => {},
515+
};
516+
517+
/** Boot the guard (and `extra`, registered AFTER it); every line the kernel logger was asked to warn or info. */
518+
async function bootGuard(extra: Array<typeof DATA_STUB> = []) {
519+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
520+
const logger = (kernel as unknown as { logger: { warn: (...args: unknown[]) => void; info: (...args: unknown[]) => void } }).logger;
521+
const warn = vi.spyOn(logger, 'warn');
522+
const info = vi.spyOn(logger, 'info');
523+
const lines = (spy: typeof warn) => spy.mock.calls.map((args) => args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '));
524+
await kernel.use(DATA_STUB);
525+
await kernel.use(createUnknownHostnameGuardPlugin({ rootDomain: ROOT_DOMAIN, readCloudUrl: () => '' }));
526+
for (const plugin of extra) await kernel.use(plugin);
527+
await kernel.bootstrap();
528+
return {
529+
warnings: () => lines(warn),
530+
infos: () => lines(info),
531+
stop: async () => { await kernel.shutdown(); warn.mockRestore(); info.mockRestore(); },
532+
};
533+
}
534+
535+
const ours = (lines: string[]) => lines.filter((l) => l.includes('[unknown-hostname-guard]'));
536+
537+
it('no server composed: the degrade line, once — no "install failed", no "ordering fault"', async () => {
538+
const boot = await bootGuard();
539+
try {
540+
const warnings = boot.warnings();
541+
const seen = `\n--- warnings ---\n${warnings.join('\n')}`;
542+
expect(warnings.filter((w) => w.includes('install failed')), seen).toEqual([]);
543+
expect(warnings.filter((w) => w.includes('ordering fault')), seen).toEqual([]);
544+
const said = ours(warnings);
545+
expect(said, `not exactly one [unknown-hostname-guard] line${seen}`).toHaveLength(1);
546+
expect(said[0], seen).toContain('http.server unavailable; guard not installed');
547+
} finally {
548+
await boot.stop();
549+
}
550+
});
551+
552+
it('CONTROL: a composed server, ordered ahead by optionalDependencies, gets the guard installed and refusing', async () => {
553+
const server = new HonoHttpServer(0);
554+
const rawApp = server.getRawApp();
555+
const serverPlugin = {
556+
name: 'com.objectstack.server.hono',
557+
version: '1.0.0',
558+
init: async (ctx: { registerService(name: string, service: unknown): void }) => {
559+
ctx.registerService('http.server', server);
560+
ctx.registerService('env-registry', { resolveByHostname: async () => null });
561+
},
562+
start: async () => {},
563+
};
564+
const boot = await bootGuard([serverPlugin]);
565+
try {
566+
const seen = `\n--- warnings ---\n${boot.warnings().join('\n')}`;
567+
expect(ours(boot.warnings()), `an [unknown-hostname-guard] warning on a composed boot${seen}`).toEqual([]);
568+
expect(ours(boot.infos()).some((l) => l.includes('installed')), seen).toBe(true);
569+
570+
// Registered after the guard, as every route-registering plugin is.
571+
rawApp.all('*', (c: { text: (body: string, status: number) => Response }) => c.text(PASSED_THROUGH, 200));
572+
const res = await rawApp.fetch(new Request('http://placeholder/api/v1/objects', {
573+
headers: { host: 'nobody.objectos.ai', accept: 'application/json' },
574+
}));
575+
expect(res.status).toBe(404);
576+
expect((await res.json() as RefusalBody).error?.code).toBe('ENVIRONMENT_NOT_FOUND');
577+
} finally {
578+
await boot.stop();
579+
}
580+
});
581+
});

‎packages/cli/src/commands/serve.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5953,7 +5953,7 @@ export function createUnknownHostnameGuardPlugin(
59535953
optionalDependencies: ['com.objectstack.server.hono'],
59545954
init: async (ctx: any) => {
59555955
try {
5956-
const httpServer: any = ctx.getService?.('http.server') ?? ctx.getService?.('http-server');
5956+
const httpServer: any = readComposedHttpServer(ctx);
59575957
const rawApp = httpServer?.getRawApp?.();
59585958
if (!rawApp || typeof rawApp.use !== 'function') {
59595959
ctx.logger?.warn?.('[unknown-hostname-guard] http.server unavailable; guard not installed');

‎scripts/slot-lookup-baseline.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"packages/cli/src/commands/migrate/files-to-references.ts": 1,
33
"packages/cli/src/commands/migrate/value-shapes.ts": 1,
4-
"packages/cli/src/commands/serve.ts": 7,
4+
"packages/cli/src/commands/serve.ts": 6,
55
"packages/client/src/client.hono.test.ts": 2,
66
"packages/cloud-connection/src/cloud-connection-plugin.ts": 5,
77
"packages/cloud-connection/src/marketplace-install-local-plugin.ts": 14,

0 commit comments

Comments
 (0)