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 },
@@ -52,6 +52,7 @@ export default {
function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext {
return {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
display: undefined,
moduleId,
api: {
async request(path) {
@@ -33,6 +33,7 @@ let artifactSource: string;
function ekklesiaContext(requested: string[]): ModuleUiHostContext {
return {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
display: undefined,
moduleId: "example",
api: {
async request(pathname) {
@@ -50,6 +51,7 @@ function ekklesiaContext(requested: string[]): ModuleUiHostContext {
function standaloneContext(requested: string[]): ModuleUiHostContext {
return {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
display: { url: "https://example.test/display" },
moduleId: "example",
api: {
async request(pathname) {
@@ -75,6 +75,14 @@ export interface ModuleUiHostContext {
/** The module this UI belongs to, as the host knows it. Supplied so a module never has to hardcode
* or re-derive its own identity from a URL. */
readonly moduleId: string;
/**
* The host-owned public page for this module's dedicated display, when one exists.
*
* The URL is already absolute and deployment-correct. A module may show, open, or copy it, but
* must never construct a platform route or combine its own id with a browser origin. A standalone
* host can provide its own display page here without imitating Ekklesia's routing.
*/
readonly display: ModuleUiDisplayLocation | undefined;
/** The only supported way for module UI to reach its own backend. See {@link ModuleUiApiClient}. */
readonly api: ModuleUiApiClient;
/** Who is viewing, for presentation decisions only. See {@link ModuleUiPrincipal} for why this is
@@ -96,6 +104,10 @@ export interface ModuleUiHostContext {
readonly contributions: ModuleUiContributions;
}
export interface ModuleUiDisplayLocation {
readonly url: string;
}
/**
* A module UI's access to its own module's HTTP API, addressed **relative to the module's own API
* root** — never as an absolute path.
@@ -186,6 +186,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
requestedUrls,
context: {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
display: undefined,
moduleId,
api,
principal: { kind: "anonymous" },
@@ -211,6 +212,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
requestedUrls,
context: {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
display: { url: "https://announcements.example.org/display" },
moduleId,
api,
principal: { kind: "anonymous" },
+24
View File
@@ -105,6 +105,8 @@ export interface HostModuleUiDescriptor {
/** Server-produced. The shell resolves it against `apiBaseUrl` and hands it to the generic loader
* unchanged; it never constructs, parses, or pattern-matches this URL. */
readonly uiUrl: string;
/** Server-produced page for this module's display, when it publishes one. */
readonly displayPageUrl: string | undefined;
/** Server-produced root of this module's own HTTP API. */
readonly apiBaseUrl: string;
/** Server-produced root for this module's own live channels. */
@@ -214,8 +216,24 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
const uiUrl = isRecord(entry) ? rootRelativePath(entry.ui, "url") : undefined;
const apiBaseUrl = isRecord(entry) ? rootRelativePath(entry.api, "baseUrl") : undefined;
const realtimeBaseUrl = isRecord(entry) ? rootRelativePath(entry.realtime, "baseUrl") : undefined;
const displayPageUrl =
isRecord(entry) && entry.display !== null ? rootRelativePath(entry.display, "url") : undefined;
const permissions = isRecord(entry) ? entry.permissions : undefined;
if (
!hasExactKeys(entry, [
"api",
"contributions",
"display",
"id",
"permissions",
"realtime",
"ui",
"version",
]) ||
!hasExactKeys(entry.api, ["baseUrl"]) ||
!hasExactKeys(entry.realtime, ["baseUrl"]) ||
!hasExactKeys(entry.ui, ["url"]) ||
(entry.display !== null && !hasExactKeys(entry.display, ["url"])) ||
id === undefined ||
version === undefined ||
uiUrl === undefined ||
@@ -229,6 +247,7 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
return Object.freeze({
apiBaseUrl,
contributions: parseContributions(isRecord(entry) ? entry.contributions : undefined),
displayPageUrl,
id,
permissions: Object.freeze([...(permissions as string[])]),
realtimeBaseUrl,
@@ -323,12 +342,14 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
"contributions",
"display",
"id",
"page",
"permissions",
"realtime",
"version",
]) ||
!hasExactKeys(entry.api, ["baseUrl"]) ||
!hasExactKeys(entry.display, ["url"]) ||
!hasExactKeys(entry.page, ["url"]) ||
!hasExactKeys(entry.realtime, ["baseUrl"])
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
@@ -338,6 +359,7 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
const version = nonEmptyString(entry.version);
const apiBaseUrl = rootRelativePath(entry.api, "baseUrl");
const uiUrl = rootRelativePath(entry.display, "url");
const displayPageUrl = rootRelativePath(entry.page, "url");
const realtimeBaseUrl = rootRelativePath(entry.realtime, "baseUrl");
const permissions = entry.permissions;
if (
@@ -347,6 +369,7 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
!isSemanticVersion(version) ||
apiBaseUrl === undefined ||
uiUrl === undefined ||
displayPageUrl === undefined ||
realtimeBaseUrl === undefined ||
!Array.isArray(permissions) ||
permissions.some((permission) => typeof permission !== "string" || permission.length === 0) ||
@@ -359,6 +382,7 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
module: Object.freeze({
apiBaseUrl,
contributions: parseContributions(entry.contributions),
displayPageUrl,
id,
permissions: Object.freeze([...(permissions as string[])]),
realtimeBaseUrl,
+4
View File
@@ -350,6 +350,10 @@ export function createModuleUiContext(options: CreateModuleUiContextOptions): Mo
options.descriptor,
options.importModule,
),
display:
options.descriptor.displayPageUrl === undefined
? undefined
: Object.freeze({ url: resolveApiUrl(options.config, options.descriptor.displayPageUrl) }),
moduleId: options.descriptor.id,
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
principal: options.principal,
@@ -52,6 +52,7 @@ function descriptorPayload(
api: { baseUrl: `/api/v1/modules/${id}` },
display: { url: `/api/v1/module-ui/${id}/${version}/display` },
id,
page: { url: `/display/${id}` },
permissions: [`${id}.slideshow.view`],
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
version,
@@ -240,6 +241,7 @@ describe("display host — full-viewport boundary", () => {
expect(Object.keys(seenContext ?? {}).sort()).toEqual([
"api",
"contributions",
"display",
"moduleId",
"permissions",
"principal",
@@ -334,6 +336,7 @@ describe("display host — full-viewport boundary", () => {
module: {
contributions: [],
api: { baseUrl: "/api/v1/modules/announcements" },
display: { url: "/display/announcements" },
id: "announcements",
permissions: ["announcements.slideshow.view"],
realtime: { baseUrl: "/api/v1/module-realtime/announcements" },
@@ -22,6 +22,7 @@ function displayPayload(overrides: Record<string, unknown> = {}): {
api: { baseUrl: "/api/v1/modules/announcements" },
display: { url: "/api/v1/module-ui/announcements/1.2.0/display" },
id: "announcements",
page: { url: "/display/announcements" },
permissions: ["announcements.slideshow.view"],
realtime: { baseUrl: "/api/v1/module-realtime/announcements" },
version: "1.2.0",
@@ -45,6 +46,7 @@ describe("display host protocol — strict descriptor", () => {
module: {
apiBaseUrl: "/api/v1/modules/announcements",
contributions: [],
displayPageUrl: "/display/announcements",
id: "announcements",
permissions: ["announcements.slideshow.view"],
realtimeBaseUrl: "/api/v1/module-realtime/announcements",
+1
View File
@@ -43,6 +43,7 @@ function entry(id: string, name: string, version = "0.1.0", permissions: readonl
module: {
contributions: [],
api: { baseUrl: `/api/v1/modules/${id}` },
display: null,
id,
permissions,
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
+18 -1
View File
@@ -39,6 +39,7 @@ const inventoryPayload = {
{
contributions: [],
api: { baseUrl: "/api/v1/modules/example" },
display: { url: "/display/example" },
id: "example",
permissions: ["example.records.read"],
realtime: { baseUrl: "/api/v1/module-realtime/example" },
@@ -51,6 +52,7 @@ const inventoryPayload = {
const calendarDescriptor: HostModuleUiDescriptor = {
contributions: [],
apiBaseUrl: "/api/v1/modules/calendar",
displayPageUrl: undefined,
id: "calendar",
permissions: [],
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
@@ -72,6 +74,7 @@ const navigationPayload = {
module: {
contributions: [],
api: { baseUrl: "/api/v1/modules/calendar" },
display: null,
id: "calendar",
permissions: [],
realtime: { baseUrl: "/api/v1/module-realtime/calendar" },
@@ -86,6 +89,7 @@ const navigationPayload = {
const exampleDescriptor: HostModuleUiDescriptor = {
contributions: [],
apiBaseUrl: "/api/v1/modules/example",
displayPageUrl: "/display/example",
id: "example",
permissions: ["example.records.read"],
realtimeBaseUrl: "/api/v1/module-realtime/example",
@@ -409,6 +413,7 @@ describe("module UI host context", () => {
const calendar: HostModuleUiDescriptor = {
contributions: [],
apiBaseUrl: "/api/v1/modules/calendar",
displayPageUrl: undefined,
id: "calendar",
permissions: [],
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
@@ -438,7 +443,18 @@ describe("module UI host context", () => {
expect(none.has("core.authorization.grants.manage")).toBe(false);
});
it("exposes exactly the six contract members and the browser principal", () => {
it("exposes the host-owned display page at the API origin in split-origin deployments", () => {
const { implementation } = recordingFetch();
const context = createModuleUiContext({
config: { apiBaseUrl: "https://api.example.test", fetch: implementation },
descriptor: exampleDescriptor,
principal: { kind: "authenticated", userId: "user-1" },
});
expect(context.display?.url).toBe("https://api.example.test/display/example");
});
it("exposes exactly the seven contract members and the browser principal", () => {
const { implementation } = recordingFetch();
const context = contextFor(implementation);
@@ -446,6 +462,7 @@ describe("module UI host context", () => {
expect(Object.keys(context).sort()).toEqual([
"api",
"contributions",
"display",
"moduleId",
"permissions",
"principal",
@@ -139,6 +139,7 @@ function descriptor(overrides: Partial<HostModuleUiDescriptor> = {}): HostModule
version: "1.0.0",
},
],
displayPageUrl: "/display/signage",
id: "signage",
permissions: [],
realtimeBaseUrl: "/api/v1/module-realtime/signage",
@@ -12,6 +12,7 @@ const apiBaseUrl = "https://ekklesia.example.test";
const exampleDescriptor: HostModuleUiDescriptor = {
contributions: [],
apiBaseUrl: "/api/v1/modules/example",
displayPageUrl: undefined,
id: "example",
permissions: ["example.state.read"],
realtimeBaseUrl: "/api/v1/module-realtime/example",
+1
View File
@@ -56,6 +56,7 @@ function recordingHost(options: {
};
return {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
display: undefined,
moduleId: "example",
api,
principal: { kind: "anonymous" },