Manage paired displays: themed select, reassignment, live list, no revoked clutter
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m28s
CI / image-build (pull_request) Successful in 1m9s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 8m58s

Real-device review of the platform display flow found four defects in
display management:

- Shared SelectMenu in @ekklesia/ui (additive): an Ekklesia-drawn
  single choice following the WAI-ARIA select-only combobox pattern,
  replacing the native select for "Show on this screen". It has a
  themed list, a chevron inside the control, keyboard support (arrows,
  Home/End, type-ahead, Enter/Space/Tab, and Escape that closes only
  the list), pointer and touch support, and focus that stays on the
  control. It is an ek-control, so it keeps the 16px phone size.
- Newly paired displays appear without a reload. Approval doesn't
  create the display; the screen does when it claims its credential a
  few seconds later. The page now shows a "Waiting for the screen to
  connect" placeholder and refetches every second until the new display
  appears (for up to a minute), and refreshes the list every 30
  seconds.
- What a display shows can change after pairing. In the single-target
  Stage 1 model, a display's current target is its one active display
  permission, so no migration is needed.
  PUT /api/v1/devices/:id/display, with core.devices.manage, a trusted
  origin and a bounded body, revokes its other active permission rows
  (kept as history) and grants the chosen module's declared display
  permission, in one transaction on the same device. The screen notices
  within about 30 seconds: a paired device on a module page that is no
  longer set to show it goes to /display/setup instead of re-pairing,
  and /display/setup opens the new target.
- Revoked displays leave the normal list at once. Their rows stay in
  the database as history.

Each display card now shows its name, Online/Offline/Not connected yet,
"Showing: ...", last seen and paired dates, and Change and Revoke.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-16 18:29:15 -05:00
co-authored by Claude Opus 5.5
parent 300d9eab51
commit d9dafcb0f7
18 changed files with 1892 additions and 197 deletions
+50
View File
@@ -432,6 +432,56 @@ export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
}
});
/**
* Changes what a paired display shows (ADR 0065): `{ "moduleId": "<id>" }` for a running display
* module, or `{ "moduleId": null }` for nothing. The permission granted is that module's declared
* display permission, never one the request names. It is the same display — the same identity,
* name, and credential — before and after; the screen notices on its next check and opens the new
* module's page, or the platform's setup page, without being paired again.
*/
routes.put(
`${DEVICES_PREFIX}/:deviceId/display`,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c: Context) => {
const parsed = safeParseEkklesiaDeviceId(c.req.param("deviceId"));
const body = await readJsonObject(c);
if (!parsed.success || body === undefined || !("moduleId" in body)) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
let permissionId: PermissionId | undefined;
if (body.moduleId !== null) {
const moduleId = readString(body, "moduleId", 128);
const display = moduleId === undefined ? undefined : findDisplay(moduleId);
if (display === undefined) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
permissionId = display.permissionId;
}
const principal = getAuthorizedPrincipal(c);
if (principal.kind !== "authenticated") {
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
}
try {
const changed = await options.devices.setDisplayTarget({
changedBy: principal.userId,
deviceId: parsed.data,
now: now(),
permissionId,
});
if (!changed) {
// Unknown and revoked alike: neither is a display whose content can change.
return sanitizedFailure(c, 404, "NOT_FOUND");
}
noStore(c);
return c.json({ moduleId: body.moduleId === null ? null : body.moduleId }, 200);
} catch {
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
}
},
);
routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => {
try {
const devices = await options.devices.listDevices();
+139 -14
View File
@@ -105,10 +105,14 @@ async function createEmptyModulesDirectory(): Promise<string> {
return directory;
}
async function createDisplayModuleDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-"));
trackedDirectories.push(directory);
const moduleDirectory = path.join(directory, "announcements");
/** A second display module's permission, for moving a display from one module to another. */
const bulletinPermission = "bulletin.display.view";
async function writeDisplayModule(
directory: string,
module: { readonly id: string; readonly name: string; readonly permission: string },
): Promise<void> {
const moduleDirectory = path.join(directory, module.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await Promise.all([
@@ -116,7 +120,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: "@test/module-announcements",
name: `@test/module-${module.id}`,
type: "module",
version: "1.0.0",
}),
@@ -126,17 +130,17 @@ async function createDisplayModuleDirectory(): Promise<string> {
JSON.stringify({
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: {
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
display: { artifact: "./dist/ui/display.js", permission: module.permission },
server: "./dist/server/index.js",
},
id: "announcements",
id: module.id,
manifestVersion: 8,
name: "Announcements fixture",
name: module.name,
permissions: [
{
baseline: "restricted",
description: "View the announcement slideshow.",
id: displayPermission,
description: "View this module's display.",
id: module.permission,
},
],
version: "1.0.0",
@@ -145,10 +149,10 @@ async function createDisplayModuleDirectory(): Promise<string> {
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default {
id: "announcements",
id: ${JSON.stringify(module.id)},
version: "1.0.0",
register({ realtime }) {
realtime.channel({ channel: "state", permission: ${JSON.stringify(displayPermission)} });
realtime.channel({ channel: "state", permission: ${JSON.stringify(module.permission)} });
},
start({ realtime }) {
realtime.publish("state", { slides: [] });
@@ -158,6 +162,25 @@ async function createDisplayModuleDirectory(): Promise<string> {
),
writeFile(path.join(moduleDirectory, "dist/ui/display.js"), "export function mount() {}\n"),
]);
}
async function createDisplayModuleDirectory(
options: { readonly second?: boolean } = {},
): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-"));
trackedDirectories.push(directory);
await writeDisplayModule(directory, {
id: "announcements",
name: "Announcements fixture",
permission: displayPermission,
});
if (options.second === true) {
await writeDisplayModule(directory, {
id: "bulletin",
name: "Bulletin fixture",
permission: bulletinPermission,
});
}
return directory;
}
@@ -275,7 +298,7 @@ interface Platform {
async function platform(
label: string,
options: { readonly modules?: boolean } = {},
options: { readonly modules?: boolean | "two" } = {},
): Promise<Platform> {
const url = await createMigratedDatabase(label);
// `modules: false` is an installation with no module at all — nothing that shows content on a
@@ -283,7 +306,7 @@ async function platform(
const modulesDirectory =
options.modules === false
? await createEmptyModulesDirectory()
: await createDisplayModuleDirectory();
: await createDisplayModuleDirectory({ second: options.modules === "two" });
const api = await startPlatform(url, modulesDirectory);
const origin = apiOrigin(api);
const ownerCookie = await createOwner(url, origin);
@@ -1147,4 +1170,106 @@ describe.sequential("platform display pairing (ADR 0065)", () => {
await running.close();
}
});
it("changes what a paired display shows, on the same display, without pairing it again", async () => {
const running = await platform("platform-change", { modules: "two" });
const device = new DeviceBrowser(running.origin);
const put = (deviceId: string, moduleId: string | null) =>
adminRequest(
running.origin,
running.ownerCookie,
`/api/v1/devices/${deviceId}/display`,
{ moduleId },
{ method: "PUT" },
);
const listed = async () =>
(
(await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ id: string; name: string; permissions: string[] }> }
).devices;
const self = async () =>
(await (await device.request("/api/v1/device-pairing/self")).json()) as {
displays: Array<{ moduleId: string }>;
};
try {
// Paired from /display/setup with nothing to show.
const { code } = (await (
await device.post("/api/v1/device-pairing/requests", {})
).json()) as { code: string };
await adminRequest(running.origin, running.ownerCookie, "/api/v1/device-pairing/approvals", {
code,
deviceName: "Foyer TV",
});
await device.post("/api/v1/device-pairing/claims", {});
const [paired] = await listed();
const id = paired?.id ?? "";
expect(paired?.permissions).toEqual([]);
// Nothing yet → a module: it may now show it, and the screen learns so.
expect((await put(id, "announcements")).status).toBe(200);
expect((await self()).displays.map((display) => display.moduleId)).toEqual(["announcements"]);
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(200);
// Module A → module B: A's permission is withdrawn, B's granted, in one change.
expect((await put(id, "bulletin")).status).toBe(200);
expect((await self()).displays.map((display) => display.moduleId)).toEqual(["bulletin"]);
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
expect((await device.request("/api/v1/module-displays/bulletin")).status).toBe(200);
// Module → nothing: it shows nothing, and is still the same paired display.
expect((await put(id, null)).status).toBe(200);
expect((await self()).displays).toEqual([]);
expect((await device.request("/api/v1/module-displays/bulletin")).status).toBe(403);
// Always the one identity, the one name, and the one credential.
const after = await listed();
expect(after).toHaveLength(1);
expect(after[0]).toMatchObject({ id, name: "Foyer TV", permissions: [] });
expect(device.has("ekklesia_device_session")).toBe(true);
// Every change is kept as history: granted rows are revoked, never deleted.
const rows = await query<{ permission_id: string; revoked: boolean }>(
running.url,
`select permission_id, revoked_at is not null as revoked from ekklesia_core_device_permission order by granted_at`,
);
expect(rows).toEqual([
{ permission_id: displayPermission, revoked: true },
{ permission_id: bulletinPermission, revoked: true },
]);
// A module that is not a running display, or a missing body, is refused; so is a revoked
// display, and a person without a session.
expect((await put(id, "not-a-display")).status).toBe(400);
expect(
(
await adminRequest(
running.origin,
running.ownerCookie,
`/api/v1/devices/${id}/display`,
{},
{ method: "PUT" },
)
).status,
).toBe(400);
expect(
(
await fetch(`${running.origin}/api/v1/devices/${id}/display`, {
body: JSON.stringify({ moduleId: null }),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "PUT",
})
).status,
).toBe(403);
await adminRequest(running.origin, running.ownerCookie, `/api/v1/devices/${id}/revoke`, {});
expect((await put(id, "announcements")).status).toBe(404);
// Revoking keeps the identity as history: the row is still there, marked revoked.
const revoked = await query<{ revoked: boolean }>(
running.url,
`select revoked_at is not null as revoked from ekklesia_core_device`,
);
expect(revoked).toEqual([{ revoked: true }]);
} finally {
await running.close();
}
});
});