feat: expose canonical module display URLs

This commit is contained in:
ekklesia
2026-09-30 18:20:40 -05:00
parent f445a83ea5
commit 3ca6b2cb32
21 changed files with 123 additions and 16 deletions
+16
View File
@@ -0,0 +1,16 @@
/**
* Canonical browser routes owned by the platform's display host.
*
* Kept separate from the route registration module so both the display host and module-UI
* inventory can publish the same path without introducing a dependency cycle.
*/
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
export function moduleDisplayPageUrl(moduleId: string): string {
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
}
export function moduleDisplayDescriptorUrl(moduleId: string): string {
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
}
+11 -10
View File
@@ -7,6 +7,13 @@ import {
requirePermission,
} from "./authorization-middleware.js";
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
export {
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
MODULE_DISPLAY_PAGE_PREFIX,
moduleDisplayDescriptorUrl,
moduleDisplayPageUrl,
} from "./module-display-path.js";
import { moduleDisplayDescriptorUrl, moduleDisplayPageUrl } from "./module-display-path.js";
import { moduleApiBaseUrl } from "./module-http-routes.js";
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
import {
@@ -18,8 +25,6 @@ import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
* `/api/v1/modules/<module-id>`, and neither string enters the module-facing UI ABI. */
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
/**
* Where the display's offline service worker is served (ADR 0043).
@@ -34,14 +39,6 @@ export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
*/
export const DISPLAY_SERVICE_WORKER_PATH = "/display/sw.js";
export function moduleDisplayPageUrl(moduleId: string): string {
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
}
export function moduleDisplayDescriptorUrl(moduleId: string): string {
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
}
/** The portable facts a browser host needs to mount this module's one display artifact. It mirrors
* the primary UI descriptor while naming the role explicitly, so a host cannot accidentally load a
* management artifact into a signage route. No outer Ekklesia route or filesystem path crosses the
@@ -50,6 +47,9 @@ export interface HostModuleDisplayDescriptor {
readonly id: string;
readonly version: string;
readonly display: { readonly url: string };
/** The public page a screen opens to mount this display. This is distinct from `display.url`,
* which is the compiled artifact imported by the platform host. */
readonly page: { readonly url: string };
readonly api: { readonly baseUrl: string };
readonly realtime: { readonly baseUrl: string };
readonly permissions: readonly string[];
@@ -157,6 +157,7 @@ async function describeDisplay(
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
display: { url: moduleDisplayUiArtifactUrl(display) },
id: display.moduleId,
page: { url: moduleDisplayPageUrl(display.moduleId) },
permissions: await grantedModulePermissionIds(
options.evaluator,
principal,
+7
View File
@@ -13,6 +13,7 @@ import {
requirePermission,
} from "./authorization-middleware.js";
import { moduleApiBaseUrl } from "./module-http-routes.js";
import { moduleDisplayPageUrl } from "./module-display-path.js";
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
import {
MODULE_UI_ASSET_PREFIX,
@@ -48,6 +49,10 @@ export interface HostModuleUiDescriptor {
* canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication
* routing can change without touching a single line of host or module code. */
readonly ui: { readonly url: string };
/** Canonical page for this module's separately declared display artifact, or `null` when the
* module publishes no display. Server-produced so neither shell nor module spells platform
* routing or infers deployment topology. */
readonly display: { readonly url: string } | null;
/** The root this module's own HTTP API is mounted at, so the host can resolve the relative paths
* a module UI asks for (`context.api.request("records")`). Server-produced for the same reason
* `ui.url` is: `/api/v1/modules/<id>` is Ekklesia's private deployment detail, and neither the
@@ -170,9 +175,11 @@ async function describeModule(
published: PublishableModuleUi,
registry: ModuleUiPublicationRegistry,
): Promise<HostModuleUiDescriptor> {
const display = registry.resolveDisplay(published.moduleId, published.version);
return {
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
contributions: describeModuleContributions(registry, published.moduleId),
display: display === undefined ? null : { url: moduleDisplayPageUrl(published.moduleId) },
id: published.moduleId,
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
+1
View File
@@ -271,6 +271,7 @@ describe("Dashboard navigation — reading", () => {
expect(livestream?.module).toEqual({
contributions: [],
api: { baseUrl: "/api/v1/modules/livestream" },
display: null,
id: "livestream",
permissions: [],
realtime: { baseUrl: "/api/v1/module-realtime/livestream" },
+9 -3
View File
@@ -274,6 +274,7 @@ describe("module display routes — authorization and descriptor", () => {
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
},
id: "announcements",
page: { url: moduleDisplayPageUrl("announcements") },
permissions: [displayPermission],
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
version: "1.4.2",
@@ -351,9 +352,14 @@ describe("module display routes — role and lifecycle separation", () => {
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
expect(
((await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { modules: unknown[] })
.modules,
).toHaveLength(1);
(
(await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
modules: Array<{ display: { url: string } | null }>;
}
).modules,
).toEqual([
expect.objectContaining({ display: { url: moduleDisplayPageUrl("announcements") } }),
]);
});
it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => {
+4 -2
View File
@@ -439,6 +439,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
expect(bootstrap.descriptor).toEqual({
apiBaseUrl: "/api/v1/modules/example",
contributions: [],
displayPageUrl: "/display/example",
id: "example",
permissions: ["example.records.read", "example.display.view"],
realtimeBaseUrl: "/api/v1/module-realtime/example",
@@ -454,8 +455,8 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
);
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
// 4. The generic loader receives the unchanged five-member browser context and mounts the
// display artifact independently of the Dashboard artifact.
// 4. The generic loader receives the host-owned browser context, including the canonical
// display page, and mounts the display artifact independently of the Dashboard artifact.
const context = createModuleUiContext({
config: harness.config,
descriptor: bootstrap.descriptor,
@@ -464,6 +465,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
expect(Object.keys(context).sort()).toEqual([
"api",
"contributions",
"display",
"moduleId",
"permissions",
"principal",
@@ -244,6 +244,7 @@ describe("module UI inventory — what it lists", () => {
{
contributions: [],
api: { baseUrl: "/api/v1/modules/example" },
display: null,
id: "example",
permissions: [],
realtime: { baseUrl: "/api/v1/module-realtime/example" },
@@ -340,6 +341,7 @@ describe("module UI inventory — what it lists", () => {
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
"api",
"contributions",
"display",
"id",
"permissions",
"realtime",
+1
View File
@@ -947,6 +947,7 @@ describe("module UI artifact publication — the generic loader consumes what is
return {
api: { request: async () => new Response(null, { status: 200 }) },
contributions: NO_MODULE_UI_CONTRIBUTIONS,
display: undefined,
moduleId: "announcements",
permissions: { has: () => false },
principal: { kind: "anonymous" as const },