feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import { SignageError, signageBoundary } from "../errors.js";
import { optionalInstant, optionalString, readJsonObject, requireUuidParam } from "../http.js";
import { SIGNAGE_IMAGE_MAX_BYTES } from "../images/limits.js";
import {
createImageDeliveryUrls,
deleteNativeImage,
ingestNativeImage,
updateNativeImage,
} from "../images/service.js";
import { readBoundedBody } from "../images/streaming.js";
import { createSignageStore } from "../store/drizzle.js";
import type { StoredImage } from "../store/types.js";
const FILENAME_MAX_LENGTH = 200;
/**
* A filename is a label an operator sees, never a path and never an object key.
*
* Path separators and control characters are refused rather than escaped, because a display name has
* no business containing either — and the value is echoed back to a browser, where a control
* character is a rendering hazard and a separator invites something to read it as a location.
*/
function isSafeLabel(value: string): boolean {
if (value.length === 0 || value.length > FILENAME_MAX_LENGTH) {
return false;
}
for (const character of value) {
const codePoint = character.codePointAt(0) ?? 0;
if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f)) {
return false;
}
if (character === "/" || character === "\\") {
return false;
}
}
return true;
}
function queryValue(request: Request, name: string): string | undefined {
const value = new URL(request.url).searchParams.get(name);
return value === null ? undefined : value;
}
function requireLabel(request: Request, field: string, fallback?: string): string {
const value = queryValue(request, field)?.trim();
if (value === undefined || value.length === 0) {
if (fallback !== undefined) {
return fallback;
}
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
}
if (!isSafeLabel(value)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
}
return value;
}
function readExpiresAt(request: Request): Date | null {
const raw = queryValue(request, "expiresAt");
if (raw === undefined || raw === "") {
// Null is permanent. Local age never invents an expiration for a church's own slide.
return null;
}
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime()) || !/[Zz]|[+-]\d{2}:?\d{2}$/u.test(raw)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "expiresAt" });
}
return parsed;
}
/** What a browser is given: the record plus fresh, short-lived delivery URLs. The logical object key
* never leaves the server — a browser has no use for it, and it is the one durable thing Signage
* stores about where bytes live. */
async function describe(
context: ModuleRouteHandlerContext,
image: StoredImage,
): Promise<Record<string, unknown>> {
const delivery = await createImageDeliveryUrls(context.storage, image.renditions);
return {
byteSize: image.originalByteSize,
contentSha256: image.contentSha256,
contentType: image.originalContentType,
createdAt: image.createdAt.toISOString(),
displayName: image.displayName,
expiresAt: image.expiresAt?.toISOString() ?? null,
hasAlpha: image.hasAlpha,
height: image.height,
id: image.id,
originalFilename: image.originalFilename,
renditions: delivery,
status: image.status,
width: image.width,
};
}
export function registerImageRoutes(http: ModuleHttpRouteRegistrar): void {
http.route({
method: "GET",
path: "/images",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const images = await createSignageStore(context.services.database).listImages();
return Response.json({
images: await Promise.all(images.map((image) => describe(context, image))),
});
});
},
});
http.route({
method: "POST",
path: "/images",
permission: "signage.assets.manage",
// One image, streamed. The platform counts bytes as the handler consumes them and never
// materializes the body; Signage counts them too and refuses at its own smaller limit first.
// The filename and display name travel in the query string, so the body is exactly the image.
maxBodyBytes: SIGNAGE_IMAGE_MAX_BYTES,
bodyMode: "streamed",
handler(request, context) {
return signageBoundary(async () => {
const filename = requireLabel(request, "filename");
const displayName = requireLabel(request, "displayName", filename);
const expiresAt = readExpiresAt(request);
// The whole body is read, and only then validated and committed. A `413` can arrive after a
// streamed handler has started, which is exactly why nothing is written until this returns.
const bytes = await readBoundedBody(request, SIGNAGE_IMAGE_MAX_BYTES);
const result = await ingestNativeImage({
bytes,
displayName,
expiresAt,
filename,
storage: context.storage,
store: createSignageStore(context.services.database),
});
return Response.json(
{ deduplicated: result.deduplicated, image: await describe(context, result.image) },
{ status: result.deduplicated ? 200 : 201 },
);
});
},
});
http.route({
method: "GET",
path: "/images/:imageId",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const imageId = requireUuidParam(context, "imageId");
const image = await createSignageStore(context.services.database).findImage(imageId);
if (!image) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
return Response.json({ image: await describe(context, image) });
});
},
});
http.route({
method: "PATCH",
path: "/images/:imageId",
permission: "signage.assets.manage",
// A rename and an expiration date. Nothing about this route needs a large body.
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const imageId = requireUuidParam(context, "imageId");
const body = await readJsonObject(request);
const displayName = optionalString(body, "displayName", {
maxLength: FILENAME_MAX_LENGTH,
});
if (displayName !== undefined && !isSafeLabel(displayName)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "displayName" });
}
const image = await updateNativeImage({
assetId: imageId,
displayName,
expiresAt: optionalInstant(body, "expiresAt"),
store: createSignageStore(context.services.database),
});
return Response.json({ image: await describe(context, image) });
});
},
});
http.route({
method: "DELETE",
path: "/images/:imageId",
permission: "signage.assets.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const imageId = requireUuidParam(context, "imageId");
await deleteNativeImage({
assetId: imageId,
storage: context.storage,
store: createSignageStore(context.services.database),
});
return new Response(null, { status: 204 });
});
},
});
}
+291
View File
@@ -0,0 +1,291 @@
import { randomUUID } from "node:crypto";
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import { SignageError, signageBoundary } from "../errors.js";
import {
isUuid,
optionalInstant,
readJsonObject,
requiredString,
requireUuidParam,
} from "../http.js";
import type { SignagePlaybackService } from "../playback/service.js";
import { createSignageStore } from "../store/drizzle.js";
import { SignageStoreConflict, type StoredChannelGroupDetail } from "../store/types.js";
/** An audience key is what a provider receives, so it uses the same flat lowercase grammar the rest
* of the platform uses for a local identifier. */
const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
function describeGroup(detail: StoredChannelGroupDetail): Record<string, unknown> {
return {
channels: detail.channels.map((channel) => ({
id: channel.id,
isDefault: channel.isDefault,
key: channel.key,
name: channel.name,
})),
id: detail.group.id,
name: detail.group.name,
revision: detail.group.revision,
};
}
export function registerPlaybackRoutes(
http: ModuleHttpRouteRegistrar,
playback: () => SignagePlaybackService,
): void {
http.route({
method: "GET",
path: "/channel-groups",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const groups = await createSignageStore(context.services.database).listChannelGroups();
return Response.json({
groups: await Promise.all(
groups.map(async (group) => ({
...describeGroup(group),
playback: await playback().snapshot(group.group.id),
})),
),
});
});
},
});
http.route({
method: "POST",
path: "/channel-groups",
permission: "signage.displays.manage",
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const body = await readJsonObject(request);
const store = createSignageStore(context.services.database);
const groupId = randomUUID();
// A group is created with its default channel and its playback row together: a group
// without a default channel has nowhere for a viewer to land.
await store.createChannelGroup({
channelId: randomUUID(),
channelName: "Default",
groupId,
name: requiredString(body, "name", { maxLength: 120 }),
});
const created = await store.findChannelGroup(groupId);
if (!created) {
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
return Response.json(describeGroup(created), { status: 201 });
});
},
});
http.route({
method: "DELETE",
path: "/channel-groups/:groupId",
permission: "signage.displays.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const groupId = requireUuidParam(context, "groupId");
const store = createSignageStore(context.services.database);
const groups = await store.listChannelGroups();
if (groups.length <= 1) {
// An installation always has somewhere for a viewer to land.
throw new SignageError("SIGNAGE_CONFLICT", { reason: "last-group" });
}
await store.deleteChannelGroup(groupId);
return new Response(null, { status: 204 });
});
},
});
http.route({
method: "POST",
path: "/channel-groups/:groupId/channels",
permission: "signage.displays.manage",
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const groupId = requireUuidParam(context, "groupId");
const body = await readJsonObject(request);
const key = requiredString(body, "key", { maxLength: 64 });
if (!AUDIENCE_KEY.test(key) || key === "default") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "key" });
}
const store = createSignageStore(context.services.database);
const group = await store.findChannelGroup(groupId);
if (!group) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
if (group.channels.some((channel) => channel.key === key)) {
throw new SignageError("SIGNAGE_CONFLICT", { field: "key" });
}
await store.addChannel({
groupId,
id: randomUUID(),
isDefault: false,
key,
name: requiredString(body, "name", { maxLength: 120 }),
});
const updated = await store.findChannelGroup(groupId);
if (!updated) {
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
return Response.json(describeGroup(updated), { status: 201 });
});
},
});
http.route({
method: "DELETE",
path: "/channels/:channelId",
permission: "signage.displays.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const channelId = requireUuidParam(context, "channelId");
try {
await createSignageStore(context.services.database).removeChannel(channelId);
} catch (error) {
if (error instanceof SignageStoreConflict) {
// A group's default channel is the one a viewer lands on. It cannot be removed.
throw new SignageError("SIGNAGE_CONFLICT", { reason: "default-channel" });
}
throw error;
}
return new Response(null, { status: 204 });
});
},
});
http.route({
method: "GET",
path: "/playback/:groupId",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const groupId = requireUuidParam(context, "groupId");
const store = createSignageStore(context.services.database);
const snapshot = await playback().snapshot(groupId);
const selection =
snapshot.currentSelectionId === null
? undefined
: await store.findSelection(snapshot.currentSelectionId);
const history = await store.listSelections(groupId, 20);
return Response.json({
current:
selection === undefined
? null
: {
deadlineAtMs: selection.deadlineAt?.getTime() ?? null,
durationMs: selection.durationMs,
id: selection.id,
itemId: selection.itemId,
itemRevision: selection.itemRevision,
providerId: selection.providerId,
rendererContributionId: selection.rendererContributionId,
revision: selection.revision,
stale: selection.stale,
startedAtMs: selection.startedAt?.getTime() ?? null,
withdrawn: selection.withdrawn,
},
// Identity and timing only. A payload belongs to a display, which asks for the channel
// state it is authorized for.
history: history.map((entry) => ({
id: entry.id,
itemId: entry.itemId,
providerId: entry.providerId,
sequence: entry.sequence,
startedAtMs: entry.startedAt?.getTime() ?? null,
})),
snapshot,
});
});
},
});
function command(
path: string,
run: (
service: SignagePlaybackService,
groupId: string,
body: Record<string, unknown>,
context: ModuleRouteHandlerContext,
) => Promise<unknown>,
options: { readonly maxBodyBytes: number },
): void {
http.route({
method: "POST",
path,
permission: "signage.playback.control",
maxBodyBytes: options.maxBodyBytes,
handler(request, context) {
return signageBoundary(async () => {
const groupId = requireUuidParam(context, "groupId");
const body = options.maxBodyBytes === 0 ? {} : await readJsonObject(request);
return Response.json(await run(playback(), groupId, body, context));
});
},
});
}
command(
"/playback/:groupId/activate",
async (service, groupId, body) => {
const timelineId = body.timelineId;
if (!isUuid(timelineId)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "timelineId" });
}
const at = optionalInstant(body, "at");
if (at) {
return service.activate(groupId, timelineId, { at });
}
const when = body.when === "next-boundary" ? "next-boundary" : "now";
return service.activate(groupId, timelineId, when);
},
{ maxBodyBytes: 2048 },
);
command("/playback/:groupId/pause", (service, groupId) => service.pause(groupId), {
maxBodyBytes: 0,
});
command("/playback/:groupId/resume", (service, groupId) => service.resume(groupId), {
maxBodyBytes: 0,
});
command("/playback/:groupId/next", (service, groupId) => service.next(groupId), {
maxBodyBytes: 0,
});
command("/playback/:groupId/previous", (service, groupId) => service.previous(groupId), {
maxBodyBytes: 0,
});
command("/playback/:groupId/reload", (service, groupId) => service.reload(groupId), {
maxBodyBytes: 0,
});
command(
"/playback/:groupId/blackout",
(service, groupId, body) => {
if (typeof body.on !== "boolean") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "on" });
}
return service.setBlackout(groupId, body.on);
},
{ maxBodyBytes: 1024 },
);
command(
"/playback/:groupId/static",
(service, groupId, body) => {
const assetId = body.assetId;
if (assetId === null) {
return service.setStaticHold(groupId, null);
}
if (!isUuid(assetId)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "assetId" });
}
return service.setStaticHold(groupId, assetId);
},
{ maxBodyBytes: 1024 },
);
}
+90
View File
@@ -0,0 +1,90 @@
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import { SignageError, signageBoundary } from "../errors.js";
import type { SignageProviderRegistry } from "../providers/registry.js";
import { readSignageSettings } from "../settings.js";
/** A provider id is another module's opaque identifier, and arrives here as a decoded path value —
* which is untrusted input whatever the declared path grammar was. Validated against Signage's own
* bound before any lookup. */
const PROVIDER_ID = /^[\w][\w.:-]{0,199}$/u;
function requireProviderId(context: ModuleRouteHandlerContext): string {
const value = context.params.providerId;
if (value === undefined || !PROVIDER_ID.test(value)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { parameter: "providerId" });
}
return value;
}
/**
* Provider health and discovery.
*
* Everything these routes return is either a provider's own declared identity or a count Signage
* itself kept. A provider's exception, message, stack, or payload never appears — which is what
* makes "an incompatible provider is clearly reported in management" true without also making a
* provider's internals an operator-visible surface.
*/
export function registerProviderRoutes(
http: ModuleHttpRouteRegistrar,
registry: () => SignageProviderRegistry,
): void {
http.route({
method: "GET",
path: "/providers",
permission: "signage.view",
maxBodyBytes: 0,
handler() {
return signageBoundary(() =>
Promise.resolve(
Response.json({
// Only what is actually registered right now. A registration is a running fact, not a
// stored one, so a provider that was uninstalled leaves nothing behind claiming it
// exists.
providers: registry().health(),
}),
),
);
},
});
http.route({
method: "GET",
path: "/providers/:providerId/sources",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const providerId = requireProviderId(context);
const settings = readSignageSettings(context.config);
// A provider that is not registered, is failing, or answers with something unusable yields
// an empty list rather than an error: the editor should say "no sources right now", not
// break, and provider health already reports why.
const sources = await registry().listSources(providerId, {
at: new Date().toISOString(),
timezone: settings.timezone,
});
return Response.json({ providerId, sources });
});
},
});
http.route({
method: "GET",
path: "/providers/:providerId/items",
permission: "signage.view",
maxBodyBytes: 0,
handler(request, context) {
return signageBoundary(async () => {
const providerId = requireProviderId(context);
const settings = readSignageSettings(context.config);
const sourceId = new URL(request.url).searchParams.get("sourceId");
const items = await registry().listItems(providerId, {
at: new Date().toISOString(),
timezone: settings.timezone,
...(sourceId === null ? {} : { sourceId }),
});
return Response.json({ items, providerId });
});
},
});
}
+420
View File
@@ -0,0 +1,420 @@
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import { SignageError, signageBoundary } from "../errors.js";
import {
isUuid,
optionalBoolean,
optionalInstant,
optionalInteger,
optionalString,
readJsonObject,
requiredString,
requireUuidParam,
} from "../http.js";
import { createSignageStore } from "../store/drizzle.js";
import type { SignageStore, StoredSlotPresentation, StoredTimelineDetail } from "../store/types.js";
import {
addProviderSource,
addSlot,
createTimeline,
deleteTimeline,
duplicateTimeline,
removeProviderSource,
removeSlot,
reorderSlots,
requireTimeline,
SIGNAGE_TIMELINE_LIMITS,
updateSlot,
updateTimeline,
} from "../timelines/service.js";
const PROVIDER_ID = /^[\w][\w.:-]{0,199}$/u;
function store(context: ModuleRouteHandlerContext): SignageStore {
return createSignageStore(context.services.database);
}
/** The wire shape of a timeline. Instants are ISO 8601 strings, which is what a browser should
* receive — never an invented epoch or a locale-formatted representation. */
function describe(detail: StoredTimelineDetail): Record<string, unknown> {
return {
slots: detail.slots.map((slot) => ({
durationOverrideMs: slot.durationOverrideMs,
eligibleFrom: slot.eligibleFrom?.toISOString() ?? null,
eligibleUntil: slot.eligibleUntil?.toISOString() ?? null,
enabled: slot.enabled,
id: slot.id,
itemId: slot.itemId,
kind: slot.kind,
position: slot.position,
presentation: slot.presentation,
providerId: slot.providerId,
sourceId: slot.sourceId,
})),
sources: detail.sources.map((source) => ({
id: source.id,
label: source.label,
providerId: source.providerId,
sourceId: source.sourceId,
// The selection scope is deliberately **not** exposed. It is an internal stable identity a
// provider receives; a dashboard that showed it would invite someone to depend on its shape.
})),
timeline: {
description: detail.timeline.description,
eligibleFrom: detail.timeline.eligibleFrom?.toISOString() ?? null,
eligibleUntil: detail.timeline.eligibleUntil?.toISOString() ?? null,
id: detail.timeline.id,
name: detail.timeline.name,
revision: detail.timeline.revision,
updatedAt: detail.timeline.updatedAt.toISOString(),
},
};
}
function readPresentation(
body: Record<string, unknown>,
): Partial<StoredSlotPresentation> | undefined {
const raw = body.presentation;
if (raw === undefined) {
return undefined;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "presentation" });
}
const value = raw as Record<string, unknown>;
const mode = value.backgroundMode;
if (
mode !== undefined &&
mode !== null &&
mode !== "blurred" &&
mode !== "solid" &&
mode !== "black"
) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "backgroundMode" });
}
return {
...(mode === undefined
? {}
: { backgroundMode: mode as StoredSlotPresentation["backgroundMode"] }),
...(value.backgroundColor === undefined
? {}
: { backgroundColor: optionalString(value, "backgroundColor", { maxLength: 7 }) ?? null }),
...(value.backgroundDarkened === undefined
? {}
: { backgroundDarkened: optionalBoolean(value, "backgroundDarkened") ?? null }),
...(value.blurPixels === undefined
? {}
: { blurPixels: optionalInteger(value, "blurPixels", { maximum: 120, minimum: 0 }) ?? null }),
...(value.foregroundShadow === undefined
? {}
: { foregroundShadow: optionalBoolean(value, "foregroundShadow") ?? null }),
};
}
function requireOpaqueId(body: Record<string, unknown>, field: string): string {
const value = body[field];
if (typeof value !== "string" || !PROVIDER_ID.test(value)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
}
return value;
}
export function registerTimelineRoutes(http: ModuleHttpRouteRegistrar): void {
http.route({
method: "GET",
path: "/timelines",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const timelines = await store(context).listTimelines();
return Response.json({
timelines: timelines.map((timeline) => ({
description: timeline.description,
eligibleFrom: timeline.eligibleFrom?.toISOString() ?? null,
eligibleUntil: timeline.eligibleUntil?.toISOString() ?? null,
id: timeline.id,
name: timeline.name,
revision: timeline.revision,
updatedAt: timeline.updatedAt.toISOString(),
})),
});
});
},
});
http.route({
method: "POST",
path: "/timelines",
permission: "signage.timelines.manage",
maxBodyBytes: 8192,
handler(request, context) {
return signageBoundary(async () => {
const body = await readJsonObject(request);
const detail = await createTimeline({
description: optionalString(body, "description", { maxLength: 500 }) ?? null,
eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null,
eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null,
name: requiredString(body, "name", {
maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength,
}),
store: store(context),
});
return Response.json(describe(detail), { status: 201 });
});
},
});
http.route({
method: "GET",
path: "/timelines/:timelineId",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
return Response.json(describe(await requireTimeline(store(context), timelineId)));
});
},
});
http.route({
method: "PATCH",
path: "/timelines/:timelineId",
permission: "signage.timelines.manage",
maxBodyBytes: 8192,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const body = await readJsonObject(request);
const detail = await updateTimeline({
changes: {
...(body.name === undefined
? {}
: {
name: requiredString(body, "name", {
maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength,
}),
}),
...(body.description === undefined
? {}
: { description: optionalString(body, "description", { maxLength: 500 }) ?? null }),
...(body.eligibleFrom === undefined
? {}
: { eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null }),
...(body.eligibleUntil === undefined
? {}
: { eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null }),
},
store: store(context),
timelineId,
});
return Response.json(describe(detail));
});
},
});
http.route({
method: "DELETE",
path: "/timelines/:timelineId",
permission: "signage.timelines.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
await deleteTimeline({ store: store(context), timelineId });
return new Response(null, { status: 204 });
});
},
});
http.route({
method: "POST",
path: "/timelines/:timelineId/duplicate",
permission: "signage.timelines.manage",
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const body = await readJsonObject(request);
const detail = await duplicateTimeline({
name: requiredString(body, "name", { maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength }),
store: store(context),
timelineId,
});
return Response.json(describe(detail), { status: 201 });
});
},
});
http.route({
method: "POST",
path: "/timelines/:timelineId/sources",
permission: "signage.timelines.manage",
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const body = await readJsonObject(request);
const detail = await addProviderSource({
label: requiredString(body, "label", { maxLength: 200 }),
providerId: requireOpaqueId(body, "providerId"),
sourceId: requireOpaqueId(body, "sourceId"),
store: store(context),
timelineId,
});
return Response.json(describe(detail), { status: 201 });
});
},
});
http.route({
method: "DELETE",
path: "/timelines/:timelineId/sources/:sourceId",
permission: "signage.timelines.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const sourceId = requireUuidParam(context, "sourceId");
return Response.json(
describe(await removeProviderSource({ sourceId, store: store(context), timelineId })),
);
});
},
});
http.route({
method: "POST",
path: "/timelines/:timelineId/slots",
permission: "signage.timelines.manage",
maxBodyBytes: 8192,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const body = await readJsonObject(request);
const sourceId = body.sourceId;
const detail = await addSlot({
durationOverrideMs: optionalInteger(body, "durationOverrideMs", {
maximum: SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs,
minimum: SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs,
}),
eligibleFrom: optionalInstant(body, "eligibleFrom"),
eligibleUntil: optionalInstant(body, "eligibleUntil"),
enabled: optionalBoolean(body, "enabled") ?? true,
...(sourceId === undefined
? {}
: {
source: {
sourceId: isUuid(sourceId)
? sourceId
: (() => {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "sourceId" });
})(),
},
}),
...(body.itemId === undefined
? {}
: {
pinned: {
itemId: requireOpaqueId(body, "itemId"),
providerId: requireOpaqueId(body, "providerId"),
},
}),
position: optionalInteger(body, "position", { maximum: 10_000, minimum: 0 }) ?? undefined,
presentation: readPresentation(body),
store: store(context),
timelineId,
});
return Response.json(describe(detail), { status: 201 });
});
},
});
http.route({
method: "PATCH",
path: "/timelines/:timelineId/slots/:slotId",
permission: "signage.timelines.manage",
maxBodyBytes: 8192,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const slotId = requireUuidParam(context, "slotId");
const body = await readJsonObject(request);
const presentation = readPresentation(body);
const detail = await updateSlot({
changes: {
...(body.enabled === undefined
? {}
: { enabled: optionalBoolean(body, "enabled") ?? true }),
...(body.durationOverrideMs === undefined
? {}
: {
durationOverrideMs: optionalInteger(body, "durationOverrideMs", {
maximum: SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs,
minimum: SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs,
}) as number | null,
}),
...(body.eligibleFrom === undefined
? {}
: { eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null }),
...(body.eligibleUntil === undefined
? {}
: { eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null }),
...(body.itemId === undefined ? {} : { itemId: requireOpaqueId(body, "itemId") }),
...(presentation === undefined ? {} : { presentation }),
},
slotId,
store: store(context),
timelineId,
});
return Response.json(describe(detail));
});
},
});
http.route({
method: "DELETE",
path: "/timelines/:timelineId/slots/:slotId",
permission: "signage.timelines.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const slotId = requireUuidParam(context, "slotId");
return Response.json(
describe(await removeSlot({ slotId, store: store(context), timelineId })),
);
});
},
});
http.route({
method: "PUT",
path: "/timelines/:timelineId/slot-order",
permission: "signage.timelines.manage",
maxBodyBytes: 16_384,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const body = await readJsonObject(request);
const ids = body.slotIds;
if (
!Array.isArray(ids) ||
ids.length > SIGNAGE_TIMELINE_LIMITS.maxSlotsPerTimeline ||
!ids.every((id) => isUuid(id))
) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "slotIds" });
}
return Response.json(
describe(
await reorderSlots({
orderedSlotIds: ids as string[],
store: store(context),
timelineId,
}),
),
);
});
},
});
}
+259
View File
@@ -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;
}