Uh oh!
There was an error while loading. Please reload this page.
fix(security): gate the metadata HMR door on an explicit development posture - #12399
Conversation
…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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2ee576a15a5562f4a6c224e5958c54a4203755a0 && git checkout 2ee576a15a5562f4a6c224e5958c54a4203755a0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fe3d74f7214b9013254292e5e35f0164f34e9374 bfbd7dd90e727712cbc6be0408143e206cdeddf6 && git checkout -B drift-repro fe3d74f7214b9013254292e5e35f0164f34e9374 && git merge --no-ff bfbd7dd90e727712cbc6be0408143e206cdeddf6
node scripts/docs-audit/affected-docs.mjs --json fe3d74f7214b9013254292e5e35f0164f34e9374
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12140
The enumeration came first, and it decided the shape
The card's own first step was to enumerate the boot shapes, because "a dev-only surface
lacking a gate that says so" and "an unauthenticated door on a real deployment" want
different repairs. Measured, not inferred:
os startwithNODE_ENV=productiondocker/Dockerfile(ENV NODE_ENV=production,CMD ["os", "start"])os start/os servereach the standalone bootpackages/cli/src/commands/serve.ts—useArtifactFallback || shouldBootWithLibrary(config)→createDefaultHostConfig/createStandaloneStackMetadataPluginunconditionallypackages/runtime/src/standalone-stack.ts— onlyartifactWatchcarries aNODE_ENVcondition; the plugin itself has noneserve.tsregistersHonoServerPluginonflags.server && !configHasHonoServer— no dev conditionSo a production-shaped boot did mount both routes and did answer them. The
POSTre-reads the compiled artifact from disk and broadcasts a reload frame to every connected
client — an unauthenticated write-shaped side effect plus a broadcast — and the
GETstream's
metadata-changeframes carry apathfield holding a server-side filesystempath. Neither sits behind REST's
enforceAuthseam, and neither can: the mount takes thehost's framework-native app handle, which the HTTP-server contract itself puts outside that
seam by construction. This is #9391's structural class.
The card's other branch ("if provably none can, turn the comment's prediction into a gate
plus a pin") is therefore not the branch this PR takes — but the repair it prescribes is
shipped anyway, because the prediction had to become a gate either way.
I also checked the one claim the card made about the existing guard and confirmed it: the
if (isDev && flags.server)inserve.tsprotects the CLI's supplementary compositiononly (it is even skipped when a
MetadataPluginis already present), and it never reachesthe standalone/artifact composition.
The fix: an environment gate at the door, not a lock on it
registerMetadataHmrRoutesnow refuses at its first statement, ahead of every sideeffect it performs, and returns
null:nullrather than an inert hub, so "nothing was mounted" is a fact the compiler forcesevery caller to handle rather than a handle someone can broadcast into and get success from.
Why an environment gate here when #9391 used authentication there. The two are not
interchangeable.
datasource-adminis the Setup → Datasources backend: it must answer ona 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),@objectstack/clientbuilds no such URL, and areload broadcast has no meaning on a deployment that is not recompiling. Bolting auth on
would have promoted an unadvertised dev loopback into a supported production surface, which
is a widening. Per the card's boundary, nothing here loosens the auth seam or makes the door
available in production.
Why exactly
NODE_ENV === 'development'and nothing else. Unset is closed, which is theload-bearing half: the maintainer's 2026-08-06 ruling (#5673) settled an absent
NODE_ENVas
production, andos servenormalises it that way in its own words — "an operator whonever exported NODE_ENV is booting a real deployment, not asking to be treated as
development".
test,staging,preview,qaare closed too. Deliberately notresolveDiscoveryEnvironment: that mapper degrades a spelling it does not recognise todevelopment, which is the safe direction for a discovery field a client reads and thebackwards direction for a gate.
Clause ② — who stops working, measured in both directions
os devis unchanged. It spawnsos serve --dev, and that branch setsNODE_ENV='development'when the operator left it unset, before any plugin starts(
serve.ts). The permitted caller is pinned working, not assumed working: the POSThandler still answers
200 {ok:true,…}and still fires the reload hook.404. That is the exposure being closed. The endpoint was never advertised, never in the
SDK, and a host-config boot already produced exactly this 404 today (
serve.tsdocumentsit), so the shape is not new.
the reload still happens; only the broadcast to (now absent) SSE clients is skipped. Its
conditions were left exactly as they were rather than folded into the new gate.
testcloses the door for vitest processes. No existing suite depended on it (nothingin the tree drove this mount before this PR); the new pins declare the posture they
exercise.
The ledger row moved with the posture
packages/metadata/src/metadata-route-ledger.ts'sPOSTrow carried the old postureverbatim (#11882), so leaving it would have left a ledger describing a door that no longer
exists. Both rows now record the gate, the
POSTrow records what it used to say and why itchanged, and the module header records that the census describes the development posture —
the only one in which either route exists. Line references the edit shifted (
plugin.ts:468→
:485,hmr-routes.ts:74→:163) were updated in the same pass.Verification
Everything below ran in the foreground, serialised through
scripts/pm/os-verify-lock.sh,at merge commit
bfbd7dd90e(this branch merged withorigin/main— the gate union wasre-derived and re-run after the merge, which brought in a new family,
check-comment-mask-adoption).Gate union — derived, never recalled:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackatbfbd7dd90e; 5 paths vs merge base9edd79f84. All 20path-derived families plus the 6 convention-triggered ones ran; every exit code was captured
before any pipe. 26/26 green. Quoting each gate's own verdict line for the two that say
more than "exit 0":
Package suite:
pnpm --filter @objectstack/metadata test→Test Files 35 passed (35),Tests 656 passed (656). Dependency closure and the fullpackages/*build were built first(
turbo run build … --concurrency=2, 70/70 successful) —check:type-check-debtrefusesoutright on an unbuilt worktree.
Ablation — the direction was predicted in writing first, then measured. The pins import
./hmr-routes.js/../plugin.jsby relative path, so vitest transforms this package'ssrc/directly: noexportsresolution, nodist/, so no rebuild leg applies to eithermutation. Each leg carried a
trap … EXIT INT TERMrestore and proved the mutation on diskwith single-line anchored
grep -cFcounts before any result was read.if (!isDevMetadataEndpointEnabled()) return null;1 → 0 occurrences (predicate itself still present: 1)Tests 4 failed | 15 passed (19)— exactly those 4=== 'development'→!== 'production'production, plus the two "unset" mount pinsTests 9 failed | 10 passed (19)— exactly those 9Leg 1's green half is the point: a predicate tested in isolation says nothing about whether
anything calls it, which is why the end-to-end
MetadataPlugin.start()pins exist. Leg 2'sgreen half is the mirror: the
productionrows pass under both spellings, so a suitethat only tested
productionwould have ratified the fail-open version. After each leg thefile was restored and verified byte-identical to
HEAD(git diff HEAD --numstatempty),and the suite re-ran green (19/19).
Repo-wide lint — a declared narrowing, with its three measurements.
pnpm lintscans thewhole repo and is CI's run; this PR ran it targeted, and the narrowing is measured rather
than asserted: (1) the population is eslint's own — it reports the changeset
.mdas "Fileignored because no matching configuration was supplied", so the linted population of this
diff is exactly the four
.tsfiles; (2) the count is read from--format json:files_linted=4, errors=0, warnings=0; (3) invariance for untouched files — this repo runsone
eslint.config.mjswhich "never enables type-aware linting (noparserOptions.project,no typed
@typescript-eslintrules) for ANY file", measured there with a positive control,so nothing in this diff can move an untouched file's verdict.
Out of scope, filed rather than fixed
metadata-route-ledger.conformance.test.ts's host-app-reach limb tests raw source whileevery other limb in that file strips comments first, so a prose mention of
getRawAppin anon-excluded source file reads as a second module reaching for the host app. It fired on the
first draft of this PR's own docblock, and the same limb is replicated in at least two other
packages. Filed unassigned as #12398 (
finding); this PR worked around it by not repeatingthe spelling in
hmr-routes.tsprose (with a comment saying why), and changed no guard.Nothing here touches
packages/drivers/driver-sql/**orpackages/platform-objects/**.Generated by Claude Code