Files
ekklesiaandClaude Opus 5 e36485bf99 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]>
2026-08-27 15:57:44 -05:00

416 lines
16 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
isModuleHttpMethod,
MODULE_HTTP_METHODS,
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
moduleRouteBodyCeiling,
type ModuleRouteDefinitionInput,
moduleRoutePathSchema,
moduleRouteShapeKey,
moduleRoutesOverlap,
parseModuleRouteDefinition,
parseModuleRoutePath,
safeParseModuleRouteDefinition,
safeParseModuleRoutePath,
} from "../src/index.js";
function noopHandler(): Response {
return new Response(null, { status: 204 });
}
function validDefinition(
overrides: Partial<ModuleRouteDefinitionInput> = {},
): ModuleRouteDefinitionInput {
return {
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
maxBodyBytes: 0,
handler: noopHandler,
...overrides,
};
}
describe("ModuleHttpMethod", () => {
it("accepts every supported method", () => {
for (const method of MODULE_HTTP_METHODS) {
expect(isModuleHttpMethod(method)).toBe(true);
}
expect(MODULE_HTTP_METHODS).toEqual(["DELETE", "GET", "PATCH", "POST", "PUT"]);
});
it("rejects lowercase, unknown, OPTIONS, HEAD, and wildcard methods", () => {
for (const method of ["get", "post", "TRACE", "OPTIONS", "HEAD", "*", "ALL", ""]) {
expect(isModuleHttpMethod(method)).toBe(false);
}
expect(isModuleHttpMethod(42)).toBe(false);
expect(isModuleHttpMethod(undefined)).toBe(false);
});
});
describe("moduleRoutePathSchema", () => {
it("accepts the root, static, nested, and parameterized canonical paths", () => {
for (const path of [
"/",
"/events",
"/events/upcoming",
"/events/:eventId",
"/events/:eventId/attendees/:attendeeId",
]) {
expect(parseModuleRoutePath(path)).toBe(path);
}
});
it("rejects paths that do not begin with /", () => {
expect(safeParseModuleRoutePath("events").success).toBe(false);
expect(safeParseModuleRoutePath("").success).toBe(false);
});
it("rejects a non-root path ending with /", () => {
expect(safeParseModuleRoutePath("/events/").success).toBe(false);
});
it("rejects empty and repeated segments", () => {
expect(safeParseModuleRoutePath("/events//upcoming").success).toBe(false);
expect(safeParseModuleRoutePath("//events").success).toBe(false);
});
it("rejects query strings and fragments", () => {
expect(safeParseModuleRoutePath("/events?limit=10").success).toBe(false);
expect(safeParseModuleRoutePath("/events#upcoming").success).toBe(false);
});
it("rejects backslashes and percent-encoded path syntax", () => {
expect(safeParseModuleRoutePath("/events\\upcoming").success).toBe(false);
expect(safeParseModuleRoutePath("/events%2Fupcoming").success).toBe(false);
expect(safeParseModuleRoutePath("/%2e%2e/events").success).toBe(false);
});
it("rejects dot segments and traversal syntax", () => {
expect(safeParseModuleRoutePath("/events/..").success).toBe(false);
expect(safeParseModuleRoutePath("/events/.").success).toBe(false);
expect(safeParseModuleRoutePath("/../events").success).toBe(false);
});
it("rejects wildcard or catch-all segments", () => {
expect(safeParseModuleRoutePath("/events/*").success).toBe(false);
expect(safeParseModuleRoutePath("/events/**").success).toBe(false);
expect(safeParseModuleRoutePath("/*").success).toBe(false);
});
it("rejects non-kebab-case static segments", () => {
expect(safeParseModuleRoutePath("/Events").success).toBe(false);
expect(safeParseModuleRoutePath("/events_list").success).toBe(false);
expect(safeParseModuleRoutePath("/events--upcoming").success).toBe(false);
expect(safeParseModuleRoutePath("/-events").success).toBe(false);
expect(safeParseModuleRoutePath("/events-").success).toBe(false);
});
it("rejects a malformed parameter segment", () => {
expect(safeParseModuleRoutePath("/events/:").success).toBe(false);
expect(safeParseModuleRoutePath("/events/:Event-Id").success).toBe(false);
expect(safeParseModuleRoutePath("/events/:1id").success).toBe(false);
});
it("rejects duplicate parameter names within one path", () => {
expect(safeParseModuleRoutePath("/events/:id/attendees/:id").success).toBe(false);
});
it("rejects an unreasonably long path", () => {
expect(safeParseModuleRoutePath(`/${"a".repeat(300)}`).success).toBe(false);
});
it("never echoes the rejected raw path in its issue messages", () => {
const secret = "/machine/private/path/traversal-attempt-%2e%2e";
const result = moduleRoutePathSchema.safeParse(secret);
expect(result.success).toBe(false);
expect(JSON.stringify(result.error?.issues)).not.toContain(secret);
});
});
describe("moduleRouteShapeKey", () => {
it("collapses different parameter names into the same shape for the same method", () => {
const eventId = parseModuleRoutePath("/events/:eventId");
const id = parseModuleRoutePath("/events/:id");
expect(moduleRouteShapeKey("GET", eventId)).toBe(moduleRouteShapeKey("GET", id));
});
it("distinguishes shapes by method and by static path structure", () => {
const path = parseModuleRoutePath("/events/:eventId");
expect(moduleRouteShapeKey("GET", path)).not.toBe(moduleRouteShapeKey("POST", path));
expect(moduleRouteShapeKey("GET", parseModuleRoutePath("/events"))).not.toBe(
moduleRouteShapeKey("GET", parseModuleRoutePath("/events/:eventId")),
);
});
it("treats the root path as its own shape", () => {
expect(moduleRouteShapeKey("GET", parseModuleRoutePath("/"))).toBe("GET /");
});
});
describe("safeParseModuleRouteDefinition", () => {
it("accepts a well-formed definition and brands its path and permission", () => {
const result = safeParseModuleRouteDefinition(validDefinition());
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual({
bodyMode: "buffered",
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
maxBodyBytes: 0,
handler: noopHandler,
});
expect(Object.isFrozen(result.data)).toBe(true);
}
});
it("rejects a non-object, null, or array definition", () => {
for (const value of [null, undefined, "route", 42, []]) {
expect(safeParseModuleRouteDefinition(value)).toEqual({
success: false,
code: "MODULE_ROUTE_DEFINITION_INVALID",
});
}
});
it("rejects an unknown route-definition field", () => {
expect(safeParseModuleRouteDefinition({ ...validDefinition(), baseline: "public" })).toEqual({
success: false,
code: "MODULE_ROUTE_DEFINITION_INVALID",
});
expect(
safeParseModuleRouteDefinition({ ...validDefinition(), mountPrefix: "/custom" }),
).toEqual({ success: false, code: "MODULE_ROUTE_DEFINITION_INVALID" });
});
it("rejects a missing required field", () => {
const { handler: _handler, ...withoutHandler } = validDefinition();
expect(safeParseModuleRouteDefinition(withoutHandler)).toEqual({
success: false,
code: "MODULE_ROUTE_DEFINITION_INVALID",
});
});
it("rejects a non-function handler", () => {
expect(
safeParseModuleRouteDefinition({ ...validDefinition(), handler: "not-a-function" }),
).toEqual({ success: false, code: "MODULE_ROUTE_DEFINITION_INVALID" });
});
it("rejects an invalid method", () => {
expect(safeParseModuleRouteDefinition({ ...validDefinition(), method: "get" })).toEqual({
success: false,
code: "MODULE_ROUTE_METHOD_INVALID",
});
});
it("rejects an invalid path", () => {
expect(safeParseModuleRouteDefinition({ ...validDefinition(), path: "events" })).toEqual({
success: false,
code: "MODULE_ROUTE_PATH_INVALID",
});
});
it("rejects an invalid permission id", () => {
expect(
safeParseModuleRouteDefinition({ ...validDefinition(), permission: "not namespaced" }),
).toEqual({ success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" });
expect(safeParseModuleRouteDefinition({ ...validDefinition(), permission: "*" })).toEqual({
success: false,
code: "MODULE_ROUTE_PERMISSION_INVALID",
});
});
it("accepts a maxBodyBytes of exactly zero (no body accepted) and exactly the platform ceiling", () => {
expect(safeParseModuleRouteDefinition(validDefinition({ maxBodyBytes: 0 })).success).toBe(true);
const atCeiling = safeParseModuleRouteDefinition(
validDefinition({ maxBodyBytes: MODULE_ROUTE_MAX_BODY_BYTES_CEILING }),
);
expect(atCeiling.success).toBe(true);
if (atCeiling.success) {
expect(atCeiling.data.maxBodyBytes).toBe(MODULE_ROUTE_MAX_BODY_BYTES_CEILING);
}
});
it("rejects a negative, non-integer, non-finite, or over-ceiling maxBodyBytes", () => {
for (const maxBodyBytes of [
-1,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
MODULE_ROUTE_MAX_BODY_BYTES_CEILING + 1,
]) {
expect(safeParseModuleRouteDefinition(validDefinition({ maxBodyBytes }))).toEqual({
success: false,
code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID",
});
}
});
it("rejects a non-numeric or missing maxBodyBytes", () => {
expect(
safeParseModuleRouteDefinition({ ...validDefinition(), maxBodyBytes: "0" }),
).toMatchObject({ success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" });
const { maxBodyBytes: _maxBodyBytes, ...withoutMaxBodyBytes } = validDefinition();
expect(safeParseModuleRouteDefinition(withoutMaxBodyBytes)).toEqual({
success: false,
code: "MODULE_ROUTE_DEFINITION_INVALID",
});
});
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", () => {
const source = { ...validDefinition() };
const result = safeParseModuleRouteDefinition(source);
expect(result.success).toBe(true);
source.method = "POST";
source.path = "/mutated";
source.permission = "mutated.permission.id";
if (result.success) {
expect(result.data.method).toBe("GET");
expect(result.data.path).toBe("/events/:eventId");
expect(result.data.permission).toBe("calendar.events.read");
}
});
});
describe("parseModuleRouteDefinition", () => {
it("returns the parsed definition for valid input", () => {
expect(parseModuleRouteDefinition(validDefinition())).toMatchObject({
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
});
});
it("throws without echoing the rejected input", () => {
const secret = "secret-token-value";
expect(() => parseModuleRouteDefinition({ ...validDefinition(), permission: secret })).toThrow(
"Invalid Ekklesia module route definition.",
);
try {
parseModuleRouteDefinition({ ...validDefinition(), permission: secret });
throw new Error("expected parseModuleRouteDefinition to throw");
} catch (error) {
expect(String(error)).not.toContain(secret);
}
});
});
describe("moduleRoutesOverlap", () => {
const route = (method: "GET" | "POST", path: string) => ({
method,
path: parseModuleRoutePath(path),
});
it.each([
["a parameter against a static literal", "/events/:id", "/events/new"],
["a later position", "/events/:id/edit", "/events/new/:action"],
["parameters on both sides at different positions", "/a/:left/c", "/a/b/:right"],
["identical parameter shapes with different names", "/events/:id", "/events/:eventId"],
["identical static paths", "/events/new", "/events/new"],
["root against root", "/", "/"],
])("reports an overlap for %s", (_label, first, second) => {
expect(moduleRoutesOverlap(route("GET", first), route("GET", second))).toBe(true);
// The relation is symmetric, so registration order cannot change the verdict.
expect(moduleRoutesOverlap(route("GET", second), route("GET", first))).toBe(true);
});
it.each([
["two different static literals", "/events/new", "/events/archive"],
["different segment counts", "/events/:id", "/events/:id/edit"],
["a static prefix that differs earlier", "/events/:id", "/people/:id"],
["root against a one-segment path", "/", "/events"],
])("reports no overlap for %s", (_label, first, second) => {
expect(moduleRoutesOverlap(route("GET", first), route("GET", second))).toBe(false);
expect(moduleRoutesOverlap(route("GET", second), route("GET", first))).toBe(false);
});
it("never reports an overlap across different methods", () => {
expect(moduleRoutesOverlap(route("GET", "/events/:id"), route("POST", "/events/new"))).toBe(
false,
);
expect(moduleRoutesOverlap(route("GET", "/events/new"), route("POST", "/events/new"))).toBe(
false,
);
});
});