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:
co-authored by
Claude Opus 5
parent
c444ff570b
commit
e36485bf99
+31
-9
@@ -408,16 +408,28 @@ protection across restarts or replicas.
|
||||
|
||||
## Request body limits
|
||||
|
||||
Every public body-consuming route has an explicit, platform-enforced byte ceiling
|
||||
(`apps/api/src/body-limits.ts`, built on Hono's pinned `hono/body-limit` middleware — verified
|
||||
directly against its source to reject both an oversized declared `Content-Length` and an oversized
|
||||
declared-length-free streamed body without ever buffering the whole payload first):
|
||||
Every public body-consuming route has an explicit, platform-enforced byte ceiling, enforced by the
|
||||
platform's own limiter (`apps/api/src/body-limits.ts`):
|
||||
|
||||
| Surface | Limit |
|
||||
| --- | --- |
|
||||
| `/api/auth/*` (every Better Auth route) | 8 KiB |
|
||||
| `POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke` | 16 KiB |
|
||||
| A module route (`http.route(...)`'s own `maxBodyBytes`) | Up to 1 MiB, the platform ceiling (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`) |
|
||||
| A module route declaring `bodyMode: "buffered"` (the default) | Up to 1 MiB (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`) |
|
||||
| A module route declaring `bodyMode: "streamed"` | Up to 32 MiB (`MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING`), which is also `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES` |
|
||||
|
||||
A declared `Content-Length` over the limit is refused synchronously, before authorization and before
|
||||
a single body byte is read. Beyond that the two module modes differ only in *when* the platform
|
||||
counts ([ADR 0042](../architecture/decisions/0042-bounded-streaming-request-bodies.md)):
|
||||
|
||||
- **`buffered`** materializes the body before the handler runs, counting as it arrives, so a lying or
|
||||
absent `Content-Length` is bounded by what actually arrived. The handler is promised a fully
|
||||
available body within its declared allowance, and the platform holds at most that allowance.
|
||||
- **`streamed`** never materializes the body: it is counted as the handler consumes it, and the
|
||||
moment the total passes the limit the stream errors, the source is cancelled, and the response
|
||||
becomes the same `413` — even if the handler caught the error and answered anyway. A `413` can
|
||||
therefore arrive *after* a streamed handler has started, which is the one consequence a module
|
||||
author must design for.
|
||||
|
||||
An oversized request always receives the identical stable, sanitized contract —
|
||||
`413 {"code":"REQUEST_BODY_TOO_LARGE"}` with `Cache-Control: no-store` — and never invokes Better
|
||||
@@ -425,10 +437,20 @@ Auth's business logic, a Core mutation, permission evaluation (where avoidable),
|
||||
or module database behavior. For a state-changing route the body limit is enforced after
|
||||
`requireTrustedOrigin` but before permission evaluation, so a rejected cross-origin mutation's body
|
||||
is never read for size at all, while an oversized same-origin request still fails before any
|
||||
authorization work runs. See [Module HTTP routes](#module-http-routes) for the module-route body
|
||||
contract itself, and [Module development](../module-development/README.md#http-routes) for the
|
||||
module-authoring side of it. Large media transfer is explicitly out of scope for ordinary module
|
||||
HTTP bodies — that belongs to a future dedicated storage design, never to widening this ceiling.
|
||||
authorization work runs. In `streamed` mode no body byte is read until the handler reads it, and the
|
||||
handler runs only after authorization succeeds, so an unauthorized caller can never push an upload
|
||||
through the platform at all.
|
||||
|
||||
Body reading is bounded in time by the server's own `headersTimeout`/`requestTimeout` below, not by a
|
||||
second per-route timeout. A body no handler read is cancelled after the request, so an abandoned
|
||||
upload releases its socket rather than draining; an aborted upload surfaces as an ordinary failed
|
||||
request rather than a `413`, because nothing exceeded a limit.
|
||||
|
||||
See [Module HTTP routes](#module-http-routes) for the module-route body contract itself, and
|
||||
[Module development](../module-development/README.md#http-routes) for the module-authoring side of
|
||||
it. The absolute ceiling is one image-sized file per request: aggregate uploads and anything larger
|
||||
than 32 MiB remain out of scope, and signed write URLs are still deliberately not offered
|
||||
([ADR 0031](../architecture/decisions/0031-direct-object-delivery.md)).
|
||||
|
||||
## Node request and header timeouts
|
||||
|
||||
|
||||
Reference in New Issue
Block a user