Files
Signage/src/server/routes/viewers.ts
T
ekklesiaandClaude Opus 5.5 f1b7d67aa5
CI / lint (pull_request) Successful in 13s
CI / typecheck (pull_request) Successful in 15s
CI / test (pull_request) Successful in 21s
CI / build (pull_request) Successful in 18s
CI / integration (pull_request) Successful in 3m32s
fix(display): define the display-server messages once, in src/shared
The display controller and the viewer routes each described the
heartbeat their own way and disagreed about one thing: the display sent
null for "nothing to report", and the route accepted only a missing
field. Every heartbeat from a screen showing a native image was refused
with a 400. The roster therefore marked working screens Offline 90 s
after they opened, and never recorded offlineReady or cachedAssetCount.

src/shared/display-protocol.ts now defines every message between a
display and the server:
- viewer session request and response;
- heartbeat request and response;
- channel state;
- the realtime display payload.

Both halves import it. Its rules: a nullable field accepts null
explicitly, an absent field means "not reported", and an unknown field
is ignored, so a newer display is never refused by an older server.

- The routes parse with the shared parsers, and a refusal names the
  field with Signage's sanitized code.
- The server's channel state extends the wire type, so a field the
  display relies on cannot drift without a compile error.
- The display builds its heartbeat as the shared type, and reads its
  registration with the shared parser instead of casting it.
- Test stubs that described server responses more loosely than the
  server sends them (no viewerName, no stale, partial media) now match
  the real responses and are typed as the protocol.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 21:25:21 -05:00

218 lines
8.0 KiB
TypeScript

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<Value>(result: ProtocolResult<Value>): 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<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,
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 });
});
},
});
}