Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions packages/cli/src/commands/serve-unknown-hostname-guard.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -391,3 +391,81 @@ describe('createUnknownHostnameGuardPlugin — install is soft, never fatal', ()
expect(h.infos.join('\n')).toContain('installed');
});
});

/**
* The install-order property the guard's rationale in `serve.ts` names.
*
* That rationale used to say the middleware intercepts everything "as long as
* it is added before kernel:listening fires". Measured false (#9745): Hono
* composes the handlers a request matched in REGISTRATION order, and
* `kernel:ready` / `kernel:bootstrapped` / `kernel:listening` all fire after
* Phase 2 `start()` — i.e. after every route on the platform is registered — so
* a guard installed from any of those hooks sits behind all of them and refuses
* nothing, with no error and no log.
*
* Nothing else in this file can fail when that claim comes back: every other
* test mounts the guard FIRST via `mountGuard`, which is precisely the case
* that works. So this pins the ordering itself, both directions on ONE app —
* neither direction can be satisfied by the middleware simply never installing:
*
* - `/before` is registered BEFORE the guard's `init()` → guard never runs
* - `/after` is registered AFTER the guard's `init()` → guard refuses
*/
describe('createUnknownHostnameGuardPlugin — install order is the contract', () => {
it('gates only what is registered after it, which is why init() is the requirement', async () => {
const server = new HonoHttpServer(0);
const rawApp = server.getRawApp();
const asked: string[] = [];

// A route that already exists when the guard installs. This is the shape a
// guard installed from a lifecycle hook would face for EVERY platform
// route, not an exotic one.
rawApp.get('/before', (c: { text: (body: string, status: number) => Response }) =>
c.text('BEFORE', 200));

const ctx = {
getService: (name: string) =>
name === 'http.server'
? { getRawApp: () => rawApp }
: name === 'env-registry'
? {
resolveByHostname: async (host: string) => { asked.push(host); return null; },
}
: undefined,
logger: { warn: () => {}, info: () => {} },
};

await createUnknownHostnameGuardPlugin({
rootDomain: ROOT_DOMAIN,
readCloudUrl: () => '',
}).init(ctx);

// Production's order: route-registering plugins run in Phase 2 `start()`,
// after every plugin's `init()`.
rawApp.get('/after', (c: { text: (body: string, status: number) => Response }) =>
c.text('AFTER', 200));

const call = (path: string) =>
rawApp.fetch(new Request(`http://placeholder${path}`, {
headers: { host: 'nobody.objectos.ai', accept: 'application/json' },
}));

// Registered first: it answers and never calls `next()`, so the guard is
// never reached — an unmapped hostname sails straight through, and the
// registry is not even consulted.
const before = await call('/before');
expect(before.status).toBe(200);
expect(await before.text()).toBe('BEFORE');

// Registered after: the guard is ahead of it in the composed chain and
// refuses. Same app, same host, same unmapped environment — registration
// order is the only difference between these two requests.
const after = await call('/after');
expect(after.status).toBe(404);
expect((await after.json() as RefusalBody).error?.code).toBe('ENVIRONMENT_NOT_FOUND');

// The registry was asked exactly once, for the request the guard actually
// saw — the direct evidence that the `/before` hit never reached it.
expect(asked).toEqual(['nobody.objectos.ai']);
});
});
25 changes: 21 additions & 4 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3427,10 +3427,27 @@ export const UNKNOWN_HOSTNAME_GUARD_HEALTH_PATHS: readonly string[] = [
*
* 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
* plugin / route-registering plugins. Phase 1 `init()` is the REQUIREMENT, not
* a convenience: Hono composes the handlers a request matched in REGISTRATION
* order, so a route registered ahead of this middleware answers and never calls
* `next()`, and the guard never runs for that path. Route registration starts
* in Phase 2 `start()` (createConsoleStaticPlugin mounts the Console there) and
* continues in the `kernel:ready` hooks registered from it (HonoServerPlugin's
* current-user endpoints, plugin-auth's terminal `/api/v1/auth/*`), while both
* kernels run every plugin's `init()` before the first `start()`
* (`LiteKernel.bootstrap`, `ObjectKernel.bootstrap`) — so an install from
* `init()` is ahead of all of it.
*
* ⛔ "Added before kernel:listening" is NOT the condition, however natural it
* reads. `kernel:ready`, `kernel:bootstrapped` and `kernel:listening` all fire
* strictly AFTER Phase 2, so a guard installed from one of those hooks is
* registered behind every route and observes NOTHING — no error, no log, no
* refusal (#9745, measured across three install points). A refusal surface that
* gates nothing is the failure this guard exists to prevent, so anything
* modelled on it installs during `init()`. Both directions are pinned in
* `serve-unknown-hostname-guard.test.ts`.
*
* 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.
*
Expand Down
Loading