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
51 changes: 51 additions & 0 deletions .changeset/hmr-dev-door-environment-gate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@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.

<!-- adr-0087: not-required (no-migration-prescription) a mounted HTTP route stops existing under a non-development posture; no authorable key, no spec declaration, no Zod schema and no stored value moves, so `objectstack migrate meta` has nothing to rewrite and no ledger entry could reach anyone this affects -->
41 changes: 32 additions & 9 deletions packages/metadata/src/metadata-route-ledger.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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.
Expand DownExpand Up@@ -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',
Expand All@@ -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.',
},
];
49 changes: 43 additions & 6 deletions packages/metadata/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 {
Expand DownExpand Up@@ -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,
});
Expand All@@ -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');
Expand Down
Loading
Loading