import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk"; import { type DisplayHeartbeatResponse, type ProtocolResult, parseDisplayHeartbeat, parseViewerSessionRequest, type ViewerSessionResponse, } from "../../shared/display-protocol.js"; import { buildDisplayChannelState } from "../display/state.js"; import { SignageError, signageBoundary } from "../errors.js"; import { optionalString, readJsonObject, requireUuidParam } from "../http.js"; import type { SignagePlaybackService } from "../playback/service.js"; import type { SignageProviderRegistry } from "../providers/registry.js"; import { readSignageSettings } from "../settings.js"; import { createSignageStore } from "../store/drizzle.js"; import type { SignageStore, StoredChannel } from "../store/types.js"; import { describeViewerHealth, openViewerSession, recordViewerHeartbeat, SIGNAGE_VIEWER_NAME_MAX, updateViewerAssignment, } from "../viewers/service.js"; /** How a route reaches Signage's tables. Production uses the module database; the display contract * test supplies the in-memory store so the real handlers can answer the real display controller. */ /** How often a display checks in. Sent to the display rather than built into it, so the server can * change its own expectations without a display deployment. */ export const SIGNAGE_HEARTBEAT_INTERVAL_MS = 12_000; /** A display message the shared protocol refused, as Signage's own sanitized code naming the field. */ function accepted(result: ProtocolResult): Value { if (!result.ok) { throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: result.field }); } return result.value; } export type ViewerRouteStore = (context: ModuleRouteHandlerContext) => SignageStore; const databaseStore: ViewerRouteStore = (context) => createSignageStore(context.services.database); /** * The safe Core device identifier, when the caller is a paired display. * * A device principal is deliberately **not** an authenticated person (ADR 0036), and Signage treats * it as exactly what it is: an opaque identifier to correlate its own roster with Core's. Pairing, * revocation, and every credential remain entirely Core's — nothing in Signage reads, stores, or * forwards one. */ function deviceIdOf(context: ModuleRouteHandlerContext): string | null { return context.principal.kind === "device" ? context.principal.deviceId : null; } async function requireChannel( signageStore: SignageStore, channelId: string, ): Promise { const groups = await signageStore.listChannelGroups(); for (const group of groups) { const channel = group.channels.find((candidate) => candidate.id === channelId); if (channel) { return channel; } } throw new SignageError("SIGNAGE_NOT_FOUND"); } export function registerViewerRoutes( http: ModuleHttpRouteRegistrar, playback: () => SignagePlaybackService, registry: () => SignageProviderRegistry, store: ViewerRouteStore = databaseStore, ): void { http.route({ method: "POST", path: "/viewers/session", permission: "signage.display.view", maxBodyBytes: 2048, handler(request, context) { return signageBoundary(async () => { const { viewerKey } = accepted(parseViewerSessionRequest(await readJsonObject(request))); // A display registers with the identity it keeps in its own IndexedDB and lands on the // default channel. It never chooses one: a public screen that could would let anyone who // reaches the page decide what a wall shows (ADR 0004). const session = await openViewerSession({ at: new Date(), deviceId: deviceIdOf(context), store: store(context), viewerKey, }); const response: ViewerSessionResponse = { channelId: session.channel.id, channelKey: session.channel.key, groupId: session.groupId, heartbeatIntervalMs: SIGNAGE_HEARTBEAT_INTERVAL_MS, viewerId: session.viewer.id, viewerName: session.viewer.name, }; return Response.json(response); }); }, }); http.route({ method: "POST", path: "/viewers/:viewerId/heartbeat", permission: "signage.display.view", maxBodyBytes: 4096, handler(request, context) { return signageBoundary(async () => { const viewerId = requireUuidParam(context, "viewerId"); const heartbeat = accepted(parseDisplayHeartbeat(await readJsonObject(request))); const settings = readSignageSettings(context.config); // Telemetry only. Nothing a display reports here changes authoritative playback, and a // local cursor is never accepted as truth. const result = await recordViewerHeartbeat({ at: new Date(), heartbeat, minimumWriteIntervalMs: settings.viewerHeartbeatWriteMs, store: store(context), viewerId, }); // The response says what a display needs to keep itself honest, and nothing about other // displays or the installation. const response: DisplayHeartbeatResponse = { acknowledged: true, recorded: result.written }; return Response.json(response); }); }, }); http.route({ method: "GET", path: "/channels/:channelId/display-state", permission: "signage.display.view", maxBodyBytes: 0, handler(_request, context) { return signageBoundary(async () => { const channelId = requireUuidParam(context, "channelId"); const signageStore = store(context); const channel = await requireChannel(signageStore, channelId); const snapshot = await playback().snapshot(channel.groupId); return Response.json( await buildDisplayChannelState({ atMs: Date.now(), channel, playback: snapshot, registry: registry(), settings: readSignageSettings(context.config), storage: context.storage, store: signageStore, }), ); }); }, }); http.route({ method: "GET", path: "/viewers", permission: "signage.view", maxBodyBytes: 0, handler(_request, context) { return signageBoundary(async () => { const atMs = Date.now(); const viewers = await store(context).listViewers(); return Response.json({ viewers: viewers.map((viewer) => describeViewerHealth(viewer, atMs)), }); }); }, }); http.route({ method: "PATCH", path: "/viewers/:viewerId", permission: "signage.displays.manage", maxBodyBytes: 4096, handler(request, context) { return signageBoundary(async () => { const viewerId = requireUuidParam(context, "viewerId"); const body = await readJsonObject(request); const channelId = body.channelId; if (channelId !== undefined && typeof channelId !== "string") { throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "channelId" }); } const viewer = await updateViewerAssignment({ ...(channelId === undefined ? {} : { channelId }), name: optionalString(body, "name", { maxLength: SIGNAGE_VIEWER_NAME_MAX }), store: store(context), viewerId, }); return Response.json({ viewer: describeViewerHealth(viewer, Date.now()) }); }); }, }); http.route({ method: "DELETE", path: "/viewers/:viewerId", permission: "signage.displays.manage", maxBodyBytes: 0, handler(_request, context) { return signageBoundary(async () => { const viewerId = requireUuidParam(context, "viewerId"); const signageStore = store(context); if (!(await signageStore.findViewer(viewerId))) { throw new SignageError("SIGNAGE_NOT_FOUND"); } // Removing a screen from the roster is a record-keeping action. A display that checks in // again is simply registered afresh, because its own identity is the one it keeps. await signageStore.deleteViewer(viewerId); return new Response(null, { status: 204 }); }); }, }); }