feat: mount discovered module UI in a minimal Ekklesia browser host #23

Merged
Zayin_Admin merged 1 commits from phase-6c/module-ui-host into main 2026-08-22 14:09:33 +00:00
46 changed files with 3629 additions and 36 deletions
+6 -1
View File
@@ -45,7 +45,12 @@ pnpm infrastructure:config
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
private deployment detail and would break standalone hosting. private deployment detail and would break standalone hosting.
- The containing application shell, navigation, global theme, and platform policy belong to Core and - The containing application shell, navigation, global theme, and platform policy belong to Core and
the host application, never to a module. the host application, never to a module. Ekklesia's own shell is `surfaces/shell`; it discovers
installed module UIs through `GET /api/v1/module-ui` and mounts them with
`@ekklesia/module-ui-host`, and it statically imports no module (ADR 0026).
- A surface is browser code. It may depend on browser-safe shared roles only — never
`@ekklesia/contracts` or `@ekklesia/module-sdk`, whose type graphs pull Node's globals into the
browser — and it reaches the server over HTTP.
- Add or update tests when changing a contract or architectural rule. - Add or update tests when changing a contract or architectural rule.
- Record major architectural changes in an ADR rather than burying them in an implementation. - Record major architectural changes in an ADR rather than burying them in an implementation.
+33 -5
View File
@@ -326,11 +326,39 @@ download that bundle. Caching is `no-cache` with a strong `ETag` rather than `im
without an installer nothing stops a version being rebuilt with different bytes. See 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). [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 Phase 6C pass 2 connects the chain: **Ekklesia now has a browser host that mounts module-owned UI.**
impossible — but nothing in Ekklesia mounts a module UI. There is no Dashboard shell, no navigation, `surfaces/shell` (`@ekklesia/shell`) is the repository's first surface — a small React application,
no place a module's screen appears, and no code calls the URL helper outside its own tests. A design built to static files with esbuild — and it does the whole loop at run time:
system, a stylesheet or general asset publication contract, a module metadata endpoint, and the
standalone host also do not exist. ```text
GET /api/v1/principal → who is viewing
GET /api/v1/module-ui → which module UIs may be mounted, and where
↓ server-produced artifact URL
@ekklesia/module-ui-host → dynamic import, identity check, mount into a host-owned container
↓
context.api.request("records") → /api/v1/modules/example/records
```
The new inventory endpoint returns exactly what building a `ModuleUiHostContext` needs — id, version,
artifact URL, module API root, and the viewer's granted permissions **for that module** — and nothing
else: no filesystem paths, and no `name`/`icon`/`folder`/`order`, because navigation metadata is a
design that does not exist yet. It is authenticated (`core.modules.ui.read`, baseline
`authenticated`) precisely because it is the module enumeration the anonymous artifact route
deliberately withholds. Both URLs are produced by the server's own canonical helpers, so the browser
never rebuilds Ekklesia's module prefixes and either can change without touching host or module code.
**The shell statically imports no module.** Every module it can mount arrives at run time from the
inventory, and its bundle is checked for exactly one dynamic import — the generic loader's — with no
module id, no `/api/v1/modules`, no `eval`, and no blob URLs. Permission hints come from the server's
own evaluator and remain presentation only: the end-to-end test proves a viewer holding no grant is
still refused `403` when the UI calls the API anyway. The host owns the container, the loading state,
and every failure; a broken module costs its own panel, not the shell. See
[ADR 0026](docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md).
**This is not the Dashboard.** There is no navigation model, grouping, folders, icons, ordering, home
screen, design system, or client-side routing — the selector is a list of module ids. Sign-in UI, a
production CSP and deployment for the built shell, asset publication beyond the UI entrypoint, React
runtime sharing, the standalone host, and every real feature module also do not exist.
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary **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 JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
+1
View File
@@ -27,6 +27,7 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.3", "@biomejs/biome": "2.5.3",
"@ekklesia/module-ui-host": "workspace:*", "@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/shell": "workspace:*",
"@ekklesia/typescript-config": "workspace:*", "@ekklesia/typescript-config": "workspace:*",
"@types/node": "24.13.3", "@types/node": "24.13.3",
"@types/pg": "8.20.0", "@types/pg": "8.20.0",
+14
View File
@@ -16,6 +16,7 @@ import { cors } from "hono/cors";
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
import { createGrantsRoutes } from "./grants-routes.js"; import { createGrantsRoutes } from "./grants-routes.js";
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js"; import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
import { createModuleUiRoutes } from "./module-ui-routes.js"; import { createModuleUiRoutes } from "./module-ui-routes.js";
export interface CreateApiAppAuthorizationOptions { export interface CreateApiAppAuthorizationOptions {
@@ -179,6 +180,19 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
// that prefix rather than nested inside it. // that prefix rather than nested inside it.
app.route("/", createModuleUiRoutes({ registry: options.moduleUi })); app.route("/", createModuleUiRoutes({ registry: options.moduleUi }));
// The browser host's bootstrap query, at the collection root of that same prefix. Unlike the
// artifact route above it is authenticated (`core.modules.ui.read`) and per-principal, because it
// is the module enumeration ADR 0025 deliberately withheld from the anonymous artifact route.
app.route(
"/",
createModuleUiInventoryRoutes({
evaluator: options.authorization.evaluator,
moduleUi: options.moduleUi,
registry: options.authorization.registry,
runtime,
}),
);
mountModuleHttpRoutes(app, options.moduleRoutes, { mountModuleHttpRoutes(app, options.moduleRoutes, {
evaluator: options.authorization.evaluator, evaluator: options.authorization.evaluator,
registry: options.authorization.registry, registry: options.authorization.registry,
+20 -3
View File
@@ -45,13 +45,30 @@ export class ModuleRouteConfigurationError extends Error {
} }
} }
/**
* The root a module's own HTTP API is mounted at — the one place that URL shape is constructed.
*
* Exported because a browser host has to resolve `context.api.request("records")`
* (`ModuleUiApiClient`, `@ekklesia/module-ui-sdk`) to a real Ekklesia URL, and a module UI must
* never build that prefix itself: it is Ekklesia's private deployment detail, and a module that
* hardcoded it could not be hosted anywhere else (ADR 0022). The server hands the resolved base to
* the host through the module UI inventory (`./module-ui-inventory.js`) rather than letting the
* browser re-spell this template, so this prefix can change without touching either.
*
* Percent-encoded for the same reason `moduleUiArtifactUrl` is: a value that somehow did not come
* from a validated manifest can only ever become one segment's worth of text, never a new segment.
* A real module id (lowercase kebab-case, `moduleIdSchema`) is unaffected by the encoding.
*/
export function moduleApiBaseUrl(moduleId: string): string {
return `${MODULE_ROUTE_PREFIX}/${encodeURIComponent(moduleId)}`;
}
/** "/" is the module's own root and maps to the bare module prefix, never a required trailing /** "/" is the module's own root and maps to the bare module prefix, never a required trailing
* slash; every other local path appends directly. Root and non-root therefore never collide, and * slash; every other local path appends directly. Root and non-root therefore never collide, and
* `/api/v1/modules/<module-id>/` (the root path with a trailing slash) is never produced here. */ * `/api/v1/modules/<module-id>/` (the root path with a trailing slash) is never produced here. */
function moduleRoutePath(moduleId: ModuleId, routePath: string): string { function moduleRoutePath(moduleId: ModuleId, routePath: string): string {
return routePath === "/" const base = moduleApiBaseUrl(moduleId);
? `${MODULE_ROUTE_PREFIX}/${moduleId}` return routePath === "/" ? base : `${base}${routePath}`;
: `${MODULE_ROUTE_PREFIX}/${moduleId}${routePath}`;
} }
function sanitizedModuleHandlerFailure(c: Context): Response { function sanitizedModuleHandlerFailure(c: Context): Response {
+193
View File
@@ -0,0 +1,193 @@
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
import type { ModuleUiPublicationRegistry, PublishableModuleUi } from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
} from "./authorization-middleware.js";
import { moduleApiBaseUrl } from "./module-http-routes.js";
import { MODULE_UI_ASSET_PREFIX, moduleUiArtifactUrl } from "./module-ui-routes.js";
/**
* The collection root of the module UI resource: `/api/v1/module-ui` lists what a browser host may
* mount, and `/api/v1/module-ui/<module-id>/<version>` (ADR 0025) serves one artifact's bytes. One
* prefix, one resource, two representations.
*/
export const MODULE_UI_INVENTORY_PATH = MODULE_UI_ASSET_PREFIX;
/**
* Exactly what a browser host needs to mount one module's UI, and deliberately nothing else.
*
* No filesystem path, package root, package name, server/schema/migrations entrypoint, manifest
* version, capability, dependency, or storage bucket appears here. Those are server-side facts a
* browser has no use for, and several of them are precisely what ADR 0025's publication route
* exists to keep off the wire.
*
* There is also no `name`, `icon`, `description`, `category`, `folder`, or `order`. Those are
* *navigation* concerns, and ADR 0021 established that Dashboard grouping is presentation
* configuration an administrator owns rather than something a module declares. A field added here
* now would quietly become module-owned navigation metadata before that design exists; the
* temporary selector renders the module id instead.
*/
export interface HostModuleUiDescriptor {
readonly id: string;
readonly version: string;
/** Where the browser fetches this module's compiled UI artifact. Produced by the server's own
* canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication
* routing can change without touching a single line of host or module code. */
readonly ui: { readonly url: string };
/** The root this module's own HTTP API is mounted at, so the host can resolve the relative paths
* a module UI asks for (`context.api.request("records")`). Server-produced for the same reason
* `ui.url` is: `/api/v1/modules/<id>` is Ekklesia's private deployment detail, and neither the
* module nor the browser host should be the one spelling it. */
readonly api: { readonly baseUrl: string };
/**
* The subset of **this module's own declared permissions** that the current principal holds,
* evaluated server-side by Core's one authorization evaluator.
*
* Scoped to the module deliberately: a host has no business handing one module the platform's
* whole authorization state, and the module's manifest already declares exactly which permissions
* its UI can meaningfully ask about. Granted ids only — a permission the principal lacks is simply
* absent, so this never enumerates a module's full permission surface to someone who holds none of
* it.
*
* **These are presentation hints, not authority** (ADR 0022). They exist so a UI can hide a
* control instead of offering one that would fail. Every module route remains enforced by
* `requirePermission` against the composed platform registry, and that enforcement neither reads
* nor trusts anything in this array.
*/
readonly permissions: readonly string[];
}
export interface HostModuleUiInventory {
readonly modules: readonly HostModuleUiDescriptor[];
}
export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions {
/** The same publication registry the artifact route serves from — the single accepted/publishable
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
* no discovery, reads no manifest, and touches no filesystem; a module absent from this registry
* is absent from the inventory, by construction rather than by filtering. Named `moduleUi` rather
* than `registry` because `AuthorizationMiddlewareOptions.registry` is already the *permission*
* registry, and one route needs both. */
readonly moduleUi: ModuleUiPublicationRegistry;
}
/**
* Evaluates one module's declared permissions against the caller, returning only the granted ids.
*
* Uses Core's one evaluator, so baselines, the platform-owner bypass, grant expiry, and
* deny-by-default all behave exactly as they do on a real request — there is no second,
* browser-only interpretation of what a permission means. An infrastructure failure propagates as
* `AuthorizationEvaluationError` rather than being folded into "not granted", because a hint that
* silently read "denied" during a database outage would blank the whole UI and look like a
* permissions problem.
*/
async function grantedPermissionIds(
evaluator: AuthorizationMiddlewareOptions["evaluator"],
principal: EkklesiaPrincipal,
declarations: readonly PermissionDeclaration[],
): Promise<readonly string[]> {
const decisions = await Promise.all(
declarations.map(async (declaration) => ({
allowed: (
await evaluator.authorize({
baseline: declaration.baseline,
permission: declaration.id,
principal,
})
).allowed,
id: String(declaration.id),
})),
);
return decisions.filter((decision) => decision.allowed).map((decision) => decision.id);
}
async function describeModule(
evaluator: AuthorizationMiddlewareOptions["evaluator"],
principal: EkklesiaPrincipal,
published: PublishableModuleUi,
): Promise<HostModuleUiDescriptor> {
return {
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
id: published.moduleId,
permissions: await grantedPermissionIds(evaluator, principal, published.permissions),
ui: { url: moduleUiArtifactUrl(published) },
version: published.version,
};
}
/** Mirrors `requirePermission`'s own infrastructure-failure result exactly: a permission hint that
* could not be determined is never reported as "not granted". Carries no module id, permission id,
* or caught error. */
function sanitizedInventoryFailure(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
}
/**
* The browser host's one bootstrap query: which module UIs may I mount, where do I fetch each one,
* where does each one's API live, and what may this viewer do inside it.
*
* ## It is not a module management API
*
* It lists mountable UIs and nothing else. There is no create, update, enable, disable, install, or
* configure; no module that declares no UI; and no field beyond what constructing a
* `ModuleUiHostContext` requires. Ekklesia has no enable/disable state to report —
* `ModuleLifecycleStage` is vocabulary with no runtime state machine behind it — and inventing one
* here would be inventing a lifecycle rather than reporting one.
*
* ## The module set comes from the publication registry, never a rescan
*
* `moduleUi.list()` is the exact accepted set ADR 0025 publishes artifacts from: modules that
* survived discovery and declare `entrypoints.ui`, captured after the module runtime started. This
* route never scans a directory, reads a manifest, or reconstructs a module from `package.json`, so
* the inventory and the artifact route can never disagree about what exists. Order is the
* registry's own discovery order, which is deterministic (topologically sorted, then by id).
*
* ## Authenticated, deliberately, unlike the artifact route
*
* ADR 0025 made the artifact bytes anonymous — fetching compiled UI code confers no authority — but
* was careful that the route never became an enumeration oracle: unknown module, no UI, and wrong
* version collapse into one indistinguishable `404`, so a caller must already know both halves of an
* identity to learn anything. **This endpoint is that enumeration.** Serving it anonymously would
* hand over exactly what that route withholds, so it requires `core.modules.ui.read` (baseline
* `authenticated`): any signed-in user of the installation, and nobody else.
*
* Enforcement goes through the same `requirePermission` middleware every other protected route uses
* — no second browser-auth mechanism — which also resolves the principal exactly once and keeps a
* genuine denial (`403`) distinct from an infrastructure failure (`503`).
*
* The response is per-principal (its `permissions` depend on who is asking), so it is `no-store` and
* varies on `Origin` and `Cookie`, exactly like `/api/v1/principal`.
*/
export function createModuleUiInventoryRoutes(options: CreateModuleUiInventoryRoutesOptions): Hono {
const routes = new Hono();
const requireInventoryPermission = requirePermission(
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
CORE_MODULES_UI_READ,
);
routes.get(MODULE_UI_INVENTORY_PATH, requireInventoryPermission, async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
// Resolved and already authorized by the middleware above; never resolved a second time here.
// The `authenticated` baseline guarantees this is an authenticated principal.
const principal = getAuthorizedPrincipal(c);
try {
const modules = await Promise.all(
options.moduleUi
.list()
.map((published) => describeModule(options.evaluator, principal, published)),
);
return c.json({ modules } satisfies HostModuleUiInventory, 200);
} catch {
return sanitizedInventoryFailure(c);
}
});
return routes;
}
+3 -1
View File
@@ -2158,7 +2158,9 @@ describe.sequential("live API — installed module permission end-to-end", () =>
expect(moduleCheck).toMatchObject({ status: "pass" }); expect(moduleCheck).toMatchObject({ status: "pass" });
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]); expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
expect(permissionsCheck).toMatchObject({ expect(permissionsCheck).toMatchObject({
details: ["2 permissions registered"], // Core's own two (`core.authorization.grants.manage`, `core.modules.ui.read`) plus the
// fixture module's one.
details: ["3 permissions registered"],
status: "pass", status: "pass",
}); });
// Doctor and permission composition read only the manifest — the fixture's register/start // Doctor and permission composition read only the manifest — the fixture's register/start
+405
View File
@@ -0,0 +1,405 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
discoverModules,
startDiscoveredModuleRuntime,
} from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host";
import {
bootstrapHost,
createModuleUiContext,
type EkklesiaHostConfig,
moduleUiSourceUrl,
} from "@ekklesia/shell";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
/**
* The whole chain, with nothing between the halves faked:
*
* ```text
* module fixture on disk -> discovery -> module runtime started -> publication registry
* -> inventory endpoint -> the shell's own bootstrap -> server-produced artifact URL
* -> the generic loader -> mount -> the module calls its own API relatively
* -> the shell resolves it to /api/v1/modules/<id>/... -> server permission enforcement
* ```
*
* Every step runs real code. The module is discovered and *started* by Core's real runtime, its
* routes are mounted by the real `createApiApp`, its artifact is published by the real ADR 0025
* route, its descriptor comes from the real inventory endpoint, and the browser half is
* `@ekklesia/shell`'s own exported protocol — the same functions the React shell calls — rather
* than a reimplementation of them here.
*
* Two things are deliberately *not* faked-around and are worth naming. The shell's `fetch` is the
* Hono app's own `request`, so requests really traverse routing, CORS, authorization middleware, and
* handlers without needing a socket. And Node cannot `import()` an `http:` URL, so the artifact is
* fetched over that same in-process HTTP path and the **bytes it returned** are handed to the
* generic loader through a `data:` URL — a genuine module URL, the same separation ADR 0025 used.
* No test here reads the artifact off the filesystem and calls that a proof of publication.
*/
const viewerId = parseEkklesiaUserId("22222222-2222-4222-8222-222222222222");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
/**
* A real, complete module: a manifest declaring a restricted permission and a UI entrypoint, a
* server entrypoint that registers a permission-gated route, and a compiled UI artifact that reads
* its host context and calls its own API relatively.
*
* The UI artifact is written as plain ESM rather than compiled from TSX here: ADR 0023's pipeline is
* already proven end to end by `templates/module`'s own tests, and what this test needs is a byte
* sequence whose journey through publication, inventory, and the loader can be asserted exactly.
*/
async function createFixtureModule(modulesDirectory: string): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, "example");
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: "@test/module-example",
type: "module",
version: "0.1.0",
}),
);
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", ui: "./dist/ui/index.js" },
id: "example",
manifestVersion: 4,
name: "Example module",
permissions: [
{
baseline: "restricted",
description: "Read example records.",
id: "example.records.read",
},
],
version: "0.1.0",
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default {
id: "example",
version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/records",
permission: "example.records.read",
maxBodyBytes: 0,
async handler() {
return Response.json([{ id: "a" }, { id: "b" }]);
},
});
},
};\n`,
);
// The module UI: host-neutral, asks for a relative path, and renders from the host context. It
// hardcodes no origin, no prefix, and no permission decision of its own.
await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`export default {
id: "example",
version: "0.1.0",
async mount(container, context) {
if (!context.permissions.has("example.records.read")) {
container.textContent = context.moduleId + ":denied";
return () => { container.textContent = ""; };
}
const response = await context.api.request("records");
const records = response.ok ? await response.json() : [];
container.textContent =
context.moduleId + ":" + response.status + ":" + records.length;
return () => { container.textContent = ""; };
},
};\n`,
);
}
interface HarnessOptions {
readonly principal?: EkklesiaPrincipal;
readonly grants?: readonly string[];
}
interface Harness {
readonly config: EkklesiaHostConfig;
readonly moduleApiCalls: readonly string[];
stop(): Promise<void>;
}
/**
* Starts a real API over a real discovered-and-started module, and returns the `EkklesiaHostConfig`
* a browser would use — with `fetch` wired to the app itself.
*/
async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-"));
temporaryDirectories.push(modulesDirectory);
await createFixtureModule(modulesDirectory);
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const services: ModuleRuntimeServices = Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
});
// The module's real runtime: its server entrypoint is imported, registered, and started, and the
// route snapshot below is the one it actually published.
const moduleRuntime = await startDiscoveredModuleRuntime(modules, services);
const granted = new Set(options.grants ?? []);
const evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
platformOwners: { isPlatformOwner: async () => false },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
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: composePlatformPermissionRegistry(modules), store },
moduleRoutes: moduleRuntime.routes,
moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules),
runtime,
trustedOrigins: ["https://dashboard.example.test"],
});
const moduleApiCalls: string[] = [];
const hostFetch = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const url = new URL(String(input));
if (url.pathname.startsWith("/api/v1/modules/")) {
moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`);
}
return app.request(url.href, init);
}) as unknown as typeof fetch;
return {
config: { apiBaseUrl: "https://ekklesia.example.test", fetch: hostFetch },
moduleApiCalls,
stop: () => moduleRuntime.stop(),
};
}
/** Node cannot `import()` an `http:` URL, so the artifact is fetched over the real route and its
* exact bytes become a `data:` URL. The loader still performs a genuine dynamic import of a real
* module URL; only the transport differs, and the transport itself is asserted separately. */
async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promise<string> {
const response = await (harness.config.fetch as typeof fetch)(sourceUrl);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
const bytes = Buffer.from(await response.arrayBuffer());
return `data:text/javascript;base64,${bytes.toString("base64")}`;
}
/** The loader hands the container straight to the module and never inspects it, so a plain sink is
* enough and no DOM implementation is needed in this Node-environment suite. */
function containerSink(): { textContent: string } {
return { textContent: "" };
}
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
describe("end to end: a discovered module's UI mounts in the Ekklesia host", () => {
it("bootstraps, publishes, loads, mounts, and reaches the module's own API", async () => {
const harness = await startHarness({
grants: ["example.records.read"],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
// 1. The shell's own bootstrap: principal, then inventory.
const bootstrap = await bootstrapHost(harness.config);
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: viewerId });
expect(bootstrap.modules).toHaveLength(1);
const descriptor = bootstrap.modules[0];
if (!descriptor) {
throw new Error("the inventory returned no module");
}
// 2. The URLs came from the server, not from anything the browser composed.
expect(descriptor).toEqual({
apiBaseUrl: "/api/v1/modules/example",
id: "example",
permissions: ["example.records.read"],
uiUrl: "/api/v1/module-ui/example/0.1.0",
version: "0.1.0",
});
// 3. The artifact really is served by the real publication route at that URL.
const sourceUrl = moduleUiSourceUrl(harness.config, descriptor);
expect(sourceUrl).toBe("https://ekklesia.example.test/api/v1/module-ui/example/0.1.0");
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
// 4. The generic loader mounts it with the context the shell built.
const container = containerSink();
const mounted = await loadModuleUi({
container: container as unknown as LoaderContainer,
context: createModuleUiContext({
config: harness.config,
descriptor,
principal: bootstrap.principal,
}),
source: { moduleId: descriptor.id, url: artifactUrl, version: descriptor.version },
});
// 5. The module rendered, having reached its own backend through the host.
expect(container.textContent).toBe("example:200:2");
// 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix.
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
// 7. And it tears down cleanly.
await mounted.unmount();
expect(container.textContent).toBe("");
expect(mounted.mounted).toBe(false);
} finally {
await harness.stop();
}
});
it("keeps server authorization authoritative when the UI ignores its own hint", async () => {
// Signed in, but holding no grant for the module's restricted permission.
const harness = await startHarness({
grants: [],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
const bootstrap = await bootstrapHost(harness.config);
const descriptor = bootstrap.modules[0];
if (!descriptor) {
throw new Error("the inventory returned no module");
}
// The hint is honest about it...
expect(descriptor.permissions).toEqual([]);
const context = createModuleUiContext({
config: harness.config,
descriptor,
principal: bootstrap.principal,
});
expect(context.permissions.has("example.records.read")).toBe(false);
// ...and the hint is not what protects the route. Calling anyway — exactly what a buggy or
// hostile UI would do — is still refused by the server.
const response = await context.api.request("records");
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
} finally {
await harness.stop();
}
});
it("renders the module's own denied state from the hint, without calling the API", async () => {
const harness = await startHarness({
grants: [],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
const bootstrap = await bootstrapHost(harness.config);
const descriptor = bootstrap.modules[0];
if (!descriptor) {
throw new Error("the inventory returned no module");
}
const artifactUrl = await publishedArtifactUrl(
harness,
moduleUiSourceUrl(harness.config, descriptor),
);
const container = containerSink();
await loadModuleUi({
container: container as unknown as LoaderContainer,
context: createModuleUiContext({
config: harness.config,
descriptor,
principal: bootstrap.principal,
}),
source: { moduleId: descriptor.id, url: artifactUrl, version: descriptor.version },
});
// Hiding a control is a usability improvement: the module simply did not ask.
expect(container.textContent).toBe("example:denied");
expect(harness.moduleApiCalls).toEqual([]);
} finally {
await harness.stop();
}
});
it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => {
const harness = await startHarness();
try {
const bootstrap = await bootstrapHost(harness.config);
expect(bootstrap.principal).toEqual({ kind: "anonymous" });
expect(bootstrap.modules).toEqual([]);
} finally {
await harness.stop();
}
});
it("refuses a module UI that tries to address another module's API through the host", async () => {
const harness = await startHarness({
grants: ["example.records.read"],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
const bootstrap = await bootstrapHost(harness.config);
const descriptor = bootstrap.modules[0];
if (!descriptor) {
throw new Error("the inventory returned no module");
}
const context = createModuleUiContext({
config: harness.config,
descriptor,
principal: bootstrap.principal,
});
await expect(context.api.request("../../grants?userId=someone")).rejects.toMatchObject({
code: "MODULE_API_PATH_INVALID",
});
// It never became a request at all.
expect(harness.moduleApiCalls).toEqual([]);
} finally {
await harness.stop();
}
});
});
+500
View File
@@ -0,0 +1,500 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
createAuthorizationEvaluator,
type PermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { moduleUiArtifactUrl } from "../src/module-ui-routes.js";
const trustedOrigin = "https://dashboard.example.test";
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
interface ModuleFixture {
readonly id: string;
readonly version?: string;
/** Omit for a backend-only module: the manifest then declares no `entrypoints.ui` at all. */
readonly withUi?: boolean;
readonly permissions?: ReadonlyArray<{
readonly id: string;
readonly baseline: "authenticated" | "public" | "restricted";
}>;
}
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.withUi ? { ui: "./dist/ui/index.js" } : {}),
},
id: fixture.id,
manifestVersion: 4,
name: `${fixture.id} module`,
permissions: (fixture.permissions ?? []).map((permission) => ({
...permission,
description: `Test permission for ${fixture.id}.`,
})),
version,
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)} };`,
);
if (fixture.withUi) {
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
);
}
}
interface InstallOptions {
readonly fixtures: readonly ModuleFixture[];
/** Anonymous by default, so every scenario has to opt in to being signed in. */
readonly principal?: EkklesiaPrincipal;
/** Permission ids the viewer holds an active grant for. Evaluated by Core's real evaluator, so
* baselines, the owner bypass, and deny-by-default all behave exactly as in production. */
readonly grants?: readonly string[];
readonly platformOwner?: boolean;
/** Makes the grant port fail, so the "a hint that could not be determined is never reported as
* denied" path can be exercised. */
readonly grantsUnavailable?: boolean;
}
interface Installation {
readonly app: Hono;
readonly modules: readonly DiscoveredModule[];
readonly modulesDirectory: string;
readonly permissionRegistry: PermissionRegistry;
}
/** A real installation: fixtures on disk, Core's real discovery, real permission composition, the
* real publication registry, the real authorization evaluator, and the real `createApiApp`. Only
* identity resolution and the two authorization ports are doubled, because neither needs PostgreSQL
* to answer the questions these tests ask. */
async function install(options: InstallOptions): Promise<Installation> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-ui-inventory-"));
temporaryDirectories.push(modulesDirectory);
for (const fixture of options.fixtures) {
await createModule(modulesDirectory, fixture);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const granted = new Set(options.grants ?? []);
const evaluator = createAuthorizationEvaluator({
grants: {
hasActiveGrant: async (query) => {
if (options.grantsUnavailable) {
throw new Error("grant store unavailable");
}
return granted.has(query.permission);
},
},
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
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 services: ModuleRuntimeServices = Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
});
const permissionRegistry = composePlatformPermissionRegistry(modules);
const app = createApiApp({
authorization: { evaluator, registry: permissionRegistry, store },
moduleRoutes: Object.freeze([]),
moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules),
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, modules, modulesDirectory, permissionRegistry };
}
function signedIn(): EkklesiaPrincipal {
return createAuthenticatedPrincipal(viewerId);
}
async function inventoryOf(app: Hono): Promise<{
readonly body: string;
readonly modules: ReadonlyArray<Record<string, unknown>>;
readonly response: Response;
}> {
const response = await app.request(MODULE_UI_INVENTORY_PATH);
const body = await response.clone().text();
const parsed = (await response.json()) as { modules?: ReadonlyArray<Record<string, unknown>> };
return { body, modules: parsed.modules ?? [], response };
}
describe("module UI inventory — what it lists", () => {
it("describes a publishable module with exactly the fields a host needs", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [], withUi: true }],
principal: signedIn(),
});
const { modules, response } = await inventoryOf(app);
expect(response.status).toBe(200);
expect(modules).toEqual([
{
api: { baseUrl: "/api/v1/modules/example" },
id: "example",
permissions: [],
ui: { url: "/api/v1/module-ui/example/0.1.0" },
version: "0.1.0",
},
]);
});
it("builds both URLs with the server's own canonical helpers", async () => {
const { app, modules: discovered } = await install({
fixtures: [{ id: "example", version: "2.4.1", withUi: true }],
principal: signedIn(),
});
const module = discovered[0] as DiscoveredModule;
const { modules } = await inventoryOf(app);
// Not "looks like the right shape" — byte-identical to what the helpers produce, so a second
// hand-written template string anywhere would fail here.
expect(modules[0]?.ui).toEqual({
url: moduleUiArtifactUrl({ moduleId: module.manifest.id, version: module.manifest.version }),
});
expect(modules[0]?.api).toEqual({ baseUrl: moduleApiBaseUrl(module.manifest.id) });
});
it("omits a module that declares no UI entrypoint", async () => {
const { app } = await install({
fixtures: [{ id: "calendar" }, { id: "example", withUi: true }],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules.map((module) => module.id)).toEqual(["example"]);
});
it("is empty, not an error, when no installed module provides a UI", async () => {
const { app } = await install({ fixtures: [{ id: "calendar" }], principal: signedIn() });
const { modules, response } = await inventoryOf(app);
expect(response.status).toBe(200);
expect(modules).toEqual([]);
});
it("orders modules deterministically, matching the publication registry", async () => {
const { app } = await install({
fixtures: [
{ id: "signage", withUi: true },
{ id: "announcements", withUi: true },
{ id: "calendar", withUi: true },
],
principal: signedIn(),
});
const first = await inventoryOf(app);
const second = await inventoryOf(app);
// Discovery order, which is sorted; the same on every request.
expect(first.modules.map((module) => module.id)).toEqual([
"announcements",
"calendar",
"signage",
]);
expect(second.modules.map((module) => module.id)).toEqual(
first.modules.map((module) => module.id),
);
});
it("leaks no filesystem path, package root, or other manifest internal", async () => {
const {
app,
modules: discovered,
modulesDirectory,
} = await install({
fixtures: [{ id: "example", permissions: [], withUi: true }],
principal: signedIn(),
});
const artifactPath = (discovered[0] as DiscoveredModule).entrypoints.ui as string;
const { body, modules } = await inventoryOf(app);
expect(body).not.toContain(modulesDirectory);
expect(body).not.toContain(artifactPath);
expect(body).not.toContain(tmpdir());
expect(body).not.toContain("dist");
expect(body).not.toContain("@test/module-example");
expect(body).not.toContain("ekklesia.module.json");
// The exact key set, so a future field cannot be added without a deliberate decision here.
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
"api",
"id",
"permissions",
"ui",
"version",
]);
});
it("carries no navigation or presentation metadata", async () => {
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
principal: signedIn(),
});
const { body } = await inventoryOf(app);
// ADR 0021 keeps Dashboard grouping an administrator's configuration, never a module
// declaration. The manifest's own `name` and `description` must not leak into it by accident.
for (const field of ["name", "description", "icon", "category", "folder", "order", "group"]) {
expect(body, field).not.toContain(`"${field}"`);
}
});
});
describe("module UI inventory — authentication", () => {
it("refuses an anonymous caller", async () => {
const { app } = await install({ fixtures: [{ id: "example", withUi: true }] });
const response = await app.request(MODULE_UI_INVENTORY_PATH);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("tells an anonymous caller nothing about what is installed", async () => {
const { app } = await install({
fixtures: [
{ id: "example", withUi: true },
{ id: "calendar", withUi: true },
],
});
const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text();
expect(body).not.toContain("example");
expect(body).not.toContain("calendar");
});
it("admits any signed-in user without needing an explicit grant", async () => {
// `core.modules.ui.read` is baseline `authenticated`, so being signed in is enough — a shell
// that rendered nothing until an operator granted every user a permission would be unusable.
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
grants: [],
principal: signedIn(),
platformOwner: false,
});
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
});
it("is never cached and varies on the caller's identity", async () => {
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
principal: signedIn(),
});
const response = await app.request(MODULE_UI_INVENTORY_PATH, {
headers: { origin: trustedOrigin },
});
expect(response.headers.get("cache-control")).toBe("no-store");
const vary = response.headers.get("vary")?.toLowerCase() ?? "";
expect(vary).toContain("origin");
expect(vary).toContain("cookie");
});
});
describe("module UI inventory — permission hints", () => {
const readPermission = { baseline: "restricted" as const, id: "example.records.read" };
const writePermission = { baseline: "restricted" as const, id: "example.records.write" };
it("returns only the permissions the server actually granted this principal", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
grants: ["example.records.read"],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules[0]?.permissions).toEqual(["example.records.read"]);
});
it("returns none of them for a signed-in user holding no grant", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
grants: [],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
// Never a default of true, and never the module's full declared surface.
expect(modules[0]?.permissions).toEqual([]);
});
it("honours a baseline that needs no grant at all", async () => {
const { app } = await install({
fixtures: [
{
id: "example",
permissions: [{ baseline: "authenticated", id: "example.records.list" }, readPermission],
withUi: true,
},
],
grants: [],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules[0]?.permissions).toEqual(["example.records.list"]);
});
it("honours the platform-owner bypass, exactly like a real request would", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
grants: [],
platformOwner: true,
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules[0]?.permissions).toEqual(["example.records.read", "example.records.write"]);
});
it("scopes hints to each module's own declared permissions", async () => {
const { app } = await install({
fixtures: [
{ id: "example", permissions: [readPermission], withUi: true },
{
id: "calendar",
permissions: [{ baseline: "restricted", id: "calendar.events.read" }],
withUi: true,
},
],
grants: ["example.records.read", "calendar.events.read"],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
const byId = new Map(modules.map((module) => [module.id, module.permissions]));
// Neither module learns anything about the other's authorization state, and neither is told
// about Core's own permissions.
expect(byId.get("example")).toEqual(["example.records.read"]);
expect(byId.get("calendar")).toEqual(["calendar.events.read"]);
expect(JSON.stringify(modules)).not.toContain("core.authorization.grants.manage");
expect(JSON.stringify(modules)).not.toContain("core.modules.ui.read");
});
it("reports an evaluation outage as unavailable rather than as 'nothing granted'", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [readPermission], withUi: true }],
grantsUnavailable: true,
principal: signedIn(),
});
const response = await app.request(MODULE_UI_INVENTORY_PATH);
// A hint that silently read "denied" during an outage would blank the UI and look like a
// permissions problem.
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
});
});
describe("module UI inventory — it is not a module management API", () => {
it("routes no method other than GET", async () => {
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
principal: signedIn(),
});
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
const response = await app.request(MODULE_UI_INVENTORY_PATH, { method });
expect(response.status, method).toBe(404);
expect(await response.json(), method).toEqual({ code: "NOT_FOUND" });
}
});
it("still refuses to serve an artifact under the collection path", async () => {
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
principal: signedIn(),
});
for (const suffix of ["/example", "/example/", "/example/0.1.0/index.js"]) {
const response = await app.request(`${MODULE_UI_INVENTORY_PATH}${suffix}`);
expect(response.status, suffix).toBe(404);
}
});
});
+18 -2
View File
@@ -440,9 +440,10 @@ describe("module UI artifact publication — filesystem exposure", () => {
// A trailing segment of any kind, including an empty one. // A trailing segment of any kind, including an empty one.
`${base}/0.1.0/`, `${base}/0.1.0/`,
`${base}/0.1.0/anything`, `${base}/0.1.0/anything`,
// Fewer segments than the route declares. // Fewer segments than the route declares. The collection root itself is deliberately absent
// from this list: it is the authenticated inventory (ADR 0026) rather than a 404, and is
// asserted separately below.
base, base,
MODULE_UI_ASSET_PREFIX,
]; ];
for (const url of unreachable) { for (const url of unreachable) {
@@ -456,6 +457,21 @@ describe("module UI artifact publication — filesystem exposure", () => {
} }
}); });
it("serves no artifact bytes from the collection root, which is the authenticated inventory", async () => {
const { app } = await installWithNeighbours();
// `/api/v1/module-ui` lists what a host may mount (ADR 0026) and requires
// `core.modules.ui.read`; this installation's caller is anonymous, so it is refused. Either way
// it never yields a byte of any artifact or neighbouring file.
const response = await app.request(MODULE_UI_ASSET_PREFIX);
const body = await response.text();
expect(response.status).toBe(403);
expect(body).not.toContain("mount");
expect(body).not.toContain("secret");
expect(body).not.toContain("internal");
});
it("treats a traversal attempt as an ordinary failed lookup", async () => { it("treats a traversal attempt as an ordinary failed lookup", async () => {
const { app } = await installWithNeighbours(); const { app } = await installWithNeighbours();
+20
View File
@@ -83,6 +83,21 @@ export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId(
"core.authorization.grants.manage", "core.authorization.grants.manage",
); );
/**
* Authenticated: any signed-in user of this installation may learn which module UIs the platform is
* currently hosting, because that is exactly what a browser shell has to render before it can offer
* anything at all. It is deliberately not `public` — the anonymous artifact route (ADR 0025) is
* careful never to become an enumeration oracle, and an anonymous inventory would hand over the very
* listing that route withholds. It is deliberately not `restricted` either: a shell that rendered
* nothing until an operator granted every user this permission would make the platform unusable for
* its ordinary users, and the module UIs themselves already gate their own content on their own
* permissions.
*
* Listing a module UI is not permission to use that module. Every module route remains enforced
* against its own declared permission, unchanged.
*/
export const CORE_MODULES_UI_READ: PermissionId = parsePermissionId("core.modules.ui.read");
/** /**
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module * Core's own declarations, exported in deeply immutable form so a trusted composer (the module
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from * integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
@@ -96,6 +111,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob
description: "Create, list, and revoke another user's explicit authorization grants.", description: "Create, list, and revoke another user's explicit authorization grants.",
id: CORE_AUTHORIZATION_GRANTS_MANAGE, id: CORE_AUTHORIZATION_GRANTS_MANAGE,
}), }),
Object.freeze({
baseline: "authenticated" as const,
description: "List the installed module UIs a browser host may mount.",
id: CORE_MODULES_UI_READ,
}),
]); ]);
/** /**
+17
View File
@@ -1,3 +1,4 @@
import type { PermissionDeclaration } from "@ekklesia/contracts";
import type { DiscoveredModule } from "./discovery.js"; import type { DiscoveredModule } from "./discovery.js";
/** /**
@@ -14,6 +15,19 @@ export interface PublishableModuleUi {
readonly moduleId: string; readonly moduleId: string;
readonly version: string; readonly version: string;
readonly artifactPath: string; readonly artifactPath: string;
/**
* The module's own manifest-declared permissions, carried here so a publisher can compute the
* presentation-only permission hints a host context needs (ADR 0022) **without** reaching back
* into the manifest and re-deriving which modules are publishable — which would put the
* publication policy in two places.
*
* Declarations only: this says what the module declares, never what any principal holds.
* Evaluating a principal against them is the caller's job, through Core's one authorization
* evaluator, and the answer is a hint rather than a grant. `artifactPath` and these declarations
* are the complete set of things a publisher may need; nothing else from the manifest belongs
* here.
*/
readonly permissions: readonly PermissionDeclaration[];
} }
/** /**
@@ -78,6 +92,9 @@ export function createModuleUiPublicationRegistry(
Object.freeze({ Object.freeze({
artifactPath, artifactPath,
moduleId: module.manifest.id, moduleId: module.manifest.id,
// Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding
// this record can never reach the manifest object graph itself.
permissions: Object.freeze([...module.manifest.permissions]),
version: module.manifest.version, version: module.manifest.version,
}), }),
); );
+22 -2
View File
@@ -2,6 +2,7 @@ import { parsePermissionId } from "@ekklesia/contracts";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_MODULES_UI_READ,
CORE_PERMISSION_DECLARATIONS, CORE_PERMISSION_DECLARATIONS,
CORE_PERMISSION_REGISTRY, CORE_PERMISSION_REGISTRY,
createPermissionRegistry, createPermissionRegistry,
@@ -89,8 +90,21 @@ describe("CORE_PERMISSION_REGISTRY", () => {
expect(declaration?.id).toBe("core.authorization.grants.manage"); expect(declaration?.id).toBe("core.authorization.grants.manage");
}); });
it("contains no other permission in this slice", () => { it("registers core.modules.ui.read as authenticated, not restricted", () => {
expect(CORE_PERMISSION_REGISTRY.declarations).toHaveLength(1); const declaration = CORE_PERMISSION_REGISTRY.get(CORE_MODULES_UI_READ);
expect(declaration).toBeDefined();
// Any signed-in user may learn which module UIs the browser host can mount; a shell that
// rendered nothing until an operator granted each user a permission would be unusable, and the
// module UIs gate their own content on their own permissions regardless (ADR 0026).
expect(declaration?.baseline).toBe("authenticated");
expect(declaration?.id).toBe("core.modules.ui.read");
});
it("contains exactly these two permissions and no others", () => {
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_MODULES_UI_READ,
]);
}); });
}); });
@@ -102,9 +116,15 @@ describe("CORE_PERMISSION_DECLARATIONS", () => {
description: "Create, list, and revoke another user's explicit authorization grants.", description: "Create, list, and revoke another user's explicit authorization grants.",
id: CORE_AUTHORIZATION_GRANTS_MANAGE, id: CORE_AUTHORIZATION_GRANTS_MANAGE,
}, },
{
baseline: "authenticated",
description: "List the installed module UIs a browser host may mount.",
id: CORE_MODULES_UI_READ,
},
]); ]);
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true); expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[0])).toBe(true); expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[0])).toBe(true);
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[1])).toBe(true);
}); });
it("matches exactly what CORE_PERMISSION_REGISTRY exposes as its own declarations, so a composer building on top of it never diverges from what Core itself registers", () => { it("matches exactly what CORE_PERMISSION_REGISTRY exposes as its own declarations, so a composer building on top of it never diverges from what Core itself registers", () => {
+1 -1
View File
@@ -78,7 +78,7 @@ Modules
✓ Dependency graph valid ✓ Dependency graph valid
Dependency order is empty. Dependency order is empty.
✓ Permission registry composed ✓ Permission registry composed
1 permission registered 2 permissions registered
Data definitions Data definitions
✓ Schemas validated ✓ Schemas validated
+5 -1
View File
@@ -2,6 +2,7 @@ import { type PermissionBaseline, parseModuleManifest } from "@ekklesia/contract
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_MODULES_UI_READ,
CORE_PERMISSION_DECLARATIONS, CORE_PERMISSION_DECLARATIONS,
PermissionRegistryError, PermissionRegistryError,
} from "../src/authorization/index.js"; } from "../src/authorization/index.js";
@@ -63,9 +64,11 @@ describe("composePlatformPermissionRegistry", () => {
const registry = composePlatformPermissionRegistry([calendar, people]); const registry = composePlatformPermissionRegistry([calendar, people]);
expect(registry.has(CORE_AUTHORIZATION_GRANTS_MANAGE)).toBe(true); expect(registry.has(CORE_AUTHORIZATION_GRANTS_MANAGE)).toBe(true);
expect(registry.has(CORE_MODULES_UI_READ)).toBe(true);
expect(registry.has("calendar.events.read")).toBe(true); expect(registry.has("calendar.events.read")).toBe(true);
expect(registry.has("people.directory.read")).toBe(true); expect(registry.has("people.directory.read")).toBe(true);
expect(registry.declarations).toHaveLength(3); // Core's own two declarations plus one from each module.
expect(registry.declarations).toHaveLength(4);
}); });
it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => { it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => {
@@ -81,6 +84,7 @@ describe("composePlatformPermissionRegistry", () => {
expect(registry.declarations.map((declaration) => declaration.id)).toEqual([ expect(registry.declarations.map((declaration) => declaration.id)).toEqual([
CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_MODULES_UI_READ,
"calendar.events.read", "calendar.events.read",
"calendar.events.write", "calendar.events.write",
"people.directory.read", "people.directory.read",
@@ -0,0 +1,310 @@
# 0026: The minimal Ekklesia module UI host
- Status: Accepted
- Date: 2026-08-22
- Extends: [0021](0021-modules-as-hostable-products.md),
[0022](0022-module-ui-authoring-and-host-contract.md),
[0024](0024-browser-safe-ui-sdk-and-host-loader.md),
[0025](0025-trusted-module-ui-asset-publication.md)
## Context
Every link in the module UI chain existed except the one that closes it. A module could be authored
in React (ADR 0022), compiled to a declared artifact (ADR 0023), validated by discovery (ADR 0021),
published at a browser URL (ADR 0025), and loaded and mounted by a generic loader (ADR 0024) — and
nothing in Ekklesia did any of it. There was no browser application at all: `apps/api` was the only
app, `surfaces/` was a workspace pattern matching an empty set, and no code anywhere called
`loadModuleUi` outside its own tests.
Two things were missing, and only one of them is the browser.
**A browser cannot discover what to mount.** ADR 0025 publishes an artifact at
`/api/v1/module-ui/<module-id>/<version>`, but a host has to already know both halves of that
identity to ask for it, and the route is deliberately an oracle for neither. Nothing told a browser
which modules exist.
**And a browser must not work it out for itself.** A host that scanned, guessed, or hardcoded module
identities — or that rebuilt `/api/v1/modules/<id>/…` to resolve a module's relative API calls —
would put Ekklesia's private deployment details in a second place and quietly undo ADR 0022's
portability rule one layer up.
## Decision
### The shell is a surface, and it is the first one
`surfaces/shell` (`@ekklesia/shell`). ADR 0001 gives surfaces "the containing application shell,
layout, navigation, accessibility, interaction, branding, and client state — including the shell
that hosts a module's own UI," which is exactly this and nothing else. No new layer, no new app: it
is a static browser application built from its own source, and `apps/*` remains the home of
deployable *servers*.
It is called `shell` rather than `dashboard` deliberately. **This is not the Dashboard**, and naming
it after the thing it is not yet would make every later document quietly overstate what exists. A
shell is the honest description of what it is now and stays accurate when navigation is added on top.
### The host inventory is the smallest thing that makes discovery possible
```text
GET /api/v1/module-ui → what may be mounted
GET /api/v1/module-ui/<id>/<version> → one artifact's bytes (ADR 0025)
```
One prefix, one resource, two representations — the collection root of the URL space ADR 0025
already owns, rather than a second parallel namespace.
```json
{
"modules": [
{
"id": "example",
"version": "0.1.0",
"ui": { "url": "/api/v1/module-ui/example/0.1.0" },
"api": { "baseUrl": "/api/v1/modules/example" },
"permissions": ["example.records.read"]
}
]
}
```
Five fields, each because constructing a `ModuleUiHostContext` requires it. No filesystem path,
package root, package name, server/schema/migrations entrypoint, capability, dependency, or storage
bucket. And no `name`, `icon`, `description`, `category`, `folder`, or `order`: those are
*navigation* metadata, and ADR 0021 made Dashboard grouping presentation configuration an
administrator owns rather than something a module declares. A display field added here now would
become module-owned navigation before that design exists, so the temporary selector renders module
ids instead.
It is not a module management API. There is no create, update, enable, disable, install, or
configure, and no module that declares no UI. Ekklesia has no enable/disable state to report —
`ModuleLifecycleStage` remains vocabulary with no runtime state machine behind it — and reporting one
would mean inventing it.
### Both URLs are server-produced, and that is the point
`ui.url` comes from `moduleUiArtifactUrl` (ADR 0025) and `api.baseUrl` from `moduleApiBaseUrl`, a new
export of the module HTTP router that `moduleRoutePath` itself now builds on. Each URL shape is
therefore constructed in exactly one place in the repository, on the server, and the browser is a
consumer of both rather than a second author.
`moduleApiBaseUrl` is the less obvious half and the more important one. Without it the shell would
hardcode `/api/v1/modules/<id>` to resolve `context.api.request("records")` — the very prefix ADR
0022 forbids a module UI from spelling, reappearing in the host instead. Publication routing and the
module mount prefix can now both change without touching browser or module code.
### The module set is the publication registry, never a rescan
The inventory calls `registry.list()` — the exact accepted set ADR 0025 serves artifacts from:
modules that survived discovery and declare `entrypoints.ui`, captured after the module runtime
started. It performs no discovery, reads no manifest, and touches no filesystem, so the inventory and
the artifact route cannot disagree about what exists. Order is the registry's own deterministic
discovery order.
`PublishableModuleUi` gained the module's manifest-declared `permissions` so the inventory can
evaluate hints without reaching back into the manifest and re-deriving publishability — which would
put the publication policy in two places.
### The inventory is authenticated; the artifact stays anonymous
ADR 0025 made artifact bytes anonymous because fetching compiled UI code confers no authority, and
was careful that the route never became an enumeration oracle: unknown module, no UI, and wrong
version collapse into one indistinguishable `404`.
**This endpoint is that enumeration**, so serving it anonymously would hand over exactly what that
route withholds. It requires a new Core permission, `core.modules.ui.read`, baseline
`authenticated`: any signed-in user of the installation, and nobody else.
`authenticated` rather than `restricted` on purpose. A shell that rendered nothing until an operator
granted every user a permission would be unusable, and each module UI already gates its own content
on its own permissions. Enforcement goes through the same `requirePermission` middleware every other
protected route uses — no second browser-auth mechanism — which also resolves the principal once and
keeps a denial (`403`) distinct from an infrastructure failure (`503`).
### Permission hints are server-evaluated, module-scoped, and granted-only
For each listed module the server evaluates that module's **own declared permissions** against the
caller, through Core's one authorization evaluator, and returns only the granted ids. Baselines, the
platform-owner bypass, grant expiry, and deny-by-default therefore behave exactly as they do on a
real request; there is no second, browser-only interpretation of what a permission means.
Three properties matter. Nothing defaults to `true`. Nothing is inferred from a client-side role
name. And no module learns anything about the viewer's authorization anywhere else in the platform —
not Core's permissions, not another module's.
An evaluation failure is a `503`, never an empty hint set: a hint that silently read "denied" during
a database outage would blank the UI and look like a permissions problem.
**These are presentation hints, not authority.** The end-to-end test proves the distinction directly:
a viewer holding no grant gets `permissions: []`, and calling the module's API anyway — exactly what
a buggy or hostile UI would do — is still refused `403` by the server.
### The host context is built from server-derived data, and nothing else
`createModuleUiContext` produces the four members ADR 0022 defined: `moduleId` and the API client
from the inventory descriptor, `principal` from the existing `/api/v1/principal` narrowed to the
browser-owned `ModuleUiPrincipal`, and `permissions` from the granted set. Bootstrap is two requests
against endpoints that already existed or were added here, in one explicit sequence, with no hidden
globals — the API origin is passed in as a plain argument, so exactly one file in the package touches
`window`.
The principal is narrowed rather than forwarded: a server that later returns an email, a role list,
or a session token adds nothing to what module UI receives.
### Module-relative API resolution is enforced, not just documented
`context.api.request("records")` becomes `<origin>/api/v1/modules/example/records`, with the module's
own `RequestInit` forwarded whole and `credentials: "include"` imposed — session handling is the
host's job and a module UI never sees or supplies a token.
Containment is checked with the URL parser rather than string inspection, because the parser is what
a real `fetch` would use: `"../calendar/records"` normalizes into another module's API, and a check
that only looked at the input string would miss it. A traversal, a protocol-relative `//host`, or an
absolute URL is refused before it becomes a request.
This is a portability boundary, not a security one. Module UI is trusted same-realm code and could
call `fetch` directly; refusing here keeps a module honest about addressing only its own backend, so
the same artifact still runs under a standalone host. Server authorization is unaffected either way.
### One frame per selection, which is what defeats the stale-load race
The shell renders `ModuleUiFrame` with a `key` of the module's identity, so selecting a different
module discards the component **and its container element** rather than reusing one across two
modules. A slow import that resolves after the selection changed therefore mounts into a node already
detached from the document, and the frame's own `cancelled` flag unmounts it immediately.
Both halves are load-bearing, and both are proven by removing them: without the key, a stale load
wipes the newer selection's content; without the `cancelled` unmount, a stale module stays running in
a detached container with no handle to stop it.
### The host owns the container, the loading state, and every failure
The mount target is a `<div>` the shell creates inside its own layout — never the page root, the
header, or the module list. Loading and error states render as siblings, so a module can never own
the shell's pre-mount or failure state.
Every failure path — inventory, principal, import, export validation, identity mismatch, mount — is
caught and turned into a fixed, host-owned message. Nothing from the caught error reaches the DOM:
not a URL, not a path, not a stack. The error object goes to the browser console, where ADR 0024
intends the loader's `cause` to be read. A broken module costs its own panel and nothing else; the
shell keeps rendering and another module still mounts.
Lifecycle semantics are the loader's, not re-implemented: a failed mount registers no cleanup, and a
throwing cleanup is swallowed during teardown and never re-invoked.
### No static module imports, enforced at two levels
The shell imports no module, and its bundle is checked for it. At source level, no import may name a
module, a template, or any `@ekklesia/*` package other than `@ekklesia/module-ui-host` and
`@ekklesia/module-ui-sdk`. At bundle level — the level that would fail invisibly — the emitted
application must contain exactly **one** dynamic import, the loader's own `(url) => import(url)`, and
no module id, no `/api/v1/modules`, no `eval`, no `new Function`, and no `createObjectURL`.
The bundle check exists because the source check cannot catch the dangerous case. A dynamic import a
bundler managed to resolve statically would look correct, pass every other test, and silently make
newly installed modules unloadable until the shell was rebuilt.
### The surface boundary excludes `@ekklesia/contracts`
A surface may now depend only on browser-safe shared roles plus dev-time tooling. `contracts` is
deliberately absent even though the boundaries table called it a "public contract" package: its
barrel pulls Drizzle's types, and with them Node's global declarations, into any program that imports
it — measured in ADR 0024, which removed exactly that import from `@ekklesia/module-ui-sdk` to close
the browser boundary. Allowing it for surfaces would reopen the same hole one layer up. `module-sdk`
re-exports that barrel and is excluded for the same reason.
A surface talks to the server over HTTP and validates what arrives. The shell therefore declares its
own descriptor type and parses the inventory strictly rather than importing the server's type; the
end-to-end test feeds a real server response through that parser, so the two declarations are proven
to agree instead of merely looking similar.
### The host build is not the module UI build
Both use esbuild; they share no configuration, contract, or output. `@ekklesia/module-ui-build`
compiles a *module's* declared artifact and is owned by the platform so module authors write no
bundler configuration; `scripts/build-browser.mjs` builds *this application* and knows nothing about
manifests. Installed module UIs are never bundled into the shell — there is no import for a bundler
to follow — and the API never builds frontend source at run time.
## Alternatives
- **Putting the shell in `apps/*`.** Rejected: `apps/*` is for deployable composition roots, and ADR
0001 already assigns the containing application shell to a surface. A static SPA needs no server
composition root.
- **Calling it `surfaces/dashboard`.** Rejected: it would name the thing this pass explicitly does
not build, and every document describing it would then overstate what exists.
- **Nesting the inventory under `/api/v1/modules`.** Rejected: that root reads as a general module
management API and invites exactly the growth this endpoint must not have.
- **A separate per-module context endpoint** returning principal and permissions at mount time.
Rejected: it adds a request and a second endpoint to deliver data the inventory already has to
compute, and the inventory is per-principal regardless.
- **Returning every declared permission with an `allowed` boolean.** Rejected: granted-only is
smaller, answers `has()` exactly, and does not enumerate a module's full permission surface to
someone who holds none of it.
- **Returning the platform's whole granted permission set.** Rejected: no module has any business
learning what the viewer may do elsewhere, and the manifest already scopes the useful answer.
- **An anonymous inventory.** Rejected: it is precisely the enumeration ADR 0025 withheld from the
anonymous artifact route, and serving it would undo that decision by the back door.
- **A hand-rolled "must be signed in" check** instead of a Core permission. Rejected: it would be a
second auth mechanism, and it would have to re-derive `requirePermission`'s own `403`-vs-`503`
distinction correctly. A permission with an `authenticated` baseline is that check, declared.
- **Letting the shell build `/api/v1/modules/<id>` itself.** Rejected as the most tempting wrong
answer: it moves Ekklesia's private prefix into a second codebase and makes the module API mount
point unchangeable in practice.
- **Vite for the host build.** Rejected for now: esbuild is already this repository's browser bundler
and its only allowed build script, and a second toolchain buys HMR that a proof does not need.
- **A client router.** Rejected: a selected-module state is sufficient, and host routing versus
module routing is a design this pass should not settle by accident.
- **`react-router-dom`.** Rejected outright — modules are denied it precisely because routing belongs
to the host, and adopting it here for a two-item selector would prejudge that design.
- **Clearing the container between selections instead of keying the frame.** Rejected: it leaves one
element shared between two modules, which is exactly the state a stale load exploits.
- **Rendering the loader's error code in the UI.** Rejected for now: the codes are sanitized and
useful, but they are developer diagnostics, and the console already carries the whole error.
## Consequences
Ekklesia has a browser host that mounts module-owned UI it knew nothing about at build time. The
chain is proven end to end against real code at every step: a module fixture on disk, Core's real
discovery, its real module runtime *started*, the real publication registry, the real inventory
endpoint, the shell's own bootstrap and context construction, the generic loader, a mount, a
module-relative API call resolved onto Ekklesia's mount prefix, and the server still enforcing
authorization.
**This is not the Dashboard.** There is no navigation model, grouping, folders, icons, ordering, home
screen, design system, or routing. The selector is a list of module ids. What exists is the
architecture beneath a Dashboard, not a Dashboard.
Node cannot `import()` an `http:` URL, so the end-to-end proof is split honestly rather than faked:
the artifact is fetched over the real publication route and the **bytes it returned** are handed to
the loader through a `data:` URL. No test reads the artifact off the filesystem and calls that a
proof of publication.
Four things changed outside the new code. `createApiApp` mounts one more route. `PublishableModuleUi`
carries declared permissions. `moduleApiBaseUrl` is exported. And Core has a second permission, which
moved five existing assertions about "exactly one Core permission" — each updated to name both
permissions rather than to loosen a count.
A note on cost: the inventory evaluates every listed module's declared permissions on every request,
and a `restricted` one costs two database reads. For a handful of modules that is unremarkable; a
platform with many modules and many declarations would want the evaluator to answer in batches. It is
measured work, not a hidden one.
The shell sets no Content-Security-Policy. It needs none of the things a strict one forbids — no
`eval`, no `new Function`, no `blob:`, no inline `<script>` — and a same-origin `import()` of a
published artifact works under `script-src 'self'`. React inline styles do require
`style-src-attr 'unsafe-inline'`. Choosing and serving the header belongs to a deployment that does
not exist yet.
## Deferred
Not implemented or promised here: **the Dashboard** — navigation, grouping and folders, ordering,
icons, a home screen, module categories, configurable or administrator-owned navigation, and the
display metadata each would need in the inventory; a design system, theme tokens, or component
library; client-side routing; stylesheet, image, and font asset publication; React runtime sharing
between host and modules; a production Content-Security-Policy and the deployment that serves the
built shell; sign-in and sign-out UI (the shell detects an anonymous viewer and says so, but Better
Auth's endpoints are driven by nothing yet); a standalone host; module installation, upload,
marketplace, hot loading, signing, or enable/disable state; and every real feature module.
The permission-id typing gap carried since ADR 0022 is unchanged and now has a second consumer:
`permissions.has` takes a plain string in the SDK, and the inventory's `permissions` array is
`string[]` on the wire, so a typo in either still silently hides a control.
+1
View File
@@ -34,3 +34,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0023: Module UI build pipeline](0023-module-ui-build-pipeline.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 - [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 - [0025: Trusted module UI asset publication](0025-trusted-module-ui-asset-publication.md) — Accepted
- [0026: The minimal Ekklesia module UI host](0026-minimal-ekklesia-module-ui-host.md) — Accepted
+30 -3
View File
@@ -240,9 +240,36 @@ so the route is not an enumeration oracle. Caching is `no-cache` with a strong `
`immutable`, because without an installer a version can be rebuilt with different bytes. The server `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. 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, **Phase 6C pass 2 builds the browser host that consumes it.**
no module metadata endpoint, and no code that calls the URL helper outside its own tests. A design [ADR 0026](decisions/0026-minimal-ekklesia-module-ui-host.md) adds `surfaces/shell`
system, stylesheet and general asset publication, and the standalone host remain future work. (`@ekklesia/shell`), the repository's first surface: a small React application, built to static files
by its own esbuild script, that discovers installed module UIs and mounts them.
The missing piece was discovery. `GET /api/v1/module-ui` — the collection root of the same prefix the
artifact route already owns — returns, per publishable module, its id and version, the server-produced
artifact URL, the server-produced root of the module's own API, and the subset of that module's
declared permissions the caller actually holds. Nothing else: no filesystem paths, and no display or
navigation metadata, since ADR 0021 keeps Dashboard grouping an administrator's presentation
configuration. The module set is the publication registry, never a rescan, so the inventory and the
artifact route cannot disagree. It requires `core.modules.ui.read` (baseline `authenticated`) because
it is precisely the enumeration ADR 0025 withheld from the anonymous artifact route.
Both URLs being server-produced is the load-bearing part. `moduleUiArtifactUrl` and the newly exported
`moduleApiBaseUrl` are each the single construction of their URL shape, so the browser is a consumer
of both — it never rebuilds `/api/v1/modules/<id>`, the prefix ADR 0022 forbids a module UI from
spelling, and neither prefix is pinned by having a second author.
The shell builds a `ModuleUiHostContext` entirely from server-derived data, resolves a module's
relative API calls onto its own API root (refusing anything that escapes it), and hands the artifact
URL to `@ekklesia/module-ui-host`. It statically imports no module: one frame per selection defeats
the stale-load race, the host owns the container and every loading and error state, and the bundle is
checked to contain exactly one dynamic import with no module identity baked in. Permission hints come
from Core's own evaluator and stay presentation-only — server authorization is proven unchanged by a
test in which a UI holding no grant calls its API anyway and is refused.
**This is not the Dashboard.** No navigation, grouping, icons, ordering, design system, or routing;
the selector lists module ids. A production CSP and a deployment for the built shell, sign-in UI,
general asset publication, and the standalone host remain future work.
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
are all unchanged. are all unchanged.
+16 -2
View File
@@ -8,7 +8,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app | | `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries | | `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, public contracts, React, and external backend libraries | | `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, public contracts, React, and external backend libraries |
| `surfaces/*` | UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, public contracts, and design packages | | `surfaces/*` | The application shell, UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, the module UI SDK and host loader, design packages, and a browser framework |
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts | | `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level | | `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
| `templates/module` | A small example backend module | The same dependencies allowed for any module | | `templates/module` | A small example backend module | The same dependencies allowed for any module |
@@ -47,7 +47,21 @@ These rules map platform ownership to filesystem and package boundaries. The exe
- A publication helper accepts module **identity** only. Neither Core's registry nor the API's URL - 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 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. to reach a second file inside a module package.
- A surface never imports Core or a feature module. It uses public API/client contracts. - A surface never imports Core or a feature module. It uses public API/client contracts, and reaches
the server over HTTP.
- A surface may depend only on browser-safe shared roles (`api-client`, `auth-client`,
`design-tokens`, `module-ui-host`, `module-ui-sdk`, `surface-sdk`, `ui`) plus dev-time `testing`
and `tooling`. **`@ekklesia/contracts` is deliberately excluded**, despite being a "public
contract" package: its barrel pulls Drizzle's types, and with them Node's global declarations, into
any program that imports it — measured in
[ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md), which removed exactly that
import from `@ekklesia/module-ui-sdk` to close the browser boundary. `module-sdk` re-exports the
same barrel and is excluded for the same reason. A surface declares its own types for the wire and
validates what arrives ([ADR 0026](decisions/0026-minimal-ekklesia-module-ui-host.md)).
- The browser host (`surfaces/shell`) statically imports no module. Every installed module UI it can
mount arrives at run time as a URL from the module UI inventory, is fetched by the generic loader,
and is never bundled into the shell. Its own build (esbuild, owned by that package) is a separate
concern from `@ekklesia/module-ui-build`, which compiles a *module's* declared artifact.
- Separate surfaces do not import each other's internals. - Separate surfaces do not import each other's internals.
- Builder code composes contracts and surface renderer identifiers; it does not import module or - Builder code composes contracts and surface renderer identifiers; it does not import module or
surface internals. surface internals.
+55
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 /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). | | `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). | | `/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` | Ekklesia | The browser host's module UI inventory: which module UIs may be mounted, where to fetch each, where each module's API lives, and the viewer's granted permissions for it. Requires `core.modules.ui.read` (baseline `authenticated`). See [Module UI inventory](#module-ui-inventory). |
| `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). | | `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 Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
@@ -451,6 +452,60 @@ 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 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"}`. restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`.
## Module UI inventory
```text
GET /api/v1/module-ui
```
The one query a browser host makes before it can mount anything
([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). It is the collection
root of the same resource the artifact route below serves: one prefix, two representations.
```json
{
"modules": [
{
"id": "example",
"version": "0.1.0",
"ui": { "url": "/api/v1/module-ui/example/0.1.0" },
"api": { "baseUrl": "/api/v1/modules/example" },
"permissions": ["example.records.read"]
}
]
}
```
- **Five fields, each because a `ModuleUiHostContext` needs it.** No filesystem path, package root,
package name, server/schema/migrations entrypoint, capability, dependency, or storage bucket. And
no `name`, `icon`, `description`, `category`, `folder`, or `order`: those are navigation metadata,
and [ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md) keeps Dashboard
grouping an administrator's presentation configuration rather than a module declaration.
- **Both URLs are server-produced.** `ui.url` comes from `moduleUiArtifactUrl` and `api.baseUrl` from
`moduleApiBaseUrl`, so each URL shape is constructed in exactly one place in the repository. A
browser never rebuilds `/api/v1/module-ui/<id>/<version>` or `/api/v1/modules/<id>`, which is what
lets either change without touching host or module code.
- **The module set is the publication registry**, not a rescan: modules that survived discovery and
declare `entrypoints.ui`, captured after the module runtime started. A module with no UI is absent.
Order is the registry's own deterministic discovery order.
- **Authenticated, unlike the artifact route.** Requires `core.modules.ui.read`, baseline
`authenticated` — any signed-in user, and nobody else. The artifact bytes are anonymous because
fetching them confers no authority, but that route is deliberately not an enumeration oracle, and
this endpoint *is* that enumeration. `authenticated` rather than `restricted` because a shell that
rendered nothing until an operator granted every user a permission would be unusable.
- **`permissions` is the subset of that module's own declared permissions the caller holds**,
evaluated server-side by Core's one authorization evaluator — so baselines, the platform-owner
bypass, grant expiry, and deny-by-default behave exactly as on a real request. Granted ids only,
scoped to the module: no module learns the viewer's authorization state anywhere else in the
platform. **These are presentation hints, never authority**; every module route stays enforced by
`requirePermission` regardless of what a browser was told.
- **Failures.** Anonymous or unpermitted callers get `403 {"code":"AUTHORIZATION_DENIED"}` from the
shared middleware. An authorization-evaluation outage is `503
{"code":"AUTHORIZATION_EVALUATION_FAILED"}` rather than an empty hint set, because a hint that
silently read "denied" during an outage would blank the UI and look like a permissions problem.
- **Per-principal**, so `Cache-Control: no-store` and `Vary: Origin, Cookie`, exactly like
`/api/v1/principal`. `GET` only; no other method is routed.
## Module UI asset publication ## Module UI asset publication
A module may declare `entrypoints.ui`, a compiled browser ESM artifact A module may declare `entrypoints.ui`, a compiled browser ESM artifact
+26
View File
@@ -126,6 +126,32 @@ may be published or mounted. A module UI that mounts has gained no privilege it
— its server half already runs in the API process with the API's database authority — and server APIs — 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. remain the sole authority for authorization regardless of what a browser loaded.
### The browser host mounts module UI; it grants nothing
Ekklesia's shell (`surfaces/shell`) discovers installed module UIs through
`GET /api/v1/module-ui` and mounts them with the generic loader
([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). Three properties are
security-relevant.
**The inventory is authenticated, deliberately.** Module UI *bytes* are anonymous because fetching
compiled code confers no authority, and that route is carefully not an enumeration oracle. The
inventory is that enumeration — it lists which module products an installation runs — so it requires
`core.modules.ui.read` (baseline `authenticated`): any signed-in user, and nobody else. It carries no
filesystem path, package root, or entrypoint.
**Permission hints are computed by the server and remain hints.** For each listed module the API
evaluates that module's *own* declared permissions against the caller using Core's one authorization
evaluator, and returns only the granted ids — never a default of `true`, never inferred from a
client-side role, and never another module's or Core's authorization state. Every module route stays
enforced by `requirePermission` regardless; the test suite proves it by having a UI that was told
"denied" call its API anyway and still receive `403`.
**The host executes trusted module code in its own page and does not pretend otherwise.** The loader
performs a same-origin dynamic `import()` — no `eval`, no `new Function`, no `blob:` URL — and the
shell owns the container, the loading state, and every error, so a failing module costs its own panel
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
JavaScript, exactly as described above.
### Client-side permission checks are not a security boundary ### Client-side permission checks are not a security boundary
A host may tell a module UI what permissions it believes the viewer holds A host may tell a module UI what permissions it believes the viewer holds
+43 -15
View File
@@ -10,12 +10,12 @@ design-token contract, error boundaries, and trusted-origin/CORS policy are Core
application's. A module consumes those; it never redefines or bypasses them. application's. A module consumes those; it never redefines or bypasses them.
> **What exists today:** you can author a module UI in React, build it into the compiled artifact your > **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 > manifest declares, have Core validate it, have Ekklesia publish it at a browser URL, and have
> `@ekklesia/module-ui-host` can load and mount. **What does not exist is a host that does so** — no > Ekklesia's browser shell discover and mount it — the loop closes end to end. **What does not exist
> Dashboard shell, no navigation, no standalone host, and nothing in Ekklesia that renders your UI to > is the Dashboard**: no navigation, grouping, icons, ordering, theme, or design system, and no
> a user. See [Declaring a UI entrypoint](#declaring-a-ui-entrypoint) and > standalone host. See [Declaring a UI entrypoint](#declaring-a-ui-entrypoint),
> [How Ekklesia publishes your artifact](#how-ekklesia-publishes-your-artifact) for exactly what is > [How Ekklesia publishes your artifact](#how-ekklesia-publishes-your-artifact), and
> and is not supported. > [What actually mounts your UI](#what-actually-mounts-your-ui).
> **Before you publish or install a module:** modules are trusted compiled backend code. Your > **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 > module runs arbitrary JavaScript inside the API process and shares its PostgreSQL role, and
@@ -385,12 +385,14 @@ 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"` — another module. An unsupported framework aliased behind an allowed name — `"react": "npm:vue@3"` —
is resolved and rejected by its real target. is resolved and rejected by its real target.
> **Your artifact is now served, but nothing mounts it.** Ekklesia publishes an installed module's > **Your artifact is served, and Ekklesia's shell mounts it.** The platform publishes your compiled UI
> compiled UI artifact at `GET /api/v1/module-ui/<module-id>/<version>` > at `GET /api/v1/module-ui/<module-id>/<version>`
> ([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)), which > ([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)) and its browser
> `@ekklesia/module-ui-host` can load. **But no Ekklesia host calls it**: there is no Dashboard shell, > shell discovers and mounts it
> no navigation, and no standalone host, so your UI is proven by tests and reachable by URL, not > ([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). That shell is a
> rendered anywhere in production. See [How a host loads your UI](#how-a-host-loads-your-ui). > module list and a mount target, not a Dashboard. See
> [How a host loads your UI](#how-a-host-loads-your-ui) and
> [What actually mounts your UI](#what-actually-mounts-your-ui).
### What this does and does not do today ### What this does and does not do today
@@ -413,7 +415,8 @@ You do not write this — a host does — but knowing the shape explains what yo
```text ```text
compiled artifact (dist/ui/index.js) compiled artifact (dist/ui/index.js)
↓ asset publication — host-specific; Ekklesia serves it at ↓ asset publication — host-specific; Ekklesia serves it at
↓ GET /api/v1/module-ui/<module-id>/<version> ↓ GET /api/v1/module-ui/<module-id>/<version>, and its shell finds it
↓ through GET /api/v1/module-ui
browser-loadable module URL browser-loadable module URL
↓ loadModuleUi({ source, container, context }) ↓ loadModuleUi({ source, container, context })
dynamic import → parseModuleUiExport → identity checked against the manifest dynamic import → parseModuleUiExport → identity checked against the manifest
@@ -472,8 +475,33 @@ The response carries `Content-Type: text/javascript; charset=utf-8`, `X-Content-
nosniff`, `Cache-Control: no-cache`, and a strong `ETag`, so rebuilding your module at the same 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. 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 ### What actually mounts your UI
> shell, no navigation, and no code that requests your UI. That is the next pass.
Ekklesia's browser shell (`surfaces/shell`) asks the server which module UIs exist, and mounts them
([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)):
```text
GET /api/v1/module-ui → { id, version, ui.url, api.baseUrl, permissions }
↓
loadModuleUi(ui.url) → your default export, identity-checked against your manifest
↓
mount(container, context)
```
Three things follow for you:
- **Your module appears automatically once it is installed and discovered.** The shell has no list of
modules and imports none; yours arrives from the inventory at run time. You register nothing.
- **`context.permissions` reflects a real server decision** about *your* module's declared
permissions for the current viewer — not a default, and not the viewer's permissions elsewhere. It
is still only a hint; your backend route is what actually protects anything.
- **`context.api.request(path)` reaches only your own module's API.** The host resolves it onto your
API root; a path that escapes it (`"../other-module/records"`, an absolute URL) is refused before
it becomes a request. Keep asking for relative paths and your artifact stays hostable elsewhere.
> **This is not the Dashboard.** The shell is a module list and a mount target. There is no
> navigation, grouping, icons, ordering, theme, or design system yet, and the selector shows your
> module's id — the inventory deliberately carries no display metadata until that design exists.
### What a module UI will never own ### What a module UI will never own
+46
View File
@@ -55,6 +55,9 @@ importers:
'@ekklesia/module-ui-host': '@ekklesia/module-ui-host':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/module-ui-host version: link:../../packages/module-ui-host
'@ekklesia/shell':
specifier: workspace:*
version: link:../../surfaces/shell
'@ekklesia/typescript-config': '@ekklesia/typescript-config':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/typescript-config version: link:../../packages/typescript-config
@@ -228,6 +231,49 @@ importers:
packages/typescript-config: {} packages/typescript-config: {}
surfaces/shell:
dependencies:
'@ekklesia/module-ui-host':
specifier: workspace:*
version: link:../../packages/module-ui-host
'@ekklesia/module-ui-sdk':
specifier: workspace:*
version: link:../../packages/module-ui-sdk
react:
specifier: 19.2.8
version: 19.2.8
react-dom:
specifier: 19.2.8
version: 19.2.8([email protected])
devDependencies:
'@biomejs/biome':
specifier: 2.5.3
version: 2.5.3
'@ekklesia/typescript-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/node':
specifier: 24.13.3
version: 24.13.3
'@types/react':
specifier: 19.2.18
version: 19.2.18
'@types/react-dom':
specifier: 19.2.4
version: 19.2.4(@types/[email protected])
esbuild:
specifier: 0.28.1
version: 0.28.1
happy-dom:
specifier: 20.0.11
version: 20.0.11
typescript:
specifier: 7.0.2
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
templates/module: templates/module:
dependencies: dependencies:
'@ekklesia/module-sdk': '@ekklesia/module-sdk':
+24
View File
@@ -235,6 +235,30 @@ function boundaryRule(from: Location, to: Location): string | undefined {
if (to.layer === "surface" && from.owner !== to.owner) { if (to.layer === "surface" && from.owner !== to.owner) {
return "surfaces-are-independent"; return "surfaces-are-independent";
} }
// A surface is browser code, so the roles it may reach are the browser-safe ones plus dev-time
// tooling. `contracts` is deliberately absent even though it is a "public contract" package:
// its barrel pulls Drizzle's types, and with them Node's global declarations, into any program
// that imports it — measured in ADR 0024, which removed exactly that import from
// `@ekklesia/module-ui-sdk` to close the browser boundary. Allowing it here would reopen the
// same hole one layer up. `module-sdk` re-exports that barrel and is excluded for the same
// reason. A surface talks to the server over HTTP and validates what arrives; it does not share
// the server's type graph.
if (
to.layer === "shared" &&
![
"api-client",
"auth-client",
"design-tokens",
"module-ui-host",
"module-ui-sdk",
"surface-sdk",
"testing",
"tooling",
"ui",
].includes(to.role ?? "")
) {
return "surfaces-use-public-contracts";
}
} }
if (from.layer === "builder" && ["app", "core", "module", "surface"].includes(to.layer)) { if (from.layer === "builder" && ["app", "core", "module", "surface"].includes(to.layer)) {
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<title>Ekklesia</title>
</head>
<body>
<!-- The shell's root. A module UI never receives this element: it is given a container the
shell creates inside its own layout. -->
<div id="ekklesia-host"></div>
<!-- A real module script, from the same origin, with no inline script anywhere on the page:
a deployment can therefore apply a strict script-src without exceptions, and the runtime
`import()` of a published module artifact stays same-origin under it. -->
<script src="./app.js" type="module"></script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@ekklesia/shell",
"version": "0.1.0",
"private": true,
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json && node scripts/build-browser.mjs",
"dev": "node scripts/build-browser.mjs --serve",
"lint": "biome lint .",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
},
"dependencies": {
"@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@ekklesia/typescript-config": "workspace:*",
"@types/node": "24.13.3",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"esbuild": "0.28.1",
"happy-dom": "20.0.11",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"ekklesia": {
"layer": "surface"
}
}
+75
View File
@@ -0,0 +1,75 @@
import { copyFile, mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import esbuild from "esbuild";
/**
* Builds the Ekklesia shell into a static browser application.
*
* This is **not** the module UI build pipeline, and the two must not be conflated. ADR 0023's
* `@ekklesia/module-ui-build` compiles a *module's* `src/ui/index.tsx` into the artifact its
* manifest declares, is owned by the platform so module authors write no bundler configuration, and
* runs when a module is packaged. This script builds *this application*, is owned by this package
* alone, and knows nothing about manifests. They happen to share a bundler; they share no
* configuration, no contract, and no output.
*
* Installed module UIs are deliberately **not** bundled in. The shell has no import of any module,
* so there is nothing for esbuild to reach: every module artifact is fetched at run time from the
* URL the inventory returned. That is the property that makes modules independently installable, and
* bundling one would silently destroy it.
*/
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const outputDirectory = path.join(packageDirectory, "dist", "browser");
const serve = process.argv.includes("--serve");
/**
* Empty means "the API is on this page's own origin" — the expected production shape. Set it to an
* absolute origin for split-origin development (`EKKLESIA_API_BASE_URL=http://127.0.0.1:3000`), and
* add this page's own origin to `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` so the API reflects it for CORS
* and the session cookie travels.
*/
const apiBaseUrl = process.env.EKKLESIA_API_BASE_URL ?? "";
/** @type {import("esbuild").BuildOptions} */
const buildOptions = {
absWorkingDir: packageDirectory,
// Everything the shell needs is bundled; a browser resolves nothing at run time except the module
// artifacts it is told about, which are real URLs rather than bare specifiers.
bundle: true,
define: {
__EKKLESIA_API_BASE_URL__: JSON.stringify(apiBaseUrl),
// React reads this at module scope and a browser has no `process`.
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
},
entryPoints: [path.join(packageDirectory, "src", "browser", "main.tsx")],
format: "esm",
jsx: "automatic",
logLevel: "info",
// Unminified, matching the module UI build: an operator is expected to be able to read what their
// installation actually serves, and nothing else in this repository minifies.
minify: false,
outfile: path.join(outputDirectory, "app.js"),
platform: "browser",
sourcemap: true,
// Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree.
target: "es2022",
};
await rm(outputDirectory, { force: true, recursive: true });
await mkdir(outputDirectory, { recursive: true });
await copyFile(path.join(packageDirectory, "index.html"), path.join(outputDirectory, "index.html"));
if (serve) {
const context = await esbuild.context(buildOptions);
await context.watch();
const { host, port } = await context.serve({ servedir: outputDirectory });
console.log(`Ekklesia shell (development) on http://${host}:${port}`);
console.log(
apiBaseUrl === ""
? "API origin: this page's own origin. Set EKKLESIA_API_BASE_URL for a split-origin API."
: `API origin: ${apiBaseUrl}`,
);
} else {
await esbuild.build(buildOptions);
console.log(`Built the Ekklesia shell into ${path.relative(packageDirectory, outputDirectory)}`);
}
+175
View File
@@ -0,0 +1,175 @@
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { type ReactElement, useEffect, useState } from "react";
import {
bootstrapHost,
type EkklesiaHostConfig,
type HostBootstrap,
HostProtocolError,
} from "../host-protocol.js";
import { ModuleUiFrame } from "./ModuleUiFrame.js";
type BootstrapState =
| { readonly status: "loading" }
| { readonly status: "ready"; readonly bootstrap: HostBootstrap }
| { readonly status: "signed-out" }
| { readonly status: "failed" };
export interface HostAppProps {
/** Must be a stable reference: bootstrap re-runs whenever it changes, so a fresh object literal
* built during render would re-fetch forever. The browser entry point builds it once at module
* scope. */
readonly config: EkklesiaHostConfig;
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
readonly importModule?: ModuleUiImporter;
}
const shellStyles = {
aside: { borderRight: "1px solid #e7e5e4", minWidth: "12rem", padding: "1rem" },
header: {
borderBottom: "1px solid #e7e5e4",
fontWeight: 600,
gridColumn: "1 / -1",
padding: "0.75rem 1rem",
},
layout: {
color: "#1c1917",
display: "grid",
fontFamily: "system-ui, sans-serif",
gridTemplateColumns: "auto 1fr",
minHeight: "100vh",
},
main: { padding: "1rem" },
message: { color: "#57534e", padding: "1rem" },
} as const;
function moduleKey(id: string, version: string): string {
return `${id}@${version}`;
}
/**
* The minimal Ekklesia browser shell: sign-in awareness, a module list, and one mount target.
*
* **This is not the Dashboard.** There is no navigation model, no grouping or folders, no icons, no
* ordering, no home screen, no design system, and no routing — a selected-module state is the whole
* of it, and the selector renders module ids because the inventory deliberately carries no display
* metadata yet (ADR 0021 keeps that an administrator's presentation configuration, not a module
* declaration). What this proves is the architecture underneath: that a module discovered on the
* server, published as an artifact, and described by the inventory can be mounted here without the
* shell knowing anything about it in advance.
*
* Nothing about any specific module appears in this file, or anywhere in this package. There is no
* import of a module, no module id, no registry, no allowlist, and no fallback bundle. Every module
* the shell can mount arrives at run time from the inventory the server returned.
*/
export function HostApp(props: HostAppProps): ReactElement {
const [state, setState] = useState<BootstrapState>({ status: "loading" });
const [selectedKey, setSelectedKey] = useState<string | undefined>(undefined);
const { config, importModule } = props;
useEffect(() => {
const abort = new AbortController();
setState({ status: "loading" });
void bootstrapHost(config, abort.signal)
.then((bootstrap) => {
if (abort.signal.aborted) {
return;
}
setState(
bootstrap.principal.kind === "anonymous"
? { status: "signed-out" }
: { bootstrap, status: "ready" },
);
setSelectedKey(
bootstrap.modules[0]
? moduleKey(bootstrap.modules[0].id, bootstrap.modules[0].version)
: undefined,
);
})
.catch((error: unknown) => {
if (abort.signal.aborted) {
return;
}
console.error("[ekklesia] host bootstrap failed", error);
setState(
error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED"
? { status: "signed-out" }
: { status: "failed" },
);
});
return () => abort.abort();
}, [config]);
if (state.status === "loading") {
return <p style={shellStyles.message}>Loading Ekklesia…</p>;
}
if (state.status === "signed-out") {
return (
<p data-testid="host-signed-out" style={shellStyles.message}>
Sign in to Ekklesia to use the module host.
</p>
);
}
if (state.status === "failed") {
return (
<p data-testid="host-error" style={shellStyles.message}>
Ekklesia could not be reached. Refresh to try again.
</p>
);
}
const { bootstrap } = state;
const selected = bootstrap.modules.find(
(module) => moduleKey(module.id, module.version) === selectedKey,
);
return (
<div style={shellStyles.layout}>
<header style={shellStyles.header}>Ekklesia</header>
<aside style={shellStyles.aside}>
{bootstrap.modules.length === 0 ? (
<p data-testid="host-empty" style={{ color: "#57534e", margin: 0 }}>
No installed module provides a user interface.
</p>
) : (
<nav style={{ display: "grid", gap: "0.25rem" }}>
{bootstrap.modules.map((module) => {
const key = moduleKey(module.id, module.version);
return (
<button
data-testid={`select-${module.id}`}
key={key}
onClick={() => setSelectedKey(key)}
style={{
background: key === selectedKey ? "#e7e5e4" : "transparent",
border: "none",
cursor: "pointer",
font: "inherit",
padding: "0.375rem 0.5rem",
textAlign: "left",
}}
type="button"
>
{module.id}
</button>
);
})}
</nav>
)}
</aside>
<main style={shellStyles.main}>
{selected ? (
// Keyed by module identity, so switching selection discards this frame — and its container
// element — entirely rather than reusing one across two modules. See `ModuleUiFrame` for
// why that is what makes a slow, stale load harmless.
<ModuleUiFrame
config={config}
descriptor={selected}
{...(importModule ? { importModule } : {})}
key={moduleKey(selected.id, selected.version)}
principal={bootstrap.principal}
/>
) : undefined}
</main>
</div>
);
}
@@ -0,0 +1,115 @@
import { loadModuleUi, type ModuleUiImporter } from "@ekklesia/module-ui-host";
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
import { type ReactElement, useEffect, useRef, useState } from "react";
import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../host-protocol.js";
import { createModuleUiContext, moduleUiSourceUrl } from "../module-context.js";
type FrameStatus = "loading" | "ready" | "failed";
export interface ModuleUiFrameProps {
readonly config: EkklesiaHostConfig;
readonly descriptor: HostModuleUiDescriptor;
readonly principal: ModuleUiPrincipal;
/** Test-only seam, forwarded to the generic loader so a test can hold an import pending and prove
* the stale-load rule. Production composition leaves it unset and the loader's own
* `(url) => import(url)` runs. */
readonly importModule?: ModuleUiImporter;
}
/**
* Mounts exactly one module UI into a container this component owns, and tears it down again.
*
* ## Host-owned container, module-owned contents
*
* The `<div>` below belongs to the shell. The module fills it and must return an unmount that
* empties it; it never receives the shell's header, the module list, the page root, or the document.
* The loading and error states are rendered as siblings, so a module can never own the shell's
* pre-mount or failure state — which is the point of the host owning them.
*
* ## One frame per selection, which is what defeats the stale-load race
*
* The parent renders this with a `key` of the module's identity, so selecting a different module
* unmounts this component entirely and creates a fresh one with a **fresh container element**. A
* slow import that resolves after the selection changed therefore mounts into a node that is already
* detached from the document and is immediately unmounted by this component's own cleanup — it can
* never render over, or into, the newer selection's container. The `cancelled` flag makes that
* explicit rather than incidental: a load that finishes after cleanup ran is torn down at once and
* never reaches `setStatus`, so it also cannot resurrect a stale status on a component React has
* already discarded.
*
* ## Failure is the host's to render
*
* Every way this can fail — the artifact will not fetch, the bundle is not a module, its declared
* identity does not match the manifest, or `mount` throws — is caught here and turned into a fixed,
* host-owned message. Nothing from the caught error reaches the DOM: not a URL, not a path, not a
* stack. The error object goes to the browser console, which is where a developer's diagnostics
* belong and where ADR 0024 intends the loader's `cause` to be read. The rest of the shell keeps
* rendering, so a broken module costs its own panel and nothing else.
*/
export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<FrameStatus>("loading");
const { config, descriptor, importModule, principal } = props;
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
let cancelled = false;
let mounted: Awaited<ReturnType<typeof loadModuleUi>> | undefined;
setStatus("loading");
void (async () => {
try {
const instance = await loadModuleUi({
container,
context: createModuleUiContext({ config, descriptor, principal }),
...(importModule ? { importModule } : {}),
source: {
moduleId: descriptor.id,
url: moduleUiSourceUrl(config, descriptor),
version: descriptor.version,
},
});
if (cancelled) {
// Selection already moved on. Release it immediately rather than leaving a module running
// in a detached container with no handle to stop it.
await instance.unmount().catch(() => undefined);
return;
}
mounted = instance;
setStatus("ready");
} catch (error) {
console.error("[ekklesia] a module UI failed to load", error);
if (!cancelled) {
setStatus("failed");
}
}
})();
return () => {
cancelled = true;
// Idempotent by contract (ADR 0024), and a throwing cleanup is swallowed here: teardown is
// already in progress, and a module that fails to clean up must not take the shell with it.
void mounted?.unmount().catch(() => undefined);
};
}, [config, descriptor, importModule, principal]);
return (
<section aria-live="polite" style={{ display: "grid", gap: "0.75rem" }}>
{status === "loading" ? (
<p data-testid="module-loading" style={{ color: "#57534e", margin: 0 }}>
Loading module…
</p>
) : undefined}
{status === "failed" ? (
<p data-testid="module-error" style={{ color: "#9f1239", margin: 0 }}>
This module’s interface could not be loaded. Try another module, or ask an administrator
to check the installation.
</p>
) : undefined}
<div data-testid="module-container" ref={containerRef} />
</section>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { createRoot } from "react-dom/client";
import type { EkklesiaHostConfig } from "../host-protocol.js";
import { HostApp } from "./HostApp.js";
/**
* Replaced at build time by `scripts/build-browser.mjs` with a JSON string literal: the API origin
* when `EKKLESIA_API_BASE_URL` was set for the build, and `""` otherwise. Declared rather than read
* from `process.env`, because a browser has no `process` and the substitution must be visible here
* instead of hidden in bundler configuration.
*/
declare const __EKKLESIA_API_BASE_URL__: string;
/**
* The one place the shell touches a browser global.
*
* An empty build-time value means the API is served from the same origin as this page, which is the
* expected production shape; a non-empty one is the split-origin development shape the platform's
* trusted-origin configuration already supports (`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` must list this
* page's origin for the session cookie to travel). Everything downstream receives the resolved
* origin as a plain argument, so no other file in this package reads `window`.
*/
const config: EkklesiaHostConfig = {
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
};
const container = document.getElementById("ekklesia-host");
if (!container) {
throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
}
createRoot(container).render(<HostApp config={config} />);
+247
View File
@@ -0,0 +1,247 @@
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
/**
* How this shell reaches the Ekklesia API.
*
* `apiBaseUrl` is an absolute **origin** (`https://ekklesia.example.test`, `http://127.0.0.1:3000`)
* rather than something read from a global. The browser entry point supplies it — from a build-time
* value when the shell and the API are served from different origins in development, and from
* `window.location.origin` otherwise — so every function below is a pure function of its arguments
* and can be exercised without a DOM, a `window`, or a page. Nothing here reads a global.
*/
export interface EkklesiaHostConfig {
readonly apiBaseUrl: string;
/** Injected so tests can drive the real protocol against a real in-process app without a socket.
* Production composition leaves this unset and the platform `fetch` is used. */
readonly fetch?: typeof fetch;
}
/** Root-relative platform paths. The shell never builds a module UI artifact URL or a module API
* URL — the server produces both and hands them over in the inventory — so these two are the only
* URLs it knows, and they are the two that describe the platform itself rather than any module. */
const PRINCIPAL_PATH = "/api/v1/principal";
const MODULE_UI_INVENTORY_PATH = "/api/v1/module-ui";
export type HostProtocolErrorCode =
/** The caller is not signed in, or lacks `core.modules.ui.read`. */
| "HOST_UNAUTHENTICATED"
/** The API answered, but not with something this shell understands. */
| "HOST_RESPONSE_INVALID"
/** The API could not be reached, or failed. */
| "HOST_REQUEST_FAILED"
/** A module UI asked for something outside its own API root. */
| "MODULE_API_PATH_INVALID";
const errorMessages: Readonly<Record<HostProtocolErrorCode, string>> = Object.freeze({
HOST_REQUEST_FAILED: "Ekklesia could not be reached.",
HOST_RESPONSE_INVALID: "Ekklesia returned a response this host does not understand.",
HOST_UNAUTHENTICATED: "You need to sign in to Ekklesia to use this page.",
MODULE_API_PATH_INVALID: "A module asked for a path outside its own API.",
});
/**
* Carries a stable code and a fixed, user-safe message — never a URL, a response body, a server
* path, or a caught cause — matching every other Ekklesia error boundary. The originating error is
* attached as `cause` for browser-console diagnostics and is never rendered.
*/
export class HostProtocolError extends Error {
readonly code: HostProtocolErrorCode;
constructor(code: HostProtocolErrorCode, options?: { readonly cause?: unknown }) {
super(errorMessages[code], options);
this.name = "HostProtocolError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
}
}
/**
* One module UI this host may mount, exactly as the server described it.
*
* This mirrors the server's `HostModuleUiDescriptor` (`apps/api/src/module-ui-inventory.ts`) rather
* than importing it: a surface may not depend on an app, and the type on either side of an HTTP
* boundary should be validated on arrival rather than assumed. `parseHostModuleUiInventory` below is
* that validation, and the end-to-end test feeds a real server response through it so the two
* declarations are proven to agree instead of merely looking similar.
*/
export interface HostModuleUiDescriptor {
readonly id: string;
readonly version: string;
/** Server-produced. The shell resolves it against `apiBaseUrl` and hands it to the generic loader
* unchanged; it never constructs, parses, or pattern-matches this URL. */
readonly uiUrl: string;
/** Server-produced root of this module's own HTTP API. */
readonly apiBaseUrl: string;
/** Granted ids only, already evaluated server-side. Presentation hints, never authority. */
readonly permissions: readonly string[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function nestedString(value: unknown, key: string): string | undefined {
return isRecord(value) ? nonEmptyString(value[key]) : undefined;
}
/**
* Both URLs in a descriptor must be **root-relative** paths, exactly as the server produces them.
*
* Enforced rather than assumed. The shell resolves each against the configured API origin, so an
* absolute or protocol-relative value arriving here would silently redirect a module's script import
* or its API calls to another origin. This does not defend against a compromised server — one could
* serve anything at the real URL — but it does pin the wire contract, so a proxy rewrite, a
* misconfiguration, or a future server-side bug fails loudly here instead of quietly changing where
* browser code is fetched from.
*/
function rootRelativePath(value: unknown, key: string): string | undefined {
const candidate = nestedString(value, key);
if (candidate === undefined || !candidate.startsWith("/") || candidate.startsWith("//")) {
return undefined;
}
return candidate;
}
/**
* Strict, total validation of the inventory document. Anything unexpected — a missing field, a
* non-array `modules`, a permission entry that is not a string — fails the whole parse rather than
* yielding a half-built descriptor, because a descriptor with a missing URL would surface much later
* as an inexplicable mount failure.
*/
export function parseHostModuleUiInventory(payload: unknown): readonly HostModuleUiDescriptor[] {
const modules = isRecord(payload) ? payload.modules : undefined;
if (!Array.isArray(modules)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze(
modules.map((entry): HostModuleUiDescriptor => {
const id = nestedString(entry, "id");
const version = nestedString(entry, "version");
const uiUrl = isRecord(entry) ? rootRelativePath(entry.ui, "url") : undefined;
const apiBaseUrl = isRecord(entry) ? rootRelativePath(entry.api, "baseUrl") : undefined;
const permissions = isRecord(entry) ? entry.permissions : undefined;
if (
id === undefined ||
version === undefined ||
uiUrl === undefined ||
apiBaseUrl === undefined ||
!Array.isArray(permissions) ||
permissions.some((permission) => typeof permission !== "string")
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
apiBaseUrl,
id,
permissions: Object.freeze([...(permissions as string[])]),
uiUrl,
version,
});
}),
);
}
/** `/api/v1/principal`'s two documented shapes, narrowed to the browser-owned `ModuleUiPrincipal`
* the UI SDK defines. Nothing else the endpoint might ever return is carried into module UI. */
export function parseHostPrincipal(payload: unknown): ModuleUiPrincipal {
if (!isRecord(payload)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
if (payload.kind === "anonymous") {
return { kind: "anonymous" };
}
const userId = nonEmptyString(payload.userId);
if (payload.kind === "authenticated" && userId !== undefined) {
return { kind: "authenticated", userId };
}
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
/** Resolves a root-relative platform path against the configured API origin. The one place the
* shell turns a server-produced path into an absolute URL. */
export function resolveApiUrl(config: EkklesiaHostConfig, rootRelativePath: string): string {
return new URL(rootRelativePath, config.apiBaseUrl).href;
}
/**
* Every request this shell makes to Ekklesia carries credentials.
*
* Same-origin that is simply the session cookie. Split-origin — the development shape the platform's
* trusted-origin configuration already supports — it is what makes the cookie travel at all, and it
* works only because the API reflects an exact trusted origin with
* `Access-Control-Allow-Credentials: true` and never a wildcard. This does not weaken that policy;
* it is the client half of it.
*/
async function requestPlatformJson(
config: EkklesiaHostConfig,
rootRelativePath: string,
signal?: AbortSignal,
): Promise<unknown> {
const performRequest = config.fetch ?? fetch;
let response: Response;
try {
response = await performRequest(resolveApiUrl(config, rootRelativePath), {
credentials: "include",
headers: { accept: "application/json" },
...(signal ? { signal } : {}),
});
} catch (error) {
throw new HostProtocolError("HOST_REQUEST_FAILED", { cause: error });
}
if (response.status === 401 || response.status === 403) {
throw new HostProtocolError("HOST_UNAUTHENTICATED");
}
if (!response.ok) {
throw new HostProtocolError("HOST_REQUEST_FAILED");
}
try {
return (await response.json()) as unknown;
} catch (error) {
throw new HostProtocolError("HOST_RESPONSE_INVALID", { cause: error });
}
}
/** Who is viewing. Anonymous is a normal answer, not a failure: the shell renders a sign-in prompt
* rather than an error, and never asks for the inventory it would be refused. */
export async function loadHostPrincipal(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<ModuleUiPrincipal> {
return parseHostPrincipal(await requestPlatformJson(config, PRINCIPAL_PATH, signal));
}
/** Which module UIs this viewer may mount, where to fetch each, and what they may do inside it. */
export async function loadModuleUiInventory(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<readonly HostModuleUiDescriptor[]> {
return parseHostModuleUiInventory(
await requestPlatformJson(config, MODULE_UI_INVENTORY_PATH, signal),
);
}
export interface HostBootstrap {
readonly principal: ModuleUiPrincipal;
readonly modules: readonly HostModuleUiDescriptor[];
}
/**
* The shell's whole startup sequence, in one explicit function: resolve who is viewing, then ask
* what they may mount. Sequential rather than parallel on purpose — an anonymous viewer is told to
* sign in and the inventory is never requested, so the shell does not depend on a `403` to discover
* something `/api/v1/principal` already said plainly.
*/
export async function bootstrapHost(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<HostBootstrap> {
const principal = await loadHostPrincipal(config, signal);
if (principal.kind === "anonymous") {
return { modules: Object.freeze([]), principal };
}
return { modules: await loadModuleUiInventory(config, signal), principal };
}
+29
View File
@@ -0,0 +1,29 @@
/**
* The framework-free half of the Ekklesia shell: how it talks to the platform and how it builds a
* module UI host context. Deliberately the only public surface of this package.
*
* The React shell itself (`src/browser/`) is not exported. It is an application, bundled straight to
* a browser artifact by this package's own build; exporting components would invite another package
* to depend on Ekklesia's shell internals, which is exactly the coupling the surface boundary
* exists to prevent. What *is* exported is the protocol — validated wire parsing, context
* construction, module API resolution — so the end-to-end test can drive the real host code against
* a real server instead of a reimplementation of it.
*/
export {
bootstrapHost,
type EkklesiaHostConfig,
type HostBootstrap,
type HostModuleUiDescriptor,
HostProtocolError,
type HostProtocolErrorCode,
loadHostPrincipal,
loadModuleUiInventory,
parseHostModuleUiInventory,
parseHostPrincipal,
resolveApiUrl,
} from "./host-protocol.js";
export {
type CreateModuleUiContextOptions,
createModuleUiContext,
moduleUiSourceUrl,
} from "./module-context.js";
+104
View File
@@ -0,0 +1,104 @@
import type {
ModuleUiApiClient,
ModuleUiHostContext,
ModuleUiPrincipal,
} from "@ekklesia/module-ui-sdk";
import {
type EkklesiaHostConfig,
type HostModuleUiDescriptor,
HostProtocolError,
resolveApiUrl,
} from "./host-protocol.js";
const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/u;
/**
* Turns the relative path a module UI asks for into the real Ekklesia URL, and refuses anything
* that is not the module's own API.
*
* `context.api.request("records")` becomes `<origin>/api/v1/modules/<module-id>/records`. The
* `/api/v1/modules/<module-id>` half is **not** written here: it arrives from the server in the
* inventory descriptor (`apiBaseUrl`), so Ekklesia's module mount prefix is spelled in exactly one
* place in the whole repository (`moduleApiBaseUrl`, `apps/api/src/module-http-routes.ts`). The
* browser is a consumer of that URL, never a second author of it.
*
* Containment is enforced with the URL parser rather than string inspection, because the parser is
* what a real `fetch` would use: `"../calendar/records"` normalizes to another module's API, and a
* check that only looked at the input string would miss it. Anything that resolves outside this
* module's own root — a traversal, a protocol-relative `//host`, an absolute URL, an encoded
* separator — is refused with `MODULE_API_PATH_INVALID`.
*
* This is a portability boundary, not a security one. Module UI is trusted same-realm code (ADR
* 0022) and could call `fetch` directly; refusing here is what keeps a module honest about
* addressing only its own backend, so the same artifact still runs under a standalone host. Server
* authorization is unaffected either way: every module route stays behind `requirePermission`.
*/
function createModuleApiClient(
config: EkklesiaHostConfig,
descriptor: HostModuleUiDescriptor,
): ModuleUiApiClient {
const performRequest = config.fetch ?? fetch;
const moduleRoot = new URL(resolveApiUrl(config, descriptor.apiBaseUrl));
return Object.freeze({
async request(path: string, init?: RequestInit): Promise<Response> {
if (ABSOLUTE_URL_PATTERN.test(path) || path.startsWith("//")) {
throw new HostProtocolError("MODULE_API_PATH_INVALID");
}
const relative = path.replace(/^\/+/u, "");
const target = new URL(
relative === "" ? moduleRoot.pathname : `${moduleRoot.pathname}/${relative}`,
moduleRoot,
);
const contained =
target.origin === moduleRoot.origin &&
(target.pathname === moduleRoot.pathname ||
target.pathname.startsWith(`${moduleRoot.pathname}/`));
if (!contained) {
throw new HostProtocolError("MODULE_API_PATH_INVALID");
}
// The module's own `init` is forwarded whole — method, headers, body, signal — and only
// `credentials` is imposed, because session handling is the host's job and a module UI never
// sees or supplies a token (ADR 0022). A module cannot opt out of it.
return performRequest(target.href, { ...init, credentials: "include" });
},
});
}
export interface CreateModuleUiContextOptions {
readonly config: EkklesiaHostConfig;
readonly descriptor: HostModuleUiDescriptor;
readonly principal: ModuleUiPrincipal;
}
/**
* Builds the `ModuleUiHostContext` (ADR 0022) Ekklesia hands a module UI at mount time.
*
* All four members come from server-derived data: the module id and API root from the inventory
* descriptor, the principal from `/api/v1/principal`, and the permission hints from the server's own
* evaluation of that module's declared permissions against that principal. Nothing is guessed,
* defaulted to `true`, or inferred from a client-side role name.
*
* The permission set is the module's own and only the module's own. A module UI cannot use this to
* discover what the viewer may do elsewhere in the platform, and `has` returning `true` is a
* statement about what the server said a moment ago — a hint for hiding a control, never a
* substitute for the server check that actually decides.
*/
export function createModuleUiContext(options: CreateModuleUiContextOptions): ModuleUiHostContext {
const granted = new Set(options.descriptor.permissions);
return Object.freeze({
api: createModuleApiClient(options.config, options.descriptor),
moduleId: options.descriptor.id,
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
principal: options.principal,
});
}
/** The absolute URL the generic loader should import this module's artifact from — the descriptor's
* server-produced path resolved against the configured API origin, and nothing more. */
export function moduleUiSourceUrl(
config: EkklesiaHostConfig,
descriptor: HostModuleUiDescriptor,
): string {
return resolveApiUrl(config, descriptor.uiUrl);
}
+415
View File
@@ -0,0 +1,415 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
// The shell deliberately reports failures to the browser console; silence them so a passing run
// is quiet, and so a test can assert *that* it reported without the noise.
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
consoleError.mockRestore();
});
function descriptor(id: string, version = "0.1.0", permissions: readonly string[] = []) {
return {
api: { baseUrl: `/api/v1/modules/${id}` },
id,
permissions,
ui: { url: `/api/v1/module-ui/${id}/${version}` },
version,
};
}
interface PlatformStub {
readonly modules?: ReadonlyArray<ReturnType<typeof descriptor>>;
readonly principal?: unknown;
readonly inventoryStatus?: number;
readonly inventoryThrows?: boolean;
}
function platformFetch(stub: PlatformStub = {}): typeof fetch {
const modules = stub.modules ?? [descriptor("example")];
return vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return new Response(
JSON.stringify(stub.principal ?? { kind: "authenticated", userId: "user-1" }),
{ headers: { "content-type": "application/json" }, status: 200 },
);
}
if (url.endsWith("/api/v1/module-ui")) {
if (stub.inventoryThrows) {
throw new TypeError("network down");
}
return new Response(JSON.stringify({ modules }), {
headers: { "content-type": "application/json" },
status: stub.inventoryStatus ?? 200,
});
}
return new Response("{}", { status: 404 });
}) as unknown as typeof fetch;
}
interface ArtifactOptions {
readonly mountThrows?: boolean;
readonly unmountThrows?: boolean;
/** Records the container each mount received, so a test can prove a stale mount never touched the
* newer selection's element. */
readonly mountedInto?: Element[];
}
/** A module UI artifact namespace, exactly the shape the generic loader parses. */
function artifact(id: string, version: string, options: ArtifactOptions = {}) {
return {
default: {
id,
mount(element: Element, context: ModuleUiHostContext) {
options.mountedInto?.push(element);
if (options.mountThrows) {
throw new Error("module mount failed");
}
element.textContent = `${context.moduleId} mounted`;
return () => {
if (options.unmountThrows) {
throw new Error("module cleanup failed");
}
element.textContent = "";
};
},
version,
},
};
}
async function render(
config: EkklesiaHostConfig,
importModule?: (url: string) => Promise<unknown>,
): Promise<void> {
await act(async () => {
root.render(<HostApp config={config} {...(importModule ? { importModule } : {})} />);
});
}
function text(): string {
return container.textContent ?? "";
}
function query(testId: string): Element | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
describe("Ekklesia shell — bootstrap", () => {
it("renders the module list the inventory returned", async () => {
await render({
apiBaseUrl,
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
});
expect(query("select-example")).not.toBeNull();
expect(query("select-calendar")).not.toBeNull();
});
it("asks an anonymous viewer to sign in, and mounts nothing", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ principal: { kind: "anonymous" } }) });
expect(query("host-signed-out")).not.toBeNull();
expect(query("module-container")).toBeNull();
});
it("treats a refused inventory as signed out rather than as a crash", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ inventoryStatus: 403 }) });
expect(query("host-signed-out")).not.toBeNull();
});
it("renders a host-owned error when the platform cannot be reached", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ inventoryThrows: true }) });
expect(query("host-error")).not.toBeNull();
expect(text()).not.toContain("network down");
});
it("says so plainly when no installed module provides a UI", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ modules: [] }) });
expect(query("host-empty")).not.toBeNull();
});
});
describe("Ekklesia shell — mount lifecycle", () => {
it("mounts the first module into the host's own container", async () => {
const mountedInto: Element[] = [];
await render({ apiBaseUrl, fetch: platformFetch() }, async () =>
artifact("example", "0.1.0", { mountedInto }),
);
expect(query("module-container")?.textContent).toBe("example mounted");
expect(query("module-loading")).toBeNull();
// The module was handed the shell's container, never the page root or the shell's own layout.
expect(mountedInto[0]).toBe(query("module-container"));
expect(mountedInto[0]).not.toBe(container);
expect(mountedInto[0]).not.toBe(document.body);
});
it("imports exactly the URL the inventory published, resolved against the API origin", async () => {
const requested: string[] = [];
await render({ apiBaseUrl, fetch: platformFetch() }, async (url) => {
requested.push(url);
return artifact("example", "0.1.0");
});
expect(requested).toEqual([`${apiBaseUrl}/api/v1/module-ui/example/0.1.0`]);
});
it("shows a host-owned loading state until the module mounts", async () => {
let release: (() => void) | undefined;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
await pending;
return artifact("example", "0.1.0");
});
expect(query("module-loading")).not.toBeNull();
expect(query("module-container")?.textContent).toBe("");
await act(async () => {
release?.();
await pending;
});
expect(query("module-loading")).toBeNull();
expect(query("module-container")?.textContent).toBe("example mounted");
});
it("unmounts the previous module when the selection changes", async () => {
const unmountedContainers: Element[] = [];
const importModule = async (url: string) => {
const id = url.includes("/calendar/") ? "calendar" : "example";
return {
default: {
id,
mount(element: Element) {
element.textContent = `${id} mounted`;
return () => {
unmountedContainers.push(element);
element.textContent = "";
};
},
version: id === "calendar" ? "2.0.0" : "0.1.0",
},
};
};
await render(
{
apiBaseUrl,
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
},
importModule,
);
expect(query("module-container")?.textContent).toBe("example mounted");
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
expect(unmountedContainers).toHaveLength(1);
expect(query("module-container")?.textContent).toBe("calendar mounted");
});
it("keeps a stale load from ever reaching the newer selection's container", async () => {
const mountedInto: Element[] = [];
const unmountedContainers: Element[] = [];
let releaseExample: (() => void) | undefined;
const examplePending = new Promise<void>((resolve) => {
releaseExample = resolve;
});
const importModule = async (url: string) => {
const id = url.includes("/calendar/") ? "calendar" : "example";
if (id === "example") {
await examplePending;
}
return {
default: {
id,
mount(element: Element) {
mountedInto.push(element);
element.textContent = `${id} mounted`;
return () => {
unmountedContainers.push(element);
element.textContent = "";
};
},
version: id === "calendar" ? "2.0.0" : "0.1.0",
},
};
};
await render(
{
apiBaseUrl,
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
},
importModule,
);
// `example` is still importing; nothing has mounted yet.
expect(mountedInto).toHaveLength(0);
// Switch to `calendar`, which resolves immediately and mounts.
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
const liveContainer = query("module-container");
expect(liveContainer?.textContent).toBe("calendar mounted");
// Only now does the stale `example` import resolve.
await act(async () => {
releaseExample?.();
await examplePending;
});
// It mounted into its own, already-discarded container — never the live one — and was torn
// down immediately, so the newer selection is untouched.
expect(liveContainer?.textContent).toBe("calendar mounted");
const staleContainer = mountedInto.find((element) => element !== liveContainer);
expect(staleContainer).toBeDefined();
expect(staleContainer?.isConnected).toBe(false);
expect(unmountedContainers).toContain(staleContainer);
});
});
describe("Ekklesia shell — failure is host-owned", () => {
it("renders its own error when the artifact cannot be imported", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
throw new Error("/srv/ekklesia/modules/example/dist/ui/index.js is missing");
});
expect(query("module-error")).not.toBeNull();
// Not a path, not a stack, not the caught message.
expect(text()).not.toContain("/srv/ekklesia");
expect(text()).not.toContain("index.js");
// The shell itself is still rendered around the failure.
expect(query("select-example")).not.toBeNull();
// The real error still reaches the browser console for a developer.
expect(consoleError).toHaveBeenCalled();
});
it("renders its own error when the artifact is not a valid module UI", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => ({ notADefault: true }));
expect(query("module-error")).not.toBeNull();
});
it("renders its own error when the artifact claims a different identity", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "9.9.9"));
expect(query("module-error")).not.toBeNull();
expect(query("module-container")?.textContent).toBe("");
});
it("renders its own error when the module's mount throws", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () =>
artifact("example", "0.1.0", { mountThrows: true }),
);
expect(query("module-error")).not.toBeNull();
});
it("stays usable after a failure: another module still mounts", async () => {
const importModule = async (url: string) => {
if (url.includes("/example/")) {
throw new Error("boom");
}
return artifact("calendar", "2.0.0");
};
await render(
{
apiBaseUrl,
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
},
importModule,
);
expect(query("module-error")).not.toBeNull();
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
expect(query("module-error")).toBeNull();
expect(query("module-container")?.textContent).toBe("calendar mounted");
});
it("survives a module whose cleanup throws, and never re-runs it", async () => {
let cleanupCalls = 0;
const importModule = async (url: string) => {
if (url.includes("/calendar/")) {
return artifact("calendar", "2.0.0");
}
return {
default: {
id: "example",
mount(element: Element) {
element.textContent = "example mounted";
return () => {
cleanupCalls += 1;
throw new Error("module cleanup failed");
};
},
version: "0.1.0",
},
};
};
await render(
{
apiBaseUrl,
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
},
importModule,
);
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
expect(cleanupCalls).toBe(1);
expect(query("module-container")?.textContent).toBe("calendar mounted");
// Switch back and away again: the throwing cleanup ran once per mount, never re-invoked for an
// instance already torn down.
await act(async () => {
(query("select-example") as HTMLButtonElement).click();
});
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
expect(cleanupCalls).toBe(2);
expect(query("module-container")?.textContent).toBe("calendar mounted");
});
});
+343
View File
@@ -0,0 +1,343 @@
import { describe, expect, it, vi } from "vitest";
import {
bootstrapHost,
type EkklesiaHostConfig,
type HostModuleUiDescriptor,
loadHostPrincipal,
loadModuleUiInventory,
parseHostModuleUiInventory,
parseHostPrincipal,
} from "../src/host-protocol.js";
import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js";
const apiBaseUrl = "https://ekklesia.example.test";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
/** A fetch double that answers the two platform paths and records every call, so the tests can
* assert both the URL the shell built and the credentials it used. */
function stubFetch(routes: Readonly<Record<string, () => Response>>) {
const calls: Array<{ init: RequestInit | undefined; url: string }> = [];
const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ init, url });
const route = Object.entries(routes).find(([path]) => url === `${apiBaseUrl}${path}`);
if (!route) {
return new Response(JSON.stringify({ code: "NOT_FOUND" }), { status: 404 });
}
return route[1]();
});
return { calls, implementation: implementation as unknown as typeof fetch };
}
const inventoryPayload = {
modules: [
{
api: { baseUrl: "/api/v1/modules/example" },
id: "example",
permissions: ["example.records.read"],
ui: { url: "/api/v1/module-ui/example/0.1.0" },
version: "0.1.0",
},
],
};
const exampleDescriptor: HostModuleUiDescriptor = {
apiBaseUrl: "/api/v1/modules/example",
id: "example",
permissions: ["example.records.read"],
uiUrl: "/api/v1/module-ui/example/0.1.0",
version: "0.1.0",
};
describe("host protocol — inventory", () => {
it("parses the server's descriptor into exactly what a host context needs", () => {
expect(parseHostModuleUiInventory(inventoryPayload)).toEqual([exampleDescriptor]);
});
it("rejects a malformed document rather than yielding a half-built descriptor", () => {
const malformed: unknown[] = [
{},
{ modules: {} },
{ modules: [{ id: "example", version: "0.1.0" }] },
{ modules: [{ ...inventoryPayload.modules[0], ui: {} }] },
{ modules: [{ ...inventoryPayload.modules[0], api: { baseUrl: "" } }] },
{ modules: [{ ...inventoryPayload.modules[0], permissions: "example.records.read" }] },
{ modules: [{ ...inventoryPayload.modules[0], permissions: [42] }] },
// Both URLs must be root-relative, so a value that would resolve against another origin is
// refused rather than silently used to fetch browser code.
{
modules: [
{ ...inventoryPayload.modules[0], ui: { url: "https://evil.example.test/x.js" } },
],
},
{ modules: [{ ...inventoryPayload.modules[0], ui: { url: "//evil.example.test/x.js" } }] },
{ modules: [{ ...inventoryPayload.modules[0], ui: { url: "relative/x.js" } }] },
{
modules: [
{ ...inventoryPayload.modules[0], api: { baseUrl: "https://evil.example.test/api" } },
],
},
];
for (const payload of malformed) {
expect(() => parseHostModuleUiInventory(payload)).toThrowError(
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
);
}
});
it("fetches the inventory from the platform path with credentials", async () => {
const { calls, implementation } = stubFetch({
"/api/v1/module-ui": () => jsonResponse(inventoryPayload),
});
const config: EkklesiaHostConfig = { apiBaseUrl, fetch: implementation };
await loadModuleUiInventory(config);
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/module-ui`);
expect(calls[0]?.init?.credentials).toBe("include");
});
it("reports an unauthenticated caller distinctly from any other failure", async () => {
for (const status of [401, 403]) {
const { implementation } = stubFetch({
"/api/v1/module-ui": () => jsonResponse({ code: "AUTHORIZATION_DENIED" }, status),
});
await expect(
loadModuleUiInventory({ apiBaseUrl, fetch: implementation }),
).rejects.toMatchObject({ code: "HOST_UNAUTHENTICATED" });
}
const { implementation } = stubFetch({
"/api/v1/module-ui": () => jsonResponse({ code: "INTERNAL_ERROR" }, 500),
});
await expect(
loadModuleUiInventory({ apiBaseUrl, fetch: implementation }),
).rejects.toMatchObject({ code: "HOST_REQUEST_FAILED" });
});
it("never renders a caught cause, a URL, or a server path in its message", async () => {
const failing = vi.fn(async () => {
throw new Error("connect ECONNREFUSED /run/postgres/.s.PGSQL.5432");
}) as unknown as typeof fetch;
const error = await loadModuleUiInventory({ apiBaseUrl, fetch: failing }).catch(
(caught: unknown) => caught,
);
expect(error).toMatchObject({ code: "HOST_REQUEST_FAILED" });
expect((error as Error).message).toBe("Ekklesia could not be reached.");
expect((error as Error).message).not.toContain("PGSQL");
});
});
describe("host protocol — principal", () => {
it("maps the platform principal onto the browser-safe SDK shape", () => {
expect(parseHostPrincipal({ kind: "anonymous" })).toEqual({ kind: "anonymous" });
expect(parseHostPrincipal({ kind: "authenticated", userId: "user-1" })).toEqual({
kind: "authenticated",
userId: "user-1",
});
});
it("carries nothing beyond kind and userId, even when the server sends more", () => {
const principal = parseHostPrincipal({
email: "[email protected]",
kind: "authenticated",
roles: ["owner"],
sessionToken: "secret",
userId: "user-1",
});
expect(Object.keys(principal).sort()).toEqual(["kind", "userId"]);
expect(JSON.stringify(principal)).not.toContain("secret");
});
it("rejects a principal it does not recognize", () => {
for (const payload of [null, {}, { kind: "authenticated" }, { kind: "owner" }]) {
expect(() => parseHostPrincipal(payload)).toThrowError(
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
);
}
});
});
describe("host protocol — bootstrap", () => {
it("resolves the principal and then the inventory", async () => {
const { calls, implementation } = stubFetch({
"/api/v1/module-ui": () => jsonResponse(inventoryPayload),
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
});
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
expect(calls.map((call) => call.url)).toEqual([
`${apiBaseUrl}/api/v1/principal`,
`${apiBaseUrl}/api/v1/module-ui`,
]);
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" });
expect(bootstrap.modules).toEqual([exampleDescriptor]);
});
it("never requests the inventory for an anonymous viewer", async () => {
const { calls, implementation } = stubFetch({
"/api/v1/module-ui": () => jsonResponse(inventoryPayload),
"/api/v1/principal": () => jsonResponse({ kind: "anonymous" }),
});
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
expect(bootstrap.principal).toEqual({ kind: "anonymous" });
expect(bootstrap.modules).toEqual([]);
expect(calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]);
});
it("sends the platform principal request with credentials too", async () => {
const { calls, implementation } = stubFetch({
"/api/v1/principal": () => jsonResponse({ kind: "anonymous" }),
});
await loadHostPrincipal({ apiBaseUrl, fetch: implementation });
expect(calls[0]?.init?.credentials).toBe("include");
});
});
describe("module UI host context", () => {
function contextFor(fetchImplementation: typeof fetch, descriptor = exampleDescriptor) {
return createModuleUiContext({
config: { apiBaseUrl, fetch: fetchImplementation },
descriptor,
principal: { kind: "authenticated", userId: "user-1" },
});
}
function recordingFetch() {
const calls: Array<{ init: RequestInit | undefined; url: string }> = [];
const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ init, url: String(input) });
return new Response("[]", { status: 200 });
});
return { calls, implementation: implementation as unknown as typeof fetch };
}
it("resolves a module-relative path onto the server-produced module API root", async () => {
const { calls, implementation } = recordingFetch();
await contextFor(implementation).api.request("records");
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/modules/example/records`);
});
it("normalizes a leading slash and preserves query strings and nested paths", async () => {
const { calls, implementation } = recordingFetch();
const api = contextFor(implementation).api;
await api.request("/records");
await api.request("records?since=2026-01-01&limit=10");
await api.request("records/abc/comments");
await api.request("");
expect(calls.map((call) => call.url)).toEqual([
`${apiBaseUrl}/api/v1/modules/example/records`,
`${apiBaseUrl}/api/v1/modules/example/records?since=2026-01-01&limit=10`,
`${apiBaseUrl}/api/v1/modules/example/records/abc/comments`,
`${apiBaseUrl}/api/v1/modules/example`,
]);
});
it("forwards the module's own RequestInit and imposes credentials", async () => {
const { calls, implementation } = recordingFetch();
await contextFor(implementation).api.request("records", {
body: JSON.stringify({ value: "one" }),
headers: { "content-type": "application/json" },
method: "POST",
});
expect(calls[0]?.init).toMatchObject({
credentials: "include",
headers: { "content-type": "application/json" },
method: "POST",
});
});
it("refuses to address anything outside the module's own API", async () => {
const { calls, implementation } = recordingFetch();
const api = contextFor(implementation).api;
const escapes = [
"../calendar/records",
"records/../../calendar/records",
"/../../v1/grants",
"https://evil.example.test/steal",
"//evil.example.test/steal",
"HTTPS://evil.example.test/steal",
"javascript:alert(1)",
];
for (const path of escapes) {
await expect(api.request(path), path).rejects.toMatchObject({
code: "MODULE_API_PATH_INVALID",
});
}
// Not one of them reached the network.
expect(calls).toEqual([]);
});
it("gives each module only its own API root", async () => {
const { calls, implementation } = recordingFetch();
const calendar: HostModuleUiDescriptor = {
apiBaseUrl: "/api/v1/modules/calendar",
id: "calendar",
permissions: [],
uiUrl: "/api/v1/module-ui/calendar/2.0.0",
version: "2.0.0",
};
await contextFor(implementation).api.request("records");
await contextFor(implementation, calendar).api.request("events");
expect(calls.map((call) => call.url)).toEqual([
`${apiBaseUrl}/api/v1/modules/example/records`,
`${apiBaseUrl}/api/v1/modules/calendar/events`,
]);
});
it("builds permission hints from the server's granted set, never a default of true", () => {
const { implementation } = recordingFetch();
const granted = contextFor(implementation).permissions;
const none = contextFor(implementation, { ...exampleDescriptor, permissions: [] }).permissions;
expect(granted.has("example.records.read")).toBe(true);
expect(granted.has("example.records.write")).toBe(false);
// A module the viewer holds nothing for gets a hint set that answers false to everything.
expect(none.has("example.records.read")).toBe(false);
expect(none.has("core.authorization.grants.manage")).toBe(false);
});
it("exposes exactly the four contract members and the browser principal", () => {
const { implementation } = recordingFetch();
const context = contextFor(implementation);
expect(Object.keys(context).sort()).toEqual(["api", "moduleId", "permissions", "principal"]);
expect(context.moduleId).toBe("example");
expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" });
});
it("resolves the artifact URL from the descriptor the server produced", () => {
expect(moduleUiSourceUrl({ apiBaseUrl }, exampleDescriptor)).toBe(
`${apiBaseUrl}/api/v1/module-ui/example/0.1.0`,
);
// Same descriptor, a different deployment origin: the shell composes, it never authors.
expect(moduleUiSourceUrl({ apiBaseUrl: "http://127.0.0.1:3000" }, exampleDescriptor)).toBe(
"http://127.0.0.1:3000/api/v1/module-ui/example/0.1.0",
);
});
});
+157
View File
@@ -0,0 +1,157 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
import { beforeAll, describe, expect, it } from "vitest";
/**
* The proof that this shell is a *generic* host rather than an application that happens to render
* some known modules.
*
* Two levels, because they fail differently. At source level, a static import of a module would be
* an architectural mistake a reviewer should catch. At bundle level, a dynamic import that a bundler
* managed to resolve statically would be an invisible one: the code would look correct, the tests
* would pass, and installing a new module would silently do nothing until the shell was rebuilt.
*
* `happy-dom` replaces the global `URL` with the DOM implementation, which `node:fs` rejects, so
* everything here works in plain filesystem paths.
*/
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const sourceDirectory = path.join(packageDirectory, "src");
/** The complete set of packages this shell may reach for. `@ekklesia/module-ui-host` is the generic
* loader and `@ekklesia/module-ui-sdk` is the browser-side contract; no other `@ekklesia/*` package
* — and no module — may appear. */
const ALLOWED_EKKLESIA_IMPORTS = new Set(["@ekklesia/module-ui-host", "@ekklesia/module-ui-sdk"]);
async function sourceFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...(await sourceFiles(entryPath)));
} else if (/\.tsx?$/u.test(entry.name)) {
files.push(entryPath);
}
}
return files.sort();
}
function importSpecifiers(source: string): string[] {
const specifiers = new Set<string>();
const patterns = [
/\b(?:import|export)\s+(?:type\s+)?[^;]*?\bfrom\s*["']([^"'\n]+)["']/gu,
/\bimport\s*["']([^"'\n]+)["']/gu,
/\bimport\s*\(\s*["']([^"'\n]+)["']\s*\)/gu,
];
for (const pattern of patterns) {
for (const match of source.matchAll(pattern)) {
if (match[1]) {
specifiers.add(match[1]);
}
}
}
return [...specifiers];
}
let files: string[];
let sources: Map<string, string>;
beforeAll(async () => {
files = await sourceFiles(sourceDirectory);
sources = new Map(
await Promise.all(files.map(async (file) => [file, await readFile(file, "utf8")] as const)),
);
});
describe("the shell is coupled to no module", () => {
it("has source files to check", () => {
expect(files.length).toBeGreaterThan(3);
});
it("imports no module, template, or workspace package beyond the two browser contracts", () => {
for (const [file, source] of sources) {
for (const specifier of importSpecifiers(source)) {
const label = `${path.relative(packageDirectory, file)} -> ${specifier}`;
expect(specifier.includes("modules/"), label).toBe(false);
expect(specifier.includes("templates/"), label).toBe(false);
// Nothing may climb out of this package at all.
expect(specifier.startsWith("../../"), label).toBe(false);
if (specifier.startsWith("@ekklesia/")) {
expect(ALLOWED_EKKLESIA_IMPORTS.has(specifier), label).toBe(true);
}
}
}
});
it("performs no dynamic import of its own: all module loading goes through the generic loader", () => {
for (const [file, source] of sources) {
// Comment lines are dropped so a doc comment may still *describe* the loader's own
// `(url) => import(url)`. The authoritative check is the bundle-level one below, which counts
// dynamic imports in the emitted output where no comment survives.
const code = source
.split("\n")
.filter((line) => !/^\s*(\*|\/\/)/u.test(line))
.join("\n");
const dynamicImports = [...code.matchAll(/\bimport\s*\(/gu)];
expect(dynamicImports, path.relative(packageDirectory, file)).toEqual([]);
}
});
});
describe("the shell builds no module knowledge into its bundle", () => {
let bundle: string;
beforeAll(async () => {
// The real production bundle configuration, produced in memory so this test never depends on a
// previous `pnpm build` having run.
const result = await build({
absWorkingDir: packageDirectory,
bundle: true,
define: {
__EKKLESIA_API_BASE_URL__: '""',
"process.env.NODE_ENV": '"production"',
},
entryPoints: [path.join(sourceDirectory, "browser", "main.tsx")],
format: "esm",
jsx: "automatic",
logLevel: "silent",
minify: false,
platform: "browser",
target: "es2022",
write: false,
});
bundle = result.outputFiles.map((file) => file.text).join("\n");
}, 60_000);
it("leaves exactly one dynamic import — the loader's — unresolved for run time", () => {
const dynamicImports = [...bundle.matchAll(/\bimport\s*\(/gu)];
// Exactly one: `@ekklesia/module-ui-host`'s `(url) => import(url)`. More would mean the shell
// acquired its own loader; fewer would mean the bundler resolved it away and installed modules
// could never be fetched at run time.
expect(dynamicImports).toHaveLength(1);
expect(bundle).toContain("(url) => import(url)");
});
it("contains no module identity and no module API prefix", () => {
// The shell learns every module id, artifact URL, and API root from the inventory at run time.
// Any of these appearing in the bundle would mean it had been baked in instead.
expect(bundle).not.toContain("/api/v1/modules");
expect(bundle).not.toContain("/api/v1/module-ui/");
expect(bundle).not.toContain("ekklesia.module.json");
expect(bundle).not.toContain("entrypoints");
});
it("spells only the two platform paths it genuinely owns", () => {
expect(bundle).toContain('"/api/v1/principal"');
expect(bundle).toContain('"/api/v1/module-ui"');
});
it("uses neither eval nor a blob URL to load module code", () => {
expect(bundle).not.toContain("new Function(");
expect(bundle).not.toMatch(/\beval\s*\(/u);
expect(bundle).not.toContain("createObjectURL");
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "@ekklesia/typescript-config/browser-react.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"types": []
},
"include": ["src/index.ts", "src/host-protocol.ts", "src/module-context.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@ekklesia/typescript-config/browser-react.json",
"compilerOptions": {
"noEmit": true,
"types": []
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@ekklesia/typescript-config/browser-react.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["tests/**/*.ts", "tests/**/*.tsx", "scripts/**/*.mjs"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "happy-dom",
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"],
passWithNoTests: false,
},
});
+1
View File
@@ -31,6 +31,7 @@ describe("repository dependency boundaries", () => {
["cross-module-import", "modules-do-not-import-other-modules"], ["cross-module-import", "modules-do-not-import-other-modules"],
["module-schema-imports-module", "modules-do-not-import-other-modules"], ["module-schema-imports-module", "modules-do-not-import-other-modules"],
["surface-imports-module", "surfaces-use-public-contracts"], ["surface-imports-module", "surfaces-use-public-contracts"],
["surface-imports-contracts", "surfaces-use-public-contracts"],
["cross-surface-import", "surfaces-are-independent"], ["cross-surface-import", "surfaces-are-independent"],
["builder-imports-module", "builder-composes-contracts"], ["builder-imports-module", "builder-composes-contracts"],
["contracts-import-core", "contracts-do-not-depend-upward"], ["contracts-import-core", "contracts-do-not-depend-upward"],
@@ -0,0 +1,11 @@
{
"name": "@ekklesia/contracts",
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "contracts"
}
}
@@ -0,0 +1 @@
export const moduleManifestSchema = {};
@@ -0,0 +1,3 @@
packages:
- "packages/*"
- "surfaces/*"
@@ -0,0 +1,14 @@
{
"name": "@ekklesia/surface-dashboard",
"version": "0.0.0",
"type": "module",
"dependencies": {
"@ekklesia/contracts": "workspace:*"
},
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "surface"
}
}
@@ -0,0 +1,3 @@
import { moduleManifestSchema } from "@ekklesia/contracts";
export const schema = moduleManifestSchema;