feat: publish validated module UI artifacts at a platform-owned URL
Phase 6C pass 1. ADR 0024 named the one thing missing between the module UI build pipeline and the generic host loader: Core resolves entrypoints.ui to an absolute server filesystem path, and a browser needs a URL. This adds that publication layer. Core owns the policy. createModuleUiPublicationRegistry builds, from the discovered and started module inventory, the answer to "may this be published, and which exact file is it?" — publishable means the module survived discovery and declares entrypoints.ui, with no new lifecycle state and no installer. apps/api owns the transport: GET /api/v1/module-ui/<module-id>/<version>. Both segments are lookup keys, not path components. An exact match yields the canonical artifact path discovery already validated; no request value is ever composed into a filesystem path, and the route declares no wildcard segment, so traversal, encoded separators, sibling chunks, package-root files, and source maps are unreachable by construction. An installed module directory never becomes a static file root. The prefix is a sibling of /api/v1/modules/, not a child: `ui` is a legal module route segment, so a module registering GET /ui/:version would otherwise compete with the platform for one URL with routing order deciding the winner. The route requires no authentication, deliberately. Fetching the bundle confers no authority, and a cookie-gated route would break cross-origin import(), which sends none. The cost is stated rather than hidden: a caller who knows a module id and its exact version can download that bundle. The three failure modes return one identical 404 so the route is not an enumeration oracle. Caching is no-cache with a strong SHA-256 ETag and RFC 9110 If-None-Match handling, not immutable: without an installer nothing stops a version being rebuilt with different bytes. The server reads bytes and writes bytes — never imports, evaluates, or transforms the artifact. Nothing mounts a module UI. A host could now obtain a URL, which was previously impossible; there is still no Dashboard shell, navigation, or metadata endpoint. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
82cbeef437
commit
a02deb9433
@@ -4,3 +4,4 @@ export * from "./migration-discovery.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./runtime.js";
|
||||
export * from "./schema-discovery.js";
|
||||
export * from "./ui-publication.js";
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user