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 | PromiseLike; /** 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 = 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 = 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/` 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(); 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; export function parseModuleRoutePath(input: unknown): ModuleRoutePath { return moduleRoutePathSchema.parse(input); } export function safeParseModuleRoutePath(input: unknown): z.ZodSafeParseResult { 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>; /** 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; /** 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) => Awaitable; /** * 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; } /** 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; } /** 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): 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/`, `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 { 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; }