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
+18 -6
View File
@@ -104,12 +104,19 @@ export const ekklesiaCoreDevicePairingRequest = pgTable(
id: uuid("id").primaryKey().defaultRandom(),
code: text("code").notNull(),
claimSecretHash: text("claim_secret_hash").notNull(),
/** The display the device actually navigated to, resolved server-side from the publication
* registry. Never a value the browser chose. */
moduleId: text("module_id").notNull(),
/** Derived from that module's validated display declaration, so a device cannot ask for a
* permission of its choosing. */
permissionId: text("permission_id").notNull(),
/**
* The module display the device will show, and the display permission approval grants it, or
* both `null` for a platform pairing (ADR 0065): a screen that opened `/display/setup` and is
* paired as a platform display with no module yet.
*
* A module display page sets both when the request is made, resolved server-side from the
* publication registry and that module's validated display declaration, so a device cannot ask
* for a permission of its choosing. A platform request may have them set once, by the
* approving administrator choosing which installed display module the screen shows; the
* permission is again derived from that module's declaration, never from the request.
*/
moduleId: text("module_id"),
permissionId: text("permission_id"),
state: text("state").notNull().default("pending"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
@@ -140,6 +147,11 @@ export const ekklesiaCoreDevicePairingRequest = pgTable(
"ekklesia_core_device_pairing_state_check",
sql`${table.state} in ('pending', 'approved', 'consumed')`,
),
// A module and its display permission are set together or not at all.
check(
"ekklesia_core_device_pairing_module_check",
sql`(${table.moduleId} is null) = (${table.permissionId} is null)`,
),
],
);
+95 -16
View File
@@ -97,9 +97,13 @@ function generatePairingCode(): string {
return randomInt(0, 1_000_000).toString().padStart(6, "0");
}
/**
* A screen asking to be paired. `display` is the module display it opened, with the permission
* derived from that module's validated declaration; it is absent for a platform pairing from
* `/display/setup`, which creates a platform display with no module yet (ADR 0065).
*/
export interface CreatePairingRequestInput {
readonly moduleId: string;
readonly permissionId: PermissionId;
readonly display?: { readonly moduleId: string; readonly permissionId: PermissionId };
readonly now: Date;
}
@@ -114,8 +118,10 @@ export interface CreatedPairingRequest {
export type PairingRequestStatus = "pending" | "approved" | "expired" | "unknown";
export interface PairingRequestView {
readonly moduleId: string;
readonly permissionId: string;
/** The module display the screen will show, or `undefined` for a platform pairing that no module
* has been chosen for. */
readonly moduleId: string | undefined;
readonly permissionId: string | undefined;
readonly createdAt: Date;
readonly expiresAt: Date;
readonly status: "pending" | "approved";
@@ -137,6 +143,13 @@ export interface DeviceSummary {
readonly permissions: readonly string[];
}
/** One live device as it sees itself (ADR 0065): its name, and the permissions it holds now. */
export interface DeviceSelf {
readonly id: EkklesiaDeviceId;
readonly name: string;
readonly permissions: readonly string[];
}
export interface ResolvedDevice {
readonly deviceId: EkklesiaDeviceId;
readonly lastSeenAt?: Date;
@@ -146,11 +159,18 @@ export interface DeviceStore {
createPairingRequest(input: CreatePairingRequestInput): Promise<CreatedPairingRequest>;
/** Looks a live request up by the code an administrator typed. Never used by the device itself. */
findPendingByCode(code: string, now: Date): Promise<PairingRequestView | undefined>;
/**
* Approves a pending request by its code and names the display. `display`, when given, is the
* module display the administrator chose for a platform request; it is refused (the request is
* left pending and `undefined` returned) for a request that already names a different module,
* and ignored when it names the same one.
*/
approvePairingRequest(input: {
readonly code: string;
readonly deviceName: string;
readonly approvedBy: EkklesiaUserId;
readonly now: Date;
readonly display?: { readonly moduleId: string; readonly permissionId: PermissionId };
}): Promise<PairingRequestView | undefined>;
/** The device's own poll. Consumes an approved request atomically and issues the credential. */
claimPairingRequest(input: {
@@ -162,6 +182,8 @@ export interface DeviceStore {
touchLastSeen(deviceId: EkklesiaDeviceId, now: Date): Promise<void>;
hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise<boolean>;
listDevices(): Promise<readonly DeviceSummary[]>;
/** A live, unrevoked device's own name and active permissions, or `undefined`. */
describeDevice(deviceId: EkklesiaDeviceId): Promise<DeviceSelf | undefined>;
revokeDevice(input: {
readonly deviceId: EkklesiaDeviceId;
readonly revokedBy: EkklesiaUserId;
@@ -201,6 +223,11 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
try {
// Conditional on `pending` and unexpired, so approving twice, approving an expired request,
// or approving one already consumed all affect zero rows rather than racing.
//
// A module chosen for a platform request is written in the same statement, and only where
// the request names no module yet or already names that one, so a module display's own
// request can never be redirected to a different module's permission.
const chosen = input.display;
const rows = await db
.update(ekklesiaCoreDevicePairingRequest)
.set({
@@ -208,12 +235,23 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
approvedByUserId: input.approvedBy,
deviceName: input.deviceName,
state: "approved",
...(chosen === undefined
? {}
: { moduleId: chosen.moduleId, permissionId: chosen.permissionId }),
})
.where(
and(
eq(ekklesiaCoreDevicePairingRequest.code, input.code),
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now),
...(chosen === undefined
? []
: [
or(
isNull(ekklesiaCoreDevicePairingRequest.moduleId),
eq(ekklesiaCoreDevicePairingRequest.moduleId, chosen.moduleId),
),
]),
),
)
.returning({
@@ -225,7 +263,13 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
const row = rows[0];
return row === undefined
? undefined
: Object.freeze({ ...row, status: "approved" as const });
: Object.freeze({
createdAt: row.createdAt,
expiresAt: row.expiresAt,
moduleId: row.moduleId ?? undefined,
permissionId: row.permissionId ?? undefined,
status: "approved" as const,
});
} catch (error) {
rethrowAsStoreError(error);
}
@@ -297,13 +341,16 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
throw new DeviceStoreError("DEVICE_STORE_FAILED");
}
// Exactly the one permission the request was created for — never a bundle, never every
// permission the module declares.
await tx.insert(ekklesiaCoreDevicePermission).values({
deviceId,
grantedByUserId: request.approvedByUserId,
permissionId: request.permissionId,
});
// Exactly the one permission the request carries — never a bundle, never every
// permission the module declares — and none at all for a platform display no module was
// chosen for: it is paired, and simply shows nothing yet (ADR 0065).
if (request.permissionId !== null) {
await tx.insert(ekklesiaCoreDevicePermission).values({
deviceId,
grantedByUserId: request.approvedByUserId,
permissionId: request.permissionId,
});
}
await tx
.update(ekklesiaCoreDevicePairingRequest)
@@ -350,8 +397,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
claimSecretHash: hashDeviceSecret(claimSecret),
code,
expiresAt,
moduleId: input.moduleId,
permissionId: input.permissionId,
moduleId: input.display?.moduleId ?? null,
permissionId: input.display?.permissionId ?? null,
});
return Object.freeze({ claimSecret, code, expiresAt });
} catch {
@@ -386,8 +433,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
: Object.freeze({
createdAt: row.createdAt,
expiresAt: row.expiresAt,
moduleId: row.moduleId,
permissionId: row.permissionId,
moduleId: row.moduleId ?? undefined,
permissionId: row.permissionId ?? undefined,
status: "pending" as const,
});
} catch (error) {
@@ -421,6 +468,38 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
}
},
async describeDevice(deviceId): Promise<DeviceSelf | undefined> {
try {
const devices = await db
.select({ id: ekklesiaCoreDevice.id, name: ekklesiaCoreDevice.name })
.from(ekklesiaCoreDevice)
.where(and(eq(ekklesiaCoreDevice.id, deviceId), isNull(ekklesiaCoreDevice.revokedAt)))
.limit(1);
const device = devices[0];
if (device === undefined) {
return undefined;
}
const permissions = await db
.select({ permissionId: ekklesiaCoreDevicePermission.permissionId })
.from(ekklesiaCoreDevicePermission)
.where(
and(
eq(ekklesiaCoreDevicePermission.deviceId, deviceId),
isNull(ekklesiaCoreDevicePermission.revokedAt),
),
);
return Object.freeze({
id: device.id as EkklesiaDeviceId,
name: device.name,
permissions: Object.freeze(
[...new Set(permissions.map((row) => row.permissionId))].sort(),
),
});
} catch (error) {
rethrowAsStoreError(error);
}
},
async listDevices(): Promise<readonly DeviceSummary[]> {
try {
const devices = await db
+11 -4
View File
@@ -1,9 +1,9 @@
/**
* Names the platform owns, which therefore can never be a module id (ADR 0057, ADR 0061).
* Names the platform owns, which therefore can never be a module id (ADR 0057, ADR 0061, ADR 0065).
*
* A module id is a **stable URL identifier**. Every module with a Dashboard UI is addressed at
* `/<module-id>`, and every module with settings at `/settings/<module-id>`, beside the platform's
* own pages in the same two namespaces. A module called `settings` would collide with the
* `/<module-id>`, every module with settings at `/settings/<module-id>`, and every module with a
* display at `/display/<module-id>`, beside the platform's own pages in the same three namespaces. A module called `settings` would collide with the
* Dashboard's Settings, and one called `discover` with Settings → Discover. This is the complete set
* the platform serves today, not a guess at what it might want later.
*/
@@ -39,6 +39,13 @@ export const RESERVED_SETTINGS_NAMES = Object.freeze([
"system",
] as const);
/**
* The display host's own pages (`/display/<name>`): `setup`, where a new screen starts pairing as a
* platform display before any module is chosen for it (ADR 0065). Every other `/display/<name>` is a
* module's display.
*/
export const RESERVED_DISPLAY_HOST_NAMES = Object.freeze(["setup"] as const);
/**
* Every reserved name, sorted. `apps/api` holds this list to the routes it actually registers and to
* the pages and Settings sections the shell interprets, so a new platform route or Settings section
@@ -46,7 +53,7 @@ export const RESERVED_SETTINGS_NAMES = Object.freeze([
* installation with a module of that id, so the set grows only deliberately.
*/
export const RESERVED_MODULE_IDS = Object.freeze(
[...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES].sort(),
[...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES, ...RESERVED_DISPLAY_HOST_NAMES].sort(),
);
const reserved: ReadonlySet<string> = new Set(RESERVED_MODULE_IDS);