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
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user