Pair displays at the platform's own /display/setup, with or without modules
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 45s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m34s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m52s

Displays are platform-owned, but a screen could only start pairing on a
module's display page: every pairing request named a module, so an
installation with no module that uses displays could not set one up.

- /display/setup is the platform's own display page, served whenever
  the deployment has device support, whatever is installed. It pairs a
  platform display with no module. Once paired, it opens the module it
  is set to show; with none set, it shows its name and waits, rechecking
  so it stays alive and notices revocation. "setup" is now a reserved
  module id.
- Migration core:0009_platform_display_pairing lets a pairing request
  name no module. It relaxes module_id and permission_id and adds a
  both-or-neither check. It is additive and destroys nothing.
- POST /api/v1/device-pairing/requests accepts {} for a platform
  display. Approval accepts an optional moduleId, so the administrator
  can choose an installed display module and the device gets that
  module's declared permission. A module display's own request can't be
  redirected.
- GET /api/v1/device-pairing/self lets a paired device describe itself:
  its name and the module displays it may show. It renews the device's
  cookie.
- GET /api/v1/devices/setup is replaced by
  GET /api/v1/devices/display-modules: the modules a display can be set
  to show.
- Settings -> Displays always offers the /display/setup address, Copy
  setup link and Add display. Add display has an optional "Show on this
  screen" choice, and each paired display says what it shows.
- /display/<module-id> is unchanged, so released Signage 0.1.5 works as
  before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-16 16:43:02 -05:00
co-authored by Claude Opus 5.5
parent 906c015ca8
commit 300d9eab51
44 changed files with 3169 additions and 396 deletions
+1
View File
@@ -261,6 +261,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
"core:0009_platform_display_pairing",
]);
expect(aggregate.migrations[0]?.checksum).toBe(
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
+16 -1
View File
@@ -20,6 +20,7 @@ import {
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleLifecycleConfiguration,
RESERVED_DISPLAY_HOST_NAMES,
RESERVED_MODULE_IDS,
RESERVED_SETTINGS_NAMES,
RESERVED_TOP_LEVEL_NAMES,
@@ -44,6 +45,7 @@ import {
createDashboardHostRoutes,
} from "../src/dashboard-host-routes.js";
import { MODULE_NAVIGATION_ICONS } from "@ekklesia/contracts";
import { DISPLAY_SETUP_PATH as SHELL_DISPLAY_SETUP_PATH } from "@ekklesia/shell";
import {
DASHBOARD_PLATFORM_PAGES,
HOST_NAMESPACES,
@@ -51,6 +53,7 @@ import {
} from "@ekklesia/shell/dashboard-routes";
import { DASHBOARD_MODULE_ICON_NAMES } from "@ekklesia/shell/module-icons";
import {
DISPLAY_SETUP_PAGE_URL,
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
MODULE_DISPLAY_PAGE_PREFIX,
} from "../src/module-display-path.js";
@@ -437,9 +440,21 @@ describe("the platform's reserved top-level names (ADR 0057)", () => {
expect(isReservedModuleId(section), section).toBe(true);
}
expect(RESERVED_MODULE_IDS).toEqual(
[...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES].sort(),
[
...RESERVED_TOP_LEVEL_NAMES,
...RESERVED_SETTINGS_NAMES,
...RESERVED_DISPLAY_HOST_NAMES,
].sort(),
);
});
it("include the display host's own setup page, so /display/<module-id> never collides (ADR 0065)", () => {
expect([...RESERVED_DISPLAY_HOST_NAMES]).toEqual(["setup"]);
expect(DISPLAY_SETUP_PAGE_URL).toBe("/display/setup");
// The shell opens and links to exactly the address the API serves.
expect(SHELL_DISPLAY_SETUP_PATH).toBe(DISPLAY_SETUP_PAGE_URL);
expect(isReservedModuleId(DISPLAY_SETUP_PAGE_URL.split("/")[2] ?? "")).toBe(true);
});
});
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
+221 -24
View File
@@ -99,6 +99,12 @@ async function query<T extends QueryResultRow>(
/** A display-capable module: one display entrypoint, one restricted permission protecting it, and
* one realtime channel gated by that same permission. */
async function createEmptyModulesDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-empty-"));
trackedDirectories.push(directory);
return directory;
}
async function createDisplayModuleDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-"));
trackedDirectories.push(directory);
@@ -267,9 +273,17 @@ interface Platform {
readonly close: () => Promise<void>;
}
async function platform(label: string): Promise<Platform> {
async function platform(
label: string,
options: { readonly modules?: boolean } = {},
): Promise<Platform> {
const url = await createMigratedDatabase(label);
const modulesDirectory = await createDisplayModuleDirectory();
// `modules: false` is an installation with no module at all — nothing that shows content on a
// screen — which can still set up and pair a display (ADR 0065).
const modulesDirectory =
options.modules === false
? await createEmptyModulesDirectory()
: await createDisplayModuleDirectory();
const api = await startPlatform(url, modulesDirectory);
const origin = apiOrigin(api);
const ownerCookie = await createOwner(url, origin);
@@ -419,27 +433,6 @@ describe.sequential("display device pairing, end to end", () => {
expect((await device.request("/display/announcements")).status).toBe(200);
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
// How an administrator learns which page to open on that screen (ADR 0065): the platform
// display page of every module that uses displays, named, and only for an administrator. The
// screen itself — still anonymous — is refused, so setup never enumerates what is installed.
const setup = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/devices/setup",
);
expect(setup.status).toBe(200);
expect(setup.headers.get("cache-control")).toBe("no-store");
expect(await setup.json()).toEqual({
displays: [
{
moduleId: "announcements",
name: "Announcements fixture",
url: "/display/announcements",
},
],
});
expect((await device.request("/api/v1/devices/setup")).status).toBe(403);
// 2. It asks to be paired and receives a code plus an HttpOnly claim cookie.
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
@@ -522,7 +515,7 @@ describe.sequential("display device pairing, end to end", () => {
});
expect((await device.request("/api/v1/dashboard/navigation")).status).toBe(403);
expect((await device.request("/api/v1/devices")).status).toBe(403);
expect((await device.request("/api/v1/devices/setup")).status).toBe(403);
expect((await device.request("/api/v1/devices/display-modules")).status).toBe(403);
// 9. The administrator revokes it.
const devices = (await (
@@ -951,3 +944,207 @@ describe.sequential("display device pairing, end to end", () => {
}
});
});
describe.sequential("platform display pairing (ADR 0065)", () => {
it("pairs a screen from /display/setup with zero display modules installed", async () => {
const running = await platform("platform-zero", { modules: false });
const device = new DeviceBrowser(running.origin);
try {
// The platform's own setup page is served with nothing installed, and nothing else is.
const page = await device.request("/display/setup");
expect(page.status).toBe(200);
expect(await page.text()).toContain("ekklesia-display");
expect((await device.request("/display/signage")).status).toBe(404);
// It asks to be paired as a platform display: no module at all.
const requested = await device.post("/api/v1/device-pairing/requests", {});
expect(requested.status).toBe(201);
const { code } = (await requested.json()) as { code: string };
expect(code).toMatch(/^[0-9]{6}$/u);
// The administrator sees a screen with no module yet, and there is no module to choose.
const lookup = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/lookups",
{ code },
);
expect(lookup.status).toBe(200);
expect(await lookup.json()).toMatchObject({ moduleId: null, permissionId: null });
const modules = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/devices/display-modules",
);
expect(modules.headers.get("cache-control")).toBe("no-store");
expect(await modules.json()).toEqual({ modules: [] });
const approved = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code, deviceName: "Foyer TV" },
);
expect(approved.status).toBe(200);
expect(await approved.json()).toEqual({ moduleId: null, permissionId: null });
// The screen collects its credential exactly as a module display does.
const claimed = await device.post("/api/v1/device-pairing/claims", {});
expect(await claimed.json()).toEqual({ status: "paired" });
expect(device.has("ekklesia_device_session")).toBe(true);
// It is a platform display: paired, named, holding no module permission.
const self = await device.request("/api/v1/device-pairing/self");
expect(self.status).toBe(200);
expect(self.headers.get("cache-control")).toBe("no-store");
expect(setCookieFor(self, "ekklesia_device_session")).toMatch(/HttpOnly/iu);
expect(await self.json()).toEqual({ displays: [], name: "Foyer TV" });
const listed = (await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ id: string; name: string; permissions: string[] }> };
expect(listed.devices).toHaveLength(1);
expect(listed.devices[0]).toMatchObject({ name: "Foyer TV", permissions: [] });
// Only a device is answered about itself: never a person, never an unpaired screen.
expect(
(await adminRequest(running.origin, running.ownerCookie, "/api/v1/device-pairing/self"))
.status,
).toBe(403);
expect(
(await new DeviceBrowser(running.origin).request("/api/v1/device-pairing/self")).status,
).toBe(403);
// Revoking it works as for any display, and it can start over at the same page.
const revoked = await adminRequest(
running.origin,
running.ownerCookie,
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
{},
);
expect(revoked.status).toBe(204);
expect((await device.request("/api/v1/device-pairing/self")).status).toBe(403);
expect((await device.request("/display/setup")).status).toBe(200);
} finally {
await running.close();
}
});
it("lets the administrator choose the module a platform display shows when approving it", async () => {
const running = await platform("platform-assign");
const device = new DeviceBrowser(running.origin);
try {
const requested = await device.post("/api/v1/device-pairing/requests", {});
const { code } = (await requested.json()) as { code: string };
const modules = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/devices/display-modules",
);
expect(await modules.json()).toEqual({
modules: [
{
moduleId: "announcements",
name: "Announcements fixture",
permissionId: displayPermission,
},
],
});
const approved = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code, deviceName: "Hall screen", moduleId: "announcements" },
);
expect(approved.status).toBe(200);
expect(await approved.json()).toEqual({
moduleId: "announcements",
permissionId: displayPermission,
});
expect(await (await device.post("/api/v1/device-pairing/claims", {})).json()).toEqual({
status: "paired",
});
// The one platform display now holds exactly that module's display permission, so the setup
// page knows where to go and the module's display accepts it.
expect(await (await device.request("/api/v1/device-pairing/self")).json()).toEqual({
displays: [{ moduleId: "announcements", url: "/display/announcements" }],
name: "Hall screen",
});
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(200);
const listed = (await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ permissions: string[] }> };
expect(listed.devices).toHaveLength(1);
expect(listed.devices[0]?.permissions).toEqual([displayPermission]);
} finally {
await running.close();
}
});
it("never takes a permission from a request, and never redirects a module display's own one", async () => {
const running = await platform("platform-refusals");
const device = new DeviceBrowser(running.origin);
try {
// A request can never name a permission: any other key is ignored, so this is a platform
// request that grants nothing. A module that is not a string is refused.
const ignored = await device.post("/api/v1/device-pairing/requests", {
permissionId: "core.devices.manage",
});
expect(ignored.status).toBe(201);
const ignoredCode = ((await ignored.json()) as { code: string }).code;
expect(
await (
await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/lookups",
{
code: ignoredCode,
},
)
).json(),
).toMatchObject({ moduleId: null, permissionId: null });
expect(
(
await new DeviceBrowser(running.origin).post("/api/v1/device-pairing/requests", {
moduleId: null,
})
).status,
).toBe(400);
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
});
const { code } = (await requested.json()) as { code: string };
// A module that is not a running display is refused at approval.
expect(
(
await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{
code,
deviceName: "Screen",
moduleId: "not-a-display",
},
)
).status,
).toBe(400);
// Naming its own module again is fine: the request is unchanged.
const approved = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code, deviceName: "Screen", moduleId: "announcements" },
);
expect(approved.status).toBe(200);
expect(await approved.json()).toEqual({
moduleId: "announcements",
permissionId: displayPermission,
});
} finally {
await running.close();
}
});
});
+14 -2
View File
@@ -24,8 +24,16 @@ vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
);
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
});
// Every startup also reads the built display host, because every installation serves the platform's
// `/display/setup` page (ADR 0065), so this loader answers a small consistent snapshot too.
const defaultDisplayHostAssets = vi.hoisted(() => ({ factory: undefined as unknown }));
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
defaultDisplayHostAssets.factory = () =>
actual.createDisplayHostAssets("<!doctype html>", "export {};\n", "export {};\n");
loadDisplayHostAssetsMock.mockImplementation(async () =>
(defaultDisplayHostAssets.factory as () => unknown)(),
);
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
});
// Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still
@@ -1096,6 +1104,9 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
drizzleAdapterMock.mockClear();
serveMock.mockReset();
loadDisplayHostAssetsMock.mockReset();
loadDisplayHostAssetsMock.mockImplementation(async () =>
(defaultDisplayHostAssets.factory as () => unknown)(),
);
vi.mocked(createApiApp).mockClear();
});
@@ -1159,7 +1170,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
expect(serveMock).not.toHaveBeenCalled();
});
it("does not read display-host assets when the eligible set declares no display", async () => {
it("reads display-host assets even when no module declares a display, for /display/setup (ADR 0065)", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
@@ -1168,7 +1179,8 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
expect(loadDisplayHostAssetsMock).not.toHaveBeenCalled();
// A screen can be set up on any installation, whatever is installed.
expect(loadDisplayHostAssetsMock).toHaveBeenCalledTimes(1);
await running.shutdown();
});
@@ -250,6 +250,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
"core:0009_platform_display_pairing",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
@@ -281,6 +282,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
"core:0009_platform_display_pairing",
"db-facade:0000_initial",
]);
expect(aggregate.migrations[0]?.checksum).toBe(
+47 -1
View File
@@ -22,14 +22,16 @@ import {
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
import {
DISPLAY_HOST_SCRIPT_PATH,
DISPLAY_SERVICE_WORKER_PATH,
DISPLAY_SETUP_PAGE_URL,
ModuleDisplayConfigurationError,
mountModuleDisplayRoutes,
moduleDisplayDescriptorUrl,
moduleDisplayPageUrl,
} from "../src/module-display-routes.js";
@@ -568,3 +570,47 @@ describe("module display host asset publication", () => {
}
});
});
describe("the platform display setup page (ADR 0065)", () => {
function bare(options: { readonly platformSetup?: boolean; readonly assets?: boolean }) {
const app = new Hono();
mountModuleDisplayRoutes(app, {
evaluator: createAuthorizationEvaluator({
grants: { hasActiveGrant: async () => false },
platformOwners: { isPlatformOwner: async () => false },
}),
// No installed module has a display at all.
moduleUi: createModuleUiPublicationRegistry([]),
registry: composePlatformPermissionRegistry([]),
runtime: {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
},
...(options.assets === false ? {} : { assets: displayHostAssets }),
...(options.platformSetup === undefined ? {} : { platformSetup: options.platformSetup }),
});
return app;
}
it("serves /display/setup and the display host with zero display modules installed", async () => {
const app = bare({ platformSetup: true });
expect(DISPLAY_SETUP_PAGE_URL).toBe("/display/setup");
const page = await app.request(DISPLAY_SETUP_PAGE_URL);
expect(page.status).toBe(200);
expect(page.headers.get("cache-control")).toBe("no-store");
// The same inert, module-agnostic page every display is served.
expect(await page.text()).toBe(displayHostAssets.html);
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(200);
expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(200);
// Nothing else beneath /display/ exists.
expect((await app.request("/display/signage")).status).toBe(404);
});
it("is not served without device support, and device support needs the display host", async () => {
expect((await bare({}).request(DISPLAY_SETUP_PAGE_URL)).status).toBe(404);
expect(() => bare({ assets: false, platformSetup: true })).toThrow(
ModuleDisplayConfigurationError,
);
});
});