Files
Signage/src/server/routes/viewers.ts
T
2026-08-28 16:41:54 -05:00

260 lines
9.1 KiB
TypeScript

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<StoredChannel> {
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;
}