Repository navigation
Commit 76d9dca
fix(hono)!: createHonoApp reads a dispatched body by plugin-hono-server's rule, so a text/plain or undeclared body is refused when a domain reads it (#24071)
Fixes #24042
Clause-②: no (narrowing)
## What
`createHonoApp` (`@objectstack/hono`) now reads a dispatched body by the
rule `@objectstack/plugin-hono-server` applies (`readRequestBody`,
`packages/plugins/plugin-hono-server/src/request-body.ts`), media type
by media type. Every refusal still rides the refuse-on-read value PR
#24005 introduced for #23914, so a refusal is answered only when a
dispatched domain reads the body, after the dispatcher's gates. One file
changes in `src`: `readDispatchBody`, its docblock, and module-private
copies of the twin's messages and of `declaresBody` beside
`RequestBodyRefusal`. Nothing new is exported.
| body (`POST` / `PUT` / `PATCH` on the catch-all; every method but
`GET` / `HEAD` on the `/auth/*` fallback) | base | head |
|---|---|---|
| declares `application/json` (case-insensitive `includes`) | parsed;
blank is `{}`; unparseable is refused on read | unchanged |
| `multipart/form-data` or `application/x-www-form-urlencoded` | `{}` |
parsed from a CLONE by Hono's `parseBody` (the function
`c.req.parseBody()` runs on the twin); a form that does not parse is
refused on read, with the twin's form message |
| any other media type, or none, when the request declares a body (RFC
9112 section 6.3: `Content-Length` other than `0`, or
`Transfer-Encoding`) | parsed as JSON when it parsed (step 4 honoured),
`{}` otherwise | refused on read, with the twin's message ("The request
body was sent as text/plain, which this server does not read into a
request body. ..."), naming the media type only when it is a plain token
pair |
| no body declared | `{}` (or parsed) | `{}` |
The JSON-sent-as-`text/plain` case (#23914's step 4) is the "other media
type" row: refused, not parsed. `content/docs/api/wire-format.mdx`
already names `Content-Type: application/json` as required (`:11`,
`:594`); no sentence there becomes false, so it is untouched.
## Measured at the door: the real dispatcher and data domain through
`createHonoApp`
A temporary file in `packages/qa/http-conformance` (the
`hono-body-refusal.conformance.test.ts` boot: a real `LiteKernel`,
ObjectQL, sqlite-wasm, the real `HttpDispatcher` and `/data` domain),
each body sent with `Content-Length` as a client sends it. The base
column is the same file with the base reader put back through
`scripts/ablation-replace.mjs` (blob `a4b335ae969d` to `d060fe2070b0`,
restored, `git diff HEAD` empty). The file was deleted after both runs
and is not in the diff.
| request | base reader | head |
|---|---|---|
| step 4: `POST /data/task/query` `{"where": {"status": "churned"}}` as
`text/plain` | 200, records (parsed and honoured) | 400
`INVALID_REQUEST`, "sent as text/plain" |
| `POST /data/task/query` `hello` as `text/plain` | 200, records (`{}`,
the empty query) | 400 `INVALID_REQUEST` |
| `PATCH /data/task/ID` `{"name":"HACKED2"}` as `text/plain` | 200, the
name changed | 400 `INVALID_REQUEST`, the row unchanged |
| `POST /data/task/query` urlencoded `where=...` | 200, records (`{}`,
the empty query) | 400 `VALIDATION_FAILED` (`query.where` is a string) |
| `POST /data/task` urlencoded `name=from_form&status=active` | 201, a
row with no name | 201, a row named `from_form` |
| `PATCH /data/task/ID` urlencoded `name=HACKED` | 200, no field changed
| 200, the name changed |
The three form rows are the parsed fields reaching the domain, which is
what the self-hosted door already hands it (see Acceptance notes).
## PM mechanism assumptions, measured at base `b1d5569d9`
1. **No first-party caller sends a JSON body without `application/json`,
or a body with no `Content-Type`, to the hosted door.** Holds.
- This repo has no production `createHonoApp` host: every importer is a
test (`packages/adapters/hono`, `packages/qa/http-conformance`), docs or
a comment. `packages/cli` mounts none.
- `packages/client`: `this.fetch` defaults `Content-Type:
application/json` (`index.ts:7450`); the one direct `fetchImpl` with a
body is the presigned upload, which goes to the storage surface, not
through `dispatch()` (the dispatcher has no `/storage` domain).
- A windowed scan of 307 request-body sites in 1309 files of
`examples/`, `packages/qa/dogfood`, `scripts/`, `packages/cli/src` and
`packages/client/src`: every platform poster declares JSON; the rest are
GitHub API calls, flow-node configs or response shapes.
- The console at the pin `f8267df93aba` (`.objectui-sha`):
`approvalsApi.call`, `CloudConnectionPanel.getJson`, `FormPage`'s
`apiFetch`, `useRecordApprovals`'s `fetchJson`,
`DatasourceResourcePage`'s `api`, plugin-chatbot's `call` and
`serverActionHandler` all set `application/json`, and none of their
body-carrying calls passes headers that would replace it.
`ApiDataSource` sends `text/plain` only for a string body, and its three
console instances are `GET` doors. No `sendBeacon`. `UploadProvider`'s
XHR PUTs to the presigned raw door. `data-objectstack`'s `uploadFile` /
`uploadFiles` post multipart to `/api/data/OBJECT/upload`, but nothing
calls them at the pin and no server route serves that path.
- NOT MEASURED: the cloud repository, the main `createHonoApp` host, is
not in this container.
2. **Parsing a form from a clone leaves the forwarded request unread.**
Holds: the raw-request form row asserts `bodyUsed === false` at dispatch
and the exact bytes on the request; a new row pins the cache path (an
upstream `c.req.parseBody()`, then the catch-all hands the parsed form
from Hono's cache).
3. **What domains do with a form body now.** Measured on the data domain
(table above). Every domain that reads `body` (data, meta, packages,
actions, analytics, automation, keys, ai, notifications, security,
share-links, permission-set-clone, catalog-activation, mcp) now receives
a form's fields where it received `{}`, as it does on
`plugin-hono-server`. The forwarders (`/approvals/act`,
`/webhooks/redeliver`, the inbound automation hooks) never read `body`,
and the legacy auth domain never reads its `_body`. No first-party
caller posts a form to a body-reading domain (assumption 1); the one
first-party form post, `/approvals/act`, is a forwarder.
4. **`dispatch()` reads no body before its gates.** Holds. `dispatch()`
holds `body` in the handler's argument literal only
(`http-dispatcher.ts` `:2890`, `:2989`) and its gates take the path and
headers. Measured on the real dispatcher and the real plugin-webhooks
member, hosted door against the self-hosted mount over a socket, with a
temporary copy of `hono-webhooks-redeliver.conformance.test.ts` (deleted
after): anonymous `text/plain` garbage, anonymous
`application/octet-stream` and anonymous no-`Content-Type` bodies all
answer `401 UNAUTHENTICATED` at both doors; signed-in `text/plain` and
no-`Content-Type` garbage answer the member's `400 "Request body must be
JSON."` at both; a signed-in valid JSON body sent as `text/plain` for an
unknown delivery answers `404 RESOURCE_NOT_FOUND` at both. Status,
headers and body bytes equal on every row.
## Tests
All runs are under `scripts/pm/os-verify-lock.sh` on head `f0c5d3bdc`,
each exit code captured before any pipe.
- `pnpm --filter @objectstack/hono exec vitest run --maxWorkers=2`: 9
files, 207 passed.
- `pnpm --filter @objectstack/hono typecheck`: exit 0 (`tsc --noEmit`
and `-p tsconfig.test.json`; both edited test files are in that program
by `--listFiles`).
- `pnpm --filter @objectstack/plugin-hono-server exec vitest run
--maxWorkers=2`: 30 files, 386 passed.
- `pnpm --filter @objectstack/http-conformance test`: 14 files, 150
passed. `hono-webhooks-redeliver.conformance.test.ts` (its `not JSON`
row included), `hono-approvals-act.conformance.test.ts` (the form `POST`
redeeming a live token through `createHonoApp`) and
`hono-body-refusal.conformance.test.ts` are unchanged and green: 3
files, 17 passed in a verbose run.
- Closure first: `pnpm --workspace-concurrency=2 --filter
'@objectstack/hono...' --filter '@objectstack/plugin-hono-server...'
--filter '@objectstack/http-conformance...' build`, exit 0.
Pins:
- `hono-body-refusal.test.ts` (43 cases). The parity case drives BOTH
doors over one 21-row table and holds status and body equal: for a
refusal the same `code` and `message`, for a read body the same value.
Rows: valid, truncated, `{ not json`, another case spelling, blank and
whitespace-only JSON; a urlencoded form, a malformed urlencoded form
(read leniently by Hono's parser on both doors, so both answer 200 with
the same fields), a multipart form, and a multipart form that does not
parse; step 4 as `text/plain` and as `text/plain;charset=UTF-8`, plain
text, a body with no `Content-Type`, `application/octet-stream`, a
`+json` suffix type, a media type that is not a token pair, and a body
framed by `Transfer-Encoding`; and three no-body rows. New reader rows:
step 4, plain text, `octet-stream`, no `Content-Type`, the `/auth/*`
fallback and a malformed multipart form, each refused 400 though the
domain answered its own 500. A new forwarder row: a `text/plain` garbage
body reaches the forwarder, which answers its own refusal with every
byte read. The CONTROL form row now expects the parsed fields.
- `hono-catch-all-raw-request.test.ts`: as #24042 mandates, the rows
`JSON sent as text/plain` (`:101`), `invalid JSON that does not declare
JSON` (`:106`) and `plain text` (`:109`) move to the new answers, and so
does `a form body` (`:108`), whose answer the form row of the rule
changes. They move out of the table (which compares against the old
reader line) into a new describe, each sent with `Content-Length`: the
three are handed on as a body whose read throws `status 400` /
`INVALID_REQUEST`, a no-`Content-Type` row is added beside them, and the
form is handed on parsed (`{ token: 'def', x: '1' }`); every one still
asserts `bodyUsed === false` and the exact bytes on the forwarded
request. One more row pins the form cache path. Every other row of the
file is unchanged and green.
## Red at base, green at head, and one ablation per row group
Each leg through `node scripts/ablation-replace.mjs` in WRAP mode
(anchor hit once, the write proven on disk by the anchor count and the
blob, the restore proven: blob equal to `HEAD`'s `a4b335ae969d`, `git
diff HEAD` empty). The suite imports `./index` and resolves
`@objectstack/plugin-hono-server` to its source (`vitest.config.ts`), so
no rebuild sits between a mutation and its run. Run: `pnpm --filter
@objectstack/hono exec vitest run --maxWorkers=2
src/hono-body-refusal.test.ts src/hono-catch-all-raw-request.test.ts`
(63 cases).
| leg | mutation | blob | result |
|---|---|---|---|
| base | `readDispatchBody` replaced by the base body | `a4b335ae969d`
to `d060fe2070b0` | 25 failed (every new row), 38 passed |
| ablation A | the "other media type" branch puts back the
content-type-blind parse | `a4b335ae969d` to `9858d8bde495` | 17 failed:
exactly the undeclared-body rows of both files; every form row green |
| ablation B | the form branch hands `{}` again | `a4b335ae969d` to
`ae5e64d9423c` | 8 failed: exactly the form rows (parity urlencoded,
malformed urlencoded, multipart, malformed multipart; the CONTROL form
row; the reader's malformed-form row; the raw-request form row and its
cache row) |
| head | none | `a4b335ae969d` | 63 passed |
17 plus 8 is the 25 that are red at base.
## Gates
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on `f0c5d3bdc` (no paths, merge base
`b1d5569d9`): 64 commands. In the first sweep, over the closure build,
63 exited 0 and `pnpm check:dual-build-cjs-loads` exited 3, PREREQUISITE
NOT MET (it reads every package's `dist/`). After `pnpm turbo run build
--filter='!@objectstack/docs' --concurrency=2` (exit 0, 75 tasks) it
exited 0: "110 published require entry point(s) across 68 package(s)
load". Reconciliation with that reading: "64 derived, 64 run, 0
NOT-MEASURED, 0 UNRUN".
- `pnpm exec eslint --no-inline-config` over the three changed `.ts`
files: exit 0, no findings. The repo-wide run is CI's `Lint & Repo
Gates`.
- Control-byte scan over the four changed files: no hits.
- `@objectstack/hono`'s built `dist/index.js` (CJS,
`require("hono/utils/body")`) and `dist/index.mjs` both load and export
`createHonoApp`.
## Acceptance notes
- **Form bodies now reach the domains on this door.** A form posted to a
domain that reads `body` was `{}` here and is its parsed fields now, as
on `plugin-hono-server`: on the data domain a form query is `400
VALIDATION_FAILED` where it read every row, a form create writes the
form's fields where it wrote an empty row, and a form `PATCH` applies
where it changed nothing (table above). The rule is the seat's; no
first-party caller posts such a form. `text/plain`, like the two form
types, is a media type a browser sends cross-origin without a preflight,
and a JSON body sent that way is no longer honoured here.
- **Seat ruling B (`6226688716`, supersedes `6226633450`).** The pending
`.changeset/23914-hono-body-refusal.md` bullet "A body that does not
declare JSON is read as before: parsed when it is JSON, `{}` when it is
not." is superseded by this PR's changeset. It is left in place because
the #17712 guard gates any edit of another PR's pending changeset on a
person's confirmation. `carrier:` the release owner, at `changeset
version` time: drop that bullet from the compiled `@objectstack/hono`
entry.
- **Presence by framing headers.** As on the twin, a body is present
only when the request declares `Content-Length` other than `0` or
`Transfer-Encoding`. An in-process `Request` built without either reads
as no body on both doors, and so would a runtime that delivered a body
with neither header. Carrier: none.
- **Message copies.** The twin's `NOT_EMPTY`, `namedMediaType`,
`malformedFormMessage`, `unreadMediaTypeMessage` and `declaresBody` are
mirrored module-private, beside the two messages PR #24005 mirrored; the
parity table holds the two doors' answers equal for every row.
- **The `/auth/*` arm.** The only in-repo reader on that arm, the legacy
auth domain, never reads its `_body`, so no answer changes there today.
- **Not merged with `origin/main`.** `git merge-tree` against
`origin/main` `b6805ac0f` is clean; of the five incoming commits, one
touches `packages/runtime/src/domains` (the read-only refusal on `POST
/packages`) and none touches this reader or its tests.
- **Not run locally:** `test:repo` (CI's `Test Core`).
## Contract review
`Clause-②: no (narrowing)`: the seat serves the `CONTRACT_REVIEW_TIER`
review before the queue. One changeset,
`.changeset/24042-hono-undeclared-body-refusal.md`, `minor` for
`@objectstack/hono` under the launch-window convention for breaking
changes, with the `Clause-②` line, the adr-0087 HTML-comment marker
`not-required (no-migration-prescription)` with the other categories
closed on facts (as #23914's carry), FROM / TO per row, and the
integrator's fix: send `Content-Type: application/json`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 8a83ceb commit 76d9dca
4 files changed
Lines changed: 396 additions & 122 deletions
File tree
- .changeset
- packages/adapters/hono/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
5 | | - | |
6 | | - | |
7 | | - | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
8 | 9 | | |
9 | | - | |
10 | | - | |
11 | | - | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
12 | 16 | | |
13 | 17 | | |
14 | 18 | | |
| |||
22 | 26 | | |
23 | 27 | | |
24 | 28 | | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
25 | 34 | | |
26 | 35 | | |
27 | 36 | | |
| |||
74 | 83 | | |
75 | 84 | | |
76 | 85 | | |
| 86 | + | |
77 | 87 | | |
78 | 88 | | |
79 | | - | |
80 | | - | |
81 | | - | |
82 | | - | |
83 | | - | |
84 | | - | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
85 | 118 | | |
86 | 119 | | |
87 | 120 | | |
88 | 121 | | |
89 | 122 | | |
90 | 123 | | |
91 | 124 | | |
92 | | - | |
| 125 | + | |
93 | 126 | | |
94 | 127 | | |
95 | 128 | | |
96 | 129 | | |
97 | | - | |
| 130 | + | |
98 | 131 | | |
99 | | - | |
| 132 | + | |
100 | 133 | | |
101 | 134 | | |
102 | 135 | | |
| |||
108 | 141 | | |
109 | 142 | | |
110 | 143 | | |
111 | | - | |
112 | | - | |
| 144 | + | |
| 145 | + | |
113 | 146 | | |
114 | 147 | | |
115 | 148 | | |
| |||
134 | 167 | | |
135 | 168 | | |
136 | 169 | | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
137 | 202 | | |
138 | 203 | | |
139 | 204 | | |
| |||
143 | 208 | | |
144 | 209 | | |
145 | 210 | | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
146 | 219 | | |
147 | 220 | | |
148 | 221 | | |
149 | 222 | | |
150 | 223 | | |
151 | 224 | | |
152 | 225 | | |
153 | | - | |
| 226 | + | |
154 | 227 | | |
155 | 228 | | |
156 | 229 | | |
157 | 230 | | |
158 | | - | |
159 | | - | |
160 | | - | |
| 231 | + | |
| 232 | + | |
161 | 233 | | |
162 | 234 | | |
163 | 235 | | |
164 | 236 | | |
165 | 237 | | |
166 | 238 | | |
167 | 239 | | |
168 | | - | |
169 | | - | |
170 | | - | |
171 | | - | |
172 | | - | |
173 | | - | |
174 | | - | |
175 | | - | |
176 | | - | |
177 | | - | |
178 | | - | |
179 | | - | |
180 | | - | |
181 | | - | |
182 | | - | |
183 | | - | |
184 | | - | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
185 | 293 | | |
186 | 294 | | |
0 commit comments