import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk"; import { buildDisplayChannelState } from "../display/state.js"; import { SignageError, signageBoundary } from "../errors.js"; import { optionalBoolean, optionalInteger, 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"; const OPAQUE_ID = /^[\w][\w.:-]{0,199}$/u; function store(context: ModuleRouteHandlerContext): SignageStore { return 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, ): void { http.route({ method: "POST", path: "/viewers/session", permission: "signage.display.view", maxBodyBytes: 2048, handler(request, context) { return signageBoundary(async () => { const body = await readJsonObject(request); const viewerKey = body.viewerKey; if (typeof viewerKey !== "string") { throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "viewerKey" }); } // 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, }); return Response.json({ channelId: session.channel.id, channelKey: session.channel.key, groupId: session.groupId, heartbeatIntervalMs: 12_000, viewerId: session.viewer.id, viewerName: session.viewer.name, }); }); }, }); 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 body = await readJsonObject(request); const settings = readSignageSettings(context.config); const rawError = body.error; // 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: { cachedAssetCount: optionalInteger(body, "cachedAssetCount", { maximum: 100_000, minimum: 0, }) ?? undefined, offlineReady: optionalBoolean(body, "offlineReady") ?? undefined, reportedContributionId: optionalString(body, "reportedContributionId", { maxLength: 200 }) ?? null, reportedPlaybackRevision: optionalInteger(body, "reportedPlaybackRevision", { maximum: Number.MAX_SAFE_INTEGER, minimum: 0, }) ?? null, reportedProviderId: optionalString(body, "reportedProviderId", { maxLength: 200 }) ?? null, reportedSelectionId: optionalString(body, "reportedSelectionId", { maxLength: 64 }) ?? null, reportedTimelineId: optionalString(body, "reportedTimelineId", { maxLength: 64 }) ?? null, ...(rawError === undefined ? {} : { error: rawError === null ? null : { code: readErrorCode(rawError), detail: readErrorDetail(rawError), }, }), }, 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. return Response.json({ acknowledged: true, recorded: result.written }); }); }, }); 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 }); }); }, }); } function readErrorCode(value: unknown): string { if (typeof value !== "object" || value === null) { throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error" }); } const code = (value as { code?: unknown }).code; if (typeof code !== "string" || !OPAQUE_ID.test(code)) { throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.code" }); } return code; } function readErrorDetail(value: unknown): string | null { const detail = (value as { detail?: unknown }).detail; if (detail === undefined || detail === null) { return null; } if (typeof detail !== "string") { throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.detail" }); } return detail; }