Files
Ekklesia/packages/contracts/src/module-http.ts
T
ekklesia 49b4d4165d
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
feat: deliver module database ABI by manifest
2026-10-07 17:29:32 -05:00

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;
}