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
+187 -17
View File
@@ -1,6 +1,10 @@
import { MODULE_ROUTE_MAX_BODY_BYTES_CEILING } from "@ekklesia/contracts"; import {
import type { Context } from "hono"; MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
import { bodyLimit } from "hono/body-limit"; MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
type ModuleRouteBodyMode,
} from "@ekklesia/contracts";
import type { Context, MiddlewareHandler } from "hono";
/** /**
* The platform's one request-body policy, centralized here rather than scattered as magic numbers * The platform's one request-body policy, centralized here rather than scattered as magic numbers
@@ -19,15 +23,20 @@ import { bodyLimit } from "hono/body-limit";
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting * field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
* abuse. * abuse.
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same * - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
* constant a module's own route declaration is validated against at registration) is the hard * constant a module's own **buffered** route declaration is validated against at registration) is
* ceiling no module route may exceed and doubles as the platform's overall request-body ceiling: * the ceiling for every body the platform materializes before a handler runs. It has not moved.
* nothing in this platform ever declares, or enforces, a larger single request body than this. * - `MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` (32 MiB) is what a route may declare when it
* Large media transfer is explicitly out of scope for ordinary module HTTP bodies — it belongs to * explicitly opts into `bodyMode: "streamed"` (ADR 0042), and doubles as
* a future dedicated storage design, not to widening this ceiling. * `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES`: no route, in any mode, may declare or accept a larger
* single request body, and no unbounded option exists anywhere in the contract.
*/ */
export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024; export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024;
export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024; export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024;
export { MODULE_ROUTE_MAX_BODY_BYTES_CEILING }; export {
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
};
/** The one stable, sanitized public contract for every request-body-size rejection across the /** The one stable, sanitized public contract for every request-body-size rejection across the
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending * platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
@@ -37,12 +46,173 @@ export function requestBodyTooLargeResponse(c: Context): Response {
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413); return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
} }
/** A single shared `hono/body-limit` factory so every call site uses the identical sanitized 413 /**
* contract rather than redefining `onError` per route. Covers both a declared oversized * A declared `Content-Length`, or `undefined` when there is not exactly one well-formed one.
* `Content-Length` and an oversized chunked/streamed body with no declared length — see *
* `hono/body-limit`'s pinned implementation, which checks `Content-Length` synchronously before * A malformed or contradictory value is treated as *absent* rather than as a rejection, deliberately:
* ever reading the stream, and otherwise reads and counts the stream incrementally, rejecting as * the byte counting below bounds the request either way, so refusing here could only ever turn a
* soon as the running total exceeds `maxSize` rather than buffering the whole body first. */ * legitimate request away without making an illegitimate one any less bounded. Node's own HTTP
export function platformBodyLimit(maxSize: number) { * parser rejects genuinely malformed framing before this code sees the request at all.
return bodyLimit({ maxSize, onError: requestBodyTooLargeResponse }); */
function declaredContentLength(request: Request): number | undefined {
const header = request.headers.get("content-length");
if (header === null || !/^\d{1,15}$/u.test(header.trim())) {
return undefined;
}
return Number.parseInt(header.trim(), 10);
}
/** Releases a body nothing consumed, so an abandoned or oversized upload does not hold a socket
* open waiting to be read. Safe on a stream that is locked, already drained, or already errored. */
function releaseUnreadBody(request: Request): void {
const body = request.body;
if (!body || body.locked) {
return;
}
void body.cancel().catch(() => undefined);
}
/**
* Reads at most `maxSize` bytes, stopping and cancelling the source the moment the running total
* exceeds it. Never allocates beyond what it has already accepted, so an over-limit body costs one
* chunk past the limit rather than the whole upload.
*/
async function readBounded(
body: ReadableStream<Uint8Array>,
maxSize: number,
): Promise<readonly Uint8Array[] | "over-limit"> {
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
return chunks;
}
size += value.byteLength;
if (size > maxSize) {
await reader.cancel().catch(() => undefined);
return "over-limit";
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
}
function streamOf(chunks: readonly Uint8Array[]): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk);
}
controller.close();
},
});
}
/**
* The platform's one request-body limiter, for every route in the application.
*
* ## Both modes reject a declared oversize before anything else happens
*
* A `Content-Length` above the limit is refused synchronously, before authorization, before the
* handler, and before a single body byte is read. That is the cheap rejection an ordinary
* too-large upload takes.
*
* ## `buffered` — the body is materialized, bounded, before the handler runs
*
* The exact behaviour every route had before streaming existed, now with the counting done here
* rather than by a dependency. Bytes are counted as they arrive and the request is rejected the
* moment the running total passes the limit, so a lying or absent `Content-Length` is bounded by
* what actually arrived rather than by what was claimed. The handler then receives a request whose
* body is fully available and provably within its declared allowance — which is what makes
* `await request.json()` safe in a module handler.
*
* The platform holds at most the route's own declared allowance in memory, and a buffered route may
* declare at most 1 MiB. That bound is the whole reason the buffered ceiling has not moved.
*
* ## `streamed` — the body is counted as the handler consumes it, and never materialized
*
* Nothing is buffered. The body is wrapped in a counting transform and handed to the handler, which
* reads it as a stream; the moment the running total passes the limit the stream errors, the source
* is cancelled, and the response is replaced with the same sanitized `413` — even if the handler
* caught the error and tried to answer anyway. That last part matters: a limit a handler can swallow
* is not a limit.
*
* The consequence a module author must know is that in this mode a `413` can arrive **after** the
* handler has started, so a handler that persists as it reads has to be prepared for a partially
* consumed upload. Writing to a temporary key and committing only after the read completes is the
* pattern; the platform cannot do it for you, because only the module knows what a partial write
* means to its own data.
*
* ## Ordering
*
* This runs before permission evaluation for both modes, so a request that is *declared* too large
* never reaches authorization work. In `streamed` mode no body byte is read until the handler reads
* it, and the handler only runs after authorization succeeds — so an unauthorized caller can never
* push a large upload through the platform at all.
*
* ## Timeouts
*
* Body reading is bounded in time by the server's own `headersTimeout`/`requestTimeout`
* (`apps/api/src/server.ts`), not by anything here. A slow upload is a slow *request*, and the
* platform already has one answer for that rather than a second per-route one.
*/
export function platformBodyLimit(
maxSize: number,
mode: ModuleRouteBodyMode = "buffered",
): MiddlewareHandler {
return async function enforceBodyLimit(c, next) {
const request = c.req.raw;
const declared = declaredContentLength(request);
if (declared !== undefined && declared > maxSize) {
return requestBodyTooLargeResponse(c);
}
if (!request.body) {
return next();
}
if (mode === "buffered") {
const chunks = await readBounded(request.body, maxSize);
if (chunks === "over-limit") {
return requestBodyTooLargeResponse(c);
}
c.req.raw = new Request(request, {
body: streamOf(chunks),
// Required by undici whenever a `Request` is constructed from a stream.
duplex: "half",
} as RequestInit);
return next();
}
let overLimit = false;
let total = 0;
const counted = request.body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
total += chunk.byteLength;
if (total > maxSize) {
overLimit = true;
// Erroring the transform propagates back to the source and cancels it, so the socket is
// released rather than left draining an upload nobody will read.
controller.error(new Error("REQUEST_BODY_TOO_LARGE"));
return;
}
controller.enqueue(chunk);
},
}),
);
c.req.raw = new Request(request, { body: counted, duplex: "half" } as RequestInit);
await next();
if (overLimit) {
c.res = requestBodyTooLargeResponse(c);
}
releaseUnreadBody(c.req.raw);
return;
};
} }
+6 -3
View File
@@ -235,9 +235,12 @@ export function mountModuleHttpRoutes(
const requireRoutePermission = requirePermission(middlewareOptions, route.permission); const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at // The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
// all) is enforced before permission evaluation ever runs, so an oversized request never // all) is enforced before permission evaluation ever runs, so a request *declared* oversized
// reaches authorization work, let alone the handler or the module database. // never reaches authorization work, let alone the handler or the module database. In
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes); // `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
// (ADR 0042).
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
const path = moduleRoutePath(route.moduleId, route.path); const path = moduleRoutePath(route.moduleId, route.path);
const finalHandler = async (c: Context): Promise<Response> => { const finalHandler = async (c: Context): Promise<Response> => {
const context: ModuleRouteHandlerContext = Object.freeze({ const context: ModuleRouteHandlerContext = Object.freeze({
+100
View File
@@ -432,6 +432,39 @@ async function createFixtureModule(
async handler(request, context) { async handler(request, context) {
return Response.json({ created: true, principal: context.principal }, { status: 201 }); return Response.json({ created: true, principal: context.principal }, { status: 201 });
}, },
});
// An image-sized streamed upload (ADR 0042). The handler counts bytes as they
// arrive and never materializes the body, which is exactly what a real module
// storing an upload would do.
http.route({
method: "POST",
path: "/uploads",
permission: "calendar.events.read",
maxBodyBytes: 33554432,
bodyMode: "streamed",
async handler(request) {
let bytes = 0;
if (request.body) {
const reader = request.body.getReader();
for (;;) {
const chunk = await reader.read();
if (chunk.done) { break; }
bytes += chunk.value.byteLength;
}
}
return Response.json({ bytes }, { status: 201 });
},
});
http.route({
method: "POST",
path: "/small-uploads",
permission: "calendar.events.read",
maxBodyBytes: 64,
bodyMode: "streamed",
async handler(request) {
await request.arrayBuffer().catch(() => undefined);
return Response.json({ swallowed: true }, { status: 201 });
},
});` });`
: "" : ""
} }
@@ -2685,6 +2718,73 @@ describe.sequential("live API — installed module permission end-to-end", () =>
expect(await trustedResponse.json()).toMatchObject({ created: true }); expect(await trustedResponse.json()).toMatchObject({ created: true });
}); });
it("streams one image-sized upload end to end over real HTTP", async () => {
// 25 MiB — an ordinary photograph, and the size the streamed ceiling exists to accommodate. It
// travels through a real socket, a real Node server, and the platform's byte counter, and the
// platform holds none of it (ADR 0042).
const image = new Uint8Array(25 * 1024 * 1024).fill(0x41);
const response = await fetch(`${origin}/api/v1/modules/calendar/uploads`, {
body: image,
headers: {
"content-type": "application/octet-stream",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(201);
expect(await response.json()).toEqual({ bytes: image.byteLength });
}, 60_000);
it("refuses a declared oversize on a streamed route with a sanitized 413", async () => {
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
body: "a".repeat(65),
headers: {
"content-type": "text/plain",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
});
it("terminates a chunked streamed body that exceeds the limit, even when the handler swallowed it", async () => {
// No declared `Content-Length`, so only the running byte count can bound this — and the
// handler deliberately ignores the stream error and answers `201`. The platform's `413` still
// wins: a limit a handler can swallow is not a limit.
const oversize = new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(new Uint8Array(512).fill(0x41));
},
});
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
body: oversize,
duplex: "half",
headers: { cookie: ownerCookie, origin: dashboardOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
}, 30_000);
it("keeps the server healthy after an oversized upload", async () => {
// The socket was released rather than left draining, so the very next request is served
// normally.
const response = await fetch(`${origin}/api/v1/modules/calendar/events/evt-43`, {
headers: { cookie: ownerCookie },
});
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ eventId: "evt-43" });
});
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => { it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
const admin = await openAdministrativeClient(database.url); const admin = await openAdministrativeClient(database.url);
try { try {
+379 -4
View File
@@ -58,14 +58,17 @@ function fakeStore(): AuthorizationStore {
}; };
} }
/** Defaults `maxBodyBytes` to a generous value for tests that are not themselves exercising the /** Defaults `maxBodyBytes` and `bodyMode` for tests that are not themselves exercising the
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not * body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
* each need to restate an unrelated body-size choice; the dedicated "module route body limits" * each need to restate an unrelated body-size choice; the dedicated "module route body limits"
* suite further down overrides it explicitly to prove the contract itself. */ * suite further down overrides them explicitly to prove the contract itself. */
function moduleRoute( function moduleRoute(
fields: Omit<RegisteredModuleHttpRoute, "maxBodyBytes"> & { maxBodyBytes?: number }, fields: Omit<RegisteredModuleHttpRoute, "bodyMode" | "maxBodyBytes"> & {
bodyMode?: RegisteredModuleHttpRoute["bodyMode"];
maxBodyBytes?: number;
},
): RegisteredModuleHttpRoute { ): RegisteredModuleHttpRoute {
return Object.freeze({ maxBodyBytes: 1024, ...fields }); return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
} }
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting, /** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
@@ -1634,3 +1637,375 @@ describe("module route overlap can never reach mounting", () => {
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true); expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
}); });
}); });
/**
* Explicit, bounded streaming uploads (ADR 0042).
*
* The distinction being proven throughout is *when* the platform knows a body is too large, and what
* it holds while finding out. In `buffered` mode the answer is "before the handler runs, holding at
* most the route's own declared allowance". In `streamed` mode it is "as the handler consumes it,
* holding nothing" — and the `413` still wins, even when the handler tried to answer anyway.
*/
describe("module route streamed body limits", () => {
const oneMebibyte = 1024 * 1024;
/** A body of `size` bytes delivered in realistic chunks, with no declared `Content-Length` — the
* chunked-transfer shape a browser `fetch` of a `ReadableStream` actually produces. */
function chunkedBody(size: number, chunkSize = 64 * 1024): ReadableStream<Uint8Array> {
let sent = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (sent >= size) {
controller.close();
return;
}
const next = Math.min(chunkSize, size - sent);
sent += next;
controller.enqueue(new Uint8Array(next).fill(97));
},
});
}
function streamingApp(options: {
readonly handler: ModuleRouteHandler;
readonly maxBodyBytes: number;
readonly evaluator?: AuthorizationEvaluator;
}) {
return buildModuleApp({
...(options.evaluator ? { evaluator: options.evaluator } : {}),
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
bodyMode: "streamed",
handler: options.handler,
maxBodyBytes: options.maxBodyBytes,
method: "POST",
moduleId: "calendar",
path: "/uploads",
permission: writePermission,
}),
],
});
}
/** Counts what the handler actually received, which is the only honest way to tell "streamed" from
* "buffered then handed over". */
async function countingHandler(received: { bytes: number }): Promise<ModuleRouteHandler> {
return async (request) => {
const body = request.body;
if (!body) {
return Response.json({ bytes: 0 });
}
const reader = body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
received.bytes += value.byteLength;
}
return Response.json({ bytes: received.bytes });
};
}
it("accepts a body of exactly the declared limit", async () => {
const received = { bytes: 0 };
const { app } = streamingApp({
handler: await countingHandler(received),
maxBodyBytes: oneMebibyte,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(oneMebibyte),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ bytes: oneMebibyte });
});
it("rejects a declared Content-Length over the limit before the handler runs", async () => {
const received = { bytes: 0 };
const counting = await countingHandler(received);
const handler = vi.fn<ModuleRouteHandler>(counting);
const { app } = streamingApp({ handler, maxBodyBytes: 1024 });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(2048, 16),
duplex: "half",
headers: { "content-length": String(1025), origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
// The cheap rejection: the declared size alone decided it, so no handler work happened and no
// byte of the upload reached module code.
expect(handler).not.toHaveBeenCalled();
expect(received.bytes).toBe(0);
});
it("terminates a body whose actual size exceeds a smaller declared Content-Length", async () => {
// A misleading `Content-Length` buys nothing: the running byte count is what bounds the request,
// so a header claiming 8 bytes cannot smuggle a megabyte past the limit.
const received = { bytes: 0 };
const { app } = streamingApp({
handler: await countingHandler(received),
maxBodyBytes: 64,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(4096, 32),
duplex: "half",
headers: { "content-length": "8", origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
// It stopped promptly rather than draining the whole upload first.
expect(received.bytes).toBeLessThanOrEqual(96);
});
it("answers 413 even when the handler swallowed the stream error and tried to respond", async () => {
// A limit a handler can swallow is not a limit.
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
try {
await request.arrayBuffer();
} catch {
// Deliberately ignored, exactly as a careless module might.
}
return Response.json({ ok: "handler won" });
});
const { app } = streamingApp({ handler, maxBodyBytes: 64 });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(4096, 32),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(handler).toHaveBeenCalledTimes(1);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
});
it("never buffers the body: the handler sees the first bytes before the last are sent", async () => {
let released: (() => void) | undefined;
const finished = new Promise<void>((resolve) => {
released = resolve;
});
let firstChunkSeen = false;
let secondChunkQueued = false;
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
if (!secondChunkQueued) {
secondChunkQueued = true;
controller.enqueue(new Uint8Array(16).fill(97));
return;
}
// Only produce the rest once the handler has demonstrably consumed the first chunk. A
// buffering platform would deadlock here rather than reach the assertion below.
await finished;
controller.close();
},
});
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
const reader = (request.body as ReadableStream<Uint8Array>).getReader();
const first = await reader.read();
firstChunkSeen = !first.done;
released?.();
for (;;) {
const next = await reader.read();
if (next.done) {
break;
}
}
return Response.json({ ok: true });
});
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(firstChunkSeen).toBe(true);
expect(response.status).toBe(200);
});
it("releases an aborted upload without taking the platform down", async () => {
const aborted = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(16).fill(97));
controller.error(new Error("client went away"));
},
});
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
await request.arrayBuffer();
return Response.json({ ok: true });
});
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: aborted,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
// An abort is a failed request, never an unhandled rejection or a 413: nothing exceeded a limit.
expect(response.status).toBe(500);
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
});
it("releases a body the handler never read", async () => {
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
cancel() {
cancelled = true;
},
pull(controller) {
controller.enqueue(new Uint8Array(16).fill(97));
},
});
const { app } = streamingApp({
handler: async () => Response.json({ ok: true }),
maxBodyBytes: oneMebibyte,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(200);
expect(cancelled).toBe(true);
});
it("refuses a declared oversize before authorization, and never streams for a denied caller", async () => {
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: false }),
};
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = buildModuleApp({
evaluator,
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
routes: [
moduleRoute({
bodyMode: "streamed",
handler,
maxBodyBytes: 64,
method: "POST",
moduleId: "calendar",
path: "/uploads",
permission: writePermission,
}),
],
});
const declaredOversize = await app.request("/api/v1/modules/calendar/uploads", {
body: "a".repeat(65),
headers: { "content-length": "65", origin: trustedOrigin },
method: "POST",
});
expect(declaredOversize.status).toBe(413);
expect(evaluator.authorize).not.toHaveBeenCalled();
// A within-limit body from a denied caller is refused by authorization, and the handler — the
// only thing that ever reads a byte in streamed mode — never runs.
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(32, 8),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(handler).not.toHaveBeenCalled();
});
it("still rejects an untrusted Origin before any body handling", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(4096),
duplex: "half",
headers: { origin: untrustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(handler).not.toHaveBeenCalled();
});
it("keeps every rejection sanitized, naming no limit, size, or body content", async () => {
const { app } = streamingApp({
handler: async () => Response.json({ ok: true }),
maxBodyBytes: 64,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: "secret-payload-".repeat(64),
headers: { "content-length": String(15 * 64), origin: trustedOrigin },
method: "POST",
});
const body = await response.text();
expect(response.status).toBe(413);
expect(body).toBe('{"code":"REQUEST_BODY_TOO_LARGE"}');
expect(body).not.toContain("secret-payload");
expect(body).not.toContain("64");
expect(response.headers.get("cache-control")).toBe("no-store");
});
it("still materializes a buffered body before the handler runs", async () => {
// The pre-existing promise, unchanged: a buffered handler may call `request.json()` and know the
// body is already there and already within its declared allowance.
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
const text = await request.text();
return Response.json({ length: text.length });
});
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
bodyMode: "buffered",
handler,
maxBodyBytes: 128,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const withinLimit = await app.request("/api/v1/modules/calendar/events", {
body: chunkedBody(128, 16),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(withinLimit.status).toBe(200);
expect(await withinLimit.json()).toEqual({ length: 128 });
const overLimit = await app.request("/api/v1/modules/calendar/events", {
body: chunkedBody(129, 16),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(overLimit.status).toBe(413);
// A buffered route's handler never sees an over-limit request at all.
expect(handler).toHaveBeenCalledTimes(1);
});
});
+2
View File
@@ -60,6 +60,7 @@ export type ModuleRuntimeIssueCode =
| "MODULE_ROLLBACK_TIMEOUT" | "MODULE_ROLLBACK_TIMEOUT"
| "MODULE_ROUTE_DEFINITION_INVALID" | "MODULE_ROUTE_DEFINITION_INVALID"
| "MODULE_ROUTE_DUPLICATE" | "MODULE_ROUTE_DUPLICATE"
| "MODULE_ROUTE_BODY_MODE_INVALID"
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" | "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
| "MODULE_ROUTE_METHOD_INVALID" | "MODULE_ROUTE_METHOD_INVALID"
| "MODULE_ROUTE_OVERLAP" | "MODULE_ROUTE_OVERLAP"
@@ -467,6 +468,7 @@ class StagedModuleHttpRoutes {
shapes.add(shapeKey); shapes.add(shapeKey);
local.push( local.push(
Object.freeze({ Object.freeze({
bodyMode: parsed.data.bodyMode,
handler: parsed.data.handler, handler: parsed.data.handler,
maxBodyBytes: parsed.data.maxBodyBytes, maxBodyBytes: parsed.data.maxBodyBytes,
method: parsed.data.method, method: parsed.data.method,
+1
View File
@@ -861,6 +861,7 @@ describe("module HTTP routes", () => {
expect(runtime.routes).toHaveLength(3); expect(runtime.routes).toHaveLength(3);
for (const route of runtime.routes) { for (const route of runtime.routes) {
expect(Object.keys(route).sort()).toEqual([ expect(Object.keys(route).sort()).toEqual([
"bodyMode",
"handler", "handler",
"maxBodyBytes", "maxBodyBytes",
"method", "method",
@@ -0,0 +1,160 @@
# 0042: Explicit, bounded streaming request bodies
- Status: Accepted
- Date: 2026-08-27
- Supersedes: none
- Related: [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md),
[ADR 0017](0017-public-request-perimeter-hardening.md),
[ADR 0030](0030-host-provided-module-object-storage.md),
[ADR 0031](0031-direct-object-delivery.md)
## Context
ADR 0017 gave every public route an enforced request-body ceiling and capped a module route at
1 MiB, with an explicit note that "large media transfer belongs to a future dedicated storage
design, never to widening this ceiling."
The storage design arrived — ADR 0030 gave modules a scoped object store, ADR 0031 took Ekklesia out
of the *read* path with signed delivery URLs — and it did not solve the *write* path. ADR 0031
deliberately declined to offer signed write URLs, so the only way bytes reach a module's bucket is
through that module's own HTTP route. That route is capped at 1 MiB, and an ordinary photograph is
not 1 MiB. A church uploading a slide for a lobby screen cannot, today, get the file in.
Two properties of the existing implementation also need naming, because they are what the change has
to preserve and what it has to fix:
- The pinned body-limit middleware **fully buffers** any body with no declared `Content-Length`
before deciding. At 1 MiB that is a bounded, acceptable cost. At image sizes it would mean the
platform holding every concurrent upload in memory.
- With a declared `Content-Length` within the limit it *counts nothing at all*, trusting the header.
Node's HTTP framing makes that safe today, but it is trust the platform does not need to extend.
## Decision
Extend the existing route contract. There is no second HTTP system, no upload endpoint, no signed
write URL, and no new middleware stack — one route declaration, one limiter, one `413`.
### A route declares how its body reaches it
`ModuleRouteDefinitionInput` gains one optional field:
```ts
http.route({
method: "POST",
path: "/uploads",
permission: "signage.slides.write",
maxBodyBytes: 33_554_432,
bodyMode: "streamed",
handler,
});
```
- **`buffered`** (the default, and what every route written before this meant) — the platform reads
and counts the whole body **before** the handler runs, so the handler is promised a fully
available, bounded body and an oversized request is rejected before any authorization or handler
work happens. Ceiling: 1 MiB, unchanged.
- **`streamed`** — the platform counts bytes **as the handler consumes them** and never materializes
the body. Ceiling: 32 MiB.
The mode is **explicit rather than inferred from the size**. Inferring it — "over 1 MiB means
streamed" — would mean an author who raised a limit silently changed how their own handler receives
its body, and when a `413` can arrive relative to their own code. That is exactly the class of
invisible change this repository refuses.
Omitting the field means `buffered`, so no existing route changed behaviour, and the canonical parsed
form always carries a mode so nothing downstream re-applies the default or disagrees about it.
### The ceilings, and why 32 MiB
`MODULE_ROUTE_MAX_BODY_BYTES_CEILING` stays at 1 MiB for buffered bodies: a body the platform
materializes is a body the platform is holding, and 1 MiB is the most a JSON request has any business
being.
`MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` is 32 MiB, sized to hold one 25 MiB image plus bounded
overhead — a multipart envelope, its boundaries and part headers, and room for a client that pads. It
is also `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES`: **no route, in any mode, may declare or accept a
larger single request body, and no unbounded option exists anywhere in the contract.** A route
declaring more than its mode's ceiling fails registration, exactly as an over-ceiling value always
has.
The unit is deliberately **one file per request**. A UI offering twenty images issues twenty
individually bounded requests, each of which the platform can reject cheaply and each of which fails
alone; a 500 MiB aggregate request would make one bad byte waste everything already sent, and nothing
in this contract can express one.
### Enforcement, in one limiter
The platform's own limiter replaces the buffering dependency. It is the same code for both modes and
differs only in *when* it counts:
1. A declared `Content-Length` above the limit is refused synchronously — before authorization,
before the handler, before a single body byte is read. That is the cheap rejection an ordinary
too-large upload takes. A malformed or contradictory header is treated as *absent* rather than as
a rejection, because the byte counting bounds the request either way and refusing could only turn
a legitimate request away.
2. `buffered`: bytes are counted as they arrive and the request is rejected the moment the running
total passes the limit — so a lying or absent `Content-Length` is bounded by what actually
arrived rather than by what was claimed, which the previous implementation did not do. At most
the route's own declared allowance is ever held.
3. `streamed`: the body is wrapped in a counting transform and handed to the handler. The moment the
total passes the limit the stream errors, the source is cancelled, and the response is replaced
with the same sanitized `413` — **even if the handler caught the error and answered anyway**. A
limit a handler can swallow is not a limit.
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, never a `413`:
nothing exceeded a limit.
Ordering is unchanged and remains deliberate: trusted-origin check, then the body contract, then
permission evaluation, then the handler. 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 — strictly better than the buffered path.
Timeouts stay where they were: the server's own `headersTimeout`/`requestTimeout`. A slow upload is a
slow *request*, and the platform already has one answer for that rather than a second per-route one.
### What a module author must know
In `streamed` mode a `413` can arrive **after** the handler has started. A handler that persists as
it reads must be prepared for a partially consumed upload — write to a temporary key and commit only
after the read completes. The platform cannot do that for the module, because only the module knows
what a partial write means to its own data.
## Consequences
- An ordinary image upload works, through the route contract a module already had, with the platform
holding none of it.
- The buffered path now counts bytes it previously trusted a header for, closing a gap rather than
opening one.
- Every existing route and every existing test is unchanged: the default is the old behaviour, and
the `413` contract, ordering, and sanitization are identical.
- `apps/api` owns ~60 lines of stream plumbing it previously delegated. That is the price of a
limiter that neither buffers a large body nor can be swallowed by a handler, and it is enforced by
tests rather than by reading the dependency's source.
- Media larger than 32 MiB — a video — still has no path in. That is deliberate and named below.
## Alternatives
- **Raise the single ceiling to 32 MiB.** Rejected: every existing route would silently gain the
ability to have 32 MiB buffered in memory on its behalf, and `await request.json()` in a module
handler would stop being a bounded operation.
- **Infer streaming from `maxBodyBytes`.** Rejected: it makes a size change silently change delivery
semantics and error timing for the author's own code.
- **A separate upload endpoint or second HTTP system.** Rejected: a second route table, a second
authorization path, a second CORS and origin policy, and a second failure vocabulary, to solve
something the existing route contract can express with one field.
- **Signed write URLs to object storage.** Deliberately still deferred (ADR 0031). A signed write URL
is a bearer capability to *create* an object, which needs quota, content-type, and lifecycle
answers this pass does not have — and a module that wants to validate, transform, or authorize per
byte cannot use one anyway.
- **Let the handler enforce its own limit.** Rejected: a limit enforced by the party being protected
against is not a limit, and every module would reimplement it slightly differently.
- **Trust `Content-Length` in streamed mode.** Rejected: the running byte count costs nothing and
removes the need to reason about framing at all.
## Deferred
Signed write URLs, resumable or chunked multi-request uploads, per-module or per-installation upload
quotas, content-type or magic-byte validation, virus scanning, image transformation, progress
reporting, and any body larger than the absolute ceiling — a video pipeline is its own design, not a
larger number here.
+1
View File
@@ -50,3 +50,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted - [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
- [0040: Module stylesheets, and every browser artifact gets an isolated root](0040-module-stylesheets-and-artifact-isolation.md) — Accepted - [0040: Module stylesheets, and every browser artifact gets an isolated root](0040-module-stylesheets-and-artifact-isolation.md) — Accepted
- [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted - [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted
- [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted
+31 -9
View File
@@ -408,16 +408,28 @@ protection across restarts or replicas.
## Request body limits ## Request body limits
Every public body-consuming route has an explicit, platform-enforced byte ceiling Every public body-consuming route has an explicit, platform-enforced byte ceiling, enforced by the
(`apps/api/src/body-limits.ts`, built on Hono's pinned `hono/body-limit` middleware — verified platform's own limiter (`apps/api/src/body-limits.ts`):
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):
| Surface | Limit | | Surface | Limit |
| --- | --- | | --- | --- |
| `/api/auth/*` (every Better Auth route) | 8 KiB | | `/api/auth/*` (every Better Auth route) | 8 KiB |
| `POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke` | 16 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 — 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 `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 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 `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 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 authorization work runs. In `streamed` mode no body byte is read until the handler reads it, and the
contract itself, and [Module development](../module-development/README.md#http-routes) for the handler runs only after authorization succeeds, so an unauthorized caller can never push an upload
module-authoring side of it. Large media transfer is explicitly out of scope for ordinary module through the platform at all.
HTTP bodies — that belongs to a future dedicated storage design, never to widening this ceiling.
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 ## Node request and header timeouts
+54 -8
View File
@@ -1005,8 +1005,8 @@ export default defineModuleRuntime({
}); });
``` ```
A route definition has exactly five fields — `method`, `path`, `permission`, `maxBodyBytes`, A route definition has exactly five required fields — `method`, `path`, `permission`,
`handler` — and nothing else; an unknown field (a baseline, a mount prefix, CORS/origin policy, `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 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`, 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 `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 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. 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: `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 `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 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, accepted. A negative, non-integer, non-finite, or over-ceiling value fails registration with
`MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (`@ekklesia/contracts`, currently 1 MiB) — a negative, `MODULE_ROUTE_MAX_BODY_BYTES_INVALID`.
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 `bodyMode` chooses how that body reaches you, and therefore which ceiling applies
evaluation and the handler, using the identical sanitized `413 {"code":"REQUEST_BODY_TOO_LARGE"}` ([ADR 0042](../architecture/decisions/0042-bounded-streaming-request-bodies.md)):
contract every other body-consuming route in the platform uses — see
| `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). [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 `path` is local to the module, never the path a caller actually sends. The platform alone owns the
+93 -9
View File
@@ -25,21 +25,72 @@ export function isModuleHttpMethod(value: unknown): value is ModuleHttpMethod {
} }
/** /**
* The platform-owned hard ceiling on any single module route's declared request-body allowance, in * How a route's request body reaches its handler, and therefore which ceiling applies to it
* bytes. No module route may declare a `maxBodyBytes` above this value — validated at registration * (ADR 0042).
* (`safeParseModuleRouteDefinition`, below), not merely documented. This is a security contract, *
* not a manifest capability: it does not gate on `manifestVersion` and is enforced identically for * - `buffered` (the default, and everything that existed before): the platform reads and counts the
* every installed module regardless of its declared compatibility range. Large media transfer * whole body **before** the handler runs, so the handler is promised a fully available, bounded
* belongs to a future dedicated storage design, never to widening this ceiling. * body and an oversized request is rejected before any authorization or handler work happens.
* Bounded by {@link MODULE_ROUTE_MAX_BODY_BYTES_CEILING}, deliberately small.
* - `streamed`: the platform counts bytes **as the handler consumes them** and never materializes
* the body. That is what makes an ordinary image upload possible without the platform holding it
* in memory, and it is the whole reason the larger ceiling exists.
*
* Selecting the mode is an explicit, conscious decision rather than something inferred from the size
* a route asked for. Inferring it would mean an author who raised a limit silently changed how their
* own handler receives its body — and when a `413` can arrive relative to their own code.
*/
export const MODULE_HTTP_BODY_MODES = ["buffered", "streamed"] as const;
export type ModuleRouteBodyMode = (typeof MODULE_HTTP_BODY_MODES)[number];
const moduleRouteBodyModes: ReadonlySet<string> = new Set(MODULE_HTTP_BODY_MODES);
export function isModuleRouteBodyMode(value: unknown): value is ModuleRouteBodyMode {
return typeof value === "string" && moduleRouteBodyModes.has(value);
}
/**
* The platform-owned hard ceiling on a **buffered** module route's declared request-body allowance,
* in bytes. No buffered route may declare a `maxBodyBytes` above this value — validated at
* registration (`safeParseModuleRouteDefinition`, below), not merely documented. This is a security
* contract, not a manifest capability: it does not gate on `manifestVersion` and is enforced
* identically for every installed module regardless of its declared compatibility range.
*
* It stays exactly where it was. A body the platform materializes before a handler runs is a body
* the platform is holding in memory, and 1 MiB is the most a JSON request has any business being.
*/ */
export const MODULE_ROUTE_MAX_BODY_BYTES_CEILING = 1_048_576; export const MODULE_ROUTE_MAX_BODY_BYTES_CEILING = 1_048_576;
/**
* The platform-owned hard ceiling on a **streamed** module route's declared allowance, in bytes,
* and the platform's absolute request-body ceiling: nothing in Ekklesia may declare or accept a
* larger single request body, and there is no unbounded option anywhere in the contract.
*
* 32 MiB, chosen to hold one 25 MiB image plus bounded request overhead — a multipart envelope, its
* boundaries and part headers, and a modest allowance for a client that pads. The unit is
* deliberately *one file*: a UI offering twenty images issues twenty individually bounded requests,
* each of which the platform can reject cheaply, rather than one 500 MiB request whose failure
* wastes everything already sent. Nothing in this contract can express an aggregate upload.
*/
export const MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING = 33_554_432;
/** The largest body any module route may declare, in any mode. */
export const MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES = MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING;
/** The ceiling that applies to one delivery mode. */
export function moduleRouteBodyCeiling(mode: ModuleRouteBodyMode): number {
return mode === "streamed"
? MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING
: MODULE_ROUTE_MAX_BODY_BYTES_CEILING;
}
const moduleRouteMaxBodyBytesSchema = z const moduleRouteMaxBodyBytesSchema = z
.number() .number()
.int() .int()
.finite() .finite()
.min(0) .min(0)
.max(MODULE_ROUTE_MAX_BODY_BYTES_CEILING); .max(MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES);
const MODULE_ROUTE_PATH_MAX_LENGTH = 256; const MODULE_ROUTE_PATH_MAX_LENGTH = 256;
const staticSegmentPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; const staticSegmentPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
@@ -265,6 +316,12 @@ export interface ModuleRouteDefinitionInput {
readonly path: string; readonly path: string;
readonly permission: string; readonly permission: string;
readonly maxBodyBytes: number; readonly maxBodyBytes: number;
/**
* How the body reaches the handler, and therefore which ceiling `maxBodyBytes` is checked
* against (ADR 0042). Omitted means `buffered`, which is what every route written before this
* existed meant, so adding this field changed no existing route's behaviour.
*/
readonly bodyMode?: ModuleRouteBodyMode;
readonly handler: ModuleRouteHandler; readonly handler: ModuleRouteHandler;
} }
@@ -275,6 +332,9 @@ export interface ModuleRouteDefinition {
readonly path: ModuleRoutePath; readonly path: ModuleRoutePath;
readonly permission: PermissionId; readonly permission: PermissionId;
readonly maxBodyBytes: number; readonly maxBodyBytes: number;
/** Always present in the canonical form, defaulted to `buffered`, so no consumer downstream has
* to re-apply the default and none of them can disagree about it. */
readonly bodyMode: ModuleRouteBodyMode;
readonly handler: ModuleRouteHandler; readonly handler: ModuleRouteHandler;
} }
@@ -294,10 +354,20 @@ export interface RegisteredModuleHttpRoute {
readonly path: ModuleRoutePath; readonly path: ModuleRoutePath;
readonly permission: PermissionId; readonly permission: PermissionId;
readonly maxBodyBytes: number; readonly maxBodyBytes: number;
readonly bodyMode: ModuleRouteBodyMode;
readonly handler: ModuleRouteHandler; readonly handler: ModuleRouteHandler;
} }
const moduleRouteDefinitionKeys = new Set([ const moduleRouteDefinitionKeys = new Set([
"method",
"path",
"permission",
"maxBodyBytes",
"bodyMode",
"handler",
]);
const requiredModuleRouteDefinitionKeys = new Set([
"method", "method",
"path", "path",
"permission", "permission",
@@ -314,15 +384,18 @@ function hasModuleRouteDefinitionShape(value: unknown): value is {
path: unknown; path: unknown;
permission: unknown; permission: unknown;
maxBodyBytes: unknown; maxBodyBytes: unknown;
bodyMode: unknown;
handler: unknown; handler: unknown;
} { } {
if (!isRecord(value)) { if (!isRecord(value)) {
return false; return false;
} }
const keys = Object.keys(value); const keys = Object.keys(value);
// Every required field present, `bodyMode` optional, and nothing else at all: a route definition
// still cannot carry a baseline, a mount prefix, middleware, or another module's namespace.
return ( return (
keys.length === moduleRouteDefinitionKeys.size &&
keys.every((key) => moduleRouteDefinitionKeys.has(key)) && keys.every((key) => moduleRouteDefinitionKeys.has(key)) &&
[...requiredModuleRouteDefinitionKeys].every((key) => keys.includes(key)) &&
typeof value.handler === "function" typeof value.handler === "function"
); );
} }
@@ -332,6 +405,7 @@ export type ModuleRouteDefinitionIssueCode =
| "MODULE_ROUTE_METHOD_INVALID" | "MODULE_ROUTE_METHOD_INVALID"
| "MODULE_ROUTE_PATH_INVALID" | "MODULE_ROUTE_PATH_INVALID"
| "MODULE_ROUTE_PERMISSION_INVALID" | "MODULE_ROUTE_PERMISSION_INVALID"
| "MODULE_ROUTE_BODY_MODE_INVALID"
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"; | "MODULE_ROUTE_MAX_BODY_BYTES_INVALID";
export type ModuleRouteDefinitionParseResult = export type ModuleRouteDefinitionParseResult =
@@ -360,13 +434,23 @@ export function safeParseModuleRouteDefinition(value: unknown): ModuleRouteDefin
if (!parsedPermission.success) { if (!parsedPermission.success) {
return { success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" }; return { success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" };
} }
if (value.bodyMode !== undefined && !isModuleRouteBodyMode(value.bodyMode)) {
return { success: false, code: "MODULE_ROUTE_BODY_MODE_INVALID" };
}
const bodyMode: ModuleRouteBodyMode = isModuleRouteBodyMode(value.bodyMode)
? value.bodyMode
: "buffered";
const parsedMaxBodyBytes = moduleRouteMaxBodyBytesSchema.safeParse(value.maxBodyBytes); const parsedMaxBodyBytes = moduleRouteMaxBodyBytesSchema.safeParse(value.maxBodyBytes);
if (!parsedMaxBodyBytes.success) { // The ceiling is chosen by the declared mode, so a route asking for more than a buffered body may
// hold has to say `streamed` — and thereby accept that its handler consumes a stream and that a
// `413` can arrive after its own code has started running.
if (!parsedMaxBodyBytes.success || parsedMaxBodyBytes.data > moduleRouteBodyCeiling(bodyMode)) {
return { success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" }; return { success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" };
} }
return { return {
success: true, success: true,
data: Object.freeze({ data: Object.freeze({
bodyMode,
method: value.method, method: value.method,
path: parsedPath.data, path: parsedPath.data,
permission: parsedPermission.data, permission: parsedPermission.data,
@@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";
import { import {
isModuleHttpMethod, isModuleHttpMethod,
MODULE_HTTP_METHODS, MODULE_HTTP_METHODS,
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
MODULE_ROUTE_MAX_BODY_BYTES_CEILING, MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
moduleRouteBodyCeiling,
type ModuleRouteDefinitionInput, type ModuleRouteDefinitionInput,
moduleRoutePathSchema, moduleRoutePathSchema,
moduleRouteShapeKey, moduleRouteShapeKey,
@@ -153,6 +156,7 @@ describe("safeParseModuleRouteDefinition", () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) { if (result.success) {
expect(result.data).toEqual({ expect(result.data).toEqual({
bodyMode: "buffered",
method: "GET", method: "GET",
path: "/events/:eventId", path: "/events/:eventId",
permission: "calendar.events.read", permission: "calendar.events.read",
@@ -258,6 +262,81 @@ describe("safeParseModuleRouteDefinition", () => {
}); });
}); });
it("defaults an omitted bodyMode to buffered, so an existing route means what it always meant", () => {
const parsed = safeParseModuleRouteDefinition(validDefinition());
expect(parsed.success && parsed.data.bodyMode).toBe("buffered");
});
it("accepts either declared body mode and carries it through", () => {
for (const bodyMode of ["buffered", "streamed"] as const) {
const parsed = safeParseModuleRouteDefinition({ ...validDefinition(), bodyMode });
expect(parsed.success && parsed.data.bodyMode).toBe(bodyMode);
}
});
it("rejects a body mode outside the closed set", () => {
for (const bodyMode of ["stream", "chunked", "", 1, null]) {
expect(safeParseModuleRouteDefinition({ ...validDefinition(), bodyMode })).toEqual({
success: false,
code: "MODULE_ROUTE_BODY_MODE_INVALID",
});
}
});
it("checks maxBodyBytes against the ceiling the declared mode selects", () => {
// A route asking for more than a buffered body may hold must say `streamed` — and thereby accept
// that its handler consumes a stream (ADR 0042).
expect(
safeParseModuleRouteDefinition(
validDefinition({ maxBodyBytes: MODULE_ROUTE_MAX_BODY_BYTES_CEILING + 1 }),
),
).toEqual({ success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" });
expect(
safeParseModuleRouteDefinition({
...validDefinition({ maxBodyBytes: MODULE_ROUTE_MAX_BODY_BYTES_CEILING + 1 }),
bodyMode: "streamed",
}).success,
).toBe(true);
});
it("holds one 25 MiB image plus bounded overhead, and refuses one byte more", () => {
const image = 25 * 1024 * 1024;
expect(MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING).toBeGreaterThan(image);
expect(
safeParseModuleRouteDefinition({
...validDefinition({ maxBodyBytes: MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING }),
bodyMode: "streamed",
}).success,
).toBe(true);
expect(
safeParseModuleRouteDefinition({
...validDefinition({ maxBodyBytes: MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING + 1 }),
bodyMode: "streamed",
}),
).toEqual({ success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" });
});
it("has one absolute ceiling and no unbounded option in any mode", () => {
expect(MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES).toBe(MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING);
expect(moduleRouteBodyCeiling("buffered")).toBe(MODULE_ROUTE_MAX_BODY_BYTES_CEILING);
expect(moduleRouteBodyCeiling("streamed")).toBe(MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES);
for (const bodyMode of ["buffered", "streamed"] as const) {
for (const maxBodyBytes of [
Number.POSITIVE_INFINITY,
Number.MAX_SAFE_INTEGER,
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES + 1,
]) {
expect(
safeParseModuleRouteDefinition({ ...validDefinition({ maxBodyBytes }), bodyMode })
.success,
).toBe(false);
}
}
});
it("does not retain the caller's mutable definition object: mutating it afterward cannot change the parsed result", () => { it("does not retain the caller's mutable definition object: mutating it afterward cannot change the parsed result", () => {
const source = { ...validDefinition() }; const source = { ...validDefinition() };
const result = safeParseModuleRouteDefinition(source); const result = safeParseModuleRouteDefinition(source);