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:
co-authored by
Claude Sonnet 5
parent
c9cf1f08e3
commit
e296e083ec
@@ -1,6 +1,7 @@
|
||||
export * from "./authorization.js";
|
||||
export * from "./identifiers.js";
|
||||
export * from "./identity.js";
|
||||
export * from "./module-http.js";
|
||||
export * from "./module-lifecycle.js";
|
||||
export * from "./module-manifest.js";
|
||||
export * from "./module-migration.js";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CapabilityId, ModuleId } from "./identifiers.js";
|
||||
import { moduleIdSchema } from "./identifiers.js";
|
||||
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
|
||||
import { semanticVersionSchema } from "./versions.js";
|
||||
|
||||
export type Awaitable<Value> = Value | PromiseLike<Value>;
|
||||
@@ -21,6 +22,10 @@ export interface CapabilityRegistrar extends CapabilityResolver {
|
||||
|
||||
export interface ModuleRegistrationContext {
|
||||
readonly capabilities: CapabilityRegistrar;
|
||||
/** Available only during `register` — never on `ModuleRuntimeContext` — so a route can only ever
|
||||
* be declared while Core is still building the immutable startup snapshot, never added or removed
|
||||
* afterward from `start`, `stop`, or any other post-registration code path. */
|
||||
readonly http: ModuleHttpRouteRegistrar;
|
||||
readonly moduleId: ModuleId;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isModuleHttpMethod,
|
||||
MODULE_HTTP_METHODS,
|
||||
type ModuleRouteDefinitionInput,
|
||||
moduleRoutePathSchema,
|
||||
moduleRouteShapeKey,
|
||||
parseModuleRouteDefinition,
|
||||
parseModuleRoutePath,
|
||||
safeParseModuleRouteDefinition,
|
||||
safeParseModuleRoutePath,
|
||||
} from "../src/index.js";
|
||||
|
||||
function noopHandler(): Response {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
function validDefinition(
|
||||
overrides: Partial<ModuleRouteDefinitionInput> = {},
|
||||
): ModuleRouteDefinitionInput {
|
||||
return {
|
||||
method: "GET",
|
||||
path: "/events/:eventId",
|
||||
permission: "calendar.events.read",
|
||||
handler: noopHandler,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ModuleHttpMethod", () => {
|
||||
it("accepts every supported method", () => {
|
||||
for (const method of MODULE_HTTP_METHODS) {
|
||||
expect(isModuleHttpMethod(method)).toBe(true);
|
||||
}
|
||||
expect(MODULE_HTTP_METHODS).toEqual(["DELETE", "GET", "PATCH", "POST", "PUT"]);
|
||||
});
|
||||
|
||||
it("rejects lowercase, unknown, OPTIONS, HEAD, and wildcard methods", () => {
|
||||
for (const method of ["get", "post", "TRACE", "OPTIONS", "HEAD", "*", "ALL", ""]) {
|
||||
expect(isModuleHttpMethod(method)).toBe(false);
|
||||
}
|
||||
expect(isModuleHttpMethod(42)).toBe(false);
|
||||
expect(isModuleHttpMethod(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moduleRoutePathSchema", () => {
|
||||
it("accepts the root, static, nested, and parameterized canonical paths", () => {
|
||||
for (const path of [
|
||||
"/",
|
||||
"/events",
|
||||
"/events/upcoming",
|
||||
"/events/:eventId",
|
||||
"/events/:eventId/attendees/:attendeeId",
|
||||
]) {
|
||||
expect(parseModuleRoutePath(path)).toBe(path);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects paths that do not begin with /", () => {
|
||||
expect(safeParseModuleRoutePath("events").success).toBe(false);
|
||||
expect(safeParseModuleRoutePath("").success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-root path ending with /", () => {
|
||||
expect(safeParseModuleRoutePath("/events/").success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty and repeated segments", () => {
|
||||
expect(safeParseModuleRoutePath("/events//upcoming").success).toBe(false);
|
||||
expect(safeParseModuleRoutePath("//events").success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects query strings and fragments", () => {
|
||||
expect(safeParseModuleRoutePath("/events?limit=10").success).toBe(false);
|
||||
expect(safeParseModuleRoutePath("/events#upcoming").success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects backslashes and percent-encoded path syntax", () => {
|
||||
expect(safeParseModuleRoutePath("/events\\upcoming").success).toBe(false);
|
||||
expect(safeParseModuleRoutePath("/events%2Fupcoming").success).toBe(false);
|
||||
expect(safeParseModuleRoutePath("/%2e%2e/events").success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects dot segments and traversal syntax", () => {
|
||||
expect(safeParseModuleRoutePath("/events/..").success).toBe(false);
|
||||
expect(safeParseModuleRoutePath("/events/.").success).toBe(false);
|
||||
expect(safeParseModuleRoutePath("/../events").success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects wildcard or catch-all segments", () => {
|
||||
expect(safeParseModuleRoutePath("/events/*").success).toBe(false);
|
||||
expect(safeParseModuleRoutePath("/events/**").success).toBe(false);
|
||||
expect(safeParseModuleRoutePath("/*").success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-kebab-case static segments", () => {
|
||||
expect(safeParseModuleRoutePath("/Events").success).toBe(false);
|
||||
expect(safeParseModuleRoutePath("/events_list").success).toBe(false);
|
||||
expect(safeParseModuleRoutePath("/events--upcoming").success).toBe(false);
|
||||
expect(safeParseModuleRoutePath("/-events").success).toBe(false);
|
||||
expect(safeParseModuleRoutePath("/events-").success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a malformed parameter segment", () => {
|
||||
expect(safeParseModuleRoutePath("/events/:").success).toBe(false);
|
||||
expect(safeParseModuleRoutePath("/events/:Event-Id").success).toBe(false);
|
||||
expect(safeParseModuleRoutePath("/events/:1id").success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects duplicate parameter names within one path", () => {
|
||||
expect(safeParseModuleRoutePath("/events/:id/attendees/:id").success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an unreasonably long path", () => {
|
||||
expect(safeParseModuleRoutePath(`/${"a".repeat(300)}`).success).toBe(false);
|
||||
});
|
||||
|
||||
it("never echoes the rejected raw path in its issue messages", () => {
|
||||
const secret = "/machine/private/path/traversal-attempt-%2e%2e";
|
||||
const result = moduleRoutePathSchema.safeParse(secret);
|
||||
expect(result.success).toBe(false);
|
||||
expect(JSON.stringify(result.error?.issues)).not.toContain(secret);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moduleRouteShapeKey", () => {
|
||||
it("collapses different parameter names into the same shape for the same method", () => {
|
||||
const eventId = parseModuleRoutePath("/events/:eventId");
|
||||
const id = parseModuleRoutePath("/events/:id");
|
||||
expect(moduleRouteShapeKey("GET", eventId)).toBe(moduleRouteShapeKey("GET", id));
|
||||
});
|
||||
|
||||
it("distinguishes shapes by method and by static path structure", () => {
|
||||
const path = parseModuleRoutePath("/events/:eventId");
|
||||
expect(moduleRouteShapeKey("GET", path)).not.toBe(moduleRouteShapeKey("POST", path));
|
||||
expect(moduleRouteShapeKey("GET", parseModuleRoutePath("/events"))).not.toBe(
|
||||
moduleRouteShapeKey("GET", parseModuleRoutePath("/events/:eventId")),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats the root path as its own shape", () => {
|
||||
expect(moduleRouteShapeKey("GET", parseModuleRoutePath("/"))).toBe("GET /");
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeParseModuleRouteDefinition", () => {
|
||||
it("accepts a well-formed definition and brands its path and permission", () => {
|
||||
const result = safeParseModuleRouteDefinition(validDefinition());
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual({
|
||||
method: "GET",
|
||||
path: "/events/:eventId",
|
||||
permission: "calendar.events.read",
|
||||
handler: noopHandler,
|
||||
});
|
||||
expect(Object.isFrozen(result.data)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a non-object, null, or array definition", () => {
|
||||
for (const value of [null, undefined, "route", 42, []]) {
|
||||
expect(safeParseModuleRouteDefinition(value)).toEqual({
|
||||
success: false,
|
||||
code: "MODULE_ROUTE_DEFINITION_INVALID",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an unknown route-definition field", () => {
|
||||
expect(safeParseModuleRouteDefinition({ ...validDefinition(), baseline: "public" })).toEqual({
|
||||
success: false,
|
||||
code: "MODULE_ROUTE_DEFINITION_INVALID",
|
||||
});
|
||||
expect(
|
||||
safeParseModuleRouteDefinition({ ...validDefinition(), mountPrefix: "/custom" }),
|
||||
).toEqual({ success: false, code: "MODULE_ROUTE_DEFINITION_INVALID" });
|
||||
});
|
||||
|
||||
it("rejects a missing required field", () => {
|
||||
const { handler: _handler, ...withoutHandler } = validDefinition();
|
||||
expect(safeParseModuleRouteDefinition(withoutHandler)).toEqual({
|
||||
success: false,
|
||||
code: "MODULE_ROUTE_DEFINITION_INVALID",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a non-function handler", () => {
|
||||
expect(
|
||||
safeParseModuleRouteDefinition({ ...validDefinition(), handler: "not-a-function" }),
|
||||
).toEqual({ success: false, code: "MODULE_ROUTE_DEFINITION_INVALID" });
|
||||
});
|
||||
|
||||
it("rejects an invalid method", () => {
|
||||
expect(safeParseModuleRouteDefinition({ ...validDefinition(), method: "get" })).toEqual({
|
||||
success: false,
|
||||
code: "MODULE_ROUTE_METHOD_INVALID",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an invalid path", () => {
|
||||
expect(safeParseModuleRouteDefinition({ ...validDefinition(), path: "events" })).toEqual({
|
||||
success: false,
|
||||
code: "MODULE_ROUTE_PATH_INVALID",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an invalid permission id", () => {
|
||||
expect(
|
||||
safeParseModuleRouteDefinition({ ...validDefinition(), permission: "not namespaced" }),
|
||||
).toEqual({ success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" });
|
||||
expect(safeParseModuleRouteDefinition({ ...validDefinition(), permission: "*" })).toEqual({
|
||||
success: false,
|
||||
code: "MODULE_ROUTE_PERMISSION_INVALID",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not retain the caller's mutable definition object: mutating it afterward cannot change the parsed result", () => {
|
||||
const source = { ...validDefinition() };
|
||||
const result = safeParseModuleRouteDefinition(source);
|
||||
expect(result.success).toBe(true);
|
||||
source.method = "POST";
|
||||
source.path = "/mutated";
|
||||
source.permission = "mutated.permission.id";
|
||||
if (result.success) {
|
||||
expect(result.data.method).toBe("GET");
|
||||
expect(result.data.path).toBe("/events/:eventId");
|
||||
expect(result.data.permission).toBe("calendar.events.read");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseModuleRouteDefinition", () => {
|
||||
it("returns the parsed definition for valid input", () => {
|
||||
expect(parseModuleRouteDefinition(validDefinition())).toMatchObject({
|
||||
method: "GET",
|
||||
path: "/events/:eventId",
|
||||
permission: "calendar.events.read",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws without echoing the rejected input", () => {
|
||||
const secret = "secret-token-value";
|
||||
expect(() => parseModuleRouteDefinition({ ...validDefinition(), permission: secret })).toThrow(
|
||||
"Invalid Ekklesia module route definition.",
|
||||
);
|
||||
try {
|
||||
parseModuleRouteDefinition({ ...validDefinition(), permission: secret });
|
||||
throw new Error("expected parseModuleRouteDefinition to throw");
|
||||
} catch (error) {
|
||||
expect(String(error)).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { defineModuleRuntime } from "../src/index.js";
|
||||
import {
|
||||
defineModuleRuntime,
|
||||
isModuleHttpMethod,
|
||||
MODULE_HTTP_METHODS,
|
||||
type ModuleHttpRouteRegistrar,
|
||||
type ModuleRegistrationContext,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("defineModuleRuntime", () => {
|
||||
it("defines a frozen server runtime without duplicating the static manifest", async () => {
|
||||
@@ -22,4 +28,42 @@ describe("defineModuleRuntime", () => {
|
||||
}),
|
||||
).toThrow("Invalid Ekklesia module runtime definition.");
|
||||
});
|
||||
|
||||
it("re-exports the module-http contract so a module's register hook can declare routes", async () => {
|
||||
expect(MODULE_HTTP_METHODS).toContain("GET");
|
||||
expect(isModuleHttpMethod("GET")).toBe(true);
|
||||
expect(isModuleHttpMethod("get")).toBe(false);
|
||||
|
||||
const registeredRoutes: unknown[] = [];
|
||||
const http: ModuleHttpRouteRegistrar = {
|
||||
route: (definition) => {
|
||||
registeredRoutes.push(definition);
|
||||
},
|
||||
};
|
||||
const runtime = defineModuleRuntime({
|
||||
id: "example",
|
||||
version: "0.1.0",
|
||||
register({ http: registrar }) {
|
||||
registrar.route({
|
||||
method: "GET",
|
||||
path: "/records",
|
||||
permission: "example.records.read",
|
||||
handler: () => new Response(null, { status: 204 }),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const context: ModuleRegistrationContext = {
|
||||
capabilities: {
|
||||
provide: () => undefined,
|
||||
resolve: () => undefined,
|
||||
},
|
||||
http,
|
||||
moduleId: "example",
|
||||
};
|
||||
await runtime.register?.(context);
|
||||
|
||||
expect(registeredRoutes).toHaveLength(1);
|
||||
expect(registeredRoutes[0]).toMatchObject({ method: "GET", path: "/records" });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user