260 lines
9.1 KiB
TypeScript
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;
|
|
}
|