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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user