From c51a68b2f8c1a0079ade2d3ede78ae363f738fd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 23:49:18 +0000 Subject: [PATCH 1/2] fix(security): gate the metadata HMR door on an explicit development posture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `registerMetadataHmrRoutes` now mounts nothing and returns `null` unless `NODE_ENV` is exactly `development`, so `GET`/`POST /api/v1/dev/metadata-events` exist only on a boot that declared itself a development one. The dev-only posture the route path advertises lived in prose alone. The distributions were enumerated: the official image runs `os start` under `NODE_ENV=production`, that boot reaches `createStandaloneStack`, and the stack composes `MetadataPlugin` unconditionally onto a kernel that registers the Hono server whenever it serves — so both routes were mounted, unauthenticated, on a production-shaped boot, with `POST` re-reading the artifact from disk and broadcasting a reload to every connected client. Unset `NODE_ENV` is closed (the 2026-08-06 ruling: absent reads as production); `test` and unrecognised spellings are closed too. `os dev` is unaffected — it spawns `os serve --dev`, which sets `NODE_ENV='development'` before any plugin starts. The metadata route ledger's `POST` row, which carried the old posture verbatim, moves with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --- .changeset/hmr-dev-door-environment-gate.md | 49 ++++ .../metadata/src/metadata-route-ledger.ts | 41 ++- packages/metadata/src/plugin.ts | 49 +++- .../src/routes/hmr-routes-dev-gate.test.ts | 236 ++++++++++++++++++ packages/metadata/src/routes/hmr-routes.ts | 96 ++++++- 5 files changed, 455 insertions(+), 16 deletions(-) create mode 100644 .changeset/hmr-dev-door-environment-gate.md create mode 100644 packages/metadata/src/routes/hmr-routes-dev-gate.test.ts diff --git a/.changeset/hmr-dev-door-environment-gate.md b/.changeset/hmr-dev-door-environment-gate.md new file mode 100644 index 0000000000..4f215274aa --- /dev/null +++ b/.changeset/hmr-dev-door-environment-gate.md @@ -0,0 +1,49 @@ +--- +"@objectstack/metadata": minor +--- + +fix(security): gate the metadata HMR door on an explicit development posture (#12140) + +**BREAKING** surface narrowing — `GET`/`POST /api/v1/dev/metadata-events` are no +longer mounted unless `NODE_ENV` is exactly `development`. Shipped as `minor` +under the repo's launch-window convention for breaking changes. + +`MetadataPlugin` mounted both HMR routes whenever a raw-app-capable HTTP server +was present, with no authentication and no environment condition of its own. The +dev-only posture the path (`/api/v1/dev/…`) advertises lived only in prose — +"production deployments simply won't have a CLI POSTing to this endpoint" — which +is a claim about who is on the network, not a gate that stops them, and the same +structural shape #9391 closed for the `datasource-admin` family: both mounts take +the host's framework-native app handle, which is outside REST's `enforceAuth` +seam by construction. + +The distributions were enumerated rather than assumed, because "a dev-only +surface lacking a gate that says so" and "an unauthenticated door on a real +deployment" want different repairs. The official container image runs `os start` +under `NODE_ENV=production`; that boot reaches `createStandaloneStack`, which +composes `MetadataPlugin` unconditionally (only `artifactWatch` was +NODE_ENV-gated) onto a kernel that registers the Hono server whenever it serves. +So a production-shaped boot did mount both routes and did answer them — `POST` +re-reading the compiled artifact from disk and broadcasting a reload frame to +every connected client, unauthenticated. + +`registerMetadataHmrRoutes` now refuses at its first statement, ahead of every +side effect it performs, and returns `null` so "nothing was mounted" is a fact +the compiler forces its caller to handle. Unset `NODE_ENV` is closed, per the +maintainer's 2026-08-06 ruling that an absent value reads as `production`; `test` +and unrecognised spellings (`staging`, `preview`, `qa`) are closed too — a gate +must not treat a spelling nobody recognises as a key. + +Who is affected, in both directions: + +- `os dev` is unchanged. It spawns `os serve --dev`, which sets + `NODE_ENV='development'` before any plugin starts, so the watch-recompile loop + still gets its `200` and Studio still gets its reload frames. +- A deployment that was reaching this endpoint on a production-posture boot now + gets its host app's `404`. That door was never advertised, never in the SDK + (`@objectstack/client` builds no such URL) and is a build-tool loopback; if you + need a supported production reload trigger, that is a product decision, not + this endpoint. +- The server-side artifact-file watcher is untouched on every boot shape that had + it: the reload still happens, only the broadcast to (now absent) SSE clients is + skipped. diff --git a/packages/metadata/src/metadata-route-ledger.ts b/packages/metadata/src/metadata-route-ledger.ts index 88f0d7a1a7..a1f9350ce1 100644 --- a/packages/metadata/src/metadata-route-ledger.ts +++ b/packages/metadata/src/metadata-route-ledger.ts @@ -8,7 +8,7 @@ * WHY THIS EXISTS. `MetadataPlugin.start()` resolves the * `http.server`/`http-server` service, takes the Hono handle through * `getRawApp()`, and hands it to `registerMetadataHmrRoutes()` - * (`plugin.ts:468`), which registers the HMR endpoints straight on it. That + * (`plugin.ts:485`), which registers the HMR endpoints straight on it. That * mount is outside every ledger the platform had, and outside the reach of the * #7526 live-mount parity gate as well: that gate reads * `IHttpServer.getMountedRoutes()`, and "routes an adapter mounts on its @@ -21,12 +21,23 @@ * SCOPE, re-derived on `origin/main` @ 2ba4329e rather than inherited from the * filing: two routes, one registrar module, one wire path served by both verbs. * + * BOTH ROWS ARE NOW CONDITIONAL, and the condition is part of each row's + * disposition rather than a footnote to it (#12140). `registerMetadataHmrRoutes` + * refuses to mount anything unless the process runs an explicit + * `NODE_ENV=development` posture, so on every production-shaped boot this + * package's mounted-route census is EMPTY. A census that describes what is + * mounted has to say when: this ledger describes the development posture, which + * is the only posture in which either route exists. The conformance guard reads + * SOURCE TEXT rather than a live mount (see its header for why), so it keeps + * accounting for both rows on either side of the gate — the gate changes what a + * running server serves, not what this module mounts on the line the scan reads. + * * THE PATH HAS A CONFIGURABLE SEAM, AND IT IS UNUSED. `registerMetadataHmrRoutes` * accepts `options.path` and falls back to `/api/v1/dev/metadata-events` - * (`routes/hmr-routes.ts:74`). Both rows below carry the DEFAULT, and that is + * (`routes/hmr-routes.ts:163`). Both rows below carry the DEFAULT, and that is * exact rather than approximate, because the seam is unreachable from outside * this package: `registerMetadataHmrRoutes` is not re-exported from `index.ts` - * or `node.ts`, and its sole in-repo caller — `plugin.ts:468` — passes no + * or `node.ts`, and its sole in-repo caller — `plugin.ts:485` — passes no * options at all. The guard asserts both halves, so the day the seam is * exported or a caller starts passing a path, these rows stop being the whole * truth loudly rather than quietly. @@ -100,7 +111,12 @@ export const METADATA_ROUTE_LEDGER: readonly MetadataRouteLedgerEntry[] = [ + 'anonymous browser surface: grepped for a session/principal/401 gate in `routes/hmr-routes.ts` and there is ' + 'none. Not SDK surface: the consumer is an EventSource in the Studio shell, and `@objectstack/client` models ' + 'request/response calls, not long-lived SSE subscriptions (its realtime channel is a separate transport). ' - + 'Zero hits for `metadata-events` anywhere in the client package.', + + 'Zero hits for `metadata-events` anywhere in the client package. POSTURE (#12140): `public` is scoped by an ' + + 'environment gate — the registrar mounts this route only under an explicit `NODE_ENV=development`, so the ' + + 'anonymous surface exists on a dev workstation and on no production-shaped boot. It stays anonymous WHEN ' + + 'mounted, deliberately: the consumer is an EventSource, which cannot set an `Authorization` header, and the ' + + 'gate rather than a credential is what bounds who can reach it. Worth naming because the frames carry a ' + + '`path` field holding a server-side filesystem path.', }, { route: 'POST /api/v1/dev/metadata-events', @@ -112,10 +128,17 @@ export const METADATA_ROUTE_LEDGER: readonly MetadataRouteLedgerEntry[] = [ + 'package header names the caller: `os dev` watching TS sources. Who builds this URL instead, measured: the ' + 'CLI, not the SDK (`packages/cli/src/commands/dev.ts:553` documents the endpoint as the one it drives). A ' + 'build-tool loopback is deliberately not application SDK surface. POSTURE, recorded because a ledger row is ' - + 'where it becomes reviewable: this door carries NO authentication and MetadataPlugin applies no environment ' - + 'gate of its own — the plugin mounts it whenever a raw-app-capable HTTP server is present, and the only ' - + '`isDev` guard in the tree is on the CLI\'s SUPPLEMENTARY composition in `serve.ts`, not on this mount. The ' - + 'plugin\'s own comment states the posture as "production deployments simply won\'t have a CLI POSTing to this ' - + 'endpoint", which is a claim about who calls it, not a gate that stops them.', + + 'where it becomes reviewable — and CHANGED by #12140, so this row moved with it. What this row used to say: ' + + 'the door carried no authentication and MetadataPlugin applied no environment gate of its own, mounting it ' + + 'whenever a raw-app-capable HTTP server was present, while the only `isDev` guard in the tree sat on the ' + + 'CLI\'s SUPPLEMENTARY composition in `serve.ts` and never reached this mount. That was measured to be ' + + 'reachable rather than theoretical: the official image runs `os start` under `NODE_ENV=production`, that ' + + 'boot reaches `createStandaloneStack`, and the stack composes MetadataPlugin unconditionally onto a kernel ' + + 'that registers the Hono server whenever it serves. What it says now: `registerMetadataHmrRoutes` mounts ' + + 'nothing and returns `null` unless `NODE_ENV` is exactly `development` (unset reads as production, per the ' + + 'maintainer\'s 2026-08-06 ruling), so this write-shaped door exists only on a boot that declared itself a ' + + 'development one. Still no authentication WHEN mounted, and that is the deliberate half: an environment gate ' + + 'closes the door instead of putting a lock on it, because promoting a build-tool loopback into an ' + + 'authenticated production surface would widen what this endpoint is rather than harden it.', }, ]; diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index a11bf33a60..b0e1633441 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -447,8 +447,22 @@ export class MetadataPlugin implements Plugin { // external watch-recompile pipeline POSTs to the same endpoint // after rebuilding the artifact, and we reload it here before // broadcasting. - // Production deployments simply won't have a CLI POSTing to this - // endpoint and won't surface the route to clients. + // [#12140] PRODUCTION BOOTS DO NOT GET THIS DOOR, and that is now + // enforced rather than predicted. This comment used to read + // "production deployments simply won't have a CLI POSTing to this + // endpoint and won't surface the route to clients" — a claim about who + // is on the network, which is exactly the shape #9391 closed for + // `datasource-admin`. It was also false in the half that mattered: the + // official image runs `os start` under `NODE_ENV=production` + // (`docker/Dockerfile`), that boot reaches `createStandaloneStack`, + // and the stack composes this plugin UNCONDITIONALLY + // (`packages/runtime/src/standalone-stack.ts` gates only `artifactWatch` + // on NODE_ENV) onto a kernel that registers `HonoServerPlugin` whenever + // it serves. Both routes were therefore mounted, unauthenticated, on a + // production-shaped boot. The gate lives in the registrar — see + // `isDevMetadataEndpointEnabled` in `routes/hmr-routes.ts` — so the + // decision cannot be bypassed by a second caller; here we only handle + // its answer. try { // [#4251] Both names are the SAME instance; `http.server` is the // canonical one (the only name every provider registers), read @@ -465,11 +479,14 @@ export class MetadataPlugin implements Plugin { const httpServer = readServer('http.server') ?? readServer('http-server'); if (httpServer && typeof httpServer.getRawApp === 'function') { const { registerMetadataHmrRoutes } = await import('./routes/hmr-routes.js'); + // `null` when the environment gate refused: nothing was + // mounted, so there is no hub, no POST handler to wire, and + // nothing to broadcast to. const hub = registerMetadataHmrRoutes(httpServer.getRawApp(), this.manager); // Wire POST → re-load the artifact from disk (when in // local-file artifact mode) so subsequent reads see fresh // metadata. The broadcast happens after the handler returns. - hub.setOnPostReload(async (body: { reason?: string; changed?: string[] } = {}) => { + hub?.setOnPostReload(async (body: { reason?: string; changed?: string[] } = {}) => { const src = this.options.artifactSource; if (src?.mode === 'local-file') { try { @@ -525,7 +542,14 @@ export class MetadataPlugin implements Plugin { pending = true; try { await this._reloadAndAnnounce(ctx, src, [src.path]); - hub.broadcastReload('artifact-file-changed', [src.path]); + // Optional for the same reason the wiring + // above is: with the door closed there are no + // SSE clients to tell. The RELOAD still + // happened — `_reloadAndAnnounce` above is + // unconditional, so the server-side artifact + // watcher keeps working exactly as before on + // every boot shape that had it. + hub?.broadcastReload('artifact-file-changed', [src.path]); ctx.logger.info('[MetadataPlugin] artifact auto-reloaded (file watcher)', { path: src.path, }); @@ -544,8 +568,21 @@ export class MetadataPlugin implements Plugin { ctx.logger.warn('[MetadataPlugin] artifact watcher failed to start', { error: e?.message }); } } - // eslint-disable-next-line no-console - console.log('[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events'); + if (hub) { + // eslint-disable-next-line no-console + console.log('[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events'); + } else { + // Printed on every non-development boot, deliberately: a + // closed door that says nothing is indistinguishable from a + // door that was never asked about, and this line is what an + // operator greps when the Studio preview stops + // auto-reloading. + // eslint-disable-next-line no-console + console.log( + '[MetadataPlugin] dev metadata-HMR endpoints NOT mounted — they require ' + + `NODE_ENV=development (this process: ${process.env.NODE_ENV ? `NODE_ENV=${process.env.NODE_ENV}` : 'NODE_ENV unset, treated as production'})`, + ); + } } else { // eslint-disable-next-line no-console console.log('[MetadataPlugin] HTTP server with getRawApp() not available — skipping HMR endpoint'); diff --git a/packages/metadata/src/routes/hmr-routes-dev-gate.test.ts b/packages/metadata/src/routes/hmr-routes-dev-gate.test.ts new file mode 100644 index 0000000000..cc08bc44d7 --- /dev/null +++ b/packages/metadata/src/routes/hmr-routes-dev-gate.test.ts @@ -0,0 +1,236 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The environment gate on the metadata HMR door (#12140). + * + * ## What was wrong, measured rather than inferred + * + * `GET`/`POST /api/v1/dev/metadata-events` mount through + * `IHttpServer.getRawApp()`, which puts them outside REST's `enforceAuth` seam + * BY CONSTRUCTION, and nothing else gated them: `MetadataPlugin` mounted them + * whenever a raw-app-capable HTTP server was present, and the only `isDev` guard + * in the tree sits on the CLI's SUPPLEMENTARY composition in `serve.ts`, which + * never reaches this mount. The distributions were then enumerated, because + * "a dev-only surface missing a gate that says so" and "an unauthenticated door + * on a real deployment" want different fixes and only the enumeration separates + * them: + * + * - `docker/Dockerfile` runs `os start` with `NODE_ENV=production`; + * - `os start` / `os serve` reach `createStandaloneStack`, which composes + * `MetadataPlugin` UNCONDITIONALLY (only `artifactWatch` is NODE_ENV-gated); + * - the serve path registers `HonoServerPlugin` whenever `flags.server` is set, + * with no dev condition, and that adapter exposes `getRawApp()`. + * + * So a production-shaped boot mounted both routes and answered them. `POST` + * re-reads the artifact from disk and broadcasts a reload to every connected + * client — an unauthenticated write-shaped side effect plus a broadcast. + * + * ## What these pins hold, and why each direction is here + * + * A gate is only a gate if it REFUSES; a test that watches the open direction + * alone passes just as well against no gate at all. So every case below comes in + * pairs: the door opens under an explicit development posture and is closed — + * zero routes registered, `null` returned — under every other reading of + * `NODE_ENV`, including the unset one that the maintainer's 2026-08-06 ruling + * settled as `production`. + * + * The last describe drives `MetadataPlugin.start()` end to end against a fake + * `http.server`, because the unit above it pins the registrar and the plugin is + * what CALLS the registrar. Without it, a future edit that resolves the raw app + * and registers routes some other way would leave every assertion above green. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { isDevMetadataEndpointEnabled, registerMetadataHmrRoutes } from './hmr-routes.js'; +import { MetadataPlugin } from '../plugin.js'; +import type { MetadataManager } from '../metadata-manager.js'; + +const ROUTE = '/api/v1/dev/metadata-events'; + +/** A stand-in for the Hono handle `getRawApp()` returns: records what is mounted. */ +function fakeApp() { + const mounted: Array<{ verb: string; path: string }> = []; + const handlers = new Map Promise>(); + const record = (verb: string) => (path: string, handler: (c: any) => Promise) => { + mounted.push({ verb, path }); + handlers.set(`${verb} ${path}`, handler); + }; + return { mounted, handlers, get: record('GET'), post: record('POST') }; +} + +/** Enough MetadataManager for the registrar: it only reads the type registry. */ +function fakeManager(): MetadataManager { + return { + getRegisteredTypes: async () => ['object', 'view'], + } as unknown as MetadataManager; +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe('[#12140] isDevMetadataEndpointEnabled — exactly one spelling opens the door', () => { + // The closed cases are the point of the table. `test` is closed on purpose: + // nothing about a vitest process makes an open reload door correct. The + // unrecognised spellings (`staging`, `preview`, `qa`) are closed for the + // reason a GATE differs from `resolveDiscoveryEnvironment`, which degrades + // exactly those to `development` — safe for a discovery field, backwards for + // a door. + const cases: Array<[string | undefined, boolean]> = [ + ['development', true], + ['Development', true], // normalised, like the discovery mapper + [' development ', true], // a stray space in a .env must not decide it + ['production', false], + ['test', false], + ['staging', false], + ['preview', false], + ['qa', false], + ['dev', false], // NOT a synonym — one spelling, deliberately + ['', false], + [undefined, false], // unset reads as production (2026-08-06 ruling) + ]; + + it.each(cases)('NODE_ENV=%o → enabled=%s', (value, expected) => { + expect(isDevMetadataEndpointEnabled({ NODE_ENV: value })).toBe(expected); + }); + + it('reads the live process env when none is injected', () => { + vi.stubEnv('NODE_ENV', 'development'); + expect(isDevMetadataEndpointEnabled()).toBe(true); + vi.stubEnv('NODE_ENV', 'production'); + expect(isDevMetadataEndpointEnabled()).toBe(false); + }); +}); + +describe('[#12140] registerMetadataHmrRoutes — the refusal is at the door', () => { + it('mounts both verbs and returns a hub under NODE_ENV=development', () => { + vi.stubEnv('NODE_ENV', 'development'); + const app = fakeApp(); + + const hub = registerMetadataHmrRoutes(app as any, fakeManager()); + + expect(hub).not.toBeNull(); + expect(app.mounted).toEqual([ + { verb: 'GET', path: ROUTE }, + { verb: 'POST', path: ROUTE }, + ]); + }); + + it('mounts NOTHING and returns null under NODE_ENV=production', () => { + vi.stubEnv('NODE_ENV', 'production'); + const app = fakeApp(); + + const hub = registerMetadataHmrRoutes(app as any, fakeManager()); + + expect(hub).toBeNull(); + expect(app.mounted).toEqual([]); + }); + + it('mounts NOTHING when NODE_ENV is unset — the fail-closed direction', () => { + vi.stubEnv('NODE_ENV', undefined); + const app = fakeApp(); + + expect(registerMetadataHmrRoutes(app as any, fakeManager())).toBeNull(); + expect(app.mounted).toEqual([]); + }); + + // The transition, measured in the direction clause ② asks for: the caller + // this door exists for — `os dev`'s watch-recompile loop — still gets its + // 200 and its broadcast when the door is open. A gate that also broke the + // permitted caller would pass every refusal assertion above. + it('the permitted caller still works: POST answers 200 and broadcasts', async () => { + vi.stubEnv('NODE_ENV', 'development'); + const app = fakeApp(); + const hub = registerMetadataHmrRoutes(app as any, fakeManager()); + expect(hub).not.toBeNull(); + + const seen: string[] = []; + hub!.setOnPostReload(async (body) => { seen.push(body?.reason ?? '(none)'); }); + + const handler = app.handlers.get(`POST ${ROUTE}`)!; + const res = await handler({ + req: { + header: () => 'application/json', + json: async () => ({ reason: 'recompiled', changed: ['dist/objectstack.json'] }), + }, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ ok: true, reason: 'recompiled' }); + expect(seen).toEqual(['recompiled']); + }); +}); + +describe('[#12140] MetadataPlugin.start() — the plugin honours the gate end to end', () => { + function artifactFile(): string { + const dir = mkdtempSync(join(tmpdir(), 'os-hmr-gate-')); + const file = join(dir, 'objectstack.json'); + writeFileSync(file, JSON.stringify({ + manifest: { + id: 'com.example.gate', name: 'gate', version: '0.0.0', + type: 'app', namespace: 'gate', defaultDatasource: 'memory', + }, + objects: [], views: [], apps: [], flows: [], + }), 'utf8'); + return file; + } + + /** + * `getService` THROWS for an empty slot — that is the kernel's contract, and + * the plugin's per-name `try` around each read depends on it. A fake that + * returned `undefined` instead would exercise a path the real kernel never + * produces. + */ + function fakeCtx(app: ReturnType) { + return { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + trigger: vi.fn(async () => {}), + getService: (name: string) => { + if (name === 'http.server') return { getRawApp: () => app }; + throw new Error(`no service '${name}'`); + }, + } as any; + } + + async function startWith(nodeEnv: string | undefined) { + vi.stubEnv('NODE_ENV', nodeEnv); + const app = fakeApp(); + const plugin = new MetadataPlugin({ + watch: false, + // `artifact-only` keeps the drive on the artifact path: no + // filesystem scan, no FileSystemRepository, nothing but the mount + // decision under test. + config: { bootstrap: 'artifact-only' }, + // The artifact-file watcher is a separate decision and is left + // alone by this card — turned off here so the drive owns no timers. + artifactWatch: false, + artifactSource: { mode: 'local-file', path: artifactFile() }, + }); + await plugin.start(fakeCtx(app)); + return app; + } + + it('mounts both routes on a development boot', async () => { + const app = await startWith('development'); + expect(app.mounted).toEqual([ + { verb: 'GET', path: ROUTE }, + { verb: 'POST', path: ROUTE }, + ]); + }); + + it('mounts NOTHING on a production boot — the docker `os start` shape', async () => { + const app = await startWith('production'); + expect(app.mounted).toEqual([]); + }); + + it('mounts NOTHING when the operator never exported NODE_ENV', async () => { + const app = await startWith(undefined); + expect(app.mounted).toEqual([]); + }); +}); diff --git a/packages/metadata/src/routes/hmr-routes.ts b/packages/metadata/src/routes/hmr-routes.ts index b9b3a08afb..2f25839ec9 100644 --- a/packages/metadata/src/routes/hmr-routes.ts +++ b/packages/metadata/src/routes/hmr-routes.ts @@ -24,6 +24,47 @@ * 2. POST /api/v1/dev/metadata-events — used by external watch-recompile * pipelines (e.g. `os dev` watching TS sources) to invalidate * previews after rebuilding the artifact. + * + * ## The `/dev/` in the path is ENFORCED, not merely declared (#12140) + * + * `registerMetadataHmrRoutes` mounts NOTHING and returns `null` unless the + * process is running an explicit `NODE_ENV=development` posture — see + * {@link isDevMetadataEndpointEnabled}. Before that gate existed, this module's + * dev-only posture was carried by two sentences of prose (this header, and + * `MetadataPlugin`'s "production deployments simply won't have a CLI POSTing to + * this endpoint"), and a sentence about who will call a route is not a gate that + * stops them. The measured consequence: the official container image runs + * `os start` under `NODE_ENV=production` (`docker/Dockerfile`), that boot reaches + * `createStandaloneStack`, and the stack composes `MetadataPlugin` + * UNCONDITIONALLY (`packages/runtime/src/standalone-stack.ts`) onto a kernel that + * always registers `HonoServerPlugin` when serving — so both routes below were + * mounted, and answering, on a production-shaped boot. + * + * What an unauthenticated caller got there: `POST` re-reads the artifact from + * disk and broadcasts a reload frame to every connected client — a write-shaped + * side effect plus a broadcast — and `GET` streams metadata-change frames whose + * `path` field is a server-side filesystem path. Neither sits behind REST's + * `enforceAuth` seam, and neither can: the caller reaches this registrar by + * taking the host's framework-native app handle off the HTTP-server contract's + * raw-app escape hatch, and routes mounted on that handle are outside the auth + * seam BY CONSTRUCTION — the contract's own words, quoted with the spelling in + * `metadata-route-ledger.ts`. (The spelling is deliberately not repeated in this + * module: the ledger's conformance guard asserts as an IDENTITY that `plugin.ts` + * is the only file in this package reaching for the host app, and it reads raw + * source, so a prose mention here would read as a second reacher.) #9391 closed + * the same structural class for the `datasource-admin` family. + * + * WHY AN ENVIRONMENT GATE HERE AND AUTHENTICATION THERE, since the two fixes are + * not interchangeable. `datasource-admin` is the Setup → Datasources backend: it + * MUST answer on a production deployment, so the only available repair was to + * make it answer to authenticated callers. This door must not answer on a + * production deployment at all — its sole caller is a build tool (`os dev`'s + * watch-recompile loop, `packages/cli/src/commands/dev.ts`), the SDK never builds + * this URL (measured: zero `metadata-events` hits in `@objectstack/client`), and + * a reload broadcast has no meaning on a deployment that is not recompiling. So + * the fix is to close the door rather than to put a lock on it: bolting auth on + * instead would have made an unadvertised dev door into a supported production + * surface, which is a widening, not a hardening. */ import type { MetadataManager } from '../metadata-manager.js'; @@ -66,11 +107,64 @@ export interface MetadataHmrHub { listenerCount(): number; } +/** + * The one decision behind this module's dev-only posture (#12140): may this + * process serve `/api/v1/dev/metadata-events` at all? + * + * ## Only a literal `development` opens it — everything else is closed + * + * Unset is CLOSED, and that is the load-bearing half. The maintainer's + * 2026-08-06 ruling (#5673) settled what an absent `NODE_ENV` means for this + * repo: `production`. "An operator who never exported NODE_ENV is booting a real + * deployment, not asking to be treated as development" — `os serve`'s own words + * where it normalises the variable (`packages/cli/src/commands/serve.ts`), which + * is also why the `os dev` workflow keeps working across this gate: `os dev` + * spawns `os serve --dev`, and that branch sets `NODE_ENV='development'` when the + * operator left it unset, BEFORE any plugin starts. + * + * `test` is closed too, deliberately. Nothing about a vitest process makes an + * open reload door correct, and a suite that wants this surface says so by + * stubbing the posture it is exercising — which is what the gate's own pins do. + * + * ## Why not `resolveDiscoveryEnvironment`, the repo's other NODE_ENV reader + * + * That mapper is right for its job and wrong for this one. It degrades a value it + * does not recognise (`qa`, `preview`, `staging`) to `development`, because a + * DISCOVERY field that guesses `production` would let a client skip production + * warnings it needed. A GATE fails the opposite way: for it, "a spelling nobody + * recognises" must not be a key that opens the door. Same variable, opposite safe + * direction — so this predicate reads the variable directly and admits exactly + * one spelling. Trimmed and lower-cased first, matching how that mapper + * normalises (`' Production '` → `'production'`), so a stray space in a + * `.env` file cannot decide a security question. + * + * @param env Injectable for tests; defaults to the live process environment. + */ +export function isDevMetadataEndpointEnabled( + env: Record = process.env, +): boolean { + return (env.NODE_ENV ?? '').trim().toLowerCase() === 'development'; +} + +/** + * Mount the HMR routes on the host's framework-native app handle — or mount + * NOTHING and return `null` when this process is not running a development + * posture ({@link isDevMetadataEndpointEnabled}). + * + * The refusal is the FIRST statement, ahead of every side effect this registrar + * performs (the broadcast hub, the `manager.subscribe` fan-out, the route + * registrations): a gate that runs after the wiring is a gate on the answer, not + * on the door. `null` rather than an inert hub, because a caller that keeps a + * handle it cannot use is a caller that will one day broadcast into nothing and + * report success; the nullable return makes "nothing was mounted" a fact the + * compiler forces every caller to handle. + */ export function registerMetadataHmrRoutes( app: any, manager: MetadataManager, options: { path?: string } = {}, -): MetadataHmrHub { +): MetadataHmrHub | null { + if (!isDevMetadataEndpointEnabled()) return null; const routePath = options.path ?? '/api/v1/dev/metadata-events'; // In-process broadcast hub. Each SSE connection registers a listener; From e64c53244245fa93d49b6bb650c5520f2545f348 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 00:04:23 +0000 Subject: [PATCH 2/2] chore(changeset): answer the ADR-0087 disposition question in writing Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --- .changeset/hmr-dev-door-environment-gate.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/hmr-dev-door-environment-gate.md b/.changeset/hmr-dev-door-environment-gate.md index 4f215274aa..d0d87f3441 100644 --- a/.changeset/hmr-dev-door-environment-gate.md +++ b/.changeset/hmr-dev-door-environment-gate.md @@ -47,3 +47,5 @@ Who is affected, in both directions: - The server-side artifact-file watcher is untouched on every boot shape that had it: the reload still happens, only the broadcast to (now absent) SSE clients is skipped. + +