feat: define module HTTP routes

Phase 5B, pass 1: framework-neutral module HTTP route contracts and runtime
registration. Adds @ekklesia/contracts' module-http.ts (methods, canonical
path grammar, route definition/context/handler types, and validation)
and Core's module runtime staging/publication of a module's declared
routes alongside its capabilities, gated on the module's own manifest
permissions. Routes are collected into StartedModuleRuntime as an
immutable snapshot but are not yet mounted into apps/api; that is pass 2.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-24 18:36:23 -05:00
co-authored by Claude Sonnet 5
parent c9cf1f08e3
commit e296e083ec
10 changed files with 1304 additions and 28 deletions
+256
View File
@@ -0,0 +1,256 @@
import { z } from "zod";
import type { EkklesiaPrincipal } from "./identity.js";
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.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);
}
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 (pass 2). 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("/")}`;
}
/**
* What a module's route handler receives about the caller and the match — never a database
* connection, 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 (pass 2 mounts and enforces); this pass never invokes a handler at all.
*/
export interface ModuleRouteHandlerContext {
readonly moduleId: ModuleId;
readonly principal: EkklesiaPrincipal;
readonly params: Readonly<Record<string, string>>;
}
/** 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<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. The type has exactly these four
* 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 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 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
* (pass 2) 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 handler: ModuleRouteHandler;
}
const moduleRouteDefinitionKeys = new Set(["method", "path", "permission", "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; 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";
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 — 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" };
}
return {
success: true,
data: Object.freeze({
method: value.method,
path: parsedPath.data,
permission: parsedPermission.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;
}