feat: mount discovered module UI in a minimal Ekklesia browser host #23
+6
-1
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Generated
+46
@@ -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':
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}`);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />);
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@ekklesia/typescript-config/browser-react.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"],
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/contracts",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "contracts"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const moduleManifestSchema = {};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "packages/*"
|
||||||
|
- "surfaces/*"
|
||||||
+14
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { moduleManifestSchema } from "@ekklesia/contracts";
|
||||||
|
|
||||||
|
export const schema = moduleManifestSchema;
|
||||||
Reference in New Issue
Block a user