feat: expose canonical module display URLs
This commit is contained in:
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -7,6 +7,13 @@ import {
|
|||||||
requirePermission,
|
requirePermission,
|
||||||
} from "./authorization-middleware.js";
|
} from "./authorization-middleware.js";
|
||||||
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.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 { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||||
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||||
import {
|
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
|
/** 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. */
|
* `/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";
|
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
|
||||||
/**
|
/**
|
||||||
* Where the display's offline service worker is served (ADR 0043).
|
* 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 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 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
|
* 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
|
* 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 id: string;
|
||||||
readonly version: string;
|
readonly version: string;
|
||||||
readonly display: { readonly url: 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 api: { readonly baseUrl: string };
|
||||||
readonly realtime: { readonly baseUrl: string };
|
readonly realtime: { readonly baseUrl: string };
|
||||||
readonly permissions: readonly string[];
|
readonly permissions: readonly string[];
|
||||||
@@ -157,6 +157,7 @@ async function describeDisplay(
|
|||||||
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
|
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
|
||||||
display: { url: moduleDisplayUiArtifactUrl(display) },
|
display: { url: moduleDisplayUiArtifactUrl(display) },
|
||||||
id: display.moduleId,
|
id: display.moduleId,
|
||||||
|
page: { url: moduleDisplayPageUrl(display.moduleId) },
|
||||||
permissions: await grantedModulePermissionIds(
|
permissions: await grantedModulePermissionIds(
|
||||||
options.evaluator,
|
options.evaluator,
|
||||||
principal,
|
principal,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
requirePermission,
|
requirePermission,
|
||||||
} from "./authorization-middleware.js";
|
} from "./authorization-middleware.js";
|
||||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||||
|
import { moduleDisplayPageUrl } from "./module-display-path.js";
|
||||||
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||||
import {
|
import {
|
||||||
MODULE_UI_ASSET_PREFIX,
|
MODULE_UI_ASSET_PREFIX,
|
||||||
@@ -48,6 +49,10 @@ export interface HostModuleUiDescriptor {
|
|||||||
* canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication
|
* canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication
|
||||||
* routing can change without touching a single line of host or module code. */
|
* routing can change without touching a single line of host or module code. */
|
||||||
readonly ui: { readonly url: string };
|
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
|
/** 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
|
* 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
|
* `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,
|
published: PublishableModuleUi,
|
||||||
registry: ModuleUiPublicationRegistry,
|
registry: ModuleUiPublicationRegistry,
|
||||||
): Promise<HostModuleUiDescriptor> {
|
): Promise<HostModuleUiDescriptor> {
|
||||||
|
const display = registry.resolveDisplay(published.moduleId, published.version);
|
||||||
return {
|
return {
|
||||||
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
||||||
contributions: describeModuleContributions(registry, published.moduleId),
|
contributions: describeModuleContributions(registry, published.moduleId),
|
||||||
|
display: display === undefined ? null : { url: moduleDisplayPageUrl(published.moduleId) },
|
||||||
id: published.moduleId,
|
id: published.moduleId,
|
||||||
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
||||||
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ describe("Dashboard navigation — reading", () => {
|
|||||||
expect(livestream?.module).toEqual({
|
expect(livestream?.module).toEqual({
|
||||||
contributions: [],
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/livestream" },
|
api: { baseUrl: "/api/v1/modules/livestream" },
|
||||||
|
display: null,
|
||||||
id: "livestream",
|
id: "livestream",
|
||||||
permissions: [],
|
permissions: [],
|
||||||
realtime: { baseUrl: "/api/v1/module-realtime/livestream" },
|
realtime: { baseUrl: "/api/v1/module-realtime/livestream" },
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ describe("module display routes — authorization and descriptor", () => {
|
|||||||
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||||
},
|
},
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
|
page: { url: moduleDisplayPageUrl("announcements") },
|
||||||
permissions: [displayPermission],
|
permissions: [displayPermission],
|
||||||
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
|
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
|
||||||
version: "1.4.2",
|
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(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
||||||
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
||||||
expect(
|
expect(
|
||||||
((await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { modules: unknown[] })
|
(
|
||||||
.modules,
|
(await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||||
).toHaveLength(1);
|
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 () => {
|
it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => {
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
expect(bootstrap.descriptor).toEqual({
|
expect(bootstrap.descriptor).toEqual({
|
||||||
apiBaseUrl: "/api/v1/modules/example",
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
contributions: [],
|
contributions: [],
|
||||||
|
displayPageUrl: "/display/example",
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: ["example.records.read", "example.display.view"],
|
permissions: ["example.records.read", "example.display.view"],
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
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);
|
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
|
||||||
|
|
||||||
// 4. The generic loader receives the unchanged five-member browser context and mounts the
|
// 4. The generic loader receives the host-owned browser context, including the canonical
|
||||||
// display artifact independently of the Dashboard artifact.
|
// display page, and mounts the display artifact independently of the Dashboard artifact.
|
||||||
const context = createModuleUiContext({
|
const context = createModuleUiContext({
|
||||||
config: harness.config,
|
config: harness.config,
|
||||||
descriptor: bootstrap.descriptor,
|
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([
|
expect(Object.keys(context).sort()).toEqual([
|
||||||
"api",
|
"api",
|
||||||
"contributions",
|
"contributions",
|
||||||
|
"display",
|
||||||
"moduleId",
|
"moduleId",
|
||||||
"permissions",
|
"permissions",
|
||||||
"principal",
|
"principal",
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ describe("module UI inventory — what it lists", () => {
|
|||||||
{
|
{
|
||||||
contributions: [],
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/example" },
|
api: { baseUrl: "/api/v1/modules/example" },
|
||||||
|
display: null,
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: [],
|
permissions: [],
|
||||||
realtime: { baseUrl: "/api/v1/module-realtime/example" },
|
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([
|
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
|
||||||
"api",
|
"api",
|
||||||
"contributions",
|
"contributions",
|
||||||
|
"display",
|
||||||
"id",
|
"id",
|
||||||
"permissions",
|
"permissions",
|
||||||
"realtime",
|
"realtime",
|
||||||
|
|||||||
@@ -947,6 +947,7 @@ describe("module UI artifact publication — the generic loader consumes what is
|
|||||||
return {
|
return {
|
||||||
api: { request: async () => new Response(null, { status: 200 }) },
|
api: { request: async () => new Response(null, { status: 200 }) },
|
||||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
|
display: undefined,
|
||||||
moduleId: "announcements",
|
moduleId: "announcements",
|
||||||
permissions: { has: () => false },
|
permissions: { has: () => false },
|
||||||
principal: { kind: "anonymous" as const },
|
principal: { kind: "anonymous" as const },
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export default {
|
|||||||
function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext {
|
function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext {
|
||||||
return {
|
return {
|
||||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
|
display: undefined,
|
||||||
moduleId,
|
moduleId,
|
||||||
api: {
|
api: {
|
||||||
async request(path) {
|
async request(path) {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ let artifactSource: string;
|
|||||||
function ekklesiaContext(requested: string[]): ModuleUiHostContext {
|
function ekklesiaContext(requested: string[]): ModuleUiHostContext {
|
||||||
return {
|
return {
|
||||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
|
display: undefined,
|
||||||
moduleId: "example",
|
moduleId: "example",
|
||||||
api: {
|
api: {
|
||||||
async request(pathname) {
|
async request(pathname) {
|
||||||
@@ -50,6 +51,7 @@ function ekklesiaContext(requested: string[]): ModuleUiHostContext {
|
|||||||
function standaloneContext(requested: string[]): ModuleUiHostContext {
|
function standaloneContext(requested: string[]): ModuleUiHostContext {
|
||||||
return {
|
return {
|
||||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
|
display: { url: "https://example.test/display" },
|
||||||
moduleId: "example",
|
moduleId: "example",
|
||||||
api: {
|
api: {
|
||||||
async request(pathname) {
|
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
|
/** 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. */
|
* or re-derive its own identity from a URL. */
|
||||||
readonly moduleId: string;
|
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}. */
|
/** The only supported way for module UI to reach its own backend. See {@link ModuleUiApiClient}. */
|
||||||
readonly api: ModuleUiApiClient;
|
readonly api: ModuleUiApiClient;
|
||||||
/** Who is viewing, for presentation decisions only. See {@link ModuleUiPrincipal} for why this is
|
/** Who is viewing, for presentation decisions only. See {@link ModuleUiPrincipal} for why this is
|
||||||
@@ -96,6 +104,10 @@ export interface ModuleUiHostContext {
|
|||||||
readonly contributions: ModuleUiContributions;
|
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
|
* 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.
|
* root** — never as an absolute path.
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
|||||||
requestedUrls,
|
requestedUrls,
|
||||||
context: {
|
context: {
|
||||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
|
display: undefined,
|
||||||
moduleId,
|
moduleId,
|
||||||
api,
|
api,
|
||||||
principal: { kind: "anonymous" },
|
principal: { kind: "anonymous" },
|
||||||
@@ -211,6 +212,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
|||||||
requestedUrls,
|
requestedUrls,
|
||||||
context: {
|
context: {
|
||||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
|
display: { url: "https://announcements.example.org/display" },
|
||||||
moduleId,
|
moduleId,
|
||||||
api,
|
api,
|
||||||
principal: { kind: "anonymous" },
|
principal: { kind: "anonymous" },
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ export interface HostModuleUiDescriptor {
|
|||||||
/** Server-produced. The shell resolves it against `apiBaseUrl` and hands it to the generic loader
|
/** 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. */
|
* unchanged; it never constructs, parses, or pattern-matches this URL. */
|
||||||
readonly uiUrl: string;
|
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. */
|
/** Server-produced root of this module's own HTTP API. */
|
||||||
readonly apiBaseUrl: string;
|
readonly apiBaseUrl: string;
|
||||||
/** Server-produced root for this module's own live channels. */
|
/** 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 uiUrl = isRecord(entry) ? rootRelativePath(entry.ui, "url") : undefined;
|
||||||
const apiBaseUrl = isRecord(entry) ? rootRelativePath(entry.api, "baseUrl") : undefined;
|
const apiBaseUrl = isRecord(entry) ? rootRelativePath(entry.api, "baseUrl") : undefined;
|
||||||
const realtimeBaseUrl = isRecord(entry) ? rootRelativePath(entry.realtime, "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;
|
const permissions = isRecord(entry) ? entry.permissions : undefined;
|
||||||
if (
|
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 ||
|
id === undefined ||
|
||||||
version === undefined ||
|
version === undefined ||
|
||||||
uiUrl === undefined ||
|
uiUrl === undefined ||
|
||||||
@@ -229,6 +247,7 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
|
|||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
contributions: parseContributions(isRecord(entry) ? entry.contributions : undefined),
|
contributions: parseContributions(isRecord(entry) ? entry.contributions : undefined),
|
||||||
|
displayPageUrl,
|
||||||
id,
|
id,
|
||||||
permissions: Object.freeze([...(permissions as string[])]),
|
permissions: Object.freeze([...(permissions as string[])]),
|
||||||
realtimeBaseUrl,
|
realtimeBaseUrl,
|
||||||
@@ -323,12 +342,14 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
|
|||||||
"contributions",
|
"contributions",
|
||||||
"display",
|
"display",
|
||||||
"id",
|
"id",
|
||||||
|
"page",
|
||||||
"permissions",
|
"permissions",
|
||||||
"realtime",
|
"realtime",
|
||||||
"version",
|
"version",
|
||||||
]) ||
|
]) ||
|
||||||
!hasExactKeys(entry.api, ["baseUrl"]) ||
|
!hasExactKeys(entry.api, ["baseUrl"]) ||
|
||||||
!hasExactKeys(entry.display, ["url"]) ||
|
!hasExactKeys(entry.display, ["url"]) ||
|
||||||
|
!hasExactKeys(entry.page, ["url"]) ||
|
||||||
!hasExactKeys(entry.realtime, ["baseUrl"])
|
!hasExactKeys(entry.realtime, ["baseUrl"])
|
||||||
) {
|
) {
|
||||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||||
@@ -338,6 +359,7 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
|
|||||||
const version = nonEmptyString(entry.version);
|
const version = nonEmptyString(entry.version);
|
||||||
const apiBaseUrl = rootRelativePath(entry.api, "baseUrl");
|
const apiBaseUrl = rootRelativePath(entry.api, "baseUrl");
|
||||||
const uiUrl = rootRelativePath(entry.display, "url");
|
const uiUrl = rootRelativePath(entry.display, "url");
|
||||||
|
const displayPageUrl = rootRelativePath(entry.page, "url");
|
||||||
const realtimeBaseUrl = rootRelativePath(entry.realtime, "baseUrl");
|
const realtimeBaseUrl = rootRelativePath(entry.realtime, "baseUrl");
|
||||||
const permissions = entry.permissions;
|
const permissions = entry.permissions;
|
||||||
if (
|
if (
|
||||||
@@ -347,6 +369,7 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
|
|||||||
!isSemanticVersion(version) ||
|
!isSemanticVersion(version) ||
|
||||||
apiBaseUrl === undefined ||
|
apiBaseUrl === undefined ||
|
||||||
uiUrl === undefined ||
|
uiUrl === undefined ||
|
||||||
|
displayPageUrl === undefined ||
|
||||||
realtimeBaseUrl === undefined ||
|
realtimeBaseUrl === undefined ||
|
||||||
!Array.isArray(permissions) ||
|
!Array.isArray(permissions) ||
|
||||||
permissions.some((permission) => typeof permission !== "string" || permission.length === 0) ||
|
permissions.some((permission) => typeof permission !== "string" || permission.length === 0) ||
|
||||||
@@ -359,6 +382,7 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
|
|||||||
module: Object.freeze({
|
module: Object.freeze({
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
contributions: parseContributions(entry.contributions),
|
contributions: parseContributions(entry.contributions),
|
||||||
|
displayPageUrl,
|
||||||
id,
|
id,
|
||||||
permissions: Object.freeze([...(permissions as string[])]),
|
permissions: Object.freeze([...(permissions as string[])]),
|
||||||
realtimeBaseUrl,
|
realtimeBaseUrl,
|
||||||
|
|||||||
@@ -350,6 +350,10 @@ export function createModuleUiContext(options: CreateModuleUiContextOptions): Mo
|
|||||||
options.descriptor,
|
options.descriptor,
|
||||||
options.importModule,
|
options.importModule,
|
||||||
),
|
),
|
||||||
|
display:
|
||||||
|
options.descriptor.displayPageUrl === undefined
|
||||||
|
? undefined
|
||||||
|
: Object.freeze({ url: resolveApiUrl(options.config, options.descriptor.displayPageUrl) }),
|
||||||
moduleId: options.descriptor.id,
|
moduleId: options.descriptor.id,
|
||||||
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
|
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
|
||||||
principal: options.principal,
|
principal: options.principal,
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ function descriptorPayload(
|
|||||||
api: { baseUrl: `/api/v1/modules/${id}` },
|
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||||
display: { url: `/api/v1/module-ui/${id}/${version}/display` },
|
display: { url: `/api/v1/module-ui/${id}/${version}/display` },
|
||||||
id,
|
id,
|
||||||
|
page: { url: `/display/${id}` },
|
||||||
permissions: [`${id}.slideshow.view`],
|
permissions: [`${id}.slideshow.view`],
|
||||||
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
|
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
|
||||||
version,
|
version,
|
||||||
@@ -240,6 +241,7 @@ describe("display host — full-viewport boundary", () => {
|
|||||||
expect(Object.keys(seenContext ?? {}).sort()).toEqual([
|
expect(Object.keys(seenContext ?? {}).sort()).toEqual([
|
||||||
"api",
|
"api",
|
||||||
"contributions",
|
"contributions",
|
||||||
|
"display",
|
||||||
"moduleId",
|
"moduleId",
|
||||||
"permissions",
|
"permissions",
|
||||||
"principal",
|
"principal",
|
||||||
@@ -334,6 +336,7 @@ describe("display host — full-viewport boundary", () => {
|
|||||||
module: {
|
module: {
|
||||||
contributions: [],
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/announcements" },
|
api: { baseUrl: "/api/v1/modules/announcements" },
|
||||||
|
display: { url: "/display/announcements" },
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
permissions: ["announcements.slideshow.view"],
|
permissions: ["announcements.slideshow.view"],
|
||||||
realtime: { baseUrl: "/api/v1/module-realtime/announcements" },
|
realtime: { baseUrl: "/api/v1/module-realtime/announcements" },
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function displayPayload(overrides: Record<string, unknown> = {}): {
|
|||||||
api: { baseUrl: "/api/v1/modules/announcements" },
|
api: { baseUrl: "/api/v1/modules/announcements" },
|
||||||
display: { url: "/api/v1/module-ui/announcements/1.2.0/display" },
|
display: { url: "/api/v1/module-ui/announcements/1.2.0/display" },
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
|
page: { url: "/display/announcements" },
|
||||||
permissions: ["announcements.slideshow.view"],
|
permissions: ["announcements.slideshow.view"],
|
||||||
realtime: { baseUrl: "/api/v1/module-realtime/announcements" },
|
realtime: { baseUrl: "/api/v1/module-realtime/announcements" },
|
||||||
version: "1.2.0",
|
version: "1.2.0",
|
||||||
@@ -45,6 +46,7 @@ describe("display host protocol — strict descriptor", () => {
|
|||||||
module: {
|
module: {
|
||||||
apiBaseUrl: "/api/v1/modules/announcements",
|
apiBaseUrl: "/api/v1/modules/announcements",
|
||||||
contributions: [],
|
contributions: [],
|
||||||
|
displayPageUrl: "/display/announcements",
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
permissions: ["announcements.slideshow.view"],
|
permissions: ["announcements.slideshow.view"],
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/announcements",
|
realtimeBaseUrl: "/api/v1/module-realtime/announcements",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function entry(id: string, name: string, version = "0.1.0", permissions: readonl
|
|||||||
module: {
|
module: {
|
||||||
contributions: [],
|
contributions: [],
|
||||||
api: { baseUrl: `/api/v1/modules/${id}` },
|
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||||
|
display: null,
|
||||||
id,
|
id,
|
||||||
permissions,
|
permissions,
|
||||||
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
|
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const inventoryPayload = {
|
|||||||
{
|
{
|
||||||
contributions: [],
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/example" },
|
api: { baseUrl: "/api/v1/modules/example" },
|
||||||
|
display: { url: "/display/example" },
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: ["example.records.read"],
|
permissions: ["example.records.read"],
|
||||||
realtime: { baseUrl: "/api/v1/module-realtime/example" },
|
realtime: { baseUrl: "/api/v1/module-realtime/example" },
|
||||||
@@ -51,6 +52,7 @@ const inventoryPayload = {
|
|||||||
const calendarDescriptor: HostModuleUiDescriptor = {
|
const calendarDescriptor: HostModuleUiDescriptor = {
|
||||||
contributions: [],
|
contributions: [],
|
||||||
apiBaseUrl: "/api/v1/modules/calendar",
|
apiBaseUrl: "/api/v1/modules/calendar",
|
||||||
|
displayPageUrl: undefined,
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
permissions: [],
|
permissions: [],
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
|
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
|
||||||
@@ -72,6 +74,7 @@ const navigationPayload = {
|
|||||||
module: {
|
module: {
|
||||||
contributions: [],
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/calendar" },
|
api: { baseUrl: "/api/v1/modules/calendar" },
|
||||||
|
display: null,
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
permissions: [],
|
permissions: [],
|
||||||
realtime: { baseUrl: "/api/v1/module-realtime/calendar" },
|
realtime: { baseUrl: "/api/v1/module-realtime/calendar" },
|
||||||
@@ -86,6 +89,7 @@ const navigationPayload = {
|
|||||||
const exampleDescriptor: HostModuleUiDescriptor = {
|
const exampleDescriptor: HostModuleUiDescriptor = {
|
||||||
contributions: [],
|
contributions: [],
|
||||||
apiBaseUrl: "/api/v1/modules/example",
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
|
displayPageUrl: "/display/example",
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: ["example.records.read"],
|
permissions: ["example.records.read"],
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||||
@@ -409,6 +413,7 @@ describe("module UI host context", () => {
|
|||||||
const calendar: HostModuleUiDescriptor = {
|
const calendar: HostModuleUiDescriptor = {
|
||||||
contributions: [],
|
contributions: [],
|
||||||
apiBaseUrl: "/api/v1/modules/calendar",
|
apiBaseUrl: "/api/v1/modules/calendar",
|
||||||
|
displayPageUrl: undefined,
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
permissions: [],
|
permissions: [],
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
|
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
|
||||||
@@ -438,7 +443,18 @@ describe("module UI host context", () => {
|
|||||||
expect(none.has("core.authorization.grants.manage")).toBe(false);
|
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 { implementation } = recordingFetch();
|
||||||
|
|
||||||
const context = contextFor(implementation);
|
const context = contextFor(implementation);
|
||||||
@@ -446,6 +462,7 @@ describe("module UI host context", () => {
|
|||||||
expect(Object.keys(context).sort()).toEqual([
|
expect(Object.keys(context).sort()).toEqual([
|
||||||
"api",
|
"api",
|
||||||
"contributions",
|
"contributions",
|
||||||
|
"display",
|
||||||
"moduleId",
|
"moduleId",
|
||||||
"permissions",
|
"permissions",
|
||||||
"principal",
|
"principal",
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ function descriptor(overrides: Partial<HostModuleUiDescriptor> = {}): HostModule
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
displayPageUrl: "/display/signage",
|
||||||
id: "signage",
|
id: "signage",
|
||||||
permissions: [],
|
permissions: [],
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/signage",
|
realtimeBaseUrl: "/api/v1/module-realtime/signage",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const apiBaseUrl = "https://ekklesia.example.test";
|
|||||||
const exampleDescriptor: HostModuleUiDescriptor = {
|
const exampleDescriptor: HostModuleUiDescriptor = {
|
||||||
contributions: [],
|
contributions: [],
|
||||||
apiBaseUrl: "/api/v1/modules/example",
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
|
displayPageUrl: undefined,
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: ["example.state.read"],
|
permissions: ["example.state.read"],
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function recordingHost(options: {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
|
display: undefined,
|
||||||
moduleId: "example",
|
moduleId: "example",
|
||||||
api,
|
api,
|
||||||
principal: { kind: "anonymous" },
|
principal: { kind: "anonymous" },
|
||||||
|
|||||||
Reference in New Issue
Block a user