Uh oh!
There was an error while loading. Please reload this page.
fix(runtime): refuse a falsy-body PUT /meta/:type/:name instead of serving it as a read - #8849
Conversation
…rving it as a read (#8842) The metadata save branch opened `if (method === 'PUT' && body)`. The `&& body` conjunct was not a guard but a hole: every path inside the block returns (including the terminal 501), so a falsy body fell through to the read `try` below and was answered with the ordinary metadata read. A write verb came back looking like a successful read, and the `manage_metadata` gate — the first thing the save branch does — was skipped entirely for such a request. Reachable from an ordinary client, measured rather than read: the Hono adapter's catch-all builds the body as `await c.req.json().catch(() => ({}))`, whose catch covers a parse failure but not a successful parse of a falsy JSON value. Driven against a real Hono app, payloads of `null`, `false`, `0` and `""` all arrive falsy; only unparseable input lands on the `{}` fallback. The branch now keys off the method alone and folds a nullish body to `{}`, matching what packages/rest's `PUT /meta/:type/:name` already does (`req.body ?? {}`), so the per-type schema refuses downstream with 422 INVALID_METADATA. Two doors onto one saveMetaItem disagreeing about what a bodyless metadata write means was the defect; the fix gives them one answer. Pinned in both directions: the refusal (422 for an authorized caller, 403 PERMISSION_DENIED for one lacking the capability, and the read spy proving no read was served) and the over-refusal guard (a PUT carrying a body still saves, body reaching the writer verbatim; a GET is still a read). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 20 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8842
The defect
packages/runtime/src/domains/meta.tsopened its metadata save branch withif (method === 'PUT' && body). The&& bodyconjunct was not a guard — it was a hole. Every path inside that block returns (including the terminal501), so a falsy body did not merely skip the write: execution continued past the whole save block into the readtrybelow, which resolved the type and answered the ordinary metadata read.A caller who asked to write received what looks like a successful read. No status, header or field distinguished it from a real write acknowledgement — the shape "Absence must be loud" exists to prevent (AGENTS.md, Route and surface ownership §3).
Not a security card. Triage's correction is carried through: the
manage_metadatagate was skipped, but that is not a privilege bypass. The request was answered by the read path, which runs the same ADR-0106 mask a plainGETruns, and nothing was written. Skipping a write gate on a request that performs no write grants nothing. The defect is the lie, not a privilege.Reachability — driven, not asserted
The card's own open question, and the thing that decides whether this is a defect or dead code. The host that mounts this dispatcher path is the Hono adapter's catch-all (
packages/adapters/hono/src/index.ts, the only adapter in the repo;packages/runtime/src/dispatcher-plugin.tsregisters no/metaroute). It builds the body as:The
.catchcovers a parse failure — it does not cover a successful parse of a falsy JSON value. Driven against a real Hono app using that exact expression:bodynullnullfalsefalse00""""{}{}So an ordinary client sending
content-type: application/jsonwith a payload ofnullreaches the branch with a falsy body. Reachable, and the premise holds.The fix
The branch keys off the method alone, and a nullish body folds to
{}. This is whatpackages/rest'sPUT /meta/:type/:namealready does (const body = req.body ?? {}, then into the save unconditionally), so the per-type schema refuses downstream with422 INVALID_METADATA— the behaviour #8818 measured end to end on the REST door. Two doors onto onesaveMetaItemdisagreeing about what a bodyless metadata write means was the actual defect; they now give one answer, from one authority. No new bespoke error path was added here.What callers see instead of a spurious read:
manage_metadata—422 INVALID_METADATA, with the structuredissuesthe Studio form reads;403 PERMISSION_DENIEDfrom the capability gate, which now runs on this request at all.Pinned in both directions, each predicted before running
packages/runtime/src/domains/meta-put-falsy-body.test.ts.Refusal — all five falsy payloads plus the compound-name form: the ADR-0112 envelope (
statusandcode), that the falsy body was folded to{}at the writer, that nothing was written, and — the load-bearing one — that the read spy was never called, since "was this write served as a read?" is exactly the question that answers.Over-refusal guard — a
PUTcarrying a body still saves, the body still reaches the writer verbatim, and aGETis still served as a read. A pin asserting only the new refusal would be satisfied by breaking every metadata write, so this half is not optional company.Reverse verification, predicted then observed: against unmodified
origin/mainthe seven refusal cases went red and every failure wasexpected 200— the read being served,getMetaItemcalled once,saveMetaItemnever — while the three over-refusal guards passed unchanged. Direction as predicted, no inversion.Verification
Run at
6ebb03ad9, the final commit:pnpm --filter @objectstack/runtime test— 161 files, 2426 tests, all passing (the 7 new pins green; no existing test changed behaviour)pnpm --filter @objectstack/runtime typecheck— cleannode scripts/pm/dispatch-gates.mjs, all passing:check:nul-bytes,check:route-envelope,check:cross-package-test-inputs,check:query-options-erasure,check:type-check-coverageOut-of-scope finding filed
#8848 —
DELETE,PATCHandPOSTon the same path are also answered as reads (measured200+ the item document, read spy called, nothing written). Same surface-lies class reached by a different trigger: the readtrycarries no method guard, and the domain registers nomethodsrestriction. Left alone here deliberately — the fix shape is a decision (refuse with405vs implement the verbs), not a one-liner, and it is outside this card's region.Generated by Claude Code