import { z } from "zod"; import type { EkklesiaPrincipal } from "./identity.js"; import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js"; import type { ModuleRuntimeServices } from "./module-database.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); } /** * The platform-owned hard ceiling on any single module route's declared request-body allowance, in * bytes. No module 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. Large media transfer * belongs to a future dedicated storage design, never to widening this ceiling. */ export const MODULE_ROUTE_MAX_BODY_BYTES_CEILING = 1_048_576; const moduleRouteMaxBodyBytesSchema = z .number() .int() .finite() .min(0) .max(MODULE_ROUTE_MAX_BODY_BYTES_CEILING); 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, storage, jobs, configuration, * secrets, the Hono `Context`, or a raw Better Auth session. `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 { readonly moduleId: ModuleId; readonly principal: EkklesiaPrincipal; readonly params: Readonly>; readonly services: ModuleRuntimeServices; } /** 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 = ( 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 { readonly method: ModuleHttpMethod; readonly path: string; readonly permission: string; readonly maxBodyBytes: number; 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 { readonly method: ModuleHttpMethod; readonly path: ModuleRoutePath; readonly permission: PermissionId; readonly maxBodyBytes: number; 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 { 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 handler: ModuleRouteHandler; } const moduleRouteDefinitionKeys = 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; handler: unknown; } { if (!isRecord(value)) { return false; } const keys = Object.keys(value); return ( keys.length === moduleRouteDefinitionKeys.size && keys.every((key) => moduleRouteDefinitionKeys.has(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_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" }; } const parsedMaxBodyBytes = moduleRouteMaxBodyBytesSchema.safeParse(value.maxBodyBytes); if (!parsedMaxBodyBytes.success) { return { success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" }; } return { success: true, data: Object.freeze({ 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; }