feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user