Skip to content

Commit f4e7ae5

Browse files
os-warrenclaude
andauthored
fix(security): gate the metadata HMR door on an explicit development posture (#12399)
* fix(security): gate the metadata HMR door on an explicit development posture `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o * chore(changeset): answer the ADR-0087 disposition question in writing Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 69d0e18 commit f4e7ae5

5 files changed

Lines changed: 457 additions & 16 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/metadata": minor
3+
---
4+
5+
fix(security): gate the metadata HMR door on an explicit development posture (#12140)
6+
7+
**BREAKING** surface narrowing — `GET`/`POST /api/v1/dev/metadata-events` are no
8+
longer mounted unless `NODE_ENV` is exactly `development`. Shipped as `minor`
9+
under the repo's launch-window convention for breaking changes.
10+
11+
`MetadataPlugin` mounted both HMR routes whenever a raw-app-capable HTTP server
12+
was present, with no authentication and no environment condition of its own. The
13+
dev-only posture the path (`/api/v1/dev/…`) advertises lived only in prose —
14+
"production deployments simply won't have a CLI POSTing to this endpoint" — which
15+
is a claim about who is on the network, not a gate that stops them, and the same
16+
structural shape #9391 closed for the `datasource-admin` family: both mounts take
17+
the host's framework-native app handle, which is outside REST's `enforceAuth`
18+
seam by construction.
19+
20+
The distributions were enumerated rather than assumed, because "a dev-only
21+
surface lacking a gate that says so" and "an unauthenticated door on a real
22+
deployment" want different repairs. The official container image runs `os start`
23+
under `NODE_ENV=production`; that boot reaches `createStandaloneStack`, which
24+
composes `MetadataPlugin` unconditionally (only `artifactWatch` was
25+
NODE_ENV-gated) onto a kernel that registers the Hono server whenever it serves.
26+
So a production-shaped boot did mount both routes and did answer them — `POST`
27+
re-reading the compiled artifact from disk and broadcasting a reload frame to
28+
every connected client, unauthenticated.
29+
30+
`registerMetadataHmrRoutes` now refuses at its first statement, ahead of every
31+
side effect it performs, and returns `null` so "nothing was mounted" is a fact
32+
the compiler forces its caller to handle. Unset `NODE_ENV` is closed, per the
33+
maintainer's 2026-08-06 ruling that an absent value reads as `production`; `test`
34+
and unrecognised spellings (`staging`, `preview`, `qa`) are closed too — a gate
35+
must not treat a spelling nobody recognises as a key.
36+
37+
Who is affected, in both directions:
38+
39+
- `os dev` is unchanged. It spawns `os serve --dev`, which sets
40+
`NODE_ENV='development'` before any plugin starts, so the watch-recompile loop
41+
still gets its `200` and Studio still gets its reload frames.
42+
- A deployment that was reaching this endpoint on a production-posture boot now
43+
gets its host app's `404`. That door was never advertised, never in the SDK
44+
(`@objectstack/client` builds no such URL) and is a build-tool loopback; if you
45+
need a supported production reload trigger, that is a product decision, not
46+
this endpoint.
47+
- The server-side artifact-file watcher is untouched on every boot shape that had
48+
it: the reload still happens, only the broadcast to (now absent) SSE clients is
49+
skipped.
50+
51+
<!-- 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 -->

packages/metadata/src/metadata-route-ledger.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* WHY THIS EXISTS. `MetadataPlugin.start()` resolves the
99
* `http.server`/`http-server` service, takes the Hono handle through
1010
* `getRawApp()`, and hands it to `registerMetadataHmrRoutes()`
11-
* (`plugin.ts:468`), which registers the HMR endpoints straight on it. That
11+
* (`plugin.ts:485`), which registers the HMR endpoints straight on it. That
1212
* mount is outside every ledger the platform had, and outside the reach of the
1313
* #7526 live-mount parity gate as well: that gate reads
1414
* `IHttpServer.getMountedRoutes()`, and "routes an adapter mounts on its
@@ -21,12 +21,23 @@
2121
* SCOPE, re-derived on `origin/main` @ 2ba4329e rather than inherited from the
2222
* filing: two routes, one registrar module, one wire path served by both verbs.
2323
*
24+
* BOTH ROWS ARE NOW CONDITIONAL, and the condition is part of each row's
25+
* disposition rather than a footnote to it (#12140). `registerMetadataHmrRoutes`
26+
* refuses to mount anything unless the process runs an explicit
27+
* `NODE_ENV=development` posture, so on every production-shaped boot this
28+
* package's mounted-route census is EMPTY. A census that describes what is
29+
* mounted has to say when: this ledger describes the development posture, which
30+
* is the only posture in which either route exists. The conformance guard reads
31+
* SOURCE TEXT rather than a live mount (see its header for why), so it keeps
32+
* accounting for both rows on either side of the gate — the gate changes what a
33+
* running server serves, not what this module mounts on the line the scan reads.
34+
*
2435
* THE PATH HAS A CONFIGURABLE SEAM, AND IT IS UNUSED. `registerMetadataHmrRoutes`
2536
* accepts `options.path` and falls back to `/api/v1/dev/metadata-events`
26-
* (`routes/hmr-routes.ts:74`). Both rows below carry the DEFAULT, and that is
37+
* (`routes/hmr-routes.ts:163`). Both rows below carry the DEFAULT, and that is
2738
* exact rather than approximate, because the seam is unreachable from outside
2839
* this package: `registerMetadataHmrRoutes` is not re-exported from `index.ts`
29-
* or `node.ts`, and its sole in-repo caller — `plugin.ts:468` — passes no
40+
* or `node.ts`, and its sole in-repo caller — `plugin.ts:485` — passes no
3041
* options at all. The guard asserts both halves, so the day the seam is
3142
* exported or a caller starts passing a path, these rows stop being the whole
3243
* truth loudly rather than quietly.
@@ -100,7 +111,12 @@ export const METADATA_ROUTE_LEDGER: readonly MetadataRouteLedgerEntry[] = [
100111
+ 'anonymous browser surface: grepped for a session/principal/401 gate in `routes/hmr-routes.ts` and there is '
101112
+ 'none. Not SDK surface: the consumer is an EventSource in the Studio shell, and `@objectstack/client` models '
102113
+ 'request/response calls, not long-lived SSE subscriptions (its realtime channel is a separate transport). '
103-
+ 'Zero hits for `metadata-events` anywhere in the client package.',
114+
+ 'Zero hits for `metadata-events` anywhere in the client package. POSTURE (#12140): `public` is scoped by an '
115+
+ 'environment gate — the registrar mounts this route only under an explicit `NODE_ENV=development`, so the '
116+
+ 'anonymous surface exists on a dev workstation and on no production-shaped boot. It stays anonymous WHEN '
117+
+ 'mounted, deliberately: the consumer is an EventSource, which cannot set an `Authorization` header, and the '
118+
+ 'gate rather than a credential is what bounds who can reach it. Worth naming because the frames carry a '
119+
+ '`path` field holding a server-side filesystem path.',
104120
},
105121
{
106122
route: 'POST /api/v1/dev/metadata-events',
@@ -112,10 +128,17 @@ export const METADATA_ROUTE_LEDGER: readonly MetadataRouteLedgerEntry[] = [
112128
+ 'package header names the caller: `os dev` watching TS sources. Who builds this URL instead, measured: the '
113129
+ 'CLI, not the SDK (`packages/cli/src/commands/dev.ts:553` documents the endpoint as the one it drives). A '
114130
+ 'build-tool loopback is deliberately not application SDK surface. POSTURE, recorded because a ledger row is '
115-
+ 'where it becomes reviewable: this door carries NO authentication and MetadataPlugin applies no environment '
116-
+ 'gate of its own — the plugin mounts it whenever a raw-app-capable HTTP server is present, and the only '
117-
+ '`isDev` guard in the tree is on the CLI\'s SUPPLEMENTARY composition in `serve.ts`, not on this mount. The '
118-
+ 'plugin\'s own comment states the posture as "production deployments simply won\'t have a CLI POSTing to this '
119-
+ 'endpoint", which is a claim about who calls it, not a gate that stops them.',
131+
+ 'where it becomes reviewable — and CHANGED by #12140, so this row moved with it. What this row used to say: '
132+
+ 'the door carried no authentication and MetadataPlugin applied no environment gate of its own, mounting it '
133+
+ 'whenever a raw-app-capable HTTP server was present, while the only `isDev` guard in the tree sat on the '
134+
+ 'CLI\'s SUPPLEMENTARY composition in `serve.ts` and never reached this mount. That was measured to be '
135+
+ 'reachable rather than theoretical: the official image runs `os start` under `NODE_ENV=production`, that '
136+
+ 'boot reaches `createStandaloneStack`, and the stack composes MetadataPlugin unconditionally onto a kernel '
137+
+ 'that registers the Hono server whenever it serves. What it says now: `registerMetadataHmrRoutes` mounts '
138+
+ 'nothing and returns `null` unless `NODE_ENV` is exactly `development` (unset reads as production, per the '
139+
+ 'maintainer\'s 2026-08-06 ruling), so this write-shaped door exists only on a boot that declared itself a '
140+
+ 'development one. Still no authentication WHEN mounted, and that is the deliberate half: an environment gate '
141+
+ 'closes the door instead of putting a lock on it, because promoting a build-tool loopback into an '
142+
+ 'authenticated production surface would widen what this endpoint is rather than harden it.',
120143
},
121144
];

packages/metadata/src/plugin.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -447,8 +447,22 @@ export class MetadataPlugin implements Plugin {
447447
// external watch-recompile pipeline POSTs to the same endpoint
448448
// after rebuilding the artifact, and we reload it here before
449449
// broadcasting.
450-
// Production deployments simply won't have a CLI POSTing to this
451-
// endpoint and won't surface the route to clients.
450+
// [#12140] PRODUCTION BOOTS DO NOT GET THIS DOOR, and that is now
451+
// enforced rather than predicted. This comment used to read
452+
// "production deployments simply won't have a CLI POSTing to this
453+
// endpoint and won't surface the route to clients" — a claim about who
454+
// is on the network, which is exactly the shape #9391 closed for
455+
// `datasource-admin`. It was also false in the half that mattered: the
456+
// official image runs `os start` under `NODE_ENV=production`
457+
// (`docker/Dockerfile`), that boot reaches `createStandaloneStack`,
458+
// and the stack composes this plugin UNCONDITIONALLY
459+
// (`packages/runtime/src/standalone-stack.ts` gates only `artifactWatch`
460+
// on NODE_ENV) onto a kernel that registers `HonoServerPlugin` whenever
461+
// it serves. Both routes were therefore mounted, unauthenticated, on a
462+
// production-shaped boot. The gate lives in the registrar — see
463+
// `isDevMetadataEndpointEnabled` in `routes/hmr-routes.ts` — so the
464+
// decision cannot be bypassed by a second caller; here we only handle
465+
// its answer.
452466
try {
453467
// [#4251] Both names are the SAME instance; `http.server` is the
454468
// canonical one (the only name every provider registers), read
@@ -465,11 +479,14 @@ export class MetadataPlugin implements Plugin {
465479
const httpServer = readServer('http.server') ?? readServer('http-server');
466480
if (httpServer && typeof httpServer.getRawApp === 'function') {
467481
const { registerMetadataHmrRoutes } = await import('./routes/hmr-routes.js');
482+
// `null` when the environment gate refused: nothing was
483+
// mounted, so there is no hub, no POST handler to wire, and
484+
// nothing to broadcast to.
468485
const hub = registerMetadataHmrRoutes(httpServer.getRawApp(), this.manager);
469486
// Wire POST → re-load the artifact from disk (when in
470487
// local-file artifact mode) so subsequent reads see fresh
471488
// metadata. The broadcast happens after the handler returns.
472-
hub.setOnPostReload(async (body: { reason?: string; changed?: string[] } = {}) => {
489+
hub?.setOnPostReload(async (body: { reason?: string; changed?: string[] } = {}) => {
473490
const src = this.options.artifactSource;
474491
if (src?.mode === 'local-file') {
475492
try {
@@ -525,7 +542,14 @@ export class MetadataPlugin implements Plugin {
525542
pending = true;
526543
try {
527544
await this._reloadAndAnnounce(ctx, src, [src.path]);
528-
hub.broadcastReload('artifact-file-changed', [src.path]);
545+
// Optional for the same reason the wiring
546+
// above is: with the door closed there are no
547+
// SSE clients to tell. The RELOAD still
548+
// happened — `_reloadAndAnnounce` above is
549+
// unconditional, so the server-side artifact
550+
// watcher keeps working exactly as before on
551+
// every boot shape that had it.
552+
hub?.broadcastReload('artifact-file-changed', [src.path]);
529553
ctx.logger.info('[MetadataPlugin] artifact auto-reloaded (file watcher)', {
530554
path: src.path,
531555
});
@@ -544,8 +568,21 @@ export class MetadataPlugin implements Plugin {
544568
ctx.logger.warn('[MetadataPlugin] artifact watcher failed to start', { error: e?.message });
545569
}
546570
}
547-
// eslint-disable-next-line no-console
548-
console.log('[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events');
571+
if (hub) {
572+
// eslint-disable-next-line no-console
573+
console.log('[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events');
574+
} else {
575+
// Printed on every non-development boot, deliberately: a
576+
// closed door that says nothing is indistinguishable from a
577+
// door that was never asked about, and this line is what an
578+
// operator greps when the Studio preview stops
579+
// auto-reloading.
580+
// eslint-disable-next-line no-console
581+
console.log(
582+
'[MetadataPlugin] dev metadata-HMR endpoints NOT mounted — they require '
583+
+ `NODE_ENV=development (this process: ${process.env.NODE_ENV ? `NODE_ENV=${process.env.NODE_ENV}` : 'NODE_ENV unset, treated as production'})`,
584+
);
585+
}
549586
} else {
550587
// eslint-disable-next-line no-console
551588
console.log('[MetadataPlugin] HTTP server with getRawApp() not available — skipping HMR endpoint');

0 commit comments

Comments
 (0)