diff --git a/.changeset/serve-unknown-hostname-guard-test-seam.md b/.changeset/serve-unknown-hostname-guard-test-seam.md new file mode 100644 index 0000000000..2fce551dfc --- /dev/null +++ b/.changeset/serve-unknown-hostname-guard-test-seam.md @@ -0,0 +1,22 @@ +--- +"@objectstack/cli": patch +--- + +test(cli): `os serve`'s unknown-hostname guard gets a test seam — the middleware, refusal included, is now reachable without booting a server (#9442) + +The `OS_ROOT_DOMAIN` guard was a plugin object literal built inside +`Serve.run()`, closing over its locals and installing itself on a `http.server` +service resolved from the plugin context. Nothing about it was exported or +constructible, so every branch — the health/readiness bypass whose own comment +says a 404 there "would kill the container", the reserved-subdomain and +`/_console` redirect branches, the `/_admin` and `/.well-known` pass-throughs, +the lazy env-registry read whose every failure mode falls through — had zero +regression coverage. + +It is now `createUnknownHostnameGuardPlugin()`, exported from `serve.ts` the way +its sibling helpers are, with `run()` calling it. Behaviour is unchanged: same +branches in the same order, same bodies, and `OS_CLOUD_URL` is still read per +request rather than captured at install time. What is new is a suite that mounts +the real middleware on a real Hono app and pins BOTH directions — every bypass +as an explicit pass-through, and the refusal by `error.code` **and** HTTP status +together. diff --git a/packages/cli/src/commands/serve-unknown-hostname-guard.test.ts b/packages/cli/src/commands/serve-unknown-hostname-guard.test.ts new file mode 100644 index 0000000000..88e9872bf4 --- /dev/null +++ b/packages/cli/src/commands/serve-unknown-hostname-guard.test.ts @@ -0,0 +1,393 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os serve`'s unknown-environment hostname guard (#9442). + * + * ## Why this file exists + * + * The guard used to be a plugin object literal built inside `Serve.run()`, + * closing over four locals and installing itself on a `http.server` service + * resolved from the plugin context. Nothing about it was exported or + * constructible, so reaching ANY of it meant booting a real `os serve` — and + * measured, with a control query that proves the search worked, no test in + * `packages/cli` mentioned it: + * + * $ grep -rln "unknown-hostname-guard\|OS_ROOT_DOMAIN" --include=*.ts packages/cli/src/ + * packages/cli/src/commands/serve.ts # the source, no test + * $ grep -rln "resolveTenancyPostureOrRefusal" --include=*.ts packages/cli/src/ # control + * packages/cli/src/commands/serve-tenancy-posture-gate.test.ts + * packages/cli/src/commands/doctor.ts + * packages/cli/src/commands/serve.ts + * + * #9442 extracted the SEAM — `createUnknownHostnameGuardPlugin()`, exported the + * way this file's sibling helpers are — without changing what the guard + * refuses, when, or what it answers. These tests are that extraction's whole + * point, and they run the REAL middleware on a REAL Hono app (the same + * `HonoHttpServer` production resolves as `http.server`), never a mock of it. + * + * ## What is pinned, and why BOTH directions + * + * The guard's value is that it refuses an unmapped hostname. Its DANGER is + * refusing one it must not: the source says so in as many words — + * "Returning 404 here on an unmapped hostname would kill the container", + * because Cloudflare's container probe hits whatever `Host` is bound to the + * worker. A suite that only asserted the refusal would stay green if the + * middleware started refusing EVERYTHING, and would have declared that + * container-killer safe. + * + * So every bypass in the matrix is pinned as a PASS-THROUGH (an explicit 200 + * from a sentinel route mounted after the guard, never merely "not a 404" — + * Hono's own unmatched answer is a 404 too and would read as a refusal), and + * the refusal is pinned by `error.code` AND HTTP status together. Either alone + * is satisfied by a body that is wrong in the other half. + * + * The reserved-subdomain list and the health-path list are iterated from the + * 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. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; + +import { + createUnknownHostnameGuardPlugin, + UNKNOWN_HOSTNAME_GUARD_HEALTH_PATHS, + UNKNOWN_HOSTNAME_GUARD_RESERVED_SUBDOMAINS, +} from './serve.js'; + +const ROOT_DOMAIN = 'objectos.ai'; + +/** What the sentinel route answers when the guard let a request through. */ +const PASSED_THROUGH = 'PASSED_THROUGH'; + +interface Harness { + /** Drive one request through the mounted middleware. */ + call(path: string, host: string, headers?: Record): Promise; + warnings: string[]; + infos: string[]; + /** Hostnames the fake env-registry was asked about, in order. */ + asked: string[]; +} + +/** + * Mount the real guard on a real Hono app, exactly the way `init()` does in + * production: resolve `http.server`, take its raw app, install the middleware. + * + * The sentinel catch-all is registered AFTER the guard, which is also + * production's order — the guard installs during `init()`, route-registering + * plugins during `start()`. + */ +function mountGuard(opts: { + rootDomain?: string; + cloudUrl?: string; + /** Hostnames the env-registry resolves. Absent ⇒ registry service missing. */ + 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'; +} = {}): Promise { + const server = new HonoHttpServer(0); + const rawApp = server.getRawApp(); + const warnings: string[] = []; + const infos: string[] = []; + const asked: string[] = []; + + const registry = opts.registryOverride !== undefined + ? opts.registryOverride + : opts.knownHosts + ? { + resolveByHostname: async (host: string) => { + asked.push(host); + return opts.knownHosts?.includes(host) ? { id: 'env_1', hostname: host } : null; + }, + } + : null; + + const services: Record = { + 'http.server': opts.httpServer === 'missing' + ? undefined + : opts.httpServer === 'no-raw-app' + ? { getRawApp: () => ({}) } + : { getRawApp: () => rawApp }, + 'env-registry': registry ?? undefined, + }; + + const ctx = { + getService: (name: string) => { + if (opts.httpServer === 'throws') throw new Error('service registry exploded'); + return services[name]; + }, + logger: { + warn: (msg: string) => { warnings.push(msg); }, + info: (msg: string) => { infos.push(msg); }, + }, + }; + + const plugin = createUnknownHostnameGuardPlugin({ + rootDomain: opts.rootDomain ?? ROOT_DOMAIN, + readCloudUrl: () => opts.cloudUrl ?? '', + }); + + return plugin.init(ctx).then(() => { + rawApp.all('*', (c: { text: (body: string, status: number) => Response }) => + c.text(PASSED_THROUGH, 200)); + return { + warnings, + infos, + asked, + call: async (path: string, host: string, headers: Record = {}) => + rawApp.fetch(new Request(`http://placeholder${path}`, { headers: { host, ...headers } })), + }; + }); +} + +/** The refusal body, as `ApiErrorSchema` declares it. */ +interface RefusalBody { + success?: boolean; + error?: { code?: string; message?: string; details?: { hostname?: string } }; +} + +describe('createUnknownHostnameGuardPlugin — the refusal', () => { + it('refuses an unmapped platform hostname with ENVIRONMENT_NOT_FOUND and 404', async () => { + const h = await mountGuard({ knownHosts: ['acme.objectos.ai'] }); + + const res = await h.call('/api/v1/objects', 'nobody.objectos.ai', { accept: 'application/json' }); + + // Both halves, together: a body with the right code under the wrong status + // and a 404 carrying the wrong code each satisfy exactly one of these. + expect(res.status).toBe(404); + const body = await res.json() as RefusalBody; + expect(body.error?.code).toBe('ENVIRONMENT_NOT_FOUND'); + + // The rest of the declared envelope (#9364): `success: false`, a message, + // and `hostname` as context under `error.details` — never a stray top-level + // key, and never a bare-string `error`. + expect(body.success).toBe(false); + expect(body.error?.message).toContain('nobody.objectos.ai'); + expect(body.error?.details?.hostname).toBe('nobody.objectos.ai'); + expect(Object.keys(body as Record).sort()).toEqual(['error', 'success']); + }); + + it('refuses with the HTML page when the caller asks for text/html', async () => { + const h = await mountGuard({ knownHosts: ['acme.objectos.ai'] }); + + const res = await h.call('/', 'nobody.objectos.ai', { accept: 'text/html,application/xhtml+xml' }); + + expect(res.status).toBe(404); + expect(res.headers.get('content-type')).toContain('text/html'); + const html = await res.text(); + expect(html).toContain('Environment not found'); + expect(html).toContain('nobody.objectos.ai'); + }); + + it('normalizes the Host header before judging it — port and casing', async () => { + const h = await mountGuard({ knownHosts: ['acme.objectos.ai'] }); + + const refused = await h.call('/api/v1/objects', 'NoBody.ObjectOS.ai:8443', { accept: 'application/json' }); + expect(refused.status).toBe(404); + expect((await refused.json() as RefusalBody).error?.code).toBe('ENVIRONMENT_NOT_FOUND'); + + const allowed = await h.call('/api/v1/objects', 'ACME.objectos.ai:443', { accept: 'application/json' }); + expect(allowed.status).toBe(200); + expect(await allowed.text()).toBe(PASSED_THROUGH); + + // The registry is asked about the normalized host, not the raw header. + expect(h.asked).toEqual(['nobody.objectos.ai', 'acme.objectos.ai']); + }); + + it('normalizes the configured root domain, so casing cannot switch the guard off', async () => { + const h = await mountGuard({ rootDomain: ' ObjectOS.AI ', knownHosts: [] }); + + const res = await h.call('/api/v1/objects', 'nobody.objectos.ai', { accept: 'application/json' }); + + expect(res.status).toBe(404); + expect((await res.json() as RefusalBody).error?.code).toBe('ENVIRONMENT_NOT_FOUND'); + }); +}); + +describe('createUnknownHostnameGuardPlugin — what must NOT be refused', () => { + it('lets a mapped hostname through to the application', async () => { + const h = await mountGuard({ knownHosts: ['acme.objectos.ai'] }); + + const res = await h.call('/api/v1/objects', 'acme.objectos.ai'); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(PASSED_THROUGH); + }); + + it.each([...UNKNOWN_HOSTNAME_GUARD_HEALTH_PATHS])( + 'never refuses the probe path %s, even on an unmapped hostname', + async (probePath) => { + // The container-killer branch. Cloudflare's probe arrives with whatever + // Host is bound to the worker, so a 404 here takes the container down. + const h = await mountGuard({ knownHosts: [] }); + + const res = await h.call(probePath, 'nobody.objectos.ai'); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(PASSED_THROUGH); + // The registry is never even consulted for a probe. + expect(h.asked).toEqual([]); + }, + ); + + it.each([...UNKNOWN_HOSTNAME_GUARD_RESERVED_SUBDOMAINS])( + 'lets the reserved platform host %j through', + async (sub) => { + const h = await mountGuard({ knownHosts: [] }); + const host = sub === '' ? ROOT_DOMAIN : `${sub}.${ROOT_DOMAIN}`; + + const res = await h.call('/_console/', host); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(PASSED_THROUGH); + }, + ); + + it('treats the LAST label before the root domain as the reserved one', async () => { + // `api.acme.objectos.ai` — `sub` is `api.acme`, whose head is `acme`, so it + // is judged; `acme.api.objectos.ai` has head `api` and bypasses. + const h = await mountGuard({ knownHosts: [] }); + + expect((await h.call('/x', 'acme.api.objectos.ai')).status).toBe(200); + expect((await h.call('/x', 'api.acme.objectos.ai')).status).toBe(404); + }); + + it.each(['/_admin', '/_admin/anything', '/.well-known/acme-challenge/token'])( + 'lets the infra path %s through on an unmapped hostname', + async (infraPath) => { + const h = await mountGuard({ knownHosts: [] }); + + const res = await h.call(infraPath, 'nobody.objectos.ai'); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(PASSED_THROUGH); + }, + ); + + it.each(['app.example.com', 'localhost', 'tenant.workers.dev', 'objectos.ai.evil.test'])( + 'never judges the non-platform hostname %s', + async (host) => { + const h = await mountGuard({ knownHosts: [] }); + + const res = await h.call('/api/v1/objects', host); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(PASSED_THROUGH); + expect(h.asked).toEqual([]); + }, + ); +}); + +describe('createUnknownHostnameGuardPlugin — every registry failure falls through', () => { + it('passes through when no env-registry service is registered', async () => { + const h = await mountGuard({}); // no knownHosts ⇒ getService('env-registry') is undefined + + const res = await h.call('/api/v1/objects', 'nobody.objectos.ai'); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(PASSED_THROUGH); + }); + + it('passes through when the registry cannot resolve hostnames at all', async () => { + const h = await mountGuard({ registryOverride: { somethingElse: true } }); + + const res = await h.call('/api/v1/objects', 'nobody.objectos.ai'); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(PASSED_THROUGH); + }); + + it('passes through when the registry lookup throws', async () => { + const h = await mountGuard({ + registryOverride: { + resolveByHostname: async () => { throw new Error('database is down'); }, + }, + }); + + const res = await h.call('/api/v1/objects', 'nobody.objectos.ai'); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(PASSED_THROUGH); + }); +}); + +describe('createUnknownHostnameGuardPlugin — the cloud /_console redirect', () => { + it('redirects Console requests on a reserved host to the control plane', async () => { + const h = await mountGuard({ cloudUrl: 'https://cloud.objectos.ai//', knownHosts: [] }); + + const res = await h.call('/_console/', ROOT_DOMAIN); + + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe('https://cloud.objectos.ai/_console/'); + }); + + it('does not redirect when the runtime is not cloud-connected', async () => { + const h = await mountGuard({ knownHosts: [] }); // no cloud URL + + const res = await h.call('/_console/', ROOT_DOMAIN); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(PASSED_THROUGH); + }); + + it('does not redirect non-Console paths on a reserved host', async () => { + const h = await mountGuard({ cloudUrl: 'https://cloud.objectos.ai', knownHosts: [] }); + + const res = await h.call('/api/v1/health', ROOT_DOMAIN); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(PASSED_THROUGH); + }); + + it('reads the cloud URL per request, not once at install time', async () => { + // Production reads `process.env.OS_CLOUD_URL` inside the middleware. The + // seam keeps that a function for exactly this reason — capturing a string + // at construction would be a behaviour change wearing a refactor's clothes. + let cloudUrl = ''; + const server = new HonoHttpServer(0); + const rawApp = server.getRawApp(); + const plugin = createUnknownHostnameGuardPlugin({ + rootDomain: ROOT_DOMAIN, + readCloudUrl: () => cloudUrl, + }); + await plugin.init({ getService: (n: string) => (n === 'http.server' ? { getRawApp: () => rawApp } : undefined) }); + rawApp.all('*', (c: { text: (body: string, status: number) => Response }) => c.text(PASSED_THROUGH, 200)); + const call = async () => + rawApp.fetch(new Request('http://placeholder/_console/', { headers: { host: ROOT_DOMAIN } })); + + expect((await call()).status).toBe(200); + cloudUrl = 'https://cloud.objectos.ai'; + expect((await call()).status).toBe(302); + }); +}); + +describe('createUnknownHostnameGuardPlugin — install is soft, never fatal', () => { + it('declines to install, with a warning, when http.server is unavailable', async () => { + const h = await mountGuard({ httpServer: 'missing' }); + + expect(h.warnings.join('\n')).toContain('http.server unavailable'); + // Nothing was mounted, so the sentinel answers every hostname. + expect((await h.call('/api/v1/objects', 'nobody.objectos.ai')).status).toBe(200); + }); + + it('declines to install when the resolved server exposes no usable raw app', async () => { + const h = await mountGuard({ httpServer: 'no-raw-app' }); + + expect(h.warnings.join('\n')).toContain('http.server unavailable'); + }); + + it('never throws out of init when service resolution fails', async () => { + const h = await mountGuard({ httpServer: 'throws' }); + + expect(h.warnings.join('\n')).toContain('install failed'); + }); + + it('logs the install once the middleware is mounted', async () => { + const h = await mountGuard({ knownHosts: [] }); + + expect(h.infos.join('\n')).toContain('installed'); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 23cacd74ef..9466ffdadb 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1830,211 +1830,17 @@ export default class Serve extends Command { // Unknown-environment hostname guard. // - // In multi-tenant cloud deployments (e.g. *.objectos.ai), every - // public hostname is expected to map to a `sys_environment` row - // whose `hostname` column matches the request `Host`. Without this - // guard, an unknown subdomain like `demo-xxx.objectos.ai` happily - // renders the control-plane Console SPA (served statically by - // createConsoleStaticPlugin), making the deployment look like an - // empty env rather than a missing one. We respond with a clear - // 404 instead. + // Activation only: everything the guard decides, and why, lives on + // `createUnknownHostnameGuardPlugin()` below — exported (#9442) so the + // middleware, bypass matrix and refusal alike, is reachable from a test + // without booting a real `os serve`. // - // Activation: only when OS_ROOT_DOMAIN is set (e.g. "objectos.ai"). - // Reserved subdomains (cloud/www/api/docs/admin/app and the apex) - // bypass the check so platform surfaces keep working. Non-root - // hostnames (custom domains, localhost, *.workers.dev) pass through - // unchanged. Infra paths under /_admin or /.well-known are always - // allowed so health checks / cert flows aren't broken. - // - // Implemented as a Plugin so the middleware is wired during init - // (when http.server is available) and BEFORE start() runs on the - // Console static plugin / route-registering plugins. Hono's - // `app.use('*')` is order-independent for matching, so as long as - // the middleware is added before kernel:listening fires, it - // intercepts every request regardless of which plugin registered - // its handler. + // Activated only when OS_ROOT_DOMAIN is set (e.g. "objectos.ai"); with no + // root domain there is no platform-host namespace to guard, so nothing is + // installed and every hostname passes through as before. const __rootDomain = (process.env.OS_ROOT_DOMAIN || '').trim().toLowerCase(); if (__rootDomain) { - const RESERVED = new Set(['', 'cloud', 'www', 'api', 'docs', 'admin', 'app']); - const guardPlugin: any = { - name: 'com.objectstack.cli.unknown-hostname-guard', - version: '1.0.0', - // init() resolves the `http.server` service the hono server plugin - // provides — order-if-present so the middleware install is - // deterministic (ADR-0116, #4471). Soft: without a server plugin the - // guard degrades 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'); - const rawApp = httpServer?.getRawApp?.(); - if (!rawApp || typeof rawApp.use !== 'function') { - ctx.logger?.warn?.('[unknown-hostname-guard] http.server unavailable; guard not installed'); - return; - } - const getEnvRegistry = () => { - try { - return ctx.getService?.('env-registry') ?? null; - } catch { - return null; - } - }; - rawApp.use('*', async (c: any, next: any) => { - const rawHost = c.req.header('host') || ''; - const host = rawHost.split(':')[0].toLowerCase(); - if (!host) return next(); - const isPlatformHost = host === __rootDomain || host.endsWith('.' + __rootDomain); - if (!isPlatformHost) return next(); - const sub = host === __rootDomain ? '' : host.slice(0, -(__rootDomain.length + 1)); - const head = sub.split('.').pop() || ''; - const p = c.req.path; - if (RESERVED.has(sub) || RESERVED.has(head)) { - // A browser loading the Console on a bare/reserved platform host - // (the apex or `www`/`app`/… — none bind a tenant env) gets the - // Console SPA, but its `/api/v1/auth/*` calls 404 (no env → no - // auth) → a dead "Auth request failed with status 404" login. - // When this runtime is cloud-connected (`OS_CLOUD_URL` set), send - // Console requests to the cloud control plane to pick/open an - // environment instead. A self-hosted single-env runtime (no - // `OS_CLOUD_URL`) keeps the prior pass-through. Non-console paths - // (infra, health, /api) fall through below unchanged. - const cloudUrl = (process.env.OS_CLOUD_URL || '').trim(); - if (cloudUrl && (p === '/_console' || p.startsWith('/_console/'))) { - return c.redirect(`${cloudUrl.replace(/\/+$/, '')}/_console/`, 302); - } - return next(); - } - if (p.startsWith('/_admin/') || p === '/_admin' || p.startsWith('/.well-known/')) { - return next(); - } - // Health and readiness endpoints must always answer 200 - // regardless of whether the requested hostname maps to - // an env — Cloudflare's container probe (and any - // upstream load balancer) hits whatever Host header is - // currently bound to the worker. Returning 404 here on - // an unmapped hostname would kill the container. - if (p === '/api/v1/health' || p === '/api/v1/ready' || p === '/health') { - return next(); - } - // Resolve env-registry lazily on each request — it may - // not be registered yet at init() time (registered by - // ObjectOSEnvironmentPlugin's init which runs in plugin - // dependency order; we don't want to rely on ordering). - const registry: any = getEnvRegistry(); - if (!registry || typeof registry.resolveByHostname !== 'function') { - return next(); - } - try { - const hit = await registry.resolveByHostname(host); - if (hit) return next(); - } catch { - return next(); - } - // Content negotiation: browsers (Accept: text/html) get - // a clean 404 page; API clients (curl/fetch with JSON - // accept) get a structured error body. - const accept = (c.req.header('accept') || '').toLowerCase(); - const wantsHtml = accept.includes('text/html'); - if (wantsHtml) { - const safeHost = host.replace(/[<>&"']/g, (ch: string) => ((({ - '<': '<', '>': '>', '&': '&', '"': '"', "'": ''', - } as Record)[ch]) ?? ch)); - const html = ` - - - - -404 — Environment not found - - - -
-

404

-

Environment not found

-

No ObjectStack environment is bound to this hostname.

-
${safeHost}
-

- If you own this domain, bind it to an environment in the - ObjectStack Cloud console. -

-
- -`; - return c.html(html, 404); - } - // The declared `BaseResponseSchema` refusal envelope. This - // used to answer `{ error: 'environment_not_found', message, - // hostname }` — the pre-#3675 dialect, where `error` is a bare - // string so `body.error.message` reads `undefined`, with two - // stray top-level keys beside it. `hostname` is context and - // moved into `error.details`, which `ApiErrorSchema` declares - // for exactly that; the code is now the ADR-0112 - // SCREAMING_SNAKE spelling of the same condition, in the - // semantic slot consumers branch on. - return c.json( - { - success: false, - error: { - code: 'ENVIRONMENT_NOT_FOUND', - message: `No environment is bound to hostname '${host}'.`, - details: { hostname: host }, - }, - }, - 404, - ); - }); - ctx.logger?.info?.('[unknown-hostname-guard] installed', { rootDomain: __rootDomain }); - } catch (err: any) { - ctx.logger?.warn?.('[unknown-hostname-guard] install failed', { error: err?.message ?? err }); - } - }, - }; + const guardPlugin: any = createUnknownHostnameGuardPlugin({ rootDomain: __rootDomain }); try { await kernel.use(guardPlugin); trackPlugin('UnknownHostnameGuard'); @@ -3542,6 +3348,289 @@ export default class Serve extends Command { } +/** + * What {@link createUnknownHostnameGuardPlugin} is constructed with. + */ +export interface UnknownHostnameGuardOptions { + /** + * The platform apex, e.g. `objectos.ai` — `OS_ROOT_DOMAIN` in production. + * Normalized here (trim + lowercase) as well as at the call site, so the guard + * cannot be constructed with a casing the `Host` comparison would then miss. + */ + rootDomain: string; + /** + * Reads the cloud control-plane URL for the `/_console` redirect branch. + * + * A FUNCTION, not a string, and called PER REQUEST — that is what production + * does (`process.env.OS_CLOUD_URL` was read inside the middleware, not at + * install time), and extracting the seam must not quietly move the read to + * construction time. Defaults to the same env read. + */ + readCloudUrl?: () => string; +} + +/** The plugin object {@link createUnknownHostnameGuardPlugin} returns. */ +export interface UnknownHostnameGuardPlugin { + name: string; + version: string; + optionalDependencies: string[]; + init: (ctx: any) => Promise; +} + +/** + * Subdomains that bypass the guard, plus the apex (`''`). + * + * Exported so a test iterates the SAME list the middleware branches on. A copy + * in the test would go stale the day a subdomain is added here: the new one + * would ship uncovered while the suite stayed green. + */ +export const UNKNOWN_HOSTNAME_GUARD_RESERVED_SUBDOMAINS: ReadonlySet = new Set([ + '', 'cloud', 'www', 'api', 'docs', 'admin', 'app', +]); + +/** + * Health and readiness paths that always pass through, whatever the hostname. + * + * Load-bearing, not a nicety: Cloudflare's container probe (and any upstream + * load balancer) hits whatever `Host` header is currently bound to the worker, + * so a 404 here would kill the container. Exported for the same reason as the + * reserved list — a probe path added here is covered the moment it is added. + */ +export const UNKNOWN_HOSTNAME_GUARD_HEALTH_PATHS: readonly string[] = [ + '/api/v1/health', '/api/v1/ready', '/health', +]; + +/** + * The unknown-environment hostname guard, as a mountable plugin (#9442). + * + * In multi-tenant cloud deployments (e.g. *.objectos.ai), every public hostname + * is expected to map to a `sys_environment` row whose `hostname` column matches + * the request `Host`. Without this guard, an unknown subdomain like + * `demo-xxx.objectos.ai` happily renders the control-plane Console SPA (served + * statically by createConsoleStaticPlugin), making the deployment look like an + * empty env rather than a missing one. It answers a clear 404 instead. + * + * The bypass matrix, in the order the middleware applies it: + * + * 1. no `Host` header, or a host outside `rootDomain` (custom domains, + * localhost, *.workers.dev) — pass through, unjudged; + * 2. a reserved subdomain or the apex ({@link + * UNKNOWN_HOSTNAME_GUARD_RESERVED_SUBDOMAINS}) — pass through, except that + * a cloud-connected runtime redirects `/_console` to the control plane so + * the Console picks an environment rather than dying on a 404 login; + * 3. `/_admin`, `/_admin/*` and `/.well-known/*` — always through, so cert + * flows are not broken; + * 4. {@link UNKNOWN_HOSTNAME_GUARD_HEALTH_PATHS} — always through; + * 5. no env-registry service, or a registry read that throws — through. Every + * failure mode falls through rather than refusing; + * 6. registry hit — through. Registry MISS is the only refusal. + * + * Implemented as a Plugin so the middleware is wired during init (when + * `http.server` is available) and BEFORE start() runs on the Console static + * plugin / route-registering plugins. Hono's `app.use('*')` is order-independent + * for matching, so as long as the middleware is added before kernel:listening + * fires, it intercepts every request regardless of which plugin registered its + * handler. The env-registry is resolved lazily per request on purpose: it is + * registered by ObjectOSEnvironmentPlugin's init, and the guard must not depend + * on plugin ordering to work. + * + * ⛔ The refusal `c.json({ ... }, 404)` inside the middleware must stay an + * INLINE OBJECT LITERAL. `scripts/check-route-envelope.mjs` judges the object + * literal passed to `c.json(...)`; an identifier or a call expression reads to + * it as a relayed body it must not police. Hoisting that body into a helper — + * even into this file — leaves the file discovered (the `c.json(` call is still + * here) while every counter reads zero, so the `{}` entry this file carries in + * `PLUGIN_ROUTE_MODULES` keeps passing and stops asserting: the #9364 + * conformance pin goes vacuous, silently, with the gate green. + */ +export function createUnknownHostnameGuardPlugin( + options: UnknownHostnameGuardOptions, +): UnknownHostnameGuardPlugin { + const rootDomain = (options.rootDomain || '').trim().toLowerCase(); + const readCloudUrl = options.readCloudUrl ?? (() => process.env.OS_CLOUD_URL || ''); + const RESERVED = UNKNOWN_HOSTNAME_GUARD_RESERVED_SUBDOMAINS; + const HEALTH_PATHS = UNKNOWN_HOSTNAME_GUARD_HEALTH_PATHS; + return { + name: 'com.objectstack.cli.unknown-hostname-guard', + version: '1.0.0', + // init() resolves the `http.server` service the hono server plugin + // provides — order-if-present so the middleware install is + // deterministic (ADR-0116, #4471). Soft: without a server plugin the + // guard degrades 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'); + const rawApp = httpServer?.getRawApp?.(); + if (!rawApp || typeof rawApp.use !== 'function') { + ctx.logger?.warn?.('[unknown-hostname-guard] http.server unavailable; guard not installed'); + return; + } + const getEnvRegistry = () => { + try { + return ctx.getService?.('env-registry') ?? null; + } catch { + return null; + } + }; + rawApp.use('*', async (c: any, next: any) => { + const rawHost = c.req.header('host') || ''; + const host = rawHost.split(':')[0].toLowerCase(); + if (!host) return next(); + const isPlatformHost = host === rootDomain || host.endsWith('.' + rootDomain); + if (!isPlatformHost) return next(); + const sub = host === rootDomain ? '' : host.slice(0, -(rootDomain.length + 1)); + const head = sub.split('.').pop() || ''; + const p = c.req.path; + if (RESERVED.has(sub) || RESERVED.has(head)) { + // A browser loading the Console on a bare/reserved platform host + // (the apex or `www`/`app`/… — none bind a tenant env) gets the + // Console SPA, but its `/api/v1/auth/*` calls 404 (no env → no + // auth) → a dead "Auth request failed with status 404" login. + // When this runtime is cloud-connected (`OS_CLOUD_URL` set), send + // Console requests to the cloud control plane to pick/open an + // environment instead. A self-hosted single-env runtime (no + // `OS_CLOUD_URL`) keeps the prior pass-through. Non-console paths + // (infra, health, /api) fall through below unchanged. + const cloudUrl = readCloudUrl().trim(); + if (cloudUrl && (p === '/_console' || p.startsWith('/_console/'))) { + return c.redirect(`${cloudUrl.replace(/\/+$/, '')}/_console/`, 302); + } + return next(); + } + if (p.startsWith('/_admin/') || p === '/_admin' || p.startsWith('/.well-known/')) { + return next(); + } + // Health and readiness endpoints must always answer 200 + // regardless of whether the requested hostname maps to + // an env — Cloudflare's container probe (and any + // upstream load balancer) hits whatever Host header is + // currently bound to the worker. Returning 404 here on + // an unmapped hostname would kill the container. + if (HEALTH_PATHS.includes(p)) { + return next(); + } + // Resolve env-registry lazily on each request — it may + // not be registered yet at init() time (registered by + // ObjectOSEnvironmentPlugin's init which runs in plugin + // dependency order; we don't want to rely on ordering). + const registry: any = getEnvRegistry(); + if (!registry || typeof registry.resolveByHostname !== 'function') { + return next(); + } + try { + const hit = await registry.resolveByHostname(host); + if (hit) return next(); + } catch { + return next(); + } + // Content negotiation: browsers (Accept: text/html) get + // a clean 404 page; API clients (curl/fetch with JSON + // accept) get a structured error body. + const accept = (c.req.header('accept') || '').toLowerCase(); + const wantsHtml = accept.includes('text/html'); + if (wantsHtml) { + const safeHost = host.replace(/[<>&"']/g, (ch: string) => ((({ + '<': '<', '>': '>', '&': '&', '"': '"', "'": ''', + } as Record)[ch]) ?? ch)); + const html = ` + + + + +404 — Environment not found + + + +
+

404

+

Environment not found

+

No ObjectStack environment is bound to this hostname.

+
${safeHost}
+

+ If you own this domain, bind it to an environment in the + ObjectStack Cloud console. +

+
+ +`; + return c.html(html, 404); + } + // The declared `BaseResponseSchema` refusal envelope. This + // used to answer `{ error: 'environment_not_found', message, + // hostname }` — the pre-#3675 dialect, where `error` is a bare + // string so `body.error.message` reads `undefined`, with two + // stray top-level keys beside it. `hostname` is context and + // moved into `error.details`, which `ApiErrorSchema` declares + // for exactly that; the code is now the ADR-0112 + // SCREAMING_SNAKE spelling of the same condition, in the + // semantic slot consumers branch on. + return c.json( + { + success: false, + error: { + code: 'ENVIRONMENT_NOT_FOUND', + message: `No environment is bound to hostname '${host}'.`, + details: { hostname: host }, + }, + }, + 404, + ); + }); + ctx.logger?.info?.('[unknown-hostname-guard] installed', { rootDomain }); + } catch (err: any) { + ctx.logger?.warn?.('[unknown-hostname-guard] install failed', { error: err?.message ?? err }); + } + }, + }; +} + /** * What the tenancy-posture boot gate decided (#5359). * diff --git a/scripts/slot-lookup-baseline.json b/scripts/slot-lookup-baseline.json index 54543fbdf2..1c5c897a2c 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": 10, + "packages/cli/src/commands/serve.ts": 9, "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": 16,