feat: publish validated module UI artifacts at a platform-owned URL
Phase 6C pass 1. ADR 0024 named the one thing missing between the module UI build pipeline and the generic host loader: Core resolves entrypoints.ui to an absolute server filesystem path, and a browser needs a URL. This adds that publication layer. Core owns the policy. createModuleUiPublicationRegistry builds, from the discovered and started module inventory, the answer to "may this be published, and which exact file is it?" — publishable means the module survived discovery and declares entrypoints.ui, with no new lifecycle state and no installer. apps/api owns the transport: GET /api/v1/module-ui/<module-id>/<version>. Both segments are lookup keys, not path components. An exact match yields the canonical artifact path discovery already validated; no request value is ever composed into a filesystem path, and the route declares no wildcard segment, so traversal, encoded separators, sibling chunks, package-root files, and source maps are unreachable by construction. An installed module directory never becomes a static file root. The prefix is a sibling of /api/v1/modules/, not a child: `ui` is a legal module route segment, so a module registering GET /ui/:version would otherwise compete with the platform for one URL with routing order deciding the winner. The route requires no authentication, deliberately. Fetching the bundle confers no authority, and a cookie-gated route would break cross-origin import(), which sends none. The cost is stated rather than hidden: a caller who knows a module id and its exact version can download that bundle. The three failure modes return one identical 404 so the route is not an enumeration oracle. Caching is no-cache with a strong SHA-256 ETag and RFC 9110 If-None-Match handling, not immutable: without an installer nothing stops a version being rebuilt with different bytes. The server reads bytes and writes bytes — never imports, evaluates, or transforms the artifact. Nothing mounts a module UI. A host could now obtain a URL, which was previously impossible; there is still no Dashboard shell, navigation, or metadata endpoint. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
82cbeef437
commit
a02deb9433
@@ -11,6 +11,10 @@ import {
|
||||
type PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
type ModuleUiPublicationRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
|
||||
@@ -43,6 +47,13 @@ function fakeModuleServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database });
|
||||
}
|
||||
|
||||
/** No module publishes UI in these wiring tests, so the publication route is mounted with an
|
||||
* empty registry built by Core's own factory — never a hand-written stub, so the route under test
|
||||
* always sees the real policy object production hands it. */
|
||||
function emptyModuleUiPublication(): ModuleUiPublicationRegistry {
|
||||
return createModuleUiPublicationRegistry([]);
|
||||
}
|
||||
|
||||
/** A fully stubbed authorization dependency, denying everything by default — tests that need a
|
||||
* specific decision or store behavior override the relevant method explicitly. The registry
|
||||
* defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the
|
||||
@@ -75,6 +86,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -92,6 +104,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -110,6 +123,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -130,6 +144,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -152,6 +167,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -174,6 +190,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -193,6 +210,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -214,6 +232,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -232,6 +251,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -252,6 +272,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -279,6 +300,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -307,6 +329,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -340,6 +363,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("has deterministic 404 behavior for an unknown route", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -355,6 +379,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -372,6 +397,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -388,6 +414,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("supports requests with no Origin header at all (native/server clients)", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -402,6 +429,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -423,6 +451,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("never wildcards Access-Control-Allow-Origin", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -447,6 +476,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -474,6 +504,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
};
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({
|
||||
evaluator: { authorize: evaluatorAuthorize },
|
||||
@@ -492,6 +523,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
|
||||
Reference in New Issue
Block a user