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
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:
co-authored by
Claude Opus 5.5
parent
906c015ca8
commit
300d9eab51
+6
-4
@@ -3,12 +3,12 @@ import {
|
||||
type ModuleSecretSource,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { AccountStore } from "@ekklesia/core/account";
|
||||
import type {
|
||||
AuthorizationEvaluator,
|
||||
AuthorizationStore,
|
||||
PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { AccountStore } from "@ekklesia/core/account";
|
||||
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
||||
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
@@ -28,8 +28,8 @@ import type {
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
import { createAccountRoutes } from "./account-routes.js";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
@@ -88,8 +88,9 @@ export interface CreateApiAppOptions {
|
||||
readonly secureCookies: boolean;
|
||||
};
|
||||
/** Surface-owned display host assets, read once by production startup. Optional only when the
|
||||
* eligible publication registry contains no display entrypoint; route composition rejects an
|
||||
* inconsistent display-bearing registry synchronously. */
|
||||
* eligible publication registry contains no display entrypoint and `devices` is absent: device
|
||||
* support always serves the platform's `/display/setup` page (ADR 0065). Route composition rejects
|
||||
* an inconsistent composition synchronously. */
|
||||
readonly displayHostAssets?: DisplayHostAssets;
|
||||
/**
|
||||
* The platform's operational module lifecycle: the store holding which modules are enabled, and the
|
||||
@@ -424,6 +425,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
||||
...(options.devices
|
||||
? {
|
||||
platformSetup: true,
|
||||
renewDeviceCredential: (c: Context) =>
|
||||
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
|
||||
}
|
||||
|
||||
+123
-33
@@ -1,4 +1,4 @@
|
||||
import { safeParseEkklesiaDeviceId } from "@ekklesia/contracts";
|
||||
import { type PermissionId, safeParseEkklesiaDeviceId } from "@ekklesia/contracts";
|
||||
import { CORE_DEVICES_MANAGE } from "@ekklesia/core/authorization";
|
||||
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||
import { DEVICE_CREDENTIAL_TTL_MS } from "@ekklesia/core/devices";
|
||||
@@ -131,6 +131,17 @@ export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): vo
|
||||
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||
const routes = new HonoApp();
|
||||
const now = options.now ?? ((): Date => new Date());
|
||||
/** A running module display by id, with its declared display permission, or `undefined`. */
|
||||
const findDisplay = (
|
||||
moduleId: string,
|
||||
): { readonly moduleId: string; readonly permissionId: PermissionId } | undefined => {
|
||||
const display = options.moduleUi
|
||||
.listDisplays()
|
||||
.find((candidate) => candidate.moduleId === moduleId);
|
||||
return display === undefined
|
||||
? undefined
|
||||
: { moduleId: display.moduleId, permissionId: display.displayPermission };
|
||||
};
|
||||
const requireManage = requirePermission(options, CORE_DEVICES_MANAGE);
|
||||
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||
@@ -138,11 +149,16 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||
/**
|
||||
* An unpaired display asks to be paired.
|
||||
*
|
||||
* The **only** thing the caller supplies is which module's display it is sitting on, and even
|
||||
* that is not taken at its word: the module is looked up in the publication registry and the
|
||||
* permission is read from that module's validated display declaration. A browser therefore cannot
|
||||
* ask to be granted `core.devices.manage`, another module's permission, or anything else — the
|
||||
* request records the permission the host derived, and approval grants exactly that.
|
||||
* Two kinds of screen ask (ADR 0065). One on the platform's own setup page (`/display/setup`)
|
||||
* sends `{}`: it becomes a platform display with no module, and the approving administrator may
|
||||
* choose which installed display module it shows. One on a module's display page sends
|
||||
* `{ "moduleId": … }`, as released displays always have.
|
||||
*
|
||||
* The module, when there is one, is not taken at its word: it is looked up in the publication
|
||||
* registry and the permission is read from that module's validated display declaration. A browser
|
||||
* therefore cannot ask to be granted `core.devices.manage`, another module's permission, or
|
||||
* anything else — any other key is ignored, the request records the permission the host derived,
|
||||
* or none, and approval grants exactly that.
|
||||
*/
|
||||
routes.post(
|
||||
`${DEVICE_PAIRING_PREFIX}/requests`,
|
||||
@@ -150,25 +166,29 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||
mutationBodyLimit,
|
||||
async (c: Context) => {
|
||||
const body = await readJsonObject(c);
|
||||
const moduleId = body === undefined ? undefined : readString(body, "moduleId", 128);
|
||||
if (moduleId === undefined) {
|
||||
if (body === undefined) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
const display = options.moduleUi
|
||||
.listDisplays()
|
||||
.find((candidate) => candidate.moduleId === moduleId);
|
||||
if (!display) {
|
||||
// Unknown, dashboard-only, disabled, or dependency-blocked all answer the same way, so
|
||||
// pairing never becomes a way to enumerate what is installed.
|
||||
return sanitizedFailure(c, 404, "NOT_FOUND");
|
||||
let display: { readonly moduleId: string; readonly permissionId: PermissionId } | undefined;
|
||||
if ("moduleId" in body) {
|
||||
const moduleId = readString(body, "moduleId", 128);
|
||||
if (moduleId === undefined) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
const found = findDisplay(moduleId);
|
||||
if (!found) {
|
||||
// Unknown, dashboard-only, disabled, or dependency-blocked all answer the same way, so
|
||||
// pairing never becomes a way to enumerate what is installed.
|
||||
return sanitizedFailure(c, 404, "NOT_FOUND");
|
||||
}
|
||||
display = found;
|
||||
}
|
||||
|
||||
try {
|
||||
const at = now();
|
||||
const created = await options.devices.createPairingRequest({
|
||||
moduleId: display.moduleId,
|
||||
...(display === undefined ? {} : { display }),
|
||||
now: at,
|
||||
permissionId: display.displayPermission,
|
||||
});
|
||||
writeDeviceCookie(
|
||||
c,
|
||||
@@ -265,8 +285,9 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||
return c.json(
|
||||
{
|
||||
expiresAt: found.expiresAt.toISOString(),
|
||||
moduleId: found.moduleId,
|
||||
permissionId: found.permissionId,
|
||||
// `null` for a platform display no module has been chosen for yet (ADR 0065).
|
||||
moduleId: found.moduleId ?? null,
|
||||
permissionId: found.permissionId ?? null,
|
||||
requestedAt: found.createdAt.toISOString(),
|
||||
},
|
||||
200,
|
||||
@@ -277,7 +298,15 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||
},
|
||||
);
|
||||
|
||||
/** Approval. The administrator names the device; the unpaired screen never names itself. */
|
||||
/**
|
||||
* Approval. The administrator names the device; the unpaired screen never names itself.
|
||||
*
|
||||
* `moduleId`, optional, is the installed display module the administrator chose for a platform
|
||||
* display to show (ADR 0065). It must be a running display, and the permission granted is that
|
||||
* module's declared display permission, never one the request names. A screen that asked from a
|
||||
* module's own display page already names its module; a different `moduleId` is refused, so its
|
||||
* permission can never be swapped for another module's.
|
||||
*/
|
||||
routes.post(
|
||||
`${DEVICE_PAIRING_PREFIX}/approvals`,
|
||||
requireOrigin,
|
||||
@@ -287,9 +316,22 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||
const body = await readJsonObject(c);
|
||||
const code = body === undefined ? undefined : readString(body, "code", 16);
|
||||
const deviceName = body === undefined ? undefined : readString(body, "deviceName", 128);
|
||||
if (code === undefined || !/^[0-9]{6}$/u.test(code) || deviceName === undefined) {
|
||||
if (
|
||||
body === undefined ||
|
||||
code === undefined ||
|
||||
!/^[0-9]{6}$/u.test(code) ||
|
||||
deviceName === undefined
|
||||
) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
let display: { readonly moduleId: string; readonly permissionId: PermissionId } | undefined;
|
||||
if ("moduleId" in body) {
|
||||
const moduleId = readString(body, "moduleId", 128);
|
||||
display = moduleId === undefined ? undefined : findDisplay(moduleId);
|
||||
if (display === undefined) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
}
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
if (principal.kind !== "authenticated") {
|
||||
// Unreachable through `requireManage`, which only ever admits a person. Stated rather than
|
||||
@@ -301,6 +343,7 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||
approvedBy: principal.userId,
|
||||
code,
|
||||
deviceName,
|
||||
...(display === undefined ? {} : { display }),
|
||||
now: now(),
|
||||
});
|
||||
if (!approved) {
|
||||
@@ -309,7 +352,10 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||
return sanitizedFailure(c, 404, "DEVICE_PAIRING_UNKNOWN");
|
||||
}
|
||||
noStore(c);
|
||||
return c.json({ moduleId: approved.moduleId, permissionId: approved.permissionId }, 200);
|
||||
return c.json(
|
||||
{ moduleId: approved.moduleId ?? null, permissionId: approved.permissionId ?? null },
|
||||
200,
|
||||
);
|
||||
} catch {
|
||||
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
|
||||
}
|
||||
@@ -317,31 +363,75 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||
);
|
||||
|
||||
/**
|
||||
* How a new screen starts pairing (ADR 0065): the platform display page for every running module
|
||||
* that shows content on screens, for Settings → Displays to tell an administrator what to open on
|
||||
* the screen. Each page is the generic display host at `/display/<module-id>` (ADR 0033), which
|
||||
* shows a pairing code until the screen is approved.
|
||||
* The modules a display can be set to show (ADR 0065): every running module with a display, by
|
||||
* name, with the display permission showing it requires, for Settings → Displays' "Show on this
|
||||
* screen" choice and to name what each paired display shows.
|
||||
*
|
||||
* Administrative, like the device list beside it: `core.devices.manage`, and never anonymous, so
|
||||
* this does not become a way to enumerate what is installed — an unpaired screen still learns
|
||||
* nothing beyond the page it was told to open. It answers names and root-relative page paths only;
|
||||
* the browser resolves them against the API origin, which is where the display host lives in both
|
||||
* topologies (ADR 0045). An empty list is a real answer: no installed module uses displays yet.
|
||||
* it is not a way to enumerate what is installed. Setting up a screen needs none of it — that is
|
||||
* the platform's own `/display/setup`, whatever is installed. An empty list is a real answer: no
|
||||
* installed module uses displays yet, and a display paired now simply shows nothing until one is.
|
||||
*/
|
||||
routes.get(`${DEVICES_PREFIX}/setup`, requireManage, (c: Context) => {
|
||||
routes.get(`${DEVICES_PREFIX}/display-modules`, requireManage, (c: Context) => {
|
||||
noStore(c);
|
||||
return c.json(
|
||||
{
|
||||
displays: options.moduleUi.listDisplays().map((display) => ({
|
||||
modules: options.moduleUi.listDisplays().map((display) => ({
|
||||
moduleId: display.moduleId,
|
||||
name: display.name,
|
||||
url: moduleDisplayPageUrl(display.moduleId),
|
||||
permissionId: display.displayPermission,
|
||||
})),
|
||||
},
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A paired display asking about itself (ADR 0065): its name, and the module displays it may show
|
||||
* now. The platform's setup page asks this to know whether to open a module's display, to show
|
||||
* "paired as Foyer TV" while nothing is assigned, and to notice revocation. Asking also keeps an
|
||||
* idle display's credential alive, exactly like a module display's descriptor (ADR 0056).
|
||||
*
|
||||
* Only a device is answered. A person — even an administrator who opened the page on their own
|
||||
* laptop — and an unpaired screen are refused, so this reveals nothing to anyone else.
|
||||
*/
|
||||
routes.get(`${DEVICE_PAIRING_PREFIX}/self`, async (c: Context) => {
|
||||
noStore(c);
|
||||
c.header("Vary", "Cookie");
|
||||
let principal: Awaited<ReturnType<typeof options.runtime.resolvePrincipal>>;
|
||||
try {
|
||||
principal = await options.runtime.resolvePrincipal(c.req.raw.headers);
|
||||
} catch {
|
||||
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
|
||||
}
|
||||
if (principal.kind !== "device") {
|
||||
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
try {
|
||||
const self = await options.devices.describeDevice(principal.deviceId);
|
||||
if (!self) {
|
||||
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
renewDeviceSessionCookie(c, options.secureCookies);
|
||||
const held = new Set(self.permissions);
|
||||
return c.json(
|
||||
{
|
||||
displays: options.moduleUi
|
||||
.listDisplays()
|
||||
.filter((display) => held.has(display.displayPermission))
|
||||
.map((display) => ({
|
||||
moduleId: display.moduleId,
|
||||
url: moduleDisplayPageUrl(display.moduleId),
|
||||
})),
|
||||
name: self.name,
|
||||
},
|
||||
200,
|
||||
);
|
||||
} catch {
|
||||
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
|
||||
}
|
||||
});
|
||||
|
||||
routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => {
|
||||
try {
|
||||
const devices = await options.devices.listDevices();
|
||||
|
||||
@@ -14,3 +14,10 @@ export function moduleDisplayPageUrl(moduleId: string): string {
|
||||
export function moduleDisplayDescriptorUrl(moduleId: string): string {
|
||||
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a new screen starts pairing as a platform display, before any module is chosen for it (ADR
|
||||
* 0065). `setup` is a reserved module id (`RESERVED_DISPLAY_HOST_NAMES`), so no module's display can
|
||||
* take this address.
|
||||
*/
|
||||
export const DISPLAY_SETUP_PAGE_URL = `${MODULE_DISPLAY_PAGE_PREFIX}/setup`;
|
||||
|
||||
@@ -8,13 +8,20 @@ import {
|
||||
} from "./authorization-middleware.js";
|
||||
import { etagMatches } from "./conditional-request.js";
|
||||
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
||||
|
||||
export {
|
||||
DISPLAY_SETUP_PAGE_URL,
|
||||
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||
MODULE_DISPLAY_PAGE_PREFIX,
|
||||
moduleDisplayDescriptorUrl,
|
||||
moduleDisplayPageUrl,
|
||||
} from "./module-display-path.js";
|
||||
import { moduleDisplayDescriptorUrl, moduleDisplayPageUrl } from "./module-display-path.js";
|
||||
|
||||
import {
|
||||
DISPLAY_SETUP_PAGE_URL,
|
||||
moduleDisplayDescriptorUrl,
|
||||
moduleDisplayPageUrl,
|
||||
} from "./module-display-path.js";
|
||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||
import {
|
||||
@@ -80,9 +87,16 @@ export interface HostModuleDisplayResource {
|
||||
export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
/** The same immutable, eligible/running publication registry used for artifact bytes. */
|
||||
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
||||
* surface-owned files before binding and supplies their frozen contents here. */
|
||||
/** Omitted only when the registry contains no display entrypoints and the deployment has no
|
||||
* device support. Production reads the exact surface-owned files before binding and supplies their
|
||||
* frozen contents here. */
|
||||
readonly assets?: DisplayHostAssets;
|
||||
/**
|
||||
* Serve the platform's own setup page, `/display/setup`, where a new screen pairs as a platform
|
||||
* display before any module is chosen for it (ADR 0065). Composed whenever the deployment has
|
||||
* device support, whatever is installed — including no module with a display at all.
|
||||
*/
|
||||
readonly platformSetup?: boolean;
|
||||
/** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the
|
||||
* deployment has device support; called only for a request that resolved to a device. */
|
||||
readonly renewDeviceCredential?: (c: Context) => void;
|
||||
@@ -229,7 +243,7 @@ function mountDisplay(
|
||||
*/
|
||||
export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutesOptions): void {
|
||||
const displays = options.moduleUi.listDisplays();
|
||||
if (displays.length === 0) {
|
||||
if (displays.length === 0 && options.platformSetup !== true) {
|
||||
return;
|
||||
}
|
||||
if (!options.assets) {
|
||||
@@ -290,6 +304,26 @@ export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutes
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The platform's setup page (ADR 0065): the same inert, module-agnostic page bytes as every module
|
||||
* display, so it carries nothing. The browser reads `setup` from its own address and asks to be
|
||||
* paired with no module; what it may show once paired comes from the device's own permissions,
|
||||
* through `/api/v1/device-pairing/self`. `setup` is a reserved module id, so no module's display
|
||||
* can take this address.
|
||||
*/
|
||||
if (options.platformSetup === true) {
|
||||
app.get(DISPLAY_SETUP_PAGE_URL, () => {
|
||||
return new Response(assets.html, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const display of displays) {
|
||||
mountDisplay(app, { ...options, assets }, display);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ import type { Socket } from "node:net";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { EMPTY_MODULE_SECRET_SOURCE, type ModuleSecretSource } from "@ekklesia/contracts";
|
||||
import { createAccountStore } from "@ekklesia/core/account";
|
||||
import {
|
||||
createAuthorizationEvaluator,
|
||||
createAuthorizationStore,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { createAccountStore } from "@ekklesia/core/account";
|
||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||
import { createDeviceStore } from "@ekklesia/core/devices";
|
||||
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
|
||||
@@ -554,8 +554,9 @@ export async function startApi(
|
||||
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
|
||||
let dashboardHostAssets: Awaited<ReturnType<typeof loadDashboardHostAssets>>;
|
||||
try {
|
||||
displayHostAssets =
|
||||
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
||||
// Unconditional: every installation can pair a screen at `/display/setup`, whatever modules are
|
||||
// installed (ADR 0065), so a missing display host build fails here too.
|
||||
displayHostAssets = await loadDisplayHostAssets();
|
||||
// Unconditional, unlike the display host: every installation serves its Dashboard (ADR 0044),
|
||||
// so a missing shell build fails here, before any module code is imported.
|
||||
// The page learns its API origin from the API that serves it, so one build serves either
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user