feat: publish validated module UI artifacts at a platform-owned URL #20

Merged
Zayin_Admin merged 1 commits from phase-6c/module-ui-asset-publication into main 2026-08-22 13:19:25 +00:00
20 changed files with 1898 additions and 30 deletions
Showing only changes of commit a02deb9433 - Show all commits
+32 -6
View File
@@ -299,12 +299,38 @@ idempotent `unmount()`. A module may never depend on it — it is host infrastru
checker enforces that. See
[ADR 0024](docs/architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md).
**Still no host, and one specific reason why.** Core resolves `entrypoints.ui` to a path on the
*server's filesystem*; a browser cannot import that. **Ekklesia has no route that serves an installed
module's artifact at a browser URL**, so nothing can mount an installed module UI yet — the loader is
complete, asset publication is not, and that is the next pass's first job. The Dashboard shell,
navigation, a design system, a stylesheet loading contract, and the standalone host also do not
exist.
Phase 6C pass 1 builds the publication layer those passes were missing. Core resolves
`entrypoints.ui` to a path on the *server's filesystem*, and a browser needs a **URL**; the platform
now serves one:
```text
GET /api/v1/module-ui/<module-id>/<version>
```
`:moduleId` and `:version` are **lookup keys, not path components**. They are matched against a
publication registry Core builds from the discovered, started module inventory, and only an exact
match causes the already-canonical artifact path discovery validated to be read. No request value is
ever joined 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**, and only the single artifact its
manifest declares is served. A module that is not installed, one that declares no UI, and a wrong
version return one identical `404`; an artifact that vanished after discovery returns a sanitized
`503` carrying no path. The prefix is a *sibling* of `/api/v1/modules/`, not a child, because `ui` is
a legal module route segment and a module could otherwise collide with the platform.
**The route requires no authentication, deliberately.** Fetching the bundle confers no authority —
every module API stays behind `requirePermission`, and the host, not the artifact, supplies the
principal — while requiring a cookie would break cross-origin `import()`, which sends none. The cost
is stated plainly: a caller who already knows a module id *and* its exact installed version can
download that bundle. Caching is `no-cache` with a strong `ETag` rather than `immutable`, because
without an installer nothing stops a version being rebuilt with different bytes. See
[ADR 0025](docs/architecture/decisions/0025-trusted-module-ui-asset-publication.md).
**This is not Dashboard integration.** A host *could* now obtain a browser URL, which was previously
impossible — but nothing in Ekklesia mounts a module UI. There is no Dashboard shell, no navigation,
no place a module's screen appears, and no code calls the URL helper outside its own tests. A design
system, a stylesheet or general asset publication contract, a module metadata endpoint, and the
standalone host also do not exist.
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
+1
View File
@@ -26,6 +26,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/typescript-config": "workspace:*",
"@types/node": "24.13.3",
"@types/pg": "8.20.0",
+14
View File
@@ -9,12 +9,14 @@ import type {
PermissionRegistry,
} from "@ekklesia/core/authorization";
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context, MiddlewareHandler } from "hono";
import { cors } from "hono/cors";
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
import { createGrantsRoutes } from "./grants-routes.js";
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
import { createModuleUiRoutes } from "./module-ui-routes.js";
export interface CreateApiAppAuthorizationOptions {
readonly evaluator: AuthorizationEvaluator;
@@ -38,6 +40,12 @@ export interface CreateApiAppOptions {
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
* `ModuleDatabase` for the lifetime of this API runtime. */
readonly moduleServices: ModuleRuntimeServices;
/** The platform's module UI publication policy — always the one Core builds from the exact
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
* is: a caller that publishes no module UI must say so explicitly with an empty registry, never
* fall through to an implicit one. See `./module-ui-routes.js`. */
readonly moduleUi: ModuleUiPublicationRegistry;
readonly runtime: EkklesiaIdentityRuntime;
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
@@ -165,6 +173,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
}),
);
// A platform-owned route, mounted exactly like Core's own: it serves only artifacts the injected
// publication registry already approved, and it can never collide with a module's own routes,
// which all live under `/api/v1/modules/`. See `./module-ui-routes.js` for why it is a sibling of
// that prefix rather than nested inside it.
app.route("/", createModuleUiRoutes({ registry: options.moduleUi }));
mountModuleHttpRoutes(app, options.moduleRoutes, {
evaluator: options.authorization.evaluator,
registry: options.authorization.registry,
+219
View File
@@ -0,0 +1,219 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context } from "hono";
/**
* The platform-owned URL space for published module UI artifacts.
*
* Deliberately **not** nested under `/api/v1/modules/<module-id>`, and that is a security decision
* rather than a stylistic one. A module may declare any route path matching Core's own grammar
* (`moduleRoutePathSchema`, `@ekklesia/contracts`), and `ui` is a perfectly legal lowercase static
* segment — so a module registering `GET /ui/:version` would be mounted at exactly
* `/api/v1/modules/<module-id>/ui/<version>`. Publishing there would put the platform and installed
* modules in the same URL namespace, with Hono's registration order silently deciding which one
* answers. A sibling prefix can never collide: every module route begins `/api/v1/modules/`, and
* nothing the platform serves here does.
*
* It stays under `/api/v1/*` so it inherits the application's one CORS policy (exact reflected
* trusted origins, never a wildcard) — which a cross-origin browser `import()` of this URL requires
* — instead of introducing a second, separately-configured public URL space.
*/
export const MODULE_UI_ASSET_PREFIX = "/api/v1/module-ui";
/**
* Revalidate-always, with a strong `ETag` doing the actual work.
*
* The URL carries module id and exact installed version, which is *identity*, not *immutability*.
* Ekklesia has no installer, no content addressing, and no integrity verification, so nothing stops
* an operator from rebuilding version `1.2.3` with different bytes and restarting — the ordinary
* inner-loop of developing a module. `immutable` (or any non-zero `max-age`) would therefore be a
* promise the platform cannot keep, and a browser that cached a superseded artifact under a reused
* version would have no way to recover.
*
* `no-cache` is not `no-store`: the response is still cached, it just has to be revalidated. With
* the `ETag` below, an unchanged artifact costs one conditional request and a `304`, and a changed
* one is picked up immediately. A version bump changes the URL and never collides at all. When a
* real installer can guarantee that an installed version's bytes never change, this is the single
* constant that would move to immutable caching.
*/
export const MODULE_UI_CACHE_CONTROL = "no-cache";
/** Modern ESM is served as `text/javascript`, the one JavaScript type the WHATWG MIME sniffing
* standard designates as canonical; `application/javascript` is a legacy alias. A browser refuses
* to evaluate an ES module served as anything else, so this is a correctness requirement, not a
* courtesy. */
export const MODULE_UI_CONTENT_TYPE = "text/javascript; charset=utf-8";
export interface ModuleUiIdentity {
readonly moduleId: string;
readonly version: string;
}
/**
* The one canonical way to turn an installed module's identity into the browser URL its UI artifact
* is published at. Every producer of that URL — this package's own route registration, a future
* module-metadata endpoint, and a future Dashboard host — goes through here, so the URL shape is
* defined once instead of being re-spelled as a template string in each of them.
*
* The result is a **root-relative browser URL, never a filesystem path** — the distinction ADR 0024
* built the loader around. A host on the API's own origin passes it to `loadModuleUi` unchanged; a
* host on a different origin resolves it against the same API base URL it already needs for
* `ModuleUiApiClient` (`new URL(moduleUiArtifactUrl(identity), apiBaseUrl)`). It is deliberately not
* absolute: the API's public origin is deployment configuration, and baking one in here would break
* both split-origin deployments and reverse proxies.
*
* This function accepts **identity only**. There is no parameter for a filename, a relative path,
* or any other artifact selector, so no caller — however careless — can use it to reach a second
* file in a module package. Both components are percent-encoded, so even a value that never came
* from a validated manifest can only ever produce one extra path segment's worth of text, never a
* new segment, a traversal, a query, or a fragment. A value that needed encoding will simply fail
* to resolve against the publication registry.
*/
export function moduleUiArtifactUrl(identity: ModuleUiIdentity): string {
return `${MODULE_UI_ASSET_PREFIX}/${encodeURIComponent(identity.moduleId)}/${encodeURIComponent(
identity.version,
)}`;
}
/**
* One 404 for every "there is nothing to publish here" case: the module is not installed, the
* installed module declares no `entrypoints.ui`, or the requested version is not the installed one.
*
* They are deliberately indistinguishable. The route requires no authentication (see
* `createModuleUiRoutes`), so a distinguishable response would let an anonymous caller enumerate
* which modules an installation runs and at which versions. Collapsing them means a caller must
* already know both the exact module id and the exact version to learn anything at all.
*/
function notPublished(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "MODULE_UI_NOT_PUBLISHED" }, 404);
}
/**
* The artifact was validated at discovery and is not retrievable now — deleted, replaced by a
* directory, or unreadable. That is a server-side condition rather than a bad request, and often a
* transient one (an operator mid-upgrade), so it reports like every other "could not be determined"
* result in this application: a sanitized 503 with a fixed code. Never carries the artifact path,
* the module directory, the `errno`, or the caught error.
*/
function artifactUnavailable(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" }, 503);
}
/**
* RFC 9110 §13.1.2 evaluation of `If-None-Match`: `*` matches any existing representation, and
* every listed entity-tag is compared with the **weak** comparison function, so a client echoing
* `W/"…"` still matches the strong tag this route issued. Anything unparsable simply fails to
* match, which costs a full 200 and can never produce a wrongly-fresh 304.
*/
function isNotModified(headerValue: string | undefined, etag: string): boolean {
if (headerValue === undefined) {
return false;
}
if (headerValue.trim() === "*") {
return true;
}
return headerValue.split(",").some((candidate) => {
const value = candidate.trim();
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
});
}
export interface CreateModuleUiRoutesOptions {
/** The platform's publication policy, built by Core from the discovered, started module
* inventory (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route derives
* every byte it serves from this registry and nothing else. */
readonly registry: ModuleUiPublicationRegistry;
}
/**
* Publishes installed modules' validated `entrypoints.ui` artifacts at a platform-owned browser
* URL, closing the one gap between Core's server-side artifact path and the generic loader's
* browser-side URL (ADR 0024, ADR 0025).
*
* ## The request never touches the filesystem
*
* `:moduleId` and `:version` are **lookup keys, not path components**. They are handed to
* `registry.resolve` and, on an exact match, the already-canonical `artifactPath` that Core's
* discovery resolved and validated is read directly. No request value is ever joined, resolved,
* normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so
* traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file
* are all unreachable by construction rather than by filtering. An installed module directory never
* becomes a static file root, and only the single artifact its manifest declares is reachable.
*
* ## No authentication, deliberately
*
* Retrieving the artifact confers no authority. The bundle is compiled first-party UI code an
* operator reviewed and installed; it carries no credential, and the host — not the artifact —
* supplies the principal and permissions at mount time. Every module API stays behind
* `requirePermission` against the composed platform registry, and nothing about fetching this file
* changes a single authorization decision.
*
* Requiring a session would also break the thing it is meant to protect: a browser's dynamic
* `import()` uses `credentials: "same-origin"`, so a cross-origin import (the split-origin
* deployments `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` exists to support) sends no cookie and would fail
* — a failure that tests green on a server and only shows up in a real browser, exactly the class of
* error ADR 0024 exists to prevent. Working around it would mean fetching the bytes and importing a
* `blob:` URL, which costs a host its `script-src` CSP.
*
* The honest cost is stated in ADR 0025: an anonymous caller who already knows a module id **and**
* its exact installed version can download that module's UI bundle. That is information disclosure,
* not authority, and it is the reason the failure responses above are indistinguishable.
*
* ## The artifact is never executed
*
* This route reads bytes and writes bytes. It does not `import()`, evaluate, transform, minify,
* re-bundle, or rewrite the artifact, and a module UI whose top level throws is served exactly like
* any other. Core's rule that discovery never executes `entrypoints.ui` (ADR 0021) is unchanged, and
* publication does not become a second, quieter way to run browser code on the server.
*/
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
const routes = new Hono();
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version`, async (c) => {
const published = options.registry.resolve(c.req.param("moduleId"), c.req.param("version"));
if (!published) {
return notPublished(c);
}
// Discovery validated this exact path earlier; the file may still have changed or vanished
// since. The response is produced from whatever a single read of that one path returns right
// now — there is deliberately no fallback path, no re-resolution, and no retry against a
// reconstructed location, so a vanished artifact fails rather than quietly resolving to
// something else.
let artifact: Buffer;
try {
artifact = await readFile(published.artifactPath);
} catch {
return artifactUnavailable(c);
}
// Hashing the bytes actually being served — rather than trusting an mtime or size — means the
// validator can never describe a different revision than the body does.
const etag = `"${createHash("sha256").update(artifact).digest("base64url")}"`;
if (isNotModified(c.req.header("if-none-match"), etag)) {
return new Response(null, {
headers: { "Cache-Control": MODULE_UI_CACHE_CONTROL, ETag: etag },
status: 304,
});
}
return new Response(artifact, {
headers: {
"Cache-Control": MODULE_UI_CACHE_CONTROL,
"Content-Type": MODULE_UI_CONTENT_TYPE,
ETag: etag,
// The declared type is the only type: a browser must not be allowed to sniff a module
// artifact into some other interpretation, and `import()` requires the JavaScript type
// anyway.
"X-Content-Type-Options": "nosniff",
},
status: 200,
});
});
return routes;
}
+6
View File
@@ -8,6 +8,7 @@ import {
} from "@ekklesia/core/authorization";
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
type DiscoveredModule,
type StartedModuleRuntime,
startDiscoveredModuleRuntime,
@@ -407,6 +408,11 @@ export async function startApi(
},
moduleRoutes: moduleRuntime.routes,
moduleServices: services,
// Built from the exact same discovered inventory, and only once the module runtime has
// started — module startup is all-or-nothing, so every module in this list is a running
// module, and a module that failed to load, register, or start never reaches this line at
// all. Modules declaring no `entrypoints.ui` are simply absent from the registry.
moduleUi: createModuleUiPublicationRegistry(inventory.modules),
runtime,
trustedOrigins: config.identity.trustedOrigins,
});
+146
View File
@@ -21,6 +21,11 @@ import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it, vi } from "vitest";
import type { ClientAddressConfigInput } from "../src/client-address.js";
import { createApiRuntimeConfig } from "../src/config.js";
import {
MODULE_UI_CACHE_CONTROL,
MODULE_UI_CONTENT_TYPE,
moduleUiArtifactUrl,
} from "../src/module-ui-routes.js";
import { startApi, type NodeServerTimeouts, type RunningApi } from "../src/server.js";
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
@@ -400,6 +405,52 @@ async function createFixtureModule(
]);
}
/** A module that ships a real, loadable UI artifact plus the neighbouring files a naive static
* mount would have exposed: a source map, a sibling bundle chunk, and a package-root secret. */
async function createUiModule(
modulesDirectory: string,
id: string,
version: string,
artifact: string,
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${id}`,
type: "module",
version,
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id,
manifestVersion: 4,
name: `${id} module`,
version,
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)} };`,
),
writeFile(path.join(moduleDirectory, "dist/ui/index.js"), artifact),
writeFile(
path.join(moduleDirectory, "dist/ui/index.js.map"),
'{"version":3,"sourcesContent":["map-secret"]}',
),
writeFile(path.join(moduleDirectory, "dist/ui/chunk.js"), "export const chunkSecret = 1;\n"),
writeFile(path.join(moduleDirectory, ".env"), "EKKLESIA_IDENTITY_SECRET=env-secret\n"),
]);
}
afterAll(async () => {
for (const databaseName of [...trackedDatabases]) {
await dropDatabase(databaseName).catch(() => undefined);
@@ -2323,6 +2374,101 @@ describe.sequential("live API — installed module permission end-to-end", () =>
});
});
/**
* The publication route over a real socket, against a real started API: real Node HTTP, real
* headers, real bytes. The unit suite (`module-ui-routes.test.ts`) covers the security matrix in
* depth; this proves the same route behaves identically once it is behind the actual server rather
* than `app.request`.
*/
describe.sequential("live API — published module UI artifact", () => {
const uiArtifact = `export default {
id: "signage",
version: "2.0.1",
mount(container, context) {
container.textContent = context.moduleId;
return () => {
container.textContent = "";
};
},
};
`;
it("serves exactly the declared artifact, and nothing else in the module package", async () => {
const modulesDirectory = await createFixtureModulesDirectory();
await createUiModule(modulesDirectory, "signage", "2.0.1", uiArtifact);
const database = await createDisposableDatabase("module_ui");
await applyCoreMigrations(database.url);
const running = await startMigratedApi(database.url, modulesDirectory);
try {
const origin = apiOrigin(running);
const url = `${origin}${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}`;
const response = await fetch(url);
const body = await response.text();
expect(response.status).toBe(200);
expect(body).toBe(uiArtifact);
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
const etag = response.headers.get("etag");
expect(etag).toMatch(/^"[A-Za-z0-9_-]+"$/u);
// Revalidation over the wire, with the validator the server itself just issued.
const revalidated = await fetch(url, { headers: { "if-none-match": etag as string } });
expect(revalidated.status).toBe(304);
expect(await revalidated.text()).toBe("");
// The exact-version rule, and the neighbouring files, over the same real transport.
const unreachable = [
moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.0" }),
moduleUiArtifactUrl({ moduleId: "announcements", version: "2.0.1" }),
`${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}/index.js.map`,
`${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}/chunk.js`,
`${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}/.env`,
`${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}.map`,
];
for (const candidate of unreachable) {
const refused = await fetch(`${origin}${candidate}`);
const refusedBody = await refused.text();
expect(refused.status, candidate).toBe(404);
expect(refusedBody, candidate).not.toContain("secret");
expect(refusedBody, candidate).not.toContain("mount");
}
} finally {
await running.shutdown().catch(() => undefined);
await dropDatabase(database.name);
}
});
it("answers a trusted browser origin with CORS, and an untrusted one without", async () => {
const modulesDirectory = await createFixtureModulesDirectory();
await createUiModule(modulesDirectory, "signage", "2.0.1", uiArtifact);
const database = await createDisposableDatabase("module_ui_cors");
await applyCoreMigrations(database.url);
const running = await startMigratedApi(database.url, modulesDirectory);
try {
const origin = apiOrigin(running);
const url = `${origin}${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}`;
// A cross-origin browser `import()` of this URL needs exactly this header, which is why the
// route lives inside the application's one CORS-governed `/api/v1/*` space.
const trusted = await fetch(url, { headers: { origin: dashboardOrigin } });
expect(trusted.status).toBe(200);
expect(trusted.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
expect(trusted.headers.get("vary")?.toLowerCase()).toContain("origin");
const untrusted = await fetch(url, { headers: { origin: untrustedOrigin } });
// The bytes are not confidential (ADR 0025), but the platform still never reflects an origin
// it does not trust, so a page on that origin cannot read the response.
expect(untrusted.headers.get("access-control-allow-origin")).toBeNull();
} finally {
await running.shutdown().catch(() => undefined);
await dropDatabase(database.name);
}
});
});
describe.sequential("live API — public signup is disabled by default", () => {
it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => {
const database = await createDisposableDatabase("signup_disabled");
+32
View File
@@ -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(),
@@ -17,6 +17,7 @@ import {
type PermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { createModuleUiPublicationRegistry } from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import {
@@ -106,6 +107,7 @@ function buildModuleApp(options: {
authorization: { evaluator, registry: options.registry, store: fakeStore() },
moduleRoutes: options.routes,
moduleServices: services,
moduleUi: createModuleUiPublicationRegistry([]),
runtime,
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
});
+716
View File
@@ -0,0 +1,716 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
type ModuleDatabase,
type ModuleRuntimeServices,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
CORE_PERMISSION_REGISTRY,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
} from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import {
MODULE_UI_ASSET_PREFIX,
MODULE_UI_CACHE_CONTROL,
MODULE_UI_CONTENT_TYPE,
moduleUiArtifactUrl,
} from "../src/module-ui-routes.js";
const trustedOrigin = "https://dashboard.example.test";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
async function createModulesDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-ui-publication-"));
temporaryDirectories.push(directory);
return directory;
}
/** A real, loadable module UI artifact: a valid `ModuleUiDefinition` default export, written as
* plain ESM. `@ekklesia/module-ui-build` already proves the esbuild pipeline emits this shape
* (`packages/module-ui-host/tests/real-artifact.test.ts`); what these tests need is a byte
* sequence whose exact publication and loader-compatibility can be asserted. */
function artifactSource(id: string, version: string): string {
return `export default {
id: ${JSON.stringify(id)},
version: ${JSON.stringify(version)},
mount(container, context) {
container.textContent = context.moduleId + ":mounted";
return () => {
container.textContent = "";
};
},
};
`;
}
interface ModuleFixture {
readonly id: string;
readonly version?: string;
/** Written to `dist/ui/index.js` and declared as `entrypoints.ui`. Omit for a backend-only
* module, whose manifest then declares no UI entrypoint at all. */
readonly ui?: string;
/** Extra package-relative files written alongside the artifact — a source map, a sibling bundle
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
* files that genuinely exist on disk rather than against hypothetical ones. */
readonly extraFiles?: Readonly<Record<string, string>>;
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const version = fixture.version ?? "0.1.0";
const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${fixture.id}`,
type: "module",
version,
}),
);
await writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: {
server: "./dist/server/index.js",
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
},
id: fixture.id,
manifestVersion: 4,
name: `${fixture.id} module`,
version,
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)} };`,
);
if (fixture.ui !== undefined) {
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
}
for (const [relativePath, contents] of Object.entries(fixture.extraFiles ?? {})) {
const target = path.join(moduleDirectory, relativePath);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, contents);
}
}
interface Installation {
readonly app: Hono;
readonly modules: readonly DiscoveredModule[];
readonly modulesDirectory: string;
}
/**
* A real installation: fixtures on disk, Core's real `discoverModules` (manifest validation,
* compiled-artifact validation, package containment, symlink canonicalization), Core's real
* publication registry, and the real `createApiApp`. Nothing about the publication policy or the
* artifact path is hand-built, so these tests exercise the actual security boundary rather than a
* reconstruction of it.
*/
async function install(fixtures: readonly ModuleFixture[]): Promise<Installation> {
const modulesDirectory = await createModulesDirectory();
for (const fixture of fixtures) {
await createModule(modulesDirectory, fixture);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const database = Object.freeze({}) as unknown as ModuleDatabase;
const services: ModuleRuntimeServices = Object.freeze({ database });
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
// Anonymous, and every authorization decision denied: whatever this route does, it does it
// without a session and without a single permission.
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
};
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: false }),
};
const store = {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore;
const app = createApiApp({
authorization: { evaluator, registry: CORE_PERMISSION_REGISTRY, store },
moduleRoutes: Object.freeze([]),
moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules),
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, modules, modulesDirectory };
}
describe("module UI artifact publication — happy path", () => {
it("serves the declared artifact byte-for-byte at the canonical URL", async () => {
const source = artifactSource("announcements", "1.4.2");
const { app } = await install([{ id: "announcements", ui: source, version: "1.4.2" }]);
const response = await app.request(
moduleUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
);
expect(response.status).toBe(200);
expect(await response.text()).toBe(source);
});
it("serves it as ES module JavaScript, with sniffing disabled", async () => {
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
]);
const response = await app.request(
moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
);
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("revalidates rather than promising immutability the platform cannot enforce", async () => {
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
]);
const response = await app.request(
moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
);
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
// A reused version can be rebuilt with different bytes, so neither of these may ever appear.
expect(response.headers.get("cache-control")).not.toContain("immutable");
expect(response.headers.get("cache-control")).not.toContain("max-age");
// A strong validator (no `W/` prefix), so the revalidation above is actually decidable.
expect(response.headers.get("etag")).toMatch(/^"[A-Za-z0-9_-]+"$/u);
});
it("varies on Origin, so a shared cache cannot reuse one origin's CORS response for another", async () => {
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
]);
const response = await app.request(
moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
{ headers: { origin: trustedOrigin } },
);
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
expect(response.headers.get("vary")?.toLowerCase()).toContain("origin");
});
it("answers a matching conditional request with 304 and no body", async () => {
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
]);
const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
const etag = (await app.request(url)).headers.get("etag") as string;
const revalidated = await app.request(url, { headers: { "if-none-match": etag } });
expect(revalidated.status).toBe(304);
expect(await revalidated.text()).toBe("");
expect(revalidated.headers.get("etag")).toBe(etag);
expect(revalidated.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
});
it("applies RFC 9110 weak comparison and the wildcard, and refuses a stale validator", async () => {
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
]);
const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
const etag = (await app.request(url)).headers.get("etag") as string;
const cases: ReadonlyArray<readonly [string, number]> = [
[`W/${etag}`, 304],
["*", 304],
[`"someone-elses-tag", ${etag}`, 304],
['"stale-tag"', 200],
["not a valid header at all", 200],
];
for (const [header, expected] of cases) {
const response = await app.request(url, { headers: { "if-none-match": header } });
expect(response.status, header).toBe(expected);
}
});
it("recomputes the validator from the bytes actually on disk after a same-version rebuild", async () => {
const { app, modulesDirectory } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
]);
const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
const first = await app.request(url);
const firstEtag = first.headers.get("etag");
// Exactly the develop-in-place case an `immutable` policy would have made unrecoverable.
const rebuilt = `${artifactSource("announcements", "0.1.0")}// rebuilt\n`;
await writeFile(path.join(modulesDirectory, "announcements/dist/ui/index.js"), rebuilt);
const second = await app.request(url, { headers: { "if-none-match": firstEtag as string } });
expect(second.status).toBe(200);
expect(await second.text()).toBe(rebuilt);
expect(second.headers.get("etag")).not.toBe(firstEtag);
});
it("serves the artifact without executing it, even when its top level throws", async () => {
const exploding = 'throw new Error("module UI must never be evaluated on the server");\n';
const { app } = await install([{ id: "announcements", ui: exploding }]);
const response = await app.request(
moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
);
expect(response.status).toBe(200);
expect(await response.text()).toBe(exploding);
});
it("requires no authentication, by deliberate decision", async () => {
// The installation's identity runtime resolves an anonymous principal and its evaluator denies
// every permission. If this route ever grows an authentication or authorization requirement,
// this test is the one that must be consciously changed.
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
]);
const response = await app.request(
moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
);
expect(response.status).toBe(200);
});
it("still refuses a protected platform API to the same anonymous caller", async () => {
// Publication grants no authority: the very same caller that just downloaded the bundle is
// still denied every permission-protected route.
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
]);
expect(
(await app.request(moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" })))
.status,
).toBe(200);
const grants = await app.request("/api/v1/grants?userId=someone");
expect(grants.status).toBe(403);
});
});
describe("module UI artifact publication — module identity", () => {
async function expectNotPublished(app: Hono, url: string): Promise<void> {
const response = await app.request(url);
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" });
}
it("refuses a module that is not installed", async () => {
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
]);
await expectNotPublished(app, moduleUiArtifactUrl({ moduleId: "calendar", version: "0.1.0" }));
});
it("refuses an installed module that declares no UI entrypoint", async () => {
const { app } = await install([{ id: "calendar" }]);
await expectNotPublished(app, moduleUiArtifactUrl({ moduleId: "calendar", version: "0.1.0" }));
});
it("refuses a version other than the installed one rather than serving what exists", async () => {
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "1.4.2"), version: "1.4.2" },
]);
for (const version of ["1.4.1", "1.4.3", "1.4", "latest", "^1.4.2"]) {
await expectNotPublished(app, moduleUiArtifactUrl({ moduleId: "announcements", version }));
}
expect(
(await app.request(moduleUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" })))
.status,
).toBe(200);
});
it("makes unknown module, no UI, and wrong version indistinguishable", async () => {
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "1.4.2"), version: "1.4.2" },
{ id: "calendar" },
]);
const responses = await Promise.all(
[
moduleUiArtifactUrl({ moduleId: "signage", version: "1.4.2" }),
moduleUiArtifactUrl({ moduleId: "calendar", version: "0.1.0" }),
moduleUiArtifactUrl({ moduleId: "announcements", version: "9.9.9" }),
].map(async (url) => {
const response = await app.request(url);
return { body: await response.text(), status: response.status };
}),
);
// An anonymous caller must not be able to use the distinction to enumerate an installation.
expect(new Set(responses.map((entry) => `${entry.status}:${entry.body}`)).size).toBe(1);
});
it("never serves one module's artifact under another module's identity", async () => {
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
{ id: "signage", ui: artifactSource("signage", "0.1.0") },
]);
const announcements = await app.request(
moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
);
const signage = await app.request(
moduleUiArtifactUrl({ moduleId: "signage", version: "0.1.0" }),
);
expect(await announcements.text()).toContain('"announcements"');
expect(await signage.text()).toContain('"signage"');
});
});
describe("module UI artifact publication — filesystem exposure", () => {
const sourceMap =
'{"version":3,"sources":["../../src/ui/index.tsx"],"sourcesContent":["secret"]}';
/** One installation carrying every kind of file a wildcard route would have leaked. */
async function installWithNeighbours(): Promise<Installation> {
return install([
{
extraFiles: {
".env": "EKKLESIA_IDENTITY_SECRET=super-secret\n",
"dist/server/index.js.map": sourceMap,
"dist/ui/index.js.map": sourceMap,
"dist/ui/internal-chunk.js": "export const internal = 1;\n",
},
id: "announcements",
ui: artifactSource("announcements", "0.1.0"),
},
]);
}
it("exposes no path shape beyond the two identity segments", async () => {
const { app } = await installWithNeighbours();
const base = `${MODULE_UI_ASSET_PREFIX}/announcements`;
const unreachable = [
// Source maps: real files, sitting right next to the published artifact.
`${base}/0.1.0.map`,
`${base}/0.1.0/index.js.map`,
`${base}/0.1.0/dist/ui/index.js.map`,
// Sibling bundle output and package-root files.
`${base}/0.1.0/internal-chunk.js`,
`${base}/0.1.0/package.json`,
`${base}/0.1.0/ekklesia.module.json`,
`${base}/0.1.0/.env`,
// A trailing segment of any kind, including an empty one.
`${base}/0.1.0/`,
`${base}/0.1.0/anything`,
// Fewer segments than the route declares.
base,
MODULE_UI_ASSET_PREFIX,
];
for (const url of unreachable) {
const response = await app.request(url);
expect(response.status, url).toBe(404);
// Nothing leaks: not the artifact, not a neighbour, not a path.
const body = await response.text();
expect(body, url).not.toContain("secret");
expect(body, url).not.toContain("internal");
expect(body, url).not.toContain("mount");
}
});
it("treats a traversal attempt as an ordinary failed lookup", async () => {
const { app } = await installWithNeighbours();
const attempts = [
`${MODULE_UI_ASSET_PREFIX}/..%2F..%2F..%2Fetc%2Fpasswd/0.1.0`,
`${MODULE_UI_ASSET_PREFIX}/announcements/..%2F..%2Fpackage.json`,
`${MODULE_UI_ASSET_PREFIX}/announcements%2Fdist%2Fui%2Findex.js/0.1.0`,
`${MODULE_UI_ASSET_PREFIX}/${encodeURIComponent("../announcements")}/0.1.0`,
`${MODULE_UI_ASSET_PREFIX}/announcements/${encodeURIComponent("0.1.0/../../.env")}`,
`${MODULE_UI_ASSET_PREFIX}/announcements/${encodeURIComponent("0.1.0 .map")}`,
];
for (const url of attempts) {
const response = await app.request(url);
expect(response.status, url).toBe(404);
expect(await response.text(), url).not.toContain("secret");
}
});
it("keeps an absolute filesystem path from ever becoming a request parameter that works", async () => {
const { app, modules } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
]);
const artifactPath = (modules[0] as DiscoveredModule).entrypoints.ui as string;
const response = await app.request(
`${MODULE_UI_ASSET_PREFIX}/${encodeURIComponent(artifactPath)}/0.1.0`,
);
expect(response.status).toBe(404);
});
});
describe("module UI artifact publication — request surface", () => {
it("is read-only: no other method is routed at all", async () => {
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
]);
const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
const response = await app.request(url, { method });
expect(response.status, method).toBe(404);
expect(await response.json(), method).toEqual({ code: "NOT_FOUND" });
}
});
it("answers HEAD with the full header contract and no body", async () => {
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
]);
const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
const response = await app.request(url, { method: "HEAD" });
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
expect(response.headers.get("etag")).toBe((await app.request(url)).headers.get("etag"));
expect(await response.text()).toBe("");
});
it("fails closed on malformed percent-encoding instead of throwing", async () => {
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
]);
for (const url of [
`${MODULE_UI_ASSET_PREFIX}/%zz/0.1.0`,
`${MODULE_UI_ASSET_PREFIX}/announcements/%E0%A4%A`,
`${MODULE_UI_ASSET_PREFIX}/a%00b/0.1.0`,
]) {
const response = await app.request(url);
// An ordinary refused lookup, never a 500 that would reveal a decoding failure.
expect(response.status, url).toBe(404);
expect(await response.json(), url).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" });
}
});
});
describe("module UI artifact publication — artifact changed after discovery", () => {
it("reports a sanitized 503 when the validated artifact has been deleted", async () => {
const { app, modulesDirectory } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
]);
const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
expect((await app.request(url)).status).toBe(200);
await rm(path.join(modulesDirectory, "announcements/dist/ui/index.js"));
const response = await app.request(url);
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" });
});
it("leaks no filesystem detail when the artifact is unreadable", async () => {
const { app, modulesDirectory } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
]);
const artifact = path.join(modulesDirectory, "announcements/dist/ui/index.js");
await rm(artifact);
// Replaced by a directory: a different `errno` (EISDIR) on the same validated path.
await mkdir(artifact);
const response = await app.request(
moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
);
const body = await response.text();
expect(response.status).toBe(503);
expect(body).not.toContain(modulesDirectory);
expect(body).not.toContain(tmpdir());
expect(body).not.toContain("dist");
expect(body).not.toContain("EISDIR");
expect(response.headers.get("cache-control")).toBe("no-store");
});
it("never falls back to a reconstructed path when the exact artifact is gone", async () => {
const { app, modulesDirectory } = await install([
{ id: "announcements", ui: artifactSource("announcements", "0.1.0") },
]);
const moduleDirectory = path.join(modulesDirectory, "announcements");
await rm(path.join(moduleDirectory, "dist/ui/index.js"));
// Plausible neighbours a path-guessing implementation might land on instead.
await writeFile(path.join(moduleDirectory, "dist/ui/index.mjs"), "export default {};\n");
await writeFile(path.join(moduleDirectory, "dist/index.js"), "export default {};\n");
const response = await app.request(
moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
);
expect(response.status).toBe(503);
});
});
describe("module UI artifact URL generation", () => {
it("agrees with the route it is generated for", async () => {
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "1.4.2"), version: "1.4.2" },
]);
const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" });
expect(url).toBe(`${MODULE_UI_ASSET_PREFIX}/announcements/1.4.2`);
expect((await app.request(url)).status).toBe(200);
});
it("is root-relative, so a host resolves it against whichever API origin it is configured for", () => {
const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" });
expect(url.startsWith("/")).toBe(true);
expect(new URL(url, "https://ekklesia.example.test").href).toBe(
`https://ekklesia.example.test${MODULE_UI_ASSET_PREFIX}/announcements/1.4.2`,
);
// A split-origin host resolves the identical value against its own API base.
expect(new URL(url, "http://127.0.0.1:3000").href).toBe(
`http://127.0.0.1:3000${MODULE_UI_ASSET_PREFIX}/announcements/1.4.2`,
);
});
it("encodes hostile input instead of letting it restructure the URL", () => {
const hostile = [
"../../etc/passwd",
"announcements/dist/ui/index.js",
"announcements?x=1",
"announcements#fragment",
"announcements ",
"a b",
];
for (const value of hostile) {
// Still exactly the prefix's own segments plus one for the module and one for the version.
const expectedSegments = MODULE_UI_ASSET_PREFIX.split("/").length + 2;
const moduleUrl = moduleUiArtifactUrl({ moduleId: value, version: "0.1.0" });
expect(moduleUrl.split("/"), value).toHaveLength(expectedSegments);
expect(moduleUrl.includes("?"), value).toBe(false);
expect(moduleUrl.includes("#"), value).toBe(false);
const versionUrl = moduleUiArtifactUrl({ moduleId: "announcements", version: value });
expect(versionUrl.split("/"), value).toHaveLength(expectedSegments);
expect(versionUrl.includes("?"), value).toBe(false);
expect(versionUrl.includes("#"), value).toBe(false);
}
});
});
describe("module UI artifact publication — the generic loader consumes what is published", () => {
/**
* Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and
* honestly: the tests above prove the route publishes the exact artifact bytes with ES module
* JavaScript semantics over real HTTP routing, and this one takes **those very bytes, as returned
* by the route**, and puts them through the real `@ekklesia/module-ui-host` loader as a `data:`
* URL — a genuine module URL, the same technique ADR 0024's own loader tests use. Nothing here
* reads the artifact from the filesystem.
*/
async function publishedModuleUrl(app: Hono, moduleId: string, version: string): Promise<string> {
const response = await app.request(moduleUiArtifactUrl({ moduleId, version }));
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
const bytes = Buffer.from(await response.arrayBuffer());
return `data:text/javascript;base64,${bytes.toString("base64")}`;
}
/**
* The loader forwards the container untouched to the module's own `mount` and never inspects it,
* so a plain sink is enough and no DOM implementation is required. The container's type is read
* back off `loadModuleUi` itself rather than written as `Element`, because `apps/api` compiles
* against Node libs only — naming a DOM type here would be the same browser/server confusion this
* route exists to keep apart.
*/
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
function containerSink(): { textContent: string } {
return { textContent: "" };
}
function hostContext() {
return {
api: { request: async () => new Response(null, { status: 200 }) },
moduleId: "announcements",
permissions: { has: () => false },
principal: { kind: "anonymous" as const },
};
}
it("mounts and unmounts an artifact obtained from the publication route", async () => {
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "1.4.2"), version: "1.4.2" },
]);
const url = await publishedModuleUrl(app, "announcements", "1.4.2");
const container = containerSink();
const mounted = await loadModuleUi({
container: container as unknown as LoaderContainer,
context: hostContext(),
source: { moduleId: "announcements", url, version: "1.4.2" },
});
expect(container.textContent).toBe("announcements:mounted");
await mounted.unmount();
expect(container.textContent).toBe("");
});
it("still rejects the published artifact when the host expects a different identity", async () => {
const { app } = await install([
{ id: "announcements", ui: artifactSource("announcements", "1.4.2"), version: "1.4.2" },
]);
const url = await publishedModuleUrl(app, "announcements", "1.4.2");
await expect(
loadModuleUi({
container: containerSink() as unknown as LoaderContainer,
context: hostContext(),
source: { moduleId: "announcements", url, version: "9.9.9" },
}),
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
});
});
+1
View File
@@ -4,3 +4,4 @@ export * from "./migration-discovery.js";
export * from "./permissions.js";
export * from "./runtime.js";
export * from "./schema-discovery.js";
export * from "./ui-publication.js";
+101
View File
@@ -0,0 +1,101 @@
import type { DiscoveredModule } from "./discovery.js";
/**
* One installed module whose UI artifact the platform is willing to publish at a browser URL.
*
* `artifactPath` is the canonical absolute path discovery already resolved and validated for
* `entrypoints.ui` — realpath-resolved, proven to be a compiled `.js`/`.mjs` file, and proven to
* stay inside the module package both before and after symlink resolution
* (`resolveEntrypoints`, `./discovery.js`). A publisher serves **exactly this path** and never
* composes a path of its own from request input; that is the whole reason this record exists
* rather than a module id plus a path-building convention.
*/
export interface PublishableModuleUi {
readonly moduleId: string;
readonly version: string;
readonly artifactPath: string;
}
/**
* The platform's answer to "may this module's UI artifact be served, and if so, which exact file?"
*
* Lookup is by module **identity**, never by path. There is deliberately no method that accepts a
* filename, a relative path, a glob, or anything else that could turn an installed module
* directory into a static file root.
*/
export interface ModuleUiPublicationRegistry {
/** Every publishable module, in the discovery order it was built from. Stable and frozen. */
list(): readonly PublishableModuleUi[];
/**
* The publishable record for an exact module id **and** exact installed version, or `undefined`.
*
* Takes `unknown` for the same reason `PermissionRegistry.get` does: the caller is usually
* handing over decoded request input, and a lookup that fails closed on anything malformed is
* safer than one whose type signature quietly assumes validation already happened. A module that
* is not installed, one that declares no `entrypoints.ui`, and one installed at a different
* version are all indistinguishable here — each returns `undefined`.
*/
resolve(moduleId: unknown, version: unknown): PublishableModuleUi | undefined;
}
/**
* Builds the platform's module UI publication policy from an already-discovered module list.
*
* The policy is deliberately small, because the repository's module state is deliberately small:
* Ekklesia has one accepted module set — the modules `discoverModules` validated at startup — and
* no installer, no enable/disable state, and no trust store. `ModuleLifecycleStage`
* (`@ekklesia/contracts`) names stages as a vocabulary but no runtime state machine implements
* them. So "publishable" means exactly:
*
* 1. the module is in the discovered, fully validated set the platform accepted at startup, and
* 2. its manifest declares `entrypoints.ui`, which discovery resolved to a real compiled file
* inside the module package.
*
* A module directory sitting on disk satisfies neither on its own — it has to have survived
* discovery — which is the property `docs/development/security.md` requires: *the existence of an
* artifact on disk must not be what makes it servable*.
*
* Pure and synchronous: no filesystem, environment, database, or network access, and it never
* imports, evaluates, or otherwise executes a module's UI artifact. The caller supplies a complete
* discovered list; production composition (`apps/api/src/server.ts`) builds this only after the
* module runtime has started, so the published set is also the running set — module startup is
* all-or-nothing (`startDiscoveredModuleRuntime`, `./runtime.js`), so a module that failed to load,
* register, or start takes the whole API startup down with it rather than lingering as a
* publishable-but-not-running module.
*/
export function createModuleUiPublicationRegistry(
modules: readonly DiscoveredModule[],
): ModuleUiPublicationRegistry {
const byModuleId = new Map<string, PublishableModuleUi>();
for (const module of modules) {
const artifactPath = module.entrypoints.ui;
if (artifactPath === undefined) {
continue;
}
byModuleId.set(
module.manifest.id,
Object.freeze({
artifactPath,
moduleId: module.manifest.id,
version: module.manifest.version,
}),
);
}
const published = Object.freeze([...byModuleId.values()]);
return Object.freeze({
list: (): readonly PublishableModuleUi[] => published,
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
if (typeof moduleId !== "string" || typeof version !== "string") {
return undefined;
}
const candidate = byModuleId.get(moduleId);
// Exact version equality, never a range, a "latest" fallback, or a nearest match. A request
// naming a version the platform does not have installed resolves to nothing at all rather
// than to a different artifact that happens to exist.
return candidate?.version === version ? candidate : undefined;
},
});
}
+186
View File
@@ -0,0 +1,186 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
} from "../src/modules/index.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
async function createModulesDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-ui-publication-"));
temporaryDirectories.push(directory);
return directory;
}
interface ModuleFixture {
readonly id: string;
readonly version?: string;
/** Omitted entirely for a backend-only module, so the manifest declares no `entrypoints.ui`. */
readonly ui?: string;
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const version = fixture.version ?? "0.1.0";
const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${fixture.id}`,
type: "module",
version,
}),
);
await writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: {
server: "./dist/server/index.js",
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
},
id: fixture.id,
manifestVersion: 4,
name: `${fixture.id} module`,
version,
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)} };`,
);
if (fixture.ui !== undefined) {
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
}
}
/** Real discovery, never a hand-built `DiscoveredModule`: the whole point of the registry is that
* it consumes only records that already passed manifest validation, compiled-artifact validation,
* and package-containment/symlink resolution. */
async function discover(fixtures: readonly ModuleFixture[]): Promise<readonly DiscoveredModule[]> {
const modulesDirectory = await createModulesDirectory();
for (const fixture of fixtures) {
await createModule(modulesDirectory, fixture);
}
return discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
}
describe("module UI publication registry", () => {
it("publishes a discovered module that declares entrypoints.ui", async () => {
const modules = await discover([{ id: "announcements", ui: "export default {};" }]);
const registry = createModuleUiPublicationRegistry(modules);
const published = registry.resolve("announcements", "0.1.0");
expect(published).toMatchObject({ moduleId: "announcements", version: "0.1.0" });
expect(registry.list()).toHaveLength(1);
});
it("exposes exactly the canonical artifact path discovery already validated", async () => {
const modules = await discover([{ id: "announcements", ui: "export default {};" }]);
const discovered = modules[0] as DiscoveredModule;
const registry = createModuleUiPublicationRegistry(modules);
// Identical to `entrypoints.ui` — not recomposed from the module directory plus a convention,
// which is the failure mode a publisher must never reintroduce.
expect(registry.resolve("announcements", "0.1.0")?.artifactPath).toBe(
discovered.entrypoints.ui,
);
expect(path.isAbsolute(discovered.entrypoints.ui as string)).toBe(true);
});
it("omits a discovered module that declares no UI entrypoint", async () => {
const modules = await discover([{ id: "calendar" }]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.list()).toEqual([]);
expect(registry.resolve("calendar", "0.1.0")).toBeUndefined();
});
it("publishes only the modules that declare UI, from a mixed installation", async () => {
const modules = await discover([
{ id: "announcements", ui: "export default {};" },
{ id: "calendar" },
{ id: "signage", ui: "export default {};" },
]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.list().map((entry) => entry.moduleId)).toEqual(["announcements", "signage"]);
});
it("never resolves a module that is not part of the discovered set", async () => {
const modules = await discover([{ id: "announcements", ui: "export default {};" }]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.resolve("calendar", "0.1.0")).toBeUndefined();
expect(registry.resolve("", "0.1.0")).toBeUndefined();
});
it("refuses a version other than the exact installed one", async () => {
const modules = await discover([
{ id: "announcements", ui: "export default {};", version: "1.4.2" },
]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.resolve("announcements", "1.4.2")).toBeDefined();
for (const version of ["1.4.1", "1.4.3", "1.4", "^1.4.2", "latest", "1.4.2 ", ""]) {
expect(registry.resolve("announcements", version)).toBeUndefined();
}
});
it("fails closed on non-string lookup input rather than trusting its caller", async () => {
const modules = await discover([{ id: "announcements", ui: "export default {};" }]);
const registry = createModuleUiPublicationRegistry(modules);
for (const value of [undefined, null, 0, {}, [], Symbol("announcements")]) {
expect(registry.resolve(value, "0.1.0")).toBeUndefined();
expect(registry.resolve("announcements", value)).toBeUndefined();
}
});
it("is an empty, usable registry when nothing is installed", () => {
const registry = createModuleUiPublicationRegistry([]);
expect(registry.list()).toEqual([]);
expect(registry.resolve("announcements", "0.1.0")).toBeUndefined();
});
it("publishes an immutable snapshot that no caller can extend", async () => {
const modules = await discover([{ id: "announcements", ui: "export default {};" }]);
const registry = createModuleUiPublicationRegistry(modules);
expect(Object.isFrozen(registry)).toBe(true);
expect(Object.isFrozen(registry.list())).toBe(true);
expect(Object.isFrozen(registry.list()[0])).toBe(true);
// The same array identity every time: nothing recomputes or re-reads the filesystem per call.
expect(registry.list()).toBe(registry.list());
});
it("never reads or evaluates the artifact it publishes", async () => {
// A top-level throw would surface immediately if anything imported this file. Registry
// construction and resolution are pure metadata operations.
const modules = await discover([
{ id: "announcements", ui: 'throw new Error("module UI must never run on the server");' },
]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.resolve("announcements", "0.1.0")?.artifactPath).toBeDefined();
});
});
@@ -0,0 +1,262 @@
# 0025: Trusted module UI asset publication
- Status: Accepted
- Date: 2026-08-22
- Extends: [0021](0021-modules-as-hostable-products.md),
[0022](0022-module-ui-authoring-and-host-contract.md),
[0023](0023-module-ui-build-pipeline.md),
[0024](0024-browser-safe-ui-sdk-and-host-loader.md)
## Context
ADR 0024 closed the loader and named the one thing still missing, precisely:
> Core's discovery resolves `entrypoints.ui` to an **absolute path on the server's disk**
> (`ResolvedModuleEntrypoints.ui`). A browser cannot import that. […] **Ekklesia has no such route
> today.** The loader is complete; publication is deferred and is the first thing the next pass must
> build.
So the pipeline ran `module UI source → build → compiled browser ESM → entrypoints.ui → discovery
validates a filesystem artifact → ??? → browser URL → loadModuleUi`. This ADR fills in the `???`.
Two constraints shape it, and both come from the repository rather than from general principle.
`docs/development/security.md` already wrote down the obligation this pass inherits: *"the existence
of an artifact on disk must not be what makes it servable. Only installed, reviewed modules may be
published or mounted under this trust model."*
And the platform's module state is genuinely thin. `ModuleLifecycleStage` (`@ekklesia/contracts`)
names nine stages, but nothing implements a state machine over them: there is no installer, no
enable/disable, no trust store, and no per-module status record. What exists is one accepted set —
the modules `discoverModules` validated at startup and `startDiscoveredModuleRuntime` then started.
## Decision
### Publication is derived from validated module identity, never from a request path
The route takes a module id and a version, hands both to a registry as **lookup keys**, and — only
on an exact match — reads the canonical `artifactPath` Core's discovery already resolved.
```text
moduleId + version
↓ exact lookup, no path arithmetic
validated DiscoveredModule.entrypoints.ui
↓ read exactly that path
response bytes
```
No request value is ever joined, resolved, normalized, or concatenated into a filesystem path, and
the route declares no wildcard segment for one to hide in. Path traversal, an encoded separator, a
symlink, a sibling chunk, a source map, and a package-root file are therefore unreachable *by
construction* rather than by filtering — there is no code path that could reach them to filter.
This is the rule the pass exists to establish: **an installed module directory is never a static
file root.** A publisher that accepted `moduleId + relativePath` would be one careless normalization
away from serving `.env`, and it would also silently create an undeclared asset contract that no
manifest field, build step, or host loader knows about.
### Core owns the policy; `apps/api` owns the HTTP
`createModuleUiPublicationRegistry(modules)` (`core/src/modules/ui-publication.ts`) answers "may this
be published, and which exact file is it?". It is pure, synchronous, and consumes only
`DiscoveredModule` records — so a module reaches it having already passed manifest validation,
compiled-artifact validation (ADR 0006, extended to every non-directory entrypoint), and package
containment before *and* after symlink resolution.
"Publishable" therefore means exactly two things: the module is in the discovered set the platform
accepted at startup, **and** its manifest declares `entrypoints.ui`. No new state, no new lifecycle,
no installer. `apps/api/src/server.ts` builds the registry only after
`startDiscoveredModuleRuntime` has succeeded, so the published set is also the running set — module
startup is all-or-nothing, and a module that failed to load, register, or start takes API startup
down rather than lingering as publishable-but-not-running.
Keeping the policy in Core is not filing: it means the decision is expressed once, over already
validated data, where the rest of module discovery and lifecycle lives, rather than being re-derived
inside an HTTP handler that also has to think about headers and status codes.
### The URL is a sibling of the module API prefix, not a child of it
```text
GET /api/v1/module-ui/<module-id>/<version>
```
Nesting it under `/api/v1/modules/<module-id>/ui/...` was the obvious first choice and is wrong.
Core's own route grammar (`moduleRoutePathSchema`) accepts `ui` as an ordinary lowercase static
segment and `:version` as an ordinary parameter, so a module may legitimately register
`GET /ui/:version` — mounted at exactly `/api/v1/modules/<module-id>/ui/<version>`. Publishing there
would put the platform and installed modules in one URL namespace with Hono's registration order
silently deciding which one answers a request. A sibling prefix cannot collide: every module route
begins `/api/v1/modules/`, and nothing published here does.
It stays inside `/api/v1/*` so it inherits the application's single CORS policy — exact reflected
trusted origins, never a wildcard — which a cross-origin browser `import()` of this URL requires. A
new top-level prefix would have meant a second, separately-configured public URL space.
`moduleUiArtifactUrl({ moduleId, version })` (`apps/api/src/module-ui-routes.ts`) is the one
construction of that URL. It accepts **identity only** — no filename, no relative path, no artifact
selector — and percent-encodes both components, so even an unvalidated value can only ever become
one segment's worth of text, never a new segment, a traversal, a query, or a fragment. The result is
root-relative: a same-origin host passes it to `loadModuleUi` unchanged, and a split-origin host
resolves it against the same API base URL it already needs for `ModuleUiApiClient`. Baking in an
absolute origin would have broken reverse proxies and split-origin deployments alike.
### Version is part of the path, and a mismatch fails
A request naming a version other than the installed one resolves to nothing. It is never satisfied
with "the version that happens to be installed", which would let a stale host mount an artifact it
did not ask for — the exact failure `parseModuleUiExport`'s identity check exists to catch, arriving
one layer earlier and more cheaply.
Version in the path also means an upgrade changes the URL, so a cached artifact from the previous
version can never be served for the new one.
### Cache: revalidate always, with a strong `ETag`
`Cache-Control: no-cache` plus a SHA-256 `ETag` over the bytes actually being served, and RFC 9110
`If-None-Match` handling (wildcard, weak comparison, `304`).
`immutable` was tempting and is unearned. The URL carries *identity*, not *immutability*: Ekklesia
has no installer, no content addressing, and no integrity verification, so nothing stops an operator
from rebuilding version `1.2.3` with different bytes and restarting — which is the ordinary inner
loop of developing a module. Under `immutable`, a browser holding a superseded artifact would have
no way to recover.
`no-cache` is not `no-store`: the response is cached and merely revalidated, so an unchanged artifact
costs one conditional request and a `304`. Hashing the bytes rather than trusting an mtime also means
the validator can never describe a different revision than the body does. When an installer can
guarantee that an installed version's bytes never change, one constant moves and the policy becomes
immutable caching.
### No authentication, decided rather than defaulted
Retrieving the artifact confers no authority. The bundle is compiled first-party UI code an operator
reviewed and installed; it carries no credential, and the host — not the artifact — supplies the
principal and permissions at mount time. Every module API stays behind `requirePermission` against
the composed platform registry, and fetching this file changes no authorization decision anywhere.
Requiring a session would also break the thing it purports to protect. A browser's dynamic `import()`
uses `credentials: "same-origin"`, so a cross-origin import — the split-origin deployments
`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` exists to support — sends no cookie and would fail. That failure
tests green on a server and only appears in a real browser, which is precisely the class of error ADR
0024 was written to prevent. Working around it means fetching bytes and importing a `blob:` URL,
which costs a host its `script-src` CSP.
**The cost, stated plainly: an anonymous caller who knows a module id *and* its exact installed
version can download that module's UI bundle, and thereby learn that the module is installed.** That
is information disclosure, not authority. Two things bound it: the caller must already know both
halves of the identity, and unknown-module, no-UI, and wrong-version all return one byte-identical
`404 MODULE_UI_NOT_PUBLISHED`, so the route is not an enumeration oracle. An operator who needs the
bundle itself confidential must not expose the API to untrusted networks — which
`docs/development/security.md` already assumes for this deployment model. If a future deployment
model needs more, an `authenticated` baseline belongs on this route and nowhere else.
### Only the declared artifact, and never the source map
`@ekklesia/module-ui-build` emits `sourcemap: true`, so a real `.map` file sits next to every built
artifact. It is not published, for the same reason `.css` sidecars are rejected at build time (ADR
0023): the manifest declares the UI entrypoint and nothing else, so nothing else has a publication
contract. A browser's devtools will request `<artifact>.map` and receive a `404`; that is the correct
answer, not a gap. Source maps embed `sourcesContent`, and publishing an installation's module
sources unauthenticated as an accident of "the file was next to the one we meant" is exactly what a
narrow route prevents.
### The response is bytes, and the server never runs them
The route reads the file and writes it back: no import, no evaluation, no transformation, no
minification, no re-bundling, no rewriting. A module UI whose top level throws is served exactly like
any other. ADR 0021's rule that discovery never executes `entrypoints.ui` is unchanged, and
publication does not become a second, quieter way to execute browser code on the server.
`Content-Type: text/javascript; charset=utf-8` — the canonical JavaScript type, which a browser
requires before it will evaluate an ES module — with `X-Content-Type-Options: nosniff`, so the
declared type is the only type.
### Discovery-time validation plus request-time exact-path read is the trust model
Discovery validated the artifact at startup; the file may change or vanish before any HTTP retrieval.
The route does not pretend otherwise. At request time it opens exactly the previously resolved path,
once. A read failure produces a sanitized `503 MODULE_UI_ARTIFACT_UNAVAILABLE` carrying no path, no
`errno`, and no caught error — there is deliberately no fallback path, no re-resolution, and no retry
against a reconstructed location, so a vanished artifact fails rather than quietly resolving to a
neighbour.
No hashing, signing, or integrity pinning is introduced. Under the current model an installed module
package is trusted server code an operator placed on disk (`docs/development/security.md`), and a
platform that cannot verify provenance at install time gains nothing from verifying it at request
time. Symlink safety is likewise not re-implemented: discovery already proved containment before and
after resolution, and this route consumes that canonical result rather than re-deriving it from
request input.
## Alternatives
- **`GET /api/v1/modules/:moduleId/ui`, under the module API prefix.** Rejected on a concrete
collision, not on taste: `ui` is a legal module route segment, so a module could register the same
path and routing order would decide who answers.
- **`GET /modules/:moduleId/assets/*path`.** Rejected outright — it is the generic static root this
ADR exists to forbid, and it would invent an undeclared asset contract with no manifest field, no
build step, and no loader behind it.
- **A new top-level prefix (`/_ekklesia/...`).** Rejected: the repository has exactly one public API
namespace, and a second one would need its own CORS, cache, and error conventions to be
re-decided and kept in sync.
- **Unversioned URLs (`/api/v1/module-ui/:moduleId`).** Rejected: it forces conservative caching for
every deployment to survive an upgrade, and it discards identity the host already has and
`parseModuleUiExport` will check anyway.
- **`Cache-Control: public, max-age=31536000, immutable`.** Rejected: version reuse is possible and
unrecoverable under it. Revisit when an installer makes an installed version's bytes immutable.
- **`Cache-Control: no-store`,** matching every other route in the application. Rejected as
needlessly strict: those routes carry personalized or credentialed data, and this one is the same
bytes for every caller. `no-cache` with a validator is both correct and cheap.
- **Requiring an authenticated principal.** Rejected on the concrete grounds above — no authority is
conferred, and cookie-less cross-origin `import()` would break. It is the change to make first if
the deployment model ever requires confidentiality.
- **Requiring a permission.** Rejected more firmly: it would mean inventing a Core permission whose
only effect is on a file that grants nothing, and it puts an authorization decision in front of an
asset while implying the asset is itself protected.
- **Publishing source maps alongside the artifact.** Rejected: undeclared, and `sourcesContent`
would expose module sources to any caller.
- **Streaming the file instead of buffering it.** Rejected as unjustified complexity for one small
artifact — and the `ETag` has to hash the bytes anyway, so buffering is what makes the validator
describe exactly what was sent.
- **A shared `@ekklesia/module-ui-publication` package for the URL contract.** Rejected: one
producer, one consumer, and a future Dashboard will receive the URL from server metadata rather
than importing a package to rebuild it.
- **Teaching `@ekklesia/module-ui-host` how Ekklesia publishes artifacts.** Rejected, again: it is
exactly the coupling ADR 0024 refused, and it would make the standalone host's problem Ekklesia's.
- **Request-time integrity verification (hash or signature).** Rejected as infrastructure without a
root of trust: nothing produces a trusted digest at install time, so the check would only compare
the file to itself.
## Consequences
A validated installed module's UI artifact can now be fetched by a browser from a platform-controlled
URL, and the bytes that URL returns are the bytes `@ekklesia/module-ui-host` mounts — proven by
feeding the route's own HTTP response into the real loader.
**This is not Dashboard integration.** Nothing in Ekklesia mounts a module UI: there is no shell, no
navigation, no place a module's screen appears, and no code calls `moduleUiArtifactUrl` outside its
own tests. What changed is that a host now *could* obtain a URL, which was previously impossible.
`createApiApp` gained a required `moduleUi` option. Required rather than defaulted, matching
`moduleRoutes`: a caller that publishes no module UI says so with an empty registry instead of
falling through to an implicit one.
Node cannot `import()` an `http:` URL, so the end-to-end chain is proven in two honest halves rather
than one misleading whole: the route's publication behaviour is proven over real HTTP (both through
`app.request` and through a real socket against a started API), and loader compatibility is proven by
taking the bytes **that route returned** and importing them as a `data:` URL — a genuine module URL,
the same technique ADR 0024's own tests use. No test imports the artifact from the filesystem and
calls that a proof of HTTP publication.
## Deferred
Not implemented or promised here: **the Dashboard host** — the shell, routing, navigation, grouping,
and the code that actually calls `loadModuleUi` with a published URL — which is the next pass; a
module metadata endpoint that hands a host the list of installed modules and their UI URLs (the URL
helper exists for it, but no route returns one); a design system, theme tokens, or component library;
stylesheet, image, font, and general module asset publication, along with the manifest contract each
would need; source-map publication; React runtime sharing; a standalone application host; module
installation, upload, marketplace, hot loading, signing, or provenance; enable/disable or trust state
for installed modules; and every real feature module.
The permission-id typing gap carried over from ADR 0022 is likewise unchanged: `permissions.has`
takes a plain string, so a typo silently hides a control.
+1
View File
@@ -33,3 +33,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0022: Module UI authoring model and host contract](0022-module-ui-authoring-and-host-contract.md) — Accepted
- [0023: Module UI build pipeline](0023-module-ui-build-pipeline.md) — Accepted
- [0024: Browser-safe module UI SDK and the generic host loader](0024-browser-safe-ui-sdk-and-host-loader.md) — Accepted
- [0025: Trusted module UI asset publication](0025-trusted-module-ui-asset-publication.md) — Accepted
+27 -4
View File
@@ -216,10 +216,33 @@ host will use: dynamic import from a browser URL, identity validated against the
mounting, a host-owned container the loader never mutates, and an idempotent unmount. Modules may not
depend on it.
The distinction that shapes the next pass: Core resolves `entrypoints.ui` to a **server filesystem
path**, and a browser needs a **URL**. Turning an installed artifact into a browser URL is asset
publication — host-specific, deliberately outside the loader, and not implemented. Until it exists,
no host can mount an installed module UI.
**Phase 6C pass 1 publishes a validated module UI artifact at a browser URL.**
[ADR 0025](decisions/0025-trusted-module-ui-asset-publication.md) closes the gap the previous pass
named: Core resolves `entrypoints.ui` to a **server filesystem path**, and a browser needs a **URL**.
Core owns the policy and `apps/api` owns the transport. `createModuleUiPublicationRegistry`
(`core/src/modules/ui-publication.ts`) answers "may this be published, and which exact file is it?"
from the discovered, started module inventory — publishable means the module survived discovery
*and* declares `entrypoints.ui`, no new lifecycle state and no installer. `apps/api` serves
`GET /api/v1/module-ui/<module-id>/<version>`, where both segments are **lookup keys, not path
components**: an exact match yields the canonical artifact path discovery already validated, and no
request value is ever composed into a filesystem path. There is no wildcard segment, so traversal,
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/`
rather than a child because `ui` is a legal module route segment and a module could otherwise collide
with the platform.
The route requires no authentication: the bundle confers no authority, every module API stays behind
`requirePermission`, and a cookie-gated route would break cross-origin `import()`, which sends none.
The disclosure that buys — a caller who knows a module id and its exact version can download that
bundle — is stated rather than papered over, and the three failure modes are made indistinguishable
so the route is not an enumeration oracle. Caching is `no-cache` with a strong `ETag`, not
`immutable`, because without an installer a version can be rebuilt with different bytes. The server
reads and writes bytes; it never imports, evaluates, or transforms the artifact.
**A host could now obtain a URL, and nothing uses one.** There is no Dashboard shell, no navigation,
no module metadata endpoint, and no code that calls the URL helper outside its own tests. A design
system, stylesheet and general asset publication, and the standalone host remain future work.
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
are all unchanged.
@@ -36,6 +36,17 @@ These rules map platform ownership to filesystem and package boundaries. The exe
Node's global declarations — into browser code. Core still owns the containing
application shell and all platform policy, and module UI reaches the host only through
`@ekklesia/module-ui-sdk`.
- Module UI **asset publication** runs Core → `apps/api`, never the other way
([ADR 0025](decisions/0025-trusted-module-ui-asset-publication.md)). Core owns the policy
(`createModuleUiPublicationRegistry`, built only from already-validated `DiscoveredModule`
records); `apps/api` owns the HTTP route and the one canonical URL construction. Neither
`@ekklesia/module-ui-host` nor `@ekklesia/module-ui-sdk` learns anything about how Ekklesia
publishes artifacts — the loader takes a URL and stays host-neutral — and no module may reach
publication internals. This introduced no new package and no boundary relaxation: `apps/*` could
already depend on Core and on shared packages.
- A publication helper accepts module **identity** only. Neither Core's registry nor the API's URL
builder takes a filename, relative path, or any other artifact selector, so no caller can use one
to reach a second file inside a module package.
- A surface never imports Core or a feature module. It uses public API/client contracts.
- Separate surfaces do not import each other's internals.
- Builder code composes contracts and surface renderer identifiers; it does not import module or
+54
View File
@@ -76,6 +76,7 @@ No real values are shown here — configure per environment and never commit the
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
deterministic `404 {"code":"NOT_FOUND"}`.
@@ -450,6 +451,59 @@ are mounted under `/api/v1/modules/<module-id>`, in the module runtime's publish
registry: installing, removing, or editing a module's routes takes effect only on the next API
restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`.
## Module UI asset publication
A module may declare `entrypoints.ui`, a compiled browser ESM artifact
([ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md),
[ADR 0023](../architecture/decisions/0023-module-ui-build-pipeline.md)). Core's discovery resolves it
to an absolute path on the server's filesystem; a browser needs a URL. This route is the bridge
([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)).
```text
GET /api/v1/module-ui/<module-id>/<version>
```
- **Identity, not paths.** `:moduleId` and `:version` are lookup keys handed to a publication
registry Core builds from the discovered, started module inventory. Only an exact match on **both**
causes the already-canonical artifact path discovery validated to be read. No request value is ever
joined, resolved, or concatenated into a filesystem path, and the route declares no wildcard
segment — so traversal, encoded separators, sibling bundle chunks, package-root files, and source
maps are unreachable by construction. **An installed module directory is never a static file
root**, and only the single artifact its manifest declares is ever served.
- **A sibling of `/api/v1/modules/`, never a child.** A module may legitimately register the route
path `/ui/:version`, so nesting publication inside the module prefix would let a module and the
platform compete for one URL with routing order deciding the winner.
- **Publishable means discovered and started.** A module directory on disk is not enough: the module
must be in the validated set the API accepted at startup *and* declare `entrypoints.ui`. There is
no installer, enable/disable state, or trust store to consult, and none was invented here.
- **No authentication, deliberately.** Fetching the bundle confers no authority — every module API
stays behind `requirePermission`, and the host, not the artifact, supplies the principal and
permissions at mount time. Requiring a cookie would also break cross-origin dynamic `import()`,
which sends none. The cost, stated plainly: a caller who already knows a module id **and** its exact
installed version can download that bundle. Unknown module, no declared UI, and wrong version all
return one byte-identical `404 {"code":"MODULE_UI_NOT_PUBLISHED"}`, so the route is not an
enumeration oracle.
- **Response.** `Content-Type: text/javascript; charset=utf-8` with `X-Content-Type-Options:
nosniff`, and the artifact byte-for-byte — never transformed, re-bundled, minified, rewritten, or
executed. A module UI whose top level throws is served like any other; the server never imports it.
- **Caching.** `Cache-Control: no-cache` plus a strong SHA-256 `ETag` over the bytes actually served,
with RFC 9110 `If-None-Match` handling (wildcard, weak comparison, `304`). Not `immutable`: without
an installer nothing stops a version being rebuilt with different bytes, and an unrecoverable stale
cache is worse than one conditional request. A version bump changes the URL anyway.
- **Artifact changed after discovery.** Discovery validated the artifact at startup; it may vanish
later. The route opens exactly the previously resolved path once, with no fallback and no
re-resolution, and a read failure returns a sanitized `503
{"code":"MODULE_UI_ARTIFACT_UNAVAILABLE"}` carrying no path, `errno`, or caught error.
`moduleUiArtifactUrl({ moduleId, version })` (`apps/api/src/module-ui-routes.ts`) is the one
construction of this URL. It accepts identity only and percent-encodes both components. The result is
root-relative, so a same-origin host uses it unchanged and a split-origin host resolves it against
the same API base URL it already needs for `ModuleUiApiClient`.
**Nothing mounts a module UI yet.** A host can now obtain a browser URL that
`@ekklesia/module-ui-host` accepts, which was previously impossible — but Ekklesia has no Dashboard
shell, no navigation, and no code that calls this route outside its own tests.
## Module database access
`context.services.database` gives a module's `start`/`stop` lifecycle hooks and every authorized
+38 -5
View File
@@ -86,11 +86,44 @@ expected — the right id, the right version, a well-formed default export — a
anything else, but that is an integrity check against a *stale or mismatched* bundle. It is not a
judgement about whether a module is safe, and it must never be described as one.
The publication side carries the real obligation, and it is not built yet. When Ekklesia gains a
route that serves installed module artifacts as browser URLs, **the existence of an artifact on disk
must not be what makes it servable.** Only installed, reviewed modules may be published or mounted
under this trust model. A module UI that mounts has gained no privilege it did not already have —
its server half already runs in the API process with the API's database authority — and server APIs
The publication side carries the real obligation, and it now exists
([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)). It honours the
rule this section demanded of it: **the existence of an artifact on disk is not what makes it
servable.** A module is publishable only if it is in the validated set the API accepted at startup
*and* its manifest declares `entrypoints.ui`.
### Publishing a module UI artifact exposes one file, not a directory
`GET /api/v1/module-ui/<module-id>/<version>` serves an installed module's compiled UI artifact. Both
segments are **lookup keys, not path components**: they are matched against a registry Core built
from discovered modules, and only an exact match causes the canonical path discovery already resolved
and containment-checked to be read. No request value is composed into a filesystem path and the route
declares no wildcard segment, so traversal, encoded separators, sibling bundle chunks, package-root
files (`package.json`, `.env`), and **source maps** are unreachable — not filtered out, but with no
code path that could reach them. `@ekklesia/module-ui-build` emits a real `.map` next to every
artifact, and it is deliberately not served: the manifest declares the entrypoint and nothing else,
and a source map embeds `sourcesContent`.
The server never executes what it publishes. The route reads bytes and writes bytes — no import, no
evaluation, no transformation — so a module UI whose top level throws is served like any other, and
ADR 0021's rule that discovery never runs `entrypoints.ui` is unchanged.
**The route requires no authentication, and that is a decision with a stated cost.** Retrieving the
artifact confers no authority: the bundle carries no credential, the host (not the artifact) supplies
the principal and permissions at mount time, and every module API remains behind `requirePermission`.
Requiring a session would also break cross-origin dynamic `import()`, which sends no cookie. The
cost is that **an anonymous caller who already knows a module id *and* its exact installed version can
download that module's UI bundle**, and thereby learn the module is installed. That is information
disclosure, not authority. Unknown module, no declared UI, and wrong version return one identical
`404`, so the route is not an enumeration oracle — but an operator who needs the bundle itself
confidential must keep the API off untrusted networks, exactly as the deployment assumptions below
already require.
Trust in a published artifact rests on discovery-time validation plus a request-time read of that
exact path. There is no signing, hashing, or provenance verification — Ekklesia has none to verify
against — so **publication is not evidence that a module is safe.** Only installed, reviewed modules
may be published or mounted. A module UI that mounts has gained no privilege it did not already have
— its server half already runs in the API process with the API's database authority — and server APIs
remain the sole authority for authorization regardless of what a browser loaded.
### Client-side permission checks are not a security boundary
+46 -15
View File
@@ -9,11 +9,13 @@ platform context, the application shell, top-level routing, navigation, the glob
design-token contract, error boundaries, and trusted-origin/CORS policy are Core's and the host
application's. A module consumes those; it never redefines or bypasses them.
> **What exists today:** a module may *declare* a UI entrypoint in its manifest, and Core validates
> it. **Nothing loads, mounts, or renders it yet** — the host shell, the UI runtime, and the
> standalone host are all later Phase 6 work. Writing a module UI is not yet possible end to end;
> see [Declaring a UI entrypoint](#declaring-a-ui-entrypoint) for exactly what is and is not
> supported.
> **What exists today:** you can author a module UI in React, build it into the compiled artifact your
> manifest declares, have Core validate it, and have Ekklesia serve it at a browser URL that
> `@ekklesia/module-ui-host` can load and mount. **What does not exist is a host that does so** — no
> Dashboard shell, no navigation, no standalone host, and nothing in Ekklesia that renders your UI to
> a user. See [Declaring a UI entrypoint](#declaring-a-ui-entrypoint) and
> [How Ekklesia publishes your artifact](#how-ekklesia-publishes-your-artifact) for exactly what is
> and is not supported.
> **Before you publish or install a module:** modules are trusted compiled backend code. Your
> module runs arbitrary JavaScript inside the API process and shares its PostgreSQL role, and
@@ -383,11 +385,12 @@ Layer rules are unchanged: module UI source may not import Core, an app, a surfa
another module. An unsupported framework aliased behind an allowed name — `"react": "npm:vue@3"` —
is resolved and rejected by its real target.
> **Still no host.** You can author, build, and test a module UI end to end, and
> `@ekklesia/module-ui-host` can load and mount a built artifact from a browser URL. **Nothing serves
> your artifact at such a URL yet** — Ekklesia has no module-asset publication route, and the
> Dashboard shell and standalone host do not exist. Your artifact is proven by tests, not mounted in
> production. See [How a host loads your UI](#how-a-host-loads-your-ui).
> **Your artifact is now served, but nothing mounts it.** Ekklesia publishes an installed module's
> compiled UI artifact at `GET /api/v1/module-ui/<module-id>/<version>`
> ([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)), which
> `@ekklesia/module-ui-host` can load. **But no Ekklesia host calls it**: there is no Dashboard shell,
> no navigation, and no standalone host, so your UI is proven by tests and reachable by URL, not
> rendered anywhere in production. See [How a host loads your UI](#how-a-host-loads-your-ui).
### What this does and does not do today
@@ -409,7 +412,8 @@ You do not write this — a host does — but knowing the shape explains what yo
```text
compiled artifact (dist/ui/index.js)
↓ asset publication — host-specific, NOT YET IMPLEMENTED for Ekklesia
↓ asset publication — host-specific; Ekklesia serves it at
↓ GET /api/v1/module-ui/<module-id>/<version>
browser-loadable module URL
↓ loadModuleUi({ source, container, context })
dynamic import → parseModuleUiExport → identity checked against the manifest
@@ -439,10 +443,37 @@ What that means for you as a module author:
- **Your UI may be mounted more than once**, into different containers, if your implementation
supports it. Each mount gets its own cleanup.
> **Asset publication is the missing piece.** Core resolves `entrypoints.ui` to a path on the
> *server's filesystem*; a browser cannot import that. Something must serve your compiled artifact at
> a URL first, and Ekklesia has no such route yet. Until it does, no host can mount an installed
> module's UI — the loader is ready, the plumbing to reach it is not.
### How Ekklesia publishes your artifact
Core resolves `entrypoints.ui` to a path on the *server's filesystem*, which a browser cannot import.
Ekklesia turns that into a URL
([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)):
```text
GET /api/v1/module-ui/<module-id>/<version>
```
Four things about it matter to you as a module author:
- **Only the artifact your manifest declares is served.** The module id and version in that URL are
lookup keys, never path components — there is no way to request a second file from your package.
Your source map, any sibling chunk, and every package-root file stay unreachable. If your UI needs
an asset, it must be inside the bundle; there is no sidecar contract yet, which is also why a
stylesheet import still fails the build.
- **The version must match exactly.** A request for any other version is refused rather than served
your installed artifact, so a stale host cannot mount something it did not ask for.
- **Your artifact is public.** The route requires no authentication — fetching it grants no
authority, and your backend routes remain permission-enforced regardless. Never put a secret, a
private endpoint, or anything you would not publish in your UI bundle.
- **It is served byte-for-byte, never executed on the server.** No transformation, no re-bundling, no
evaluation. What `pnpm build` produced is exactly what a browser receives.
The response carries `Content-Type: text/javascript; charset=utf-8`, `X-Content-Type-Options:
nosniff`, `Cache-Control: no-cache`, and a strong `ETag`, so rebuilding your module at the same
version is picked up on the next request rather than being cached indefinitely.
> **Still nothing mounts it.** The URL exists and the loader accepts it, but Ekklesia has no Dashboard
> shell, no navigation, and no code that requests your UI. That is the next pass.
### What a module UI will never own
+3
View File
@@ -52,6 +52,9 @@ importers:
'@biomejs/biome':
specifier: 2.5.3
version: 2.5.3
'@ekklesia/module-ui-host':
specifier: workspace:*
version: link:../../packages/module-ui-host
'@ekklesia/typescript-config':
specifier: workspace:*
version: link:../../packages/typescript-config