feat: let a module route stream an image-sized upload, explicitly and bounded

ADR 0017 capped a module route at 1 MiB and said large media belonged to a
future storage design. That design arrived — ADR 0030 gave modules a bucket,
ADR 0031 took Ekklesia out of the read path — and it did not solve the write
path: signed write URLs were deliberately declined, so the only way bytes
reach a bucket is a module's own route, and a photograph is not 1 MiB.

Extend the route contract rather than adding a second HTTP system. A route
declares `bodyMode`: `buffered` (the default, and what every existing route
already meant) keeps the 1 MiB ceiling and materializes the body before the
handler runs; `streamed` raises the ceiling to 32 MiB and counts bytes as the
handler consumes them, holding nothing. The mode is explicit rather than
inferred from the size, because inferring it would mean an author who raised a
limit silently changed how their own handler receives its body and when a 413
can reach their own code.

32 MiB holds one 25 MiB image plus bounded overhead and is also the platform's
absolute ceiling: no route in any mode may declare more, and no unbounded
option exists in the contract. The unit is one file per request — twenty
images are twenty individually bounded requests, and nothing here can express
a 500 MiB aggregate whose failure would waste everything already sent.

Replace the buffering dependency with the platform's own limiter. A declared
Content-Length over the limit is refused synchronously, before authorization
and before a byte is read. Buffered mode now counts what actually arrives
rather than trusting the header, which closes a gap the previous
implementation had. Streamed mode wraps the body in a counting transform;
on overflow the stream errors, the source is cancelled, and the response is
replaced with the same sanitized 413 even if the handler swallowed the error —
a limit a handler can swallow is not a limit. An unread body is cancelled so
an abandoned upload releases its socket, and an abort stays an ordinary failed
request rather than becoming a 413.

Ordering is unchanged and now strictly better: in streamed mode no byte is
read until the handler reads it, and the handler runs only after authorization
succeeds, so an unauthorized caller cannot push an upload through at all.

The live suite uploads a real 25 MiB body over a real socket end to end.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-27 15:57:44 -05:00
co-authored by Claude Opus 5
parent c444ff570b
commit e36485bf99
12 changed files with 1093 additions and 50 deletions
+54 -8
View File
@@ -1005,8 +1005,8 @@ export default defineModuleRuntime({
});
```
A route definition has exactly five fields — `method`, `path`, `permission`, `maxBodyBytes`,
`handler` — and nothing else; an unknown field (a baseline, a mount prefix, CORS/origin policy,
A route definition has exactly five required fields — `method`, `path`, `permission`,
`maxBodyBytes`, `handler` — plus one optional `bodyMode`, and nothing else; an unknown field (a baseline, a mount prefix, CORS/origin policy,
middleware, audit metadata, authentication behavior, or another module's namespace) fails
registration outright rather than being ignored. `method` is one of `GET`, `POST`, `PUT`, `PATCH`,
`DELETE`, always uppercase; there is no `OPTIONS` (the platform owns CORS preflight) and no
@@ -1071,15 +1071,61 @@ ones. The platform continues to own authentication, authorization, `Origin`, COR
cache behavior, and uncaught-handler sanitization, and permission enforcement always completes
before your handler runs — an unauthorized caller can never reach your validation at all.
### Request bodies: `maxBodyBytes` and `bodyMode`
`maxBodyBytes` is a conscious, required request-body contract, not an optional or defaulted field:
`0` means the route accepts no body at all, and a non-empty declared (`Content-Length`) or streamed
body is rejected before the handler ever runs; any other value is the exact maximum number of bytes
accepted. It must be a non-negative integer no greater than the platform ceiling,
`MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (`@ekklesia/contracts`, currently 1 MiB) — a negative,
non-integer, non-finite, or over-ceiling value fails registration with
`MODULE_ROUTE_MAX_BODY_BYTES_INVALID`. The platform enforces this limit itself, ahead of permission
evaluation and the handler, using the identical sanitized `413 {"code":"REQUEST_BODY_TOO_LARGE"}`
contract every other body-consuming route in the platform uses — see
accepted. A negative, non-integer, non-finite, or over-ceiling value fails registration with
`MODULE_ROUTE_MAX_BODY_BYTES_INVALID`.
`bodyMode` chooses how that body reaches you, and therefore which ceiling applies
([ADR 0042](../architecture/decisions/0042-bounded-streaming-request-bodies.md)):
| `bodyMode` | Ceiling | What the platform does |
| --- | --- | --- |
| `"buffered"` (default) | 1 MiB | Reads and counts the whole body **before** your handler runs |
| `"streamed"` | 32 MiB | Counts bytes **as you consume them**, and never materializes the body |
```ts
http.route({
method: "POST",
path: "/slides",
permission: "signage.slides.write",
maxBodyBytes: 33_554_432,
bodyMode: "streamed",
async handler(request, context) { /* read request.body as a stream */ },
});
```
Omitting `bodyMode` means `buffered`, which is what every route written before this existed meant.
**Memory.** In `buffered` mode the platform holds at most your declared allowance, which is why that
ceiling is small and why `await request.json()` is safe there. In `streamed` mode the platform holds
nothing: read `request.body` as a stream. Calling `request.arrayBuffer()` on a streamed route works
but throws away the entire point — you have just buffered 32 MiB yourself.
**Streaming.** A declared `Content-Length` over your limit is refused synchronously, before
authorization and before a byte is read. Otherwise the platform counts what actually arrives, so a
misleading or absent `Content-Length` bounds nothing.
**A `413` can arrive after your handler starts.** This is the one consequence of `streamed` mode you
must design for. When the running total passes your limit the stream errors, the source is cancelled,
and the platform replaces your response with the sanitized `413` — even if you caught the error and
answered anyway. If you persist as you read, write to a temporary key and commit only once the read
completes; the platform cannot do that for you, because only your module knows what a partial write
means to its own data.
**Timeouts.** Body reading is bounded by the server's own header and request timeouts, not by a
per-route setting. A slow upload is a slow request, and the platform has one answer for that.
**One file per request.** 32 MiB is the platform's absolute ceiling in every mode — there is no
unbounded option anywhere in the contract. A UI uploading twenty images issues twenty individually
bounded requests; nothing here can express a 500 MiB aggregate upload, deliberately, because one bad
byte in one would waste everything already sent.
Every rejection uses the identical sanitized `413 {"code":"REQUEST_BODY_TOO_LARGE"}` contract every
other body-consuming route in the platform uses — see
[API](../development/api.md#request-body-limits).
`path` is local to the module, never the path a caller actually sends. The platform alone owns the