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