A module can register both `GET /events/:id` and `GET /events/new`. Driven through the pinned router, `/events/new` reached the parameter handler under `demo.events.read` when the parameter route registered first, and the static handler under `demo.events.create` when it registered second. Nothing rejected the pair, so declaration order decided which permission guarded the path. That is a permission policy, not a routing preference, and it must not be inferred from array position. Two routes of one module now overlap when they share a 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 grammar — static literals or `:name` parameters, no wildcard, regex, optional, or catch-all — that is exactly "some concrete request path matches both". `moduleRoutesOverlap` implements it in the contracts package, where path semantics already live and Hono cannot reach. Core checks it during staged registration, after the existing duplicate check, so identical shapes (including paths differing only in parameter name) keep returning MODULE_ROUTE_DUPLICATE and a genuine intersection returns the new MODULE_ROUTE_OVERLAP. The rejected module publishes none of its staged routes or capabilities, no start hook runs, and no listener binds. Rejection rather than a specificity rule: ranking static above parameter would still silently pick which permission applies. Disjoint routes keep their order; different methods, different segment counts, and the same local path in two different modules (each has its own platform-owned mount prefix) all remain legal. The same requests also pinned down what handlers actually receive. Route declarations are validated; route *parameters* are not, and the two are unrelated. A single encoded segment arrives decoded as `/../admin`, `a\b`, an embedded NUL, CR/LF, `1; drop`, or non-ASCII text — none of which any declared path could contain. The contract now says so explicitly: decoded values are untrusted, module-owned input that a handler must validate against its own exact domain grammar before any database, filesystem, command, or identifier use, and re-encoding is not a substitute. No generic validator, path-normalization layer, or route field was added, because module parameters are module-owned domain types and one platform-wide pattern would either reject legitimate values or approve meaningless ones. Co-Authored-By: Claude Opus 5 <[email protected]>
369 lines
16 KiB
TypeScript
369 lines
16 KiB
TypeScript
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> = 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);
|
|
}
|
|
|
|
/**
|
|
* 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/<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, 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<Record<string, string>>;
|
|
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<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 {
|
|
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/<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 handler: ModuleRouteHandler;
|
|
}
|
|
|
|
const moduleRouteDefinitionKeys = 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;
|
|
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;
|
|
}
|