diff --git a/src/display/controller.ts b/src/display/controller.ts index 35bd1aa..98db5cc 100644 --- a/src/display/controller.ts +++ b/src/display/controller.ts @@ -18,6 +18,13 @@ import { type SignageMediaCache, signageMediaKey, } from "../browser/storage.js"; +import { + type DisplayChannelState, + type DisplayHeartbeat, + type DisplayRealtimeGroup, + parseViewerSessionResponse, + type ViewerSessionResponse, +} from "../shared/display-protocol.js"; /** * The display: what it fetches, what it shows, and what it reports. @@ -38,36 +45,11 @@ const SNAPSHOT_STORAGE = "last-known-good"; * must not sit on a stale frame until the next; and a delivery URL has a finite life. */ const REFRESH_INTERVAL_MS = 4 * 60 * 1000; -export interface DisplayChannelStateView { - readonly serverNowMs: number; - readonly channel: { readonly id: string; readonly key: string; readonly groupId: string }; - readonly crossfadeMs: number; - readonly playback: { - readonly revision: number; - readonly transport: string; - readonly blackout: boolean; - readonly remainingMs: number | null; - readonly selectionRevision: number | null; - readonly currentSelectionId: string | null; - readonly currentSlotId: string | null; - readonly activeTimelineId: string | null; - readonly pendingTimelineId: string | null; - readonly startedAtMs: number | null; - readonly deadlineAtMs: number | null; - }; - readonly timeline: OfflineSnapshot["timeline"]; - readonly current: SceneSelection | null; - readonly staticHold: SceneSelection | null; - readonly selections: readonly SceneSelection[]; -} +/** The channel state a display renders, exactly as the server sends it (`src/shared`). */ +export type DisplayChannelStateView = DisplayChannelState; -export interface DisplaySessionView { - readonly viewerId: string; - readonly channelId: string; - readonly channelKey: string; - readonly groupId: string; - readonly heartbeatIntervalMs: number; -} +/** The display's registration, exactly as the server answers it (`src/shared`). */ +export type DisplaySessionView = ViewerSessionResponse; /** What an operator can see about this screen, and what a test asserts on. Sanitized by * construction: identity, counts, and a code. */ @@ -533,21 +515,22 @@ export function createDisplayController(options: DisplayControllerOptions): Disp return; } try { + // Built as the shared message type, which is the same type the route parses with. + const report: DisplayHeartbeat = { + cachedAssetCount, + error: lastError === undefined ? null : { code: lastError.code, detail: lastError.detail }, + offlineReady: snapshot !== undefined && snapshot.selections.length > 0, + reportedContributionId: + lastState?.current?.renderer.kind === "contribution" + ? lastState.current.renderer.contributionId + : null, + reportedPlaybackRevision: appliedRevision >= 0 ? appliedRevision : null, + reportedProviderId: lastState?.current?.providerId ?? null, + reportedSelectionId: renderedSelectionId ?? null, + reportedTimelineId: lastState?.playback.activeTimelineId ?? null, + }; await options.context.api.request(`viewers/${session.viewerId}/heartbeat`, { - body: JSON.stringify({ - cachedAssetCount, - error: - lastError === undefined ? null : { code: lastError.code, detail: lastError.detail }, - offlineReady: snapshot !== undefined && snapshot.selections.length > 0, - reportedContributionId: - lastState?.current?.renderer.kind === "contribution" - ? lastState.current.renderer.contributionId - : null, - reportedPlaybackRevision: appliedRevision >= 0 ? appliedRevision : null, - reportedProviderId: lastState?.current?.providerId ?? null, - reportedSelectionId: renderedSelectionId ?? null, - reportedTimelineId: lastState?.playback.activeTimelineId ?? null, - }), + body: JSON.stringify(report), headers: { "content-type": "application/json" }, method: "POST", }); @@ -602,8 +585,12 @@ export function createDisplayController(options: DisplayControllerOptions): Disp method: "POST", }); if (response.ok) { - session = (await response.json()) as DisplaySessionView; - online = true; + // Read, not cast: a registration the display cannot understand is no registration. + const parsed = parseViewerSessionResponse(await response.json()); + if (parsed.ok) { + session = parsed.value; + online = true; + } } } catch { online = false; @@ -656,7 +643,10 @@ export function createDisplayController(options: DisplayControllerOptions): Disp if (stopped || !session) { return; } - const groups = (payload as { groups?: readonly Record[] } | null)?.groups; + // Checked at run time as well as typed: realtime state is the platform's transport, and a frame + // this display cannot read is simply not one it acts on. + const groups = (payload as { groups?: readonly Partial[] } | null) + ?.groups; if (!Array.isArray(groups)) { return; } diff --git a/src/server/display/state.ts b/src/server/display/state.ts index b408e26..1d6d7e0 100644 --- a/src/server/display/state.ts +++ b/src/server/display/state.ts @@ -1,4 +1,9 @@ import type { ModuleStorage } from "@ekklesia/module-sdk"; +import type { + DisplayChannelState as DisplayChannelStateWire, + DisplaySelection, + DisplayTimeline, +} from "../../shared/display-protocol.js"; import { resolveNativeScene, SIGNAGE_NATIVE_PROVIDER_ID, @@ -35,78 +40,21 @@ import type { * and a display refreshes state long before one lapses. */ -export interface DisplayMedia { - readonly url: string; - readonly expiresAtMs: number; - readonly width: number; - readonly height: number; -} +export type { + DisplayMedia, + DisplaySelection, + DisplayTimeline, + DisplayTimelineSlot, +} from "../../shared/display-protocol.js"; -export interface DisplaySelection { - readonly id: string; - readonly revision: number; - readonly slotId: string | null; - readonly providerId: string; - readonly itemId: string; - readonly itemRevision: string; - readonly startedAtMs: number | null; - readonly deadlineAtMs: number | null; - readonly durationMs: number; - readonly availableUntilMs: number | null; - readonly withdrawn: boolean; - readonly stale: boolean; - /** `native-image` is drawn by Signage's own display code; `contribution` is mounted through the - * platform's contribution ABI by id. A display never receives a URL for either. */ - readonly renderer: - | { readonly kind: "native-image" } - | { - readonly kind: "contribution"; - readonly contributionId: string; - readonly contributionVersion: string; - } - | { readonly kind: "unavailable" }; - /** The provider's own payload for this channel's audience, or its default. */ - readonly payload: unknown; - readonly presentation: unknown; - /** Native images only: fresh delivery URLs, and the already-resolved scene. */ - readonly media?: { readonly display: DisplayMedia; readonly thumbnail: DisplayMedia }; - readonly scene?: ReturnType; -} - -export interface DisplayTimelineSlot { - readonly id: string; - readonly position: number; - readonly enabled: boolean; - readonly eligibleFromMs: number | null; - readonly eligibleUntilMs: number | null; - readonly estimatedDurationMs: number; -} - -export interface DisplayTimeline { - readonly id: string; - readonly revision: number; - readonly eligibleFromMs: number | null; - readonly eligibleUntilMs: number | null; - readonly slots: readonly DisplayTimelineSlot[]; -} - -export interface DisplayChannelState { - readonly serverNowMs: number; - readonly channel: { - readonly id: string; - readonly key: string; - readonly name: string; - readonly groupId: string; - }; - readonly crossfadeMs: number; +/** + * The channel state the server builds: the wire shape a display reads (`src/shared`), plus the + * channel's name and the group's whole playback snapshot. Declared as an extension so that a field + * the display relies on cannot be renamed or retyped here without failing to compile. + */ +export interface DisplayChannelState extends DisplayChannelStateWire { + readonly channel: DisplayChannelStateWire["channel"] & { readonly name: string }; readonly playback: PlaybackSnapshot; - readonly timeline: DisplayTimeline | null; - readonly current: DisplaySelection | null; - /** The held image, already resolved as a scene. Separate from `current` because a static hold sits - * *over* a preserved cursor rather than replacing it. */ - readonly staticHold: DisplaySelection | null; - /** Bounded, newest first. Material for offline continuation, never a substitute for authority. */ - readonly selections: readonly DisplaySelection[]; } /** How many resolved selections travel to a display. Enough to cover a pattern and then some, small diff --git a/src/server/routes/viewers.ts b/src/server/routes/viewers.ts index 436904d..0c079c7 100644 --- a/src/server/routes/viewers.ts +++ b/src/server/routes/viewers.ts @@ -1,13 +1,14 @@ 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 { - optionalBoolean, - optionalInteger, - optionalString, - readJsonObject, - requireUuidParam, -} from "../http.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"; @@ -21,12 +22,24 @@ import { updateViewerAssignment, } from "../viewers/service.js"; -const OPAQUE_ID = /^[\w][\w.:-]{0,199}$/u; +/** 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; -function store(context: ModuleRouteHandlerContext): SignageStore { - return createSignageStore(context.services.database); +/** A display message the shared protocol refused, as Signage's own sanitized code naming the field. */ +function accepted(result: ProtocolResult): 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. * @@ -57,6 +70,7 @@ export function registerViewerRoutes( http: ModuleHttpRouteRegistrar, playback: () => SignagePlaybackService, registry: () => SignageProviderRegistry, + store: ViewerRouteStore = databaseStore, ): void { http.route({ method: "POST", @@ -65,11 +79,7 @@ export function registerViewerRoutes( 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" }); - } + 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). @@ -79,14 +89,15 @@ export function registerViewerRoutes( store: store(context), viewerKey, }); - return Response.json({ + const response: ViewerSessionResponse = { channelId: session.channel.id, channelKey: session.channel.key, groupId: session.groupId, - heartbeatIntervalMs: 12_000, + heartbeatIntervalMs: SIGNAGE_HEARTBEAT_INTERVAL_MS, viewerId: session.viewer.id, viewerName: session.viewer.name, - }); + }; + return Response.json(response); }); }, }); @@ -99,52 +110,21 @@ export function registerViewerRoutes( handler(request, context) { return signageBoundary(async () => { const viewerId = requireUuidParam(context, "viewerId"); - const body = await readJsonObject(request); + const heartbeat = accepted(parseDisplayHeartbeat(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), - }, - }), - }, + 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. - return Response.json({ acknowledged: true, recorded: result.written }); + const response: DisplayHeartbeatResponse = { acknowledged: true, recorded: result.written }; + return Response.json(response); }); }, }); @@ -235,25 +215,3 @@ export function registerViewerRoutes( }, }); } - -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; -} diff --git a/src/server/runtime.ts b/src/server/runtime.ts index f1d347d..a8c712e 100644 --- a/src/server/runtime.ts +++ b/src/server/runtime.ts @@ -3,6 +3,7 @@ import { type ModuleRuntimeContext, type ModuleRuntimeDefinition, } from "@ekklesia/module-sdk"; +import type { DisplayRealtimePayload } from "../shared/display-protocol.js"; import { SIGNAGE_SLIDE_RENDERER_INPUT_VERSION } from "../shared/renderer-input.js"; import { SIGNAGE_IMAGE_MAX_BYTES, @@ -98,10 +99,10 @@ export function createSignageModuleRuntime( left.groupId.localeCompare(right.groupId), ); try { - context.realtime.publish(SIGNAGE_DISPLAY_CHANNEL, { - groups, - serverNowMs: now().getTime(), - }); + // The shape the display reads (`src/shared/display-protocol.ts`): each group's whole snapshot + // carries the revisions it compares. + const display: DisplayRealtimePayload = { groups, serverNowMs: now().getTime() }; + context.realtime.publish(SIGNAGE_DISPLAY_CHANNEL, display); context.realtime.publish(SIGNAGE_MANAGEMENT_CHANNEL, { // Revisions only. The dashboard refetches what changed over authorized HTTP. groups: groups.map((snapshot) => ({ diff --git a/src/shared/display-protocol.ts b/src/shared/display-protocol.ts new file mode 100644 index 0000000..e165d9c --- /dev/null +++ b/src/shared/display-protocol.ts @@ -0,0 +1,318 @@ +/** + * What a display and Signage's server say to each other, defined once. + * + * The display controller builds these messages and the viewer routes read them, from this one file. + * They used to be described twice — once in the controller's own interfaces and once in each + * route's field-by-field parsing — and the two descriptions disagreed about one thing: the display + * sent `null` for "nothing to report" and the heartbeat route accepted only a *missing* field. Every + * heartbeat from a screen showing a native image was refused with a 400, silently, for as long as it + * ran. Nothing here can drift that way again, because there is nothing to drift between. + * + * Three rules hold for every message: + * + * - **A nullable field accepts `null` explicitly.** `null` means "I have nothing", and is a value. + * - **An absent field means "not reported".** A receiver keeps whatever it already had. + * - **An unknown field is ignored, never refused.** A newer display reporting something an older + * server does not know yet must still be heard; refusing it would be this same bug in reverse. + * + * Like the rest of `src/shared`, it imports nothing from outside `src/shared`: it is compiled into the browser + * artifact as well as the server, and must pull neither the module SDK nor Node into a display. + */ + +import type { SignageNativeScene } from "./native-images.js"; + +// --- Parsing --------------------------------------------------------------------------------------- + +/** The outcome of reading one message: the value, or the one field that was wrong. Never a parser's + * own message, which is not something a client or an operator should see. */ +export type ProtocolResult = + | { readonly ok: true; readonly value: Value } + | { readonly ok: false; readonly field: string }; + +type Reader = (raw: unknown) => { readonly ok: true; readonly value: Value } | undefined; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function text(options: { readonly maxLength: number; readonly pattern?: RegExp }): Reader { + return (raw) => { + if (typeof raw !== "string") { + return undefined; + } + const value = raw.trim(); + if (value.length === 0 || value.length > options.maxLength) { + return undefined; + } + if (options.pattern && !options.pattern.test(value)) { + return undefined; + } + return { ok: true, value }; + }; +} + +function integer(options: { readonly minimum: number; readonly maximum: number }): Reader { + return (raw) => + typeof raw === "number" && + Number.isSafeInteger(raw) && + raw >= options.minimum && + raw <= options.maximum + ? { ok: true, value: raw } + : undefined; +} + +const flag: Reader = (raw) => + typeof raw === "boolean" ? { ok: true, value: raw } : undefined; + +function nullable(read: Reader): Reader { + return (raw) => (raw === null ? { ok: true, value: null } : read(raw)); +} + +/** Reads the named fields of a message. Absent fields are left out of the result; any other field + * of the object is ignored. */ +function readFields( + raw: unknown, + readers: { readonly [Key in keyof Shape]-?: Reader> }, +): ProtocolResult { + if (!isRecord(raw)) { + return { field: "body", ok: false }; + } + const value: Record = {}; + for (const key of Object.keys(readers) as (keyof Shape & string)[]) { + if (raw[key] === undefined) { + continue; + } + const read = readers[key](raw[key]); + if (!read) { + return { field: key, ok: false }; + } + value[key] = read.value; + } + return { ok: true, value: value as Shape }; +} + +function requireFields( + result: ProtocolResult>, + required: readonly (keyof Shape & string)[], +): ProtocolResult { + if (!result.ok) { + return result; + } + for (const key of required) { + if (result.value[key] === undefined) { + return { field: key, ok: false }; + } + } + return result as ProtocolResult; +} + +// --- Limits ---------------------------------------------------------------------------------------- + +/** A display's own identity, kept in its IndexedDB. */ +export const DISPLAY_VIEWER_KEY = /^[\w-]{8,128}$/u; +/** The longest error detail a display may report. Operator-facing text a browser supplied, so it is + * bounded, and because an unbounded one is a way to fill a table. */ +export const DISPLAY_ERROR_DETAIL_MAX = 200; +export const DISPLAY_ERROR_CODE = /^[\w][\w.:-]{0,63}$/u; +export const DISPLAY_CACHED_ASSET_MAX = 100_000; + +// --- Viewer session: POST viewers/session ---------------------------------------------------------- + +export interface ViewerSessionRequest { + readonly viewerKey: string; +} + +export interface ViewerSessionResponse { + readonly viewerId: string; + readonly viewerName: string; + readonly channelId: string; + readonly channelKey: string; + readonly groupId: string; + readonly heartbeatIntervalMs: number; +} + +const identifier = text({ maxLength: 200 }); + +export function parseViewerSessionRequest(raw: unknown): ProtocolResult { + return requireFields( + readFields>(raw, { + viewerKey: text({ maxLength: 128, pattern: DISPLAY_VIEWER_KEY }), + }), + ["viewerKey"], + ); +} + +export function parseViewerSessionResponse(raw: unknown): ProtocolResult { + return requireFields( + readFields>(raw, { + channelId: identifier, + channelKey: identifier, + groupId: identifier, + heartbeatIntervalMs: integer({ maximum: 600_000, minimum: 1_000 }), + viewerId: identifier, + viewerName: text({ maxLength: 200 }), + }), + ["viewerId", "viewerName", "channelId", "channelKey", "groupId", "heartbeatIntervalMs"], + ); +} + +// --- Heartbeat: POST viewers/:viewerId/heartbeat --------------------------------------------------- + +export interface DisplayReportedError { + readonly code: string; + readonly detail: string | null; +} + +/** + * What a display reports about itself. Telemetry, never authority: nothing here changes playback. + * + * Every field is optional on the wire (absent = not reported). The display sends all of them. + */ +export interface DisplayHeartbeat { + readonly cachedAssetCount?: number; + readonly offlineReady?: boolean; + readonly reportedTimelineId?: string | null; + readonly reportedSelectionId?: string | null; + readonly reportedProviderId?: string | null; + readonly reportedContributionId?: string | null; + readonly reportedPlaybackRevision?: number | null; + readonly error?: DisplayReportedError | null; +} + +const reportedError: Reader = (raw) => { + const read = readFields<{ code?: string; detail?: string | null }>(raw, { + code: text({ maxLength: 64, pattern: DISPLAY_ERROR_CODE }), + detail: nullable(text({ maxLength: DISPLAY_ERROR_DETAIL_MAX })), + }); + if (!read.ok || read.value.code === undefined) { + return undefined; + } + return { ok: true, value: { code: read.value.code, detail: read.value.detail ?? null } }; +}; + +export function parseDisplayHeartbeat(raw: unknown): ProtocolResult { + return readFields(raw, { + cachedAssetCount: integer({ maximum: DISPLAY_CACHED_ASSET_MAX, minimum: 0 }), + error: nullable(reportedError), + offlineReady: flag, + reportedContributionId: nullable(text({ maxLength: 200 })), + reportedPlaybackRevision: nullable(integer({ maximum: Number.MAX_SAFE_INTEGER, minimum: 0 })), + reportedProviderId: nullable(text({ maxLength: 200 })), + reportedSelectionId: nullable(text({ maxLength: 64 })), + reportedTimelineId: nullable(text({ maxLength: 64 })), + }); +} + +export interface DisplayHeartbeatResponse { + readonly acknowledged: true; + readonly recorded: boolean; +} + +// --- Channel state: GET channels/:channelId/display-state ----------------------------------------- + +/** A fresh, signed delivery URL. A bearer capability with an expiry: never persisted by a display. */ +export interface DisplayMedia { + readonly url: string; + readonly expiresAtMs: number; + readonly width: number; + readonly height: number; +} + +export type DisplayRenderer = + | { readonly kind: "native-image" } + | { + readonly kind: "contribution"; + readonly contributionId: string; + readonly contributionVersion: string; + } + | { readonly kind: "unavailable" }; + +export interface DisplaySelection { + readonly id: string; + readonly revision: number; + readonly slotId: string | null; + readonly providerId: string; + readonly itemId: string; + readonly itemRevision: string; + readonly startedAtMs: number | null; + readonly deadlineAtMs: number | null; + readonly durationMs: number; + readonly availableUntilMs: number | null; + readonly withdrawn: boolean; + readonly stale: boolean; + /** `native-image` is drawn by Signage's own display code; `contribution` is mounted through the + * platform's contribution ABI by id. A display never receives a URL for either. */ + readonly renderer: DisplayRenderer; + /** The provider's own payload for this channel's audience, or its default. */ + readonly payload: unknown; + readonly presentation: unknown; + /** Native images only: fresh delivery URLs, and the already-resolved scene. */ + readonly media?: { readonly display: DisplayMedia; readonly thumbnail: DisplayMedia }; + readonly scene?: SignageNativeScene; +} + +export interface DisplayTimelineSlot { + readonly id: string; + readonly position: number; + readonly enabled: boolean; + readonly eligibleFromMs: number | null; + readonly eligibleUntilMs: number | null; + readonly estimatedDurationMs: number; +} + +export interface DisplayTimeline { + readonly id: string; + readonly revision: number; + readonly eligibleFromMs: number | null; + readonly eligibleUntilMs: number | null; + readonly slots: readonly DisplayTimelineSlot[]; +} + +/** The part of the group's playback a display acts on. The server sends its whole snapshot; this is + * what a display may rely on being there. */ +export interface DisplayPlayback { + readonly revision: number; + readonly transport: string; + readonly blackout: boolean; + readonly remainingMs: number | null; + readonly selectionRevision: number | null; + readonly currentSelectionId: string | null; + readonly currentSlotId: string | null; + readonly activeTimelineId: string | null; + readonly pendingTimelineId: string | null; + readonly startedAtMs: number | null; + readonly deadlineAtMs: number | null; +} + +export interface DisplayChannelState { + readonly serverNowMs: number; + readonly channel: { + readonly id: string; + readonly key: string; + readonly groupId: string; + }; + readonly crossfadeMs: number; + readonly playback: DisplayPlayback; + readonly timeline: DisplayTimeline | null; + readonly current: DisplaySelection | null; + /** The held image, already resolved as a scene. Separate from `current` because a static hold sits + * *over* a preserved cursor rather than replacing it. */ + readonly staticHold: DisplaySelection | null; + /** Bounded, newest first. Material for offline continuation, never a substitute for authority. */ + readonly selections: readonly DisplaySelection[]; +} + +// --- Realtime: the display-state channel ---------------------------------------------------------- + +/** One group's revisions on the realtime channel. Compact on purpose: a revision this display has + * not applied means "fetch the state you are authorized for", never content. */ +export interface DisplayRealtimeGroup { + readonly groupId: string; + readonly revision: number; + readonly selectionRevision: number | null; +} + +export interface DisplayRealtimePayload { + readonly groups: readonly DisplayRealtimeGroup[]; + readonly serverNowMs: number; +} diff --git a/tests/browser/contributed-renderer.test.tsx b/tests/browser/contributed-renderer.test.tsx index 2cd1943..9bf934f 100644 --- a/tests/browser/contributed-renderer.test.tsx +++ b/tests/browser/contributed-renderer.test.tsx @@ -43,6 +43,7 @@ const SELECTION = { }, revision: 1, slotId: "slot-trivia", + stale: false, startedAtMs: 0, withdrawn: false, }; diff --git a/tests/browser/display-contract.test.ts b/tests/browser/display-contract.test.ts new file mode 100644 index 0000000..09e3c2a --- /dev/null +++ b/tests/browser/display-contract.test.ts @@ -0,0 +1,223 @@ +// @vitest-environment happy-dom +import type { ModuleRouteDefinitionInput, ModuleRouteHandlerContext } from "@ekklesia/module-sdk"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createMemoryMediaCache, createMemoryStore } from "../../src/browser/storage.js"; +import { createDisplayController } from "../../src/display/controller.js"; +import { ingestNativeImage } from "../../src/server/images/service.js"; +import { + createPlaybackService, + type SignagePlaybackService, +} from "../../src/server/playback/service.js"; +import { createNativeImageProvider } from "../../src/server/providers/native-provider.js"; +import { + createProviderRegistry, + type SignageProviderRegistry, +} from "../../src/server/providers/registry.js"; +import { registerViewerRoutes } from "../../src/server/routes/viewers.js"; +import { addSlot, createTimeline } from "../../src/server/timelines/service.js"; +import { SIGNAGE_NATIVE_PROVIDER_ID } from "../../src/shared/native-images.js"; +import { SIGNAGE_DEFAULT_SETTINGS } from "../../src/shared/settings.js"; +import { createFakeStorage, type FakeStorage } from "../support/fake-storage.js"; +import { makeImage } from "../support/images.js"; +import { createMemorySignageStore, type MemorySignageStore } from "../support/memory-store.js"; +import { fakeConfiguration, fakeHandlerContext } from "../support/module-harness.js"; +import { createFakeHost, type FakeHost } from "./support/fake-host.js"; + +/** + * The display and the server, speaking to each other for real. + * + * Every other display test answers the controller with a hand-written stub, and every server test + * calls the service with a hand-written object. Each side was therefore only ever tested against its + * own idea of the other, and in production every heartbeat a display sent was refused with a 400: + * the display sent `null` for an absent value and the route accepted only a missing field. + * + * Here the **real** display controller's requests are answered by the **real** viewer routes, over + * Signage's in-memory store. A request one side builds and the other refuses fails this suite. + */ + +const START = Date.parse("2026-10-05T15:00:00.000Z"); +const DEVICE_ID = "6f1c2d3e-4b5a-4c6d-8e7f-901a2b3c4d5e"; + +interface RouteCall { + readonly method: string; + readonly path: string; + readonly status: number; + readonly code: string | undefined; +} + +/** Matches a relative module path against the routes the module registered, and runs the handler + * exactly as the platform would: one Request, one handler context. */ +function routeTable(routes: readonly ModuleRouteDefinitionInput[]) { + return routes.map((route) => { + const names: string[] = []; + const pattern = new RegExp( + `^${route.path.replace(/:(\w+)/gu, (_match, name: string) => { + names.push(name); + return "([^/]+)"; + })}$`, + "u", + ); + return { names, pattern, route }; + }); +} + +describe("display ↔ server contract", () => { + let clock: number; + let counter: number; + let store: MemorySignageStore; + let storage: FakeStorage; + let registry: SignageProviderRegistry; + let playback: SignagePlaybackService; + let calls: RouteCall[]; + let host: FakeHost; + let root: HTMLElement; + + const newId = () => { + counter += 1; + return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`; + }; + + beforeEach(async () => { + clock = START; + counter = 0; + calls = []; + store = createMemorySignageStore({ now: () => new Date(clock) }); + storage = createFakeStorage({ now: () => new Date(clock) }); + registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 }); + registry.capability.register( + createNativeImageProvider({ defaultDurationMs: 7000, now: () => new Date(clock), store }) + .registration, + ); + playback = createPlaybackService({ + newId, + now: () => clock, + registry, + setTimer: () => undefined, + settings: SIGNAGE_DEFAULT_SETTINGS, + store, + }); + await playback.ensureDefaultGroup(); + + // One native image on a pinned slot: the most ordinary thing a wall shows, and exactly the case + // whose heartbeat carries no contribution id. + const image = await ingestNativeImage({ + bytes: await makeImage({ format: "png", height: 1080, width: 1920 }), + displayName: "Welcome", + expiresAt: null, + filename: "welcome.png", + storage, + store, + }); + const timeline = await createTimeline({ name: "Lobby", newId, store }); + await addSlot({ + newId, + pinned: { itemId: image.image.id, providerId: SIGNAGE_NATIVE_PROVIDER_ID }, + store, + timelineId: timeline.timeline.id, + }); + const groups = await store.listChannelGroups(); + await playback.activate(groups[0]?.group.id as string, timeline.timeline.id, "now"); + + const registered: ModuleRouteDefinitionInput[] = []; + registerViewerRoutes( + { route: (definition) => registered.push(definition) }, + () => playback, + () => registry, + () => store, + ); + const table = routeTable(registered); + + host = createFakeHost({ + permissions: ["signage.display.view"], + principal: { deviceId: DEVICE_ID, kind: "device" } as never, + routes: [], + }); + Object.assign(host.context.api, { + async request(path: string, init?: RequestInit): Promise { + const method = (init?.method ?? "GET").toUpperCase(); + const relative = `/${path.replace(/^\//u, "").split("?")[0]}`; + for (const { names, pattern, route } of table) { + const match = pattern.exec(relative); + if (route.method !== method || !match) { + continue; + } + const params = Object.fromEntries(names.map((name, index) => [name, match[index + 1]])); + const context: ModuleRouteHandlerContext = fakeHandlerContext({ + config: fakeConfiguration({}), + params: Object.freeze(params) as never, + principal: { deviceId: DEVICE_ID, kind: "device" } as never, + storage, + }); + const request = new Request( + `https://api.example.test/api/v1/modules/signage${relative}`, + { + ...(init?.body === undefined ? {} : { body: init.body }), + headers: init?.headers ?? {}, + method, + }, + ); + const response = await route.handler(request, context); + const body = (await response + .clone() + .json() + .catch(() => null)) as { code?: string } | null; + calls.push({ code: body?.code, method, path: route.path, status: response.status }); + return response; + } + throw new Error( + `The display asked for ${method} ${relative}, which Signage does not serve.`, + ); + }, + }); + + root = document.createElement("div"); + document.body.append(root); + }); + + async function settle(): Promise { + for (let index = 0; index < 20; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + function controller() { + return createDisplayController({ + context: host.context, + decodeImage: () => Promise.resolve(), + media: createMemoryMediaCache(), + newViewerKey: () => "kiosk-contract-0001", + now: () => clock, + root, + store: createMemoryStore(), + }); + } + + it("the server accepts every request the display sends", async () => { + const display = controller(); + await display.start(); + await settle(); + await display.stop(); + + const sent = calls.map((call) => `${call.method} ${call.path}`); + expect(sent).toContain("POST /viewers/session"); + expect(sent).toContain("GET /channels/:channelId/display-state"); + expect(sent).toContain("POST /viewers/:viewerId/heartbeat"); + // Not one refusal, of any kind, for anything the display built itself. + expect(calls.filter((call) => call.status >= 400)).toEqual([]); + }); + + it("a heartbeat from a screen showing a native image is recorded", async () => { + const display = controller(); + await display.start(); + await settle(); + await display.stop(); + + const [viewer] = await store.listViewers(); + expect(viewer).toBeDefined(); + // What the display reported is what the roster now holds — which is only true if the server + // took the heartbeat, rather than refusing it and leaving the session's values in place. + expect(viewer?.reportedSelectionId).not.toBeNull(); + expect(viewer?.reportedProviderId).toBe(SIGNAGE_NATIVE_PROVIDER_ID); + expect(viewer?.reportedContributionId).toBeNull(); + }); +}); diff --git a/tests/browser/display.test.ts b/tests/browser/display.test.ts index 5d7031b..6b819f0 100644 --- a/tests/browser/display.test.ts +++ b/tests/browser/display.test.ts @@ -6,6 +6,7 @@ import { type DisplayChannelStateView, type DisplayController, } from "../../src/display/controller.js"; +import type { ViewerSessionResponse } from "../../src/shared/display-protocol.js"; import { createFakeHost, jsonResponse } from "./support/fake-host.js"; /** @@ -114,12 +115,14 @@ function state(overrides: Partial = {}): DisplayChannel }; } -const SESSION = { +/** Typed as the shared protocol message, so a stub cannot drift from what the server sends. */ +const SESSION: ViewerSessionResponse = { channelId: "chan-1", channelKey: "default", groupId: "group-1", heartbeatIntervalMs: 12_000, viewerId: "11111111-1111-4111-8111-111111111111", + viewerName: "Foyer", }; describe("display controller", () => { diff --git a/tests/browser/offline.test.ts b/tests/browser/offline.test.ts index 0a4915d..65b0702 100644 --- a/tests/browser/offline.test.ts +++ b/tests/browser/offline.test.ts @@ -16,6 +16,7 @@ import { createDisplayController, type DisplayChannelStateView, } from "../../src/display/controller.js"; +import type { DisplaySelection, ViewerSessionResponse } from "../../src/shared/display-protocol.js"; import { createFakeHost, jsonResponse } from "./support/fake-host.js"; /** @@ -309,14 +310,26 @@ describe("a display that loses its network", () => { } } - function nativeSelection(id: string, slotId: string): SceneSelection { + /** A native selection exactly as the server sends one in channel state. */ + function nativeSelection(id: string, slotId: string): DisplaySelection { return { ...selection({ id, slotId }), media: { - display: { url: `https://cdn.invalid/${id}?sig=a` }, - thumbnail: { url: `https://cdn.invalid/${id}-t?sig=a` }, + display: { + expiresAtMs: 900_000, + height: 1080, + url: `https://cdn.invalid/${id}?sig=a`, + width: 1920, + }, + thumbnail: { + expiresAtMs: 900_000, + height: 270, + url: `https://cdn.invalid/${id}-t?sig=a`, + width: 480, + }, }, - } as SceneSelection; + stale: false, + }; } function state(currentId: string): DisplayChannelStateView { @@ -373,7 +386,8 @@ describe("a display that loses its network", () => { groupId: "group-1", heartbeatIntervalMs: 12_000, viewerId: "11111111-1111-4111-8111-111111111111", - }), + viewerName: "Foyer", + } satisfies ViewerSessionResponse), }, { method: "GET", diff --git a/tests/display-protocol.test.ts b/tests/display-protocol.test.ts new file mode 100644 index 0000000..f7001a4 --- /dev/null +++ b/tests/display-protocol.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { + DISPLAY_ERROR_DETAIL_MAX, + parseDisplayHeartbeat, + parseViewerSessionRequest, + parseViewerSessionResponse, +} from "../src/shared/display-protocol.js"; + +/** + * The rules every display message follows, pinned one by one, because the production failure was + * exactly a disagreement about one of them. + */ + +const FULL_HEARTBEAT = { + cachedAssetCount: 3, + error: null, + offlineReady: true, + reportedContributionId: null, + reportedPlaybackRevision: 4, + reportedProviderId: "signage.native-images", + reportedSelectionId: "sel-1", + reportedTimelineId: "timeline-1", +}; + +describe("display heartbeat", () => { + it("accepts null for every nullable field: null is a value, not an omission", () => { + const result = parseDisplayHeartbeat({ + ...FULL_HEARTBEAT, + reportedContributionId: null, + reportedPlaybackRevision: null, + reportedProviderId: null, + reportedSelectionId: null, + reportedTimelineId: null, + }); + expect(result).toEqual({ + ok: true, + value: { + ...FULL_HEARTBEAT, + reportedContributionId: null, + reportedPlaybackRevision: null, + reportedProviderId: null, + reportedSelectionId: null, + reportedTimelineId: null, + }, + }); + }); + + it("treats an absent field as not reported, so the receiver keeps what it had", () => { + const result = parseDisplayHeartbeat({ offlineReady: false }); + expect(result).toEqual({ ok: true, value: { offlineReady: false } }); + }); + + it("ignores a field it does not know, so a newer display is never refused by an older server", () => { + const result = parseDisplayHeartbeat({ ...FULL_HEARTBEAT, reportedSomethingNew: { a: 1 } }); + expect(result).toEqual({ ok: true, value: FULL_HEARTBEAT }); + }); + + it.each([ + ["cachedAssetCount", null], + ["cachedAssetCount", -1], + ["cachedAssetCount", 1.5], + ["offlineReady", null], + ["offlineReady", "yes"], + ["reportedSelectionId", ""], + ["reportedSelectionId", "x".repeat(65)], + ["reportedProviderId", 7], + ["reportedPlaybackRevision", -1], + ["error", { code: "" }], + ["error", { code: "BAD CODE" }], + ["error", { code: "OK", detail: "y".repeat(DISPLAY_ERROR_DETAIL_MAX + 1) }], + ["error", "SLIDE_UNRENDERABLE"], + ])("refuses %s = %j, naming that field and nothing else", (field, value) => { + expect(parseDisplayHeartbeat({ ...FULL_HEARTBEAT, [field]: value })).toEqual({ + field, + ok: false, + }); + }); + + it("accepts a reported error with or without detail", () => { + expect(parseDisplayHeartbeat({ error: { code: "SLIDE_UNRENDERABLE", detail: null } })).toEqual({ + ok: true, + value: { error: { code: "SLIDE_UNRENDERABLE", detail: null } }, + }); + expect(parseDisplayHeartbeat({ error: { code: "SLIDE_UNRENDERABLE" } })).toEqual({ + ok: true, + value: { error: { code: "SLIDE_UNRENDERABLE", detail: null } }, + }); + }); + + it("refuses something that is not an object at all", () => { + for (const body of [null, [], "heartbeat", 3]) { + expect(parseDisplayHeartbeat(body)).toEqual({ field: "body", ok: false }); + } + }); +}); + +describe("viewer session", () => { + it("requires a well-formed viewer key", () => { + expect(parseViewerSessionRequest({ viewerKey: "kiosk-lobby-0001" })).toEqual({ + ok: true, + value: { viewerKey: "kiosk-lobby-0001" }, + }); + for (const viewerKey of [undefined, null, "short", "has spaces in it", "x".repeat(129)]) { + expect(parseViewerSessionRequest({ viewerKey })).toEqual({ field: "viewerKey", ok: false }); + } + }); + + it("a display accepts only a complete registration", () => { + const complete = { + channelId: "chan-1", + channelKey: "default", + groupId: "group-1", + heartbeatIntervalMs: 12_000, + viewerId: "11111111-1111-4111-8111-111111111111", + viewerName: "New display", + }; + expect(parseViewerSessionResponse(complete)).toEqual({ ok: true, value: complete }); + const { viewerId: _omitted, ...missing } = complete; + expect(parseViewerSessionResponse(missing)).toEqual({ field: "viewerId", ok: false }); + expect(parseViewerSessionResponse({ ...complete, heartbeatIntervalMs: 0 })).toEqual({ + field: "heartbeatIntervalMs", + ok: false, + }); + }); +});