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