Skip to content

fix(security): gate the metadata HMR door on an explicit development posture - #12399

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-12140-hmr-door-gate
Aug 26, 2026
Merged

fix(security): gate the metadata HMR door on an explicit development posture#12399
os-warren merged 3 commits into
mainfrom
claude/issue-12140-hmr-door-gate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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:

link in the chainevidence
the official image runs os start with NODE_ENV=productiondocker/Dockerfile (ENV NODE_ENV=production, CMD ["os", "start"])
os start / os serve reach the standalone bootpackages/cli/src/commands/serve.tsuseArtifactFallback || shouldBootWithLibrary(config)createDefaultHostConfig / createStandaloneStack
that stack composes MetadataPluginunconditionallypackages/runtime/src/standalone-stack.ts — only artifactWatch carries a NODE_ENV condition; the plugin itself has none
the same kernel always has a raw-app-capable server when servingserve.ts registers HonoServerPlugin on flags.server && !configHasHonoServer — no dev condition

So a production-shaped boot did mount both routes and did answer them. The POST
re-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 GET
stream's metadata-change frames carry a path field holding a server-side filesystem
path. Neither sits behind REST's enforceAuth seam, and neither can: the mount takes the
host'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) in serve.ts protects the CLI's supplementary composition
only (it is even skipped when a MetadataPlugin is already present), and it never reaches
the standalone/artifact composition.

The fix: an environment gate at the door, not a lock on it

registerMetadataHmrRoutes now refuses at its first statement, ahead of every side
effect it performs, and returns null:

if(!isDevMetadataEndpointEnabled())returnnull;

null rather than an inert hub, so "nothing was mounted" is a fact the compiler forces
every 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-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), @objectstack/client builds no such URL, and a
reload 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 the
load-bearing half: the maintainer's 2026-08-06 ruling (#5673) settled an absent NODE_ENV
as production, and os serve normalises it that way in its own words — "an operator who
never exported NODE_ENV is booting a real deployment, not asking to be treated as
development". test, staging, preview, qa are closed too. Deliberately not
resolveDiscoveryEnvironment: that mapper degrades a spelling it does not recognise to
development, which is the safe direction for a discovery field a client reads and the
backwards direction for a gate.

Clause ② — who stops working, measured in both directions

  • os dev is unchanged. It spawns os serve --dev, and that branch sets
    NODE_ENV='development' when the operator left it unset, before any plugin starts
    (serve.ts). The permitted caller is pinned working, not assumed working: the POST
    handler still answers 200 {ok:true,…} and still fires the reload hook.
  • A caller reaching this endpoint on a production-posture boot now gets the host app's
    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.ts documents
    it), so the shape is not new.
  • 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. Its
    conditions were left exactly as they were rather than folded into the new gate.
  • test closes the door for vitest processes. No existing suite depended on it (nothing
    in 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's POST row carried the old posture
verbatim (#11882), so leaving it would have left a ledger describing a door that no longer
exists. Both rows now record the gate, the POST row records what it used to say and why it
changed, 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 with origin/main — the gate union was
re-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/objectstack at bfbd7dd90e; 5 paths vs merge base 9edd79f84. All 20
path-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":

✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.
.changeset/hmr-dev-door-environment-gate.md [BREAKING] not-required (no-migration-prescription)
check-type-check-coverage: OK — 65/78 workspace packages type-checked (plus the root), 13 in the DEBT ledger …
check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 251.6s, 1843 raw tsc error(s) total, none above its recorded number.

Package suite:pnpm --filter @objectstack/metadata testTest Files 35 passed (35),
Tests 656 passed (656). Dependency closure and the full packages/* build were built first
(turbo run build … --concurrency=2, 70/70 successful) — check:type-check-debt refuses
outright on an unbuilt worktree.

Ablation — the direction was predicted in writing first, then measured. The pins import
./hmr-routes.js / ../plugin.js by relative path, so vitest transforms this package's
src/ directly: no exports resolution, no dist/, so no rebuild leg applies to either
mutation. Each leg carried a trap … EXIT INT TERM restore and proved the mutation on disk
with single-line anchored grep -cF counts before any result was read.

legmutation, proven on diskpredictedmeasured
1 — delete the gateif (!isDevMetadataEndpointEnabled()) return null; 1 → 0 occurrences (predicate itself still present: 1)the 4 "mounts NOTHING" pins go red; all 11 predicate-table rows stay greenTests 4 failed | 15 passed (19) — exactly those 4
2 — the tempting wrong spelling === 'development'!== 'production'correct spelling 1 → 0, wrong spelling 0 → 1the 7 closed rows that are not literal production, plus the two "unset" mount pinsTests 9 failed | 10 passed (19) — exactly those 9

Leg 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's
green half is the mirror: the production rows pass under both spellings, so a suite
that only tested production would have ratified the fail-open version. After each leg the
file was restored and verified byte-identical to HEAD (git diff HEAD --numstat empty),
and the suite re-ran green (19/19).

Repo-wide lint — a declared narrowing, with its three measurements.pnpm lint scans the
whole 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 .md as "File
ignored because no matching configuration was supplied", so the linted population of this
diff is exactly the four .ts files; (2) the count is read from --format json:
files_linted=4, errors=0, warnings=0; (3) invariance for untouched files — this repo runs
one eslint.config.mjs which "never enables type-aware linting (no parserOptions.project,
no typed @typescript-eslint rules) 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 while
every other limb in that file strips comments first, so a prose mention of getRawApp in a
non-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 repeating
the spelling in hmr-routes.ts prose (with a comment saying why), and changed no guard.

Nothing here touches packages/drivers/driver-sql/** or packages/platform-objects/**.


Generated by Claude Code

…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
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation labels Aug 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata, touching 3 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via /api/v1/dev/metadata-events (route))
What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json fe3d74f7214b9013254292e5e35f0164f34e9374packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2ee576a15a5562f4a6c224e5958c54a4203755a0 — the merge of head bfbd7dd90e727712cbc6be0408143e206cdeddf6 into base fe3d74f7214b9013254292e5e35f0164f34e9374, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs fe3d74f7214b9013254292e5e35f0164f34e9374 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The metadata HMR reload door POST /api/v1/dev/metadata-events carries no auth and no environment gate of its own

2 participants

@os-warren@claude