feat: define module HTTP routes

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

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-24 18:36:23 -05:00
co-authored by Claude Sonnet 5
parent c9cf1f08e3
commit e296e083ec
10 changed files with 1304 additions and 28 deletions
+75 -4
View File
@@ -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
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
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
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
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.
staged capability registry and an unpublished staged HTTP route list side by side, and starts them in
the same order. Neither the capability registry nor the route list is published — visible on
`StartedModuleRuntime` — unless every module registers and starts successfully. Shutdown is
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