CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
480 lines
22 KiB
TypeScript
480 lines
22 KiB
TypeScript
import { z } from "zod";
|
|
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
|
|
import type { EkklesiaPrincipal } from "./identity.js";
|
|
import type { ModuleConfiguration } from "./module-configuration.js";
|
|
import type { ModuleDatabase, ModuleRuntimeServices } from "./module-database.js";
|
|
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
|
|
import type { ModuleRealtimePublisher } from "./module-realtime.js";
|
|
import type { ModuleStorage } from "./module-storage.js";
|
|
|
|
// Not imported from `./module-runtime.js`'s own `Awaitable` (structurally identical) — importing
|
|
// it would create a source dependency cycle with that module, which also imports from this one for
|
|
// `ModuleHttpRouteRegistrar`. `scripts/check-boundaries.ts` rejects any source cycle, including one
|
|
// formed only by `import type`, so this tiny, fixed-shape utility type is kept private here instead.
|
|
type Awaitable<Value> = Value | PromiseLike<Value>;
|
|
|
|
/** The exact, canonical HTTP methods a module route may declare. Uppercase only — a module never
|
|
* gets `OPTIONS` (the platform owns CORS preflight) or a wildcard/all-methods registration. */
|
|
export const MODULE_HTTP_METHODS = ["DELETE", "GET", "PATCH", "POST", "PUT"] as const;
|
|
|
|
export type ModuleHttpMethod = (typeof MODULE_HTTP_METHODS)[number];
|
|
|
|
const moduleHttpMethods: ReadonlySet<string> = new Set(MODULE_HTTP_METHODS);
|
|
|
|
export function isModuleHttpMethod(value: unknown): value is ModuleHttpMethod {
|
|
return typeof value === "string" && moduleHttpMethods.has(value);
|
|
}
|
|
|
|
/**
|
|
* How a route's request body reaches its handler, and therefore which ceiling applies to it
|
|
* (ADR 0042).
|
|
*
|
|
* - `buffered` (the default, and everything that existed before): 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.
|
|
* 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;
|
|
|
|
/**
|
|
* 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
|
|
.number()
|
|
.int()
|
|
.finite()
|
|
.min(0)
|
|
.max(MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES);
|
|
|
|
const MODULE_ROUTE_PATH_MAX_LENGTH = 256;
|
|
const staticSegmentPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
const parameterSegmentPattern = /^:[a-z][a-zA-Z0-9]*$/u;
|
|
|
|
/**
|
|
* A module-local route path — never the production-mounted `/api/v1/modules/<module-id>` path a
|
|
* caller actually sends (`apps/api/src/module-http-routes.ts` owns that mapping). Not branded
|
|
* (unlike `PermissionId`): like `PackageRelativePath`,
|
|
* this is a validated location, not an opaque cross-system identifier. `"/"` is the module root;
|
|
* any other canonical path begins with `/`, never ends with `/`, and is composed only of lowercase
|
|
* kebab-case static segments or single `:parameterName` segments — no empty or repeated slashes, no
|
|
* query string or fragment, no backslash or percent-encoding, no dot segments, and no wildcard or
|
|
* catch-all segment, since none of those can ever match either segment grammar. Malformed input is
|
|
* rejected outright rather than normalized, so a module can never register a path Core silently
|
|
* reinterpreted.
|
|
*/
|
|
export const moduleRoutePathSchema = z
|
|
.string()
|
|
.max(MODULE_ROUTE_PATH_MAX_LENGTH)
|
|
.superRefine((value, context) => {
|
|
if (
|
|
!value.startsWith("/") ||
|
|
value.includes("\\") ||
|
|
value.includes("%") ||
|
|
value.includes("?") ||
|
|
value.includes("#")
|
|
) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message:
|
|
"Module route paths must begin with / and must not contain backslashes, " +
|
|
"percent-encoding, query strings, or fragments.",
|
|
});
|
|
return;
|
|
}
|
|
if (value === "/") {
|
|
return;
|
|
}
|
|
if (value.endsWith("/")) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "Non-root module route paths must not end with /.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const segments = value.slice(1).split("/");
|
|
const parameterNames = new Set<string>();
|
|
for (const segment of segments) {
|
|
const isParameter = segment.startsWith(":");
|
|
const matchesGrammar = isParameter
|
|
? parameterSegmentPattern.test(segment)
|
|
: staticSegmentPattern.test(segment);
|
|
if (!matchesGrammar) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message:
|
|
"Module route path segments must be lowercase kebab-case static segments or a " +
|
|
"single :parameterName segment.",
|
|
});
|
|
return;
|
|
}
|
|
if (isParameter) {
|
|
const parameterName = segment.slice(1);
|
|
if (parameterNames.has(parameterName)) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "Module route path parameter names must be unique within the path.",
|
|
});
|
|
return;
|
|
}
|
|
parameterNames.add(parameterName);
|
|
}
|
|
}
|
|
});
|
|
|
|
export type ModuleRoutePath = z.output<typeof moduleRoutePathSchema>;
|
|
|
|
export function parseModuleRoutePath(input: unknown): ModuleRoutePath {
|
|
return moduleRoutePathSchema.parse(input);
|
|
}
|
|
|
|
export function safeParseModuleRoutePath(input: unknown): z.ZodSafeParseResult<ModuleRoutePath> {
|
|
return moduleRoutePathSchema.safeParse(input);
|
|
}
|
|
|
|
/**
|
|
* Produces a deterministic key identifying a route's shape — method plus path with every parameter
|
|
* segment collapsed to one placeholder — so `/events/:eventId` and `/events/:id` collide under the
|
|
* same method exactly like the identical route they are, regardless of the parameter's chosen name.
|
|
* Takes an already-validated `ModuleRoutePath`; never re-validates or echoes the original path.
|
|
*/
|
|
export function moduleRouteShapeKey(method: ModuleHttpMethod, path: ModuleRoutePath): string {
|
|
if (path === "/") {
|
|
return `${method} /`;
|
|
}
|
|
const shapeSegments = path
|
|
.slice(1)
|
|
.split("/")
|
|
.map((segment) => (segment.startsWith(":") ? ":param" : segment));
|
|
return `${method} /${shapeSegments.join("/")}`;
|
|
}
|
|
|
|
/** `"/"` is the module root and has no segments; every other canonical path is its `/`-separated
|
|
* segments. Takes an already-validated `ModuleRoutePath`. */
|
|
function moduleRoutePathSegments(path: ModuleRoutePath): readonly string[] {
|
|
return path === "/" ? [] : path.slice(1).split("/");
|
|
}
|
|
|
|
/**
|
|
* Whether two routes of the same module can both match at least one request path.
|
|
*
|
|
* Two routes overlap when they share an HTTP method and a segment count, and at every position
|
|
* either both segments are the same static literal or at least one is a parameter. Under this
|
|
* route grammar — static kebab-case literals or `:name` parameters, with no wildcard, regex,
|
|
* optional, or catch-all segment — that condition is exactly "some concrete path matches both".
|
|
*
|
|
* This exists because an overlap is a *permission* ambiguity, not a routing preference:
|
|
* `GET /events/:id` and `GET /events/new` can carry different permissions, and with an
|
|
* order-sensitive matcher whichever registered first decides which permission governs
|
|
* `/events/new`. Core rejects the overlap at registration instead of picking a winner, so no
|
|
* router's matching order can ever select the governing permission. Identical shapes (including
|
|
* paths differing only in parameter name) remain the pre-existing duplicate result; callers check
|
|
* that first.
|
|
*
|
|
* Deliberately framework-neutral: path semantics already live in this package, and nothing about
|
|
* this rule may depend on Hono, which stays confined to `apps/api`.
|
|
*/
|
|
export function moduleRoutesOverlap(
|
|
first: { readonly method: ModuleHttpMethod; readonly path: ModuleRoutePath },
|
|
second: { readonly method: ModuleHttpMethod; readonly path: ModuleRoutePath },
|
|
): boolean {
|
|
if (first.method !== second.method) {
|
|
return false;
|
|
}
|
|
const firstSegments = moduleRoutePathSegments(first.path);
|
|
const secondSegments = moduleRoutePathSegments(second.path);
|
|
if (firstSegments.length !== secondSegments.length) {
|
|
return false;
|
|
}
|
|
return firstSegments.every((segment, index) => {
|
|
const other = secondSegments[index] as string;
|
|
return segment.startsWith(":") || other.startsWith(":") || segment === other;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* What a module's route handler receives about the caller, the match, and the platform services
|
|
* available to it — never an authorization store, grant-management state, jobs, the Hono `Context`,
|
|
* or a raw Better Auth session. Its storage, configuration, and realtime publisher are all scoped
|
|
* to the owning module; secret values appear only when explicitly declared and supplied through
|
|
* that module's configuration contract. `principal` is already authorized by
|
|
* the time a handler runs: `apps/api/src/module-http-routes.ts` mounts and enforces every route
|
|
* before ever invoking its handler; this contracts-layer module never invokes a handler itself.
|
|
* `services` is the same frozen `ModuleRuntimeServices` object the owning module's `start`/`stop`
|
|
* hooks receive for this API runtime — never a fresh one built per request.
|
|
*
|
|
* ## `params` values are untrusted input
|
|
*
|
|
* `params` holds the HTTP router's **decoded** path values, frozen alongside this context. Decoding
|
|
* is not validation, and the declaration grammar above constrains only what a module may *declare*
|
|
* — it says nothing about what a request may *send*. A caller can percent-encode anything into a
|
|
* single segment, so a decoded value routinely contains characters no declared path could:
|
|
* verified against the pinned router, `%2F..%2Fadmin` arrives as `/../admin`, `a%5Cb` as `a\b`,
|
|
* `a%00b` with an embedded NUL, `a%0D%0Ab` with CR/LF, and `%C3%A9v%C3%A9nement` as `événement`.
|
|
*
|
|
* Every handler must therefore validate each parameter it uses against its own exact domain
|
|
* grammar — a UUID, slug, date, numeric id, or whatever that module owns — **before** any
|
|
* database, filesystem, command, identifier, or other side-effecting use, and reject a missing,
|
|
* repeated, malformed, or semantically invalid value with its own sanitized response. Re-encoding,
|
|
* decode/re-encode round-tripping, or inspecting the raw URL is not a substitute for that check.
|
|
* The platform deliberately supplies no generic parameter grammar: module parameters are
|
|
* module-owned domain types, and one platform-wide pattern would either reject legitimate values
|
|
* or approve meaningless ones.
|
|
*
|
|
* The platform continues to own authentication, authorization, `Origin`, CORS, body limits, cache
|
|
* behavior, and uncaught-handler sanitization; permission enforcement always completes before a
|
|
* handler — and therefore any parameter validation it performs — can run.
|
|
*/
|
|
export interface ModuleRouteHandlerContext<
|
|
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
|
> {
|
|
readonly moduleId: ModuleId;
|
|
readonly principal: EkklesiaPrincipal;
|
|
readonly params: Readonly<Record<string, string>>;
|
|
/** The identical scoped storage facade this module's `start`/`stop` hooks received — the same
|
|
* object, not a re-resolved one (ADR 0030). This is what lets a module stream an object straight
|
|
* into a `Response` without the browser ever learning that object storage exists. */
|
|
readonly storage: ModuleStorage;
|
|
readonly services: ModuleRuntimeServices<Database>;
|
|
/** The identical module-scoped realtime publisher used by lifecycle hooks. */
|
|
readonly realtime: ModuleRealtimePublisher;
|
|
/** The identical frozen configuration snapshot this module's `start`/`stop` hooks received — the
|
|
* same object, not a re-read — so a handler and a lifecycle hook can never disagree about a value
|
|
* (ADR 0029). Never serialized into a response by Core; a module that chooses to expose part of
|
|
* its own configuration does so through its own route, deliberately, and must never expose a
|
|
* secret. */
|
|
readonly config: ModuleConfiguration;
|
|
}
|
|
|
|
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
|
|
* query string remains available through `Request.url`; there is no competing query or body
|
|
* abstraction. */
|
|
export type ModuleRouteHandler<
|
|
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
|
> = (request: Request, context: ModuleRouteHandlerContext<Database>) => Awaitable<Response>;
|
|
|
|
/**
|
|
* What a module passes to `http.route(...)` during `register`. `permission` is a plain string here
|
|
* (never the branded `PermissionId`) so a module can write a literal permission id without an
|
|
* explicit parse call first; `http.route` validates and brands it. `maxBodyBytes` is the route's
|
|
* conscious request-body contract — `0` means the route accepts no body at all (a non-empty
|
|
* declared or streamed body is rejected), and any other value is the exact maximum number of bytes
|
|
* accepted, capped at `MODULE_ROUTE_MAX_BODY_BYTES_CEILING`. This is a security contract the
|
|
* platform enforces before a handler ever runs, not a manifest capability: it is not optional and
|
|
* has no implicit default, so a module author must make an explicit choice for every route. The
|
|
* type has exactly these five fields — a module cannot specify a baseline, mount prefix, absolute
|
|
* path, CORS/origin policy, authentication behavior, grant/platform-owner state, middleware, audit
|
|
* metadata, another module's namespace, or an unprotected/no-permission route.
|
|
*/
|
|
export interface ModuleRouteDefinitionInput<
|
|
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
|
> {
|
|
readonly method: ModuleHttpMethod;
|
|
readonly path: string;
|
|
readonly permission: string;
|
|
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<Database>;
|
|
}
|
|
|
|
/** The validated, canonical form of a `ModuleRouteDefinitionInput` — what `http.route` produces
|
|
* only after every field passes its own grammar. */
|
|
export interface ModuleRouteDefinition<
|
|
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
|
> {
|
|
readonly method: ModuleHttpMethod;
|
|
readonly path: ModuleRoutePath;
|
|
readonly permission: PermissionId;
|
|
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<Database>;
|
|
}
|
|
|
|
/** The Fetch-based registrar exposed as `context.http` during `register` only — never on
|
|
* `ModuleRuntimeContext`, so `start`/`stop` cannot register a route after the fact. */
|
|
export interface ModuleHttpRouteRegistrar<
|
|
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
|
> {
|
|
route(definition: ModuleRouteDefinitionInput<Database>): void;
|
|
}
|
|
|
|
/** One published route in `StartedModuleRuntime`'s immutable route snapshot — the same validated
|
|
* fields as `ModuleRouteDefinition` plus the owning module's ID, which the production mount prefix
|
|
* (`/api/v1/modules/<module-id>`, `apps/api/src/module-http-routes.ts`) uses to keep two modules'
|
|
* routes from ever colliding regardless of their own local paths. */
|
|
export interface RegisteredModuleHttpRoute {
|
|
readonly moduleId: ModuleId;
|
|
readonly method: ModuleHttpMethod;
|
|
readonly path: ModuleRoutePath;
|
|
readonly permission: PermissionId;
|
|
readonly maxBodyBytes: number;
|
|
readonly bodyMode: ModuleRouteBodyMode;
|
|
/** Runtime-erased callback. Module authors get the database-mode-specific handler type through
|
|
* `ModuleHttpRouteRegistrar`; Core stores the validated callback opaquely in this snapshot. */
|
|
readonly handler: ModuleRouteHandler;
|
|
}
|
|
|
|
const moduleRouteDefinitionKeys = new Set([
|
|
"method",
|
|
"path",
|
|
"permission",
|
|
"maxBodyBytes",
|
|
"bodyMode",
|
|
"handler",
|
|
]);
|
|
|
|
const requiredModuleRouteDefinitionKeys = new Set([
|
|
"method",
|
|
"path",
|
|
"permission",
|
|
"maxBodyBytes",
|
|
"handler",
|
|
]);
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function hasModuleRouteDefinitionShape(value: unknown): value is {
|
|
method: unknown;
|
|
path: unknown;
|
|
permission: unknown;
|
|
maxBodyBytes: unknown;
|
|
bodyMode: unknown;
|
|
handler: unknown;
|
|
} {
|
|
if (!isRecord(value)) {
|
|
return false;
|
|
}
|
|
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 (
|
|
keys.every((key) => moduleRouteDefinitionKeys.has(key)) &&
|
|
[...requiredModuleRouteDefinitionKeys].every((key) => keys.includes(key)) &&
|
|
typeof value.handler === "function"
|
|
);
|
|
}
|
|
|
|
export type ModuleRouteDefinitionIssueCode =
|
|
| "MODULE_ROUTE_DEFINITION_INVALID"
|
|
| "MODULE_ROUTE_METHOD_INVALID"
|
|
| "MODULE_ROUTE_PATH_INVALID"
|
|
| "MODULE_ROUTE_PERMISSION_INVALID"
|
|
| "MODULE_ROUTE_BODY_MODE_INVALID"
|
|
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID";
|
|
|
|
export type ModuleRouteDefinitionParseResult =
|
|
| { readonly success: true; readonly data: ModuleRouteDefinition }
|
|
| { readonly success: false; readonly code: ModuleRouteDefinitionIssueCode };
|
|
|
|
/**
|
|
* Validates every field-level rule a route definition must satisfy on its own — exact shape,
|
|
* canonical method, canonical path, well-formed permission id, well-formed body-size contract — but
|
|
* never whether the permission is actually declared by the registering module. That ownership check
|
|
* needs the module's own manifest and belongs to the caller (Core's module runtime), not this
|
|
* framework-neutral contract. Never echoes the rejected input in the returned code.
|
|
*/
|
|
export function safeParseModuleRouteDefinition(value: unknown): ModuleRouteDefinitionParseResult {
|
|
if (!hasModuleRouteDefinitionShape(value)) {
|
|
return { success: false, code: "MODULE_ROUTE_DEFINITION_INVALID" };
|
|
}
|
|
if (!isModuleHttpMethod(value.method)) {
|
|
return { success: false, code: "MODULE_ROUTE_METHOD_INVALID" };
|
|
}
|
|
const parsedPath = moduleRoutePathSchema.safeParse(value.path);
|
|
if (!parsedPath.success) {
|
|
return { success: false, code: "MODULE_ROUTE_PATH_INVALID" };
|
|
}
|
|
const parsedPermission = permissionIdSchema.safeParse(value.permission);
|
|
if (!parsedPermission.success) {
|
|
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);
|
|
// 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: true,
|
|
data: Object.freeze({
|
|
bodyMode,
|
|
method: value.method,
|
|
path: parsedPath.data,
|
|
permission: parsedPermission.data,
|
|
maxBodyBytes: parsedMaxBodyBytes.data,
|
|
handler: value.handler as ModuleRouteHandler,
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function parseModuleRouteDefinition(value: unknown): ModuleRouteDefinition {
|
|
const result = safeParseModuleRouteDefinition(value);
|
|
if (!result.success) {
|
|
throw new TypeError("Invalid Ekklesia module route definition.");
|
|
}
|
|
return result.data;
|
|
}
|