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
+147
-21
@@ -4,12 +4,17 @@ import {
|
|||||||
type CapabilityRegistrar,
|
type CapabilityRegistrar,
|
||||||
type CapabilityResolver,
|
type CapabilityResolver,
|
||||||
capabilityIdSchema,
|
capabilityIdSchema,
|
||||||
|
type ModuleHttpRouteRegistrar,
|
||||||
type ModuleId,
|
type ModuleId,
|
||||||
type ModuleRegistrationContext,
|
type ModuleRegistrationContext,
|
||||||
|
type ModuleRouteDefinitionInput,
|
||||||
type ModuleRuntimeContext,
|
type ModuleRuntimeContext,
|
||||||
type ModuleRuntimeDefinition,
|
type ModuleRuntimeDefinition,
|
||||||
|
moduleRouteShapeKey,
|
||||||
parseModuleRuntimeDefinition,
|
parseModuleRuntimeDefinition,
|
||||||
type RegisteredCapability,
|
type RegisteredCapability,
|
||||||
|
type RegisteredModuleHttpRoute,
|
||||||
|
safeParseModuleRouteDefinition,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
@@ -27,6 +32,12 @@ export type ModuleRuntimeIssueCode =
|
|||||||
| "MODULE_CAPABILITY_UNREGISTERED"
|
| "MODULE_CAPABILITY_UNREGISTERED"
|
||||||
| "MODULE_REGISTER_FAILED"
|
| "MODULE_REGISTER_FAILED"
|
||||||
| "MODULE_ROLLBACK_FAILED"
|
| "MODULE_ROLLBACK_FAILED"
|
||||||
|
| "MODULE_ROUTE_DEFINITION_INVALID"
|
||||||
|
| "MODULE_ROUTE_DUPLICATE"
|
||||||
|
| "MODULE_ROUTE_METHOD_INVALID"
|
||||||
|
| "MODULE_ROUTE_PATH_INVALID"
|
||||||
|
| "MODULE_ROUTE_PERMISSION_INVALID"
|
||||||
|
| "MODULE_ROUTE_PERMISSION_UNDECLARED"
|
||||||
| "MODULE_RUNTIME_EXPORT_INVALID"
|
| "MODULE_RUNTIME_EXPORT_INVALID"
|
||||||
| "MODULE_RUNTIME_ID_MISMATCH"
|
| "MODULE_RUNTIME_ID_MISMATCH"
|
||||||
| "MODULE_RUNTIME_IMPORT_FAILED"
|
| "MODULE_RUNTIME_IMPORT_FAILED"
|
||||||
@@ -53,6 +64,9 @@ export interface RunningModule {
|
|||||||
export interface StartedModuleRuntime {
|
export interface StartedModuleRuntime {
|
||||||
readonly capabilities: CapabilityRegistry;
|
readonly capabilities: CapabilityRegistry;
|
||||||
readonly modules: readonly RunningModule[];
|
readonly modules: readonly RunningModule[];
|
||||||
|
/** One immutable startup snapshot: discovered-module order, then registration order within each
|
||||||
|
* module. Never mutated after startup — there is no dynamic route addition or removal. */
|
||||||
|
readonly routes: readonly RegisteredModuleHttpRoute[];
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,11 +77,16 @@ interface LoadedModule {
|
|||||||
readonly discovered: DiscoveredModule;
|
readonly discovered: DiscoveredModule;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RegistrationStage {
|
interface CapabilityRegistrationStage {
|
||||||
readonly context: ModuleRegistrationContext;
|
readonly capabilities: CapabilityRegistrar;
|
||||||
commit(): readonly ModuleRuntimeIssue[];
|
commit(): readonly ModuleRuntimeIssue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HttpRouteRegistrationStage {
|
||||||
|
readonly http: ModuleHttpRouteRegistrar;
|
||||||
|
commit(): void;
|
||||||
|
}
|
||||||
|
|
||||||
class ModuleRuntimeSignal extends Error {
|
class ModuleRuntimeSignal extends Error {
|
||||||
readonly issue: ModuleRuntimeIssue;
|
readonly issue: ModuleRuntimeIssue;
|
||||||
|
|
||||||
@@ -109,7 +128,7 @@ function defaultExport(moduleNamespace: unknown): unknown {
|
|||||||
class StagedCapabilityRegistry {
|
class StagedCapabilityRegistry {
|
||||||
readonly #registered = new Map<CapabilityId, RegisteredCapability>();
|
readonly #registered = new Map<CapabilityId, RegisteredCapability>();
|
||||||
|
|
||||||
createRegistrationStage(module: DiscoveredModule): RegistrationStage {
|
createRegistrationStage(module: DiscoveredModule): CapabilityRegistrationStage {
|
||||||
const local = new Map<CapabilityId, RegisteredCapability>();
|
const local = new Map<CapabilityId, RegisteredCapability>();
|
||||||
const declarations = new Map(
|
const declarations = new Map(
|
||||||
module.manifest.capabilities.map((declaration) => [declaration.id, declaration] as const),
|
module.manifest.capabilities.map((declaration) => [declaration.id, declaration] as const),
|
||||||
@@ -154,13 +173,9 @@ class StagedCapabilityRegistry {
|
|||||||
},
|
},
|
||||||
resolve,
|
resolve,
|
||||||
});
|
});
|
||||||
const context: ModuleRegistrationContext = Object.freeze({
|
|
||||||
capabilities,
|
|
||||||
moduleId: module.manifest.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
context,
|
capabilities,
|
||||||
commit: (): readonly ModuleRuntimeIssue[] => {
|
commit: (): readonly ModuleRuntimeIssue[] => {
|
||||||
const missing = [...declarations.keys()]
|
const missing = [...declarations.keys()]
|
||||||
.filter((capabilityId) => !local.has(capabilityId))
|
.filter((capabilityId) => !local.has(capabilityId))
|
||||||
@@ -252,6 +267,88 @@ class StagedCapabilityRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stages a module's HTTP route registrations during `register`, exactly like
|
||||||
|
* `StagedCapabilityRegistry` stages capability provision: routes accumulate in a per-module local
|
||||||
|
* list, validated and rejected immediately (`ModuleRuntimeSignal`) at the `http.route(...)` call
|
||||||
|
* site, and are only merged into the shared, eventually-published list by `commit()` — called only
|
||||||
|
* after the module's `register` hook returns without throwing. Unlike capabilities, there is no
|
||||||
|
* "declared but never provided" concept for routes (a manifest never declares routes), so `commit`
|
||||||
|
* has nothing to validate; it only publishes what already passed validation.
|
||||||
|
*/
|
||||||
|
class StagedModuleHttpRoutes {
|
||||||
|
readonly #routes: RegisteredModuleHttpRoute[] = [];
|
||||||
|
|
||||||
|
createRegistrationStage(module: DiscoveredModule): HttpRouteRegistrationStage {
|
||||||
|
const local: RegisteredModuleHttpRoute[] = [];
|
||||||
|
const shapes = new Set<string>();
|
||||||
|
const ownedPermissionIds = new Set(
|
||||||
|
module.manifest.permissions.map((declaration) => declaration.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const http: ModuleHttpRouteRegistrar = Object.freeze({
|
||||||
|
route: (definition: ModuleRouteDefinitionInput): void => {
|
||||||
|
const parsed = safeParseModuleRouteDefinition(definition);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new ModuleRuntimeSignal(
|
||||||
|
runtimeIssue(
|
||||||
|
parsed.code,
|
||||||
|
"register",
|
||||||
|
module.manifest.id,
|
||||||
|
`Module ${module.manifest.id} registered an invalid HTTP route.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Ownership is checked against this module's own manifest-declared permissions only, never
|
||||||
|
// the composed platform registry — a module can therefore never gate a route with Core's or
|
||||||
|
// another module's permission, structurally, regardless of what that other permission is.
|
||||||
|
if (!ownedPermissionIds.has(parsed.data.permission)) {
|
||||||
|
throw new ModuleRuntimeSignal(
|
||||||
|
runtimeIssue(
|
||||||
|
"MODULE_ROUTE_PERMISSION_UNDECLARED",
|
||||||
|
"register",
|
||||||
|
module.manifest.id,
|
||||||
|
`Module ${module.manifest.id} registered an HTTP route with a permission its own manifest does not declare.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const shapeKey = moduleRouteShapeKey(parsed.data.method, parsed.data.path);
|
||||||
|
if (shapes.has(shapeKey)) {
|
||||||
|
throw new ModuleRuntimeSignal(
|
||||||
|
runtimeIssue(
|
||||||
|
"MODULE_ROUTE_DUPLICATE",
|
||||||
|
"register",
|
||||||
|
module.manifest.id,
|
||||||
|
`Module ${module.manifest.id} registered a duplicate HTTP route.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
shapes.add(shapeKey);
|
||||||
|
local.push(
|
||||||
|
Object.freeze({
|
||||||
|
handler: parsed.data.handler,
|
||||||
|
method: parsed.data.method,
|
||||||
|
moduleId: module.manifest.id,
|
||||||
|
path: parsed.data.path,
|
||||||
|
permission: parsed.data.permission,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
http,
|
||||||
|
commit: (): void => {
|
||||||
|
this.#routes.push(...local);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(): readonly RegisteredModuleHttpRoute[] {
|
||||||
|
return Object.freeze([...this.#routes]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRuntimeDefinitions(
|
async function loadRuntimeDefinitions(
|
||||||
discoveredModules: readonly DiscoveredModule[],
|
discoveredModules: readonly DiscoveredModule[],
|
||||||
): Promise<readonly LoadedModule[]> {
|
): Promise<readonly LoadedModule[]> {
|
||||||
@@ -327,12 +424,19 @@ async function loadRuntimeDefinitions(
|
|||||||
|
|
||||||
async function registerModules(
|
async function registerModules(
|
||||||
modules: readonly LoadedModule[],
|
modules: readonly LoadedModule[],
|
||||||
registry: StagedCapabilityRegistry,
|
capabilityRegistry: StagedCapabilityRegistry,
|
||||||
|
httpRoutes: StagedModuleHttpRoutes,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const module of modules) {
|
for (const module of modules) {
|
||||||
const stage = registry.createRegistrationStage(module.discovered);
|
const capabilityStage = capabilityRegistry.createRegistrationStage(module.discovered);
|
||||||
|
const routeStage = httpRoutes.createRegistrationStage(module.discovered);
|
||||||
|
const context: ModuleRegistrationContext = Object.freeze({
|
||||||
|
capabilities: capabilityStage.capabilities,
|
||||||
|
http: routeStage.http,
|
||||||
|
moduleId: module.discovered.manifest.id,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await module.definition.register?.(stage.context);
|
await module.definition.register?.(context);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ModuleRuntimeSignal) {
|
if (error instanceof ModuleRuntimeSignal) {
|
||||||
throw new ModuleRuntimeError([error.issue]);
|
throw new ModuleRuntimeError([error.issue]);
|
||||||
@@ -346,10 +450,15 @@ async function registerModules(
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
const issues = stage.commit();
|
const issues = capabilityStage.commit();
|
||||||
if (issues.length > 0) {
|
if (issues.length > 0) {
|
||||||
throw new ModuleRuntimeError(issues);
|
throw new ModuleRuntimeError(issues);
|
||||||
}
|
}
|
||||||
|
// Routes are only merged into the shared published list once this module's registration has
|
||||||
|
// fully succeeded (register hook did not throw, and every declared capability was provided) —
|
||||||
|
// a failed registration therefore publishes no routes for this module, matching the same
|
||||||
|
// all-or-nothing guarantee capabilities already have.
|
||||||
|
routeStage.commit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,16 +514,25 @@ async function startModules(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startModuleRuntime(
|
/**
|
||||||
options: StartModuleRuntimeOptions,
|
* Starts the module runtime from a module list the caller already discovered — performs no
|
||||||
|
* module-directory scan or manifest read of its own, and preserves the given list's order exactly.
|
||||||
|
* `startModuleRuntime` below is the convenient discovery-plus-start wrapper most callers want;
|
||||||
|
* `apps/api`'s production startup (pass 2) will call this form directly so permission composition
|
||||||
|
* and module HTTP/runtime startup share Phase 5A's exact same one-time discovery rather than
|
||||||
|
* discovering modules twice.
|
||||||
|
*/
|
||||||
|
export async function startDiscoveredModuleRuntime(
|
||||||
|
discoveredModules: readonly DiscoveredModule[],
|
||||||
): Promise<StartedModuleRuntime> {
|
): Promise<StartedModuleRuntime> {
|
||||||
const discovered = await discoverModules(options);
|
const modules = await loadRuntimeDefinitions(discoveredModules);
|
||||||
const modules = await loadRuntimeDefinitions(discovered);
|
const capabilityRegistry = new StagedCapabilityRegistry();
|
||||||
const stagedRegistry = new StagedCapabilityRegistry();
|
const httpRoutes = new StagedModuleHttpRoutes();
|
||||||
await registerModules(modules, stagedRegistry);
|
await registerModules(modules, capabilityRegistry, httpRoutes);
|
||||||
await startModules(modules, stagedRegistry);
|
await startModules(modules, capabilityRegistry);
|
||||||
|
|
||||||
const capabilities = stagedRegistry.publish();
|
const capabilities = capabilityRegistry.publish();
|
||||||
|
const routes = httpRoutes.publish();
|
||||||
const runningModules = Object.freeze(
|
const runningModules = Object.freeze(
|
||||||
modules.map((module) =>
|
modules.map((module) =>
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
@@ -428,9 +546,10 @@ export async function startModuleRuntime(
|
|||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
capabilities,
|
capabilities,
|
||||||
modules: runningModules,
|
modules: runningModules,
|
||||||
|
routes,
|
||||||
stop: (): Promise<void> => {
|
stop: (): Promise<void> => {
|
||||||
stopPromise ??= (async () => {
|
stopPromise ??= (async () => {
|
||||||
const issues = await stopModules(modules, stagedRegistry, "stop");
|
const issues = await stopModules(modules, capabilityRegistry, "stop");
|
||||||
if (issues.length > 0) {
|
if (issues.length > 0) {
|
||||||
throw new ModuleRuntimeError(issues);
|
throw new ModuleRuntimeError(issues);
|
||||||
}
|
}
|
||||||
@@ -439,3 +558,10 @@ export async function startModuleRuntime(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function startModuleRuntime(
|
||||||
|
options: StartModuleRuntimeOptions,
|
||||||
|
): Promise<StartedModuleRuntime> {
|
||||||
|
const discovered = await discoverModules(options);
|
||||||
|
return startDiscoveredModuleRuntime(discovered);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const readdir = vi.fn();
|
||||||
|
const readFile = vi.fn();
|
||||||
|
const realpath = vi.fn();
|
||||||
|
const stat = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("node:fs/promises", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("node:fs/promises")>()),
|
||||||
|
readdir,
|
||||||
|
readFile,
|
||||||
|
realpath,
|
||||||
|
stat,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("module HTTP contract and runtime import safety", () => {
|
||||||
|
it("performs no filesystem I/O merely by importing the module runtime or the module-http contract", async () => {
|
||||||
|
await import("../src/modules/index.js");
|
||||||
|
await import("@ekklesia/contracts");
|
||||||
|
|
||||||
|
expect(readdir).not.toHaveBeenCalled();
|
||||||
|
expect(readFile).not.toHaveBeenCalled();
|
||||||
|
expect(realpath).not.toHaveBeenCalled();
|
||||||
|
expect(stat).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { discoverModules } from "../src/modules/discovery.js";
|
||||||
import {
|
import {
|
||||||
ModuleDiscoveryError,
|
ModuleDiscoveryError,
|
||||||
ModuleRuntimeError,
|
ModuleRuntimeError,
|
||||||
type ModuleRuntimeIssueCode,
|
type ModuleRuntimeIssueCode,
|
||||||
|
startDiscoveredModuleRuntime,
|
||||||
startModuleRuntime,
|
startModuleRuntime,
|
||||||
} from "../src/modules/index.js";
|
} from "../src/modules/index.js";
|
||||||
|
|
||||||
|
vi.mock("../src/modules/discovery.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../src/modules/discovery.js")>();
|
||||||
|
return { ...actual, discoverModules: vi.fn(actual.discoverModules) };
|
||||||
|
});
|
||||||
|
|
||||||
interface RuntimeModuleFixture {
|
interface RuntimeModuleFixture {
|
||||||
readonly directory?: string;
|
readonly directory?: string;
|
||||||
readonly manifest?: Record<string, unknown>;
|
readonly manifest?: Record<string, unknown>;
|
||||||
@@ -40,6 +47,7 @@ function manifestFor(
|
|||||||
options: {
|
options: {
|
||||||
capabilities?: Array<{ id: string; version: string }>;
|
capabilities?: Array<{ id: string; version: string }>;
|
||||||
dependencies?: Record<string, unknown>;
|
dependencies?: Record<string, unknown>;
|
||||||
|
permissions?: Array<{ id: string; baseline: string; description: string }>;
|
||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
@@ -52,7 +60,7 @@ function manifestFor(
|
|||||||
moduleSdk: "^0.1.0",
|
moduleSdk: "^0.1.0",
|
||||||
},
|
},
|
||||||
capabilities: options.capabilities ?? [],
|
capabilities: options.capabilities ?? [],
|
||||||
permissions: [],
|
permissions: options.permissions ?? [],
|
||||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||||
entrypoints: {
|
entrypoints: {
|
||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
@@ -370,3 +378,482 @@ describe("module runtime", () => {
|
|||||||
expect(JSON.stringify(error.issues)).not.toContain(modulesDirectory);
|
expect(JSON.stringify(error.issues)).not.toContain(modulesDirectory);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function noopHandlerSource(): string {
|
||||||
|
return "() => new Response(null, { status: 204 })";
|
||||||
|
}
|
||||||
|
|
||||||
|
function permissionDeclaration(
|
||||||
|
id: string,
|
||||||
|
baseline: "authenticated" | "public" | "restricted" = "restricted",
|
||||||
|
): { id: string; baseline: string; description: string } {
|
||||||
|
return { id, baseline, description: `Test permission ${id}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("module HTTP routes", () => {
|
||||||
|
it("registers one route and publishes it on the started runtime", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "single", {
|
||||||
|
manifest: manifestFor("single", {
|
||||||
|
permissions: [permissionDeclaration("single.items.read")],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "single", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/items",
|
||||||
|
permission: "single.items.read",
|
||||||
|
handler: ${noopHandlerSource()},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||||
|
expect(runtime.routes).toHaveLength(1);
|
||||||
|
expect(runtime.routes[0]).toMatchObject({
|
||||||
|
moduleId: "single",
|
||||||
|
method: "GET",
|
||||||
|
path: "/items",
|
||||||
|
permission: "single.items.read",
|
||||||
|
});
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains registration order within a module and discovery order across modules", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "alpha", {
|
||||||
|
manifest: manifestFor("alpha", {
|
||||||
|
permissions: [
|
||||||
|
permissionDeclaration("alpha.first.read"),
|
||||||
|
permissionDeclaration("alpha.second.read"),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "alpha", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "/first", permission: "alpha.first.read", handler: ${noopHandlerSource()} });
|
||||||
|
http.route({ method: "GET", path: "/second", permission: "alpha.second.read", handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
await createRuntimeModule(modulesDirectory, "beta", {
|
||||||
|
manifest: manifestFor("beta", {
|
||||||
|
permissions: [permissionDeclaration("beta.third.read")],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "beta", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "/third", permission: "beta.third.read", handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||||
|
expect(
|
||||||
|
runtime.routes.map((route) => `${route.moduleId}:${route.method}:${route.path}`),
|
||||||
|
).toEqual(["alpha:GET:/first", "alpha:GET:/second", "beta:GET:/third"]);
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows the same path under different methods", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "same-path", {
|
||||||
|
manifest: manifestFor("same-path", {
|
||||||
|
permissions: [
|
||||||
|
permissionDeclaration("same-path.items.read"),
|
||||||
|
permissionDeclaration("same-path.items.write"),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "same-path", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "/items", permission: "same-path.items.read", handler: ${noopHandlerSource()} });
|
||||||
|
http.route({ method: "POST", path: "/items", permission: "same-path.items.write", handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||||
|
expect(runtime.routes.map((route) => `${route.method} ${route.path}`)).toEqual([
|
||||||
|
"GET /items",
|
||||||
|
"POST /items",
|
||||||
|
]);
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a duplicate method/path route registered by the same module", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "dup", {
|
||||||
|
manifest: manifestFor("dup", {
|
||||||
|
permissions: [permissionDeclaration("dup.items.read")],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "dup", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "/items", permission: "dup.items.read", handler: ${noopHandlerSource()} });
|
||||||
|
http.route({ method: "GET", path: "/items", permission: "dup.items.read", handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await captureRuntimeError(modulesDirectory);
|
||||||
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_DUPLICATE"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collides parameter-name-only path variants under the same method", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "param-shape", {
|
||||||
|
manifest: manifestFor("param-shape", {
|
||||||
|
permissions: [permissionDeclaration("param-shape.events.read")],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "param-shape", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "/events/:eventId", permission: "param-shape.events.read", handler: ${noopHandlerSource()} });
|
||||||
|
http.route({ method: "GET", path: "/events/:id", permission: "param-shape.events.read", handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await captureRuntimeError(modulesDirectory);
|
||||||
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_DUPLICATE"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a malformed route with its specific structured issue code", async () => {
|
||||||
|
const badMethodDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(badMethodDirectory, "bad-method", {
|
||||||
|
manifest: manifestFor("bad-method", {
|
||||||
|
permissions: [permissionDeclaration("bad-method.items.read")],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "bad-method", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "get", path: "/items", permission: "bad-method.items.read", handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
expect(runtimeIssueCodes(await captureRuntimeError(badMethodDirectory))).toEqual([
|
||||||
|
"MODULE_ROUTE_METHOD_INVALID",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const badPathDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(badPathDirectory, "bad-path", {
|
||||||
|
manifest: manifestFor("bad-path", {
|
||||||
|
permissions: [permissionDeclaration("bad-path.items.read")],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "bad-path", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "items", permission: "bad-path.items.read", handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
expect(runtimeIssueCodes(await captureRuntimeError(badPathDirectory))).toEqual([
|
||||||
|
"MODULE_ROUTE_PATH_INVALID",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const badDefinitionDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(badDefinitionDirectory, "bad-shape", {
|
||||||
|
manifest: manifestFor("bad-shape", {
|
||||||
|
permissions: [permissionDeclaration("bad-shape.items.read")],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "bad-shape", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/items",
|
||||||
|
permission: "bad-shape.items.read",
|
||||||
|
baseline: "public",
|
||||||
|
handler: ${noopHandlerSource()},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
expect(runtimeIssueCodes(await captureRuntimeError(badDefinitionDirectory))).toEqual([
|
||||||
|
"MODULE_ROUTE_DEFINITION_INVALID",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a route permission the registering module does not itself declare", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "undeclared", {
|
||||||
|
manifest: manifestFor("undeclared"),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "undeclared", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "/items", permission: "undeclared.items.read", handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await captureRuntimeError(modulesDirectory);
|
||||||
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_PERMISSION_UNDECLARED"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a module gating a route with a Core-owned permission it cannot declare", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "coreattempt", {
|
||||||
|
manifest: manifestFor("coreattempt"),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "coreattempt", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/items",
|
||||||
|
permission: "core.authorization.grants.manage",
|
||||||
|
handler: ${noopHandlerSource()},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await captureRuntimeError(modulesDirectory);
|
||||||
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_PERMISSION_UNDECLARED"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a module gating a route with another module's permission", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "alpha", {
|
||||||
|
manifest: manifestFor("alpha", {
|
||||||
|
permissions: [permissionDeclaration("alpha.items.read")],
|
||||||
|
}),
|
||||||
|
serverSource: `export default { id: "alpha", version: "0.1.0" };`,
|
||||||
|
});
|
||||||
|
await createRuntimeModule(modulesDirectory, "beta", {
|
||||||
|
manifest: manifestFor("beta"),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "beta", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "/items", permission: "alpha.items.read", handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await captureRuntimeError(modulesDirectory);
|
||||||
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_PERMISSION_UNDECLARED"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a declared public, authenticated, or restricted permission without interpreting its baseline", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "baselines", {
|
||||||
|
manifest: manifestFor("baselines", {
|
||||||
|
permissions: [
|
||||||
|
permissionDeclaration("baselines.public.read", "public"),
|
||||||
|
permissionDeclaration("baselines.authenticated.read", "authenticated"),
|
||||||
|
permissionDeclaration("baselines.restricted.read", "restricted"),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "baselines", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "/public", permission: "baselines.public.read", handler: ${noopHandlerSource()} });
|
||||||
|
http.route({ method: "GET", path: "/authenticated", permission: "baselines.authenticated.read", handler: ${noopHandlerSource()} });
|
||||||
|
http.route({ method: "GET", path: "/restricted", permission: "baselines.restricted.read", handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||||
|
expect(runtime.routes).toHaveLength(3);
|
||||||
|
for (const route of runtime.routes) {
|
||||||
|
expect(Object.keys(route).sort()).toEqual([
|
||||||
|
"handler",
|
||||||
|
"method",
|
||||||
|
"moduleId",
|
||||||
|
"path",
|
||||||
|
"permission",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never invokes a route handler while registering or starting", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createRuntimeModule(modulesDirectory, "never-invoked", {
|
||||||
|
manifest: manifestFor("never-invoked", {
|
||||||
|
permissions: [permissionDeclaration("never-invoked.items.read")],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "never-invoked", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/items",
|
||||||
|
permission: "never-invoked.items.read",
|
||||||
|
handler: () => { ${recorder.expression}.push("handler:called"); return new Response(null, { status: 204 }); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||||
|
expect(recorder.events).toEqual([]);
|
||||||
|
await runtime.stop();
|
||||||
|
expect(recorder.events).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes frozen, immutable routes as a frozen route list", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "frozen", {
|
||||||
|
manifest: manifestFor("frozen", {
|
||||||
|
permissions: [permissionDeclaration("frozen.items.read")],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "frozen", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "/items", permission: "frozen.items.read", handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||||
|
expect(Object.isFrozen(runtime.routes)).toBe(true);
|
||||||
|
expect(Object.isFrozen(runtime.routes[0])).toBe(true);
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a module mutate the published route by mutating its own definition afterward", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "mutate", {
|
||||||
|
manifest: manifestFor("mutate", {
|
||||||
|
permissions: [
|
||||||
|
permissionDeclaration("mutate.items.read"),
|
||||||
|
permissionDeclaration("mutate.other.read"),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "mutate", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
const definition = {
|
||||||
|
method: "GET",
|
||||||
|
path: "/items",
|
||||||
|
permission: "mutate.items.read",
|
||||||
|
handler: ${noopHandlerSource()},
|
||||||
|
};
|
||||||
|
http.route(definition);
|
||||||
|
definition.method = "POST";
|
||||||
|
definition.path = "/mutated";
|
||||||
|
definition.permission = "mutate.other.read";
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||||
|
expect(runtime.routes[0]).toMatchObject({
|
||||||
|
method: "GET",
|
||||||
|
path: "/items",
|
||||||
|
permission: "mutate.items.read",
|
||||||
|
});
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes no runtime when a later module's route registration fails", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "first-ok", {
|
||||||
|
manifest: manifestFor("first-ok", {
|
||||||
|
permissions: [permissionDeclaration("first-ok.items.read")],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "first-ok", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "/items", permission: "first-ok.items.read", handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
await createRuntimeModule(modulesDirectory, "second-bad", {
|
||||||
|
manifest: manifestFor("second-bad"),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "second-bad", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "/items", permission: "second-bad.items.read", handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await captureRuntimeError(modulesDirectory);
|
||||||
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_PERMISSION_UNDECLARED"]);
|
||||||
|
expect(error.issues[0]?.moduleId).toBe("second-bad");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves existing rollback behavior when a registered route's module later fails to start", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createRuntimeModule(modulesDirectory, "routed", {
|
||||||
|
manifest: manifestFor("routed", {
|
||||||
|
permissions: [permissionDeclaration("routed.items.read")],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "routed", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "/items", permission: "routed.items.read", handler: ${noopHandlerSource()} });
|
||||||
|
},
|
||||||
|
start() { ${recorder.expression}.push("routed:start"); },
|
||||||
|
stop() { ${recorder.expression}.push("routed:stop"); }
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
await createRuntimeModule(modulesDirectory, "failing", {
|
||||||
|
manifest: manifestFor("failing", {
|
||||||
|
dependencies: { modules: { required: { routed: "^0.1.0" } } },
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "failing", version: "0.1.0",
|
||||||
|
start() { throw new Error("boom"); }
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await captureRuntimeError(modulesDirectory);
|
||||||
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_START_FAILED"]);
|
||||||
|
expect(recorder.events).toEqual(["routed:start", "routed:stop"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("already-discovered module runtime", () => {
|
||||||
|
it("starts from an already-discovered inventory without a second directory scan", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "already-discovered", {
|
||||||
|
serverSource: `export default { id: "already-discovered", version: "0.1.0" };`,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(discoverModules).mockClear();
|
||||||
|
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
||||||
|
expect(discoverModules).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const runtime = await startDiscoveredModuleRuntime(discovered);
|
||||||
|
expect(discoverModules).toHaveBeenCalledTimes(1);
|
||||||
|
expect(runtime.modules.map((module) => module.id)).toEqual(["already-discovered"]);
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the given discovered-module list order exactly, without re-sorting", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "first", {
|
||||||
|
serverSource: `export default { id: "first", version: "0.1.0" };`,
|
||||||
|
});
|
||||||
|
await createRuntimeModule(modulesDirectory, "second", {
|
||||||
|
serverSource: `export default { id: "second", version: "0.1.0" };`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
||||||
|
expect(discovered.map((module) => module.manifest.id)).toEqual(["first", "second"]);
|
||||||
|
|
||||||
|
const runtime = await startDiscoveredModuleRuntime([...discovered].reverse());
|
||||||
|
expect(runtime.modules.map((module) => module.id)).toEqual(["second", "first"]);
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("startModuleRuntime remains backward-compatible and discovers exactly once", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "compat", {
|
||||||
|
serverSource: `export default { id: "compat", version: "0.1.0" };`,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(discoverModules).mockClear();
|
||||||
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
||||||
|
expect(discoverModules).toHaveBeenCalledTimes(1);
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -177,6 +177,75 @@ Entrypoint imports must have no top-level side effects. Modules are trusted back
|
|||||||
evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and
|
evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and
|
||||||
close them in `stop`.
|
close them in `stop`.
|
||||||
|
|
||||||
|
## HTTP routes
|
||||||
|
|
||||||
|
A module registers plain Fetch (`Request` in, `Response` out) route handlers during `register`,
|
||||||
|
through the `http` registrar `ModuleRegistrationContext` provides alongside `capabilities`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineModuleRuntime } from "@ekklesia/module-sdk";
|
||||||
|
|
||||||
|
export default defineModuleRuntime({
|
||||||
|
id: "calendar",
|
||||||
|
version: "0.1.0",
|
||||||
|
|
||||||
|
register({ capabilities, http }) {
|
||||||
|
capabilities.provide("calendar.events", calendarEvents);
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/events/:eventId",
|
||||||
|
permission: "calendar.events.read",
|
||||||
|
async handler(request, context) {
|
||||||
|
return Response.json({
|
||||||
|
eventId: context.params.eventId,
|
||||||
|
principal: context.principal.kind,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
A route definition has exactly four fields — `method`, `path`, `permission`, `handler` — and nothing
|
||||||
|
else; an unknown field (a baseline, a mount prefix, CORS/origin policy, middleware, audit metadata,
|
||||||
|
authentication behavior, or another module's namespace) fails registration outright rather than
|
||||||
|
being ignored. `method` is one of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, always uppercase; there is
|
||||||
|
no `OPTIONS` (the platform owns CORS preflight) and no wildcard/all-method registration.
|
||||||
|
|
||||||
|
`path` is local to the module, never the path a caller actually sends. The platform alone owns the
|
||||||
|
production mount prefix, `/api/v1/modules/<module-id>`: `"/"` is the module's own root, `"/events"`
|
||||||
|
means `/api/v1/modules/calendar/events`, and `"/events/:eventId"` is a parameterized route using the
|
||||||
|
same `:name` syntax a caller's request is matched against. A canonical path begins with `/`, a
|
||||||
|
non-root path never ends with `/`, and every segment is either a lowercase kebab-case static segment
|
||||||
|
or one `:parameterName` segment — no empty or repeated slashes, no query string or fragment, no
|
||||||
|
backslash or percent-encoding, no dot segments, and no wildcard/catch-all segment. A malformed path
|
||||||
|
is rejected outright, never silently normalized.
|
||||||
|
|
||||||
|
Every route — including a route meant to be publicly reachable — names exactly one `permission` the
|
||||||
|
owning module's own manifest declares; there is no way to register an unprotected, no-permission
|
||||||
|
route. A module cannot supply a baseline directly: the permission's trusted `public`, `authenticated`,
|
||||||
|
or `restricted` baseline always comes from the composed platform registry, exactly like any other
|
||||||
|
enforcement decision. Public access is therefore expressed by referencing a permission whose manifest
|
||||||
|
declaration has `baseline: "public"`, never by omitting a permission. A module may only reference a
|
||||||
|
permission its own manifest declares — never a Core-owned permission, and never another module's —
|
||||||
|
so it can never gate a route behind authority it does not itself own. The same local path may be
|
||||||
|
registered under more than one method, but registering the same module twice under the identical
|
||||||
|
method and path (or two paths that are identical once every parameter is treated as an anonymous
|
||||||
|
placeholder, such as `/events/:eventId` and `/events/:id` under `GET`) fails as a duplicate route.
|
||||||
|
|
||||||
|
The handler receives the standard `Request` plus a context of exactly `moduleId`, the caller's
|
||||||
|
already-authorized `EkklesiaPrincipal`, and the immutable decoded path parameters — never a database
|
||||||
|
connection, authorization store, grant/platform-owner state, storage, jobs, configuration, secrets,
|
||||||
|
a framework-specific request context, or a raw Better Auth session. The full query string remains
|
||||||
|
available through `Request.url`; there is no separate query or body abstraction.
|
||||||
|
|
||||||
|
This pass only defines, validates, and collects routes into `StartedModuleRuntime`'s immutable route
|
||||||
|
snapshot — Core never invokes a handler here. Mounting routes into the production `apps/api` Hono
|
||||||
|
application, enforcing each route's permission through `requirePermission`, applying
|
||||||
|
`requireTrustedOrigin`, mapping handler errors to sanitized responses, and wiring this into
|
||||||
|
`startApi`'s production lifecycle are later work.
|
||||||
|
|
||||||
## Drizzle schema definition
|
## Drizzle schema definition
|
||||||
|
|
||||||
A module with tables keeps the generator-facing declarations in `src/schema/tables.ts`:
|
A module with tables keeps the generator-facing declarations in `src/schema/tables.ts`:
|
||||||
@@ -327,10 +396,12 @@ not misreported as a successful check.
|
|||||||
|
|
||||||
Core completes discovery before importing any server entrypoint. It then loads and verifies every
|
Core completes discovery before importing any server entrypoint. It then loads and verifies every
|
||||||
runtime definition before running hooks, registers all modules dependency-first into an unpublished
|
runtime definition before running hooks, registers all modules dependency-first into an unpublished
|
||||||
staged capability registry, and starts them in the same order. Shutdown is reverse-order. If a later
|
staged capability registry and an unpublished staged HTTP route list side by side, and starts them in
|
||||||
module fails to start, Core stops already-started modules in reverse order and reports any cleanup
|
the same order. Neither the capability registry nor the route list is published — visible on
|
||||||
failures without leaking caught exceptions or machine paths. A failing `start` hook must clean up
|
`StartedModuleRuntime` — unless every module registers and starts successfully. Shutdown is
|
||||||
its own partial work before rejecting.
|
reverse-order. If a later module fails to start, Core stops already-started modules in reverse order
|
||||||
|
and reports any cleanup failures without leaking caught exceptions or machine paths. A failing
|
||||||
|
`start` hook must clean up its own partial work before rejecting.
|
||||||
|
|
||||||
## Start from the template
|
## Start from the template
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from "./authorization.js";
|
export * from "./authorization.js";
|
||||||
export * from "./identifiers.js";
|
export * from "./identifiers.js";
|
||||||
export * from "./identity.js";
|
export * from "./identity.js";
|
||||||
|
export * from "./module-http.js";
|
||||||
export * from "./module-lifecycle.js";
|
export * from "./module-lifecycle.js";
|
||||||
export * from "./module-manifest.js";
|
export * from "./module-manifest.js";
|
||||||
export * from "./module-migration.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 type { CapabilityId, ModuleId } from "./identifiers.js";
|
||||||
import { moduleIdSchema } from "./identifiers.js";
|
import { moduleIdSchema } from "./identifiers.js";
|
||||||
|
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
|
||||||
import { semanticVersionSchema } from "./versions.js";
|
import { semanticVersionSchema } from "./versions.js";
|
||||||
|
|
||||||
export type Awaitable<Value> = Value | PromiseLike<Value>;
|
export type Awaitable<Value> = Value | PromiseLike<Value>;
|
||||||
@@ -21,6 +22,10 @@ export interface CapabilityRegistrar extends CapabilityResolver {
|
|||||||
|
|
||||||
export interface ModuleRegistrationContext {
|
export interface ModuleRegistrationContext {
|
||||||
readonly capabilities: CapabilityRegistrar;
|
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;
|
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 { 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", () => {
|
describe("defineModuleRuntime", () => {
|
||||||
it("defines a frozen server runtime without duplicating the static manifest", async () => {
|
it("defines a frozen server runtime without duplicating the static manifest", async () => {
|
||||||
@@ -22,4 +28,42 @@ describe("defineModuleRuntime", () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow("Invalid Ekklesia module runtime definition.");
|
).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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ describe("module template", () => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
http: {
|
||||||
|
route() {
|
||||||
|
throw new Error("The template does not register any HTTP route.");
|
||||||
|
},
|
||||||
|
},
|
||||||
moduleId: "example",
|
moduleId: "example",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user