From 92bef4c7aa93e3c0fb1da7de2035ad8c04615fdf Mon Sep 17 00:00:00 2001 From: ekklesia Date: Fri, 28 Aug 2026 16:41:54 -0500 Subject: [PATCH] feat: initial Signage 0.1.0 --- .gitignore | 6 + README.md | 140 ++ ...gnage-owns-timing-providers-own-content.md | 80 + .../0002-content-provider-contract.md | 114 ++ ...03-timelines-slots-and-selection-scopes.md | 99 ++ .../0004-channel-groups-and-one-clock.md | 62 + .../decisions/0005-authoritative-playback.md | 98 ++ ...play-rendering-and-offline-continuation.md | 111 ++ .../decisions/0007-native-images.md | 108 ++ docs/architecture/decisions/README.md | 20 + docs/provider-contract.md | 343 ++++ drizzle.config.ts | 11 + drizzle/0000_initial.sql | 215 +++ drizzle/meta/0000_snapshot.json | 1534 +++++++++++++++++ drizzle/meta/_ekklesia.json | 12 + drizzle/meta/_journal.json | 13 + ekklesia.module.json | 158 ++ package.json | 55 + src/browser/desktop-gate.tsx | 31 + src/browser/offline.ts | 288 ++++ src/browser/scene-request.ts | 177 ++ src/browser/scene.ts | 360 ++++ src/browser/slate.tsx | 32 + src/browser/storage.ts | 251 +++ src/browser/uploads.ts | 192 +++ src/display/controller.ts | 676 ++++++++ src/display/index.tsx | 69 + src/display/styles.css | 227 +++ src/module-styles.d.ts | 17 + src/schema/index.ts | 50 + src/schema/tables.ts | 485 ++++++ src/server/display/state.ts | 370 ++++ src/server/errors.ts | 118 ++ src/server/http.ts | 126 ++ src/server/images/limits.ts | 46 + src/server/images/processing.ts | 199 +++ src/server/images/service.ts | 278 +++ src/server/images/streaming.ts | 76 + src/server/index.ts | 11 + src/server/playback/capability.ts | 68 + src/server/playback/service.ts | 1005 +++++++++++ src/server/providers/contract.ts | 201 +++ src/server/providers/native-provider.ts | 210 +++ src/server/providers/registry.ts | 362 ++++ src/server/providers/validation.ts | 366 ++++ src/server/routes/images.ts | 209 +++ src/server/routes/playback.ts | 291 ++++ src/server/routes/providers.ts | 90 + src/server/routes/timelines.ts | 420 +++++ src/server/routes/viewers.ts | 259 +++ src/server/runtime.ts | 267 +++ src/server/settings.ts | 52 + src/server/store/drizzle-playback.ts | 371 ++++ src/server/store/drizzle-timelines.ts | 399 +++++ src/server/store/drizzle-viewers.ts | 99 ++ src/server/store/drizzle.ts | 269 +++ src/server/store/types.ts | 447 +++++ src/server/timelines/service.ts | 429 +++++ src/server/viewers/capability.ts | 86 + src/server/viewers/service.ts | 324 ++++ src/shared/native-images.ts | 98 ++ src/shared/playback-timing.ts | 306 ++++ src/shared/renderer-input.ts | 100 ++ src/shared/settings.ts | 69 + src/ui/SignageDashboard.tsx | 142 ++ src/ui/api.ts | 107 ++ src/ui/hooks.ts | 122 ++ src/ui/index.tsx | 25 + src/ui/panels/DisplaysPanel.tsx | 288 ++++ src/ui/panels/ImagesPanel.tsx | 226 +++ src/ui/panels/PlaybackPanel.tsx | 274 +++ src/ui/panels/PreviewPanel.tsx | 92 + src/ui/panels/ProvidersPanel.tsx | 116 ++ src/ui/panels/TimelinesPanel.tsx | 476 +++++ src/ui/panels/shared.tsx | 91 + src/ui/styles.css | 522 ++++++ src/ui/types.ts | 198 +++ tests/acceptance.test.ts | 269 +++ tests/browser/contributed-renderer.test.tsx | 366 ++++ tests/browser/display.test.ts | 471 +++++ tests/browser/management-ui.test.tsx | 493 ++++++ tests/browser/module-ui.test.ts | 170 ++ tests/browser/offline.test.ts | 562 ++++++ tests/browser/scene.test.ts | 412 +++++ tests/browser/support/fake-host.ts | 122 ++ tests/browser/support/fixture-renderer.tsx | 130 ++ tests/images.test.ts | 502 ++++++ tests/manifest.test.ts | 290 ++++ tests/playback.test.ts | 622 +++++++ tests/providers.test.ts | 516 ++++++ tests/schema.test.ts | 139 ++ tests/support/fake-storage.ts | 130 ++ tests/support/fixture-provider.ts | 294 ++++ tests/support/images.ts | 80 + tests/support/memory-playback.ts | 249 +++ tests/support/memory-store.ts | 120 ++ tests/support/memory-timelines.ts | 238 +++ tests/support/memory-viewers.ts | 72 + tests/support/module-harness.ts | 106 ++ tests/timelines.test.ts | 486 ++++++ tests/uploads.test.ts | 143 ++ tests/viewers.test.ts | 603 +++++++ tsconfig.build.json | 21 + tsconfig.json | 15 + tsconfig.ui-test.json | 8 + tsconfig.ui.json | 19 + vitest.config.ts | 15 + 107 files changed, 24397 insertions(+) create mode 100644 .gitignore create mode 100644 docs/architecture/decisions/0001-signage-owns-timing-providers-own-content.md create mode 100644 docs/architecture/decisions/0002-content-provider-contract.md create mode 100644 docs/architecture/decisions/0003-timelines-slots-and-selection-scopes.md create mode 100644 docs/architecture/decisions/0004-channel-groups-and-one-clock.md create mode 100644 docs/architecture/decisions/0005-authoritative-playback.md create mode 100644 docs/architecture/decisions/0006-display-rendering-and-offline-continuation.md create mode 100644 docs/architecture/decisions/0007-native-images.md create mode 100644 docs/architecture/decisions/README.md create mode 100644 docs/provider-contract.md create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_initial.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_ekklesia.json create mode 100644 drizzle/meta/_journal.json create mode 100644 ekklesia.module.json create mode 100644 package.json create mode 100644 src/browser/desktop-gate.tsx create mode 100644 src/browser/offline.ts create mode 100644 src/browser/scene-request.ts create mode 100644 src/browser/scene.ts create mode 100644 src/browser/slate.tsx create mode 100644 src/browser/storage.ts create mode 100644 src/browser/uploads.ts create mode 100644 src/display/controller.ts create mode 100644 src/display/index.tsx create mode 100644 src/display/styles.css create mode 100644 src/module-styles.d.ts create mode 100644 src/schema/index.ts create mode 100644 src/schema/tables.ts create mode 100644 src/server/display/state.ts create mode 100644 src/server/errors.ts create mode 100644 src/server/http.ts create mode 100644 src/server/images/limits.ts create mode 100644 src/server/images/processing.ts create mode 100644 src/server/images/service.ts create mode 100644 src/server/images/streaming.ts create mode 100644 src/server/index.ts create mode 100644 src/server/playback/capability.ts create mode 100644 src/server/playback/service.ts create mode 100644 src/server/providers/contract.ts create mode 100644 src/server/providers/native-provider.ts create mode 100644 src/server/providers/registry.ts create mode 100644 src/server/providers/validation.ts create mode 100644 src/server/routes/images.ts create mode 100644 src/server/routes/playback.ts create mode 100644 src/server/routes/providers.ts create mode 100644 src/server/routes/timelines.ts create mode 100644 src/server/routes/viewers.ts create mode 100644 src/server/runtime.ts create mode 100644 src/server/settings.ts create mode 100644 src/server/store/drizzle-playback.ts create mode 100644 src/server/store/drizzle-timelines.ts create mode 100644 src/server/store/drizzle-viewers.ts create mode 100644 src/server/store/drizzle.ts create mode 100644 src/server/store/types.ts create mode 100644 src/server/timelines/service.ts create mode 100644 src/server/viewers/capability.ts create mode 100644 src/server/viewers/service.ts create mode 100644 src/shared/native-images.ts create mode 100644 src/shared/playback-timing.ts create mode 100644 src/shared/renderer-input.ts create mode 100644 src/shared/settings.ts create mode 100644 src/ui/SignageDashboard.tsx create mode 100644 src/ui/api.ts create mode 100644 src/ui/hooks.ts create mode 100644 src/ui/index.tsx create mode 100644 src/ui/panels/DisplaysPanel.tsx create mode 100644 src/ui/panels/ImagesPanel.tsx create mode 100644 src/ui/panels/PlaybackPanel.tsx create mode 100644 src/ui/panels/PreviewPanel.tsx create mode 100644 src/ui/panels/ProvidersPanel.tsx create mode 100644 src/ui/panels/TimelinesPanel.tsx create mode 100644 src/ui/panels/shared.tsx create mode 100644 src/ui/styles.css create mode 100644 src/ui/types.ts create mode 100644 tests/acceptance.test.ts create mode 100644 tests/browser/contributed-renderer.test.tsx create mode 100644 tests/browser/display.test.ts create mode 100644 tests/browser/management-ui.test.tsx create mode 100644 tests/browser/module-ui.test.ts create mode 100644 tests/browser/offline.test.ts create mode 100644 tests/browser/scene.test.ts create mode 100644 tests/browser/support/fake-host.ts create mode 100644 tests/browser/support/fixture-renderer.tsx create mode 100644 tests/images.test.ts create mode 100644 tests/manifest.test.ts create mode 100644 tests/playback.test.ts create mode 100644 tests/providers.test.ts create mode 100644 tests/schema.test.ts create mode 100644 tests/support/fake-storage.ts create mode 100644 tests/support/fixture-provider.ts create mode 100644 tests/support/images.ts create mode 100644 tests/support/memory-playback.ts create mode 100644 tests/support/memory-store.ts create mode 100644 tests/support/memory-timelines.ts create mode 100644 tests/support/memory-viewers.ts create mode 100644 tests/support/module-harness.ts create mode 100644 tests/timelines.test.ts create mode 100644 tests/uploads.test.ts create mode 100644 tests/viewers.test.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 tsconfig.ui-test.json create mode 100644 tsconfig.ui.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4a1214 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.turbo/ +coverage/ +*.log +.DS_Store diff --git a/README.md b/README.md index e69de29..076ed74 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,140 @@ +# Signage + +Ekklesia's visual programming, timeline, synchronization, and display-delivery module. + +Signage decides **what is on a screen, when, and on which screens together**. It natively supports +static images; every other kind of content reaches a screen because another Ekklesia module +registered a content provider and contributed a live React renderer for it. Signage never learns what +an announcement, a Bible passage, or a trivia question is. + +This repository is a standalone, independently versioned Ekklesia module package. It is not a fork of +Ekklesia and vendors none of its SDKs. + +- Module id: `signage` +- Package: `@ekklesia/signage` +- Manifest version: 7 + +## Status + +Version 0.1.0 is feature-complete for its declared scope and unreleased. What exists today: + +- The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and + one logical storage bucket. +- Eleven tables and a single initial migration, declared non-destructive. +- Native image ingest: one image per streamed request, 25 MiB, content-addressed, with display and + thumbnail renditions and temporary object keys removed after any unsuccessful upload. +- The content provider contract and registry, a bounded and sanitized call path, and Signage's own + built-in image provider registered through that same capability. +- Timelines, provider source slots, pinned slots, channel groups, synchronized audience channels, + authoritative playback with absolute instants, activation and scheduling, and viewer + registration, assignment, heartbeat, and health. +- A management-UI artifact and a full-viewport display artifact, both built through the canonical + Ekklesia module UI pipeline and mounted through the platform's browser ABI. +- Two permanent scene layers with a crossfade that never fades the outgoing slide, provider + renderers mounted as browser contributions, the built-in "Content unavailable" slate, deliberate + blackout, and offline continuation from this display's own persisted state. + +Nothing here is deployed, and no cutover from the legacy Announcements container has happened or is +planned by this repository. Video, audio, MQTT, Home Assistant, Companion, and a notification outbox +are out of scope; so is any content module — Signage ships with no announcements, scripture, or +trivia, and cannot render any until one is installed. + +### Offline cold start is not yet possible + +A display that is **already mounted** keeps playing with no network: its schedule, playback state, +provider payloads, and media are Signage's own, persisted by this module in IndexedDB and Cache +Storage. Core's service worker caches module *code*, not Signage domain data. + +A display that is **restarted while Ekklesia is completely unreachable** cannot mount Signage at all. +Mounting requires an authenticated module descriptor, and Core does not yet cache one. True offline +cold start depends on a Core-provided authorized offline display descriptor or lease; until that +exists, this module does not claim it. See ADR 0006. + +## Developing against Ekklesia + +Signage is developed **inside the authoritative Ekklesia workspace**, checked out at the path it will +eventually be installed at: + +```bash +git clone ekklesia +git clone ekklesia/modules/signage +cd ekklesia +pnpm install +``` + +`modules/*` is an Ekklesia workspace glob, so the checkout is picked up with no registry edit, +no second copy of Ekklesia, and no vendored SDK. `@ekklesia/module-sdk`, `@ekklesia/module-ui-sdk`, +`@ekklesia/design-tokens`, `@ekklesia/ui`, and `@ekklesia/module-ui-build` all resolve through the +workspace as `workspace:*` dependencies. + +Two consequences worth knowing: + +- Ekklesia uses a shared workspace lockfile, so installing here writes an importer entry for + `modules/signage` into Ekklesia's `pnpm-lock.yaml`. That entry belongs to whoever maintains the + Ekklesia checkout; it is not committed from this repository. +- Signage requires the Ekklesia platform prerequisites for shared design tokens, module stylesheets, + browser contributions, bounded streaming request bodies, and display offline code + (ADRs 0039–0043). An Ekklesia checkout without them cannot build or validate this module. + +### Commands + +Run from the **Ekklesia repository root**: + +```bash +pnpm --filter @ekklesia/signage build # tsc + the canonical module UI build +pnpm --filter @ekklesia/signage test # unit and browser-artifact tests +pnpm --filter @ekklesia/signage typecheck # node, browser, and browser-test projects +pnpm --filter @ekklesia/signage lint + +pnpm boundaries # four-layer boundary enforcement +pnpm modules:validate # static manifest, entrypoint, and schema validation +pnpm migrations:validate # migration journal, policy, ordering, and checksums +pnpm verify # the full Docker-free gate +``` + +Signage's tests are its own: `pnpm test` here runs `vitest` against this package's `vitest.config.ts`, +which includes `.test.tsx` as well as `.test.ts`. The workspace-root config includes only `.test.ts`, +so a browser test added here without that config would be silently skipped. + +Never run `pnpm migrations:apply` against a persistent database while developing this module. Signage +reads no environment variable, constructs no database client, configures no object storage, hardcodes +no Ekklesia URL, opens no socket of its own, and applies no migration: every one of those reaches it +through the host. + +### Changing the schema + +```bash +pnpm --filter @ekklesia/signage migration:generate # drizzle-kit, into drizzle/ +``` + +`tests/schema.test.ts` pins the checksum of the generated SQL, so a regenerated migration fails the +suite until the pin is updated deliberately. `drizzle/meta/_ekklesia.json` declares the migration +non-destructive; `pnpm migrations:validate` enforces that. + +### Verifying what only a browser can show + +Automated DOM assertions cannot prove a crossfade, a letterboxed image, or a slate. Those are +verified by driving the **built** artifacts in a real Chromium against a stub host and reading the +frames: native fill, both letterboxed fits, a contributed renderer, a frozen mid-crossfade composite, +the slate, a blackout, the offline indicator, every management tab, and the narrow-screen gate. The +harness is deliberately not committed — it stubs a host and would rot into a second, wrong +description of the platform ABI. + +## What Signage owns, and what providers own + +| Signage owns | A content provider owns | +| --- | --- | +| Native image assets and their renditions | Its own domain content and tables | +| Saved timelines, repeating patterns, and slots | Which of its content is available now | +| Channel groups and synchronized audience channels | Selecting the next item from one of its sources | +| Slot timing and authoritative playback | Repeat and no-repeat policy | +| Timeline activation and scheduling | Audience-specific variants and fallback | +| Viewer registration, assignment, heartbeat, health | Its own live React presentation | +| Transitions, offline continuation, playback controls | Dynamic change within one slide | +| The management and display UI | Its own internal multi-stage behaviour | + +## Licence and scope + +Deployment, the module store, a theme editor, a mobile management UI, video and audio, MQTT, Home +Assistant, Companion, and any migration of legacy Announcements data are explicitly out of scope for +this repository. diff --git a/docs/architecture/decisions/0001-signage-owns-timing-providers-own-content.md b/docs/architecture/decisions/0001-signage-owns-timing-providers-own-content.md new file mode 100644 index 0000000..411160d --- /dev/null +++ b/docs/architecture/decisions/0001-signage-owns-timing-providers-own-content.md @@ -0,0 +1,80 @@ +# 0001: Signage owns timing; a provider owns content + +- Status: Accepted +- Date: 2026-08-27 +- Related: Ekklesia [ADR 0006](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) + (versioned capabilities), [ADR 0041](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) + (browser contributions) + +## Context + +Church screens show announcements, scripture, trivia, service times, giving prompts, and photographs. +The obvious design — one module that knows about all of them — is the design the legacy Announcements +container has, and it is why adding trivia to that product means changing that product. + +Signage exists to make the opposite true: adding trivia to the screens means installing a Trivia +module, and Signage never changes. + +That only works if the line between the two is drawn somewhere principled. Drawn in the wrong place, +Signage ends up with an `announcementId` column, or a provider ends up owning a timer and two +displays drift apart. + +## Decision + +**Signage owns everything about *when*. A provider owns everything about *what*.** + +| Signage decides | A provider decides | +| --- | --- | +| Which slot is on screen, starting exactly when, until exactly when | Which of its items is next in a given selection scope | +| That a slot's turn has come and a selection must be made | Whether that item may repeat, and when its pool is exhausted | +| That every channel in a group advances together | What the item looks like, in its own React renderer | +| How long a slot lasts when nothing overrides it | Its own default duration, and its own availability window | +| That a resolved selection is persisted, revised, and replayed identically | What is inside the payload it hands back | + +Concretely: + +- Signage calls a provider; a provider never calls Signage's playback. A provider can *notify* + Signage that something changed (invalidation) but cannot start, stop, skip, extend, or reorder + anything. +- Signage supplies a **stable opaque selection-scope id** and asks for the next item. The provider + advances its own cursor within that scope, in its own tables. Signage stores no cursor of the + provider's, and the provider stores no timing of Signage's. +- Signage persists the *result* — item identity, revision, payload per audience, duration, + availability — so that the same slot renders identically for a viewer that joined ten seconds later, + survives a restart, and can be replayed by `previous` without asking the provider again. +- Signage has no column, field, route, or type naming an announcement, a passage, a question, a + campus, or a service. A payload is opaque JSON on the way through and opaque JSON at rest. + +## The one exception, and why it is not one + +Native images are Signage's own content: it stores them, processes them, and renders them. That looks +like an exception to the rule until you see how it is built — as **Signage's own built-in content +provider**, `signage:native-images`, implementing the same registration shape an external provider +implements, resolving through the same code path, and persisting into the same resolved-selection +table. + +There is therefore exactly one selection pipeline, not two. A native image slot and a Trivia slot +differ in which provider answers, and in nothing else. Had native images been special-cased, every +later feature — history, offline continuation, audience variants, `previous` — would have had to be +written twice and would have drifted. + +## Consequences + +- A provider module can be written, installed, versioned, and removed without Signage knowing it + exists. Signage never enumerates provider modules and declares no dependency on one. +- A church that never installs Trivia has a Signage that is not missing anything; a timeline simply + has no Trivia slot. +- Provider failure cannot corrupt playback, because playback state was never the provider's to hold. + What a failure costs is at most the freshness of one slot's content. +- Signage cannot offer a feature that requires understanding content — "show announcements sorted by + campus" is a Trivia-shaped request that belongs to whichever module owns campuses. + +## Alternatives + +- **One module that knows every content type.** Rejected: it is the legacy design, and every new kind + of content is a change to the module every screen depends on. +- **Providers push content into Signage's tables.** Rejected: two writers to one timeline, no way to + attribute a bad row, and a provider outage that leaves stale rows nobody owns. +- **Providers own their own timing and Signage only composes screens.** Rejected outright: two + independent clocks cannot hold two televisions on the same slide, which is the single thing a + synchronized channel group exists to guarantee. diff --git a/docs/architecture/decisions/0002-content-provider-contract.md b/docs/architecture/decisions/0002-content-provider-contract.md new file mode 100644 index 0000000..7013587 --- /dev/null +++ b/docs/architecture/decisions/0002-content-provider-contract.md @@ -0,0 +1,114 @@ +# 0002: The content-provider contract, and what it deliberately cannot express + +- Status: Accepted +- Date: 2026-08-27 +- Related: [0001](0001-signage-owns-timing-providers-own-content.md), + [0003](0003-timelines-slots-and-selection-scopes.md) + +## Context + +`signage.content-providers` is the seam every future content module reaches Signage through. It is +the one contract in this repository that will have implementations Signage's authors never see, so +its shape has to be right before there is a second implementation rather than after. + +## Decision + +A provider module resolves the capability during its own `start` and registers once. Registration +returns a handle; the handle is released during `stop`. Signage keeps registrations **in memory +only** — a registration is a running fact, not a stored one, so a provider that is uninstalled leaves +no row behind claiming it exists. + +### What a registration carries + +- `providerId`, `providerVersion` — stable identity, chosen by the provider. +- `contractVersion` — which version of *this* contract the provider was written against. Signage + refuses a registration outside its supported range rather than guessing. +- `renderer: { contributionId, contributionVersion }` — the browser renderer this provider's content + is drawn by. Signage never imports it; it hands the identity to its own display, which asks the + host to mount it (ADR 0041). A provider whose renderer the platform did not publish is registered + and reported as unrenderable rather than refused, because publication is the platform's decision + and can change without the provider changing. +- `defaultDurationMs` — how long this provider's content is shown when no slot overrides it. +- `listSources(query)` — the sources an operator may put in a timeline. Management discovery only. +- `listItems(query)` — optional. Specific items, so an operator can pin one. +- `resolveNext(request)` — **the only call that advances anything.** +- `refresh(request)` — re-resolve one already-selected item without advancing. + +### What Signage supplies, and nothing more + +```text +selectionScopeId stable, opaque, and the same string every time this source occurs in this timeline +sourceId which of the provider's own sources is being asked +timelineId which timeline is running +channelGroupId which synchronized group is asking +audienceKeys every audience channel in that group, in a stable order +at the instant being resolved for, as an absolute ISO 8601 instant +timezone the installation's IANA zone, for a provider whose availability is calendar-shaped +``` + +Signage does not supply a principal, a session, a database, storage, a URL, or a permission. A +provider is already running inside the same trusted process; what it needs from Signage is *when*, +and that is the whole list. + +### What a provider returns + +One **logical** selection — never one per channel: + +```text +itemId the provider's own stable identity for the content +itemRevision changes whenever the payload changes and the item stays the same thing +durationMs? overrides the provider's default for this one item +availableUntil? when this selection stops being valid, as an absolute instant +withdrawn? this item is explicitly gone; remove it at the next safe boundary +presentation? opaque presentation values the provider itself supplies to its own renderer +variants one payload per audience key, plus one default +``` + +The default variant is required. An audience-specific variant is optional, and a provider that has +none simply returns its default — which is what makes "in-person" and "online" a provider feature +rather than a Signage one. + +### Three distinct failure answers, deliberately + +| The provider says | Signage does | +| --- | --- | +| Returns `null` | Nothing is available for this source right now. **Skip the slot immediately.** No error, no slate, no gap. | +| Returns `{ withdrawn: true }` | This item is explicitly gone. Drop it at the next safe reconciliation boundary. | +| Throws, rejects, or exceeds its deadline | A temporary failure. **Keep the last known good selection, mark it stale**, and never reset the slot. | + +Collapsing these into one would be the whole design's biggest mistake: an empty Trivia pool would +show an error, and a provider restart would clear the screen. + +### Bounded, and sanitized + +Every provider call is wrapped: a deadline (`providerTimeoutMilliseconds`), a `try`/`catch`, and a +result validated before it is believed. A malformed result is rejected with a stable +`SIGNAGE_PROVIDER_*` code carrying no provider value, no payload, and no caught exception. A provider +cannot crash the Signage runtime, corrupt playback, or put its own words on a screen or in a log. + +### Invalidation is a notification, not a command + +`handle.invalidate({ ... })` tells Signage that something it already resolved may have changed. +Signage decides what to do: refresh the affected selections, bump their revision, and broadcast. The +provider cannot say *when*, cannot skip a slot, and cannot extend a deadline. This is what lets a +cancelled event update a mounted React renderer within a second while its slot's authoritative +deadline stays exactly where it was. + +## Consequences + +- A provider is a small object of pure functions over its own data. It needs no timer, no socket, no + knowledge of channels, and no Signage table. +- Signage's provider code is the same for every provider, including its own built-in image provider. +- A provider written against contract v1 keeps working when v2 exists, because v2 will be a new + version a provider opts into by declaring it. + +## Alternatives + +- **Let providers write resolved selections directly.** Rejected: two writers, no attribution, and + stale rows after an uninstall. +- **One `resolve(at)` call with no scope.** Rejected: five Announcement slots in one pattern must + share one cursor, and without a scope id a provider cannot tell "the next one" from "the first one". +- **Per-channel resolution.** Rejected: it makes audience variants a race. One logical selection + resolved once, with variants derived from it, is what keeps two channels on the same slide. +- **An error means "skip".** Rejected: a provider that is briefly down would silently rewrite the + running order, and nobody would ever find out. diff --git a/docs/architecture/decisions/0003-timelines-slots-and-selection-scopes.md b/docs/architecture/decisions/0003-timelines-slots-and-selection-scopes.md new file mode 100644 index 0000000..1feb2d1 --- /dev/null +++ b/docs/architecture/decisions/0003-timelines-slots-and-selection-scopes.md @@ -0,0 +1,99 @@ +# 0003: Timelines are ordered patterns; a source instance is a selection scope + +- Status: Accepted +- Date: 2026-08-27 +- Related: [0002](0002-content-provider-contract.md), [0004](0004-channel-groups-and-one-clock.md) + +## Context + +An operator building a Sunday-morning loop wants something like: + +```text +Announcements Announcements Announcements Trivia Announcements Announcements Scripture ↻ +``` + +Five Announcement slots. They must not all show the same announcement, and they must not each start a +separate rotation that happens to begin at the same place. They are five *turns* of one rotation. + +## Decision + +Three concepts, and no more. + +### A timeline is an ordered, repeating pattern + +`signage_timelines` holds a name, an optional eligibility window, and a revision. It holds no cursor, +no clock, and no channel: a timeline is a *program*, and running one is what a channel group does. +The same timeline may be activated on two groups at once and they will not interfere. + +### A slot is a position in that pattern + +`signage_timeline_slots` rows are `(timeline_id, position)` with `position` **unique and contiguous** +from zero. Contiguity is enforced inside the transaction that changes it, never by renumbering on +read: a gap would make "the next slot" ambiguous exactly when playback needs it least. + +A slot's identity is its `id`, which is stable across every reorder. Playback history, resolved +selections, and a display's cursor all reference the id, so dragging a slot up the list never +rewrites what already happened. + +Each slot is one of two kinds: + +- **`provider-source`** — "ask this source for the next item when this slot's turn comes". +- **`pinned-item`** — "show exactly this item". A native Signage image is a pinned item of the + built-in image provider, which is why the two need no separate code path (ADR 0001). + +Every slot may carry an `enabled` flag, a duration override, its own eligibility window, and — for +native images only — presentation overrides. + +### A source instance is a selection scope + +`signage_provider_sources` is the piece that makes the five-Announcement pattern work. One row is +"the Announcements module's `active-slides` source, as used by this timeline", and it carries a stable +opaque `selection_scope` string. + +Every slot referencing that row passes the **same** scope id to `resolveNext`. The provider advances +its own cursor within that scope, so the five occurrences walk through five different announcements +and the sixth wraps according to *the provider's* repeat policy — which Signage neither implements +nor knows about. + +Two source rows for the same provider source are two independent rotations, which is exactly how an +operator expresses "a fast loop here and a slow one there". + +Signage generates the scope id and stores it. It is opaque to the provider in the sense that its +shape means nothing; it is stable in the sense that it survives edits, restarts, and reorders. It is +**not** derived from the timeline id or the slot id, because either would silently reset a +provider's rotation when an operator renamed or reordered something. + +### Eligibility is `[start, end)`, in absolute instants + +Both a timeline and a slot may carry a window. It is half-open: active exactly at `start`, inactive +exactly at `end`, which is the only convention under which two adjacent windows neither overlap nor +leave a gap. + +Every stored instant is absolute (`timestamptz`). The installation's IANA zone is an operator setting +used to *write and read* those instants for a human, never to interpret an ambiguous local timestamp +at run time. A daylight-saving transition therefore cannot move a slot. + +Signage decides whether a source *participates*; the provider decides what is *available*. Leaving +Trivia out of a service means using a timeline without a Trivia slot, or disabling that slot — and +Signage still never learns what Trivia is. + +## Consequences + +- Reordering, renaming, duplicating, and disabling are cheap and safe: none of them touch a scope id + or a selection's identity. +- A timeline with every slot disabled, or every slot ineligible, is a legitimate state — it resolves + to nothing, and the display shows its slate rather than an error. +- An operator can express "three announcements, then trivia, then two more announcements" without any + concept of grouping, nesting, or weighting. The pattern *is* the expression. + +## Alternatives + +- **Slot position as identity.** Rejected: every reorder would rewrite history and break a display's + cursor mid-service. +- **Scope derived from `(timeline, provider, source)`.** Rejected: it silently merges two source rows + an operator deliberately created as separate rotations, and changes meaning when a timeline is + duplicated. +- **A `repeat: n` count on one slot instead of repeated slots.** Rejected: the pattern is the thing an + operator reads, and a count hides where in the loop the repeats actually fall. +- **Local wall-clock schedules.** Rejected: one ambiguous hour every autumn, resolved silently, on the + screens a congregation is looking at. diff --git a/docs/architecture/decisions/0004-channel-groups-and-one-clock.md b/docs/architecture/decisions/0004-channel-groups-and-one-clock.md new file mode 100644 index 0000000..252fd74 --- /dev/null +++ b/docs/architecture/decisions/0004-channel-groups-and-one-clock.md @@ -0,0 +1,62 @@ +# 0004: A channel group is one clock; an audience channel is one rendering of it + +- Status: Accepted +- Date: 2026-08-27 +- Related: [0002](0002-content-provider-contract.md), [0005](0005-authoritative-playback.md) + +## Context + +A church wants the lobby screens, the sanctuary side screens, and the online stream overlay to be on +the same slide at the same moment — but the online audience should see "watching from home" wording +where the room sees "welcome in". That is one program with two renderings, not two programs. + +It also wants the nursery hallway to run a completely different loop. That *is* two programs. + +## Decision + +**A channel group owns exactly one authoritative timeline clock.** It has one active timeline, one +current slot, one start instant, one deadline, one pause state, one blackout state, and one pending +activation. Everything a group's channels do, they do together. + +**A channel is an audience within a group.** Each has a `key` (`default`, `in-person`, `online`, …), +and exactly one channel per group is the default. Channels differ in exactly one way: which variant of +a resolved selection they render. + +- Signage resolves **one logical selection per slot occurrence**, for the whole group. +- The provider is asked once, with every audience key the group has. +- Each channel renders the variant matching its key, or the default variant if the provider offered + none. + +A truly independent clock requires another channel group. There is no per-channel pause, no +per-channel skip, and no per-channel timeline — and no route that could express one. + +**One default channel is created automatically**, and no other. `in-person` and `online` are examples +in a document, not rows in a migration: a church that never needs a second audience never sees one. + +**A viewer never chooses its channel.** A display registers with its persistent viewer identity and +joins the default channel of the default group; an administrator moves it from the management +dashboard. A public display that could pick its own channel would let anyone who reaches the page +select what a screen shows. + +## Consequences + +- Two viewers that connected minutes apart render the same slot with the same deadline, because both + read the same persisted absolute instants rather than counting from when they arrived. +- Adding an audience is additive: the provider starts returning a second variant, and the default + channel is unaffected. +- A provider that returns no variant for `online` is not a failure and produces no warning. Returning + the default is the contract. +- "Pause the lobby but not the sanctuary" is impossible by construction, and correctly so — those are + two groups. + +## Alternatives + +- **One clock per channel, synchronized by convention.** Rejected: two clocks drift, and no amount of + correction makes two televisions in one room agree. +- **Per-channel timelines with a shared cursor.** Rejected: the cursor is only meaningful against one + pattern; two patterns of different length make "the same slot" undefined. +- **Let the display pick its audience from a query parameter.** Rejected: it is an unauthenticated + content selector on a screen in a public hallway. +- **Create `in-person` and `online` channels on install.** Rejected: it presumes a broadcast ministry + every church does not have, and an unused channel is a thing an operator has to understand and + ignore forever. diff --git a/docs/architecture/decisions/0005-authoritative-playback.md b/docs/architecture/decisions/0005-authoritative-playback.md new file mode 100644 index 0000000..2279136 --- /dev/null +++ b/docs/architecture/decisions/0005-authoritative-playback.md @@ -0,0 +1,98 @@ +# 0005: Playback is absolute instants reconciled forward, never a countdown + +- Status: Accepted +- Date: 2026-08-27 +- Related: [0004](0004-channel-groups-and-one-clock.md), + [0006](0006-display-rendering-and-offline-continuation.md) + +## Context + +The proven behaviour this preserves comes from the legacy Announcements `v2` playback engine, which +ran unattended church screens through outages, restarts, and mid-service edits. Its three hard-won +properties are worth restating, because they are the ones a naive implementation loses: + +1. **Absolute instants, not remaining time.** A slide has a `startedAt` and a `deadlineAt`; a timer is + only ever a hint about when to look again. +2. **Reconcile forward from the previous authoritative deadline**, never from "now". Otherwise a + delayed callback shortens nothing and lengthens everything, and drift accumulates all day. +3. **Catch up arithmetically.** A screen that was off for a week must not replay a week of slides. + +## Decision + +### One serialized service per channel group + +Each group has exactly one playback service owning its persisted state and its timers. Every mutation +— a timer firing, an operator command, a provider invalidation — runs serialized against that group, +inside one explicit transaction, and produces at most one revision increment. Two concurrent +commands cannot interleave into a state neither asked for. + +`revision` increases **only on semantic change**. An exact no-op — resuming something already +running, activating the timeline already active — commits nothing and broadcasts nothing. + +### Reconciliation, not simulation + +`reconcile(group, at)` is the single entry point. It reads persisted state and walks it forward to +`at`: + +- If the current slot's deadline is still in the future, **nothing happens**. This is what makes + restart recovery show the same item it was showing, rather than choosing a new one. +- Otherwise the boundary is the *previous deadline*, not `at`. The next slot starts exactly when the + last one ended, so a callback that fired 40 ms late costs 40 ms once instead of 40 ms per slide + forever. +- Eligibility and expiration boundaries are honoured at their exact instants, and a currently showing + slot can only be cut short by *its own* window, never by another slot's. + +### Catch-up is bounded by boundaries, not by missed slides + +When the gap is long, Signage advances the slot cursor **arithmetically**: it computes the effective +duration of each enabled, eligible slot from what it already knows — the slot override, or the +provider's registered default, or the native-image default — sums one pass of the pattern, and skips +whole passes with a division. Only the slot it actually lands on is resolved. + +That is the property that keeps a provider's rotation sane: catching up a week does **not** call +`resolveNext` ten thousand times and does not consume a provider's no-repeat pool. Fast-forwarding +stops at the first eligibility boundary in the interval, exactly as `v2` did, so a schedule change +inside the outage is still honoured. + +### Transport + +| Control | Meaning | +| --- | --- | +| Pause | Freeze the current slot. Persist the remainder; clear start and deadline. | +| Resume | `deadline = now + remainder`; `start = deadline − duration`, so the reconstructed timeline stays consistent. | +| Static hold | Show one native image while **preserving the normal cursor and remainder** underneath. Releasing it resumes from that remainder. | +| Next / Previous | Move over **actually resolved playback**: `previous` re-shows the previous resolved selection from history, not "the slot before this one". | +| Blackout | Cover the output with black. The hidden timeline keeps running, keeps its deadlines, and keeps resolving. | +| Reload | Refresh provider availability and re-resolve upcoming selections **without disturbing the current frame or its deadline**. | +| Activate now / at next boundary | Immediate, or queued as `pending` and applied at the next safe slot boundary. | + +A provider's internal React behaviour may change anything it likes during a slot; it may not extend +that slot's authoritative deadline. The deadline was set by Signage before the renderer mounted. + +### Realtime carries revisions, not content + +Signage publishes compact authoritative state — group id, revision, slot identity, absolute +start/deadline, transport flags — and nothing large. Timelines, histories, and renderer payloads are +fetched over authorized HTTP, keyed by revision, so a client that sees a revision it does not have +refetches rather than waiting. That closes the GET-then-subscribe race without a second transport, +and keeps every publication far inside the platform's payload ceiling. + +Signage introduces no socket, no broker, and no second realtime transport. It publishes through the +platform's module realtime contract and nothing else. + +## Consequences + +- Server authority is unambiguous: on reconnect, the server's state wins immediately, and a client's + local cursor is discarded rather than reconciled. +- Timers are never load-bearing. Losing one costs a late transition, not a wrong one. +- Two displays that connected minutes apart agree, because they both read the same absolute instants. +- A long outage costs one reconciliation, not one per missed slide. + +## Alternatives + +- **A countdown per slide.** Rejected: it drifts, and it cannot be restored after a restart. +- **Reconcile from `now`.** Rejected: every late callback permanently lengthens the loop. +- **Replay every missed slide to catch up.** Rejected: it burns a provider's rotation and can take + longer than the outage. +- **Publish full state over realtime.** Rejected: a large timeline exceeds the payload ceiling, and a + screen that missed one message would be wrong until the next one. diff --git a/docs/architecture/decisions/0006-display-rendering-and-offline-continuation.md b/docs/architecture/decisions/0006-display-rendering-and-offline-continuation.md new file mode 100644 index 0000000..f1168aa --- /dev/null +++ b/docs/architecture/decisions/0006-display-rendering-and-offline-continuation.md @@ -0,0 +1,111 @@ +# 0006: Two permanent layers, real readiness, and an offline continuation that never becomes authority + +- Status: Accepted +- Date: 2026-08-27 +- Related: [0005](0005-authoritative-playback.md), Ekklesia + [ADR 0043](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (display offline code) + +## Context + +The legacy `v1` renderer rebuilt image elements per slide and faded the outgoing frame to +transparent, which let the black page background show through the middle of every transition. `v2` +fixed it with two permanent layers and never fading the outgoing one, and that fix is the single most +visible quality difference on a real television. + +Signage additionally has to transition between things that are not images at all: a provider's live +React renderer, mounted through the platform's contribution ABI. + +## Decision + +### Two permanent scene layers, and one direction of fade + +The display owns exactly two full-screen layers, created once and never rebuilt. For every +transition: + +1. Resolve and mount the incoming content in the **hidden** layer — a native image, or a contributed + renderer mounted through `context.contributions.mount(...)`. +2. Wait for **actual readiness**: `img.decode()` for an image, and the renderer's own `mount` + resolution for a contribution, which the contribution ABI defines as asynchronous precisely so a + renderer can say "not yet". +3. Discard completion belonging to a stale generation. A newer transition always wins. +4. Keep the outgoing layer **fully opaque** the entire time. +5. Raise the prepared incoming layer above it at opacity zero. +6. Fade **only the incoming layer** in. +7. Unmount and release the old layer after the fade completes. + +Both layers are never faded at once, so the black root can never contribute to the composite. + +### Failure keeps the last good frame + +If incoming content cannot be decoded or mounted: keep the frame that is already on screen, record a +bounded structured error, mark **that selection** locally unusable, and continue. A broken item is +never retried in a tight loop, and one broken item never blanks a screen. + +An individually empty provider slot is different, and is skipped immediately with no error at all — +that is the provider saying "nothing right now", which is ordinary. + +Only when there is neither renderable timeline content nor a cached good frame does the display show +its built-in slate: restrained, non-flashing bars and the words *Content unavailable*, carrying no +exception, URL, payload, credential, database detail, or identifier. Detail belongs in Signage's +sanitized management telemetry, where an already-authorized operator can read it. + +A deliberate blackout is not a failure and never reaches the slate: it is pure black. + +### The management preview is the same code + +The Dashboard's live preview mounts the identical renderer host, the identical native-image +presentation resolver, and the identical layer transition. It differs in exactly two ways: it does not +register a viewer, and it sends no heartbeat. A preview that reimplemented the transition would drift +from what the wall actually shows, which makes it worse than no preview. + +### Offline continuation, and its honest limit + +Once mounted, a display survives losing the host. Signage persists its own domain state — viewer +identity, the last authoritative snapshot, resolved selections and revisions, audience payloads, the +timing anchor, transport state, renderer contribution identity, and the most recent safe error — in +**IndexedDB**, and native image bytes in **Cache Storage**. Ordinary HTTP cache is not an asset +manager and is not used as one. + +While offline the display continues deterministically from the last authoritative anchor, applying +cached scheduling and expiration, preserving pause, blackout, static hold, and remaining duration, +skipping explicitly expired or withdrawn content, keeping the last good frame when expected content +is unusable, and never inventing provider data. Catch-up is arithmetic, mirroring the server's own +rule, so a long outage does not replay linearly. + +**A local cursor is never reported back as authority.** On reconnect the server's state wins +immediately; incoming artifacts and data are staged before the current frame is replaced, no missed +slides are replayed, and superseded payloads and image bytes are pruned only after the new snapshot +is usable. + +### The limit this cannot fix, stated rather than papered over + +**A cold browser start while Ekklesia is unreachable cannot mount Signage at all.** The platform's +display service worker caches the shell and the declared code, deliberately not the display +descriptor — a descriptor is authorization-bearing, and caching one would let a revoked screen keep +showing content. Signage cannot and must not work around that: everything above applies to a display +that is **already mounted** when its network fails, which is the overwhelmingly common case (a router +reboot, an uplink drop, a switch power-cycle). + +True offline cold start requires something only Core can provide: an authorized offline display +descriptor or lease with its own expiry and revocation. That is tracked as an Ekklesia prerequisite, +not as Signage work, and this repository does not claim the capability it does not have. + +## Consequences + +- Transitions are visually identical for images and for live React renderers, because readiness is + awaited rather than assumed for both. +- A provider renderer that throws costs one slide, not the screen. +- The preview and the wall cannot disagree. +- A screen that reboots during an outage shows Ekklesia's own offline state and recovers by itself + when connectivity returns, but shows no Signage content until it can resolve its descriptor. + +## Alternatives + +- **Crossfade both layers.** Rejected: it is the `v1` bug, and it is visible on every transition. +- **Treat `onload`, or a fixed delay, as readiness.** Rejected: a large image paints mid-fade, and a + renderer that is still fetching a font shows a flash of the wrong thing. +- **Cache the display descriptor so a cold start works offline.** Rejected: it is a stored permission + decision, and a revoked screen would keep showing content. This is the platform's rule, and Signage + respecting it is the point. +- **Use the HTTP cache for image bytes.** Rejected: no eviction control, no way to prune superseded + renditions deliberately, and no way to know what is actually available offline. diff --git a/docs/architecture/decisions/0007-native-images.md b/docs/architecture/decisions/0007-native-images.md new file mode 100644 index 0000000..1d42355 --- /dev/null +++ b/docs/architecture/decisions/0007-native-images.md @@ -0,0 +1,108 @@ +# 0007: A native image is content-addressed, archived once, and never baked + +- Status: Accepted +- Date: 2026-08-27 +- Related: [0001](0001-signage-owns-timing-providers-own-content.md), Ekklesia + [ADR 0030](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (host-provided storage), + [ADR 0042](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (streaming bodies) + +## Context + +Native images are the only content Signage owns, and the one thing it must do well with no other +module installed. A church's actual usage is an operator dragging twenty exported slides onto a page, +some of them the same file they uploaded last week. + +## Decision + +### Identity is the content hash + +An asset's identity is the **SHA-256 of the uploaded bytes**, and the database enforces it as unique. +Uploading the same bytes twice returns the existing asset instead of storing a second copy, and two +operators uploading the same file — even simultaneously — produce one asset and one set of stored +objects: the loser of that race finds the winner's asset and reports success, because the image the +operator uploaded is stored. + +Object keys are scoped to the asset (`assets//…`), and until that asset's row commits they +are **temporary**: nothing references them, nothing can select them, and a failed ingest deletes +every one it wrote. Scoping them to the asset rather than to a throwaway upload id is what makes a +successful upload cost exactly one write of each object instead of two — the commit is the database +row, not a second copy of 25 MiB. + +### One file per request, streamed, bounded + +Uploads use `bodyMode: "streamed"` with a **25 MiB** Signage limit, inside the platform's 32 MiB +absolute ceiling. The unit is deliberately one file: a dashboard offering twenty images issues twenty +individually bounded requests, each of which can fail cheaply, rather than one request whose failure +wastes everything already sent. Nothing in Signage can express an aggregate upload. + +Because a `413` can arrive *after* the handler starts in streamed mode, **nothing is written anywhere +until the whole request has been read and validated**. An upload that is aborted, oversized, +malformed, or unsupported has therefore written zero objects and zero rows. Once validation passes, +the objects are written to the temporary asset-scoped keys above and the row commits last; any +failure in between removes every object that attempt wrote. A partial write is never promoted to an +asset. + +### Accepted, and refused + +Accepted: still PNG, JPEG, and WebP. Refused: SVG, GIF and any animation, PDF, audio, video, +multipage input, anything malformed, anything over 25 MiB, and anything whose decoded size exceeds +**40 million pixels** — the last because decoded pixels, not file size, are what a decompression bomb +actually costs. + +Format is decided by **decoding the bytes**, never by the filename or the declared content type. + +### Three stored objects, and what each is for + +| Object | Purpose | +| --- | --- | +| Original | Archived **byte-for-byte, unmodified**. It is the church's file; Signage is not entitled to rewrite it. | +| Display WebP | Inside-fit within 1920×1080, quality 82. What a television actually renders. | +| Thumbnail WebP | Inside-fit within 480×270, quality 72. What the library grid renders. | + +Both renditions are auto-oriented, **never cropped**, **never upscaled**, stripped of metadata, and +flattened against black. The original's real dimensions, orientation, and useful metadata stay in +PostgreSQL, where they are queryable without touching storage. + +### Presentation is resolved at render time, never baked + +A 16:9 image (within a documented tolerance of 0.02) fills the frame on its own: no blurred +duplicate, no pillarbox, no floating-card shadow. Anything else is shown **whole** with +`object-fit: contain` over a blurred, solid, or black background, optionally darkened, with a +foreground shadow. + +None of that is written into a stored rendition. Changing a slot's background from blurred to black +is a database update, not a re-encode, and the same rendition serves both. These controls apply to +native images only — an external provider owns its rendering completely. + +### Keys are persisted; URLs are not + +Signage stores **logical object keys** and asks the host for a fresh read URL when something is about +to be delivered. A read URL is a bearer capability with an expiry in it; storing one in a table would +be storing a credential that stops working. Signage never configures, names, or identifies a storage +service. + +### Deletion is reference-aware + +Deleting an asset referenced by a timeline slot, by a pinned item, or by a live static hold is refused +with a sanitized code naming what refers to it. An asset with a null expiration is permanent; local +age never invents one. Rendition identity is immutable — a rendition is replaced by uploading +different bytes, which by definition produces a different asset. + +## Consequences + +- Twenty uploads are twenty independent, individually recoverable requests, and the dashboard stays + responsive because none of them blocks another. +- Re-uploading last week's file costs one hash and one row read. +- A partially failed ingest leaves a temporary object that is removed, never an asset in a state + playback could select. +- Changing presentation is instant and lossless, because nothing was baked. + +## Alternatives + +- **A generated UUID as identity.** Rejected: it makes deduplication impossible and retries dangerous. +- **Trust the declared content type.** Rejected: it is attacker-controlled and routinely wrong even + when it is not. +- **Bake the background and shadow into the rendition.** Rejected: every presentation change becomes a + re-encode, and the archived original stops being the source of truth. +- **One multi-file upload request.** Rejected: one bad byte wastes every byte already sent, and the + platform's ceiling is per request for exactly that reason. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md new file mode 100644 index 0000000..8c50545 --- /dev/null +++ b/docs/architecture/decisions/README.md @@ -0,0 +1,20 @@ +# Signage architecture decision records + +These record the durable choices behind Signage itself. Ekklesia's own ADRs remain authoritative for +everything the platform owns — manifests, capabilities, permissions, storage, realtime, browser +artifacts, contributions, and request bodies — and are never restated here. + +- Filenames use a four-digit sequence followed by a short kebab-case title. +- Status is `Proposed`, `Accepted`, `Superseded`, or `Rejected`. +- Accepted ADRs are immutable except for status and cross-reference corrections. +- A changed decision receives a new ADR that links to and supersedes the old one. + +## Index + +- [0001: Signage owns timing; a provider owns content](0001-signage-owns-timing-providers-own-content.md) — Accepted +- [0002: The content-provider contract, and what it deliberately cannot express](0002-content-provider-contract.md) — Accepted +- [0003: Timelines are ordered patterns; a source instance is a selection scope](0003-timelines-slots-and-selection-scopes.md) — Accepted +- [0004: A channel group is one clock; an audience channel is one rendering of it](0004-channel-groups-and-one-clock.md) — Accepted +- [0005: Playback is absolute instants reconciled forward, never a countdown](0005-authoritative-playback.md) — Accepted +- [0006: Two permanent layers, real readiness, and an offline continuation that never becomes authority](0006-display-rendering-and-offline-continuation.md) — Accepted +- [0007: A native image is content-addressed, archived once, and never baked](0007-native-images.md) — Accepted diff --git a/docs/provider-contract.md b/docs/provider-contract.md new file mode 100644 index 0000000..8319aaf --- /dev/null +++ b/docs/provider-contract.md @@ -0,0 +1,343 @@ +# Writing a Signage content provider + +Signage decides **when** something is on screen. Your module decides **what**. + +This document is the published contract for `signage.content-providers` version **1.0.0**, and for +the renderer input Signage hands your browser contribution. It is written to be implementable from +here alone — you will not import a line of Signage's code, because Ekklesia modules are independently +installed peers and `pnpm boundaries` refuses a module that imports another module. + +--- + +## 1. What you declare + +In your own `ekklesia.module.json`: + +```json +{ + "dependencies": { + "modules": { "required": { "signage": "^0.1.0" } }, + "capabilities": { + "required": { + "signage.content-providers": "^1.0.0", + "signage.slide-renderer": "^1.0.0" + } + } + }, + "contributions": [ + { + "id": "announcements.signage-slide", + "version": "1.0.0", + "capability": "signage.slide-renderer", + "capabilityRange": "^1.0.0", + "artifact": "./dist/contributions/signage-slide/index.js" + } + ] +} +``` + +The module dependency is what makes the platform start Signage before you and stop you before +Signage — so your registration always has something to register with, and your unregistration always +happens first. + +## 2. What you register + +During your own `start` hook: + +```ts +const signage = context.capabilities.resolve("signage.content-providers"); +if (!signage) { + return; // Signage is not installed. Nothing else about your module should care. +} +const registry = signage.implementation as SignageContentProviders; +this.handle = registry.register({ + providerId: "announcements", + providerVersion: "1.0.0", + contractVersion: "1.0.0", + renderer: { contributionId: "announcements.signage-slide", contributionVersion: "1.0.0" }, + defaultDurationMs: 10_000, + listSources, + listItems, + resolveNext, + refresh, +}); +``` + +And release it in `stop`: + +```ts +this.handle?.unregister(); +``` + +`unregister` is idempotent, which matters: your `stop` hook runs **before** rollback if your own +`start` throws partway, and again during reverse-order shutdown. + +### The types, to copy into your own module + +```ts +type SignageJsonValue = + | boolean | number | string | null + | readonly SignageJsonValue[] + | { readonly [key: string]: SignageJsonValue }; + +interface SignageContentProviders { + readonly contractVersion: string; + register(registration: SignageProviderRegistration): SignageProviderHandle; +} + +interface SignageProviderHandle { + unregister(): void; + invalidate(change?: { sourceId?: string; itemId?: string }): void; +} + +interface SignageProviderRegistration { + providerId: string; // stable, yours, `[\w][\w.:-]*`, ≤ 200 chars + providerVersion: string; // semantic version + contractVersion: string; // "1.0.0" + renderer: { contributionId: string; contributionVersion: string }; + defaultDurationMs: number; // 1000 … 3_600_000 + listSources(request: DiscoveryRequest): Awaitable; + listItems?(request: DiscoveryRequest & { sourceId?: string }): Awaitable; + resolveNext(request: ResolveRequest): Awaitable; + refresh(request: RefreshRequest): Awaitable; +} + +interface DiscoveryRequest { at: string; timezone: string } + +interface SourceDescriptor { id: string; label: string; description?: string } +interface ItemDescriptor { id: string; label: string; description?: string; availableUntil?: string | null } + +interface ResolveRequest { + selectionScopeId: string; // stable and opaque — see §3 + sourceId: string; + timelineId: string; + channelGroupId: string; + audienceKeys: readonly string[]; + at: string; // absolute ISO 8601 instant + timezone: string; // IANA zone +} + +interface RefreshRequest extends Omit { + itemId: string; + sourceId?: string; + selectionScopeId?: string; +} + +interface Resolution { + itemId: string; + itemRevision: string; // changes when the payload changes, item unchanged + durationMs?: number; // 1000 … 3_600_000 + availableUntil?: string | null; // absolute ISO 8601 instant + withdrawn?: boolean; + presentation?: SignageJsonValue; // yours, for your own renderer + variants: readonly { audienceKey: string | null; payload: SignageJsonValue }[]; +} +``` + +## 3. The selection scope is the whole idea + +An operator's timeline might be: + +```text +Announcements Announcements Announcements Trivia Announcements Announcements Scripture ↻ +``` + +Those five Announcement slots are five **turns of one rotation**, not five rotations. Signage +expresses that by giving every slot that references one configured source instance the same +`selectionScopeId`, and asking `resolveNext` once per turn. + +- The scope id is **stable**: it survives edits, reorders, renames, and restarts. +- It is **opaque**: its shape means nothing, so never parse it. +- It is **not** derived from the timeline or slot id, because either would silently reset your + rotation when an operator dragged a row. + +Keep your cursor, your "already shown in this pass" set, and anything else you need **in your own +tables, keyed by scope id**. Signage stores none of it and will never tell you what it thinks your +cursor is. + +Two configured source instances of the same source are two independent rotations. That is an +operator deliberately asking for one. + +## 4. Repeat policy is yours + +Signage implements none of it: + +| Your module | Your policy | +| --- | --- | +| Announcements | Cycle the active set forever, wrapping where you choose | +| Trivia | Never repeat until every currently available question has appeared | +| Scripture | Never repeat until the reading pool is exhausted | + +Signage asks "what is next in this scope?" and shows what you hand back. + +## 5. Three answers, and why they stay three + +| Return | Meaning | What Signage does | +| --- | --- | --- | +| `null` | Nothing available for this source right now | **Skips the slot immediately.** No error, no slate, no gap. | +| `{ withdrawn: true, … }` | This item is explicitly gone | Removes it at the next safe reconciliation boundary, never mid-slide. | +| throw / reject / exceed the deadline | A temporary failure | **Keeps the last known good selection and marks it stale.** The slot is not reset and playback does not stop. | + +Collapsing these is the single most damaging mistake a provider can make. An empty question pool that +throws becomes an error on a lobby wall; a genuine outage that returns `null` silently rewrites the +running order and nobody finds out. + +Every call is bounded by a deadline (an installation setting, 2 seconds by default). A call that +never settles is a temporary failure — Signage does not wait, and does not cancel your work. + +## 6. One selection, many audiences + +A channel group has one clock and one or more audience channels (`default`, and optionally +`in-person`, `online`, …). Signage asks you **once** per slot occurrence, with every audience key, +and you return **one logical item** with a variant per audience: + +```ts +return { + itemId: "advent-service", + itemRevision: "3", + variants: [ + { audienceKey: null, payload: { headline: "Join us Sunday, 5pm" } }, + { audienceKey: "online", payload: { headline: "Streaming Sunday, 5pm" } }, + ], +}; +``` + +- The **default variant is required** — `null` or the literal `"default"`. +- An audience with no variant of its own renders the default. That fallback is the contract, not a + convenience, and a provider with nothing audience-specific simply returns one variant. +- Resolving once is what keeps every channel on the same slide with the same deadline. There is no + per-channel resolution and no way to ask for one. + +## 7. Invalidation updates a slide without restarting it + +When your content changes — an event is cancelled, a countdown ticks past a threshold, a service time +is corrected — tell Signage: + +```ts +this.handle.invalidate({ itemId: "advent-service" }); +``` + +Signage decides what to do: it re-resolves the affected selection through `refresh`, and if your +`itemRevision` changed it stores the new payloads, bumps the selection's revision, and broadcasts. +A mounted React renderer receives new input **in place**, with the slot's authoritative deadline +unchanged. + +This is a **notification, not a command**. You cannot skip a slot, extend a deadline, reorder a +timeline, or make something appear. That is deliberate: one provider must not be able to take over a +channel group. + +## 8. What Signage validates, and rejects + +Everything crossing the boundary, before it is believed: + +- A registration whose id, version, renderer identity, duration, or functions are not the shape above + is refused with `SIGNAGE_PROVIDER_REGISTRATION_INVALID` — and a `contractVersion` outside `1.x` with + `SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED`, so an operator can see that your module needs an update + rather than that it is broken. +- A resolution is refused with `SIGNAGE_PROVIDER_RESULT_INVALID` when it has no default variant, a + duplicate audience key, more than 16 variants, payloads totalling over 32 KiB, a duration outside + 1 s … 1 h, a non-absolute `availableUntil`, or a payload that is not genuinely finite JSON — a + `Date`, a function, `NaN`, `undefined`, a class instance, or a cycle. +- Every rejection carries a stable code and **nothing of your value**: not your payload, not your + exception, not your message. Signage will not put your words on a screen or in a log. + +None of this is a sandbox, and it is not described as one. Your module is trusted code in the same +process. This is the difference between your bug staying your bug and a malformed value reaching a +television. + +## 9. Your renderer + +Your browser contribution is the actual live renderer for your content. Signage mounts it through the +platform's contribution ABI and treats it as completely opaque. + +```tsx +export default defineModuleContribution({ + id: "announcements.signage-slide", + version: "1.0.0", + styles, + mount(container, context) { + const root = createRoot(container); + const input = context.input as SignageSlideRendererInput; + root.render(); + return { + update(next) { root.render(); }, + unmount() { root.unmount(); }, + }; + }, +}); +``` + +`mount` is asynchronous by contract. **Resolve it only when you are genuinely ready to be seen** — a +font loaded, an image decoded, a layout measured. Signage waits for that promise before it fades your +layer in, which is what makes a transition into a live renderer look like a transition into an image. + +### The input, version 1.0.0 + +```ts +interface SignageSlideRendererInput { + contract: "signage.slide-renderer"; + version: string; // "1.0.0" + providerId: string; + itemId: string; + itemRevision: string; + audienceKey: string; + payload: unknown; // yours, exactly as you returned it + presentation: unknown; // yours, or null + timing: { + startedAtMs: number; // absolute, server clock + deadlineAtMs: number; + durationMs: number; + paused: boolean; + remainingMs: number | null; // when paused + }; + clock: { serverNowMs: number; localNowMs: number }; + generation: string; // changes whenever anything above changed +} +``` + +**Use the clock anchor, not `Date.now()` alone.** A television's clock is routinely wrong by hours. +The authoritative now is: + +```ts +const serverNow = input.clock.serverNowMs + (Date.now() - input.clock.localNowMs); +``` + +That stays correct however wrong the device's clock is, and it is what makes a countdown on two +screens agree. + +### What your renderer must and must not do + +Must: + +- Be ready asynchronously, and update in place through `update` without remounting. +- Handle timing changes: paused, resumed, re-anchored after a reconnect. +- Unmount deterministically, releasing every timer, listener, and framework root. +- Survive repeated mount/unmount without leaking. +- Render sensibly for an input shape older or newer than the one you developed against. + +Must not: + +- Reach the network, a database, storage, or a credential. **You are given none of them**, on + purpose: mounting your renderer grants your module nothing, and everything you need arrives in + `payload` because Signage is the party that was authorized to fetch it. +- Assume your module's management UI is mounted, or that any other renderer exists. +- Extend the slot. `deadlineAtMs` was decided before you mounted. +- Depend on an external internet service. A renderer must work from its cached artifact and its + cached payload, because a display's network is the first thing that fails. + +A provider that genuinely needs cloud access needs a **later** contract version. Version 1 will not +be weakened to allow it. + +## 10. A checklist before you ship + +- [ ] `providerId` is stable and will never change for this module. +- [ ] Cursor and no-repeat state live in your own tables, keyed by `selectionScopeId`. +- [ ] `resolveNext` returns `null` for "nothing right now" and throws only for a genuine failure. +- [ ] `refresh` returns `{ withdrawn: true }` for content that is genuinely gone. +- [ ] Every resolution has a default variant. +- [ ] Payloads are finite JSON, well under 32 KiB for all variants together. +- [ ] `itemRevision` changes exactly when the payload changes. +- [ ] `invalidate` is called when your content changes, and never used to try to control playback. +- [ ] Your renderer resolves `mount` only when it is ready, implements `update`, and cleans up. +- [ ] Your renderer needs no network. +- [ ] `unregister` is called from `stop`, and calling it twice is safe. diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..9b92095 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "postgresql", + schema: "./src/schema/tables.ts", + out: "./drizzle", + migrations: { + prefix: "index", + }, + breakpoints: true, +}); diff --git a/drizzle/0000_initial.sql b/drizzle/0000_initial.sql new file mode 100644 index 0000000..d43d7b3 --- /dev/null +++ b/drizzle/0000_initial.sql @@ -0,0 +1,215 @@ +CREATE TABLE "signage_channel_groups" ( + "id" uuid PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "revision" integer DEFAULT 1 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "signage_channels" ( + "id" uuid PRIMARY KEY NOT NULL, + "group_id" uuid NOT NULL, + "key" text NOT NULL, + "name" text NOT NULL, + "is_default" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "signage_channels_key_check" CHECK ("signage_channels"."key" ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*$') +); +--> statement-breakpoint +CREATE TABLE "signage_image_assets" ( + "id" uuid PRIMARY KEY NOT NULL, + "content_sha256" text NOT NULL, + "display_name" text NOT NULL, + "original_filename" text NOT NULL, + "original_object_key" text NOT NULL, + "original_content_type" text NOT NULL, + "original_byte_size" bigint NOT NULL, + "width" integer NOT NULL, + "height" integer NOT NULL, + "encoded_width" integer NOT NULL, + "encoded_height" integer NOT NULL, + "orientation" integer, + "has_alpha" boolean DEFAULT false NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "failure_code" text, + "expires_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "signage_image_assets_status_check" CHECK ("signage_image_assets"."status" in ('pending', 'ready', 'failed')), + CONSTRAINT "signage_image_assets_dimensions_check" CHECK ("signage_image_assets"."width" > 0 and "signage_image_assets"."height" > 0 and "signage_image_assets"."encoded_width" > 0 and "signage_image_assets"."encoded_height" > 0) +); +--> statement-breakpoint +CREATE TABLE "signage_image_renditions" ( + "id" uuid PRIMARY KEY NOT NULL, + "asset_id" uuid NOT NULL, + "kind" text NOT NULL, + "object_key" text NOT NULL, + "content_type" text NOT NULL, + "content_sha256" text NOT NULL, + "byte_size" bigint NOT NULL, + "width" integer NOT NULL, + "height" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "signage_image_renditions_kind_check" CHECK ("signage_image_renditions"."kind" in ('display', 'thumbnail')) +); +--> statement-breakpoint +CREATE TABLE "signage_playback_states" ( + "group_id" uuid PRIMARY KEY NOT NULL, + "active_timeline_id" uuid, + "pending_timeline_id" uuid, + "pending_requested_at" timestamp with time zone, + "current_selection_id" uuid, + "current_slot_id" uuid, + "slot_started_at" timestamp with time zone, + "slot_deadline_at" timestamp with time zone, + "paused_remaining_ms" integer, + "paused" boolean DEFAULT false NOT NULL, + "blackout" boolean DEFAULT false NOT NULL, + "static_asset_id" uuid, + "scheduled_timeline_id" uuid, + "scheduled_activation_at" timestamp with time zone, + "revision" integer DEFAULT 1 NOT NULL, + "next_sequence" bigint DEFAULT 1 NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "signage_playback_states_remaining_check" CHECK ("signage_playback_states"."paused_remaining_ms" is null or "signage_playback_states"."paused_remaining_ms" >= 0), + CONSTRAINT "signage_playback_states_window_check" CHECK ("signage_playback_states"."slot_started_at" is null or "signage_playback_states"."slot_deadline_at" is null or "signage_playback_states"."slot_started_at" <= "signage_playback_states"."slot_deadline_at") +); +--> statement-breakpoint +CREATE TABLE "signage_provider_sources" ( + "id" uuid PRIMARY KEY NOT NULL, + "timeline_id" uuid NOT NULL, + "provider_id" text NOT NULL, + "source_id" text NOT NULL, + "label" text NOT NULL, + "selection_scope" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "signage_selection_variants" ( + "id" uuid PRIMARY KEY NOT NULL, + "selection_id" uuid NOT NULL, + "audience_key" text NOT NULL, + "is_default" boolean DEFAULT false NOT NULL, + "payload" jsonb NOT NULL +); +--> statement-breakpoint +CREATE TABLE "signage_selections" ( + "id" uuid PRIMARY KEY NOT NULL, + "group_id" uuid NOT NULL, + "sequence" bigint NOT NULL, + "timeline_id" uuid, + "slot_id" uuid, + "selection_scope" text, + "provider_id" text NOT NULL, + "item_id" text NOT NULL, + "item_revision" text NOT NULL, + "renderer_contribution_id" text, + "renderer_contribution_version" text, + "presentation" jsonb, + "duration_ms" integer NOT NULL, + "resolved_at" timestamp with time zone DEFAULT now() NOT NULL, + "started_at" timestamp with time zone, + "deadline_at" timestamp with time zone, + "available_until" timestamp with time zone, + "withdrawn" boolean DEFAULT false NOT NULL, + "stale" boolean DEFAULT false NOT NULL, + "revision" integer DEFAULT 1 NOT NULL, + CONSTRAINT "signage_selections_duration_check" CHECK ("signage_selections"."duration_ms" > 0) +); +--> statement-breakpoint +CREATE TABLE "signage_timeline_slots" ( + "id" uuid PRIMARY KEY NOT NULL, + "timeline_id" uuid NOT NULL, + "position" integer NOT NULL, + "kind" text NOT NULL, + "source_id" uuid, + "provider_id" text, + "item_id" text, + "enabled" boolean DEFAULT true NOT NULL, + "duration_override_ms" integer, + "eligible_from" timestamp with time zone, + "eligible_until" timestamp with time zone, + "background_mode" text, + "background_color" text, + "background_darkened" boolean, + "blur_pixels" integer, + "foreground_shadow" boolean, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "signage_timeline_slots_position_check" CHECK ("signage_timeline_slots"."position" >= 0), + CONSTRAINT "signage_timeline_slots_kind_check" CHECK ("signage_timeline_slots"."kind" in ('provider-source', 'pinned-item')), + CONSTRAINT "signage_timeline_slots_reference_check" CHECK (("signage_timeline_slots"."kind" = 'provider-source' and "signage_timeline_slots"."source_id" is not null and "signage_timeline_slots"."provider_id" is null and "signage_timeline_slots"."item_id" is null) + or ("signage_timeline_slots"."kind" = 'pinned-item' and "signage_timeline_slots"."source_id" is null and "signage_timeline_slots"."provider_id" is not null and "signage_timeline_slots"."item_id" is not null)), + CONSTRAINT "signage_timeline_slots_duration_check" CHECK ("signage_timeline_slots"."duration_override_ms" is null or "signage_timeline_slots"."duration_override_ms" > 0), + CONSTRAINT "signage_timeline_slots_eligibility_check" CHECK ("signage_timeline_slots"."eligible_from" is null or "signage_timeline_slots"."eligible_until" is null or "signage_timeline_slots"."eligible_from" < "signage_timeline_slots"."eligible_until"), + CONSTRAINT "signage_timeline_slots_background_check" CHECK ("signage_timeline_slots"."background_mode" is null or "signage_timeline_slots"."background_mode" in ('blurred', 'solid', 'black')) +); +--> statement-breakpoint +CREATE TABLE "signage_timelines" ( + "id" uuid PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "description" text, + "eligible_from" timestamp with time zone, + "eligible_until" timestamp with time zone, + "revision" integer DEFAULT 1 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "signage_timelines_eligibility_check" CHECK ("signage_timelines"."eligible_from" is null or "signage_timelines"."eligible_until" is null or "signage_timelines"."eligible_from" < "signage_timelines"."eligible_until") +); +--> statement-breakpoint +CREATE TABLE "signage_viewers" ( + "id" uuid PRIMARY KEY NOT NULL, + "viewer_key" text NOT NULL, + "name" text NOT NULL, + "device_id" text, + "channel_id" uuid, + "last_seen_at" timestamp with time zone, + "reported_timeline_id" uuid, + "reported_selection_id" uuid, + "reported_provider_id" text, + "reported_contribution_id" text, + "reported_playback_revision" integer, + "offline_ready" boolean DEFAULT false NOT NULL, + "cached_asset_count" integer DEFAULT 0 NOT NULL, + "last_error_code" text, + "last_error_detail" text, + "last_error_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "signage_channels" ADD CONSTRAINT "signage_channels_group_id_signage_channel_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."signage_channel_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "signage_image_renditions" ADD CONSTRAINT "signage_image_renditions_asset_id_signage_image_assets_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."signage_image_assets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "signage_playback_states" ADD CONSTRAINT "signage_playback_states_group_id_signage_channel_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."signage_channel_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "signage_playback_states" ADD CONSTRAINT "signage_playback_states_active_timeline_id_signage_timelines_id_fk" FOREIGN KEY ("active_timeline_id") REFERENCES "public"."signage_timelines"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "signage_playback_states" ADD CONSTRAINT "signage_playback_states_pending_timeline_id_signage_timelines_id_fk" FOREIGN KEY ("pending_timeline_id") REFERENCES "public"."signage_timelines"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "signage_playback_states" ADD CONSTRAINT "signage_playback_states_static_asset_id_signage_image_assets_id_fk" FOREIGN KEY ("static_asset_id") REFERENCES "public"."signage_image_assets"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "signage_playback_states" ADD CONSTRAINT "signage_playback_states_scheduled_timeline_id_signage_timelines_id_fk" FOREIGN KEY ("scheduled_timeline_id") REFERENCES "public"."signage_timelines"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "signage_provider_sources" ADD CONSTRAINT "signage_provider_sources_timeline_id_signage_timelines_id_fk" FOREIGN KEY ("timeline_id") REFERENCES "public"."signage_timelines"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "signage_selection_variants" ADD CONSTRAINT "signage_selection_variants_selection_id_signage_selections_id_fk" FOREIGN KEY ("selection_id") REFERENCES "public"."signage_selections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "signage_selections" ADD CONSTRAINT "signage_selections_group_id_signage_channel_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."signage_channel_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "signage_selections" ADD CONSTRAINT "signage_selections_slot_id_signage_timeline_slots_id_fk" FOREIGN KEY ("slot_id") REFERENCES "public"."signage_timeline_slots"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "signage_timeline_slots" ADD CONSTRAINT "signage_timeline_slots_timeline_id_signage_timelines_id_fk" FOREIGN KEY ("timeline_id") REFERENCES "public"."signage_timelines"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "signage_timeline_slots" ADD CONSTRAINT "signage_timeline_slots_source_id_signage_provider_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."signage_provider_sources"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "signage_viewers" ADD CONSTRAINT "signage_viewers_channel_id_signage_channels_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."signage_channels"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "signage_channel_groups_name_idx" ON "signage_channel_groups" USING btree ("name");--> statement-breakpoint +CREATE UNIQUE INDEX "signage_channels_group_key_key" ON "signage_channels" USING btree ("group_id","key");--> statement-breakpoint +CREATE UNIQUE INDEX "signage_channels_group_default_key" ON "signage_channels" USING btree ("group_id") WHERE "signage_channels"."is_default";--> statement-breakpoint +CREATE UNIQUE INDEX "signage_image_assets_content_sha256_key" ON "signage_image_assets" USING btree ("content_sha256");--> statement-breakpoint +CREATE INDEX "signage_image_assets_status_idx" ON "signage_image_assets" USING btree ("status");--> statement-breakpoint +CREATE UNIQUE INDEX "signage_image_renditions_asset_kind_key" ON "signage_image_renditions" USING btree ("asset_id","kind");--> statement-breakpoint +CREATE UNIQUE INDEX "signage_provider_sources_selection_scope_key" ON "signage_provider_sources" USING btree ("selection_scope");--> statement-breakpoint +CREATE INDEX "signage_provider_sources_timeline_idx" ON "signage_provider_sources" USING btree ("timeline_id");--> statement-breakpoint +CREATE INDEX "signage_provider_sources_provider_idx" ON "signage_provider_sources" USING btree ("provider_id");--> statement-breakpoint +CREATE UNIQUE INDEX "signage_selection_variants_selection_audience_key" ON "signage_selection_variants" USING btree ("selection_id","audience_key");--> statement-breakpoint +CREATE UNIQUE INDEX "signage_selections_group_sequence_key" ON "signage_selections" USING btree ("group_id","sequence");--> statement-breakpoint +CREATE INDEX "signage_selections_group_resolved_idx" ON "signage_selections" USING btree ("group_id","resolved_at");--> statement-breakpoint +CREATE INDEX "signage_selections_provider_item_idx" ON "signage_selections" USING btree ("provider_id","item_id");--> statement-breakpoint +CREATE UNIQUE INDEX "signage_timeline_slots_position_key" ON "signage_timeline_slots" USING btree ("timeline_id","position");--> statement-breakpoint +CREATE INDEX "signage_timeline_slots_source_idx" ON "signage_timeline_slots" USING btree ("source_id");--> statement-breakpoint +CREATE INDEX "signage_timelines_name_idx" ON "signage_timelines" USING btree ("name");--> statement-breakpoint +CREATE UNIQUE INDEX "signage_viewers_viewer_key_key" ON "signage_viewers" USING btree ("viewer_key");--> statement-breakpoint +CREATE INDEX "signage_viewers_channel_idx" ON "signage_viewers" USING btree ("channel_id");--> statement-breakpoint +CREATE INDEX "signage_viewers_last_seen_idx" ON "signage_viewers" USING btree ("last_seen_at"); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..206fb7c --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1534 @@ +{ + "id": "9196bc73-5692-46ff-bd5d-79da512ecd3b", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.signage_channel_groups": { + "name": "signage_channel_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "signage_channel_groups_name_idx": { + "name": "signage_channel_groups_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signage_channels": { + "name": "signage_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "signage_channels_group_key_key": { + "name": "signage_channels_group_key_key", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signage_channels_group_default_key": { + "name": "signage_channels_group_default_key", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"signage_channels\".\"is_default\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "signage_channels_group_id_signage_channel_groups_id_fk": { + "name": "signage_channels_group_id_signage_channel_groups_id_fk", + "tableFrom": "signage_channels", + "tableTo": "signage_channel_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "signage_channels_key_check": { + "name": "signage_channels_key_check", + "value": "\"signage_channels\".\"key\" ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*$'" + } + }, + "isRLSEnabled": false + }, + "public.signage_image_assets": { + "name": "signage_image_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "content_sha256": { + "name": "content_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_object_key": { + "name": "original_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_content_type": { + "name": "original_content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_byte_size": { + "name": "original_byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encoded_width": { + "name": "encoded_width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encoded_height": { + "name": "encoded_height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "orientation": { + "name": "orientation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_alpha": { + "name": "has_alpha", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "signage_image_assets_content_sha256_key": { + "name": "signage_image_assets_content_sha256_key", + "columns": [ + { + "expression": "content_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signage_image_assets_status_idx": { + "name": "signage_image_assets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "signage_image_assets_status_check": { + "name": "signage_image_assets_status_check", + "value": "\"signage_image_assets\".\"status\" in ('pending', 'ready', 'failed')" + }, + "signage_image_assets_dimensions_check": { + "name": "signage_image_assets_dimensions_check", + "value": "\"signage_image_assets\".\"width\" > 0 and \"signage_image_assets\".\"height\" > 0 and \"signage_image_assets\".\"encoded_width\" > 0 and \"signage_image_assets\".\"encoded_height\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.signage_image_renditions": { + "name": "signage_image_renditions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_sha256": { + "name": "content_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "signage_image_renditions_asset_kind_key": { + "name": "signage_image_renditions_asset_kind_key", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "signage_image_renditions_asset_id_signage_image_assets_id_fk": { + "name": "signage_image_renditions_asset_id_signage_image_assets_id_fk", + "tableFrom": "signage_image_renditions", + "tableTo": "signage_image_assets", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "signage_image_renditions_kind_check": { + "name": "signage_image_renditions_kind_check", + "value": "\"signage_image_renditions\".\"kind\" in ('display', 'thumbnail')" + } + }, + "isRLSEnabled": false + }, + "public.signage_playback_states": { + "name": "signage_playback_states", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "active_timeline_id": { + "name": "active_timeline_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pending_timeline_id": { + "name": "pending_timeline_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pending_requested_at": { + "name": "pending_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_selection_id": { + "name": "current_selection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "current_slot_id": { + "name": "current_slot_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slot_started_at": { + "name": "slot_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "slot_deadline_at": { + "name": "slot_deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_remaining_ms": { + "name": "paused_remaining_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "paused": { + "name": "paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "blackout": { + "name": "blackout", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "static_asset_id": { + "name": "static_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scheduled_timeline_id": { + "name": "scheduled_timeline_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scheduled_activation_at": { + "name": "scheduled_activation_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "next_sequence": { + "name": "next_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "signage_playback_states_group_id_signage_channel_groups_id_fk": { + "name": "signage_playback_states_group_id_signage_channel_groups_id_fk", + "tableFrom": "signage_playback_states", + "tableTo": "signage_channel_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "signage_playback_states_active_timeline_id_signage_timelines_id_fk": { + "name": "signage_playback_states_active_timeline_id_signage_timelines_id_fk", + "tableFrom": "signage_playback_states", + "tableTo": "signage_timelines", + "columnsFrom": ["active_timeline_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "signage_playback_states_pending_timeline_id_signage_timelines_id_fk": { + "name": "signage_playback_states_pending_timeline_id_signage_timelines_id_fk", + "tableFrom": "signage_playback_states", + "tableTo": "signage_timelines", + "columnsFrom": ["pending_timeline_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "signage_playback_states_static_asset_id_signage_image_assets_id_fk": { + "name": "signage_playback_states_static_asset_id_signage_image_assets_id_fk", + "tableFrom": "signage_playback_states", + "tableTo": "signage_image_assets", + "columnsFrom": ["static_asset_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "signage_playback_states_scheduled_timeline_id_signage_timelines_id_fk": { + "name": "signage_playback_states_scheduled_timeline_id_signage_timelines_id_fk", + "tableFrom": "signage_playback_states", + "tableTo": "signage_timelines", + "columnsFrom": ["scheduled_timeline_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "signage_playback_states_remaining_check": { + "name": "signage_playback_states_remaining_check", + "value": "\"signage_playback_states\".\"paused_remaining_ms\" is null or \"signage_playback_states\".\"paused_remaining_ms\" >= 0" + }, + "signage_playback_states_window_check": { + "name": "signage_playback_states_window_check", + "value": "\"signage_playback_states\".\"slot_started_at\" is null or \"signage_playback_states\".\"slot_deadline_at\" is null or \"signage_playback_states\".\"slot_started_at\" <= \"signage_playback_states\".\"slot_deadline_at\"" + } + }, + "isRLSEnabled": false + }, + "public.signage_provider_sources": { + "name": "signage_provider_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "timeline_id": { + "name": "timeline_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "selection_scope": { + "name": "selection_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "signage_provider_sources_selection_scope_key": { + "name": "signage_provider_sources_selection_scope_key", + "columns": [ + { + "expression": "selection_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signage_provider_sources_timeline_idx": { + "name": "signage_provider_sources_timeline_idx", + "columns": [ + { + "expression": "timeline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signage_provider_sources_provider_idx": { + "name": "signage_provider_sources_provider_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "signage_provider_sources_timeline_id_signage_timelines_id_fk": { + "name": "signage_provider_sources_timeline_id_signage_timelines_id_fk", + "tableFrom": "signage_provider_sources", + "tableTo": "signage_timelines", + "columnsFrom": ["timeline_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signage_selection_variants": { + "name": "signage_selection_variants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "selection_id": { + "name": "selection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "audience_key": { + "name": "audience_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "signage_selection_variants_selection_audience_key": { + "name": "signage_selection_variants_selection_audience_key", + "columns": [ + { + "expression": "selection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "audience_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "signage_selection_variants_selection_id_signage_selections_id_fk": { + "name": "signage_selection_variants_selection_id_signage_selections_id_fk", + "tableFrom": "signage_selection_variants", + "tableTo": "signage_selections", + "columnsFrom": ["selection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signage_selections": { + "name": "signage_selections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "timeline_id": { + "name": "timeline_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slot_id": { + "name": "slot_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "selection_scope": { + "name": "selection_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_revision": { + "name": "item_revision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "renderer_contribution_id": { + "name": "renderer_contribution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "renderer_contribution_version": { + "name": "renderer_contribution_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "presentation": { + "name": "presentation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deadline_at": { + "name": "deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "available_until": { + "name": "available_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "withdrawn": { + "name": "withdrawn", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stale": { + "name": "stale", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": { + "signage_selections_group_sequence_key": { + "name": "signage_selections_group_sequence_key", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signage_selections_group_resolved_idx": { + "name": "signage_selections_group_resolved_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resolved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signage_selections_provider_item_idx": { + "name": "signage_selections_provider_item_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "signage_selections_group_id_signage_channel_groups_id_fk": { + "name": "signage_selections_group_id_signage_channel_groups_id_fk", + "tableFrom": "signage_selections", + "tableTo": "signage_channel_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "signage_selections_slot_id_signage_timeline_slots_id_fk": { + "name": "signage_selections_slot_id_signage_timeline_slots_id_fk", + "tableFrom": "signage_selections", + "tableTo": "signage_timeline_slots", + "columnsFrom": ["slot_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "signage_selections_duration_check": { + "name": "signage_selections_duration_check", + "value": "\"signage_selections\".\"duration_ms\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.signage_timeline_slots": { + "name": "signage_timeline_slots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "timeline_id": { + "name": "timeline_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "duration_override_ms": { + "name": "duration_override_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "eligible_from": { + "name": "eligible_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "eligible_until": { + "name": "eligible_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "background_mode": { + "name": "background_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_color": { + "name": "background_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_darkened": { + "name": "background_darkened", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "blur_pixels": { + "name": "blur_pixels", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "foreground_shadow": { + "name": "foreground_shadow", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "signage_timeline_slots_position_key": { + "name": "signage_timeline_slots_position_key", + "columns": [ + { + "expression": "timeline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signage_timeline_slots_source_idx": { + "name": "signage_timeline_slots_source_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "signage_timeline_slots_timeline_id_signage_timelines_id_fk": { + "name": "signage_timeline_slots_timeline_id_signage_timelines_id_fk", + "tableFrom": "signage_timeline_slots", + "tableTo": "signage_timelines", + "columnsFrom": ["timeline_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "signage_timeline_slots_source_id_signage_provider_sources_id_fk": { + "name": "signage_timeline_slots_source_id_signage_provider_sources_id_fk", + "tableFrom": "signage_timeline_slots", + "tableTo": "signage_provider_sources", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "signage_timeline_slots_position_check": { + "name": "signage_timeline_slots_position_check", + "value": "\"signage_timeline_slots\".\"position\" >= 0" + }, + "signage_timeline_slots_kind_check": { + "name": "signage_timeline_slots_kind_check", + "value": "\"signage_timeline_slots\".\"kind\" in ('provider-source', 'pinned-item')" + }, + "signage_timeline_slots_reference_check": { + "name": "signage_timeline_slots_reference_check", + "value": "(\"signage_timeline_slots\".\"kind\" = 'provider-source' and \"signage_timeline_slots\".\"source_id\" is not null and \"signage_timeline_slots\".\"provider_id\" is null and \"signage_timeline_slots\".\"item_id\" is null)\n or (\"signage_timeline_slots\".\"kind\" = 'pinned-item' and \"signage_timeline_slots\".\"source_id\" is null and \"signage_timeline_slots\".\"provider_id\" is not null and \"signage_timeline_slots\".\"item_id\" is not null)" + }, + "signage_timeline_slots_duration_check": { + "name": "signage_timeline_slots_duration_check", + "value": "\"signage_timeline_slots\".\"duration_override_ms\" is null or \"signage_timeline_slots\".\"duration_override_ms\" > 0" + }, + "signage_timeline_slots_eligibility_check": { + "name": "signage_timeline_slots_eligibility_check", + "value": "\"signage_timeline_slots\".\"eligible_from\" is null or \"signage_timeline_slots\".\"eligible_until\" is null or \"signage_timeline_slots\".\"eligible_from\" < \"signage_timeline_slots\".\"eligible_until\"" + }, + "signage_timeline_slots_background_check": { + "name": "signage_timeline_slots_background_check", + "value": "\"signage_timeline_slots\".\"background_mode\" is null or \"signage_timeline_slots\".\"background_mode\" in ('blurred', 'solid', 'black')" + } + }, + "isRLSEnabled": false + }, + "public.signage_timelines": { + "name": "signage_timelines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eligible_from": { + "name": "eligible_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "eligible_until": { + "name": "eligible_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "signage_timelines_name_idx": { + "name": "signage_timelines_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "signage_timelines_eligibility_check": { + "name": "signage_timelines_eligibility_check", + "value": "\"signage_timelines\".\"eligible_from\" is null or \"signage_timelines\".\"eligible_until\" is null or \"signage_timelines\".\"eligible_from\" < \"signage_timelines\".\"eligible_until\"" + } + }, + "isRLSEnabled": false + }, + "public.signage_viewers": { + "name": "signage_viewers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "viewer_key": { + "name": "viewer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reported_timeline_id": { + "name": "reported_timeline_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reported_selection_id": { + "name": "reported_selection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reported_provider_id": { + "name": "reported_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_contribution_id": { + "name": "reported_contribution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_playback_revision": { + "name": "reported_playback_revision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "offline_ready": { + "name": "offline_ready", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cached_asset_count": { + "name": "cached_asset_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_detail": { + "name": "last_error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "signage_viewers_viewer_key_key": { + "name": "signage_viewers_viewer_key_key", + "columns": [ + { + "expression": "viewer_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signage_viewers_channel_idx": { + "name": "signage_viewers_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signage_viewers_last_seen_idx": { + "name": "signage_viewers_last_seen_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "signage_viewers_channel_id_signage_channels_id_fk": { + "name": "signage_viewers_channel_id_signage_channels_id_fk", + "tableFrom": "signage_viewers", + "tableTo": "signage_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_ekklesia.json b/drizzle/meta/_ekklesia.json new file mode 100644 index 0000000..f253a05 --- /dev/null +++ b/drizzle/meta/_ekklesia.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "moduleId": "signage", + "entries": [ + { + "tag": "0000_initial", + "destructive": { + "mode": "none" + } + } + ] +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..ead17ee --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1787887037559, + "tag": "0000_initial", + "breakpoints": true + } + ] +} diff --git a/ekklesia.module.json b/ekklesia.module.json new file mode 100644 index 0000000..5a1c98a --- /dev/null +++ b/ekklesia.module.json @@ -0,0 +1,158 @@ +{ + "manifestVersion": 7, + "id": "signage", + "name": "Signage", + "description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.", + "version": "0.1.0", + "compatibility": { + "platform": "^0.1.0", + "moduleSdk": "^0.1.0" + }, + "capabilities": [ + { + "id": "signage.content-providers", + "version": "1.0.0" + }, + { + "id": "signage.slide-renderer", + "version": "1.0.0" + }, + { + "id": "signage.playback", + "version": "1.0.0" + }, + { + "id": "signage.displays", + "version": "1.0.0" + } + ], + "permissions": [ + { + "id": "signage.view", + "baseline": "restricted", + "description": "View Signage timelines, images, channels, provider health, and display health." + }, + { + "id": "signage.assets.manage", + "baseline": "restricted", + "description": "Upload, rename, expire, and delete Signage's own native images." + }, + { + "id": "signage.timelines.manage", + "baseline": "restricted", + "description": "Create, edit, reorder, duplicate, schedule, and delete saved timelines." + }, + { + "id": "signage.playback.control", + "baseline": "restricted", + "description": "Activate timelines and operate live playback transport for a channel group." + }, + { + "id": "signage.displays.manage", + "baseline": "restricted", + "description": "Create channel groups and audience channels, and assign or rename viewers." + }, + { + "id": "signage.display.view", + "baseline": "restricted", + "description": "Render the Signage full-viewport display and report its own health." + } + ], + "configuration": [ + { + "key": "timezone", + "type": "string", + "default": "America/Chicago", + "minLength": 1, + "maxLength": 64, + "description": "IANA time zone this installation's Signage schedules are written and displayed in." + }, + { + "key": "nativeImageDurationSeconds", + "type": "integer", + "default": 7, + "minimum": 1, + "maximum": 3600, + "description": "How long a native Signage image is shown when no slot override applies." + }, + { + "key": "crossfadeMilliseconds", + "type": "integer", + "default": 1000, + "minimum": 0, + "maximum": 10000, + "description": "How long a display fades an incoming slide over the outgoing one." + }, + { + "key": "defaultBackgroundMode", + "type": "enum", + "values": ["blurred", "solid", "black"], + "default": "blurred", + "description": "How a native image that is not 16:9 is backed when a slot says nothing." + }, + { + "key": "defaultBackgroundColor", + "type": "string", + "default": "#000000", + "minLength": 7, + "maxLength": 7, + "description": "The solid background colour used when the background mode is solid." + }, + { + "key": "defaultBackgroundDarkened", + "type": "boolean", + "default": false, + "description": "Whether a native image's background is darkened behind the foreground by default." + }, + { + "key": "defaultBlurPixels", + "type": "integer", + "default": 20, + "minimum": 0, + "maximum": 120, + "description": "Blur radius applied to a blurred native-image background." + }, + { + "key": "providerTimeoutMilliseconds", + "type": "integer", + "default": 2000, + "minimum": 100, + "maximum": 15000, + "description": "How long Signage waits for one content-provider call before treating it as failed." + }, + { + "key": "playbackHistoryLimit", + "type": "integer", + "default": 50, + "minimum": 2, + "maximum": 500, + "description": "How many resolved selections per channel group are retained as playback history." + }, + { + "key": "viewerHeartbeatWriteSeconds", + "type": "integer", + "default": 30, + "minimum": 5, + "maximum": 600, + "description": "Shortest interval between two persisted heartbeat writes for one steady viewer." + } + ], + "storage": { + "buckets": [ + { + "id": "native-images", + "description": "Archived original uploads and generated renditions for Signage's native images." + } + ] + }, + "entrypoints": { + "server": "./dist/server/index.js", + "schema": "./dist/schema/index.js", + "migrations": "./drizzle", + "ui": "./dist/ui/index.js", + "display": { + "artifact": "./dist/display/index.js", + "permission": "signage.display.view" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6c76fe5 --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "@ekklesia/signage", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Ekklesia's visual programming, timeline, synchronization, and display-delivery module.", + "files": [ + "dist", + "drizzle", + "ekklesia.module.json" + ], + "exports": { + ".": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + } + }, + "scripts": { + "build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui", + "lint": "biome lint .", + "migration:generate": "drizzle-kit generate --config=drizzle.config.ts", + "prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/signage", + "test": "vitest run", + "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json && tsc --noEmit --project tsconfig.ui-test.json" + }, + "dependencies": { + "@ekklesia/design-tokens": "workspace:*", + "@ekklesia/module-sdk": "workspace:*", + "@ekklesia/module-ui-sdk": "workspace:*", + "@ekklesia/ui": "workspace:*", + "drizzle-orm": "0.45.2", + "react": "19.2.8", + "react-dom": "19.2.8", + "sharp": "0.35.4" + }, + "devDependencies": { + "@biomejs/biome": "2.5.3", + "@ekklesia/module-ui-build": "workspace:*", + "@ekklesia/typescript-config": "workspace:*", + "@types/node": "24.13.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.4", + "drizzle-kit": "0.31.10", + "happy-dom": "20.0.11", + "typescript": "7.0.2", + "vitest": "4.1.10" + }, + "ekklesia": { + "layer": "module" + } +} diff --git a/src/browser/desktop-gate.tsx b/src/browser/desktop-gate.tsx new file mode 100644 index 0000000..0af2331 --- /dev/null +++ b/src/browser/desktop-gate.tsx @@ -0,0 +1,31 @@ +import type { ReactElement, ReactNode } from "react"; + +/** + * The narrowest viewport the Signage management UI is designed for. + * + * Signage's dashboard is a timeline editor, a slot table, a live preview, and a display roster shown + * together. Compressing that into a phone would not make it usable, it would make it a different + * product — the mobile application this release deliberately does not build. So below this width the + * operator is told plainly where to go instead of being handed a broken layout. + * + * The switch is a CSS media query rather than a measured width in JavaScript: both branches are + * always in the DOM and always in the accessibility tree, so a screen reader and a test find the + * same content regardless of how the environment reports its viewport. + */ +export const SIGNAGE_MIN_DESKTOP_WIDTH_PX = 1024; + +export function DesktopOnly({ children }: { readonly children: ReactNode }): ReactElement { + return ( + <> +
+

Signage needs a desktop screen

+

+ {`Open the Ekklesia Dashboard on a display at least ${SIGNAGE_MIN_DESKTOP_WIDTH_PX} pixels wide to manage timelines, images, channels, and playback.`} +

+
+
+ {children} +
+ + ); +} diff --git a/src/browser/offline.ts b/src/browser/offline.ts new file mode 100644 index 0000000..803eedc --- /dev/null +++ b/src/browser/offline.ts @@ -0,0 +1,288 @@ +import { + advanceCursor, + nextTimingBoundary, + type TimingSlot, + type TimingTimeline, +} from "../shared/playback-timing.js"; +import type { SceneSelection } from "./scene-request.js"; + +/** + * Deterministic local continuation of the last authoritative state (ADR 0006). + * + * This is **resilience, never a second authority**. It reproduces the server's own documented + * semantics using the *same* arithmetic module the server reconciles with, so a screen that has been + * disconnected for an hour is showing what the server would have said, not what a second + * implementation guessed. + * + * Three rules bound what it may do: + * + * - **It never invents provider data.** It replays selections the server already resolved and this + * display already cached. A slot it has no cached answer for holds the last good frame rather than + * fabricating one. + * - **It never reports its own cursor back as authority.** Nothing here is sent anywhere. + * - **It catches up arithmetically.** A long outage costs one evaluation, not one per missed slide. + */ + +export interface OfflineTimeline { + readonly id: string; + readonly revision: number; + readonly eligibleFromMs: number | null; + readonly eligibleUntilMs: number | null; + readonly slots: readonly { + readonly id: string; + readonly position: number; + readonly enabled: boolean; + readonly eligibleFromMs: number | null; + readonly eligibleUntilMs: number | null; + readonly estimatedDurationMs: number; + }[]; +} + +/** Everything a display persists so it can keep going. Structured, JSON-safe, and bounded. */ +export interface OfflineSnapshot { + readonly version: 1; + readonly channel: { readonly id: string; readonly key: string; readonly groupId: string }; + readonly crossfadeMs: number; + readonly timeline: OfflineTimeline | null; + readonly playback: { + readonly revision: number; + readonly transport: string; + readonly blackout: boolean; + readonly remainingMs: number | null; + readonly currentSelectionId: string | null; + readonly currentSlotId: string | null; + readonly startedAtMs: number | null; + readonly deadlineAtMs: number | null; + readonly pendingTimelineId: string | null; + }; + readonly selections: readonly SceneSelection[]; + readonly staticHold: SceneSelection | null; + /** The two clocks at the moment this was captured. A television's own clock is routinely wrong, so + * every later evaluation is done on the *server's* timeline, reconstructed from this pair. */ + readonly anchor: { readonly serverNowMs: number; readonly localNowMs: number }; +} + +export type OfflineResolution = + /** Show this selection, with this window. */ + | { + readonly kind: "selection"; + readonly selection: SceneSelection; + readonly startedAtMs: number; + readonly deadlineAtMs: number; + } + /** The held static image. */ + | { readonly kind: "static"; readonly selection: SceneSelection } + /** Frozen: whatever is on screen stays, with its remainder preserved. */ + | { readonly kind: "paused"; readonly selection: SceneSelection | undefined } + /** Keep the last good frame. Something is genuinely unknown — a queued activation whose timeline + * this display never received, or a slot with no cached answer. Holding is the honest response. */ + | { readonly kind: "hold"; readonly reason: string } + /** The cached timeline really has nothing eligible. Black is correct. */ + | { readonly kind: "empty" }; + +/** The server's clock, reconstructed from the anchor. Correct however far off the device's is. */ +export function serverNowFrom(snapshot: OfflineSnapshot, localNowMs: number): number { + return snapshot.anchor.serverNowMs + (localNowMs - snapshot.anchor.localNowMs); +} + +function timingTimelineOf(timeline: OfflineTimeline): TimingTimeline { + const slots: TimingSlot[] = timeline.slots.map((slot) => ({ + eligibleFromMs: slot.eligibleFromMs, + eligibleUntilMs: slot.eligibleUntilMs, + enabled: slot.enabled, + estimatedDurationMs: slot.estimatedDurationMs, + id: slot.id, + position: slot.position, + })); + return { + eligibleFromMs: timeline.eligibleFromMs, + eligibleUntilMs: timeline.eligibleUntilMs, + id: timeline.id, + slots, + }; +} + +/** Whether a cached selection may still be shown. Expired and explicitly withdrawn content is + * skipped; a null expiry is permanent, and local age never invents one. */ +function isUsable(selection: SceneSelection, atMs: number, unusable: ReadonlySet): boolean { + if (unusable.has(selection.id) || selection.withdrawn) { + return false; + } + return selection.availableUntilMs === null || atMs < selection.availableUntilMs; +} + +/** The most recent cached answer for one slot. History is newest-first, so the first match is the + * one that most recently played there. */ +function cachedForSlot( + snapshot: OfflineSnapshot, + slotId: string, + atMs: number, + unusable: ReadonlySet, +): SceneSelection | undefined { + return snapshot.selections.find( + (selection) => selection.slotId === slotId && isUsable(selection, atMs, unusable), + ); +} + +export interface OfflineEvaluation { + readonly resolution: OfflineResolution; + /** When to look again, or `null` when nothing is scheduled to change. */ + readonly nextBoundaryMs: number | null; +} + +/** + * Resolves what a disconnected display should be showing at `atMs`, on the server's clock. + * + * Pure and deterministic: the same snapshot and the same instant always give the same answer, which + * is what lets two screens that lost the same network stay on the same slide. + */ +export function resolveOffline(input: { + readonly snapshot: OfflineSnapshot; + readonly atMs: number; + readonly unusableSelectionIds?: ReadonlySet; +}): OfflineEvaluation { + const { snapshot, atMs } = input; + const unusable = input.unusableSelectionIds ?? new Set(); + + if (snapshot.staticHold !== null) { + // A hold is a deliberate operator decision, and local age never releases one. Only an explicit + // expiry on the held image does. + if (isUsable(snapshot.staticHold, atMs, unusable)) { + return { + nextBoundaryMs: null, + resolution: { kind: "static", selection: snapshot.staticHold }, + }; + } + } + + if (snapshot.playback.transport === "paused") { + const current = snapshot.selections.find( + (selection) => selection.id === snapshot.playback.currentSelectionId, + ); + // Frozen means frozen: the remainder is preserved and nothing advances. + return { nextBoundaryMs: null, resolution: { kind: "paused", selection: current } }; + } + + if (snapshot.playback.pendingTimelineId !== null) { + const deadline = snapshot.playback.deadlineAtMs; + if (deadline !== null && atMs >= deadline) { + // The next boundary belongs to a timeline this display never received. Going further into the + // old one would show known-wrong content, so the last good frame stays until the server can + // speak again. + return { nextBoundaryMs: null, resolution: { kind: "hold", reason: "pending-activation" } }; + } + } + + const timeline = snapshot.timeline; + if (!timeline || timeline.slots.length === 0) { + return { nextBoundaryMs: null, resolution: { kind: "empty" } }; + } + + const timing = timingTimelineOf(timeline); + let cursor = { + deadlineAtMs: snapshot.playback.deadlineAtMs, + slotId: snapshot.playback.currentSlotId, + startedAtMs: snapshot.playback.startedAtMs, + }; + + // Bounded by the number of slots: a slot with no cached answer is skipped exactly as the server + // skips a provider with nothing available, and one pass proves there is nothing to show. + const attempts = Math.max(1, timeline.slots.length); + for (let attempt = 0; attempt <= attempts; attempt += 1) { + const advance = advanceCursor(timing, cursor, atMs); + + if (advance.kind === "empty") { + return { + nextBoundaryMs: nextTimingBoundary(timing, cursor, atMs), + resolution: { kind: "empty" }, + }; + } + + if (advance.kind === "hold") { + const current = snapshot.selections.find( + (selection) => selection.id === snapshot.playback.currentSelectionId, + ); + if ( + current && + cursor.startedAtMs !== null && + cursor.deadlineAtMs !== null && + isUsable(current, atMs, unusable) + ) { + return { + nextBoundaryMs: nextTimingBoundary(timing, cursor, atMs), + resolution: { + deadlineAtMs: cursor.deadlineAtMs, + kind: "selection", + selection: current, + startedAtMs: cursor.startedAtMs, + }, + }; + } + return { + nextBoundaryMs: nextTimingBoundary(timing, cursor, atMs), + resolution: { kind: "hold", reason: "no-cached-selection" }, + }; + } + + const cached = cachedForSlot(snapshot, advance.slotId, atMs, unusable); + if (cached) { + const nextCursor = { + deadlineAtMs: advance.deadlineAtMs, + slotId: advance.slotId, + startedAtMs: advance.startedAtMs, + }; + return { + nextBoundaryMs: nextTimingBoundary(timing, nextCursor, atMs), + resolution: { + deadlineAtMs: advance.deadlineAtMs, + kind: "selection", + selection: cached, + startedAtMs: advance.startedAtMs, + }, + }; + } + + // No cached answer for this slot. Skip it exactly as the server skips an empty provider slot, + // and keep walking — inventing content would be worse than moving on. + cursor = { + deadlineAtMs: advance.startedAtMs, + slotId: advance.slotId, + startedAtMs: advance.startedAtMs, + }; + } + + return { + nextBoundaryMs: nextTimingBoundary(timing, cursor, atMs), + resolution: { kind: "hold", reason: "no-cached-selection" }, + }; +} + +/** Builds the snapshot a display persists. Bounded and JSON-safe by construction. */ +export function snapshotFrom(input: { + readonly channel: OfflineSnapshot["channel"]; + readonly crossfadeMs: number; + readonly timeline: OfflineTimeline | null; + readonly playback: OfflineSnapshot["playback"]; + readonly selections: readonly SceneSelection[]; + readonly staticHold: SceneSelection | null; + readonly serverNowMs: number; + readonly localNowMs: number; +}): OfflineSnapshot { + return { + anchor: { localNowMs: input.localNowMs, serverNowMs: input.serverNowMs }, + channel: input.channel, + crossfadeMs: input.crossfadeMs, + playback: input.playback, + // A delivery URL is a bearer capability with an expiry in it. Persisting one would store a + // credential that stops working; the bytes live in the media cache under a stable key instead. + selections: input.selections.map(withoutDeliveryUrls), + staticHold: input.staticHold === null ? null : withoutDeliveryUrls(input.staticHold), + timeline: input.timeline, + version: 1, + }; +} + +function withoutDeliveryUrls(selection: SceneSelection): SceneSelection { + const { media: _media, ...rest } = selection as SceneSelection & { media?: unknown }; + return rest as SceneSelection; +} diff --git a/src/browser/scene-request.ts b/src/browser/scene-request.ts new file mode 100644 index 0000000..9b174df --- /dev/null +++ b/src/browser/scene-request.ts @@ -0,0 +1,177 @@ +import { + SIGNAGE_SLIDE_RENDERER_CONTRACT, + SIGNAGE_SLIDE_RENDERER_INPUT_VERSION, + type SignageSlideRendererInput, + signageRendererGeneration, +} from "../shared/renderer-input.js"; +import type { SceneRequest } from "./scene.js"; + +/** + * Turning one resolved selection into something the stage can show. + * + * The one decision here worth naming is the **key**, and it is narrower than it first looks: a slide's + * identity is the selection and the audience, and *nothing else*. A payload revision, a pause, a + * re-anchored clock, and a fresh delivery signature are all things that happen to a slide already on + * screen — they become an `update`, which is the whole reason a cancelled event can change a + * mounted React tree instead of restarting its slot and every animation in it. + * + * The renderer still learns that something changed: `generation` inside its input *does* carry the + * revision and the paused flag, so a renderer keying internal state on it sees the change without + * the stage remounting anything. + */ + +/** The subset of a channel state's selection this needs. Structural on purpose: the display and the + * management preview build the same request from the same shape. */ +export interface SceneSelection { + readonly id: string; + readonly revision: number; + /** Which slot this occurrence came from. `null` for a static hold, and for history whose slot an + * operator has since deleted. */ + readonly slotId: string | null; + /** When this selection stops being valid. `null` is permanent; local age never invents one. */ + readonly availableUntilMs: number | null; + readonly withdrawn: boolean; + readonly providerId: string; + readonly itemId: string; + readonly itemRevision: string; + readonly durationMs: number; + readonly startedAtMs: number | null; + readonly deadlineAtMs: number | null; + readonly payload: unknown; + readonly presentation: unknown; + readonly renderer: + | { readonly kind: "native-image" } + | { + readonly kind: "contribution"; + readonly contributionId: string; + readonly contributionVersion: string; + } + | { readonly kind: "unavailable" }; + readonly media?: { + readonly display: { readonly url: string }; + readonly thumbnail: { readonly url: string }; + }; + readonly scene?: { + readonly fit: "fill" | "contain"; + readonly backgroundMode: "blurred" | "solid" | "black" | "none"; + readonly backgroundColor: string; + readonly backgroundImage: boolean; + readonly blurPixels: number; + readonly darkenOpacity: number; + readonly shadow: boolean; + }; +} + +export interface SceneContext { + readonly audienceKey: string; + readonly paused: boolean; + readonly remainingMs: number | null; + /** The server's clock at the moment this state was received, and the device's at the same instant. + * A television's own clock is routinely wrong; this pair is what makes a renderer's countdown + * correct anyway. */ + readonly serverNowMs: number; + readonly localNowMs: number; +} + +export function sceneKeyFor(selection: SceneSelection, context: SceneContext): string { + return `${selection.id}:${context.audienceKey}`; +} + +export function rendererInputFor( + selection: SceneSelection, + context: SceneContext, +): SignageSlideRendererInput { + return { + audienceKey: context.audienceKey, + clock: { localNowMs: context.localNowMs, serverNowMs: context.serverNowMs }, + contract: SIGNAGE_SLIDE_RENDERER_CONTRACT, + generation: signageRendererGeneration({ + audienceKey: context.audienceKey, + paused: context.paused, + revision: selection.revision, + selectionId: selection.id, + }), + itemId: selection.itemId, + itemRevision: selection.itemRevision, + payload: selection.payload, + presentation: selection.presentation, + providerId: selection.providerId, + timing: { + deadlineAtMs: selection.deadlineAtMs ?? context.serverNowMs + selection.durationMs, + durationMs: selection.durationMs, + paused: context.paused, + remainingMs: context.paused ? context.remainingMs : null, + startedAtMs: selection.startedAtMs ?? context.serverNowMs, + }, + version: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION, + }; +} + +/** + * Builds a native-image scene from bytes this display already has. + * + * Separate from {@link sceneRequestFor} because offline is a different question: a persisted + * selection deliberately carries **no** delivery URL — one is a bearer capability with an expiry — + * so the only thing that can paint it is the local copy, and if there is none there is nothing to + * show. + */ +export function nativeSceneRequestFor( + selection: SceneSelection, + context: SceneContext, + localUrl: string, +): SceneRequest | undefined { + if (selection.renderer.kind !== "native-image" || selection.scene === undefined) { + return undefined; + } + return { + alt: "", + key: sceneKeyFor(selection, context), + kind: "native-image", + scene: selection.scene, + url: localUrl, + }; +} + +/** + * Builds a scene, or returns `undefined` when this selection cannot be shown here. + * + * `undefined` is not a failure: a native image whose delivery URL is missing, or a contributed + * renderer the platform did not publish, is content this display genuinely cannot render — and the + * caller keeps its last good frame rather than blanking the screen. + */ +export function sceneRequestFor( + selection: SceneSelection, + context: SceneContext, + options: { readonly canMountContribution: (contributionId: string) => boolean }, +): SceneRequest | undefined { + const key = sceneKeyFor(selection, context); + + if (selection.renderer.kind === "native-image") { + const url = selection.media?.display.url; + if (url === undefined || selection.scene === undefined) { + return undefined; + } + return { + alt: "", + key, + kind: "native-image", + scene: selection.scene, + url, + }; + } + + if (selection.renderer.kind !== "contribution") { + return undefined; + } + if (!options.canMountContribution(selection.renderer.contributionId)) { + // The contributing module is absent, disabled, or outside Signage's compatible range. That is + // ordinary rather than exceptional (ADR 0041) and the display simply cannot draw this item. + return undefined; + } + return { + contributionId: selection.renderer.contributionId, + input: rendererInputFor(selection, context), + key, + kind: "contribution", + }; +} diff --git a/src/browser/scene.ts b/src/browser/scene.ts new file mode 100644 index 0000000..7be6fad --- /dev/null +++ b/src/browser/scene.ts @@ -0,0 +1,360 @@ +import type { SignageNativeScene } from "../shared/native-images.js"; +import type { SignageSlideRendererInput } from "../shared/renderer-input.js"; + +/** + * The two-layer scene stage (ADR 0006). + * + * The legacy `v1` renderer rebuilt image elements per slide and faded the outgoing frame to + * transparent, which let the black page background show through the middle of every transition. That + * is the single most visible defect this file exists to prevent, and the rule that prevents it is + * one sentence: **the outgoing layer is never faded.** + * + * Two layers are created once and never rebuilt. For every transition the incoming content is + * prepared in the hidden layer and *actually awaited* — an image decode, or a contributed renderer's + * own `mount` promise — then raised above the outgoing layer at opacity zero and faded in. The + * outgoing layer stays fully opaque underneath until the fade completes. + * + * Everything the stage touches arrives as an option: the document, the clock, the timer, how an + * image is decoded, and how a contribution is mounted. That is what lets the transition itself be + * exercised rather than assumed, and what lets the management preview reuse this exact code without + * pretending to be a viewer. + */ + +/** What a layer is asked to show. */ +export type SceneRequest = + | { + readonly kind: "native-image"; + /** Identity, so the stage can tell "the same slide, updated" from "a different slide". */ + readonly key: string; + readonly url: string; + readonly alt: string; + readonly scene: SignageNativeScene; + } + | { + readonly kind: "contribution"; + readonly key: string; + readonly contributionId: string; + readonly input: SignageSlideRendererInput; + }; + +/** What the stage needs of a mounted contribution. Structurally the platform's + * `MountedModuleContribution`, restated so this file imports no SDK and stays testable. */ +export interface MountedRenderer { + update?(input: unknown): PromiseLike | void; + unmount?(): PromiseLike | void; +} + +export interface SceneStageOptions { + readonly root: Element; + /** The starting crossfade. It is an operator setting that arrives with channel state, so it is + * also settable afterwards — a stage is created before the first state is fetched. */ + readonly crossfadeMs: number; + /** Mounts one contributed renderer into a container the stage owns. */ + readonly mountContribution?: (options: { + readonly container: Element; + readonly contributionId: string; + readonly input: SignageSlideRendererInput; + }) => Promise; + /** How an image is awaited. A browser's `decode()` by default; a test supplies its own. */ + readonly decodeImage?: (image: HTMLImageElement) => Promise; + /** How the crossfade is waited out. A test supplies a synchronous one. */ + readonly setTimer?: (handler: () => void, delayMs: number) => unknown; +} + +export type SceneOutcome = + /** The scene is now on screen. */ + | { readonly kind: "shown" } + /** The scene was already on screen; only its input changed, and the renderer was updated in place + * without a transition. This is what lets a cancelled event change a slide without restarting it. */ + | { readonly kind: "updated" } + /** A newer transition won while this one was preparing. Nothing changed. */ + | { readonly kind: "superseded" } + /** The content could not be prepared. **The last good frame is still on screen.** */ + | { readonly kind: "failed"; readonly code: string }; + +export interface SceneStage { + show(request: SceneRequest): Promise; + /** Covers the output with pure black. The caller's timeline keeps running underneath. */ + setBlackout(on: boolean): void; + /** The built-in broadcast slate. Shown only when there is nothing renderable *and* no good frame. */ + setSlate(on: boolean): void; + /** What is on screen now, or `undefined` if nothing ever was. */ + currentKey(): string | undefined; + setCrossfade(milliseconds: number): void; + destroy(): Promise; +} + +interface Layer { + readonly element: HTMLElement; + readonly background: HTMLElement; + readonly backgroundImage: HTMLImageElement; + readonly darken: HTMLElement; + readonly foreground: HTMLImageElement; + readonly rendererHost: HTMLElement; + key: string | undefined; + renderer: MountedRenderer | undefined; +} + +function createLayer(document: Document, index: number): Layer { + const element = document.createElement("div"); + element.className = "signage-layer"; + element.dataset.layer = String(index); + element.style.opacity = "0"; + + const background = document.createElement("div"); + background.className = "signage-layer__background"; + const backgroundImage = document.createElement("img"); + backgroundImage.className = "signage-layer__background-image"; + backgroundImage.alt = ""; + const darken = document.createElement("div"); + darken.className = "signage-layer__darken"; + const foreground = document.createElement("img"); + foreground.className = "signage-layer__foreground"; + const rendererHost = document.createElement("div"); + rendererHost.className = "signage-layer__renderer"; + + element.append(background, backgroundImage, darken, foreground, rendererHost); + return { + background, + backgroundImage, + darken, + element, + foreground, + key: undefined, + renderer: undefined, + rendererHost, + }; +} + +export function createSceneStage(options: SceneStageOptions): SceneStage { + const document = options.root.ownerDocument; + if (!document) { + throw new Error("A Signage scene stage needs a document."); + } + const decodeImage = + options.decodeImage ?? + ((image: HTMLImageElement) => + typeof image.decode === "function" ? image.decode() : Promise.resolve()); + const setTimer = + options.setTimer ?? ((handler: () => void, delayMs: number) => setTimeout(handler, delayMs)); + + const layers: [Layer, Layer] = [createLayer(document, 0), createLayer(document, 1)]; + // The stage owns the two layers and nothing else. Blackout and the slate are **states of the + // root**, reported as attributes: whoever owns this root renders them, so there is exactly one + // slate implementation and this file never duplicates it. + const root = options.root as HTMLElement; + root.dataset.blackout = "false"; + root.dataset.slate = "false"; + root.prepend(layers[1].element); + root.prepend(layers[0].element); + + let currentIndex = 0; + let generation = 0; + let destroyed = false; + let crossfadeMs = options.crossfadeMs; + + async function releaseRenderer(layer: Layer): Promise { + const renderer = layer.renderer; + layer.renderer = undefined; + if (!renderer?.unmount) { + return; + } + try { + await renderer.unmount(); + } catch { + // A renderer whose cleanup throws must not stop the stage. The layer is reused either way, + // and the DOM it owned is cleared below. + } + } + + async function clearLayer(layer: Layer): Promise { + await releaseRenderer(layer); + layer.foreground.removeAttribute("src"); + layer.backgroundImage.removeAttribute("src"); + layer.rendererHost.replaceChildren(); + layer.key = undefined; + } + + /** + * Prepares the hidden layer, refusing to leave anything behind for a generation that has already + * been superseded. + * + * The token is not a nicety: two transitions can be in flight at once — one waiting on a slow + * decode while a newer one arrives — and both target the same hidden layer. Without checking the + * token *after* every await, a slow mount resolving late would overwrite the newer transition's + * renderer handle and leak the renderer it replaced. + */ + async function prepare(layer: Layer, request: SceneRequest, token: number): Promise { + await clearLayer(layer); + layer.element.dataset.sceneKey = request.key; + + if (request.kind === "native-image") { + const scene = request.scene; + layer.element.dataset.fit = scene.fit; + layer.background.style.backgroundColor = + scene.backgroundMode === "solid" ? scene.backgroundColor : "#000000"; + if (scene.backgroundImage) { + layer.backgroundImage.style.display = "block"; + layer.backgroundImage.style.filter = `blur(${scene.blurPixels}px)`; + layer.backgroundImage.setAttribute("src", request.url); + } else { + layer.backgroundImage.style.display = "none"; + } + layer.darken.style.opacity = String(scene.darkenOpacity); + layer.foreground.style.display = "block"; + layer.foreground.style.objectFit = scene.fit === "fill" ? "cover" : "contain"; + layer.foreground.style.filter = scene.shadow + ? "drop-shadow(0 0.6vh 1.6vh rgba(0, 0, 0, 0.45))" + : "none"; + layer.foreground.alt = request.alt; + layer.foreground.setAttribute("src", request.url); + // The element that will actually be composited must be **decodable**, not merely present in + // the DOM. `onload` is not enough: a large image paints mid-fade. + await decodeImage(layer.foreground); + if (scene.backgroundImage) { + await decodeImage(layer.backgroundImage); + } + if (token !== generation) { + return; + } + layer.key = request.key; + return; + } + + if (!options.mountContribution) { + throw new Error("This stage cannot mount contributed renderers."); + } + layer.foreground.removeAttribute("src"); + layer.foreground.style.display = "none"; + layer.backgroundImage.style.display = "none"; + layer.darken.style.opacity = "0"; + layer.background.style.backgroundColor = "#000000"; + // Asynchronous by contract: the renderer resolves when it is genuinely ready to be seen, and the + // fade below does not begin until it does. + const mounted = await options.mountContribution({ + container: layer.rendererHost, + contributionId: request.contributionId, + input: request.input, + }); + if (token !== generation) { + // A newer transition owns this layer now. Release what was just mounted rather than storing a + // handle over the one that transition is using. + try { + await mounted.unmount?.(); + } catch { + // Nothing to do: the layer belongs to someone else, and its own cleanup will run. + } + return; + } + layer.renderer = mounted; + layer.key = request.key; + } + + function delay(ms: number): Promise { + if (ms <= 0) { + return Promise.resolve(); + } + // Nothing is cleared here: a timer that has fired needs no cancelling, and clearing one from + // inside its own handler is what breaks when a caller supplies a synchronous timer. + return new Promise((resolve) => { + setTimer(resolve, ms); + }); + } + + return { + currentKey: () => layers[currentIndex]?.key, + + async destroy(): Promise { + destroyed = true; + generation += 1; + await Promise.all([clearLayer(layers[0]), clearLayer(layers[1])]); + layers[0].element.remove(); + layers[1].element.remove(); + }, + + setBlackout(on: boolean): void { + // Pure black, over everything, and nothing else changes. The timeline underneath keeps its + // deadlines and keeps resolving, so ending a blackout lands where it would have. + root.dataset.blackout = on ? "true" : "false"; + }, + + setCrossfade(milliseconds: number): void { + crossfadeMs = Math.max(0, milliseconds); + }, + + setSlate(on: boolean): void { + root.dataset.slate = on ? "true" : "false"; + }, + + async show(request: SceneRequest): Promise { + if (destroyed) { + return { kind: "superseded" }; + } + const outgoing = layers[currentIndex] as Layer; + + // Already on screen. If it is a contributed renderer, hand it the new input in place: the + // whole point of `update` is a long-running screen changing content without restarting + // animations, reloading media, or resetting its slot. + if (outgoing.key === request.key) { + if (request.kind === "contribution" && outgoing.renderer?.update) { + try { + await outgoing.renderer.update(request.input); + return { kind: "updated" }; + } catch { + return { code: "RENDERER_UPDATE_FAILED", kind: "failed" }; + } + } + return { kind: "updated" }; + } + + generation += 1; + const token = generation; + const incomingIndex = currentIndex === 0 ? 1 : 0; + const incoming = layers[incomingIndex] as Layer; + + try { + await prepare(incoming, request, token); + } catch { + // The last good frame stays exactly where it is. A broken slide costs one slide. + await clearLayer(incoming); + return { + code: request.kind === "native-image" ? "SLIDE_DECODE_FAILED" : "RENDERER_MOUNT_FAILED", + kind: "failed", + }; + } + + // Authoritative state may have moved on while this was preparing. Completion from a stale + // generation is discarded rather than shown — and deliberately without clearing the layer, + // which by now belongs to the newer transition. + if (destroyed || token !== generation) { + return { kind: "superseded" }; + } + + incoming.element.style.transitionDuration = "0ms"; + incoming.element.style.opacity = "0"; + incoming.element.style.zIndex = "2"; + outgoing.element.style.zIndex = "1"; + // Guarantees the browser observes opacity 0 before the transition starts, so the incoming + // layer animates instead of snapping. + void incoming.element.offsetHeight; + incoming.element.style.transitionDuration = `${Math.max(0, crossfadeMs)}ms`; + incoming.element.style.opacity = "1"; + incoming.element.dataset.visible = "true"; + // The outgoing layer is **not** touched. Fading both is what let the black root show through. + + await delay(crossfadeMs); + + if (destroyed || token !== generation) { + // A newer transition won while this one was fading; it owns the layers now. + return { kind: "superseded" }; + } + + currentIndex = incomingIndex; + outgoing.element.dataset.visible = "false"; + outgoing.element.style.transitionDuration = "0ms"; + outgoing.element.style.opacity = "0"; + await clearLayer(outgoing); + return { kind: "shown" }; + }, + }; +} diff --git a/src/browser/slate.tsx b/src/browser/slate.tsx new file mode 100644 index 0000000..84d0e0b --- /dev/null +++ b/src/browser/slate.tsx @@ -0,0 +1,32 @@ +import type { ReactElement } from "react"; + +/** + * The built-in slate a Signage display shows when it has nothing it can honestly render: no timeline + * content, and no cached good frame to keep. + * + * It is deliberately unmistakable and deliberately uninformative. Restrained broadcast-style bars + * and four words tell anyone in the room that the screen is working and the content is not — while + * showing no exception, URL, provider payload, credential, database detail, or internal identifier. + * Full error detail belongs only in Signage's sanitized management telemetry, where an operator who + * is already authorized can read it. + * + * Nothing here animates. A lobby television showing a flashing diagnostic for an hour is worse than + * one showing a still frame, and an unattended screen is exactly the case this exists for. + * + * A deliberate blackout is **not** this: that renders pure black, and never reaches here. + */ +export function SignageSlate(): ReactElement { + return ( +
+ +

Content unavailable

+
+ ); +} diff --git a/src/browser/storage.ts b/src/browser/storage.ts new file mode 100644 index 0000000..ca883c1 --- /dev/null +++ b/src/browser/storage.ts @@ -0,0 +1,251 @@ +/** + * A display's own local storage: **IndexedDB for structured state, Cache Storage for image bytes** + * (ADR 0006). + * + * Ordinary HTTP cache is deliberately not used as the asset manager. It offers no eviction control, + * no way to prune a superseded rendition on purpose, and no way to answer the one question a display + * has to answer honestly — *what can I actually render if the network is gone?* + * + * Everything here is behind a small interface with an in-memory implementation, because a display's + * offline behaviour is the part that must be tested and IndexedDB is the part a test runner cannot + * reliably host. Both implementations satisfy the same contract; the browser one is thin. + */ + +/** One key-value store of structured, JSON-serializable state. */ +export interface SignageLocalStore { + read(key: string): Promise; + write(key: string, value: unknown): Promise; + delete(key: string): Promise; +} + +/** + * Image bytes a display can still paint with no network. + * + * Everything is addressed by a **stable content key**, never by the delivery URL. A delivery URL is + * signed and short-lived: it changes on every refresh of the same bytes, so a cache keyed on one + * would store the same image over and over and find none of it after a reconnect. The key is derived + * from identity that does not change — the provider, the item, and the item's revision — so the + * bytes a display cached an hour ago are the bytes it can still paint now. + */ +export interface SignageMediaCache { + /** Fetches from `url` and stores under `key`, returning a local URL to render from. */ + store(key: string, url: string): Promise; + /** A local URL for previously stored bytes, or `undefined` if they were never stored. */ + find(key: string): Promise; + /** Removes every key not in the keep set. Called only after a new snapshot is usable, so a prune + * can never leave a screen with less than it had. */ + prune(keep: readonly string[]): Promise; + count(): Promise; +} + +/** The stable key a native image's bytes are cached under. Deliberately built from identity, never + * from a URL. */ +export function signageMediaKey(input: { + readonly providerId: string; + readonly itemId: string; + readonly itemRevision: string; + readonly rendition: string; +}): string { + return `signage-media/${encodeURIComponent(input.providerId)}/${encodeURIComponent(input.itemId)}/${encodeURIComponent(input.itemRevision)}/${input.rendition}`; +} + +export const SIGNAGE_LOCAL_DATABASE = "ekklesia-signage"; +export const SIGNAGE_LOCAL_STORE = "state"; +export const SIGNAGE_MEDIA_CACHE = "ekklesia-signage-media-v1"; + +export function createMemoryStore(): SignageLocalStore { + const values = new Map(); + return { + delete(key: string): Promise { + values.delete(key); + return Promise.resolve(); + }, + read(key: string): Promise { + return Promise.resolve(values.get(key) as Value | undefined); + }, + write(key: string, value: unknown): Promise { + // Structured state only. Round-tripping through JSON here is what keeps a `Date`, a function, + // or a cycle from being written now and failing to come back later. + values.set(key, JSON.parse(JSON.stringify(value)) as unknown); + return Promise.resolve(); + }, + }; +} + +export function createMemoryMediaCache(): SignageMediaCache { + const stored = new Map(); + return { + count: () => Promise.resolve(stored.size), + find: (key: string) => Promise.resolve(stored.get(key)), + prune(keep: readonly string[]): Promise { + const retained = new Set(keep); + for (const key of [...stored.keys()]) { + if (!retained.has(key)) { + stored.delete(key); + } + } + return Promise.resolve(); + }, + store(key: string, url: string): Promise { + stored.set(key, url); + return Promise.resolve(url); + }, + }; +} + +/** The IndexedDB-backed store a real display uses. Falls back to memory where IndexedDB is absent — + * a private window, a policy that blocks storage — because a display that cannot persist must still + * render, online. */ +export function createBrowserStore(scope: typeof globalThis = globalThis): SignageLocalStore { + const maybeIndexedDb = (scope as { indexedDB?: IDBFactory }).indexedDB; + if (!maybeIndexedDb) { + return createMemoryStore(); + } + const indexedDb: IDBFactory = maybeIndexedDb; + + function open(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDb.open(SIGNAGE_LOCAL_DATABASE, 1); + request.onupgradeneeded = () => { + const database = request.result; + if (!database.objectStoreNames.contains(SIGNAGE_LOCAL_STORE)) { + database.createObjectStore(SIGNAGE_LOCAL_STORE); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(new Error("Signage could not open its local store.")); + }); + } + + async function transact( + mode: IDBTransactionMode, + run: (store: IDBObjectStore) => IDBRequest, + ): Promise { + let database: IDBDatabase; + try { + database = await open(); + } catch { + return undefined; + } + return new Promise((resolve) => { + try { + const transaction = database.transaction(SIGNAGE_LOCAL_STORE, mode); + const request = run(transaction.objectStore(SIGNAGE_LOCAL_STORE)); + request.onsuccess = () => resolve(request.result as Result | undefined); + request.onerror = () => resolve(undefined); + transaction.oncomplete = () => database.close(); + } catch { + resolve(undefined); + } + }); + } + + return { + async delete(key: string): Promise { + await transact("readwrite", (store) => store.delete(key)); + }, + read(key: string): Promise { + return transact("readonly", (store) => store.get(key)); + }, + async write(key: string, value: unknown): Promise { + await transact("readwrite", (store) => store.put(value, key)); + }, + }; +} + +/** The Cache Storage-backed media cache a real display uses. Absent Cache Storage — an insecure + * origin in development, a policy that refuses — a display works exactly as it did before: online. */ +export function createBrowserMediaCache(scope: typeof globalThis = globalThis): SignageMediaCache { + const maybeCaches = (scope as { caches?: CacheStorage }).caches; + const fetchFn = (scope as { fetch?: typeof fetch }).fetch; + if (!maybeCaches || !fetchFn) { + return createMemoryMediaCache(); + } + const caches: CacheStorage = maybeCaches; + + async function open(): Promise { + try { + return await caches.open(SIGNAGE_MEDIA_CACHE); + } catch { + return undefined; + } + } + + /** Cache Storage keys on a request URL, so a stable key becomes a stable absolute URL on this + * origin. It is never fetched; it exists only to be a key. */ + function cacheRequest(key: string): string { + const origin = + (scope as { location?: { origin?: string } }).location?.origin ?? "https://signage.invalid"; + return `${origin}/${key}`; + } + + async function localUrl(cache: Cache, url: string): Promise { + const response = await cache.match(url); + if (!response) { + return undefined; + } + try { + const blob = await response.blob(); + return URL.createObjectURL(blob); + } catch { + return undefined; + } + } + + return { + async count(): Promise { + const cache = await open(); + if (!cache) { + return 0; + } + try { + return (await cache.keys()).length; + } catch { + return 0; + } + }, + + async find(key: string): Promise { + const cache = await open(); + return cache ? localUrl(cache, cacheRequest(key)) : undefined; + }, + + async prune(keep: readonly string[]): Promise { + const cache = await open(); + if (!cache) { + return; + } + const retained = new Set(keep.map((key) => cacheRequest(key))); + try { + for (const request of await cache.keys()) { + if (!retained.has(request.url)) { + await cache.delete(request); + } + } + } catch { + // A prune that fails leaves a screen with more than it needs, which is the safe direction. + } + }, + + async store(key: string, url: string): Promise { + const cache = await open(); + if (!cache) { + return undefined; + } + try { + const response = await fetchFn(url); + // Only a complete, successful response is stored. A `404` or a redirect replayed later as a + // slide would be worse than having nothing cached. + if (!response.ok || response.status !== 200) { + return undefined; + } + // Stored under the *stable* key, so the next refresh's freshly signed URL finds these + // bytes instead of downloading them again. + await cache.put(cacheRequest(key), response.clone()); + return localUrl(cache, cacheRequest(key)); + } catch { + return undefined; + } + }, + }; +} diff --git a/src/browser/uploads.ts b/src/browser/uploads.ts new file mode 100644 index 0000000..c2511d9 --- /dev/null +++ b/src/browser/uploads.ts @@ -0,0 +1,192 @@ +/** + * The upload queue behind the image library. + * + * The shape of it is the whole point: **one image, one request**, several at a time but never all at + * once. Twenty slides an operator dropped on the page become twenty individually bounded requests, + * each of which the server can refuse cheaply, rather than one enormous one whose failure wastes + * every byte already sent (ADR 0007). And because only a few are in flight, the dashboard stays + * responsive and the API's memory stays bounded while a batch runs. + * + * Pure and injectable: the queue takes a function that uploads one file and knows nothing about HTTP, + * so its ordering, concurrency, and failure behaviour are exercised directly. + */ + +export type UploadStatus = "queued" | "uploading" | "done" | "failed" | "rejected"; + +export interface UploadItem { + readonly id: string; + readonly name: string; + readonly size: number; + readonly status: UploadStatus; + /** A sanitized code from the server, or one the queue itself decided before sending. */ + readonly code?: string; + readonly message?: string; +} + +export interface UploadFile { + readonly name: string; + readonly size: number; + readonly type: string; + slice?(start?: number, end?: number): Blob; +} + +export interface UploadQueueOptions { + readonly concurrency: number; + readonly maxBytes: number; + readonly maxFiles: number; + /** Uploads exactly one file. Rejecting is a failure for that file and nothing else. */ + readonly upload: (file: UploadFile) => Promise; + readonly onChange: (items: readonly UploadItem[]) => void; + readonly newId?: () => string; +} + +export interface UploadQueue { + /** Adds files and starts working. Returns immediately; progress arrives through `onChange`. */ + enqueue(files: readonly UploadFile[]): void; + items(): readonly UploadItem[]; + /** Resolves when nothing is left in flight. */ + drain(): Promise; + clearFinished(): void; +} + +/** Formats the two rejections the queue itself makes, so an operator is told before a byte is sent + * rather than after the server refuses. */ +export const UPLOAD_REJECTIONS = Object.freeze({ + SIGNAGE_IMAGE_TOO_LARGE: "This file is larger than Signage accepts.", + SIGNAGE_UPLOAD_BATCH_TOO_LARGE: "Too many files at once. Add them in smaller batches.", + SIGNAGE_UPLOAD_UNSUPPORTED_TYPE: "Signage accepts PNG, JPEG, and WebP images.", +}); + +const ACCEPTED_TYPES: ReadonlySet = new Set(["image/jpeg", "image/png", "image/webp"]); + +export function createUploadQueue(options: UploadQueueOptions): UploadQueue { + const newId = options.newId ?? (() => Math.random().toString(36).slice(2)); + let items: UploadItem[] = []; + const pending: { item: UploadItem; file: UploadFile }[] = []; + let active = 0; + let idle: Promise = Promise.resolve(); + let settleIdle: (() => void) | undefined; + + function publish(): void { + options.onChange([...items]); + } + + function replace(id: string, changes: Partial): void { + items = items.map((item) => (item.id === id ? { ...item, ...changes } : item)); + publish(); + } + + function pump(): void { + while (active < options.concurrency && pending.length > 0) { + const next = pending.shift(); + if (!next) { + break; + } + active += 1; + replace(next.item.id, { status: "uploading" }); + void options + .upload(next.file) + .then(() => replace(next.item.id, { status: "done" })) + .catch((error: unknown) => { + // One bad file fails alone. The rest of the batch keeps going, which is the entire reason + // a batch is many requests rather than one. + const failure = error as { failure?: { code?: string; message?: string } }; + replace(next.item.id, { + code: failure.failure?.code ?? "SIGNAGE_REQUEST_FAILED", + message: failure.failure?.message ?? "Signage could not be reached.", + status: "failed", + }); + }) + .finally(() => { + active -= 1; + if (pending.length > 0) { + pump(); + } else if (active === 0) { + settleIdle?.(); + settleIdle = undefined; + } + }); + } + } + + return { + clearFinished(): void { + items = items.filter((item) => item.status === "queued" || item.status === "uploading"); + publish(); + }, + + drain(): Promise { + if (active === 0 && pending.length === 0) { + return Promise.resolve(); + } + idle = new Promise((resolve) => { + settleIdle = resolve; + }); + return idle; + }, + + enqueue(files: readonly UploadFile[]): void { + const accepted = files.slice(0, options.maxFiles); + const overflow = files.slice(options.maxFiles); + + for (const file of accepted) { + const item: UploadItem = { + id: newId(), + name: file.name, + size: file.size, + status: "queued", + }; + items = [...items, item]; + // Two rejections happen here, before anything is sent: a file too large for Signage, and a + // type it does not accept. The server checks both again — it must, because a browser is not + // a security boundary — but telling an operator now costs them nothing. + if (file.size > options.maxBytes) { + items = items.map((candidate) => + candidate.id === item.id + ? { + ...candidate, + code: "SIGNAGE_IMAGE_TOO_LARGE", + message: UPLOAD_REJECTIONS.SIGNAGE_IMAGE_TOO_LARGE, + status: "rejected", + } + : candidate, + ); + continue; + } + if (file.type !== "" && !ACCEPTED_TYPES.has(file.type)) { + items = items.map((candidate) => + candidate.id === item.id + ? { + ...candidate, + code: "SIGNAGE_UPLOAD_UNSUPPORTED_TYPE", + message: UPLOAD_REJECTIONS.SIGNAGE_UPLOAD_UNSUPPORTED_TYPE, + status: "rejected", + } + : candidate, + ); + continue; + } + pending.push({ file, item }); + } + + for (const file of overflow) { + items = [ + ...items, + { + code: "SIGNAGE_UPLOAD_BATCH_TOO_LARGE", + id: newId(), + message: UPLOAD_REJECTIONS.SIGNAGE_UPLOAD_BATCH_TOO_LARGE, + name: file.name, + size: file.size, + status: "rejected", + }, + ]; + } + + publish(); + pump(); + }, + + items: () => [...items], + }; +} diff --git a/src/display/controller.ts b/src/display/controller.ts new file mode 100644 index 0000000..35bd1aa --- /dev/null +++ b/src/display/controller.ts @@ -0,0 +1,676 @@ +import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; +import { + type OfflineSnapshot, + resolveOffline, + serverNowFrom, + snapshotFrom, +} from "../browser/offline.js"; +import { createSceneStage, type SceneStage } from "../browser/scene.js"; +import { + nativeSceneRequestFor, + type SceneSelection, + sceneRequestFor, +} from "../browser/scene-request.js"; +import { + createBrowserMediaCache, + createBrowserStore, + type SignageLocalStore, + type SignageMediaCache, + signageMediaKey, +} from "../browser/storage.js"; + +/** + * The display: what it fetches, what it shows, and what it reports. + * + * The order of operations is the product. Server state arrives, the scene is prepared in the hidden + * layer and *awaited*, and only then does the incoming layer fade in over an outgoing layer that was + * never touched. When content cannot be prepared, the last good frame stays exactly where it is and + * the failure becomes a bounded, sanitized code in the next heartbeat — never anything on screen. + * + * Everything the platform provides arrives through the host context: the API client, realtime, and + * the renderers other modules contributed. Nothing here builds a URL, opens a socket, or reaches for + * a credential. + */ + +const VIEWER_KEY_STORAGE = "viewer-key"; +const SNAPSHOT_STORAGE = "last-known-good"; +/** A safety-net refetch. Realtime drives every transition, but a display that missed one message + * 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[]; +} + +export interface DisplaySessionView { + readonly viewerId: string; + readonly channelId: string; + readonly channelKey: string; + readonly groupId: string; + readonly heartbeatIntervalMs: number; +} + +/** What an operator can see about this screen, and what a test asserts on. Sanitized by + * construction: identity, counts, and a code. */ +export interface DisplayDiagnostics { + readonly viewerId: string | undefined; + readonly channelId: string | undefined; + readonly renderedKey: string | undefined; + readonly renderedSelectionId: string | undefined; + readonly slate: boolean; + readonly blackout: boolean; + readonly online: boolean; + /** Whether this display is currently continuing from its own persisted state rather than from the + * server. Reported so an operator can see it; never sent back as authority. */ + readonly offline: boolean; + readonly offlineReady: boolean; + readonly cachedAssetCount: number; + readonly unusableSelectionIds: readonly string[]; + readonly lastError: { readonly code: string; readonly detail: string | null } | undefined; +} + +export interface DisplayControllerOptions { + readonly context: ModuleUiHostContext; + readonly root: Element; + readonly now?: () => number; + readonly setTimer?: (handler: () => void, delayMs: number) => unknown; + readonly clearTimer?: (handle: unknown) => void; + readonly store?: SignageLocalStore; + readonly media?: SignageMediaCache; + readonly decodeImage?: (image: HTMLImageElement) => Promise; + readonly newViewerKey?: () => string; + readonly onDiagnostics?: (diagnostics: DisplayDiagnostics) => void; + /** Suppresses viewer registration and heartbeats. The management preview uses this: it renders + * exactly what a wall renders, and must not appear in the display roster while doing so. */ + readonly telemetry?: boolean; +} + +export interface DisplayController { + start(): Promise; + stop(): Promise; + /** Applies one channel state. Exposed so a test — and the preview — can drive it directly. */ + apply(state: DisplayChannelStateView): Promise; + diagnostics(): DisplayDiagnostics; +} + +export function createDisplayController(options: DisplayControllerOptions): DisplayController { + const now = options.now ?? (() => Date.now()); + const setTimer = + options.setTimer ?? + ((handler: () => void, delayMs: number) => { + return setTimeout(handler, delayMs); + }); + const clearTimer = options.clearTimer ?? ((handle: unknown) => clearTimeout(handle as never)); + const store = options.store ?? createBrowserStore(); + const media = options.media ?? createBrowserMediaCache(); + const telemetry = options.telemetry !== false; + + let stage: SceneStage | undefined; + let session: DisplaySessionView | undefined; + let subscription: { close(): void } | undefined; + let heartbeatHandle: unknown; + let refreshHandle: unknown; + let stopped = false; + let online = false; + let renderedSelectionId: string | undefined; + let cachedAssetCount = 0; + let lastError: { code: string; detail: string | null } | undefined; + let appliedRevision = -1; + let appliedSelectionRevision: number | null = null; + let lastState: DisplayChannelStateView | undefined; + let snapshot: OfflineSnapshot | undefined; + let offlineActive = false; + let offlineHandle: unknown; + /** Selections this display has tried and failed to render. A broken item is skipped rather than + * retried in a tight loop, which is what keeps one bad slide from becoming a flicker. */ + const unusable = new Set(); + + function report(): void { + // A quiet, honest state on the root, which the display renders as a small corner indicator. A + // screen whose switch was rebooted must not blank a lobby wall to say so. + (options.root as HTMLElement).dataset.offline = offlineActive ? "true" : "false"; + options.onDiagnostics?.(diagnostics()); + } + + function diagnostics(): DisplayDiagnostics { + return { + blackout: lastState?.playback.blackout ?? snapshot?.playback.blackout ?? false, + cachedAssetCount, + channelId: session?.channelId, + lastError, + offline: offlineActive, + offlineReady: + snapshot !== undefined && cachedAssetCount >= 0 && snapshot.selections.length > 0, + online, + renderedKey: stage?.currentKey(), + renderedSelectionId, + slate: stage?.currentKey() === undefined, + unusableSelectionIds: [...unusable], + viewerId: session?.viewerId, + }; + } + + function recordError(code: string, detail: string | null = null): void { + lastError = { code, detail }; + report(); + } + + async function viewerKey(): Promise { + const existing = await store.read(VIEWER_KEY_STORAGE); + if (typeof existing === "string" && existing.length >= 8) { + return existing; + } + // A display's identity is its own and persists across reboots, so a television that restarts is + // recognized as the same screen rather than filling the roster with ghosts. + const created = + options.newViewerKey?.() ?? + `viewer-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`; + await store.write(VIEWER_KEY_STORAGE, created); + return created; + } + + /** Caches a native image's bytes under a **stable** key, and prefers the cached copy when it + * exists. A delivery URL is signed and changes on every refresh; the bytes do not. */ + async function localUrlFor(selection: SceneSelection): Promise { + if (selection.renderer.kind !== "native-image") { + return undefined; + } + const key = signageMediaKey({ + itemId: selection.itemId, + itemRevision: selection.itemRevision, + providerId: selection.providerId, + rendition: "display", + }); + const cached = await media.find(key); + if (cached !== undefined) { + return cached; + } + const remote = selection.media?.display.url; + if (remote === undefined) { + return undefined; + } + const stored = await media.store(key, remote); + cachedAssetCount = await media.count(); + return stored ?? remote; + } + + async function renderSelection( + selection: SceneSelection, + state: DisplayChannelStateView, + ): Promise { + if (!stage) { + return; + } + if (unusable.has(selection.id)) { + // Already known broken here. Keep what is on screen rather than trying it again immediately. + return; + } + const context = { + audienceKey: state.channel.key, + localNowMs: now(), + paused: state.playback.transport === "paused", + remainingMs: state.playback.remainingMs, + serverNowMs: state.serverNowMs, + }; + const request = sceneRequestFor(selection, context, { + canMountContribution: (contributionId) => + options.context.contributions + .list("signage.slide-renderer") + .some((candidate) => candidate.id === contributionId), + }); + if (!request) { + // Not renderable here — a missing delivery URL, or a renderer the platform did not publish. + // That is a fact about this display, not a failure of playback. + unusable.add(selection.id); + recordError("SLIDE_UNRENDERABLE"); + return; + } + if (request.kind === "native-image") { + const url = await localUrlFor(selection); + if (url !== undefined) { + Object.assign(request, { url }); + } + } + + const outcome = await stage.show(request); + if (outcome.kind === "failed") { + unusable.add(selection.id); + recordError(outcome.code); + return; + } + if (outcome.kind === "superseded") { + return; + } + renderedSelectionId = selection.id; + if (lastError !== undefined) { + lastError = undefined; + } + report(); + } + + async function apply(state: DisplayChannelStateView): Promise { + if (stopped || !stage) { + return; + } + // The server is authoritative the instant it speaks. Any local cursor is abandoned rather than + // reconciled, and it was never reported anywhere to begin with. + online = true; + exitOffline(); + lastState = state; + // The crossfade is an operator setting and arrives with the state, so the stage is told rather + // than assuming one. + stage.setCrossfade(state.crossfadeMs); + appliedRevision = state.playback.revision; + appliedSelectionRevision = state.playback.selectionRevision; + // Blackout covers the output; the timeline underneath keeps running and keeps being prepared, so + // ending it lands on whatever the server says is current rather than on a stale frame. + stage.setBlackout(state.playback.blackout); + + const target = state.staticHold ?? state.current; + if (target === null) { + // Nothing renderable from the server. If something good is already on screen it stays; only a + // display with nothing at all shows the slate. + if (stage.currentKey() === undefined) { + stage.setSlate(true); + } + report(); + return; + } + stage.setSlate(false); + await renderSelection(target, state); + // Everything else the server sent is staged for later: a reconnect should have nothing left to + // download, and an outage should have something to continue from. The snapshot is written only + // once the frame is up, so what a display resumes from is what it actually showed — and the + // media prune runs after it, so nothing is discarded until its replacement is usable. + void prefetch(state); + await persist(state); + scheduleRefresh(state); + } + + /** + * Persists the last known good state. + * + * Written **after** the frame is on screen, so what a display resumes from is what it actually + * showed. Delivery URLs are stripped on the way in: one is a bearer capability with an expiry in + * it, and the bytes live in the media cache under a stable key instead. + */ + async function persist(state: DisplayChannelStateView): Promise { + snapshot = snapshotFrom({ + channel: state.channel, + crossfadeMs: state.crossfadeMs, + localNowMs: now(), + playback: { + blackout: state.playback.blackout, + currentSelectionId: state.playback.currentSelectionId, + currentSlotId: state.playback.currentSlotId, + deadlineAtMs: state.playback.deadlineAtMs, + pendingTimelineId: state.playback.pendingTimelineId, + remainingMs: state.playback.remainingMs, + revision: state.playback.revision, + startedAtMs: state.playback.startedAtMs, + transport: state.playback.transport, + }, + selections: state.selections, + serverNowMs: state.serverNowMs, + staticHold: state.staticHold, + timeline: state.timeline, + }); + try { + await store.write(SNAPSHOT_STORAGE, snapshot); + } catch { + // Persistence is best effort and never blocks rendering. A display that cannot write still + // works — online. + } + } + + /** + * Continues from the last authoritative anchor, with no network. + * + * Deterministic, arithmetic, and bounded: it replays selections the server already resolved and + * this display already cached, and it never invents a provider's answer. Its cursor is never + * reported anywhere — the moment the server speaks again, the server wins. + */ + async function runOffline(): Promise { + if (stopped || online || !stage || !snapshot) { + return; + } + offlineActive = true; + // The crossfade is part of the persisted state too, so a screen that resumes offline fades the + // way its operator configured rather than the way this file happened to default. + stage.setCrossfade(snapshot.crossfadeMs); + const atMs = serverNowFrom(snapshot, now()); + const evaluation = resolveOffline({ atMs, snapshot, unusableSelectionIds: unusable }); + const resolution = evaluation.resolution; + + if (resolution.kind === "empty") { + // The cached timeline genuinely has nothing eligible. Black is correct, and only when nothing + // good is already up. + if (stage.currentKey() === undefined) { + stage.setSlate(true); + } + } else if (resolution.kind === "static" || resolution.kind === "selection") { + await renderOfflineSelection(resolution.selection, atMs); + } else if (resolution.kind === "paused" && resolution.selection) { + await renderOfflineSelection(resolution.selection, atMs); + } + // `hold` is deliberate: keep the last good frame and say nothing. + + report(); + scheduleOffline(evaluation.nextBoundaryMs, atMs); + } + + async function renderOfflineSelection(selection: SceneSelection, atMs: number): Promise { + if (!stage || !snapshot) { + return; + } + const context = { + audienceKey: snapshot.channel.key, + localNowMs: now(), + paused: snapshot.playback.transport === "paused", + remainingMs: snapshot.playback.remainingMs, + serverNowMs: atMs, + }; + + let request: ReturnType; + if (selection.renderer.kind === "native-image") { + // Offline, only what is genuinely cached can be painted: a persisted selection carries no + // delivery URL, deliberately, so the local copy is the only source there is. + const cached = await media.find( + signageMediaKey({ + itemId: selection.itemId, + itemRevision: selection.itemRevision, + providerId: selection.providerId, + rendition: "display", + }), + ); + request = + cached === undefined ? undefined : nativeSceneRequestFor(selection, context, cached); + } else { + request = sceneRequestFor(selection, context, { + canMountContribution: (contributionId) => + options.context.contributions + .list("signage.slide-renderer") + .some((candidate) => candidate.id === contributionId), + }); + } + + if (!request) { + // Nothing local can draw this. The slot is skipped and the last good frame stays. + unusable.add(selection.id); + return; + } + const outcome = await stage.show(request); + if (outcome.kind === "failed") { + unusable.add(selection.id); + return; + } + if (outcome.kind !== "superseded") { + renderedSelectionId = selection.id; + } + } + + function scheduleOffline(boundaryMs: number | null, atMs: number): void { + if (offlineHandle !== undefined) { + clearTimer(offlineHandle); + offlineHandle = undefined; + } + if (stopped || online || boundaryMs === null) { + return; + } + offlineHandle = setTimer( + () => { + offlineHandle = undefined; + void runOffline(); + }, + Math.max(250, boundaryMs - atMs), + ); + } + + function exitOffline(): void { + offlineActive = false; + if (offlineHandle !== undefined) { + clearTimer(offlineHandle); + offlineHandle = undefined; + } + } + + async function prefetch(state: DisplayChannelStateView): Promise { + for (const selection of state.selections) { + if (selection.renderer.kind !== "native-image") { + continue; + } + try { + await localUrlFor(selection); + } catch { + // A slide that cannot be pre-cached is simply not pre-cached. It will be tried again when it + // is actually needed, and the frame on screen is unaffected. + } + } + // Superseded bytes are removed **only now**, after everything this state needs is present: a + // prune that ran first could leave a screen with less than it had. + await media.prune( + state.selections + .filter((selection) => selection.renderer.kind === "native-image") + .map((selection) => + signageMediaKey({ + itemId: selection.itemId, + itemRevision: selection.itemRevision, + providerId: selection.providerId, + rendition: "display", + }), + ), + ); + cachedAssetCount = await media.count(); + report(); + } + + function scheduleRefresh(state: DisplayChannelStateView): void { + if (refreshHandle !== undefined) { + clearTimer(refreshHandle); + refreshHandle = undefined; + } + if (stopped) { + return; + } + // Just after the current slot's deadline, so a missed realtime message costs one late + // transition rather than a stuck screen; and never longer than the safety-net interval. + const untilDeadline = + state.playback.deadlineAtMs === null + ? REFRESH_INTERVAL_MS + : Math.max(250, state.playback.deadlineAtMs - state.serverNowMs + 250); + refreshHandle = setTimer( + () => { + refreshHandle = undefined; + void refresh(); + }, + Math.min(REFRESH_INTERVAL_MS, untilDeadline), + ); + } + + async function refresh(): Promise { + if (stopped || !session) { + return; + } + try { + const response = await options.context.api.request( + `channels/${session.channelId}/display-state`, + ); + if (!response.ok) { + online = false; + await runOffline(); + return; + } + await apply((await response.json()) as DisplayChannelStateView); + } catch { + // The server is unreachable. The frame stays and local continuation takes over; nothing about + // a network failure belongs on a lobby wall. + online = false; + await runOffline(); + } + } + + async function heartbeat(): Promise { + if (stopped || !session || !telemetry) { + return; + } + try { + 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, + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + } catch { + // Telemetry is best effort and never affects what is on screen. + } + } + + return { + apply, + diagnostics, + + async start(): Promise { + stopped = false; + stage = createSceneStage({ + crossfadeMs: 1000, + ...(options.decodeImage ? { decodeImage: options.decodeImage } : {}), + mountContribution: async ({ container, contributionId, input }) => { + // The platform loads, verifies, and mounts the renderer. Signage hands it identity and its + // own opaque input, and receives a handle — never a URL, and never the module's code. + const mounted = await options.context.contributions.mount({ + container, + contributionId, + input: input as never, + }); + return { + unmount: () => mounted.unmount(), + update: (next: unknown) => mounted.update(next as never), + }; + }, + root: options.root, + setTimer, + }); + + // A frame from the last known good state goes up before the first request returns, so a + // reload during a service does not stare at black while a fetch completes. + try { + const restored = await store.read(SNAPSHOT_STORAGE); + if (restored?.version === 1) { + snapshot = restored; + await runOffline(); + } + } catch { + // Nothing persisted, or nothing readable. The display simply starts from the server. + } + + if (telemetry) { + try { + const response = await options.context.api.request("viewers/session", { + body: JSON.stringify({ viewerKey: await viewerKey() }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + if (response.ok) { + session = (await response.json()) as DisplaySessionView; + online = true; + } + } catch { + online = false; + } + } + + if (session) { + subscription = options.context.realtime.subscribe("display-state", { + onSnapshot: (state) => onRealtime(state), + onStatus: (status) => { + online = status === "connected"; + report(); + }, + onUpdate: (state) => onRealtime(state), + }); + heartbeatHandle = setInterval( + () => void heartbeat(), + session.heartbeatIntervalMs, + ) as unknown; + await refresh(); + void heartbeat(); + } + report(); + }, + + async stop(): Promise { + stopped = true; + subscription?.close(); + subscription = undefined; + if (heartbeatHandle !== undefined) { + clearInterval(heartbeatHandle as never); + heartbeatHandle = undefined; + } + if (refreshHandle !== undefined) { + clearTimer(refreshHandle); + refreshHandle = undefined; + } + exitOffline(); + await stage?.destroy(); + stage = undefined; + }, + }; + + /** + * A realtime frame carries compact authoritative state and revisions, never content. A revision + * this display has not applied means "fetch the state you are authorized for", which is what + * closes the GET-then-subscribe race without a second transport (ADR 0005). + */ + function onRealtime(payload: unknown): void { + if (stopped || !session) { + return; + } + const groups = (payload as { groups?: readonly Record[] } | null)?.groups; + if (!Array.isArray(groups)) { + return; + } + const mine = groups.find((group) => group.groupId === session?.groupId); + if (!mine) { + return; + } + online = true; + const revision = typeof mine.revision === "number" ? mine.revision : -1; + const selectionRevision = + typeof mine.selectionRevision === "number" ? mine.selectionRevision : null; + if (revision === appliedRevision && selectionRevision === appliedSelectionRevision) { + return; + } + void refresh(); + } +} diff --git a/src/display/index.tsx b/src/display/index.tsx new file mode 100644 index 0000000..d4268ee --- /dev/null +++ b/src/display/index.tsx @@ -0,0 +1,69 @@ +import { defineModuleUi, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; +import { type ReactElement, useEffect, useRef } from "react"; +import { createRoot } from "react-dom/client"; +import { SignageSlate } from "../browser/slate.js"; +import { createDisplayController } from "./controller.js"; +import displayStyles from "./styles.css"; + +/** + * Signage's full-viewport display. + * + * React renders the fixed furniture — the stage's root, the blackout cover, and the slate — and the + * controller owns everything that moves. That split is deliberate: the two scene layers are created + * once and never rebuilt, and a React tree that re-created them on every state change would be the + * `v1` renderer again. + * + * The artifact performs no permission check of its own. The host resolved an authorization-bearing + * descriptor before these bytes were ever fetched, and a television has nobody standing at it to act + * on a denial — telling a lobby wall "you may not see this" would only report something about the + * installation's authorization to whoever walks past. + */ +function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }): ReactElement { + const stageRef = useRef(null); + + useEffect(() => { + const root = stageRef.current; + if (!root) { + return; + } + const controller = createDisplayController({ context, root }); + void controller.start(); + return () => { + void controller.stop(); + }; + }, [context]); + + return ( +
+
+ {/* Shown only when the stage says there is nothing renderable *and* no good frame. */} +
+ +
+ {/* A deliberate blackout: pure black over everything, while the hidden timeline runs on. */} + +
+ ); +} + +/** + * The compiled full-viewport display artifact. It uses the same role-neutral browser ABI as the + * management UI; its separate manifest entrypoint — not a mode flag — is what tells a host which + * artifact it is mounting. + */ +export default defineModuleUi({ + id: "signage", + version: "0.1.0", + styles: displayStyles, + mount(container, context) { + const root = createRoot(container); + root.render(); + return () => root.unmount(); + }, +}); diff --git a/src/display/styles.css b/src/display/styles.css new file mode 100644 index 0000000..9e072f6 --- /dev/null +++ b/src/display/styles.css @@ -0,0 +1,227 @@ +/* + * The Signage display artifact's own stylesheet, isolated from the management UI's exactly as it is + * from the Dashboard's: two artifacts of one module share no styles unless they share a source file + * (ADR 0040). + * + * `:host { display: block }` overrides the host's transparent default deliberately — a full-viewport + * display needs a real box, and the host installs its base rules before this stylesheet precisely so + * an artifact can override them by ordinary cascade order. + * + * Almost every value here is a literal rather than a token, and that is the one deliberate exception + * in this module. A television is not a themed surface: its ground is black because anything else + * would show as a frame around a church's own artwork, and the slate must stay unmistakable whatever + * a deployment's theme becomes. The Dashboard's theme never recolours a provider's output or an + * uploaded image. + */ + +/* + * The box model for the tree Signage owns. An isolated root inherits nothing from the page around + * it, so a full-viewport layout that assumes `border-box` has to say so. + */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +:host { + display: block; + + --signage-slate-bar-one: #6f6f6f; + --signage-slate-bar-two: #6f6f2f; + --signage-slate-bar-three: #2f6f6f; + --signage-slate-bar-four: #2f6f2f; + --signage-slate-bar-five: #6f2f6f; + --signage-slate-bar-six: #6f2f2f; +} + +.signage-display { + background: #000000; + color: #ffffff; + font-family: var(--ekklesia-typography-family-sans); + height: 100%; + inset: 0; + margin: 0; + min-height: 100vh; + overflow: hidden; + position: relative; + width: 100%; +} + +.signage-stage { + height: 100%; + inset: 0; + min-height: 100vh; + position: absolute; + width: 100%; +} + +/* --------------------------------------------------------------------------------------------- + * The two permanent scene layers. + * + * Created once by the stage and never rebuilt. Only their contents, stacking, and opacity change — + * and an *outgoing* layer is never faded, which is what keeps the black root from showing through + * the middle of a transition. + * ------------------------------------------------------------------------------------------- */ + +.signage-layer { + height: 100%; + inset: 0; + opacity: 0; + position: absolute; + transition-duration: 0ms; + transition-property: opacity; + transition-timing-function: linear; + width: 100%; +} + +.signage-layer__background { + background: #000000; + inset: 0; + position: absolute; +} + +.signage-layer__background-image { + display: none; + height: 100%; + inset: 0; + object-fit: cover; + position: absolute; + transform: scale(1.08); + width: 100%; +} + +.signage-layer__darken { + background: #000000; + inset: 0; + opacity: 0; + position: absolute; +} + +.signage-layer__foreground { + height: 100%; + inset: 0; + object-fit: contain; + position: absolute; + width: 100%; +} + +.signage-layer__renderer { + inset: 0; + position: absolute; +} + +/* --------------------------------------------------------------------------------------------- + * Blackout and the slate, both states of the stage root. + * ------------------------------------------------------------------------------------------- */ + +.signage-blackout { + background: #000000; + inset: 0; + opacity: 0; + pointer-events: none; + position: absolute; + transition: opacity 200ms linear; + z-index: 20; +} + +[data-blackout="true"] > .signage-blackout { + opacity: 1; +} + +.signage-slate-host { + inset: 0; + opacity: 0; + pointer-events: none; + position: absolute; + z-index: 10; +} + +[data-slate="true"] > .signage-slate-host { + opacity: 1; +} + +.signage-slate { + align-items: stretch; + display: flex; + flex-direction: column; + height: 100%; + inset: 0; + position: absolute; + width: 100%; +} + +.signage-slate__bars { + display: grid; + flex: 1 1 auto; + grid-template-columns: repeat(6, 1fr); +} + +.signage-slate__bar { + display: block; + height: 100%; +} + +.signage-slate__bar--one { + background: var(--signage-slate-bar-one); +} + +.signage-slate__bar--two { + background: var(--signage-slate-bar-two); +} + +.signage-slate__bar--three { + background: var(--signage-slate-bar-three); +} + +.signage-slate__bar--four { + background: var(--signage-slate-bar-four); +} + +.signage-slate__bar--five { + background: var(--signage-slate-bar-five); +} + +.signage-slate__bar--six { + background: var(--signage-slate-bar-six); +} + +.signage-slate__message { + background: #000000; + flex: 0 0 auto; + font-size: 4vh; + letter-spacing: 0.08em; + margin: 0; + padding: 3vh 0; + text-align: center; + text-transform: uppercase; +} + +/* --------------------------------------------------------------------------------------------- + * The offline indicator. + * + * Deliberately small, deliberately in a corner, and deliberately not an error. A screen continuing + * from its own last authoritative state is working; a full-page failure message on a lobby wall + * would be the actual failure. + * ------------------------------------------------------------------------------------------- */ + +.signage-offline-note { + background: rgba(0, 0, 0, 0.55); + border-radius: 0.6vh; + bottom: 2vh; + color: rgba(255, 255, 255, 0.75); + font-size: 1.6vh; + letter-spacing: 0.04em; + margin: 0; + opacity: 0; + padding: 0.8vh 1.4vh; + pointer-events: none; + position: absolute; + right: 2vh; + transition: opacity 400ms linear; + z-index: 30; +} + +[data-offline="true"] > .signage-offline-note { + opacity: 1; +} diff --git a/src/module-styles.d.ts b/src/module-styles.d.ts new file mode 100644 index 0000000..cc9e9ef --- /dev/null +++ b/src/module-styles.d.ts @@ -0,0 +1,17 @@ +/** + * What `@ekklesia/module-ui-build` turns a local stylesheet import into: that stylesheet's compiled + * CSS **text**, as a string (ADR 0040). + * + * ```ts + * import styles from "./styles.css"; + * export default defineModuleUi({ id, version, styles, mount }); + * ``` + * + * There is no sidecar file and no URL — the text is compiled into the declared JavaScript artifact, + * and the host installs it into the artifact's isolated root at mount. Copy this file into your own + * module and include it in your UI TypeScript project. + */ +declare module "*.css" { + const stylesheet: string; + export default stylesheet; +} diff --git a/src/schema/index.ts b/src/schema/index.ts new file mode 100644 index 0000000..beb9fd2 --- /dev/null +++ b/src/schema/index.ts @@ -0,0 +1,50 @@ +import { defineModuleSchema } from "@ekklesia/module-sdk"; +import { + channelGroups, + channels, + imageAssets, + imageRenditions, + playbackStates, + providerSources, + selections, + selectionVariants, + timelineSlots, + timelines, + viewers, +} from "./tables.js"; + +export { + channelGroups, + channels, + imageAssets, + imageRenditions, + playbackStates, + providerSources, + selections, + selectionVariants, + timelineSlots, + timelines, + viewers, +} from "./tables.js"; + +/** + * Signage's compiled schema entrypoint: the same table objects `tables.ts` declares, wrapped with the + * ownership marker Core validates during discovery. Side-effect free by contract — importing this + * connects to nothing and reads nothing. + */ +export default defineModuleSchema({ + moduleId: "signage", + tables: { + channelGroups, + channels, + imageAssets, + imageRenditions, + playbackStates, + providerSources, + selections, + selectionVariants, + timelines, + timelineSlots, + viewers, + }, +}); diff --git a/src/schema/tables.ts b/src/schema/tables.ts new file mode 100644 index 0000000..37ab33d --- /dev/null +++ b/src/schema/tables.ts @@ -0,0 +1,485 @@ +import { sql } from "drizzle-orm"; +import { + bigint, + boolean, + check, + index, + integer, + jsonb, + pgTableCreator, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; + +/** + * Signage's own PostgreSQL tables. + * + * Kept to **pure Drizzle declarations** so the stable Drizzle Kit generator can load this file + * without executing the ESM Module SDK wrapper; `./index.ts` wraps these same objects with the + * ownership marker Core validates. The required `signage_` namespace is applied once here, and Core + * validates it independently of this helper. + * + * Two rules hold everywhere below, and both are architecture rather than style: + * + * - **No foreign key leaves this module.** A provider's id, a source id, an item id, and a Core + * device id are all stored as opaque text. Signage cannot join to another module's data and must + * never be able to (ADR 0007 of the platform, ADR 0001 here). + * - **No signed delivery URL is ever stored.** Objects are addressed by their logical key, and a + * read URL is requested fresh when something is about to be delivered — a stored URL is a + * credential with an expiry date in it. + */ +const moduleTable = pgTableCreator((localName) => `signage_${localName}`); + +/** Timestamps are absolute instants. The installation's IANA zone is an operator setting used to + * write and read them for a person, never to interpret one at run time. */ +const instant = (name: string) => timestamp(name, { mode: "date", withTimezone: true }); + +// ------------------------------------------------------------------------------------------------- +// Native images. +// ------------------------------------------------------------------------------------------------- + +/** + * One native image, identified by the SHA-256 of the bytes an operator uploaded (ADR 0007). + * + * `content_sha256` is unique, so uploading the same file twice returns the same asset instead of + * storing a second copy, and re-running a failed ingest is idempotent. + */ +export const imageAssets = moduleTable( + "image_assets", + { + id: uuid("id").primaryKey(), + contentSha256: text("content_sha256").notNull(), + displayName: text("display_name").notNull(), + originalFilename: text("original_filename").notNull(), + /** The archived original, byte for byte. Signage is not entitled to rewrite a church's file. */ + originalObjectKey: text("original_object_key").notNull(), + originalContentType: text("original_content_type").notNull(), + originalByteSize: bigint("original_byte_size", { mode: "number" }).notNull(), + /** How the original should appear once its own orientation is applied. */ + width: integer("width").notNull(), + height: integer("height").notNull(), + /** What is actually encoded in the file, kept for diagnostics without implying the archived + * bytes were rewritten. */ + encodedWidth: integer("encoded_width").notNull(), + encodedHeight: integer("encoded_height").notNull(), + orientation: integer("orientation"), + hasAlpha: boolean("has_alpha").notNull().default(false), + /** `pending` while ingest is in flight, `ready` once every rendition is committed, `failed` when + * ingest gave up. Only `ready` is ever selectable by playback. */ + status: text("status").notNull().default("pending"), + /** A stable sanitized code, never a caught exception, a path, or a provider message. */ + failureCode: text("failure_code"), + /** Null means permanent. Local age never invents an expiration. */ + expiresAt: instant("expires_at"), + createdAt: instant("created_at").notNull().defaultNow(), + updatedAt: instant("updated_at").notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("signage_image_assets_content_sha256_key").on(table.contentSha256), + index("signage_image_assets_status_idx").on(table.status), + check( + "signage_image_assets_status_check", + sql`${table.status} in ('pending', 'ready', 'failed')`, + ), + check( + "signage_image_assets_dimensions_check", + sql`${table.width} > 0 and ${table.height} > 0 and ${table.encodedWidth} > 0 and ${table.encodedHeight} > 0`, + ), + ], +); + +/** + * A generated rendition of one asset. Immutable: different bytes are a different asset, so a + * rendition is never rewritten in place. + */ +export const imageRenditions = moduleTable( + "image_renditions", + { + id: uuid("id").primaryKey(), + assetId: uuid("asset_id") + .notNull() + .references(() => imageAssets.id, { onDelete: "cascade" }), + kind: text("kind").notNull(), + objectKey: text("object_key").notNull(), + contentType: text("content_type").notNull(), + contentSha256: text("content_sha256").notNull(), + byteSize: bigint("byte_size", { mode: "number" }).notNull(), + width: integer("width").notNull(), + height: integer("height").notNull(), + createdAt: instant("created_at").notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("signage_image_renditions_asset_kind_key").on(table.assetId, table.kind), + check("signage_image_renditions_kind_check", sql`${table.kind} in ('display', 'thumbnail')`), + ], +); + +// ------------------------------------------------------------------------------------------------- +// Timelines, sources, and slots. +// ------------------------------------------------------------------------------------------------- + +/** A saved, ordered, repeating pattern. It holds no cursor and no clock: running one is what a + * channel group does, and the same timeline may run on two groups without interfering (ADR 0003). */ +export const timelines = moduleTable( + "timelines", + { + id: uuid("id").primaryKey(), + name: text("name").notNull(), + description: text("description"), + /** Timeline-level eligibility, half-open `[eligibleFrom, eligibleUntil)`. */ + eligibleFrom: instant("eligible_from"), + eligibleUntil: instant("eligible_until"), + /** Increments only on a semantic change, so a client can compare and refetch. */ + revision: integer("revision").notNull().default(1), + createdAt: instant("created_at").notNull().defaultNow(), + updatedAt: instant("updated_at").notNull().defaultNow(), + }, + (table) => [ + index("signage_timelines_name_idx").on(table.name), + check( + "signage_timelines_eligibility_check", + sql`${table.eligibleFrom} is null or ${table.eligibleUntil} is null or ${table.eligibleFrom} < ${table.eligibleUntil}`, + ), + ], +); + +/** + * One **selection scope**: a provider's source as used by a timeline (ADR 0003). + * + * Every slot referencing this row passes the same `selection_scope` to `resolveNext`, so five + * Announcement slots walk one rotation rather than five. Two rows for the same provider source are + * two deliberately independent rotations. + * + * `provider_id` and `source_id` are opaque text. There is no foreign key here and there never can be: + * the provider is another module. + */ +export const providerSources = moduleTable( + "provider_sources", + { + id: uuid("id").primaryKey(), + timelineId: uuid("timeline_id") + .notNull() + .references(() => timelines.id, { onDelete: "cascade" }), + providerId: text("provider_id").notNull(), + sourceId: text("source_id").notNull(), + /** What an operator sees. Captured from provider discovery when the source was added, so the + * editor still reads sensibly when that provider is temporarily not running. */ + label: text("label").notNull(), + /** Stable, opaque, and deliberately not derived from the timeline or slot id — either would + * silently reset a provider's rotation when an operator renamed or reordered something. */ + selectionScope: text("selection_scope").notNull(), + createdAt: instant("created_at").notNull().defaultNow(), + updatedAt: instant("updated_at").notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("signage_provider_sources_selection_scope_key").on(table.selectionScope), + index("signage_provider_sources_timeline_idx").on(table.timelineId), + index("signage_provider_sources_provider_idx").on(table.providerId), + ], +); + +/** + * One position in a timeline's pattern. + * + * `position` is unique per timeline and kept contiguous from zero inside the transaction that + * changes it — a gap would make "the next slot" ambiguous exactly when playback needs it least. The + * slot's `id` is stable across every reorder, which is what lets history and a display's cursor + * survive an operator dragging a row. + */ +export const timelineSlots = moduleTable( + "timeline_slots", + { + id: uuid("id").primaryKey(), + timelineId: uuid("timeline_id") + .notNull() + .references(() => timelines.id, { onDelete: "cascade" }), + position: integer("position").notNull(), + /** `provider-source` asks for the next item; `pinned-item` shows exactly one. A native image is + * a pinned item of Signage's own built-in image provider (ADR 0001). */ + kind: text("kind").notNull(), + sourceId: uuid("source_id").references(() => providerSources.id, { onDelete: "cascade" }), + /** Set for a pinned item only. Opaque: another module's identity for its own content. */ + providerId: text("provider_id"), + itemId: text("item_id"), + enabled: boolean("enabled").notNull().default(true), + durationOverrideMs: integer("duration_override_ms"), + /** Per-slot eligibility, half-open `[eligibleFrom, eligibleUntil)`. */ + eligibleFrom: instant("eligible_from"), + eligibleUntil: instant("eligible_until"), + /** Native-image presentation overrides. Null means "take the installation default". These apply + * to native images only; an external provider owns its rendering completely. */ + backgroundMode: text("background_mode"), + backgroundColor: text("background_color"), + backgroundDarkened: boolean("background_darkened"), + blurPixels: integer("blur_pixels"), + foregroundShadow: boolean("foreground_shadow"), + createdAt: instant("created_at").notNull().defaultNow(), + updatedAt: instant("updated_at").notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("signage_timeline_slots_position_key").on(table.timelineId, table.position), + index("signage_timeline_slots_source_idx").on(table.sourceId), + check("signage_timeline_slots_position_check", sql`${table.position} >= 0`), + check( + "signage_timeline_slots_kind_check", + sql`${table.kind} in ('provider-source', 'pinned-item')`, + ), + // Exactly one of the two shapes is populated, so a slot can never be both or neither. + check( + "signage_timeline_slots_reference_check", + sql`(${table.kind} = 'provider-source' and ${table.sourceId} is not null and ${table.providerId} is null and ${table.itemId} is null) + or (${table.kind} = 'pinned-item' and ${table.sourceId} is null and ${table.providerId} is not null and ${table.itemId} is not null)`, + ), + check( + "signage_timeline_slots_duration_check", + sql`${table.durationOverrideMs} is null or ${table.durationOverrideMs} > 0`, + ), + check( + "signage_timeline_slots_eligibility_check", + sql`${table.eligibleFrom} is null or ${table.eligibleUntil} is null or ${table.eligibleFrom} < ${table.eligibleUntil}`, + ), + check( + "signage_timeline_slots_background_check", + sql`${table.backgroundMode} is null or ${table.backgroundMode} in ('blurred', 'solid', 'black')`, + ), + ], +); + +// ------------------------------------------------------------------------------------------------- +// Channel groups, channels, and authoritative playback. +// ------------------------------------------------------------------------------------------------- + +/** One synchronized group: one authoritative timeline clock, one active timeline, one cursor + * (ADR 0004). A genuinely independent clock is another group. */ +export const channelGroups = moduleTable( + "channel_groups", + { + id: uuid("id").primaryKey(), + name: text("name").notNull(), + /** Increments when the group's channel membership changes, so a client knows to refetch. */ + revision: integer("revision").notNull().default(1), + createdAt: instant("created_at").notNull().defaultNow(), + updatedAt: instant("updated_at").notNull().defaultNow(), + }, + (table) => [index("signage_channel_groups_name_idx").on(table.name)], +); + +/** An audience within a group. Channels differ in exactly one way: which variant of a resolved + * selection they render. Exactly one channel per group is the default. */ +export const channels = moduleTable( + "channels", + { + id: uuid("id").primaryKey(), + groupId: uuid("group_id") + .notNull() + .references(() => channelGroups.id, { onDelete: "cascade" }), + /** The audience key handed to a provider. `default` is the base channel's. */ + key: text("key").notNull(), + name: text("name").notNull(), + isDefault: boolean("is_default").notNull().default(false), + createdAt: instant("created_at").notNull().defaultNow(), + updatedAt: instant("updated_at").notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("signage_channels_group_key_key").on(table.groupId, table.key), + // Exactly one default per group, enforced by the database rather than by convention. + uniqueIndex("signage_channels_group_default_key") + .on(table.groupId) + .where(sql`${table.isDefault}`), + check("signage_channels_key_check", sql`${table.key} ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*$'`), + ], +); + +/** + * One row per channel group: the authoritative clock (ADR 0005). + * + * `slot_started_at` and `slot_deadline_at` are **absolute instants**, never a remaining count. That + * is what lets two viewers who connected minutes apart agree, and what lets a restart resume the + * item it was already showing instead of choosing a new one. + */ +export const playbackStates = moduleTable( + "playback_states", + { + groupId: uuid("group_id") + .primaryKey() + .references(() => channelGroups.id, { onDelete: "cascade" }), + activeTimelineId: uuid("active_timeline_id").references(() => timelines.id, { + onDelete: "set null", + }), + /** Queued activation, applied at the next safe slot boundary. */ + pendingTimelineId: uuid("pending_timeline_id").references(() => timelines.id, { + onDelete: "set null", + }), + pendingRequestedAt: instant("pending_requested_at"), + /** Which resolved selection is on screen. Nulled rather than cascaded when history is pruned. */ + currentSelectionId: uuid("current_selection_id"), + /** Where in the pattern the cursor is. Kept beside the selection so an empty or skipped slot + * still advances deterministically. */ + currentSlotId: uuid("current_slot_id"), + slotStartedAt: instant("slot_started_at"), + slotDeadlineAt: instant("slot_deadline_at"), + /** Preserved across pause and static hold, so releasing either continues the same slide. */ + pausedRemainingMs: integer("paused_remaining_ms"), + paused: boolean("paused").notNull().default(false), + /** Covers the output with black while the hidden timeline keeps running. */ + blackout: boolean("blackout").notNull().default(false), + /** A native image held on screen while the normal cursor and remainder stay preserved. */ + staticAssetId: uuid("static_asset_id").references(() => imageAssets.id, { + onDelete: "set null", + }), + /** An activation an operator asked for in advance. One per group: "activate this timeline at + * this instant" is a single intention, and a queue of them would be a scheduling product + * Signage does not have. */ + scheduledTimelineId: uuid("scheduled_timeline_id").references(() => timelines.id, { + onDelete: "set null", + }), + scheduledActivationAt: instant("scheduled_activation_at"), + /** Monotonic. Increments only on a semantic change; an exact no-op commits nothing. */ + revision: integer("revision").notNull().default(1), + /** The next sequence number a resolved selection for this group will take. */ + nextSequence: bigint("next_sequence", { mode: "number" }).notNull().default(1), + updatedAt: instant("updated_at").notNull().defaultNow(), + }, + (table) => [ + check( + "signage_playback_states_remaining_check", + sql`${table.pausedRemainingMs} is null or ${table.pausedRemainingMs} >= 0`, + ), + check( + "signage_playback_states_window_check", + sql`${table.slotStartedAt} is null or ${table.slotDeadlineAt} is null or ${table.slotStartedAt} <= ${table.slotDeadlineAt}`, + ), + ], +); + +// ------------------------------------------------------------------------------------------------- +// Resolved selections and bounded history. +// ------------------------------------------------------------------------------------------------- + +/** + * One logical selection, resolved once for the whole channel group (ADR 0004). + * + * Persisting this is what makes two viewers that joined at different times render the same slot, + * what lets `previous` walk actual playback rather than the slot order, what makes restart recovery + * show the same item, and what gives offline continuation something real to continue from. + * + * `slot_id` is nulled rather than cascaded when a slot is deleted: history is a record of what + * happened, and an operator editing a timeline must not erase it. + */ +export const selections = moduleTable( + "selections", + { + id: uuid("id").primaryKey(), + groupId: uuid("group_id") + .notNull() + .references(() => channelGroups.id, { onDelete: "cascade" }), + /** Monotonic per group. The ordering `previous` walks. */ + sequence: bigint("sequence", { mode: "number" }).notNull(), + timelineId: uuid("timeline_id"), + slotId: uuid("slot_id").references(() => timelineSlots.id, { onDelete: "set null" }), + /** The selection scope this item came from, copied at resolution time. Kept even after the + * source row is gone, so history stays readable and a provider's rotation can be re-seeded from + * what actually played rather than from the beginning. Null for a pinned item. */ + selectionScope: text("selection_scope"), + /** Opaque provider identity, and the provider's own identity for its content. */ + providerId: text("provider_id").notNull(), + itemId: text("item_id").notNull(), + /** Changes whenever the payload changes and the item stays the same thing. */ + itemRevision: text("item_revision").notNull(), + /** Which renderer draws this. Signage never imports it; the display asks the host to mount it. */ + rendererContributionId: text("renderer_contribution_id"), + rendererContributionVersion: text("renderer_contribution_version"), + /** Opaque presentation values the provider itself supplied to its own renderer. */ + presentation: jsonb("presentation"), + durationMs: integer("duration_ms").notNull(), + resolvedAt: instant("resolved_at").notNull().defaultNow(), + startedAt: instant("started_at"), + deadlineAt: instant("deadline_at"), + /** When this selection stops being valid, as supplied by the provider. */ + availableUntil: instant("available_until"), + /** The provider said this item is explicitly gone. Removed at the next safe boundary. */ + withdrawn: boolean("withdrawn").notNull().default(false), + /** A provider call failed temporarily and this is the last known good answer. */ + stale: boolean("stale").notNull().default(false), + /** Increments when a refresh changes payloads without changing which item this is — which is + * what updates a mounted renderer in place without restarting its slot. */ + revision: integer("revision").notNull().default(1), + }, + (table) => [ + uniqueIndex("signage_selections_group_sequence_key").on(table.groupId, table.sequence), + index("signage_selections_group_resolved_idx").on(table.groupId, table.resolvedAt), + index("signage_selections_provider_item_idx").on(table.providerId, table.itemId), + check("signage_selections_duration_check", sql`${table.durationMs} > 0`), + ], +); + +/** One audience's payload for one selection. The default variant is always present; an + * audience-specific one exists only when the provider offered it. */ +export const selectionVariants = moduleTable( + "selection_variants", + { + id: uuid("id").primaryKey(), + selectionId: uuid("selection_id") + .notNull() + .references(() => selections.id, { onDelete: "cascade" }), + /** The audience key this payload is for. The default variant uses the literal `default` key. */ + audienceKey: text("audience_key").notNull(), + isDefault: boolean("is_default").notNull().default(false), + /** Opaque JSON. Signage validates that it *is* finite JSON and never what it means. */ + payload: jsonb("payload").notNull(), + }, + (table) => [ + uniqueIndex("signage_selection_variants_selection_audience_key").on( + table.selectionId, + table.audienceKey, + ), + ], +); + +// ------------------------------------------------------------------------------------------------- +// Viewers. +// ------------------------------------------------------------------------------------------------- + +/** + * One display Signage has seen. + * + * `viewer_key` is the persistent identity a display keeps in its own IndexedDB, so a reconnect is + * recognized as the same screen rather than a new one. `device_id` is the **safe** Core device + * identifier a paired display's principal carries — an opaque correlation value. Signage never sees, + * stores, or handles a device credential, and pairing remains entirely Core's. + */ +export const viewers = moduleTable( + "viewers", + { + id: uuid("id").primaryKey(), + viewerKey: text("viewer_key").notNull(), + name: text("name").notNull(), + deviceId: text("device_id"), + channelId: uuid("channel_id").references(() => channels.id, { onDelete: "set null" }), + lastSeenAt: instant("last_seen_at"), + /** What the viewer last reported it was actually showing. Telemetry: a display never mutates + * authoritative playback, and a local cursor is never accepted as authority. */ + reportedTimelineId: uuid("reported_timeline_id"), + reportedSelectionId: uuid("reported_selection_id"), + reportedProviderId: text("reported_provider_id"), + reportedContributionId: text("reported_contribution_id"), + reportedPlaybackRevision: integer("reported_playback_revision"), + /** Whether the viewer says it has enough cached to keep rendering through an outage. */ + offlineReady: boolean("offline_ready").notNull().default(false), + cachedAssetCount: integer("cached_asset_count").notNull().default(0), + /** A stable sanitized code and a bounded operator-facing detail. Never an exception, a URL, a + * payload, or a stack. */ + lastErrorCode: text("last_error_code"), + lastErrorDetail: text("last_error_detail"), + lastErrorAt: instant("last_error_at"), + createdAt: instant("created_at").notNull().defaultNow(), + updatedAt: instant("updated_at").notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("signage_viewers_viewer_key_key").on(table.viewerKey), + index("signage_viewers_channel_idx").on(table.channelId), + index("signage_viewers_last_seen_idx").on(table.lastSeenAt), + ], +); diff --git a/src/server/display/state.ts b/src/server/display/state.ts new file mode 100644 index 0000000..b408e26 --- /dev/null +++ b/src/server/display/state.ts @@ -0,0 +1,370 @@ +import type { ModuleStorage } from "@ekklesia/module-sdk"; +import { + resolveNativeScene, + SIGNAGE_NATIVE_PROVIDER_ID, + SIGNAGE_NATIVE_RENDERER, + type SignageNativePresentation, +} from "../../shared/native-images.js"; +import type { SignageSettings } from "../../shared/settings.js"; +import { createImageDeliveryUrls } from "../images/service.js"; +import type { PlaybackSnapshot } from "../playback/service.js"; +import type { SignageProviderRegistry } from "../providers/registry.js"; +import type { + SignageStore, + StoredChannel, + StoredSelection, + StoredSlot, + StoredTimelineDetail, +} from "../store/types.js"; + +/** + * Everything one channel's display needs to render, and to keep rendering when its network drops. + * + * Three groups of things travel together here, and each earns its place: + * + * - **What is on screen now**, resolved for this channel's audience, with fresh delivery URLs for a + * native image. This is the frame. + * - **The timeline's shape** — slot identities, eligibility windows, and estimated durations. This + * is what lets a disconnected display advance its own cursor with the same arithmetic the server + * reconciles with, rather than guessing (ADR 0006). + * - **Recent resolved selections**, again for this audience. This is what a disconnected display + * *renders* when its cursor lands on a slot it has already seen. It never resolves new provider + * content offline, because inventing a provider's answer is worse than keeping the last frame. + * + * A delivery URL is minted here and never stored: it is a bearer capability with an expiry in it, + * 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 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; + 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 + * enough that the response stays a response. */ +export const SIGNAGE_DISPLAY_SELECTION_LIMIT = 24; + +function slotPresentationFor( + slot: StoredSlot | undefined, + settings: SignageSettings, +): SignageNativePresentation { + const defaults = settings.nativePresentation; + return { + backgroundColor: slot?.presentation.backgroundColor ?? defaults.backgroundColor, + backgroundDarkened: slot?.presentation.backgroundDarkened ?? defaults.backgroundDarkened, + backgroundMode: slot?.presentation.backgroundMode ?? defaults.backgroundMode, + blurPixels: slot?.presentation.blurPixels ?? defaults.blurPixels, + foregroundShadow: slot?.presentation.foregroundShadow ?? true, + }; +} + +function payloadFor(selection: StoredSelection, audienceKey: string): unknown { + const exact = selection.variants.find((variant) => variant.audienceKey === audienceKey); + if (exact) { + return exact.payload; + } + // The fallback *is* the contract: a provider with nothing audience-specific returns one variant, + // and every channel renders it (ADR 0002). + return selection.variants.find((variant) => variant.isDefault)?.payload ?? null; +} + +interface NativePayloadShape { + readonly imageId?: unknown; + readonly width?: unknown; + readonly height?: unknown; + readonly renditionKeys?: { readonly display?: unknown; readonly thumbnail?: unknown }; +} + +export interface BuildDisplayStateInput { + readonly store: SignageStore; + readonly storage: ModuleStorage; + readonly registry: SignageProviderRegistry; + readonly settings: SignageSettings; + readonly channel: StoredChannel; + readonly playback: PlaybackSnapshot; + readonly atMs: number; +} + +export async function buildDisplayChannelState( + input: BuildDisplayStateInput, +): Promise { + const group = await input.store.findChannelGroup(input.channel.groupId); + const channelName = group?.channels.find((c) => c.id === input.channel.id)?.name ?? "Display"; + const timelineDetail = + input.playback.activeTimelineId === null + ? undefined + : await input.store.findTimeline(input.playback.activeTimelineId); + + const history = await input.store.listSelections( + input.channel.groupId, + SIGNAGE_DISPLAY_SELECTION_LIMIT, + ); + const rendered = await Promise.all( + history.map((selection) => + renderSelection(input, selection, timelineDetail, input.channel.key), + ), + ); + const current = + rendered.find((selection) => selection.id === input.playback.currentSelectionId) ?? null; + + return { + channel: { + groupId: input.channel.groupId, + id: input.channel.id, + key: input.channel.key, + name: channelName, + }, + crossfadeMs: input.settings.crossfadeMs, + current, + playback: input.playback, + selections: rendered, + serverNowMs: input.atMs, + staticHold: + input.playback.staticAssetId === null + ? null + : await renderStaticHold(input, input.playback.staticAssetId), + timeline: timelineDetail ? describeTimeline(input, timelineDetail) : null, + }; +} + +function describeTimeline( + input: BuildDisplayStateInput, + detail: StoredTimelineDetail, +): DisplayTimeline { + return { + eligibleFromMs: detail.timeline.eligibleFrom?.getTime() ?? null, + eligibleUntilMs: detail.timeline.eligibleUntil?.getTime() ?? null, + id: detail.timeline.id, + revision: detail.timeline.revision, + slots: detail.slots.map((slot) => { + const providerId = + slot.kind === "pinned-item" + ? slot.providerId + : (detail.sources.find((source) => source.id === slot.sourceId)?.providerId ?? null); + return { + eligibleFromMs: slot.eligibleFrom?.getTime() ?? null, + eligibleUntilMs: slot.eligibleUntil?.getTime() ?? null, + enabled: slot.enabled, + // The same estimate the server uses to skip whole passes, so a disconnected display's + // arithmetic and the server's agree. + estimatedDurationMs: + slot.durationOverrideMs ?? + (providerId === null ? undefined : input.registry.defaultDurationMs(providerId)) ?? + input.settings.nativeImageDurationMs, + id: slot.id, + position: slot.position, + }; + }), + }; +} + +async function renderSelection( + input: BuildDisplayStateInput, + selection: StoredSelection, + timeline: StoredTimelineDetail | undefined, + audienceKey: string, +): Promise { + const slot = + selection.slotId === null + ? undefined + : timeline?.slots.find((candidate) => candidate.id === selection.slotId); + const payload = payloadFor(selection, audienceKey); + + const base: DisplaySelection = { + availableUntilMs: selection.availableUntil?.getTime() ?? null, + deadlineAtMs: selection.deadlineAt?.getTime() ?? null, + durationMs: selection.durationMs, + id: selection.id, + itemId: selection.itemId, + itemRevision: selection.itemRevision, + payload, + presentation: selection.presentation, + providerId: selection.providerId, + renderer: + selection.providerId === SIGNAGE_NATIVE_PROVIDER_ID || + selection.rendererContributionId === SIGNAGE_NATIVE_RENDERER + ? { kind: "native-image" } + : selection.rendererContributionId === null || + selection.rendererContributionVersion === null + ? { kind: "unavailable" } + : { + contributionId: selection.rendererContributionId, + contributionVersion: selection.rendererContributionVersion, + kind: "contribution", + }, + revision: selection.revision, + slotId: selection.slotId, + stale: selection.stale, + startedAtMs: selection.startedAt?.getTime() ?? null, + withdrawn: selection.withdrawn, + }; + + if (base.renderer.kind !== "native-image") { + return base; + } + const media = await nativeMediaFor(input, payload); + const shape = payload as NativePayloadShape | null; + const width = typeof shape?.width === "number" ? shape.width : 16; + const height = typeof shape?.height === "number" ? shape.height : 9; + return { + ...base, + ...(media ? { media } : {}), + scene: resolveNativeScene({ height, width }, slotPresentationFor(slot, input.settings)), + }; +} + +async function nativeMediaFor( + input: BuildDisplayStateInput, + payload: unknown, +): Promise { + const shape = payload as NativePayloadShape | null; + const displayKey = shape?.renditionKeys?.display; + const thumbnailKey = shape?.renditionKeys?.thumbnail; + if (typeof displayKey !== "string" || typeof thumbnailKey !== "string") { + return undefined; + } + const width = typeof shape?.width === "number" ? shape.width : 0; + const height = typeof shape?.height === "number" ? shape.height : 0; + try { + const urls = await createImageDeliveryUrls(input.storage, [ + { height, kind: "display", objectKey: displayKey, width }, + { height, kind: "thumbnail", objectKey: thumbnailKey, width }, + ]); + const display = urls.find((url) => url.kind === "display"); + const thumbnail = urls.find((url) => url.kind === "thumbnail"); + if (!display || !thumbnail) { + return undefined; + } + return { + display: { + expiresAtMs: Date.parse(display.expiresAt), + height, + url: display.url, + width, + }, + thumbnail: { + expiresAtMs: Date.parse(thumbnail.expiresAt), + height, + url: thumbnail.url, + width, + }, + }; + } catch { + // Storage being briefly unreachable costs this frame's URLs, not the whole state: a display + // that already has the bytes cached keeps rendering, and the next refresh carries fresh ones. + return undefined; + } +} + +/** The held image, shaped exactly like a selection so a display's renderer path is the same one. */ +async function renderStaticHold( + input: BuildDisplayStateInput, + imageId: string, +): Promise { + const image = await input.store.findImage(imageId); + if (image?.status !== "ready") { + return null; + } + const display = image.renditions.find((rendition) => rendition.kind === "display"); + const thumbnail = image.renditions.find((rendition) => rendition.kind === "thumbnail"); + const payload = { + displayName: image.displayName, + height: image.height, + imageId: image.id, + kind: "native-image", + renditionKeys: { + display: display?.objectKey ?? image.originalObjectKey, + thumbnail: thumbnail?.objectKey ?? image.originalObjectKey, + }, + width: image.width, + }; + const media = await nativeMediaFor(input, payload); + return { + availableUntilMs: image.expiresAt?.getTime() ?? null, + deadlineAtMs: null, + durationMs: 0, + id: `static:${image.id}`, + itemId: image.id, + itemRevision: image.contentSha256.slice(0, 32), + ...(media ? { media } : {}), + payload, + presentation: null, + providerId: SIGNAGE_NATIVE_PROVIDER_ID, + renderer: { kind: "native-image" }, + revision: 1, + scene: resolveNativeScene(image, slotPresentationFor(undefined, input.settings)), + slotId: null, + stale: false, + startedAtMs: null, + withdrawn: false, + }; +} diff --git a/src/server/errors.ts b/src/server/errors.ts new file mode 100644 index 0000000..77abbe8 --- /dev/null +++ b/src/server/errors.ts @@ -0,0 +1,118 @@ +/** + * Signage's one sanitized failure vocabulary. + * + * Every error that can reach a browser, a log, or an operator carries a **stable code and a fixed + * message**, and deliberately nothing else: no caught exception, no stack, no cause, no SQL, no + * object key, no storage endpoint, no provider text, no machine path, and no payload. This follows + * the same discipline as the platform's own `ModuleStorageError` and migration-runner errors. + * + * A `detail` is allowed only when it is a value **Signage itself supplied or the caller itself + * sent** — an asset id the caller named, a limit Signage declared. It is never something the host, + * the database, or another module handed back. + */ +export const SIGNAGE_ERROR_CODES = [ + /** The request body, query, or path parameter is not something this route accepts. */ + "SIGNAGE_INVALID_REQUEST", + /** The named resource does not exist, or is not one this caller may address. */ + "SIGNAGE_NOT_FOUND", + /** The request is well formed but conflicts with current state. */ + "SIGNAGE_CONFLICT", + /** The asset is referenced by a timeline slot, a pinned item, or a live static hold. */ + "SIGNAGE_ASSET_IN_USE", + /** The uploaded bytes are not a still PNG, JPEG, or WebP. */ + "SIGNAGE_IMAGE_UNSUPPORTED_FORMAT", + /** The uploaded bytes could not be decoded as an image at all. */ + "SIGNAGE_IMAGE_MALFORMED", + /** Animation is not supported; a signage slide is a still frame. */ + "SIGNAGE_IMAGE_ANIMATED", + /** Multipage input is not supported. */ + "SIGNAGE_IMAGE_MULTIPAGE", + /** The upload exceeded Signage's per-file byte limit. */ + "SIGNAGE_IMAGE_TOO_LARGE", + /** The image decodes to more pixels than Signage will process. */ + "SIGNAGE_IMAGE_PIXEL_LIMIT", + /** Object storage could not be reached, or refused the operation. */ + "SIGNAGE_STORAGE_UNAVAILABLE", + /** Signage could not complete the operation, and has said everything it safely can. */ + "SIGNAGE_OPERATION_FAILED", +] as const; + +export type SignageErrorCode = (typeof SIGNAGE_ERROR_CODES)[number]; + +const messages: Readonly> = Object.freeze({ + SIGNAGE_ASSET_IN_USE: "This image is still referenced and cannot be deleted.", + SIGNAGE_CONFLICT: "The request conflicts with the current state.", + SIGNAGE_IMAGE_ANIMATED: "Animated images are not supported.", + SIGNAGE_IMAGE_MALFORMED: "The uploaded file is not a decodable image.", + SIGNAGE_IMAGE_MULTIPAGE: "Multipage images are not supported.", + SIGNAGE_IMAGE_PIXEL_LIMIT: "The image exceeds the decoded-pixel limit.", + SIGNAGE_IMAGE_TOO_LARGE: "The image exceeds the maximum upload size.", + SIGNAGE_IMAGE_UNSUPPORTED_FORMAT: "Only still PNG, JPEG, and WebP images are supported.", + SIGNAGE_INVALID_REQUEST: "The request is not valid.", + SIGNAGE_NOT_FOUND: "The requested resource was not found.", + SIGNAGE_OPERATION_FAILED: "The operation could not be completed.", + SIGNAGE_STORAGE_UNAVAILABLE: "Object storage is not available.", +}); + +const statuses: Readonly> = Object.freeze({ + SIGNAGE_ASSET_IN_USE: 409, + SIGNAGE_CONFLICT: 409, + SIGNAGE_IMAGE_ANIMATED: 415, + SIGNAGE_IMAGE_MALFORMED: 422, + SIGNAGE_IMAGE_MULTIPAGE: 415, + SIGNAGE_IMAGE_PIXEL_LIMIT: 413, + SIGNAGE_IMAGE_TOO_LARGE: 413, + SIGNAGE_IMAGE_UNSUPPORTED_FORMAT: 415, + SIGNAGE_INVALID_REQUEST: 400, + SIGNAGE_NOT_FOUND: 404, + SIGNAGE_OPERATION_FAILED: 500, + SIGNAGE_STORAGE_UNAVAILABLE: 503, +}); + +/** What a caller may safely be told alongside a code: values the caller sent, or limits Signage + * declared. Never a host value, a driver message, or another module's text. */ +export type SignageErrorDetail = Readonly>; + +export class SignageError extends Error { + readonly code: SignageErrorCode; + readonly status: number; + readonly detail?: SignageErrorDetail; + + constructor(code: SignageErrorCode, detail?: SignageErrorDetail) { + super(messages[code]); + this.name = "SignageError"; + this.code = code; + this.status = statuses[code]; + if (detail !== undefined) { + this.detail = Object.freeze({ ...detail }); + } + this.stack = `${this.name}: ${this.message}`; + } +} + +export function signageErrorResponse(error: SignageError): Response { + return Response.json( + { code: error.code, message: error.message, ...(error.detail ? { detail: error.detail } : {}) }, + { status: error.status }, + ); +} + +/** + * The one boundary every Signage route handler runs inside. + * + * A `SignageError` becomes its own sanitized response. **Anything else becomes + * `SIGNAGE_OPERATION_FAILED` with nothing of the caught value in it** — a database error, a storage + * failure, a bug, and a provider's own exception all look identical from outside, which is the + * point. The platform would sanitize an uncaught throw too; catching it here is how Signage keeps a + * useful code instead of a generic one. + */ +export async function signageBoundary(run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + if (error instanceof SignageError) { + return signageErrorResponse(error); + } + return signageErrorResponse(new SignageError("SIGNAGE_OPERATION_FAILED")); + } +} diff --git a/src/server/http.ts b/src/server/http.ts new file mode 100644 index 0000000..5087f61 --- /dev/null +++ b/src/server/http.ts @@ -0,0 +1,126 @@ +import type { ModuleRouteHandlerContext } from "@ekklesia/module-sdk"; +import { SignageError } from "./errors.js"; + +/** + * Request-shaping helpers, and — more importantly — parameter validation. + * + * `context.params` holds the router's **decoded** path values. Decoding is not validation, and the + * platform deliberately supplies no generic parameter grammar: these are Signage's domain types, so + * validating them is Signage's job, before any database, storage, or identifier use. + */ +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; + +export function requireUuidParam(context: ModuleRouteHandlerContext, name: string): string { + const value = context.params[name]; + if (value === undefined || !UUID.test(value)) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { parameter: name }); + } + return value; +} + +export function isUuid(value: unknown): value is string { + return typeof value === "string" && UUID.test(value); +} + +/** Reads a JSON request body a route declared a buffered allowance for. A body that is absent, + * oversized for its own declared shape, or not a JSON object is refused with Signage's own code + * rather than a parser's message. */ +export async function readJsonObject(request: Request): Promise> { + let parsed: unknown; + try { + parsed = await request.json(); + } catch { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" }); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" }); + } + return parsed as Record; +} + +export function optionalString( + body: Record, + field: string, + options: { readonly maxLength: number }, +): string | undefined { + const value = body[field]; + if (value === undefined) { + return undefined; + } + if (typeof value !== "string" || value.trim().length === 0 || value.length > options.maxLength) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field }); + } + return value.trim(); +} + +export function requiredString( + body: Record, + field: string, + options: { readonly maxLength: number }, +): string { + const value = optionalString(body, field, options); + if (value === undefined) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field }); + } + return value; +} + +/** An absolute instant, or `null` to clear one. Signage never accepts an ambiguous local timestamp: + * a value must parse as an absolute instant or be refused. */ +export function optionalInstant( + body: Record, + field: string, +): Date | null | undefined { + const value = body[field]; + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + if (typeof value !== "string") { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field }); + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime()) || !/[Zz]|[+-]\d{2}:?\d{2}$/u.test(value)) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field }); + } + return parsed; +} + +export function optionalInteger( + body: Record, + field: string, + options: { readonly minimum: number; readonly maximum: number }, +): number | null | undefined { + const value = body[field]; + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value < options.minimum || + value > options.maximum + ) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field }); + } + return value; +} + +export function optionalBoolean( + body: Record, + field: string, +): boolean | null | undefined { + const value = body[field]; + if (value === undefined || value === null) { + return value as null | undefined; + } + if (typeof value !== "boolean") { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field }); + } + return value; +} diff --git a/src/server/images/limits.ts b/src/server/images/limits.ts new file mode 100644 index 0000000..c54131f --- /dev/null +++ b/src/server/images/limits.ts @@ -0,0 +1,46 @@ +/** + * The bounds Signage's native image ingest enforces, in one place. + * + * These are Signage's own product limits, deliberately inside the platform's. The platform's + * absolute request-body ceiling is 32 MiB; Signage declares 25 MiB and refuses anything larger + * itself rather than relying on the platform's refusal. Two limits that agree are two chances to + * catch a mistake, and a module that only had the platform's would silently change its own product + * behaviour if the platform's ever moved. + * + * The upload route carries **raw image bytes**, not a multipart envelope, so the declared allowance + * is exactly the image limit. One request is one file; nothing in Signage can express an aggregate + * upload, and the filename and display name travel in the query string. + */ + +/** The largest image file an operator may upload. */ +export const SIGNAGE_IMAGE_MAX_BYTES = 25 * 1024 * 1024; + +/** + * The decoded-pixel budget. + * + * File size does not bound decoding: a few hundred kilobytes of PNG can decode to gigabytes of + * pixels, which is the actual shape of a decompression bomb. 40 million pixels is roughly a 7700 × + * 5200 photograph — far beyond any signage slide, and far below anything that threatens the process. + */ +export const SIGNAGE_IMAGE_MAX_PIXELS = 40_000_000; + +/** How many files the dashboard offers to queue at once. One request each, always. */ +export const SIGNAGE_UPLOAD_BATCH_MAX_FILES = 20; + +/** How many of those requests the dashboard runs concurrently. Bounded so a batch cannot pin the + * API's memory with several simultaneous full-size images, and so the dashboard stays responsive. */ +export const SIGNAGE_UPLOAD_CONCURRENCY = 3; + +export const SIGNAGE_IMAGE_DISPLAY_RENDITION = Object.freeze({ + kind: "display" as const, + maxWidth: 1920, + maxHeight: 1080, + quality: 82, +}); + +export const SIGNAGE_IMAGE_THUMBNAIL_RENDITION = Object.freeze({ + kind: "thumbnail" as const, + maxWidth: 480, + maxHeight: 270, + quality: 72, +}); diff --git a/src/server/images/processing.ts b/src/server/images/processing.ts new file mode 100644 index 0000000..2236d62 --- /dev/null +++ b/src/server/images/processing.ts @@ -0,0 +1,199 @@ +import { createHash } from "node:crypto"; +import sharp, { type Metadata, type OutputInfo } from "sharp"; +import { SignageError } from "../errors.js"; +import { + SIGNAGE_IMAGE_DISPLAY_RENDITION, + SIGNAGE_IMAGE_MAX_PIXELS, + SIGNAGE_IMAGE_THUMBNAIL_RENDITION, +} from "./limits.js"; + +/** + * Signage's native image pipeline. + * + * Three properties matter more than the code: + * + * 1. **Format comes from the bytes.** A filename and a declared content type are attacker-controlled + * and routinely wrong even when they are not; the decoder decides what this is. + * 2. **The original is never rewritten.** It is a church's file, archived exactly as uploaded. Only + * the two renditions Signage generates are transformed. + * 3. **Nothing about presentation is baked in.** Background, blur, darkening, and shadow are + * resolved at render time from a slot's own settings, so changing them is a database update + * rather than a re-encode (ADR 0007). + */ + +const SUPPORTED_FORMATS: ReadonlySet = new Set(["jpeg", "png", "webp"]); + +const CONTENT_TYPES: Readonly> = Object.freeze({ + jpeg: "image/jpeg", + png: "image/png", + webp: "image/webp", +}); + +const FILE_EXTENSIONS: Readonly> = Object.freeze({ + jpeg: "jpg", + png: "png", + webp: "webp", +}); + +export interface SignageImageRendition { + readonly kind: "display" | "thumbnail"; + readonly bytes: Uint8Array; + readonly contentType: string; + readonly contentSha256: string; + readonly byteSize: number; + readonly width: number; + readonly height: number; +} + +export interface SignageProcessedImage { + readonly contentSha256: string; + readonly byteSize: number; + readonly format: string; + readonly contentType: string; + readonly fileExtension: string; + /** How the original should appear once its own orientation is applied. */ + readonly width: number; + readonly height: number; + /** What is actually encoded in the archived bytes. */ + readonly encodedWidth: number; + readonly encodedHeight: number; + readonly orientation: number | null; + readonly hasAlpha: boolean; + readonly renditions: readonly SignageImageRendition[]; +} + +export function sha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** Sharp reports a pixel-limit breach and a decode failure through the same exception type. The + * message is inspected here and then **discarded** — nothing from the decoder reaches a caller. */ +function decodeFailure(error: unknown): SignageError { + const message = error instanceof Error ? error.message : ""; + if (/pixel limit/iu.test(message)) { + return new SignageError("SIGNAGE_IMAGE_PIXEL_LIMIT", { maxPixels: SIGNAGE_IMAGE_MAX_PIXELS }); + } + return new SignageError("SIGNAGE_IMAGE_MALFORMED"); +} + +async function readMetadata(bytes: Uint8Array): Promise { + try { + return await sharp(bytes, { + animated: true, + failOn: "error", + limitInputPixels: SIGNAGE_IMAGE_MAX_PIXELS, + }).metadata(); + } catch (error) { + throw decodeFailure(error); + } +} + +function assertSupported(metadata: Metadata): void { + const pages = metadata.pages ?? 1; + if (pages > 1) { + const animated = + metadata.loop !== undefined || (Array.isArray(metadata.delay) && metadata.delay.length > 0); + throw new SignageError(animated ? "SIGNAGE_IMAGE_ANIMATED" : "SIGNAGE_IMAGE_MULTIPAGE", { + pages, + }); + } + if (metadata.format === undefined || !SUPPORTED_FORMATS.has(metadata.format)) { + // The detected format is echoed because the caller supplied the bytes it came from. Nothing + // about the host, the decoder, or the process is in this response. + throw new SignageError("SIGNAGE_IMAGE_UNSUPPORTED_FORMAT", { + detectedFormat: metadata.format ?? null, + }); + } + if ( + !Number.isSafeInteger(metadata.width) || + (metadata.width ?? 0) < 1 || + !Number.isSafeInteger(metadata.height) || + (metadata.height ?? 0) < 1 + ) { + throw new SignageError("SIGNAGE_IMAGE_MALFORMED"); + } + if ((metadata.width ?? 0) * (metadata.height ?? 0) > SIGNAGE_IMAGE_MAX_PIXELS) { + throw new SignageError("SIGNAGE_IMAGE_PIXEL_LIMIT", { maxPixels: SIGNAGE_IMAGE_MAX_PIXELS }); + } +} + +async function renderRendition( + bytes: Uint8Array, + target: { + readonly kind: "display" | "thumbnail"; + maxWidth: number; + maxHeight: number; + quality: number; + }, +): Promise { + let output: { data: Buffer; info: OutputInfo }; + try { + output = await sharp(bytes, { failOn: "error", limitInputPixels: SIGNAGE_IMAGE_MAX_PIXELS }) + // Honour the original's own orientation, so a phone photograph is not shown on its side. + .autoOrient() + // Transparency becomes black rather than white: a signage display's ground is black, and a + // white flash behind a logo is the artefact this avoids. + .flatten({ background: { b: 0, g: 0, r: 0 } }) + // `inside` never crops, and `withoutEnlargement` never upscales — a small image stays small + // rather than being blown up into softness. + .resize({ + fit: "inside", + height: target.maxHeight, + width: target.maxWidth, + withoutEnlargement: true, + }) + .toColourspace("srgb") + // No `withMetadata()`: EXIF, GPS coordinates, and camera serial numbers are stripped from + // anything Signage publishes. The original keeps them; the rendition a browser fetches + // does not. + .webp({ effort: 4, quality: target.quality }) + .toBuffer({ resolveWithObject: true }); + } catch (error) { + throw decodeFailure(error); + } + const data = new Uint8Array(output.data); + return { + byteSize: data.byteLength, + bytes: data, + contentSha256: sha256Hex(data), + contentType: CONTENT_TYPES.webp as string, + height: output.info.height, + kind: target.kind, + width: output.info.width, + }; +} + +/** + * Validates the uploaded bytes and produces both renditions, or throws one of Signage's own + * sanitized codes. Deterministic: the same input always yields the same output bytes, which is what + * makes a retry idempotent. + */ +export async function processSignageImage(bytes: Uint8Array): Promise { + const metadata = await readMetadata(bytes); + assertSupported(metadata); + + const format = metadata.format as string; + const oriented = metadata.autoOrient ?? { + height: metadata.height as number, + width: metadata.width as number, + }; + const [display, thumbnail] = await Promise.all([ + renderRendition(bytes, SIGNAGE_IMAGE_DISPLAY_RENDITION), + renderRendition(bytes, SIGNAGE_IMAGE_THUMBNAIL_RENDITION), + ]); + + return { + byteSize: bytes.byteLength, + contentSha256: sha256Hex(bytes), + contentType: CONTENT_TYPES[format] as string, + encodedHeight: metadata.height as number, + encodedWidth: metadata.width as number, + fileExtension: FILE_EXTENSIONS[format] as string, + format, + hasAlpha: Boolean(metadata.hasAlpha), + height: oriented.height, + orientation: metadata.orientation ?? null, + renditions: [display, thumbnail], + width: oriented.width, + }; +} diff --git a/src/server/images/service.ts b/src/server/images/service.ts new file mode 100644 index 0000000..6ef2e26 --- /dev/null +++ b/src/server/images/service.ts @@ -0,0 +1,278 @@ +import { randomUUID } from "node:crypto"; +import { + type ModuleStorage, + type ModuleStorageBucket, + ModuleStorageError, +} from "@ekklesia/module-sdk"; +import { SignageError } from "../errors.js"; +import { type SignageStore, SignageStoreConflict, type StoredImage } from "../store/types.js"; +import { SIGNAGE_IMAGE_MAX_BYTES } from "./limits.js"; +import { processSignageImage, type SignageProcessedImage } from "./processing.js"; + +/** The one logical bucket Signage declares. It names no service, endpoint, region, or credential — + * which is what lets the same code run under Ekklesia and under a future standalone host. */ +export const SIGNAGE_IMAGE_BUCKET = "native-images"; + +export function nativeImageBucket(storage: ModuleStorage): ModuleStorageBucket { + return storage.bucket(SIGNAGE_IMAGE_BUCKET); +} + +/** + * Object keys are scoped to the asset they belong to. + * + * Until the asset row commits these are **temporary**: nothing references them, nothing can select + * them, and a failed ingest deletes every one it wrote. Scoping them to the asset rather than to a + * throwaway upload id is what makes a successful upload cost exactly one write of each object + * instead of two — the commit is the database row, not a second copy of 25 MiB. + */ +export function originalObjectKey(assetId: string, extension: string): string { + return `assets/${assetId}/original.${extension}`; +} + +export function renditionObjectKey(assetId: string, kind: string): string { + return `assets/${assetId}/${kind}.webp`; +} + +/** + * Every storage failure becomes one of Signage's own codes. + * + * `ModuleStorageError` already carries no endpoint, credential, or provider body; collapsing it + * further keeps even *which* storage operation failed out of Signage's responses, because that is + * not a caller's business. + */ +function storageFailure(error: unknown): SignageError { + if (error instanceof ModuleStorageError && error.code === "STORAGE_OBJECT_NOT_FOUND") { + return new SignageError("SIGNAGE_NOT_FOUND"); + } + return new SignageError("SIGNAGE_STORAGE_UNAVAILABLE"); +} + +export interface IngestNativeImageInput { + readonly store: SignageStore; + readonly storage: ModuleStorage; + readonly bytes: Uint8Array; + readonly filename: string; + readonly displayName: string; + readonly expiresAt: Date | null; + /** Supplied so a test can pin identity. Production passes nothing and gets a fresh UUID. */ + readonly newId?: () => string; +} + +export interface IngestNativeImageResult { + readonly image: StoredImage; + /** True when identical bytes were already stored, so nothing new was written anywhere. */ + readonly deduplicated: boolean; +} + +/** + * Ingests one native image. + * + * The order **is** the contract (ADR 0007): + * + * 1. The caller has already read the whole request body under Signage's own bound. + * 2. Decode and validate. An invalid image has cost zero storage writes and zero rows. + * 3. Deduplicate by content hash. Identical bytes return the existing asset — again, zero writes. + * 4. Write the original and both renditions to asset-scoped keys. Temporary: nothing refers to them. + * 5. Commit the asset and its renditions in one transaction. That row is what makes the asset exist, + * so a partial write can never be promoted into something playback could select. + * 6. On any failure after step 4, delete every object this attempt wrote. + */ +export async function ingestNativeImage( + input: IngestNativeImageInput, +): Promise { + if (input.bytes.byteLength > SIGNAGE_IMAGE_MAX_BYTES) { + throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes: SIGNAGE_IMAGE_MAX_BYTES }); + } + + const processed: SignageProcessedImage = await processSignageImage(input.bytes); + + const existing = await input.store.findReadyImageByContentHash(processed.contentSha256); + if (existing) { + return { deduplicated: true, image: existing }; + } + + const bucket = nativeImageBucket(input.storage); + const assetId = (input.newId ?? randomUUID)(); + const original = originalObjectKey(assetId, processed.fileExtension); + const written: string[] = []; + + try { + await bucket.put(original, input.bytes, { contentType: processed.contentType }); + written.push(original); + + const renditions = []; + for (const rendition of processed.renditions) { + const key = renditionObjectKey(assetId, rendition.kind); + await bucket.put(key, rendition.bytes, { contentType: rendition.contentType }); + written.push(key); + renditions.push({ + byteSize: rendition.byteSize, + contentSha256: rendition.contentSha256, + contentType: rendition.contentType, + height: rendition.height, + kind: rendition.kind, + objectKey: key, + width: rendition.width, + }); + } + + await input.store.insertImage({ + contentSha256: processed.contentSha256, + displayName: input.displayName, + encodedHeight: processed.encodedHeight, + encodedWidth: processed.encodedWidth, + expiresAt: input.expiresAt, + hasAlpha: processed.hasAlpha, + height: processed.height, + id: assetId, + orientation: processed.orientation, + originalByteSize: processed.byteSize, + originalContentType: processed.contentType, + originalFilename: input.filename, + originalObjectKey: original, + renditions, + status: "ready", + width: processed.width, + }); + } catch (error) { + // Nothing this attempt wrote is referenced by anything, so removing all of it is always safe. + await Promise.all(written.map((key) => bucket.delete(key).catch(() => undefined))); + + if (error instanceof SignageStoreConflict) { + // Another request committed the identical bytes while this one was writing. For the caller + // that is a success, not a failure: the image they uploaded is stored. + const raced = await input.store.findReadyImageByContentHash(processed.contentSha256); + if (raced) { + return { deduplicated: true, image: raced }; + } + throw new SignageError("SIGNAGE_CONFLICT"); + } + if (error instanceof SignageError) { + throw error; + } + if (error instanceof ModuleStorageError) { + throw storageFailure(error); + } + throw new SignageError("SIGNAGE_OPERATION_FAILED"); + } + + const image = await input.store.findImage(assetId); + if (!image) { + throw new SignageError("SIGNAGE_OPERATION_FAILED"); + } + return { deduplicated: false, image }; +} + +export interface SignageImageDelivery { + readonly kind: string; + readonly url: string; + readonly expiresAt: string; + readonly width: number; + readonly height: number; +} + +/** + * Fresh, short-lived delivery URLs for one image's renditions. + * + * Requested when something is about to be delivered, and then forgotten. Signage persists the + * logical key and never the URL: a URL is a bearer capability with an expiry in it, and storing one + * in a table would be storing a credential that stops working. + */ +export async function createImageDeliveryUrls( + storage: ModuleStorage, + renditions: readonly { + readonly kind: string; + readonly objectKey: string; + readonly width: number; + readonly height: number; + }[], +): Promise { + const bucket = nativeImageBucket(storage); + try { + return await Promise.all( + renditions.map(async (rendition) => { + const url = await bucket.createReadUrl(rendition.objectKey); + return { + expiresAt: url.expiresAt.toISOString(), + height: rendition.height, + kind: rendition.kind, + url: url.url, + width: rendition.width, + }; + }), + ); + } catch (error) { + throw storageFailure(error); + } +} + +export async function updateNativeImage(input: { + readonly store: SignageStore; + readonly assetId: string; + readonly displayName?: string | undefined; + readonly expiresAt?: Date | null | undefined; +}): Promise { + const existing = await input.store.findImage(input.assetId); + if (!existing) { + throw new SignageError("SIGNAGE_NOT_FOUND"); + } + + const changes: { displayName?: string; expiresAt?: Date | null } = {}; + if (input.displayName !== undefined && input.displayName !== existing.displayName) { + changes.displayName = input.displayName; + } + if (input.expiresAt !== undefined) { + const current = existing.expiresAt?.getTime() ?? null; + const next = input.expiresAt?.getTime() ?? null; + if (current !== next) { + changes.expiresAt = input.expiresAt; + } + } + // An exact no-op writes nothing at all. + if (Object.keys(changes).length === 0) { + return existing; + } + + await input.store.updateImage(input.assetId, changes); + const updated = await input.store.findImage(input.assetId); + if (!updated) { + throw new SignageError("SIGNAGE_OPERATION_FAILED"); + } + return updated; +} + +/** + * Deletes an image, and refuses while something still refers to it. + * + * A slot pinned to a deleted image would resolve to nothing at the moment its turn came, mid-service. + * Refusing with a count of what refers to it is something an operator can act on; a silent cascade + * is not. + */ +export async function deleteNativeImage(input: { + readonly store: SignageStore; + readonly storage: ModuleStorage; + readonly assetId: string; +}): Promise { + const existing = await input.store.findImage(input.assetId); + if (!existing) { + throw new SignageError("SIGNAGE_NOT_FOUND"); + } + + const references = await input.store.countImageReferences(input.assetId); + if (references.slots > 0 || references.staticHolds > 0) { + throw new SignageError("SIGNAGE_ASSET_IN_USE", { + heldByChannelGroups: references.staticHolds, + referencedBySlots: references.slots, + }); + } + + // The row goes first: an asset nothing can select is the state that matters, and an object left + // behind by a storage outage is inert rather than dangerous. + await input.store.deleteImage(input.assetId); + const bucket = nativeImageBucket(input.storage); + const keys = [ + existing.originalObjectKey, + ...existing.renditions.map((rendition) => rendition.objectKey), + ]; + await Promise.all(keys.map((key) => bucket.delete(key).catch(() => undefined))); +} diff --git a/src/server/images/streaming.ts b/src/server/images/streaming.ts new file mode 100644 index 0000000..b5e714f --- /dev/null +++ b/src/server/images/streaming.ts @@ -0,0 +1,76 @@ +import { SignageError } from "../errors.js"; + +/** + * Reads a streamed request body under a hard byte bound. + * + * The upload route runs in the platform's `streamed` body mode, which means the platform counts + * bytes **as they are consumed** and never materializes the body itself. Signage counts them too, + * and stops at its own smaller limit — so an oversized upload is refused by Signage's own code with + * Signage's own message, rather than only by the platform's later `413`. + * + * The bytes are accumulated because an image cannot be validated, hashed, or resized from a partial + * stream: a decoder needs the whole file, and the decoded-pixel budget can only be checked once the + * header is present. The bound is what makes that safe — the peak is one image, never a request of + * unknown size, and never more than {@link maxBytes}. + * + * The source is cancelled as soon as the bound is passed, so a client sending more stops being read + * rather than being read and discarded. + */ +export async function readBoundedBody(request: Request, maxBytes: number): Promise { + const declared = request.headers.get("content-length"); + if (declared !== null) { + const length = Number(declared); + // A declared length over the limit is refused before a byte is read. A missing or lying one + // bounds nothing, which is exactly why the running total below is the real check. + if (Number.isFinite(length) && length > maxBytes) { + throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes }); + } + } + + const body = request.body; + if (!body) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" }); + } + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => undefined); + throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes }); + } + chunks.push(value); + } + } catch (error) { + if (error instanceof SignageError) { + throw error; + } + // The platform errors the stream when *its* limit is passed, and cancels the source. Nothing + // about that error reaches a caller: it becomes Signage's own bounded code. + throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes }); + } finally { + reader.releaseLock(); + } + + if (total === 0) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" }); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..adbd81f --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,11 @@ +import { createSignageModuleRuntime } from "./runtime.js"; + +/** + * Signage's compiled server entrypoint. + * + * `id` and `version` are build-artifact identity markers and must match `ekklesia.module.json` + * exactly; Core verifies both before this module is allowed to register anything (ADR 0006). The + * runtime itself is built by a factory (`./runtime.ts`) so a test can construct a fresh one rather + * than exercising a shared singleton's leftovers. + */ +export default createSignageModuleRuntime().definition; diff --git a/src/server/playback/capability.ts b/src/server/playback/capability.ts new file mode 100644 index 0000000..50df6e7 --- /dev/null +++ b/src/server/playback/capability.ts @@ -0,0 +1,68 @@ +import type { PlaybackSnapshot, SignagePlaybackService } from "./service.js"; + +/** + * `signage.playback` — sanitized playback state and commands for another module. + * + * The intended consumer is something like an Ekklesia Companion: a module that gives a volunteer at + * the back of a room a way to hold a slide or black out a screen. What it gets is exactly the + * operations an operator has in the dashboard, over the same authoritative service, with the same + * serialization. + * + * What it deliberately is **not**: a transport. There is no topic, no broker, no webhook, no + * integration vocabulary of any kind in this contract — a consumer that wanted to bridge these + * commands onto something else would do that in its own module, where that decision belongs. + * + * What it deliberately does not carry: a Core credential, a session, a device identity, a provider + * payload, or any private host internal. State here is identity, timing, and revisions. + */ +export const SIGNAGE_PLAYBACK_CAPABILITY_VERSION = "1.0.0"; + +/** One channel group's state, exactly as a display sees it. */ +export type SignagePlaybackState = PlaybackSnapshot; + +export interface SignagePlaybackCapability { + readonly contractVersion: string; + /** Every channel group and its current authoritative state. */ + listGroups(): Promise; + readGroup(groupId: string): Promise; + /** Immediately, or queued for the next safe slot boundary. */ + activate( + groupId: string, + timelineId: string, + when: "now" | "next-boundary", + ): Promise; + pause(groupId: string): Promise; + resume(groupId: string): Promise; + next(groupId: string): Promise; + previous(groupId: string): Promise; + setBlackout(groupId: string, on: boolean): Promise; + /** `null` clears the hold and continues the slide underneath from its preserved remainder. */ + setStaticHold(groupId: string, imageId: string | null): Promise; + /** Re-resolves provider availability and the current selection without disturbing the frame. */ + reload(groupId: string): Promise; +} + +export function createPlaybackCapability( + playback: () => SignagePlaybackService, + listGroupIds: () => Promise, +): SignagePlaybackCapability { + const capability: SignagePlaybackCapability = { + activate: (groupId: string, timelineId: string, when: "now" | "next-boundary") => + playback().activate(groupId, timelineId, when), + contractVersion: SIGNAGE_PLAYBACK_CAPABILITY_VERSION, + async listGroups(): Promise { + const ids = await listGroupIds(); + return Promise.all(ids.map((groupId) => playback().snapshot(groupId))); + }, + next: (groupId: string) => playback().next(groupId), + pause: (groupId: string) => playback().pause(groupId), + previous: (groupId: string) => playback().previous(groupId), + readGroup: (groupId: string) => playback().snapshot(groupId), + reload: (groupId: string) => playback().reload(groupId), + resume: (groupId: string) => playback().resume(groupId), + setBlackout: (groupId: string, on: boolean) => playback().setBlackout(groupId, on), + setStaticHold: (groupId: string, imageId: string | null) => + playback().setStaticHold(groupId, imageId), + }; + return Object.freeze(capability); +} diff --git a/src/server/playback/service.ts b/src/server/playback/service.ts new file mode 100644 index 0000000..a3354a6 --- /dev/null +++ b/src/server/playback/service.ts @@ -0,0 +1,1005 @@ +import { randomUUID } from "node:crypto"; +import { SIGNAGE_NATIVE_PROVIDER_ID, SIGNAGE_NATIVE_RENDERER } from "../../shared/native-images.js"; +import { + advanceCursor, + nextTimingBoundary, + remainingMs, + resumeWindow, + type TimingSlot, + type TimingTimeline, +} from "../../shared/playback-timing.js"; +import type { SignageSettings } from "../../shared/settings.js"; +import { SignageError } from "../errors.js"; +import { SIGNAGE_DEFAULT_AUDIENCE_KEY } from "../providers/contract.js"; +import type { ProviderCallOutcome, SignageProviderRegistry } from "../providers/registry.js"; +import type { ParsedProviderResolution } from "../providers/validation.js"; +import type { + SignageStore, + StoredChannelGroupDetail, + StoredSelection, + StoredSelectionVariant, + StoredSlot, + StoredTimelineDetail, + StoredTransport, +} from "../store/types.js"; + +/** + * Authoritative playback (ADR 0005). + * + * One serialized service owns every channel group's persisted state and timers. Every mutation — a + * timer firing, an operator command, a provider invalidation — runs serialized against its group, + * so two commands can never interleave into a state neither asked for. + * + * The arithmetic lives in `shared/playback-timing.ts`, compiled into the browser too, so a + * disconnected display continues with the *same* rules the server reconciles with. + */ + +/** What a display and the dashboard are told. Compact by design: large state — timelines, history, + * renderer payloads — is fetched over authorized HTTP keyed by revision, so a realtime frame stays + * far inside the platform's ceiling and a client that missed one refetches rather than guesses. */ +export interface PlaybackSnapshot { + readonly groupId: string; + readonly revision: number; + readonly serverNowMs: number; + readonly transport: StoredTransport; + readonly blackout: boolean; + readonly activeTimelineId: string | null; + readonly pendingTimelineId: string | null; + readonly scheduledTimelineId: string | null; + readonly scheduledActivationAtMs: number | null; + readonly currentSelectionId: string | null; + readonly currentSlotId: string | null; + readonly startedAtMs: number | null; + readonly deadlineAtMs: number | null; + readonly remainingMs: number | null; + /** The current selection's own revision. A change here means "same slide, new payload", which is + * what updates a mounted renderer in place rather than restarting its slot. */ + readonly selectionRevision: number | null; + readonly staticAssetId: string | null; +} + +export type PlaybackReason = + | "activated" + | "blackout" + | "invalidated" + | "next" + | "paused" + | "previous" + | "reload" + | "resumed" + | "startup" + | "static" + | "timer"; + +export interface SignagePlaybackServiceOptions { + readonly store: SignageStore; + readonly registry: SignageProviderRegistry; + readonly settings: SignageSettings; + readonly now?: () => number; + readonly setTimer?: (handler: () => void, delayMs: number) => unknown; + readonly clearTimer?: (handle: unknown) => void; + readonly onSnapshot?: (snapshot: PlaybackSnapshot, reason: PlaybackReason) => void; + readonly newId?: () => string; +} + +/** How far ahead a timer may be armed. A schedule months away must not become one enormous timer a + * restart silently loses; the service simply looks again sooner. */ +const MAX_TIMER_MS = 60 * 60 * 1000; + +export interface SignagePlaybackService { + /** Creates the one default channel group and its default channel if this installation has none. + * Exactly one, and no `in-person` or `online` channel: those are an administrator's decision. */ + ensureDefaultGroup(): Promise; + snapshot(groupId: string): Promise; + reconcile(groupId: string, reason?: PlaybackReason): Promise; + activate( + groupId: string, + timelineId: string, + when: "now" | "next-boundary" | { readonly at: Date }, + ): Promise; + pause(groupId: string): Promise; + resume(groupId: string): Promise; + next(groupId: string): Promise; + previous(groupId: string): Promise; + setBlackout(groupId: string, on: boolean): Promise; + setStaticHold(groupId: string, assetId: string | null): Promise; + reload(groupId: string): Promise; + /** A provider's notification that something it already resolved may have changed. */ + invalidate(notice: { + readonly providerId: string; + readonly sourceId?: string; + readonly itemId?: string; + }): Promise; + /** Reconciles every group and arms its timer. */ + start(): Promise; + stop(): void; +} + +export function createPlaybackService( + options: SignagePlaybackServiceOptions, +): SignagePlaybackService { + const now = options.now ?? (() => Date.now()); + const newId = options.newId ?? randomUUID; + const setTimer = + options.setTimer ?? + ((handler: () => void, delayMs: number) => { + const handle = setTimeout(handler, delayMs); + handle.unref?.(); + return handle; + }); + const clearTimer = options.clearTimer ?? ((handle: unknown) => clearTimeout(handle as never)); + + /** One promise chain per group. Serialization is the property every guarantee below rests on. */ + const locks = new Map>(); + const timers = new Map(); + let running = false; + + function withGroupLock(groupId: string, run: () => Promise): Promise { + const previous = locks.get(groupId) ?? Promise.resolve(); + const next = previous.then(run, run); + // The chain must not break on a failure, or one rejected command would deadlock the group. + locks.set( + groupId, + next.catch(() => undefined), + ); + return next; + } + + async function requireGroup(groupId: string): Promise { + const detail = await options.store.findChannelGroup(groupId); + if (!detail) { + throw new SignageError("SIGNAGE_NOT_FOUND"); + } + return detail; + } + + function providerIdFor(slot: StoredSlot, timeline: StoredTimelineDetail): string | undefined { + if (slot.kind === "pinned-item") { + return slot.providerId ?? undefined; + } + return timeline.sources.find((source) => source.id === slot.sourceId)?.providerId; + } + + function estimatedDurationFor(slot: StoredSlot, timeline: StoredTimelineDetail): number { + if (slot.durationOverrideMs !== null) { + return slot.durationOverrideMs; + } + const providerId = providerIdFor(slot, timeline); + const registered = + providerId === undefined ? undefined : options.registry.defaultDurationMs(providerId); + return registered ?? options.settings.nativeImageDurationMs; + } + + function timingTimelineFor(timeline: StoredTimelineDetail): TimingTimeline { + const slots: TimingSlot[] = timeline.slots.map((slot) => ({ + eligibleFromMs: slot.eligibleFrom?.getTime() ?? null, + eligibleUntilMs: slot.eligibleUntil?.getTime() ?? null, + enabled: slot.enabled, + estimatedDurationMs: estimatedDurationFor(slot, timeline), + id: slot.id, + position: slot.position, + })); + return { + eligibleFromMs: timeline.timeline.eligibleFrom?.getTime() ?? null, + eligibleUntilMs: timeline.timeline.eligibleUntil?.getTime() ?? null, + id: timeline.timeline.id, + slots, + }; + } + + function variantsOf( + resolution: ParsedProviderResolution, + audienceKeys: readonly string[], + ): StoredSelectionVariant[] { + const variants: StoredSelectionVariant[] = []; + const seen = new Set(); + for (const [audienceKey, payload] of resolution.variants) { + // Only audiences this group actually has, plus the required default. A provider that offered a + // variant for an audience nobody configured is not an error; it is simply unused. + if (audienceKey !== SIGNAGE_DEFAULT_AUDIENCE_KEY && !audienceKeys.includes(audienceKey)) { + continue; + } + variants.push({ + audienceKey, + isDefault: audienceKey === SIGNAGE_DEFAULT_AUDIENCE_KEY, + payload, + }); + seen.add(audienceKey); + } + if (!seen.has(SIGNAGE_DEFAULT_AUDIENCE_KEY)) { + // Unreachable: validation already requires the default. Kept because a selection with no + // default would leave a channel with nothing to render, which is worse than a loud failure. + throw new SignageError("SIGNAGE_OPERATION_FAILED"); + } + return variants; + } + + /** Resolves one slot's content. `null` means "skip this slot": a provider with nothing available, + * an unregistered provider, or one that failed. Which of those it was is already recorded in + * provider health; playback's only decision is to move on. */ + async function resolveSlot( + group: StoredChannelGroupDetail, + timeline: StoredTimelineDetail, + slot: StoredSlot, + atMs: number, + ): Promise<{ + readonly resolution: ParsedProviderResolution; + readonly providerId: string; + readonly selectionScope: string | null; + } | null> { + const providerId = providerIdFor(slot, timeline); + if (providerId === undefined) { + return null; + } + const audienceKeys = group.channels.map((channel) => channel.key); + const request = { + at: new Date(atMs).toISOString(), + audienceKeys, + channelGroupId: group.group.id, + timelineId: timeline.timeline.id, + timezone: options.settings.timezone, + }; + + let outcome: ProviderCallOutcome; + let selectionScope: string | null = null; + if (slot.kind === "pinned-item") { + outcome = await options.registry.refresh(providerId, { + ...request, + itemId: slot.itemId ?? "", + }); + } else { + const source = timeline.sources.find((candidate) => candidate.id === slot.sourceId); + if (!source) { + return null; + } + selectionScope = source.selectionScope; + outcome = await options.registry.resolveNext(providerId, { + ...request, + selectionScopeId: source.selectionScope, + sourceId: source.sourceId, + }); + } + + if (outcome.kind !== "resolved" || !outcome.resolution || outcome.resolution.withdrawn) { + return null; + } + return { providerId, resolution: outcome.resolution, selectionScope }; + } + + function durationFor( + slot: StoredSlot, + resolution: ParsedProviderResolution, + providerId: string, + ): number { + // A slot override wins over everything, then the provider's own answer for this item, then the + // provider's registered default, then Signage's native-image default. + return ( + slot.durationOverrideMs ?? + resolution.durationMs ?? + options.registry.defaultDurationMs(providerId) ?? + options.settings.nativeImageDurationMs + ); + } + + async function recordSelection(input: { + readonly group: StoredChannelGroupDetail; + readonly timeline: StoredTimelineDetail; + readonly slot: StoredSlot; + readonly providerId: string; + readonly selectionScope: string | null; + readonly resolution: ParsedProviderResolution; + readonly startedAtMs: number; + }): Promise { + const durationMs = durationFor(input.slot, input.resolution, input.providerId); + const renderer = + input.providerId === SIGNAGE_NATIVE_PROVIDER_ID + ? { contributionId: SIGNAGE_NATIVE_RENDERER, contributionVersion: "1.0.0" } + : options.registry.rendererFor(input.providerId); + const id = newId(); + const sequence = await options.store.takeSelectionSequence(input.group.group.id); + await options.store.insertSelection({ + availableUntil: input.resolution.availableUntil, + deadlineAt: new Date(input.startedAtMs + durationMs), + durationMs, + groupId: input.group.group.id, + id, + itemId: input.resolution.itemId, + itemRevision: input.resolution.itemRevision, + presentation: input.resolution.presentation ?? null, + providerId: input.providerId, + rendererContributionId: renderer?.contributionId ?? null, + rendererContributionVersion: renderer?.contributionVersion ?? null, + selectionScope: input.selectionScope, + sequence, + slotId: input.slot.id, + stale: false, + startedAt: new Date(input.startedAtMs), + timelineId: input.timeline.timeline.id, + variants: variantsOf( + input.resolution, + input.group.channels.map((channel) => channel.key), + ), + withdrawn: false, + }); + await options.store.pruneSelections( + input.group.group.id, + options.settings.playbackHistoryLimit, + ); + const stored = await options.store.findSelection(id); + if (!stored) { + throw new SignageError("SIGNAGE_OPERATION_FAILED"); + } + return stored; + } + + function transportOf(state: { + readonly paused: boolean; + readonly staticAssetId: string | null; + readonly currentSelectionId: string | null; + }): StoredTransport { + if (state.staticAssetId !== null) { + return "static"; + } + if (state.paused) { + return "paused"; + } + return state.currentSelectionId === null ? "idle" : "playing"; + } + + async function buildSnapshot(groupId: string, atMs: number): Promise { + const detail = await requireGroup(groupId); + const state = detail.playback; + const selection = + state.currentSelectionId === null + ? undefined + : await options.store.findSelection(state.currentSelectionId); + const cursor = { + deadlineAtMs: state.slotDeadlineAt?.getTime() ?? null, + slotId: state.currentSlotId, + startedAtMs: state.slotStartedAt?.getTime() ?? null, + }; + return { + activeTimelineId: state.activeTimelineId, + blackout: state.blackout, + currentSelectionId: state.currentSelectionId, + currentSlotId: state.currentSlotId, + deadlineAtMs: cursor.deadlineAtMs, + groupId, + pendingTimelineId: state.pendingTimelineId, + remainingMs: + state.paused || state.staticAssetId !== null + ? state.pausedRemainingMs + : remainingMs(cursor, atMs), + revision: state.revision, + scheduledActivationAtMs: state.scheduledActivationAt?.getTime() ?? null, + scheduledTimelineId: state.scheduledTimelineId, + selectionRevision: selection?.revision ?? null, + serverNowMs: atMs, + startedAtMs: cursor.startedAtMs, + staticAssetId: state.staticAssetId, + transport: transportOf(state), + }; + } + + function armTimer(groupId: string, boundaryMs: number | null, atMs: number): void { + const existing = timers.get(groupId); + if (existing !== undefined) { + clearTimer(existing); + timers.delete(groupId); + } + if (!running || boundaryMs === null) { + return; + } + const delay = Math.min(MAX_TIMER_MS, Math.max(0, boundaryMs - atMs)); + timers.set( + groupId, + setTimer(() => { + timers.delete(groupId); + void reconcile(groupId, "timer").catch(() => { + // A failed reconciliation must not stop the clock. The next command or the next arm + // recovers, and nothing about the failure reaches a screen. + armTimer(groupId, atMs + 1000, atMs); + }); + }, delay), + ); + } + + function publish(snapshot: PlaybackSnapshot, reason: PlaybackReason): void { + try { + options.onSnapshot?.(snapshot, reason); + } catch { + // Observability must never change committed playback state. + } + } + + /** + * Walks one group's clock forward to `atMs`. + * + * The order is deliberate: a due scheduled activation first (it is the operator's intention for + * this instant), then held states, then the pending activation at a boundary, then the ordinary + * advance. Doing the advance first would spend one slide of the old timeline after the moment the + * new one was supposed to start. + */ + async function reconcileLocked( + groupId: string, + atMs: number, + reason: PlaybackReason, + ): Promise { + let detail = await requireGroup(groupId); + let state = detail.playback; + + // A scheduled activation is applied at its own instant, not at the moment the timer happened to + // fire, so a late callback does not shift the service. + if (state.scheduledTimelineId !== null && state.scheduledActivationAt !== null) { + const dueAtMs = state.scheduledActivationAt.getTime(); + if (dueAtMs <= atMs) { + await options.store.updatePlayback(groupId, { + activeTimelineId: state.scheduledTimelineId, + currentSelectionId: null, + currentSlotId: null, + pendingRequestedAt: null, + pendingTimelineId: null, + scheduledActivationAt: null, + scheduledTimelineId: null, + slotDeadlineAt: null, + slotStartedAt: null, + }); + detail = await requireGroup(groupId); + state = detail.playback; + } + } + + if (state.activeTimelineId === null) { + const snapshot = await buildSnapshot(groupId, atMs); + armTimer(groupId, state.scheduledActivationAt?.getTime() ?? null, atMs); + publish(snapshot, reason); + return snapshot; + } + + // Paused and static hold both freeze the cursor. The hidden timeline does not advance, and the + // remainder is preserved exactly so resuming continues the same slide. + if (state.paused || state.staticAssetId !== null) { + const snapshot = await buildSnapshot(groupId, atMs); + armTimer(groupId, state.scheduledActivationAt?.getTime() ?? null, atMs); + publish(snapshot, reason); + return snapshot; + } + + let timeline = await options.store.findTimeline(state.activeTimelineId); + if (!timeline) { + // The active timeline was deleted out from under playback. Nothing can be shown, and inventing + // content would be worse than saying so. + await options.store.updatePlayback(groupId, { + activeTimelineId: null, + currentSelectionId: null, + currentSlotId: null, + slotDeadlineAt: null, + slotStartedAt: null, + }); + const snapshot = await buildSnapshot(groupId, atMs); + publish(snapshot, reason); + return snapshot; + } + + let cursor = { + deadlineAtMs: state.slotDeadlineAt?.getTime() ?? null, + slotId: state.currentSlotId, + startedAtMs: state.slotStartedAt?.getTime() ?? null, + }; + + // A queued activation takes effect at the first boundary, which is the whole point of asking for + // one: an operator who wanted it now would have said now. + if (state.pendingTimelineId !== null) { + const boundaryMs = cursor.deadlineAtMs; + if (boundaryMs === null || boundaryMs <= atMs) { + const pending = await options.store.findTimeline(state.pendingTimelineId); + await options.store.updatePlayback(groupId, { + activeTimelineId: state.pendingTimelineId, + currentSelectionId: null, + currentSlotId: null, + pendingRequestedAt: null, + pendingTimelineId: null, + slotDeadlineAt: null, + slotStartedAt: null, + }); + detail = await requireGroup(groupId); + state = detail.playback; + if (!pending) { + const snapshot = await buildSnapshot(groupId, atMs); + publish(snapshot, reason); + return snapshot; + } + timeline = pending; + cursor = { + deadlineAtMs: null, + slotId: null, + startedAtMs: boundaryMs === null ? atMs : boundaryMs, + }; + } + } + + const timing = timingTimelineFor(timeline); + // Bounded: every slot may legitimately have nothing available, and one pass proves it. + const attempts = Math.max(1, timeline.slots.length); + for (let attempt = 0; attempt <= attempts; attempt += 1) { + const advance = advanceCursor(timing, cursor, atMs); + + if (advance.kind === "hold") { + const snapshot = await buildSnapshot(groupId, atMs); + armTimer(groupId, nextTimingBoundary(timing, cursor, atMs), atMs); + publish(snapshot, reason); + return snapshot; + } + + if (advance.kind === "empty") { + await options.store.updatePlayback(groupId, { + currentSelectionId: null, + currentSlotId: null, + slotDeadlineAt: null, + slotStartedAt: null, + }); + const snapshot = await buildSnapshot(groupId, atMs); + armTimer(groupId, nextTimingBoundary(timing, cursor, atMs), atMs); + publish(snapshot, reason); + return snapshot; + } + + const slot = timeline.slots.find((candidate) => candidate.id === advance.slotId); + if (!slot) { + break; + } + const resolved = await resolveSlot(detail, timeline, slot, advance.startedAtMs); + if (!resolved) { + // Nothing available for this slot right now. Skip it **immediately** — no error, no gap, no + // slate — by continuing from exactly where it would have started. + cursor = { + deadlineAtMs: advance.startedAtMs, + slotId: slot.id, + startedAtMs: advance.startedAtMs, + }; + continue; + } + + const selection = await recordSelection({ + group: detail, + providerId: resolved.providerId, + resolution: resolved.resolution, + selectionScope: resolved.selectionScope, + slot, + startedAtMs: advance.startedAtMs, + timeline, + }); + await options.store.updatePlayback(groupId, { + currentSelectionId: selection.id, + currentSlotId: slot.id, + pausedRemainingMs: null, + slotDeadlineAt: selection.deadlineAt, + slotStartedAt: selection.startedAt, + }); + const snapshot = await buildSnapshot(groupId, atMs); + armTimer( + groupId, + nextTimingBoundary( + timing, + { + deadlineAtMs: selection.deadlineAt?.getTime() ?? null, + slotId: slot.id, + startedAtMs: selection.startedAt?.getTime() ?? null, + }, + atMs, + ), + atMs, + ); + publish(snapshot, reason); + return snapshot; + } + + // Every eligible slot had nothing available. The display shows its slate; nothing is invented. + await options.store.updatePlayback(groupId, { + currentSelectionId: null, + currentSlotId: null, + slotDeadlineAt: null, + slotStartedAt: null, + }); + const snapshot = await buildSnapshot(groupId, atMs); + armTimer(groupId, nextTimingBoundary(timing, cursor, atMs), atMs); + publish(snapshot, reason); + return snapshot; + } + + function reconcile(groupId: string, reason: PlaybackReason = "timer"): Promise { + return withGroupLock(groupId, () => reconcileLocked(groupId, now(), reason)); + } + + /** Re-resolves one already-showing selection **without touching its deadline**. This is what turns + * a cancelled event into an updated React slide rather than a restarted slot. */ + async function refreshCurrent( + groupId: string, + atMs: number, + ): Promise<{ readonly changed: boolean }> { + const detail = await requireGroup(groupId); + const selectionId = detail.playback.currentSelectionId; + if (selectionId === null) { + return { changed: false }; + } + const selection = await options.store.findSelection(selectionId); + if (!selection || selection.timelineId === null) { + return { changed: false }; + } + const timeline = await options.store.findTimeline(selection.timelineId); + if (!timeline) { + return { changed: false }; + } + const outcome = await options.registry.refresh(selection.providerId, { + at: new Date(atMs).toISOString(), + audienceKeys: detail.channels.map((channel) => channel.key), + channelGroupId: groupId, + itemId: selection.itemId, + timelineId: timeline.timeline.id, + timezone: options.settings.timezone, + ...(selection.selectionScope === null ? {} : { selectionScopeId: selection.selectionScope }), + }); + + if (outcome.kind === "failed" || outcome.kind === "unregistered") { + // A temporary failure keeps the last known good item and marks it stale. It never resets the + // slot, and it never erases what was already persisted. + if (!selection.stale) { + await options.store.updateSelection(selectionId, { bumpRevision: true, stale: true }); + return { changed: true }; + } + return { changed: false }; + } + + if (!outcome.resolution) { + // Nothing available now. The current frame stays; the slot ends on its own deadline. + return { changed: false }; + } + if (outcome.resolution.withdrawn) { + // Explicitly gone. Marked, and removed at the next reconciliation boundary rather than + // mid-slide. + await options.store.updateSelection(selectionId, { bumpRevision: true, withdrawn: true }); + return { changed: true }; + } + if ( + outcome.resolution.itemRevision === selection.itemRevision && + !selection.stale && + outcome.resolution.itemId === selection.itemId + ) { + // Nothing semantically changed: no revision bump, so no client is woken by nothing. + return { changed: false }; + } + + await options.store.updateSelection(selectionId, { + availableUntil: outcome.resolution.availableUntil, + bumpRevision: true, + itemRevision: outcome.resolution.itemRevision, + presentation: outcome.resolution.presentation ?? null, + stale: false, + variants: variantsOf( + outcome.resolution, + detail.channels.map((channel) => channel.key), + ), + }); + return { changed: true }; + } + + const service: SignagePlaybackService = { + async activate( + groupId: string, + timelineId: string, + when: "now" | "next-boundary" | { readonly at: Date }, + ): Promise { + return withGroupLock(groupId, async () => { + const atMs = now(); + const detail = await requireGroup(groupId); + const timeline = await options.store.findTimeline(timelineId); + if (!timeline) { + throw new SignageError("SIGNAGE_NOT_FOUND"); + } + + if (typeof when === "object") { + if (when.at.getTime() <= atMs) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "at" }); + } + await options.store.updatePlayback(groupId, { + scheduledActivationAt: when.at, + scheduledTimelineId: timelineId, + }); + return reconcileLocked(groupId, atMs, "activated"); + } + + if (when === "next-boundary" && detail.playback.activeTimelineId !== null) { + if (detail.playback.activeTimelineId === timelineId) { + // Already what is running. An exact no-op writes nothing. + return buildSnapshot(groupId, atMs); + } + await options.store.updatePlayback(groupId, { + pendingRequestedAt: new Date(atMs), + pendingTimelineId: timelineId, + }); + return reconcileLocked(groupId, atMs, "activated"); + } + + await options.store.updatePlayback(groupId, { + activeTimelineId: timelineId, + currentSelectionId: null, + currentSlotId: null, + pausedRemainingMs: null, + pendingRequestedAt: null, + pendingTimelineId: null, + slotDeadlineAt: null, + slotStartedAt: null, + }); + return reconcileLocked(groupId, atMs, "activated"); + }); + }, + + async ensureDefaultGroup(): Promise { + const existing = await options.store.listChannelGroups(); + const first = existing[0]; + if (first) { + return first; + } + const groupId = newId(); + await options.store.createChannelGroup({ + channelId: newId(), + channelName: "Default", + groupId, + name: "Main", + }); + return requireGroup(groupId); + }, + + async invalidate(notice): Promise { + const groups = await options.store.listChannelGroups(); + for (const group of groups) { + await withGroupLock(group.group.id, async () => { + const atMs = now(); + const detail = await requireGroup(group.group.id); + const selectionId = detail.playback.currentSelectionId; + if (selectionId === null) { + return; + } + const selection = await options.store.findSelection(selectionId); + if (!selection || selection.providerId !== notice.providerId) { + return; + } + if (notice.itemId !== undefined && selection.itemId !== notice.itemId) { + return; + } + if ( + notice.sourceId !== undefined && + selection.selectionScope !== null && + notice.itemId === undefined + ) { + // A source-wide notification is about what comes next as much as what is showing, so the + // current item is refreshed rather than assumed unaffected. + } + const result = await refreshCurrent(group.group.id, atMs); + if (result.changed) { + publish(await buildSnapshot(group.group.id, atMs), "invalidated"); + } + }); + } + }, + + async next(groupId: string): Promise { + return withGroupLock(groupId, async () => { + const atMs = now(); + const detail = await requireGroup(groupId); + // Ending the current slot *now* is what "next" means; reconciliation then does the ordinary + // work of choosing what follows, so skipping, wraparound, and eligibility all still apply. + await options.store.updatePlayback(groupId, { + pausedRemainingMs: null, + slotDeadlineAt: new Date(atMs), + slotStartedAt: detail.playback.slotStartedAt ?? new Date(atMs), + }); + return reconcileLocked(groupId, atMs, "next"); + }); + }, + + async pause(groupId: string): Promise { + return withGroupLock(groupId, async () => { + const atMs = now(); + const detail = await requireGroup(groupId); + if (detail.playback.paused) { + return buildSnapshot(groupId, atMs); + } + const remaining = remainingMs( + { + deadlineAtMs: detail.playback.slotDeadlineAt?.getTime() ?? null, + slotId: detail.playback.currentSlotId, + startedAtMs: detail.playback.slotStartedAt?.getTime() ?? null, + }, + atMs, + ); + // The remainder is preserved and the window cleared, so nothing about the frozen slide can + // be mistaken for a running one. + await options.store.updatePlayback(groupId, { + paused: true, + pausedRemainingMs: remaining, + slotDeadlineAt: null, + slotStartedAt: null, + }); + const snapshot = await buildSnapshot(groupId, atMs); + armTimer(groupId, null, atMs); + publish(snapshot, "paused"); + return snapshot; + }); + }, + + async previous(groupId: string): Promise { + return withGroupLock(groupId, async () => { + const atMs = now(); + const detail = await requireGroup(groupId); + const history = await options.store.listSelections( + groupId, + options.settings.playbackHistoryLimit, + ); + const currentIndex = history.findIndex( + (selection) => selection.id === detail.playback.currentSelectionId, + ); + // `previous` walks **actual resolved playback**, not the slot order: what was on screen + // before is what an operator means, including a slot that was skipped for being empty. + const target = history[currentIndex + 1]; + if (!target) { + return buildSnapshot(groupId, atMs); + } + // The same occurrence is re-anchored rather than duplicated, so pressing previous twice + // continues backwards instead of oscillating. + await options.store.updateSelection(target.id, { + deadlineAt: new Date(atMs + target.durationMs), + startedAt: new Date(atMs), + }); + await options.store.updatePlayback(groupId, { + currentSelectionId: target.id, + currentSlotId: target.slotId, + pausedRemainingMs: null, + slotDeadlineAt: new Date(atMs + target.durationMs), + slotStartedAt: new Date(atMs), + }); + const snapshot = await buildSnapshot(groupId, atMs); + armTimer(groupId, atMs + target.durationMs, atMs); + publish(snapshot, "previous"); + return snapshot; + }); + }, + + async reconcile(groupId: string, reason: PlaybackReason = "timer"): Promise { + return reconcile(groupId, reason); + }, + + async reload(groupId: string): Promise { + return withGroupLock(groupId, async () => { + const atMs = now(); + // The current frame and its deadline are deliberately untouched: reload refreshes what is + // showing and what comes next, and an operator pressing it must not see the wall blink. + await refreshCurrent(groupId, atMs); + const snapshot = await buildSnapshot(groupId, atMs); + publish(snapshot, "reload"); + return snapshot; + }); + }, + + async resume(groupId: string): Promise { + return withGroupLock(groupId, async () => { + const atMs = now(); + const detail = await requireGroup(groupId); + if (!detail.playback.paused && detail.playback.staticAssetId === null) { + return buildSnapshot(groupId, atMs); + } + const selection = + detail.playback.currentSelectionId === null + ? undefined + : await options.store.findSelection(detail.playback.currentSelectionId); + const window = resumeWindow( + atMs, + detail.playback.pausedRemainingMs ?? selection?.durationMs ?? 0, + selection?.durationMs ?? 0, + ); + await options.store.updatePlayback(groupId, { + paused: false, + pausedRemainingMs: null, + slotDeadlineAt: selection ? new Date(window.deadlineAtMs) : null, + slotStartedAt: selection ? new Date(window.startedAtMs) : null, + staticAssetId: null, + }); + return reconcileLocked(groupId, atMs, "resumed"); + }); + }, + + async setBlackout(groupId: string, on: boolean): Promise { + return withGroupLock(groupId, async () => { + const atMs = now(); + const detail = await requireGroup(groupId); + if (detail.playback.blackout === on) { + return buildSnapshot(groupId, atMs); + } + // Blackout covers the output and changes nothing else: the hidden timeline keeps its + // deadlines, keeps advancing, and keeps resolving, so clearing it lands where it would have. + await options.store.updatePlayback(groupId, { blackout: on }); + const snapshot = await buildSnapshot(groupId, atMs); + publish(snapshot, "blackout"); + return snapshot; + }); + }, + + async setStaticHold(groupId: string, assetId: string | null): Promise { + return withGroupLock(groupId, async () => { + const atMs = now(); + const detail = await requireGroup(groupId); + if (assetId === null) { + if (detail.playback.staticAssetId === null) { + return buildSnapshot(groupId, atMs); + } + const selection = + detail.playback.currentSelectionId === null + ? undefined + : await options.store.findSelection(detail.playback.currentSelectionId); + const window = resumeWindow( + atMs, + detail.playback.pausedRemainingMs ?? selection?.durationMs ?? 0, + selection?.durationMs ?? 0, + ); + await options.store.updatePlayback(groupId, { + pausedRemainingMs: null, + slotDeadlineAt: selection ? new Date(window.deadlineAtMs) : null, + slotStartedAt: selection ? new Date(window.startedAtMs) : null, + staticAssetId: null, + }); + return reconcileLocked(groupId, atMs, "static"); + } + + const image = await options.store.findImage(assetId); + if (image?.status !== "ready") { + throw new SignageError("SIGNAGE_NOT_FOUND"); + } + if (image.expiresAt !== null && image.expiresAt.getTime() <= atMs) { + throw new SignageError("SIGNAGE_CONFLICT", { reason: "expired" }); + } + // The normal cursor and its remainder are preserved underneath, which is exactly what makes + // clearing a hold continue the same slide rather than restart the loop. + const remaining = remainingMs( + { + deadlineAtMs: detail.playback.slotDeadlineAt?.getTime() ?? null, + slotId: detail.playback.currentSlotId, + startedAtMs: detail.playback.slotStartedAt?.getTime() ?? null, + }, + atMs, + ); + await options.store.updatePlayback(groupId, { + pausedRemainingMs: detail.playback.pausedRemainingMs ?? remaining, + slotDeadlineAt: null, + slotStartedAt: null, + staticAssetId: assetId, + }); + const snapshot = await buildSnapshot(groupId, atMs); + armTimer(groupId, null, atMs); + publish(snapshot, "static"); + return snapshot; + }); + }, + + async snapshot(groupId: string): Promise { + return buildSnapshot(groupId, now()); + }, + + async start(): Promise { + running = true; + const groups = await options.store.listChannelGroups(); + for (const group of groups) { + await reconcile(group.group.id, "startup"); + } + }, + + stop(): void { + running = false; + for (const handle of timers.values()) { + clearTimer(handle); + } + timers.clear(); + }, + }; + + return service; +} diff --git a/src/server/providers/contract.ts b/src/server/providers/contract.ts new file mode 100644 index 0000000..c2f3cf5 --- /dev/null +++ b/src/server/providers/contract.ts @@ -0,0 +1,201 @@ +/** + * The `signage.content-providers` contract, version 1 (ADR 0002). + * + * This is the seam every future content module reaches Signage through: Announcements, Scripture, + * Trivia, and anything else a church installs. It is the one contract in this repository with + * implementations Signage's authors will never see, so it is written to be implementable from the + * documentation alone. + * + * ## A provider does not import this file + * + * Modules are independently installed peers, and `pnpm boundaries` refuses a module that imports + * another module. A provider therefore resolves the capability, receives an `unknown` + * implementation, and casts it to **its own local copy** of these declarations — which is what keeps + * the two modules independently versioned and separately releasable. `docs/provider-contract.md` + * publishes the shapes for exactly that purpose. + * + * ## Signage validates what it receives, always + * + * Everything crossing this boundary is validated before it is believed: registrations, resolutions, + * payloads, and durations. A provider is trusted code in the same process, so this is not a sandbox + * — it is the difference between one module's bug staying one module's bug and a malformed value + * reaching a television. + */ + +/** The contract version Signage implements, and the only one it accepts a registration for. */ +export const SIGNAGE_PROVIDER_CONTRACT_VERSION = "1.0.0"; + +/** Major-version compatibility: a provider written against `1.x` works, `2.x` does not. Stated as a + * predicate rather than a range string so no semantic-version parser has to cross this boundary. */ +export function isSupportedProviderContractVersion(version: string): boolean { + return /^1\.\d+\.\d+$/u.test(version); +} + +export type SignageJsonValue = + | boolean + | number + | string + | null + | readonly SignageJsonValue[] + | { readonly [key: string]: SignageJsonValue }; + +/** The audience key of the base channel every group has. A provider that offers nothing + * audience-specific returns exactly one variant, under this key. */ +export const SIGNAGE_DEFAULT_AUDIENCE_KEY = "default"; + +/** One of a provider's own sources, as an operator picks it in the timeline editor. */ +export interface SignageProviderSourceDescriptor { + /** The provider's own identity for this source. Opaque to Signage. */ + readonly id: string; + /** What an operator sees. Signage stores a copy so the editor still reads sensibly when the + * provider is temporarily not running. */ + readonly label: string; + readonly description?: string; +} + +/** One specific item, so an operator can pin it into a slot. */ +export interface SignageProviderItemDescriptor { + readonly id: string; + readonly label: string; + readonly description?: string; + /** When this item stops being available, as an absolute ISO 8601 instant. */ + readonly availableUntil?: string | null; +} + +/** One audience's rendering of one logical selection. */ +export interface SignageProviderVariant { + /** `null` — or {@link SIGNAGE_DEFAULT_AUDIENCE_KEY} — is the default every channel falls back to. */ + readonly audienceKey: string | null; + /** Structured, finite JSON. Signage validates that it *is* JSON and never what it means. */ + readonly payload: SignageJsonValue; +} + +/** + * What a provider hands back for one slot occurrence: **one logical item**, never one per channel. + * + * Resolving once and deriving variants is what keeps every channel in a group on the same slide with + * the same deadline, and what makes an audience a rendering rather than a second program (ADR 0004). + */ +export interface SignageProviderResolution { + /** The provider's own stable identity for this content. */ + readonly itemId: string; + /** Changes whenever the payload changes and the item stays the same thing. */ + readonly itemRevision: string; + /** Overrides this provider's default duration for this one item. */ + readonly durationMs?: number; + /** When this selection stops being valid, as an absolute ISO 8601 instant. */ + readonly availableUntil?: string | null; + /** This item is explicitly gone. Signage removes it at the next safe reconciliation boundary + * rather than immediately, so a screen does not cut mid-slide. */ + readonly withdrawn?: boolean; + /** Presentation values the provider supplies to its **own** renderer. Opaque to Signage, which + * never interprets, merges, or overrides them. */ + readonly presentation?: SignageJsonValue; + /** At least the default variant. */ + readonly variants: readonly SignageProviderVariant[]; +} + +/** What Signage tells a provider when it asks for the next item. Deliberately nothing more: a + * provider is already running in this process, and what it needs from Signage is *when*. */ +export interface SignageProviderResolveRequest { + /** Stable and opaque. Every slot referencing one source instance passes the same value, which is + * what makes five Announcement slots one rotation rather than five (ADR 0003). */ + readonly selectionScopeId: string; + /** Which of the provider's own sources is being asked. */ + readonly sourceId: string; + readonly timelineId: string; + readonly channelGroupId: string; + /** Every audience channel in the group, in a stable order. */ + readonly audienceKeys: readonly string[]; + /** The instant being resolved for, as an absolute ISO 8601 instant. */ + readonly at: string; + /** The installation's IANA zone, for a provider whose availability is calendar-shaped. */ + readonly timezone: string; +} + +/** What Signage tells a provider when it wants one already-known item resolved again — a pinned + * slot's item, or a live refresh of what is currently on screen. */ +export interface SignageProviderRefreshRequest + extends Omit { + readonly itemId: string; + /** Present when the item came from a source; absent for a pinned item. */ + readonly sourceId?: string; + readonly selectionScopeId?: string; +} + +export interface SignageProviderDiscoveryRequest { + readonly at: string; + readonly timezone: string; +} + +/** The renderer a provider's content is drawn by: a browser contribution the platform publishes + * against Signage's `signage.slide-renderer` capability (ADR 0041). Signage never imports it; its + * display asks the host to mount it by id. */ +export interface SignageProviderRenderer { + readonly contributionId: string; + readonly contributionVersion: string; +} + +/** + * What a provider module passes to `register`. + * + * Every function may reject or throw. Signage bounds each call with a deadline, catches everything, + * and converts the outcome into one of three answers — nothing available, explicitly withdrawn, or + * temporarily failed — which is the distinction the whole failure model rests on (ADR 0002). + */ +export interface SignageProviderRegistration { + readonly providerId: string; + readonly providerVersion: string; + /** Which version of this contract the provider was written against. */ + readonly contractVersion: string; + readonly renderer: SignageProviderRenderer; + /** How long this provider's content is shown when no slot overrides it. */ + readonly defaultDurationMs: number; + listSources( + request: SignageProviderDiscoveryRequest, + ): + | Promise + | readonly SignageProviderSourceDescriptor[]; + /** Optional. Without it, an operator cannot pin one of this provider's items. */ + listItems?( + request: SignageProviderDiscoveryRequest & { readonly sourceId?: string }, + ): Promise | readonly SignageProviderItemDescriptor[]; + /** **The only call that advances anything.** `null` means nothing is available for this source + * right now, and Signage skips the slot immediately with no error and no slate. */ + resolveNext( + request: SignageProviderResolveRequest, + ): Promise | SignageProviderResolution | null; + /** Re-resolves one already-known item without advancing. */ + refresh( + request: SignageProviderRefreshRequest, + ): Promise | SignageProviderResolution | null; +} + +/** What a provider may tell Signage has changed. A **notification**, never a command: Signage + * decides what to refresh and when, and nothing here can skip a slot or move a deadline. */ +export interface SignageProviderInvalidation { + /** Limits the notification to one of the provider's sources. */ + readonly sourceId?: string; + /** Limits it to one item — a cancelled event, a changed countdown. */ + readonly itemId?: string; +} + +/** What `register` returns. Held by the provider module and released in its own `stop` hook. */ +export interface SignageProviderHandle { + /** Idempotent. Unregistering an already-unregistered provider is a no-op, which is what makes a + * rollback after a partially completed `start` safe. */ + unregister(): void; + /** Notifies Signage that something it already resolved may have changed. Never throws for a + * provider that has been unregistered — a late notification from a module being torn down is + * ordinary, not an error. */ + invalidate(change?: SignageProviderInvalidation): void; +} + +/** The capability implementation Signage provides as `signage.content-providers`. */ +export interface SignageContentProviderCapability { + readonly contractVersion: string; + /** Takes `unknown` deliberately: the caller is another module with its own copy of these types, + * and Signage validates the value rather than trusting a structural type it cannot enforce across + * a module boundary. */ + register(registration: unknown): SignageProviderHandle; +} diff --git a/src/server/providers/native-provider.ts b/src/server/providers/native-provider.ts new file mode 100644 index 0000000..6a74759 --- /dev/null +++ b/src/server/providers/native-provider.ts @@ -0,0 +1,210 @@ +import { SIGNAGE_NATIVE_PROVIDER_ID, SIGNAGE_NATIVE_RENDERER } from "../../shared/native-images.js"; +import type { SignageStore, StoredImage } from "../store/types.js"; +import type { + SignageProviderDiscoveryRequest, + SignageProviderItemDescriptor, + SignageProviderRefreshRequest, + SignageProviderRegistration, + SignageProviderResolution, + SignageProviderResolveRequest, + SignageProviderSourceDescriptor, +} from "./contract.js"; +import { SIGNAGE_PROVIDER_CONTRACT_VERSION } from "./contract.js"; + +/** + * Signage's own content provider. + * + * It implements the identical registration shape an external provider implements, registers through + * the identical capability, and resolves through the identical code path (ADR 0001). That is the + * whole point: there is **one** selection pipeline, so history, `previous`, restart recovery, + * audience variants, and offline continuation are written once rather than twice and drifting. + * + * Two things are special about it, and only two: + * + * - Its renderer is `signage.native-image`, which is not a browser contribution at all. A display + * that sees that identity paints an image element itself instead of asking the host to mount + * anything, because the content is Signage's own. + * - Its payload shape is Signage's own, so the display-state route may read it — to mint fresh + * delivery URLs from the stored object keys. Every *other* provider's payload stays opaque. + */ + +/** The one source Signage's own provider offers: every ready, currently-available image, in the + * order they were added, cycling forever. A pinned slot does not use this at all. */ +export const SIGNAGE_NATIVE_LIBRARY_SOURCE = "library"; + +/** What a native-image selection carries. The renditions are **object keys**, not URLs: a URL is a + * bearer capability with an expiry, minted when something is about to be delivered and never + * stored (ADR 0007). */ +// A `type` rather than an `interface` on purpose: TypeScript grants a type alias an implicit index +// signature, so this shape is assignable to `SignageJsonValue` without a cast — which is the +// compiler proving that everything Signage's own provider emits really is finite JSON. +export type SignageNativeImagePayload = { + readonly kind: "native-image"; + readonly imageId: string; + readonly displayName: string; + readonly width: number; + readonly height: number; + readonly renditionKeys: { readonly display: string; readonly thumbnail: string }; +}; + +function isAvailable(image: StoredImage, at: Date): boolean { + if (image.status !== "ready") { + return false; + } + // Half-open: available up to but not including the expiration instant. + return image.expiresAt === null || at < image.expiresAt; +} + +function payloadFor(image: StoredImage): SignageNativeImagePayload { + const display = image.renditions.find((rendition) => rendition.kind === "display"); + const thumbnail = image.renditions.find((rendition) => rendition.kind === "thumbnail"); + return { + displayName: image.displayName, + height: image.height, + imageId: image.id, + kind: "native-image", + renditionKeys: { + display: display?.objectKey ?? image.originalObjectKey, + thumbnail: thumbnail?.objectKey ?? image.originalObjectKey, + }, + width: image.width, + }; +} + +function resolutionFor(image: StoredImage): SignageProviderResolution { + return { + availableUntil: image.expiresAt?.toISOString() ?? null, + // The image's own content hash is its revision: different bytes are a different asset, so a + // rendition can never change underneath a revision that stayed the same. + itemId: image.id, + itemRevision: image.contentSha256.slice(0, 32), + // One variant, under the default key. Native images have no audience-specific rendering: a + // church's slide is the same slide in the room and online. + variants: [{ audienceKey: null, payload: payloadFor(image) }], + }; +} + +export interface NativeImageProviderOptions { + readonly store: SignageStore; + readonly defaultDurationMs: number; + readonly now?: () => Date; +} + +/** + * Builds the registration for Signage's built-in image provider. + * + * The library source's rotation cursor is held **in memory, per selection scope**. That is a + * deliberate simplification with a stated cost: a restart resumes the library rotation from the + * image that last played in that scope when Signage can tell it — which it can, because it persists + * every resolved selection — and from the beginning when it cannot. The current slide itself is + * unaffected either way, because that is persisted authoritative state rather than a cursor. + */ +export interface NativeImageProvider { + readonly registration: SignageProviderRegistration; + /** Re-seeds a scope's rotation from what actually played last, so a restart continues the library + * where it left off rather than at the beginning. */ + seedCursor(selectionScopeId: string, itemId: string): void; +} + +export function createNativeImageProvider( + options: NativeImageProviderOptions, +): NativeImageProvider { + const now = options.now ?? (() => new Date()); + const cursors = new Map(); + + async function available(at: Date): Promise { + const images = await options.store.listImages(); + return ( + images + .filter((image) => isAvailable(image, at)) + // Oldest first, so a rotation an operator watched yesterday runs in the same order today. + .slice() + .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()) + ); + } + + const registration: SignageProviderRegistration = { + contractVersion: SIGNAGE_PROVIDER_CONTRACT_VERSION, + defaultDurationMs: options.defaultDurationMs, + + async listItems( + _request: SignageProviderDiscoveryRequest, + ): Promise { + const at = now(); + const images = await available(at); + return images.map((image) => ({ + availableUntil: image.expiresAt?.toISOString() ?? null, + description: `${image.width}×${image.height}`, + id: image.id, + label: image.displayName, + })); + }, + + listSources( + _request: SignageProviderDiscoveryRequest, + ): readonly SignageProviderSourceDescriptor[] { + return [ + { + description: + "Every Signage image that is ready and not expired, in the order it was added.", + id: SIGNAGE_NATIVE_LIBRARY_SOURCE, + label: "All Signage images", + }, + ]; + }, + + providerId: SIGNAGE_NATIVE_PROVIDER_ID, + providerVersion: "1.0.0", + + async refresh( + request: SignageProviderRefreshRequest, + ): Promise { + const image = await options.store.findImage(request.itemId); + if (!image) { + // The operator deleted it. That is a withdrawal, not a failure: the item is genuinely gone, + // and the slot should stop showing it at the next safe boundary rather than keeping a stale + // frame forever. + return { + itemId: request.itemId, + itemRevision: "deleted", + variants: [{ audienceKey: null, payload: { kind: "withdrawn" } }], + withdrawn: true, + }; + } + if (!isAvailable(image, new Date(request.at))) { + // Expired or not yet ready: nothing to show right now, which is a skip rather than an error. + return null; + } + return resolutionFor(image); + }, + + renderer: { contributionId: SIGNAGE_NATIVE_RENDERER, contributionVersion: "1.0.0" }, + + async resolveNext( + request: SignageProviderResolveRequest, + ): Promise { + if (request.sourceId !== SIGNAGE_NATIVE_LIBRARY_SOURCE) { + return null; + } + const images = await available(new Date(request.at)); + if (images.length === 0) { + return null; + } + const previous = cursors.get(request.selectionScopeId); + const index = + previous === undefined ? -1 : images.findIndex((image) => image.id === previous); + // Round robin. An image added or removed since the last turn simply shifts the ring; nothing + // about the cursor is an index into a list that may have changed underneath it. + const next = images[(index + 1) % images.length] as StoredImage; + cursors.set(request.selectionScopeId, next.id); + return resolutionFor(next); + }, + }; + + return { + registration, + seedCursor(selectionScopeId: string, itemId: string): void { + cursors.set(selectionScopeId, itemId); + }, + }; +} diff --git a/src/server/providers/registry.ts b/src/server/providers/registry.ts new file mode 100644 index 0000000..f4c068f --- /dev/null +++ b/src/server/providers/registry.ts @@ -0,0 +1,362 @@ +import type { + SignageContentProviderCapability, + SignageJsonValue, + SignageProviderDiscoveryRequest, + SignageProviderHandle, + SignageProviderInvalidation, + SignageProviderItemDescriptor, + SignageProviderRefreshRequest, + SignageProviderRegistration, + SignageProviderResolveRequest, + SignageProviderSourceDescriptor, +} from "./contract.js"; +import { SIGNAGE_PROVIDER_CONTRACT_VERSION } from "./contract.js"; +import { + type ParsedProviderResolution, + parseItemDescriptors, + parseProviderRegistration, + parseProviderResolution, + parseSourceDescriptors, + SignageProviderError, + type SignageProviderErrorCode, +} from "./validation.js"; + +/** + * The live registry of content providers. + * + * **In memory only, deliberately.** A registration is a running fact, not a stored one: a provider + * that is uninstalled or disabled leaves no row behind claiming it exists, and Signage never + * enumerates provider *modules* — only whatever registered itself while running. + * + * Every call out to a provider goes through {@link callProvider}: a deadline, a `catch`, and + * validation of what came back. A provider cannot crash the Signage runtime, hang playback, corrupt + * a selection, or put its own words on a screen or in a log. + */ + +/** What management sees about one provider. Everything here is either the provider's own declared + * identity or a count Signage itself kept — never a provider message or a caught exception. */ +export interface SignageProviderHealth { + readonly providerId: string; + readonly providerVersion: string; + readonly contractVersion: string; + readonly renderer: { readonly contributionId: string; readonly contributionVersion: string }; + readonly defaultDurationMs: number; + readonly registeredAt: string; + readonly consecutiveFailures: number; + readonly lastFailureCode: SignageProviderErrorCode | null; + readonly lastFailureAt: string | null; + readonly lastSuccessAt: string | null; +} + +/** What Signage does when a provider says something it already resolved may have changed. The + * registry only *reports* the notification; deciding what to refresh, and when, belongs to + * playback, which is the only thing that knows what is on screen. */ +export type SignageInvalidationListener = (notice: { + readonly providerId: string; + readonly sourceId?: string; + readonly itemId?: string; +}) => void; + +export interface SignageProviderRegistryOptions { + /** How long one provider call may take. Signage's own operator setting. */ + readonly timeoutMs: number; + readonly now?: () => Date; + readonly onInvalidate?: SignageInvalidationListener; +} + +interface RegisteredProvider { + readonly registration: SignageProviderRegistration; + readonly registeredAt: Date; + consecutiveFailures: number; + lastFailureCode: SignageProviderErrorCode | null; + lastFailureAt: Date | null; + lastSuccessAt: Date | null; + active: boolean; +} + +export interface SignageProviderRegistry { + /** The capability implementation handed to `capabilities.provide`. */ + readonly capability: SignageContentProviderCapability; + /** Every currently registered provider, in a stable order. */ + health(): readonly SignageProviderHealth[]; + has(providerId: string): boolean; + rendererFor(providerId: string): SignageProviderHealth["renderer"] | undefined; + defaultDurationMs(providerId: string): number | undefined; + listSources( + providerId: string, + request: SignageProviderDiscoveryRequest, + ): Promise; + listItems( + providerId: string, + request: SignageProviderDiscoveryRequest & { readonly sourceId?: string }, + ): Promise; + resolveNext( + providerId: string, + request: SignageProviderResolveRequest, + ): Promise; + refresh(providerId: string, request: SignageProviderRefreshRequest): Promise; + /** Releases every registration. Used when Signage itself stops. */ + clear(): void; +} + +/** + * The three answers a resolution call can produce, kept distinct all the way up (ADR 0002). + * + * Collapsing them would be this design's biggest mistake: an empty pool would show an error, and a + * provider restart would clear the screen. + */ +export type ProviderCallOutcome = + /** The provider answered. `resolution` is `null` when nothing is available right now. */ + | { readonly kind: "resolved"; readonly resolution: ParsedProviderResolution | null } + /** The provider is not registered at all — uninstalled, disabled, or still starting. */ + | { readonly kind: "unregistered" } + /** The provider threw, rejected, timed out, or returned something unusable. Temporary. */ + | { readonly kind: "failed"; readonly code: SignageProviderErrorCode }; + +export function createProviderRegistry( + options: SignageProviderRegistryOptions, +): SignageProviderRegistry { + const now = options.now ?? (() => new Date()); + const providers = new Map(); + + function recordSuccess(entry: RegisteredProvider): void { + entry.consecutiveFailures = 0; + entry.lastFailureCode = null; + entry.lastSuccessAt = now(); + } + + function recordFailure(entry: RegisteredProvider, code: SignageProviderErrorCode): void { + entry.consecutiveFailures += 1; + entry.lastFailureCode = code; + entry.lastFailureAt = now(); + } + + /** + * One bounded, sanitized call into a provider. + * + * The deadline is what stops a provider that hangs from stopping a channel group: playback is + * serialized per group, so a call that never settles would freeze the wall. The timer never + * rejects the provider's own promise — a late answer is simply ignored, because cancelling + * another module's work is not something this contract can express. + */ + async function callProvider( + entry: RegisteredProvider, + invoke: () => Promise | Result, + ): Promise<{ ok: true; value: Result } | { ok: false; code: SignageProviderErrorCode }> { + let timer: ReturnType | undefined; + try { + const value = await Promise.race([ + Promise.resolve().then(invoke), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new SignageProviderError("SIGNAGE_PROVIDER_UNAVAILABLE")), + options.timeoutMs, + ); + timer.unref?.(); + }), + ]); + recordSuccess(entry); + return { ok: true, value }; + } catch (error) { + // Whatever the provider threw is discarded here. Its message, stack, and cause never reach a + // log, a response, or a screen. + const code = + error instanceof SignageProviderError && error.code === "SIGNAGE_PROVIDER_RESULT_INVALID" + ? "SIGNAGE_PROVIDER_RESULT_INVALID" + : "SIGNAGE_PROVIDER_UNAVAILABLE"; + recordFailure(entry, code); + return { code, ok: false }; + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } + } + + function active(providerId: string): RegisteredProvider | undefined { + const entry = providers.get(providerId); + return entry?.active === true ? entry : undefined; + } + + const capability: SignageContentProviderCapability = Object.freeze({ + contractVersion: SIGNAGE_PROVIDER_CONTRACT_VERSION, + + register(value: unknown): SignageProviderHandle { + const registration = parseProviderRegistration(value); + const existing = providers.get(registration.providerId); + if (existing?.active === true) { + throw new SignageProviderError("SIGNAGE_PROVIDER_DUPLICATE", registration.providerId); + } + + const entry: RegisteredProvider = { + active: true, + consecutiveFailures: 0, + lastFailureAt: null, + lastFailureCode: null, + lastSuccessAt: null, + registeredAt: now(), + registration, + }; + providers.set(registration.providerId, entry); + + return Object.freeze({ + invalidate(change?: SignageProviderInvalidation): void { + // A late notification from a module being torn down is ordinary, not an error. + if (!entry.active) { + return; + } + options.onInvalidate?.({ + providerId: registration.providerId, + ...(typeof change?.sourceId === "string" ? { sourceId: change.sourceId } : {}), + ...(typeof change?.itemId === "string" ? { itemId: change.itemId } : {}), + }); + }, + unregister(): void { + // Idempotent, which is what makes a rollback after a partially completed `start` safe: + // the hook that failed runs its own cleanup, and reverse-order shutdown runs it again. + if (!entry.active) { + return; + } + entry.active = false; + providers.delete(registration.providerId); + }, + }); + }, + }); + + return { + capability, + + clear(): void { + for (const entry of providers.values()) { + entry.active = false; + } + providers.clear(); + }, + + defaultDurationMs(providerId: string): number | undefined { + return active(providerId)?.registration.defaultDurationMs; + }, + + has(providerId: string): boolean { + return active(providerId) !== undefined; + }, + + health(): readonly SignageProviderHealth[] { + return [...providers.values()] + .filter((entry) => entry.active) + .map((entry) => ({ + consecutiveFailures: entry.consecutiveFailures, + contractVersion: entry.registration.contractVersion, + defaultDurationMs: entry.registration.defaultDurationMs, + lastFailureAt: entry.lastFailureAt?.toISOString() ?? null, + lastFailureCode: entry.lastFailureCode, + lastSuccessAt: entry.lastSuccessAt?.toISOString() ?? null, + providerId: entry.registration.providerId, + providerVersion: entry.registration.providerVersion, + registeredAt: entry.registeredAt.toISOString(), + renderer: { + contributionId: entry.registration.renderer.contributionId, + contributionVersion: entry.registration.renderer.contributionVersion, + }, + })) + .sort((left, right) => left.providerId.localeCompare(right.providerId)); + }, + + async listItems( + providerId: string, + request: SignageProviderDiscoveryRequest & { readonly sourceId?: string }, + ): Promise { + const entry = active(providerId); + if (!entry?.registration.listItems) { + return []; + } + const listItems = entry.registration.listItems.bind(entry.registration); + const result = await callProvider(entry, () => listItems(request)); + if (!result.ok) { + return []; + } + try { + return parseItemDescriptors(result.value); + } catch { + recordFailure(entry, "SIGNAGE_PROVIDER_RESULT_INVALID"); + return []; + } + }, + + async listSources( + providerId: string, + request: SignageProviderDiscoveryRequest, + ): Promise { + const entry = active(providerId); + if (!entry) { + return []; + } + const listSources = entry.registration.listSources.bind(entry.registration); + const result = await callProvider(entry, () => listSources(request)); + if (!result.ok) { + return []; + } + try { + return parseSourceDescriptors(result.value); + } catch { + recordFailure(entry, "SIGNAGE_PROVIDER_RESULT_INVALID"); + return []; + } + }, + + async refresh( + providerId: string, + request: SignageProviderRefreshRequest, + ): Promise { + const entry = active(providerId); + if (!entry) { + return { kind: "unregistered" }; + } + const refresh = entry.registration.refresh.bind(entry.registration); + const result = await callProvider(entry, () => refresh(request)); + if (!result.ok) { + return { code: result.code, kind: "failed" }; + } + try { + return { kind: "resolved", resolution: parseProviderResolution(result.value) }; + } catch { + recordFailure(entry, "SIGNAGE_PROVIDER_RESULT_INVALID"); + return { code: "SIGNAGE_PROVIDER_RESULT_INVALID", kind: "failed" }; + } + }, + + rendererFor(providerId: string): SignageProviderHealth["renderer"] | undefined { + const entry = active(providerId); + return entry + ? { + contributionId: entry.registration.renderer.contributionId, + contributionVersion: entry.registration.renderer.contributionVersion, + } + : undefined; + }, + + async resolveNext( + providerId: string, + request: SignageProviderResolveRequest, + ): Promise { + const entry = active(providerId); + if (!entry) { + return { kind: "unregistered" }; + } + const resolveNext = entry.registration.resolveNext.bind(entry.registration); + const result = await callProvider(entry, () => resolveNext(request)); + if (!result.ok) { + return { code: result.code, kind: "failed" }; + } + try { + return { kind: "resolved", resolution: parseProviderResolution(result.value) }; + } catch { + recordFailure(entry, "SIGNAGE_PROVIDER_RESULT_INVALID"); + return { code: "SIGNAGE_PROVIDER_RESULT_INVALID", kind: "failed" }; + } + }, + }; +} + +export type { SignageJsonValue }; diff --git a/src/server/providers/validation.ts b/src/server/providers/validation.ts new file mode 100644 index 0000000..35dbab3 --- /dev/null +++ b/src/server/providers/validation.ts @@ -0,0 +1,366 @@ +import type { + SignageJsonValue, + SignageProviderItemDescriptor, + SignageProviderRegistration, + SignageProviderSourceDescriptor, + SignageProviderVariant, +} from "./contract.js"; +import { isSupportedProviderContractVersion, SIGNAGE_DEFAULT_AUDIENCE_KEY } from "./contract.js"; + +/** + * Everything crossing the provider boundary is validated here, before it is believed. + * + * A provider is trusted code in the same process, so this is not a sandbox and never claims to be. + * It is the difference between one module's bug staying one module's bug and a malformed value + * reaching a television — a `NaN` duration that makes a slide last forever, a cyclic payload that + * cannot be stored, an unbounded string that fills a realtime frame. + * + * Every rejection is a stable code carrying **nothing of the rejected value**. + */ + +export const SIGNAGE_PROVIDER_ERROR_CODES = [ + /** The registration is not the shape this contract accepts. */ + "SIGNAGE_PROVIDER_REGISTRATION_INVALID", + /** The provider was written against a contract version Signage does not implement. */ + "SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED", + /** A provider with this id is already registered. */ + "SIGNAGE_PROVIDER_DUPLICATE", + /** The provider returned something this contract does not accept. */ + "SIGNAGE_PROVIDER_RESULT_INVALID", + /** The provider threw, rejected, or exceeded its deadline. Temporary by definition. */ + "SIGNAGE_PROVIDER_UNAVAILABLE", +] as const; + +export type SignageProviderErrorCode = (typeof SIGNAGE_PROVIDER_ERROR_CODES)[number]; + +const providerErrorMessages: Readonly> = Object.freeze({ + SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED: + "This content provider was written against an unsupported contract version.", + SIGNAGE_PROVIDER_DUPLICATE: "A content provider with this identifier is already registered.", + SIGNAGE_PROVIDER_REGISTRATION_INVALID: "The content provider registration is not valid.", + SIGNAGE_PROVIDER_RESULT_INVALID: "The content provider returned an unusable result.", + SIGNAGE_PROVIDER_UNAVAILABLE: "The content provider could not be reached.", +}); + +/** Carries a code and, at most, a value the **provider itself supplied as its own identity** — never + * its payload, its exception, or anything of the host's. */ +export class SignageProviderError extends Error { + readonly code: SignageProviderErrorCode; + readonly providerId?: string; + + constructor(code: SignageProviderErrorCode, providerId?: string) { + super(providerErrorMessages[code]); + this.name = "SignageProviderError"; + this.code = code; + if (providerId !== undefined) { + this.providerId = providerId; + } + this.stack = `${this.name}: ${this.message}`; + } +} + +/** Signage's own bounds on what a provider may hand back. They exist so one provider cannot make a + * realtime frame, a database row, or a slide duration unusable for everything else. */ +export const SIGNAGE_PROVIDER_LIMITS = Object.freeze({ + /** A slide shorter than a second is a flicker; one longer than an hour is a static hold. */ + maxDurationMs: 3_600_000, + /** One selection's payloads, serialized. Well inside the platform's 64 KiB realtime ceiling even + * before Signage decides to deliver large state over HTTP instead. */ + maxPayloadBytes: 32 * 1024, + /** Discovery is a picker, not a catalogue. */ + maxDiscoveryResults: 500, + maxIdentifierLength: 200, + maxLabelLength: 200, + maxVariants: 16, + minDurationMs: 1000, +}); + +const IDENTIFIER = /^[\w][\w.:-]*$/u; +const SEMANTIC_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?$/u; + +function isBoundedString(value: unknown, maxLength: number): value is string { + return typeof value === "string" && value.length > 0 && value.length <= maxLength; +} + +function isIdentifier(value: unknown): value is string { + return ( + isBoundedString(value, SIGNAGE_PROVIDER_LIMITS.maxIdentifierLength) && IDENTIFIER.test(value) + ); +} + +function isFunction(value: unknown): boolean { + return typeof value === "function"; +} + +/** + * Whether a value is genuinely JSON-serializable, checked structurally rather than by attempting + * `JSON.stringify` and hoping. + * + * `undefined`, a function, a `Symbol`, a `Date`, a class instance, a cycle, `NaN`, and `Infinity` + * all either vanish or throw on the way to storage or a browser, and the failure would surface far + * from the provider that caused it. The walk is bounded, so a cycle is caught rather than followed. + */ +export function isFiniteJson(value: unknown, seen = new Set()): value is SignageJsonValue { + if (value === null || typeof value === "boolean" || typeof value === "string") { + return true; + } + if (typeof value === "number") { + return Number.isFinite(value); + } + if (typeof value !== "object") { + return false; + } + if (seen.has(value)) { + return false; + } + seen.add(value); + const isArray = Array.isArray(value); + if (!isArray && Object.getPrototypeOf(value) !== Object.prototype) { + return false; + } + const entries = isArray ? value : Object.values(value); + const valid = entries.every((entry) => isFiniteJson(entry, seen)); + seen.delete(value); + return valid; +} + +function jsonByteLength(value: SignageJsonValue): number { + return new TextEncoder().encode(JSON.stringify(value) ?? "").length; +} + +function isInstantOrNull(value: unknown): value is string | null | undefined { + if (value === undefined || value === null) { + return true; + } + if (typeof value !== "string") { + return false; + } + // Absolute instants only. An ambiguous local timestamp is refused rather than guessed at. + return !Number.isNaN(Date.parse(value)) && /[Zz]|[+-]\d{2}:?\d{2}$/u.test(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Validates a registration and returns it in its canonical form. + * + * Throws {@link SignageProviderError} — never a `TypeError`, never anything carrying the offending + * value — because the caller is another module's `start` hook, and what it does with a rejection is + * its own business. + */ +export function parseProviderRegistration(value: unknown): SignageProviderRegistration { + if (!isRecord(value)) { + throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID"); + } + const providerId = value.providerId; + if (!isIdentifier(providerId)) { + throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID"); + } + if (typeof value.contractVersion !== "string") { + throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId); + } + if (!isSupportedProviderContractVersion(value.contractVersion)) { + // Reported separately from a malformed registration, because an operator can act on it: the + // provider module needs an update, and nothing about it is broken. + throw new SignageProviderError("SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED", providerId); + } + if (typeof value.providerVersion !== "string" || !SEMANTIC_VERSION.test(value.providerVersion)) { + throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId); + } + const renderer = value.renderer; + if ( + !isRecord(renderer) || + !isIdentifier(renderer.contributionId) || + typeof renderer.contributionVersion !== "string" || + !SEMANTIC_VERSION.test(renderer.contributionVersion) + ) { + throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId); + } + const defaultDurationMs = value.defaultDurationMs; + if ( + typeof defaultDurationMs !== "number" || + !Number.isSafeInteger(defaultDurationMs) || + defaultDurationMs < SIGNAGE_PROVIDER_LIMITS.minDurationMs || + defaultDurationMs > SIGNAGE_PROVIDER_LIMITS.maxDurationMs + ) { + throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId); + } + if ( + !isFunction(value.listSources) || + !isFunction(value.resolveNext) || + !isFunction(value.refresh) || + (value.listItems !== undefined && !isFunction(value.listItems)) + ) { + throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId); + } + + return value as unknown as SignageProviderRegistration; +} + +export function parseSourceDescriptors(value: unknown): readonly SignageProviderSourceDescriptor[] { + if (!Array.isArray(value) || value.length > SIGNAGE_PROVIDER_LIMITS.maxDiscoveryResults) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + return value.map((entry) => { + if ( + !isRecord(entry) || + !isIdentifier(entry.id) || + !isBoundedString(entry.label, SIGNAGE_PROVIDER_LIMITS.maxLabelLength) || + (entry.description !== undefined && + !isBoundedString(entry.description, SIGNAGE_PROVIDER_LIMITS.maxLabelLength)) + ) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + return { + id: entry.id, + label: entry.label, + ...(entry.description === undefined ? {} : { description: entry.description as string }), + }; + }); +} + +export function parseItemDescriptors(value: unknown): readonly SignageProviderItemDescriptor[] { + if (!Array.isArray(value) || value.length > SIGNAGE_PROVIDER_LIMITS.maxDiscoveryResults) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + return value.map((entry) => { + if ( + !isRecord(entry) || + !isIdentifier(entry.id) || + !isBoundedString(entry.label, SIGNAGE_PROVIDER_LIMITS.maxLabelLength) || + !isInstantOrNull(entry.availableUntil) || + (entry.description !== undefined && + !isBoundedString(entry.description, SIGNAGE_PROVIDER_LIMITS.maxLabelLength)) + ) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + return { + id: entry.id, + label: entry.label, + ...(entry.description === undefined ? {} : { description: entry.description as string }), + ...(entry.availableUntil === undefined + ? {} + : { availableUntil: entry.availableUntil as string | null }), + }; + }); +} + +/** The canonical form of a resolution: variants keyed and deduplicated, the default guaranteed + * present, and every bound already applied. */ +export interface ParsedProviderResolution { + readonly itemId: string; + readonly itemRevision: string; + readonly durationMs: number | undefined; + readonly availableUntil: Date | null; + readonly withdrawn: boolean; + readonly presentation: SignageJsonValue | undefined; + /** Always contains {@link SIGNAGE_DEFAULT_AUDIENCE_KEY}. */ + readonly variants: ReadonlyMap; +} + +/** + * Validates one resolution. + * + * `null` passes straight through: "nothing available right now" is an answer, not a failure, and + * conflating the two is how an empty Trivia pool becomes an error on a lobby wall. + */ +export function parseProviderResolution(value: unknown): ParsedProviderResolution | null { + if (value === null || value === undefined) { + return null; + } + if (!isRecord(value)) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + if (!isIdentifier(value.itemId) || !isBoundedString(value.itemRevision, 128)) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + if ( + value.durationMs !== undefined && + (typeof value.durationMs !== "number" || + !Number.isSafeInteger(value.durationMs) || + value.durationMs < SIGNAGE_PROVIDER_LIMITS.minDurationMs || + value.durationMs > SIGNAGE_PROVIDER_LIMITS.maxDurationMs) + ) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + if (!isInstantOrNull(value.availableUntil)) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + if (value.withdrawn !== undefined && typeof value.withdrawn !== "boolean") { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + if (value.presentation !== undefined && !isFiniteJson(value.presentation)) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + + const rawVariants = value.variants; + if ( + !Array.isArray(rawVariants) || + rawVariants.length === 0 || + rawVariants.length > SIGNAGE_PROVIDER_LIMITS.maxVariants + ) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + + const variants = new Map(); + let payloadBytes = 0; + for (const entry of rawVariants as readonly unknown[]) { + if (!isRecord(entry)) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + const key = + entry.audienceKey === null || entry.audienceKey === undefined + ? SIGNAGE_DEFAULT_AUDIENCE_KEY + : entry.audienceKey; + if (!isBoundedString(key, 64)) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + if (variants.has(key)) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + if (!isFiniteJson(entry.payload)) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + payloadBytes += jsonByteLength(entry.payload); + if (payloadBytes > SIGNAGE_PROVIDER_LIMITS.maxPayloadBytes) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + variants.set(key, entry.payload); + } + + // Every channel must have something to render, and a provider with no audience-specific content + // returns exactly one variant. Requiring the default is what makes the fallback rule real. + if (!variants.has(SIGNAGE_DEFAULT_AUDIENCE_KEY)) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + + const withdrawn = value.withdrawn === true; + const availableUntil = + typeof value.availableUntil === "string" ? new Date(value.availableUntil) : null; + return { + availableUntil, + durationMs: typeof value.durationMs === "number" ? value.durationMs : undefined, + itemId: value.itemId, + itemRevision: value.itemRevision, + presentation: + value.presentation === undefined ? undefined : (value.presentation as SignageJsonValue), + variants, + withdrawn, + }; +} + +export function parseProviderVariants( + variants: readonly SignageProviderVariant[], +): ReadonlyMap { + const parsed = parseProviderResolution({ + itemId: "x", + itemRevision: "1", + variants, + }); + if (!parsed) { + throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID"); + } + return parsed.variants; +} diff --git a/src/server/routes/images.ts b/src/server/routes/images.ts new file mode 100644 index 0000000..e08d620 --- /dev/null +++ b/src/server/routes/images.ts @@ -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> { + 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 }); + }); + }, + }); +} diff --git a/src/server/routes/playback.ts b/src/server/routes/playback.ts new file mode 100644 index 0000000..5fcd252 --- /dev/null +++ b/src/server/routes/playback.ts @@ -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 { + 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, + context: ModuleRouteHandlerContext, + ) => Promise, + 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 }, + ); +} diff --git a/src/server/routes/providers.ts b/src/server/routes/providers.ts new file mode 100644 index 0000000..ba24ee2 --- /dev/null +++ b/src/server/routes/providers.ts @@ -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 }); + }); + }, + }); +} diff --git a/src/server/routes/timelines.ts b/src/server/routes/timelines.ts new file mode 100644 index 0000000..1e5ce07 --- /dev/null +++ b/src/server/routes/timelines.ts @@ -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 { + 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, +): Partial | 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; + 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, 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, + }), + ), + ); + }); + }, + }); +} diff --git a/src/server/routes/viewers.ts b/src/server/routes/viewers.ts new file mode 100644 index 0000000..436904d --- /dev/null +++ b/src/server/routes/viewers.ts @@ -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 { + 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; +} diff --git a/src/server/runtime.ts b/src/server/runtime.ts new file mode 100644 index 0000000..3ed7103 --- /dev/null +++ b/src/server/runtime.ts @@ -0,0 +1,267 @@ +import { + defineModuleRuntime, + type ModuleRuntimeContext, + type ModuleRuntimeDefinition, +} from "@ekklesia/module-sdk"; +import { SIGNAGE_SLIDE_RENDERER_INPUT_VERSION } from "../shared/renderer-input.js"; +import { + SIGNAGE_IMAGE_MAX_BYTES, + SIGNAGE_IMAGE_MAX_PIXELS, + SIGNAGE_UPLOAD_BATCH_MAX_FILES, + SIGNAGE_UPLOAD_CONCURRENCY, +} from "./images/limits.js"; +import { createPlaybackCapability } from "./playback/capability.js"; +import { + createPlaybackService, + type PlaybackSnapshot, + type SignagePlaybackService, +} from "./playback/service.js"; +import type { SignageProviderHandle } from "./providers/contract.js"; +import { + createNativeImageProvider, + type NativeImageProvider, +} from "./providers/native-provider.js"; +import { createProviderRegistry, type SignageProviderRegistry } from "./providers/registry.js"; +import { registerImageRoutes } from "./routes/images.js"; +import { registerPlaybackRoutes } from "./routes/playback.js"; +import { registerProviderRoutes } from "./routes/providers.js"; +import { registerTimelineRoutes } from "./routes/timelines.js"; +import { registerViewerRoutes } from "./routes/viewers.js"; +import { readSignageSettings } from "./settings.js"; +import { createSignageStore } from "./store/drizzle.js"; +import { createDisplaysCapability } from "./viewers/capability.js"; + +/** + * Signage's server runtime, as a factory rather than a module-level singleton. + * + * A factory because the runtime owns real state — the live provider registry, the built-in image + * provider's registration handle, the playback service and its timers — and a test that exercised + * registration twice against one shared instance would be testing leftovers. `index.ts` calls this + * once; a test calls it per case. + * + * Registration stays declarative and I/O-free: nothing here connects to PostgreSQL, contacts object + * storage, reads the environment, opens a socket, or applies a migration. Live services reach + * Signage only through the contexts the platform hands `start`, `stop`, and route handlers. + */ +export interface SignageRuntimeOptions { + readonly now?: () => Date; +} + +export interface SignageModuleRuntime { + readonly definition: ModuleRuntimeDefinition; + /** The live registry, exposed for tests and for the routes that report provider health. */ + readonly registry: SignageProviderRegistry; +} + +/** Realtime carries compact authoritative state and revisions, never content (ADR 0005). Large + * state — a timeline, a history, a renderer payload — is fetched over authorized HTTP keyed by + * revision, which closes the GET-then-subscribe race without a second transport. */ +export const SIGNAGE_DISPLAY_CHANNEL = "display-state"; +export const SIGNAGE_MANAGEMENT_CHANNEL = "management-state"; + +export function createSignageModuleRuntime( + options: SignageRuntimeOptions = {}, +): SignageModuleRuntime { + const now = options.now ?? (() => new Date()); + let registry: SignageProviderRegistry | undefined; + let nativeProvider: NativeImageProvider | undefined; + let nativeHandle: SignageProviderHandle | undefined; + let playback: SignagePlaybackService | undefined; + let store: ReturnType | undefined; + /** The latest snapshot per group, so a publication never has to read the database from inside the + * very lock that produced it. */ + const snapshots = new Map(); + + function requireRegistry(): SignageProviderRegistry { + if (!registry) { + throw new Error("Signage's provider registry is only available after registration."); + } + return registry; + } + + function requirePlayback(): SignagePlaybackService { + if (!playback) { + throw new Error("Signage's playback service is only available after start."); + } + return playback; + } + + function requireStore(): ReturnType { + if (!store) { + throw new Error("Signage's database facade is only available after start."); + } + return store; + } + + function publish(context: ModuleRuntimeContext): void { + const groups = [...snapshots.values()].sort((left, right) => + left.groupId.localeCompare(right.groupId), + ); + try { + context.realtime.publish(SIGNAGE_DISPLAY_CHANNEL, { + groups, + serverNowMs: now().getTime(), + }); + context.realtime.publish(SIGNAGE_MANAGEMENT_CHANNEL, { + // Revisions only. The dashboard refetches what changed over authorized HTTP. + groups: groups.map((snapshot) => ({ + groupId: snapshot.groupId, + revision: snapshot.revision, + selectionRevision: snapshot.selectionRevision, + transport: snapshot.transport, + })), + providers: requireRegistry() + .health() + .map((provider) => ({ + consecutiveFailures: provider.consecutiveFailures, + lastFailureCode: provider.lastFailureCode, + providerId: provider.providerId, + })), + serverNowMs: now().getTime(), + }); + } catch { + // Realtime is best effort and never blocks or changes committed playback state. + } + } + + const definition = defineModuleRuntime({ + id: "signage", + version: "0.1.0", + + register({ capabilities, config, http, realtime }) { + const settings = readSignageSettings(config); + + registry = createProviderRegistry({ + now, + onInvalidate: (notice) => { + // A notification, never a command: playback decides what to refresh and when, because it + // is the only thing that knows what is on screen. + void playback?.invalidate(notice).catch(() => undefined); + }, + timeoutMs: settings.providerTimeoutMs, + }); + + // The extension seam every future content module reaches Signage through (ADR 0002). + capabilities.provide("signage.content-providers", registry.capability); + + // The browser half of the same seam. Nothing registers against it server-side: a contribution + // is declared in a *contributing* module's manifest and published by the platform, and this + // declaration is what makes `capabilityRange` mean something. Resolving it tells a module + // which version of Signage's renderer input contract this installation speaks. + capabilities.provide( + "signage.slide-renderer", + Object.freeze({ inputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION }), + ); + + // Sanitized playback state and commands for a future consumer such as an Ekklesia Companion. + // Exactly what an operator has in the dashboard, over the same serialized service — and + // deliberately no transport, topic, broker, or integration vocabulary of any kind. + capabilities.provide( + "signage.playback", + createPlaybackCapability(requirePlayback, async () => { + const groups = await requireStore().listChannelGroups(); + return groups.map((group) => group.group.id); + }), + ); + + // Sanitized viewer and channel health, and assignment, for another module. Names, online + // state, and what a screen last reported — never a credential, a pairing token, or a host + // internal. + capabilities.provide("signage.displays", createDisplaysCapability(requireStore, now)); + + realtime.channel({ channel: SIGNAGE_DISPLAY_CHANNEL, permission: "signage.display.view" }); + realtime.channel({ channel: SIGNAGE_MANAGEMENT_CHANNEL, permission: "signage.view" }); + + http.route({ + method: "GET", + path: "/settings", + permission: "signage.view", + // A GET route accepts no request body at all. + maxBodyBytes: 0, + handler(_request, context) { + const current = readSignageSettings(context.config); + // Operator settings and Signage's own declared limits. Signage declares no `secret` + // configuration key, and this route would still never serialize one: what an operator may + // see of a module's configuration is the module's deliberate decision, value by value. + // + // The limits travel with the settings so the dashboard never hardcodes a number the + // server enforces — a UI believing a stale limit would offer an upload the server refuses. + return Response.json({ + crossfadeMs: current.crossfadeMs, + moduleVersion: "0.1.0", + nativeImageDurationMs: current.nativeImageDurationMs, + nativePresentation: current.nativePresentation, + rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION, + timezone: current.timezone, + uploads: { + batchMaxFiles: SIGNAGE_UPLOAD_BATCH_MAX_FILES, + concurrency: SIGNAGE_UPLOAD_CONCURRENCY, + maxBytes: SIGNAGE_IMAGE_MAX_BYTES, + maxPixels: SIGNAGE_IMAGE_MAX_PIXELS, + }, + }); + }, + }); + + registerImageRoutes(http); + registerProviderRoutes(http, requireRegistry); + registerTimelineRoutes(http); + registerPlaybackRoutes(http, requirePlayback); + registerViewerRoutes(http, requirePlayback, requireRegistry); + }, + + async start(context: ModuleRuntimeContext) { + const settings = readSignageSettings(context.config); + store = createSignageStore(context.services.database); + + // Signage's own content provider registers through the identical capability an external + // provider uses. There is one selection pipeline, not a native special case (ADR 0001). + nativeProvider = createNativeImageProvider({ + defaultDurationMs: settings.nativeImageDurationMs, + now, + store, + }); + nativeHandle = requireRegistry().capability.register(nativeProvider.registration); + + playback = createPlaybackService({ + now: () => now().getTime(), + onSnapshot: (snapshot) => { + snapshots.set(snapshot.groupId, snapshot); + publish(context); + }, + registry: requireRegistry(), + settings, + store, + }); + + // One default channel group with one default channel, and nothing else. `in-person` and + // `online` are an administrator's decision, not an install-time one (ADR 0004). + await playback.ensureDefaultGroup(); + await playback.start(); + + // Every declared channel must receive an initial publication before `start` completes, so a + // subscriber that connects immediately is given state rather than silence. + publish(context); + }, + + stop() { + // Idempotent, and safe after a `start` that failed partway: `stop` runs before rollback, and + // may run again during reverse-order shutdown. + playback?.stop(); + playback = undefined; + store = undefined; + snapshots.clear(); + nativeHandle?.unregister(); + nativeHandle = undefined; + nativeProvider = undefined; + registry?.clear(); + }, + }); + + return { + definition, + get registry(): SignageProviderRegistry { + return requireRegistry(); + }, + }; +} diff --git a/src/server/settings.ts b/src/server/settings.ts new file mode 100644 index 0000000..24849e0 --- /dev/null +++ b/src/server/settings.ts @@ -0,0 +1,52 @@ +import type { ModuleConfiguration } from "@ekklesia/module-sdk"; +import { + isSignageBackgroundMode, + SIGNAGE_DEFAULT_SETTINGS, + SIGNAGE_HEX_COLOR, + type SignageSettings, +} from "../shared/settings.js"; + +/** + * Reads Signage's own declared configuration through the one platform contract that offers it. + * + * Signage never reads `process.env`, never queries a platform table for a setting, and has no way to + * see another module's configuration — which is what keeps it runnable under a future standalone + * host that stores configuration somewhere else entirely. + * + * The manifest declares a default for every key, so in a real deployment each accessor is guaranteed + * a value before any Signage code runs. The fallbacks exist because the accessors are typed + * `T | undefined` for keys a manifest does not declare, and because a module-layer test may + * construct a `ModuleConfiguration` by hand. + */ +export function readSignageSettings(config: ModuleConfiguration): SignageSettings { + const defaults = SIGNAGE_DEFAULT_SETTINGS; + const backgroundColor = config.getString("defaultBackgroundColor"); + const backgroundMode = config.getEnum("defaultBackgroundMode"); + const durationSeconds = config.getInteger("nativeImageDurationSeconds"); + const heartbeatSeconds = config.getInteger("viewerHeartbeatWriteSeconds"); + return Object.freeze({ + crossfadeMs: config.getInteger("crossfadeMilliseconds") ?? defaults.crossfadeMs, + nativeImageDurationMs: + durationSeconds === undefined ? defaults.nativeImageDurationMs : durationSeconds * 1000, + nativePresentation: Object.freeze({ + backgroundColor: + backgroundColor !== undefined && SIGNAGE_HEX_COLOR.test(backgroundColor) + ? backgroundColor.toLowerCase() + : defaults.nativePresentation.backgroundColor, + backgroundDarkened: + config.getBoolean("defaultBackgroundDarkened") ?? + defaults.nativePresentation.backgroundDarkened, + backgroundMode: isSignageBackgroundMode(backgroundMode) + ? backgroundMode + : defaults.nativePresentation.backgroundMode, + blurPixels: config.getInteger("defaultBlurPixels") ?? defaults.nativePresentation.blurPixels, + }), + playbackHistoryLimit: + config.getInteger("playbackHistoryLimit") ?? defaults.playbackHistoryLimit, + providerTimeoutMs: + config.getInteger("providerTimeoutMilliseconds") ?? defaults.providerTimeoutMs, + timezone: config.getString("timezone") ?? defaults.timezone, + viewerHeartbeatWriteMs: + heartbeatSeconds === undefined ? defaults.viewerHeartbeatWriteMs : heartbeatSeconds * 1000, + }); +} diff --git a/src/server/store/drizzle-playback.ts b/src/server/store/drizzle-playback.ts new file mode 100644 index 0000000..7995e31 --- /dev/null +++ b/src/server/store/drizzle-playback.ts @@ -0,0 +1,371 @@ +import { randomUUID } from "node:crypto"; +import type { ModuleDatabase } from "@ekklesia/module-sdk"; +import { and, asc, desc, eq, inArray, lt, sql } from "drizzle-orm"; +import { + channelGroups, + channels, + playbackStates, + selections, + selectionVariants, +} from "../../schema/tables.js"; +import { + type NewStoredSelection, + type PlaybackChanges, + type SelectionChanges, + type SignagePlaybackStore, + SignageStoreConflict, + type StoredChannel, + type StoredChannelGroupDetail, + type StoredPlaybackState, + type StoredSelection, + type StoredSelectionVariant, +} from "./types.js"; + +/** + * The playback half of Signage's production store. + * + * Two things here are transactional because they cannot be safe otherwise: creating a group together + * with its playback row and its default channel, and taking the next selection sequence. A group + * without a default channel has nowhere for a viewer to land, and two selections that took the same + * sequence would make history ambiguous exactly where `previous` reads it. + */ + +type SelectionRow = typeof selections.$inferSelect; + +function toPlayback(row: typeof playbackStates.$inferSelect): StoredPlaybackState { + return { + activeTimelineId: row.activeTimelineId, + blackout: row.blackout, + currentSelectionId: row.currentSelectionId, + currentSlotId: row.currentSlotId, + groupId: row.groupId, + nextSequence: row.nextSequence, + paused: row.paused, + pausedRemainingMs: row.pausedRemainingMs, + pendingRequestedAt: row.pendingRequestedAt, + pendingTimelineId: row.pendingTimelineId, + revision: row.revision, + scheduledActivationAt: row.scheduledActivationAt, + scheduledTimelineId: row.scheduledTimelineId, + slotDeadlineAt: row.slotDeadlineAt, + slotStartedAt: row.slotStartedAt, + staticAssetId: row.staticAssetId, + updatedAt: row.updatedAt, + }; +} + +function toChannel(row: typeof channels.$inferSelect): StoredChannel { + return { + groupId: row.groupId, + id: row.id, + isDefault: row.isDefault, + key: row.key, + name: row.name, + }; +} + +function toSelection( + row: SelectionRow, + variants: readonly StoredSelectionVariant[], +): StoredSelection { + return { + availableUntil: row.availableUntil, + deadlineAt: row.deadlineAt, + durationMs: row.durationMs, + groupId: row.groupId, + id: row.id, + itemId: row.itemId, + itemRevision: row.itemRevision, + presentation: row.presentation, + providerId: row.providerId, + rendererContributionId: row.rendererContributionId, + rendererContributionVersion: row.rendererContributionVersion, + resolvedAt: row.resolvedAt, + revision: row.revision, + selectionScope: row.selectionScope, + sequence: row.sequence, + slotId: row.slotId, + stale: row.stale, + startedAt: row.startedAt, + timelineId: row.timelineId, + variants, + withdrawn: row.withdrawn, + }; +} + +export function createPlaybackStore(database: ModuleDatabase): SignagePlaybackStore { + async function variantsFor( + selectionIds: readonly string[], + ): Promise> { + const grouped = new Map(); + if (selectionIds.length === 0) { + return grouped; + } + const rows = await database + .select() + .from(selectionVariants) + .where(inArray(selectionVariants.selectionId, [...selectionIds])); + for (const row of rows) { + const list = grouped.get(row.selectionId) ?? []; + list.push({ audienceKey: row.audienceKey, isDefault: row.isDefault, payload: row.payload }); + grouped.set(row.selectionId, list); + } + return grouped; + } + + async function hydrateSelections(rows: readonly SelectionRow[]): Promise { + const variants = await variantsFor(rows.map((row) => row.id)); + return rows.map((row) => toSelection(row, variants.get(row.id) ?? [])); + } + + async function groupDetail( + groupRow: typeof channelGroups.$inferSelect, + ): Promise { + const [channelRows, playbackRows] = await Promise.all([ + database + .select() + .from(channels) + .where(eq(channels.groupId, groupRow.id)) + .orderBy(desc(channels.isDefault), asc(channels.key)), + database.select().from(playbackStates).where(eq(playbackStates.groupId, groupRow.id)), + ]); + const playback = playbackRows[0]; + if (!playback) { + return undefined; + } + return { + channels: channelRows.map(toChannel), + group: { id: groupRow.id, name: groupRow.name, revision: groupRow.revision }, + playback: toPlayback(playback), + }; + } + + return { + async addChannel(channel: StoredChannel): Promise { + await database.transaction(async (transaction) => { + await transaction.insert(channels).values({ + groupId: channel.groupId, + id: channel.id, + isDefault: channel.isDefault, + key: channel.key, + name: channel.name, + }); + await transaction + .update(channelGroups) + .set({ revision: sql`${channelGroups.revision} + 1`, updatedAt: new Date() }) + .where(eq(channelGroups.id, channel.groupId)); + }); + }, + + async createChannelGroup(input: { + readonly groupId: string; + readonly name: string; + readonly channelId: string; + readonly channelName: string; + }): Promise { + await database.transaction(async (transaction) => { + await transaction.insert(channelGroups).values({ id: input.groupId, name: input.name }); + await transaction.insert(channels).values({ + groupId: input.groupId, + id: input.channelId, + isDefault: true, + key: "default", + name: input.channelName, + }); + await transaction.insert(playbackStates).values({ groupId: input.groupId }); + }); + }, + + async deleteChannelGroup(groupId: string): Promise { + await database.delete(channelGroups).where(eq(channelGroups.id, groupId)); + }, + + async findChannelGroup(groupId: string): Promise { + const rows = await database.select().from(channelGroups).where(eq(channelGroups.id, groupId)); + const row = rows[0]; + return row ? groupDetail(row) : undefined; + }, + + async findLatestSelectionForScope( + groupId: string, + selectionScope: string, + ): Promise { + const rows = await database + .select() + .from(selections) + .where(and(eq(selections.groupId, groupId), eq(selections.selectionScope, selectionScope))) + .orderBy(desc(selections.sequence)) + .limit(1); + return (await hydrateSelections(rows))[0]; + }, + + async findSelection(selectionId: string): Promise { + const rows = await database.select().from(selections).where(eq(selections.id, selectionId)); + return (await hydrateSelections(rows))[0]; + }, + + async insertSelection(selection: NewStoredSelection): Promise { + await database.transaction(async (transaction) => { + await transaction.insert(selections).values({ + availableUntil: selection.availableUntil, + deadlineAt: selection.deadlineAt, + durationMs: selection.durationMs, + groupId: selection.groupId, + id: selection.id, + itemId: selection.itemId, + itemRevision: selection.itemRevision, + presentation: selection.presentation, + providerId: selection.providerId, + rendererContributionId: selection.rendererContributionId, + rendererContributionVersion: selection.rendererContributionVersion, + selectionScope: selection.selectionScope, + sequence: selection.sequence, + slotId: selection.slotId, + stale: selection.stale, + startedAt: selection.startedAt, + timelineId: selection.timelineId, + withdrawn: selection.withdrawn, + }); + if (selection.variants.length > 0) { + await transaction.insert(selectionVariants).values( + selection.variants.map((variant) => ({ + audienceKey: variant.audienceKey, + id: randomUUID(), + isDefault: variant.isDefault, + payload: variant.payload, + selectionId: selection.id, + })), + ); + } + }); + }, + + async listChannelGroups(): Promise { + const rows = await database.select().from(channelGroups).orderBy(asc(channelGroups.name)); + const details = await Promise.all(rows.map((row) => groupDetail(row))); + return details.filter((detail): detail is StoredChannelGroupDetail => detail !== undefined); + }, + + async listSelections(groupId: string, limit: number): Promise { + const rows = await database + .select() + .from(selections) + .where(eq(selections.groupId, groupId)) + .orderBy(desc(selections.sequence)) + .limit(limit); + return hydrateSelections(rows); + }, + + async pruneSelections(groupId: string, limit: number): Promise { + const keep = await database + .select({ sequence: selections.sequence }) + .from(selections) + .where(eq(selections.groupId, groupId)) + .orderBy(desc(selections.sequence)) + .limit(limit); + const oldest = keep.at(-1)?.sequence; + if (oldest === undefined) { + return; + } + await database + .delete(selections) + .where(and(eq(selections.groupId, groupId), lt(selections.sequence, oldest))); + }, + + async removeChannel(channelId: string): Promise { + await database.transaction(async (transaction) => { + const rows = await transaction.select().from(channels).where(eq(channels.id, channelId)); + const channel = rows[0]; + if (!channel) { + return; + } + if (channel.isDefault) { + // Something must remain for a viewer to land on. + throw new SignageStoreConflict("channel.default"); + } + await transaction.delete(channels).where(eq(channels.id, channelId)); + await transaction + .update(channelGroups) + .set({ revision: sql`${channelGroups.revision} + 1`, updatedAt: new Date() }) + .where(eq(channelGroups.id, channel.groupId)); + }); + }, + + async takeSelectionSequence(groupId: string): Promise { + const rows = await database + .update(playbackStates) + .set({ nextSequence: sql`${playbackStates.nextSequence} + 1` }) + .where(eq(playbackStates.groupId, groupId)) + .returning({ nextSequence: playbackStates.nextSequence }); + const next = rows[0]?.nextSequence; + if (next === undefined) { + throw new SignageStoreConflict("playback.sequence"); + } + // `returning` gives the incremented value, so the sequence this call owns is one below it. + return next - 1; + }, + + async updatePlayback( + groupId: string, + changes: PlaybackChanges, + options?: { readonly bumpRevision?: boolean }, + ): Promise { + const values: Record = { updatedAt: new Date() }; + for (const [key, value] of Object.entries(changes)) { + if (value !== undefined) { + values[key] = value; + } else if (Object.hasOwn(changes, key)) { + values[key] = null; + } + } + if (options?.bumpRevision !== false) { + values.revision = sql`${playbackStates.revision} + 1`; + } + await database.update(playbackStates).set(values).where(eq(playbackStates.groupId, groupId)); + }, + + async updateSelection(selectionId: string, changes: SelectionChanges): Promise { + await database.transaction(async (transaction) => { + const values: Record = {}; + for (const key of [ + "availableUntil", + "deadlineAt", + "durationMs", + "itemRevision", + "presentation", + "stale", + "startedAt", + "withdrawn", + ] as const) { + if (changes[key] !== undefined) { + values[key] = changes[key]; + } + } + if (changes.bumpRevision === true) { + values.revision = sql`${selections.revision} + 1`; + } + if (Object.keys(values).length > 0) { + await transaction.update(selections).set(values).where(eq(selections.id, selectionId)); + } + if (changes.variants) { + // Replaced wholesale: a partial update would leave one audience rendering a payload from + // a revision that no longer exists. + await transaction + .delete(selectionVariants) + .where(eq(selectionVariants.selectionId, selectionId)); + if (changes.variants.length > 0) { + await transaction.insert(selectionVariants).values( + changes.variants.map((variant) => ({ + audienceKey: variant.audienceKey, + id: randomUUID(), + isDefault: variant.isDefault, + payload: variant.payload, + selectionId, + })), + ); + } + } + }); + }, + }; +} diff --git a/src/server/store/drizzle-timelines.ts b/src/server/store/drizzle-timelines.ts new file mode 100644 index 0000000..8a2347b --- /dev/null +++ b/src/server/store/drizzle-timelines.ts @@ -0,0 +1,399 @@ +import type { ModuleDatabase } from "@ekklesia/module-sdk"; +import { asc, eq, or, sql } from "drizzle-orm"; +import { playbackStates, providerSources, timelineSlots, timelines } from "../../schema/tables.js"; +import { + type NewStoredProviderSource, + type NewStoredSlot, + type NewStoredTimeline, + SignageStoreConflict, + type SignageTimelineStore, + type SlotChanges, + type StoredProviderSource, + type StoredSlot, + type StoredTimeline, + type StoredTimelineDetail, + type StoredTimelineUsage, + type TimelineChanges, +} from "./types.js"; + +/** + * The timeline half of Signage's production store. + * + * Two invariants live here rather than in the services above, because they are only safe if they are + * transactional: + * + * - **Slot positions stay unique and contiguous from zero.** Every operation that can change the + * order rewrites the whole order inside one transaction, so a gap can never be observed — not even + * briefly, by a reconciliation running at the same moment. + * - **A timeline's revision increments exactly once per semantic change**, in the same transaction + * as the change, so a client that compares revisions can never see a changed timeline at an + * unchanged revision. + */ + +type TimelineRow = typeof timelines.$inferSelect; +type SlotRow = typeof timelineSlots.$inferSelect; +type SourceRow = typeof providerSources.$inferSelect; + +function toTimeline(row: TimelineRow): StoredTimeline { + return { + createdAt: row.createdAt, + description: row.description, + eligibleFrom: row.eligibleFrom, + eligibleUntil: row.eligibleUntil, + id: row.id, + name: row.name, + revision: row.revision, + updatedAt: row.updatedAt, + }; +} + +function toSource(row: SourceRow): StoredProviderSource { + return { + createdAt: row.createdAt, + id: row.id, + label: row.label, + providerId: row.providerId, + selectionScope: row.selectionScope, + sourceId: row.sourceId, + timelineId: row.timelineId, + }; +} + +function toSlot(row: SlotRow): StoredSlot { + return { + durationOverrideMs: row.durationOverrideMs, + eligibleFrom: row.eligibleFrom, + eligibleUntil: row.eligibleUntil, + enabled: row.enabled, + id: row.id, + itemId: row.itemId, + kind: row.kind === "pinned-item" ? "pinned-item" : "provider-source", + position: row.position, + presentation: { + backgroundColor: row.backgroundColor, + backgroundDarkened: row.backgroundDarkened, + backgroundMode: + row.backgroundMode === "blurred" || + row.backgroundMode === "solid" || + row.backgroundMode === "black" + ? row.backgroundMode + : null, + blurPixels: row.blurPixels, + foregroundShadow: row.foregroundShadow, + }, + providerId: row.providerId, + sourceId: row.sourceId, + timelineId: row.timelineId, + }; +} + +function slotValues(slot: NewStoredSlot, position: number) { + return { + backgroundColor: slot.presentation.backgroundColor, + backgroundDarkened: slot.presentation.backgroundDarkened, + backgroundMode: slot.presentation.backgroundMode, + blurPixels: slot.presentation.blurPixels, + durationOverrideMs: slot.durationOverrideMs, + eligibleFrom: slot.eligibleFrom, + eligibleUntil: slot.eligibleUntil, + enabled: slot.enabled, + foregroundShadow: slot.presentation.foregroundShadow, + id: slot.id, + itemId: slot.itemId, + kind: slot.kind, + position, + providerId: slot.providerId, + sourceId: slot.sourceId, + timelineId: slot.timelineId, + }; +} + +export function createTimelineStore(database: ModuleDatabase): SignageTimelineStore { + /** + * Rewrites `position` for one timeline from an explicit order. + * + * Positions are written in two passes, through a temporary negative range, because the unique + * `(timeline_id, position)` index is checked per statement: moving a slot from 3 to 1 while + * another still holds 1 would collide. Negative positions are outside every real value, so the + * intermediate state cannot be mistaken for an order. + */ + async function renumber( + transaction: ModuleDatabase, + timelineId: string, + orderedSlotIds: readonly string[], + ): Promise { + for (const [index, slotId] of orderedSlotIds.entries()) { + await transaction + .update(timelineSlots) + .set({ position: -1 - index }) + .where(eq(timelineSlots.id, slotId)); + } + for (const [index, slotId] of orderedSlotIds.entries()) { + await transaction + .update(timelineSlots) + .set({ position: index, updatedAt: new Date() }) + .where(eq(timelineSlots.id, slotId)); + } + await bumpRevision(transaction, timelineId); + } + + async function bumpRevision(transaction: ModuleDatabase, timelineId: string): Promise { + await transaction + .update(timelines) + .set({ revision: sql`${timelines.revision} + 1`, updatedAt: new Date() }) + .where(eq(timelines.id, timelineId)); + } + + async function orderedSlotIdsFor( + transaction: ModuleDatabase, + timelineId: string, + ): Promise { + const rows = await transaction + .select({ id: timelineSlots.id, position: timelineSlots.position }) + .from(timelineSlots) + .where(eq(timelineSlots.timelineId, timelineId)) + .orderBy(asc(timelineSlots.position)); + return rows.map((row) => row.id); + } + + async function timelineIdForSlot( + transaction: ModuleDatabase, + slotId: string, + ): Promise { + const rows = await transaction + .select({ timelineId: timelineSlots.timelineId }) + .from(timelineSlots) + .where(eq(timelineSlots.id, slotId)); + return rows[0]?.timelineId; + } + + return { + async addSlot(slot: NewStoredSlot, position?: number): Promise { + await database.transaction(async (transaction) => { + const existing = await orderedSlotIdsFor(transaction, slot.timelineId); + const index = + position === undefined + ? existing.length + : Math.max(0, Math.min(position, existing.length)); + await transaction.insert(timelineSlots).values(slotValues(slot, -1_000_000)); + const next = [...existing.slice(0, index), slot.id, ...existing.slice(index)]; + await renumber(transaction, slot.timelineId, next); + }); + }, + + async addSource(source: NewStoredProviderSource): Promise { + await database.insert(providerSources).values({ + id: source.id, + label: source.label, + providerId: source.providerId, + selectionScope: source.selectionScope, + sourceId: source.sourceId, + timelineId: source.timelineId, + }); + }, + + async createTimeline(input: { + readonly timeline: NewStoredTimeline; + readonly sources: readonly NewStoredProviderSource[]; + readonly slots: readonly NewStoredSlot[]; + }): Promise { + await database.transaction(async (transaction) => { + await transaction.insert(timelines).values({ + description: input.timeline.description, + eligibleFrom: input.timeline.eligibleFrom, + eligibleUntil: input.timeline.eligibleUntil, + id: input.timeline.id, + name: input.timeline.name, + }); + if (input.sources.length > 0) { + await transaction.insert(providerSources).values( + input.sources.map((source) => ({ + id: source.id, + label: source.label, + providerId: source.providerId, + selectionScope: source.selectionScope, + sourceId: source.sourceId, + timelineId: source.timelineId, + })), + ); + } + if (input.slots.length > 0) { + await transaction + .insert(timelineSlots) + .values(input.slots.map((slot, index) => slotValues(slot, index))); + } + }); + }, + + async deleteTimeline(timelineId: string): Promise { + // Sources and slots cascade with the timeline; resolved selections do not, because history is + // a record of what a screen actually showed. + await database.delete(timelines).where(eq(timelines.id, timelineId)); + }, + + async findTimeline(timelineId: string): Promise { + const timelineRows = await database + .select() + .from(timelines) + .where(eq(timelines.id, timelineId)); + const row = timelineRows[0]; + if (!row) { + return undefined; + } + const [sourceRows, slotRows] = await Promise.all([ + database + .select() + .from(providerSources) + .where(eq(providerSources.timelineId, timelineId)) + .orderBy(asc(providerSources.createdAt)), + database + .select() + .from(timelineSlots) + .where(eq(timelineSlots.timelineId, timelineId)) + .orderBy(asc(timelineSlots.position)), + ]); + return { + slots: slotRows.map(toSlot), + sources: sourceRows.map(toSource), + timeline: toTimeline(row), + }; + }, + + async listTimelines(): Promise { + const rows = await database.select().from(timelines).orderBy(asc(timelines.name)); + return rows.map(toTimeline); + }, + + async removeSlot(slotId: string): Promise { + await database.transaction(async (transaction) => { + const timelineId = await timelineIdForSlot(transaction, slotId); + if (timelineId === undefined) { + return; + } + await transaction.delete(timelineSlots).where(eq(timelineSlots.id, slotId)); + await renumber(transaction, timelineId, await orderedSlotIdsFor(transaction, timelineId)); + }); + }, + + async removeSource(sourceId: string): Promise { + await database.transaction(async (transaction) => { + const rows = await transaction + .select({ timelineId: providerSources.timelineId }) + .from(providerSources) + .where(eq(providerSources.id, sourceId)); + const timelineId = rows[0]?.timelineId; + if (timelineId === undefined) { + return; + } + // Slots referencing the source cascade with it; the remainder is renumbered so the pattern + // an operator reads never has a hole in it. + await transaction.delete(providerSources).where(eq(providerSources.id, sourceId)); + await renumber(transaction, timelineId, await orderedSlotIdsFor(transaction, timelineId)); + }); + }, + + async reorderSlots(timelineId: string, orderedSlotIds: readonly string[]): Promise { + await database.transaction(async (transaction) => { + const current = await orderedSlotIdsFor(transaction, timelineId); + const given = new Set(orderedSlotIds); + // Exhaustive and exact: anything else could lose a slot, duplicate one, or leave a gap. + if (given.size !== orderedSlotIds.length || given.size !== current.length) { + throw new SignageStoreConflict("timeline.slotOrder"); + } + for (const slotId of current) { + if (!given.has(slotId)) { + throw new SignageStoreConflict("timeline.slotOrder"); + } + } + await renumber(transaction, timelineId, orderedSlotIds); + }); + }, + + async timelineUsage(timelineId: string): Promise { + const rows = await database + .select({ + activeTimelineId: playbackStates.activeTimelineId, + pendingTimelineId: playbackStates.pendingTimelineId, + scheduledTimelineId: playbackStates.scheduledTimelineId, + }) + .from(playbackStates) + .where( + or( + eq(playbackStates.activeTimelineId, timelineId), + eq(playbackStates.pendingTimelineId, timelineId), + eq(playbackStates.scheduledTimelineId, timelineId), + ), + ); + return { + activeGroups: rows.filter((row) => row.activeTimelineId === timelineId).length, + pendingGroups: rows.filter((row) => row.pendingTimelineId === timelineId).length, + scheduledGroups: rows.filter((row) => row.scheduledTimelineId === timelineId).length, + }; + }, + + async updateSlot(slotId: string, changes: SlotChanges): Promise { + await database.transaction(async (transaction) => { + const timelineId = await timelineIdForSlot(transaction, slotId); + if (timelineId === undefined) { + return; + } + const values: Record = { updatedAt: new Date() }; + if (changes.enabled !== undefined) { + values.enabled = changes.enabled; + } + if (changes.durationOverrideMs !== undefined) { + values.durationOverrideMs = changes.durationOverrideMs; + } + if (changes.eligibleFrom !== undefined) { + values.eligibleFrom = changes.eligibleFrom; + } + if (changes.eligibleUntil !== undefined) { + values.eligibleUntil = changes.eligibleUntil; + } + if (changes.itemId !== undefined) { + values.itemId = changes.itemId; + } + const presentation = changes.presentation; + if (presentation) { + if (presentation.backgroundMode !== undefined) { + values.backgroundMode = presentation.backgroundMode; + } + if (presentation.backgroundColor !== undefined) { + values.backgroundColor = presentation.backgroundColor; + } + if (presentation.backgroundDarkened !== undefined) { + values.backgroundDarkened = presentation.backgroundDarkened; + } + if (presentation.blurPixels !== undefined) { + values.blurPixels = presentation.blurPixels; + } + if (presentation.foregroundShadow !== undefined) { + values.foregroundShadow = presentation.foregroundShadow; + } + } + await transaction.update(timelineSlots).set(values).where(eq(timelineSlots.id, slotId)); + await bumpRevision(transaction, timelineId); + }); + }, + + async updateTimeline(timelineId: string, changes: TimelineChanges): Promise { + const values: Record = { + revision: sql`${timelines.revision} + 1`, + updatedAt: new Date(), + }; + if (changes.name !== undefined) { + values.name = changes.name; + } + if (changes.description !== undefined) { + values.description = changes.description; + } + if (changes.eligibleFrom !== undefined) { + values.eligibleFrom = changes.eligibleFrom; + } + if (changes.eligibleUntil !== undefined) { + values.eligibleUntil = changes.eligibleUntil; + } + await database.update(timelines).set(values).where(eq(timelines.id, timelineId)); + }, + }; +} diff --git a/src/server/store/drizzle-viewers.ts b/src/server/store/drizzle-viewers.ts new file mode 100644 index 0000000..fc3c172 --- /dev/null +++ b/src/server/store/drizzle-viewers.ts @@ -0,0 +1,99 @@ +import type { ModuleDatabase } from "@ekklesia/module-sdk"; +import { asc, eq } from "drizzle-orm"; +import { viewers } from "../../schema/tables.js"; +import type { NewStoredViewer, SignageViewerStore, StoredViewer, ViewerChanges } from "./types.js"; + +/** The viewer half of Signage's production store. Deliberately dull: heartbeat coalescing, health + * derivation, and every other decision live above it. */ + +function toViewer(row: typeof viewers.$inferSelect): StoredViewer { + return { + cachedAssetCount: row.cachedAssetCount, + channelId: row.channelId, + createdAt: row.createdAt, + deviceId: row.deviceId, + id: row.id, + lastError: + row.lastErrorCode === null || row.lastErrorAt === null + ? null + : { at: row.lastErrorAt, code: row.lastErrorCode, detail: row.lastErrorDetail }, + lastSeenAt: row.lastSeenAt, + name: row.name, + offlineReady: row.offlineReady, + reportedContributionId: row.reportedContributionId, + reportedPlaybackRevision: row.reportedPlaybackRevision, + reportedProviderId: row.reportedProviderId, + reportedSelectionId: row.reportedSelectionId, + reportedTimelineId: row.reportedTimelineId, + viewerKey: row.viewerKey, + }; +} + +export function createViewerStore(database: ModuleDatabase): SignageViewerStore { + return { + async deleteViewer(viewerId: string): Promise { + await database.delete(viewers).where(eq(viewers.id, viewerId)); + }, + + async findViewer(viewerId: string): Promise { + const rows = await database.select().from(viewers).where(eq(viewers.id, viewerId)); + const row = rows[0]; + return row ? toViewer(row) : undefined; + }, + + async findViewerByKey(viewerKey: string): Promise { + const rows = await database.select().from(viewers).where(eq(viewers.viewerKey, viewerKey)); + const row = rows[0]; + return row ? toViewer(row) : undefined; + }, + + async insertViewer(viewer: NewStoredViewer): Promise { + await database.insert(viewers).values({ + cachedAssetCount: viewer.cachedAssetCount, + channelId: viewer.channelId, + deviceId: viewer.deviceId, + id: viewer.id, + name: viewer.name, + offlineReady: viewer.offlineReady, + reportedContributionId: viewer.reportedContributionId, + reportedPlaybackRevision: viewer.reportedPlaybackRevision, + reportedProviderId: viewer.reportedProviderId, + reportedSelectionId: viewer.reportedSelectionId, + reportedTimelineId: viewer.reportedTimelineId, + viewerKey: viewer.viewerKey, + }); + }, + + async listViewers(): Promise { + const rows = await database.select().from(viewers).orderBy(asc(viewers.name)); + return rows.map(toViewer); + }, + + async updateViewer(viewerId: string, changes: ViewerChanges): Promise { + const values: Record = { updatedAt: new Date() }; + for (const key of [ + "cachedAssetCount", + "channelId", + "deviceId", + "lastSeenAt", + "name", + "offlineReady", + "reportedContributionId", + "reportedPlaybackRevision", + "reportedProviderId", + "reportedSelectionId", + "reportedTimelineId", + ] as const) { + if (changes[key] !== undefined) { + values[key] = changes[key]; + } + } + if (changes.lastError !== undefined) { + values.lastErrorAt = changes.lastError?.at ?? null; + values.lastErrorCode = changes.lastError?.code ?? null; + values.lastErrorDetail = changes.lastError?.detail ?? null; + } + await database.update(viewers).set(values).where(eq(viewers.id, viewerId)); + }, + }; +} diff --git a/src/server/store/drizzle.ts b/src/server/store/drizzle.ts new file mode 100644 index 0000000..7a0bde7 --- /dev/null +++ b/src/server/store/drizzle.ts @@ -0,0 +1,269 @@ +import { randomUUID } from "node:crypto"; +import type { ModuleDatabase } from "@ekklesia/module-sdk"; +import { and, desc, eq, inArray } from "drizzle-orm"; +import { + imageAssets, + imageRenditions, + playbackStates, + timelineSlots, +} from "../../schema/tables.js"; +import { SIGNAGE_NATIVE_PROVIDER_ID } from "../../shared/native-images.js"; +import { createPlaybackStore } from "./drizzle-playback.js"; +import { createTimelineStore } from "./drizzle-timelines.js"; +import { createViewerStore } from "./drizzle-viewers.js"; +import { + type NewStoredImage, + type SignageImageStore, + type SignageStore, + SignageStoreConflict, + type StoredImage, + type StoredImageReferences, + type StoredImageRendition, +} from "./types.js"; + +/** + * The production implementation of Signage's data port, over the platform's `ModuleDatabase`. + * + * This file is deliberately dull: it maps rows and nothing else. Every decision — what to write, + * when a revision changes, what a no-op is — lives in the services above it, so that behaviour can + * be proven without PostgreSQL and this mapping can be proven against a live one. + * + * The facade is the only way Signage reaches PostgreSQL. Nothing here constructs a client, reads a + * connection string, or touches another module's table. + */ + +const assetColumns = { + contentSha256: imageAssets.contentSha256, + createdAt: imageAssets.createdAt, + displayName: imageAssets.displayName, + encodedHeight: imageAssets.encodedHeight, + encodedWidth: imageAssets.encodedWidth, + expiresAt: imageAssets.expiresAt, + hasAlpha: imageAssets.hasAlpha, + height: imageAssets.height, + id: imageAssets.id, + orientation: imageAssets.orientation, + originalByteSize: imageAssets.originalByteSize, + originalContentType: imageAssets.originalContentType, + originalFilename: imageAssets.originalFilename, + originalObjectKey: imageAssets.originalObjectKey, + status: imageAssets.status, + width: imageAssets.width, +}; + +type AssetRow = { + [Key in keyof typeof assetColumns]: (typeof imageAssets.$inferSelect)[Key]; +}; + +const renditionColumns = { + assetId: imageRenditions.assetId, + byteSize: imageRenditions.byteSize, + contentSha256: imageRenditions.contentSha256, + contentType: imageRenditions.contentType, + height: imageRenditions.height, + kind: imageRenditions.kind, + objectKey: imageRenditions.objectKey, + width: imageRenditions.width, +}; + +/** PostgreSQL reports a uniqueness violation as SQLSTATE `23505`. The code is all that is read; the + * driver's message, detail, and constraint text are never surfaced. */ +function isUniqueViolation(error: unknown): boolean { + return ( + typeof error === "object" && error !== null && (error as { code?: unknown }).code === "23505" + ); +} + +function toRendition(row: { + byteSize: number; + contentSha256: string; + contentType: string; + height: number; + kind: string; + objectKey: string; + width: number; +}): StoredImageRendition { + return { + byteSize: row.byteSize, + contentSha256: row.contentSha256, + contentType: row.contentType, + height: row.height, + kind: row.kind === "thumbnail" ? "thumbnail" : "display", + objectKey: row.objectKey, + width: row.width, + }; +} + +function toImage(row: AssetRow, renditions: readonly StoredImageRendition[]): StoredImage { + return { + contentSha256: row.contentSha256, + createdAt: row.createdAt, + displayName: row.displayName, + encodedHeight: row.encodedHeight, + encodedWidth: row.encodedWidth, + expiresAt: row.expiresAt, + hasAlpha: row.hasAlpha, + height: row.height, + id: row.id, + orientation: row.orientation, + originalByteSize: row.originalByteSize, + originalContentType: row.originalContentType, + originalFilename: row.originalFilename, + originalObjectKey: row.originalObjectKey, + renditions, + status: row.status === "ready" ? "ready" : row.status === "failed" ? "failed" : "pending", + width: row.width, + }; +} + +export function createImageStore(database: ModuleDatabase): SignageImageStore { + async function renditionsFor( + assetIds: readonly string[], + ): Promise> { + const grouped = new Map(); + if (assetIds.length === 0) { + return grouped; + } + const rows = await database + .select(renditionColumns) + .from(imageRenditions) + .where(inArray(imageRenditions.assetId, [...assetIds])); + for (const row of rows) { + const list = grouped.get(row.assetId) ?? []; + list.push(toRendition(row)); + grouped.set(row.assetId, list); + } + for (const list of grouped.values()) { + list.sort((left, right) => left.kind.localeCompare(right.kind)); + } + return grouped; + } + + async function hydrate(rows: readonly AssetRow[]): Promise { + const renditions = await renditionsFor(rows.map((row) => row.id)); + return rows.map((row) => toImage(row, renditions.get(row.id) ?? [])); + } + + return { + async countImageReferences(imageId: string): Promise { + const [slots, holds] = await Promise.all([ + database + .select({ id: timelineSlots.id }) + .from(timelineSlots) + .where( + and( + eq(timelineSlots.providerId, SIGNAGE_NATIVE_PROVIDER_ID), + eq(timelineSlots.itemId, imageId), + ), + ), + database + .select({ groupId: playbackStates.groupId }) + .from(playbackStates) + .where(eq(playbackStates.staticAssetId, imageId)), + ]); + return { slots: slots.length, staticHolds: holds.length }; + }, + + async deleteImage(imageId: string): Promise { + // Renditions cascade with the asset, so one delete is the whole removal. + await database.delete(imageAssets).where(eq(imageAssets.id, imageId)); + }, + + async findImage(imageId: string): Promise { + const rows = (await database + .select(assetColumns) + .from(imageAssets) + .where(eq(imageAssets.id, imageId))) as AssetRow[]; + return (await hydrate(rows))[0]; + }, + + async findReadyImageByContentHash(contentSha256: string): Promise { + const rows = (await database + .select(assetColumns) + .from(imageAssets) + .where( + and(eq(imageAssets.contentSha256, contentSha256), eq(imageAssets.status, "ready")), + )) as AssetRow[]; + return (await hydrate(rows))[0]; + }, + + async insertImage(image: NewStoredImage): Promise { + try { + await database.transaction(async (transaction) => { + await transaction.insert(imageAssets).values({ + contentSha256: image.contentSha256, + displayName: image.displayName, + encodedHeight: image.encodedHeight, + encodedWidth: image.encodedWidth, + expiresAt: image.expiresAt, + hasAlpha: image.hasAlpha, + height: image.height, + id: image.id, + orientation: image.orientation, + originalByteSize: image.originalByteSize, + originalContentType: image.originalContentType, + originalFilename: image.originalFilename, + originalObjectKey: image.originalObjectKey, + status: image.status, + width: image.width, + }); + await transaction.insert(imageRenditions).values( + image.renditions.map((rendition) => ({ + assetId: image.id, + byteSize: rendition.byteSize, + contentSha256: rendition.contentSha256, + contentType: rendition.contentType, + height: rendition.height, + id: randomUUID(), + kind: rendition.kind, + objectKey: rendition.objectKey, + width: rendition.width, + })), + ); + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new SignageStoreConflict("image.contentSha256"); + } + throw error; + } + }, + + async listImages(): Promise { + const rows = (await database + .select(assetColumns) + .from(imageAssets) + .orderBy(desc(imageAssets.createdAt))) as AssetRow[]; + return hydrate(rows); + }, + + async updateImage( + imageId: string, + changes: { readonly displayName?: string; readonly expiresAt?: Date | null }, + ): Promise { + const values: Record = { updatedAt: new Date() }; + if (changes.displayName !== undefined) { + values.displayName = changes.displayName; + } + if (changes.expiresAt !== undefined) { + values.expiresAt = changes.expiresAt; + } + await database.update(imageAssets).set(values).where(eq(imageAssets.id, imageId)); + }, + }; +} + +/** + * Signage's complete production store: the image half above, plus the timeline half beside it. + * + * Composed rather than written as one object because the two halves share nothing but the database + * handle, and keeping them separate is what lets each be read on its own. + */ +export function createSignageStore(database: ModuleDatabase): SignageStore { + return { + ...createImageStore(database), + ...createTimelineStore(database), + ...createPlaybackStore(database), + ...createViewerStore(database), + }; +} diff --git a/src/server/store/types.ts b/src/server/store/types.ts new file mode 100644 index 0000000..abfd423 --- /dev/null +++ b/src/server/store/types.ts @@ -0,0 +1,447 @@ +/** + * Signage's narrow data port. + * + * Every piece of Signage's behaviour — ingest, timeline editing, provider resolution, authoritative + * playback, viewer health — is written against this interface rather than against Drizzle directly. + * There are two implementations: the production one over the platform's `ModuleDatabase` + * (`./drizzle.ts`), and an in-memory one used by tests. + * + * This is the same discipline Core's own migration runner follows, and it exists for the same + * reason: the behaviour worth proving is *what Signage decides*, and proving that against a real + * PostgreSQL for every case would make the interesting tests slow, flaky, and rare. The Drizzle + * implementation is exercised separately, against a live disposable database, where what is being + * proven is the mapping rather than the decision. + * + * The port is deliberately **narrow and domain-shaped**. It exposes the operations Signage performs, + * not a query language: there is no way to express an arbitrary join, and no way for a caller to + * reach a table this file does not name. + */ + +export interface StoredImageRendition { + readonly kind: "display" | "thumbnail"; + readonly objectKey: string; + readonly contentType: string; + readonly contentSha256: string; + readonly byteSize: number; + readonly width: number; + readonly height: number; +} + +export interface StoredImage { + readonly id: string; + readonly contentSha256: string; + readonly displayName: string; + readonly originalFilename: string; + readonly originalObjectKey: string; + readonly originalContentType: string; + readonly originalByteSize: number; + /** How the original should appear once its own orientation is applied. */ + readonly width: number; + readonly height: number; + readonly encodedWidth: number; + readonly encodedHeight: number; + readonly orientation: number | null; + readonly hasAlpha: boolean; + readonly status: "pending" | "ready" | "failed"; + /** Null is permanent. */ + readonly expiresAt: Date | null; + readonly createdAt: Date; + readonly renditions: readonly StoredImageRendition[]; +} + +export interface NewStoredImage extends Omit { + readonly status: "ready"; +} + +export interface StoredImageReferences { + /** Timeline slots pinned to this image. */ + readonly slots: number; + /** Channel groups currently holding this image as a static hold. */ + readonly staticHolds: number; +} + +export interface SignageImageStore { + listImages(): Promise; + findImage(imageId: string): Promise; + /** Content-addressed lookup, restricted to `ready` assets: an asset whose ingest failed must not + * make a retry of the same bytes a no-op. */ + findReadyImageByContentHash(contentSha256: string): Promise; + /** + * Inserts the asset and every rendition **atomically**. A partially inserted asset would be + * selectable by playback with renditions that do not exist. + * + * Rejects with {@link SignageStoreConflict} when an asset with the same content hash already + * exists, so a race between two identical uploads resolves to one asset rather than to a + * corrupted pair. + */ + insertImage(image: NewStoredImage): Promise; + /** Applies only the fields present. An exact no-op is the caller's to detect, not the store's. */ + updateImage( + imageId: string, + changes: { readonly displayName?: string; readonly expiresAt?: Date | null }, + ): Promise; + deleteImage(imageId: string): Promise; + countImageReferences(imageId: string): Promise; +} + +/** Thrown by a store implementation when a uniqueness guarantee the database owns was violated. + * Carries no driver message, no SQL, and no constraint name — only which guarantee it was. */ +export class SignageStoreConflict extends Error { + readonly guarantee: string; + + constructor(guarantee: string) { + super("A Signage uniqueness guarantee was violated."); + this.name = "SignageStoreConflict"; + this.guarantee = guarantee; + this.stack = `${this.name}: ${this.message}`; + } +} + +// ------------------------------------------------------------------------------------------------- +// Timelines, provider sources, and slots. +// ------------------------------------------------------------------------------------------------- + +export interface StoredTimeline { + readonly id: string; + readonly name: string; + readonly description: string | null; + /** Half-open `[eligibleFrom, eligibleUntil)`. */ + readonly eligibleFrom: Date | null; + readonly eligibleUntil: Date | null; + /** Increments only on a semantic change, so a client can compare and refetch. */ + readonly revision: number; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export interface StoredProviderSource { + readonly id: string; + readonly timelineId: string; + /** Another module's opaque identity. Never a foreign key. */ + readonly providerId: string; + readonly sourceId: string; + readonly label: string; + /** Stable and opaque. Every slot referencing this row passes it to `resolveNext`, which is what + * makes repeated slots one rotation rather than several (ADR 0003). */ + readonly selectionScope: string; + readonly createdAt: Date; +} + +export type StoredSlotKind = "provider-source" | "pinned-item"; + +/** Native-image presentation overrides. Every field is `null` for "take the installation default", + * and the whole group is meaningless for a slot whose content comes from another module — an + * external provider owns its rendering completely. */ +export interface StoredSlotPresentation { + readonly backgroundMode: "blurred" | "solid" | "black" | null; + readonly backgroundColor: string | null; + readonly backgroundDarkened: boolean | null; + readonly blurPixels: number | null; + readonly foregroundShadow: boolean | null; +} + +export interface StoredSlot { + readonly id: string; + readonly timelineId: string; + /** Unique within the timeline and contiguous from zero. */ + readonly position: number; + readonly kind: StoredSlotKind; + /** Set for a `provider-source` slot. */ + readonly sourceId: string | null; + /** Set for a `pinned-item` slot. Both opaque. */ + readonly providerId: string | null; + readonly itemId: string | null; + readonly enabled: boolean; + readonly durationOverrideMs: number | null; + readonly eligibleFrom: Date | null; + readonly eligibleUntil: Date | null; + readonly presentation: StoredSlotPresentation; +} + +/** A timeline and everything in it, in one read. Slots are ordered by position. */ +export interface StoredTimelineDetail { + readonly timeline: StoredTimeline; + readonly sources: readonly StoredProviderSource[]; + readonly slots: readonly StoredSlot[]; +} + +export type NewStoredTimeline = Omit; +export type NewStoredProviderSource = Omit; +/** Position is assigned by the store, which is what keeps positions contiguous without every caller + * having to know the current length. */ +export type NewStoredSlot = Omit; + +export interface TimelineChanges { + readonly name?: string; + readonly description?: string | null; + readonly eligibleFrom?: Date | null; + readonly eligibleUntil?: Date | null; +} + +export interface SlotChanges { + readonly enabled?: boolean; + readonly durationOverrideMs?: number | null; + readonly eligibleFrom?: Date | null; + readonly eligibleUntil?: Date | null; + readonly presentation?: Partial; + readonly itemId?: string; +} + +/** Where a timeline is currently referenced by live playback. Reported so a delete can refuse with + * something an operator can act on rather than cascading silently. */ +export interface StoredTimelineUsage { + readonly activeGroups: number; + readonly pendingGroups: number; + readonly scheduledGroups: number; +} + +export interface SignageTimelineStore { + listTimelines(): Promise; + findTimeline(timelineId: string): Promise; + /** + * Creates a timeline together with every source and slot it starts with, **atomically**. + * + * One operation rather than three, because that is what makes duplication safe: a copy that + * committed its timeline and then failed on its slots would be a half-copy an operator has to + * find and clean up. Slot positions are assigned from the order given. + */ + createTimeline(input: { + readonly timeline: NewStoredTimeline; + readonly sources: readonly NewStoredProviderSource[]; + readonly slots: readonly NewStoredSlot[]; + }): Promise; + /** Applies only the fields present, and bumps the revision. An exact no-op is the caller's to + * detect: the store does not guess what "changed" means. */ + updateTimeline(timelineId: string, changes: TimelineChanges): Promise; + deleteTimeline(timelineId: string): Promise; + timelineUsage(timelineId: string): Promise; + + addSource(source: NewStoredProviderSource): Promise; + /** Removes the source and every slot that referenced it, then renumbers what remains. */ + removeSource(sourceId: string): Promise; + + /** Appends a slot at the end, or inserts it at `position` and shifts the rest. */ + addSlot(slot: NewStoredSlot, position?: number): Promise; + updateSlot(slotId: string, changes: SlotChanges): Promise; + /** Removes the slot and renumbers the remainder so positions stay contiguous. */ + removeSlot(slotId: string): Promise; + /** + * Rewrites the whole order. + * + * All-or-nothing and exhaustive: the given ids must be exactly the timeline's current slots, so a + * reorder can never lose a slot, duplicate one, or leave a gap. Rejects with + * {@link SignageStoreConflict} otherwise. + */ + reorderSlots(timelineId: string, orderedSlotIds: readonly string[]): Promise; +} + +/** The complete port. It grows one section at a time as Signage's behaviour does. */ + +// ------------------------------------------------------------------------------------------------- +// Channel groups, channels, playback, and resolved selections. +// ------------------------------------------------------------------------------------------------- + +export interface StoredChannel { + readonly id: string; + readonly groupId: string; + /** The audience key handed to a provider. The base channel's is `default`. */ + readonly key: string; + readonly name: string; + readonly isDefault: boolean; +} + +export interface StoredChannelGroup { + readonly id: string; + readonly name: string; + readonly revision: number; +} + +export type StoredTransport = "idle" | "playing" | "paused" | "static"; + +/** One channel group's authoritative clock. Every instant is absolute (ADR 0005). */ +export interface StoredPlaybackState { + readonly groupId: string; + readonly activeTimelineId: string | null; + readonly pendingTimelineId: string | null; + readonly pendingRequestedAt: Date | null; + readonly scheduledTimelineId: string | null; + readonly scheduledActivationAt: Date | null; + readonly currentSelectionId: string | null; + readonly currentSlotId: string | null; + readonly slotStartedAt: Date | null; + readonly slotDeadlineAt: Date | null; + readonly pausedRemainingMs: number | null; + readonly paused: boolean; + readonly blackout: boolean; + readonly staticAssetId: string | null; + readonly revision: number; + readonly nextSequence: number; + readonly updatedAt: Date; +} + +export interface StoredChannelGroupDetail { + readonly group: StoredChannelGroup; + readonly channels: readonly StoredChannel[]; + readonly playback: StoredPlaybackState; +} + +export type PlaybackChanges = Partial< + Omit +>; + +export interface StoredSelectionVariant { + readonly audienceKey: string; + readonly isDefault: boolean; + readonly payload: unknown; +} + +/** One logical selection, resolved once for a whole channel group. */ +export interface StoredSelection { + readonly id: string; + readonly groupId: string; + readonly sequence: number; + readonly timelineId: string | null; + readonly slotId: string | null; + readonly selectionScope: string | null; + readonly providerId: string; + readonly itemId: string; + readonly itemRevision: string; + readonly rendererContributionId: string | null; + readonly rendererContributionVersion: string | null; + readonly presentation: unknown; + readonly durationMs: number; + readonly resolvedAt: Date; + readonly startedAt: Date | null; + readonly deadlineAt: Date | null; + readonly availableUntil: Date | null; + readonly withdrawn: boolean; + readonly stale: boolean; + /** Increments when a refresh changes payloads without changing which item this is — the change + * that updates a mounted renderer in place rather than restarting its slot. */ + readonly revision: number; + readonly variants: readonly StoredSelectionVariant[]; +} + +export type NewStoredSelection = Omit; + +export interface SelectionChanges { + readonly startedAt?: Date | null; + readonly deadlineAt?: Date | null; + readonly availableUntil?: Date | null; + readonly withdrawn?: boolean; + readonly stale?: boolean; + readonly durationMs?: number; + readonly itemRevision?: string; + readonly presentation?: unknown; + /** Replaces the whole variant set. A partial update would leave an audience rendering a payload + * from a revision that no longer exists. */ + readonly variants?: readonly StoredSelectionVariant[]; + /** Increments the selection's revision, which is what tells a mounted renderer to update. */ + readonly bumpRevision?: boolean; +} + +export interface SignagePlaybackStore { + listChannelGroups(): Promise; + findChannelGroup(groupId: string): Promise; + /** Creates a group, its playback row, and its one default channel, atomically. A group without a + * default channel would have nowhere for a viewer to land. */ + createChannelGroup(input: { + readonly groupId: string; + readonly name: string; + readonly channelId: string; + readonly channelName: string; + }): Promise; + deleteChannelGroup(groupId: string): Promise; + addChannel(channel: StoredChannel): Promise; + /** Refuses to remove a group's default channel: something must remain for a viewer to join. */ + removeChannel(channelId: string): Promise; + /** Applies only the fields present, and bumps the group's playback revision. */ + updatePlayback( + groupId: string, + changes: PlaybackChanges, + options?: { readonly bumpRevision?: boolean }, + ): Promise; + /** Takes the next sequence number for this group and increments it, atomically. */ + takeSelectionSequence(groupId: string): Promise; + + insertSelection(selection: NewStoredSelection): Promise; + findSelection(selectionId: string): Promise; + updateSelection(selectionId: string, changes: SelectionChanges): Promise; + /** Most recent first, bounded. This is the history `previous` walks. */ + listSelections(groupId: string, limit: number): Promise; + /** The most recent selection for one selection scope, so a provider's rotation can be re-seeded + * from what actually played rather than from the beginning. */ + findLatestSelectionForScope( + groupId: string, + selectionScope: string, + ): Promise; + /** Keeps the newest `limit` selections for a group and removes the rest. */ + pruneSelections(groupId: string, limit: number): Promise; +} + +/** The complete port. It grows one section at a time as Signage's behaviour does. */ + +// ------------------------------------------------------------------------------------------------- +// Viewers. +// ------------------------------------------------------------------------------------------------- + +/** A sanitized report from a display. Never an exception, a stack, a URL, or a payload. */ +export interface StoredViewerError { + readonly code: string; + readonly detail: string | null; + readonly at: Date; +} + +export interface StoredViewer { + readonly id: string; + /** The persistent identity a display keeps in its own IndexedDB, so a reconnect is recognized as + * the same screen rather than a new one. */ + readonly viewerKey: string; + readonly name: string; + /** The **safe** Core device identifier a paired display's principal carries — an opaque + * correlation value. Signage never sees, stores, or handles a device credential. */ + readonly deviceId: string | null; + readonly channelId: string | null; + readonly lastSeenAt: Date | null; + readonly reportedTimelineId: string | null; + readonly reportedSelectionId: string | null; + readonly reportedProviderId: string | null; + readonly reportedContributionId: string | null; + readonly reportedPlaybackRevision: number | null; + readonly offlineReady: boolean; + readonly cachedAssetCount: number; + readonly lastError: StoredViewerError | null; + readonly createdAt: Date; +} + +export type NewStoredViewer = Omit; + +export interface ViewerChanges { + readonly name?: string; + readonly channelId?: string | null; + readonly deviceId?: string | null; + readonly lastSeenAt?: Date; + readonly reportedTimelineId?: string | null; + readonly reportedSelectionId?: string | null; + readonly reportedProviderId?: string | null; + readonly reportedContributionId?: string | null; + readonly reportedPlaybackRevision?: number | null; + readonly offlineReady?: boolean; + readonly cachedAssetCount?: number; + readonly lastError?: StoredViewerError | null; +} + +export interface SignageViewerStore { + listViewers(): Promise; + findViewer(viewerId: string): Promise; + findViewerByKey(viewerKey: string): Promise; + insertViewer(viewer: NewStoredViewer): Promise; + updateViewer(viewerId: string, changes: ViewerChanges): Promise; + deleteViewer(viewerId: string): Promise; +} + +/** The complete port. It grows one section at a time as Signage's behaviour does. */ +export type SignageStore = SignageImageStore & + SignagePlaybackStore & + SignageTimelineStore & + SignageViewerStore; diff --git a/src/server/timelines/service.ts b/src/server/timelines/service.ts new file mode 100644 index 0000000..b863e26 --- /dev/null +++ b/src/server/timelines/service.ts @@ -0,0 +1,429 @@ +import { randomUUID } from "node:crypto"; +import { SIGNAGE_HEX_COLOR } from "../../shared/settings.js"; +import { SignageError } from "../errors.js"; +import { + type NewStoredProviderSource, + type NewStoredSlot, + type SignageStore, + SignageStoreConflict, + type SlotChanges, + type StoredSlotPresentation, + type StoredTimelineDetail, + type TimelineChanges, +} from "../store/types.js"; + +/** + * Timeline, source, and slot management. + * + * The rules that matter are here rather than in the routes, because they are decisions rather than + * transport: what a duplicate copies and what it deliberately does not, when an edit is a no-op, + * and what a delete refuses. + */ + +/** Bounds an operator can actually hit. Generous for a real church, small enough that a timeline + * stays something a person can read. */ +export const SIGNAGE_TIMELINE_LIMITS = Object.freeze({ + maxNameLength: 120, + maxSlotDurationMs: 3_600_000, + maxSlotsPerTimeline: 200, + maxSourcesPerTimeline: 50, + minSlotDurationMs: 1000, +}); + +export interface TimelineIdentitySource { + /** Overridable so a test can pin identity. Production takes fresh UUIDs. */ + readonly newId?: () => string; +} + +function nextId(input: TimelineIdentitySource): string { + return (input.newId ?? randomUUID)(); +} + +function requireInterval(from: Date | null | undefined, until: Date | null | undefined): void { + if (from && until && from.getTime() >= until.getTime()) { + // Half-open `[from, until)`: an empty or inverted window is a mistake an operator wants to hear + // about now rather than discover as a slot that never appears. + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "eligibility" }); + } +} + +function normalizePresentation( + input: Partial | undefined, +): StoredSlotPresentation { + const presentation: StoredSlotPresentation = { + backgroundColor: input?.backgroundColor ?? null, + backgroundDarkened: input?.backgroundDarkened ?? null, + backgroundMode: input?.backgroundMode ?? null, + blurPixels: input?.blurPixels ?? null, + foregroundShadow: input?.foregroundShadow ?? null, + }; + if ( + presentation.backgroundColor !== null && + !SIGNAGE_HEX_COLOR.test(presentation.backgroundColor) + ) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "backgroundColor" }); + } + if ( + presentation.blurPixels !== null && + (!Number.isSafeInteger(presentation.blurPixels) || + presentation.blurPixels < 0 || + presentation.blurPixels > 120) + ) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "blurPixels" }); + } + return presentation; +} + +export interface CreateTimelineInput extends TimelineIdentitySource { + readonly store: SignageStore; + readonly name: string; + readonly description?: string | null; + readonly eligibleFrom?: Date | null; + readonly eligibleUntil?: Date | null; +} + +export async function createTimeline(input: CreateTimelineInput): Promise { + requireInterval(input.eligibleFrom, input.eligibleUntil); + const id = nextId(input); + await input.store.createTimeline({ + slots: [], + sources: [], + timeline: { + description: input.description ?? null, + eligibleFrom: input.eligibleFrom ?? null, + eligibleUntil: input.eligibleUntil ?? null, + id, + name: input.name, + }, + }); + return requireTimeline(input.store, id); +} + +export async function requireTimeline( + store: SignageStore, + timelineId: string, +): Promise { + const detail = await store.findTimeline(timelineId); + if (!detail) { + throw new SignageError("SIGNAGE_NOT_FOUND"); + } + return detail; +} + +/** + * Duplicates a timeline, and gives every copied source a **new selection scope**. + * + * That is the one non-obvious decision here, and it is deliberate. A duplicate is a separate + * program: an operator copying "Sunday Morning" to make "Sunday Evening" expects the two to rotate + * independently, and a shared scope would make the evening loop continue wherever the morning one + * happened to be. Slot ids are new for the same reason — history belongs to the timeline that + * actually played. + */ +export async function duplicateTimeline(input: { + readonly store: SignageStore; + readonly timelineId: string; + readonly name: string; + readonly newId?: () => string; +}): Promise { + const original = await requireTimeline(input.store, input.timelineId); + const newId = input.newId ?? randomUUID; + const timelineId = newId(); + const sourceIds = new Map(); + + const sources: NewStoredProviderSource[] = original.sources.map((source) => { + const id = newId(); + sourceIds.set(source.id, id); + return { + id, + label: source.label, + providerId: source.providerId, + // A new rotation, not a continuation of the original's. + selectionScope: newId(), + sourceId: source.sourceId, + timelineId, + }; + }); + + const slots: NewStoredSlot[] = original.slots.map((slot) => ({ + durationOverrideMs: slot.durationOverrideMs, + eligibleFrom: slot.eligibleFrom, + eligibleUntil: slot.eligibleUntil, + enabled: slot.enabled, + id: newId(), + itemId: slot.itemId, + kind: slot.kind, + presentation: slot.presentation, + providerId: slot.providerId, + sourceId: slot.sourceId === null ? null : (sourceIds.get(slot.sourceId) ?? null), + timelineId, + })); + + await input.store.createTimeline({ + slots, + sources, + timeline: { + description: original.timeline.description, + eligibleFrom: original.timeline.eligibleFrom, + eligibleUntil: original.timeline.eligibleUntil, + id: timelineId, + name: input.name, + }, + }); + return requireTimeline(input.store, timelineId); +} + +export async function updateTimeline(input: { + readonly store: SignageStore; + readonly timelineId: string; + readonly changes: TimelineChanges; +}): Promise { + const current = await requireTimeline(input.store, input.timelineId); + const changes: TimelineChanges = {}; + const timeline = current.timeline; + + if (input.changes.name !== undefined && input.changes.name !== timeline.name) { + Object.assign(changes, { name: input.changes.name }); + } + if ( + input.changes.description !== undefined && + input.changes.description !== timeline.description + ) { + Object.assign(changes, { description: input.changes.description }); + } + for (const field of ["eligibleFrom", "eligibleUntil"] as const) { + const next = input.changes[field]; + if (next !== undefined && (next?.getTime() ?? null) !== (timeline[field]?.getTime() ?? null)) { + Object.assign(changes, { [field]: next }); + } + } + + // An exact no-op writes nothing and — crucially — does not bump the revision, so a client polling + // on revision is not woken by an operator saving a form they did not change. + if (Object.keys(changes).length === 0) { + return current; + } + requireInterval( + changes.eligibleFrom === undefined ? timeline.eligibleFrom : changes.eligibleFrom, + changes.eligibleUntil === undefined ? timeline.eligibleUntil : changes.eligibleUntil, + ); + await input.store.updateTimeline(input.timelineId, changes); + return requireTimeline(input.store, input.timelineId); +} + +/** + * Deletes a timeline, and refuses while playback still points at it. + * + * A timeline deleted out from under a running channel group would blank a wall mid-service. Refusing + * with counts is something an operator can act on: activate something else first. + */ +export async function deleteTimeline(input: { + readonly store: SignageStore; + readonly timelineId: string; +}): Promise { + await requireTimeline(input.store, input.timelineId); + const usage = await input.store.timelineUsage(input.timelineId); + if (usage.activeGroups > 0 || usage.pendingGroups > 0 || usage.scheduledGroups > 0) { + throw new SignageError("SIGNAGE_CONFLICT", { + activeGroups: usage.activeGroups, + pendingGroups: usage.pendingGroups, + scheduledGroups: usage.scheduledGroups, + }); + } + await input.store.deleteTimeline(input.timelineId); +} + +export async function addProviderSource(input: { + readonly store: SignageStore; + readonly timelineId: string; + readonly providerId: string; + readonly sourceId: string; + readonly label: string; + readonly newId?: () => string; +}): Promise { + const detail = await requireTimeline(input.store, input.timelineId); + if (detail.sources.length >= SIGNAGE_TIMELINE_LIMITS.maxSourcesPerTimeline) { + throw new SignageError("SIGNAGE_CONFLICT", { + maxSources: SIGNAGE_TIMELINE_LIMITS.maxSourcesPerTimeline, + }); + } + const newId = input.newId ?? randomUUID; + await input.store.addSource({ + id: newId(), + label: input.label, + providerId: input.providerId, + // Generated here, stable forever, and deliberately not derived from the timeline or slot id: + // either would silently reset a provider's rotation when an operator renamed or reordered + // something (ADR 0003). + selectionScope: newId(), + sourceId: input.sourceId, + timelineId: input.timelineId, + }); + return requireTimeline(input.store, input.timelineId); +} + +export async function removeProviderSource(input: { + readonly store: SignageStore; + readonly timelineId: string; + readonly sourceId: string; +}): Promise { + const detail = await requireTimeline(input.store, input.timelineId); + if (!detail.sources.some((source) => source.id === input.sourceId)) { + throw new SignageError("SIGNAGE_NOT_FOUND"); + } + await input.store.removeSource(input.sourceId); + return requireTimeline(input.store, input.timelineId); +} + +export interface AddSlotInput extends TimelineIdentitySource { + readonly store: SignageStore; + readonly timelineId: string; + readonly position?: number | undefined; + readonly enabled?: boolean | undefined; + readonly durationOverrideMs?: number | null | undefined; + readonly eligibleFrom?: Date | null | undefined; + readonly eligibleUntil?: Date | null | undefined; + readonly presentation?: Partial | undefined; + /** Exactly one of these two shapes. */ + readonly source?: { readonly sourceId: string } | undefined; + readonly pinned?: { readonly providerId: string; readonly itemId: string } | undefined; +} + +export async function addSlot(input: AddSlotInput): Promise { + const detail = await requireTimeline(input.store, input.timelineId); + if (detail.slots.length >= SIGNAGE_TIMELINE_LIMITS.maxSlotsPerTimeline) { + throw new SignageError("SIGNAGE_CONFLICT", { + maxSlots: SIGNAGE_TIMELINE_LIMITS.maxSlotsPerTimeline, + }); + } + if ((input.source === undefined) === (input.pinned === undefined)) { + // A slot is a provider source or a pinned item. Both or neither is not a slot. + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "kind" }); + } + if (input.source && !detail.sources.some((source) => source.id === input.source?.sourceId)) { + // A slot may only reference a source configured on its own timeline. + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "sourceId" }); + } + if ( + input.durationOverrideMs !== undefined && + input.durationOverrideMs !== null && + (!Number.isSafeInteger(input.durationOverrideMs) || + input.durationOverrideMs < SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs || + input.durationOverrideMs > SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs) + ) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "durationOverrideMs" }); + } + requireInterval(input.eligibleFrom, input.eligibleUntil); + + await input.store.addSlot( + { + durationOverrideMs: input.durationOverrideMs ?? null, + eligibleFrom: input.eligibleFrom ?? null, + eligibleUntil: input.eligibleUntil ?? null, + enabled: input.enabled ?? true, + id: nextId(input), + itemId: input.pinned?.itemId ?? null, + kind: input.source ? "provider-source" : "pinned-item", + presentation: normalizePresentation(input.presentation), + providerId: input.pinned?.providerId ?? null, + sourceId: input.source?.sourceId ?? null, + timelineId: input.timelineId, + }, + input.position, + ); + return requireTimeline(input.store, input.timelineId); +} + +export async function updateSlot(input: { + readonly store: SignageStore; + readonly timelineId: string; + readonly slotId: string; + readonly changes: SlotChanges; +}): Promise { + const detail = await requireTimeline(input.store, input.timelineId); + const slot = detail.slots.find((candidate) => candidate.id === input.slotId); + if (!slot) { + throw new SignageError("SIGNAGE_NOT_FOUND"); + } + const changes: SlotChanges = {}; + if (input.changes.enabled !== undefined && input.changes.enabled !== slot.enabled) { + Object.assign(changes, { enabled: input.changes.enabled }); + } + if ( + input.changes.durationOverrideMs !== undefined && + input.changes.durationOverrideMs !== slot.durationOverrideMs + ) { + const value = input.changes.durationOverrideMs; + if ( + value !== null && + (!Number.isSafeInteger(value) || + value < SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs || + value > SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs) + ) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "durationOverrideMs" }); + } + Object.assign(changes, { durationOverrideMs: value }); + } + for (const field of ["eligibleFrom", "eligibleUntil"] as const) { + const next = input.changes[field]; + if (next !== undefined && (next?.getTime() ?? null) !== (slot[field]?.getTime() ?? null)) { + Object.assign(changes, { [field]: next }); + } + } + if (input.changes.itemId !== undefined && input.changes.itemId !== slot.itemId) { + if (slot.kind !== "pinned-item") { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "itemId" }); + } + Object.assign(changes, { itemId: input.changes.itemId }); + } + if (input.changes.presentation) { + const merged = normalizePresentation({ ...slot.presentation, ...input.changes.presentation }); + const changed = (Object.keys(merged) as (keyof StoredSlotPresentation)[]).some( + (key) => merged[key] !== slot.presentation[key], + ); + if (changed) { + Object.assign(changes, { presentation: merged }); + } + } + + if (Object.keys(changes).length === 0) { + return detail; + } + requireInterval( + changes.eligibleFrom === undefined ? slot.eligibleFrom : changes.eligibleFrom, + changes.eligibleUntil === undefined ? slot.eligibleUntil : changes.eligibleUntil, + ); + await input.store.updateSlot(input.slotId, changes); + return requireTimeline(input.store, input.timelineId); +} + +export async function removeSlot(input: { + readonly store: SignageStore; + readonly timelineId: string; + readonly slotId: string; +}): Promise { + const detail = await requireTimeline(input.store, input.timelineId); + if (!detail.slots.some((slot) => slot.id === input.slotId)) { + throw new SignageError("SIGNAGE_NOT_FOUND"); + } + await input.store.removeSlot(input.slotId); + return requireTimeline(input.store, input.timelineId); +} + +export async function reorderSlots(input: { + readonly store: SignageStore; + readonly timelineId: string; + readonly orderedSlotIds: readonly string[]; +}): Promise { + await requireTimeline(input.store, input.timelineId); + try { + await input.store.reorderSlots(input.timelineId, input.orderedSlotIds); + } catch (error) { + if (error instanceof SignageStoreConflict) { + // The order was not exactly this timeline's current slots. Refusing is what makes a reorder + // unable to lose a slot, duplicate one, or leave a hole in the pattern. + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "slotIds" }); + } + throw error; + } + return requireTimeline(input.store, input.timelineId); +} diff --git a/src/server/viewers/capability.ts b/src/server/viewers/capability.ts new file mode 100644 index 0000000..23af27d --- /dev/null +++ b/src/server/viewers/capability.ts @@ -0,0 +1,86 @@ +import { SignageError } from "../errors.js"; +import type { SignageStore } from "../store/types.js"; +import { describeViewerHealth, updateViewerAssignment, type ViewerHealth } from "./service.js"; + +/** + * `signage.displays` — sanitized viewer and channel health, and assignment, for another module. + * + * What a consuming module gets is exactly the roster an operator sees in the dashboard: names, + * online state, assignment, what each screen last reported, and its most recent safe error code. + * + * What it deliberately does not get: a device credential, a pairing token, a session, a provider + * payload, an object key, a delivery URL, or any private host internal. `deviceId` is the same + * opaque correlation value Core exposes on a device principal — it identifies a screen, and it + * unlocks nothing. + */ +export const SIGNAGE_DISPLAYS_CAPABILITY_VERSION = "1.0.0"; + +export interface SignageChannelSummary { + readonly id: string; + readonly groupId: string; + readonly groupName: string; + readonly key: string; + readonly name: string; + readonly isDefault: boolean; +} + +export interface SignageDisplaysCapability { + readonly contractVersion: string; + listChannels(): Promise; + listViewers(): Promise; + /** Moves a screen to another channel. Refuses a channel that does not exist rather than leaving a + * display pointed at nothing. */ + assignViewer(viewerId: string, channelId: string): Promise; + renameViewer(viewerId: string, name: string): Promise; +} + +export function createDisplaysCapability( + store: () => SignageStore, + now: () => Date, +): SignageDisplaysCapability { + async function health( + viewerId: string, + name?: string, + channelId?: string, + ): Promise { + const viewer = await updateViewerAssignment({ + ...(channelId === undefined ? {} : { channelId }), + ...(name === undefined ? {} : { name }), + store: store(), + viewerId, + }); + return describeViewerHealth(viewer, now().getTime()); + } + + const capability: SignageDisplaysCapability = { + assignViewer(viewerId: string, channelId: string): Promise { + if (typeof channelId !== "string" || channelId.length === 0) { + return Promise.reject(new SignageError("SIGNAGE_INVALID_REQUEST", { field: "channelId" })); + } + return health(viewerId, undefined, channelId); + }, + contractVersion: SIGNAGE_DISPLAYS_CAPABILITY_VERSION, + async listChannels(): Promise { + const groups = await store().listChannelGroups(); + return groups.flatMap((group) => + group.channels.map((channel) => ({ + groupId: group.group.id, + groupName: group.group.name, + id: channel.id, + isDefault: channel.isDefault, + key: channel.key, + name: channel.name, + })), + ); + }, + async listViewers(): Promise { + const atMs = now().getTime(); + const viewers = await store().listViewers(); + return viewers.map((viewer) => describeViewerHealth(viewer, atMs)); + }, + renameViewer(viewerId: string, name: string): Promise { + return health(viewerId, name, undefined); + }, + }; + return Object.freeze(capability); +} diff --git a/src/server/viewers/service.ts b/src/server/viewers/service.ts new file mode 100644 index 0000000..0f0a94d --- /dev/null +++ b/src/server/viewers/service.ts @@ -0,0 +1,324 @@ +import { randomUUID } from "node:crypto"; +import { SignageError } from "../errors.js"; +import type { + SignageStore, + StoredChannel, + StoredViewer, + StoredViewerError, + ViewerChanges, +} from "../store/types.js"; + +/** + * Viewer registration, assignment, heartbeat, and health (ADR 0004). + * + * Two rules shape everything here. + * + * **A viewer never chooses its channel.** A display registers with the identity it keeps in its own + * IndexedDB and lands on the default channel of the default group. An administrator moves it from + * the dashboard. A public display that could select its own channel would let anyone who reaches the + * page decide what a screen shows. + * + * **A heartbeat is telemetry, never authority.** What a viewer reports about what it is showing is + * recorded so an operator can see it; it never changes playback, and a local cursor is never + * accepted as truth. + */ + +/** The longest a viewer's own reported error detail may be. Bounded because it is operator-facing + * text a display supplied, and because an unbounded one is a way to fill a table. */ +export const SIGNAGE_VIEWER_ERROR_DETAIL_MAX = 200; +export const SIGNAGE_VIEWER_NAME_MAX = 120; +/** A display is considered offline when nothing has been heard from it for this long. Comfortably + * more than a few missed heartbeats, so a slow network does not make a working screen look dead. */ +export const SIGNAGE_VIEWER_OFFLINE_AFTER_MS = 90_000; + +export interface ViewerSessionInput { + readonly store: SignageStore; + readonly viewerKey: string; + /** The safe Core device identifier, when the principal is a paired device. Never a credential. */ + readonly deviceId: string | null; + readonly at: Date; + readonly newId?: () => string; +} + +export interface ViewerSession { + readonly viewer: StoredViewer; + readonly channel: StoredChannel; + readonly groupId: string; +} + +const VIEWER_KEY = /^[\w-]{8,128}$/u; + +export function isViewerKey(value: unknown): value is string { + return typeof value === "string" && VIEWER_KEY.test(value); +} + +/** + * Registers a display, or recognizes one that has been here before. + * + * A returning viewer keeps its assignment, its name, and its history. Nothing about a reconnect + * looks like a new screen, which is what keeps a display roster from filling with ghosts every time + * a television reboots. + */ +export async function openViewerSession(input: ViewerSessionInput): Promise { + if (!isViewerKey(input.viewerKey)) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "viewerKey" }); + } + const newId = input.newId ?? randomUUID; + const groups = await input.store.listChannelGroups(); + const group = groups[0]; + if (!group) { + throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-channel-group" }); + } + + const existing = await input.store.findViewerByKey(input.viewerKey); + if (existing) { + const changes: ViewerChanges = { lastSeenAt: input.at }; + if (input.deviceId !== null && input.deviceId !== existing.deviceId) { + // A screen that was paired since it last checked in. The identifier is opaque and is stored + // only so an operator can correlate the two rosters. + Object.assign(changes, { deviceId: input.deviceId }); + } + if (existing.channelId === null) { + // Its channel was removed. It lands back on a default rather than rendering nothing. + Object.assign(changes, { channelId: defaultChannelOf(group.channels).id }); + } + await input.store.updateViewer(existing.id, changes); + const refreshed = await input.store.findViewer(existing.id); + if (!refreshed) { + throw new SignageError("SIGNAGE_OPERATION_FAILED"); + } + return resolveSession(input.store, refreshed); + } + + const channel = defaultChannelOf(group.channels); + const viewer: StoredViewer = { + cachedAssetCount: 0, + channelId: channel.id, + createdAt: input.at, + deviceId: input.deviceId, + id: newId(), + lastError: null, + lastSeenAt: input.at, + name: "New display", + offlineReady: false, + reportedContributionId: null, + reportedPlaybackRevision: null, + reportedProviderId: null, + reportedSelectionId: null, + reportedTimelineId: null, + viewerKey: input.viewerKey, + }; + await input.store.insertViewer(viewer); + await input.store.updateViewer(viewer.id, { lastSeenAt: input.at }); + return { channel, groupId: group.group.id, viewer }; +} + +function defaultChannelOf(channels: readonly StoredChannel[]): StoredChannel { + const fallback = channels.find((channel) => channel.isDefault) ?? channels[0]; + if (!fallback) { + throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-channel" }); + } + return fallback; +} + +async function resolveSession(store: SignageStore, viewer: StoredViewer): Promise { + const groups = await store.listChannelGroups(); + for (const group of groups) { + const channel = group.channels.find((candidate) => candidate.id === viewer.channelId); + if (channel) { + return { channel, groupId: group.group.id, viewer }; + } + } + const first = groups[0]; + if (!first) { + throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-channel-group" }); + } + return { channel: defaultChannelOf(first.channels), groupId: first.group.id, viewer }; +} + +export interface ViewerHeartbeat { + readonly reportedTimelineId?: string | null | undefined; + readonly reportedSelectionId?: string | null | undefined; + readonly reportedProviderId?: string | null | undefined; + readonly reportedContributionId?: string | null | undefined; + readonly reportedPlaybackRevision?: number | null | undefined; + readonly offlineReady?: boolean | undefined; + readonly cachedAssetCount?: number | undefined; + readonly error?: { readonly code: string; readonly detail?: string | null } | null | undefined; +} + +/** + * Records a heartbeat, and **coalesces** it. + * + * A steady display checks in every few seconds. Writing a row every time would turn a wall of + * televisions into a constant stream of PostgreSQL writes that says nothing new. So a write happens + * only when something an operator would notice changed, or when enough time has passed that "last + * seen" would otherwise go stale. + * + * Returns whether a write actually happened, so this is measurable rather than asserted. + */ +export async function recordViewerHeartbeat(input: { + readonly store: SignageStore; + readonly viewerId: string; + readonly heartbeat: ViewerHeartbeat; + readonly at: Date; + readonly minimumWriteIntervalMs: number; +}): Promise<{ readonly written: boolean; readonly viewer: StoredViewer }> { + const viewer = await input.store.findViewer(input.viewerId); + if (!viewer) { + throw new SignageError("SIGNAGE_NOT_FOUND"); + } + + const error = normalizeViewerError(input.heartbeat.error, input.at); + const changed = + fieldChanged(viewer.reportedTimelineId, input.heartbeat.reportedTimelineId) || + fieldChanged(viewer.reportedSelectionId, input.heartbeat.reportedSelectionId) || + fieldChanged(viewer.reportedProviderId, input.heartbeat.reportedProviderId) || + fieldChanged(viewer.reportedContributionId, input.heartbeat.reportedContributionId) || + fieldChanged(viewer.reportedPlaybackRevision, input.heartbeat.reportedPlaybackRevision) || + fieldChanged(viewer.offlineReady, input.heartbeat.offlineReady) || + fieldChanged(viewer.cachedAssetCount, input.heartbeat.cachedAssetCount) || + (viewer.lastError?.code ?? null) !== (error?.code ?? null); + + const staleMs = input.at.getTime() - (viewer.lastSeenAt?.getTime() ?? 0); + if (!changed && staleMs < input.minimumWriteIntervalMs) { + // Nothing new, and "last seen" is recent enough to still be true. Say nothing to the database. + return { viewer, written: false }; + } + + const changes: ViewerChanges = { lastSeenAt: input.at }; + for (const key of [ + "cachedAssetCount", + "offlineReady", + "reportedContributionId", + "reportedPlaybackRevision", + "reportedProviderId", + "reportedSelectionId", + "reportedTimelineId", + ] as const) { + const value = input.heartbeat[key]; + if (value !== undefined) { + Object.assign(changes, { [key]: value }); + } + } + if (input.heartbeat.error !== undefined) { + Object.assign(changes, { lastError: error }); + } + await input.store.updateViewer(input.viewerId, changes); + const updated = await input.store.findViewer(input.viewerId); + if (!updated) { + throw new SignageError("SIGNAGE_OPERATION_FAILED"); + } + return { viewer: updated, written: true }; +} + +function fieldChanged(current: Value, next: Value | undefined): boolean { + return next !== undefined && next !== current; +} + +/** A display's own reported error, bounded and stripped. A code and a short detail — never a stack, + * a URL, a payload, or anything else a browser might have had to hand. */ +function normalizeViewerError(error: ViewerHeartbeat["error"], at: Date): StoredViewerError | null { + if (error === undefined || error === null) { + return null; + } + if (typeof error.code !== "string" || error.code.length === 0 || error.code.length > 64) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.code" }); + } + const detail = error.detail ?? null; + if ( + detail !== null && + (typeof detail !== "string" || detail.length > SIGNAGE_VIEWER_ERROR_DETAIL_MAX) + ) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.detail" }); + } + return { at, code: error.code, detail }; +} + +export interface ViewerHealth { + readonly id: string; + readonly name: string; + readonly online: boolean; + readonly channelId: string | null; + readonly deviceId: string | null; + readonly lastSeenAtMs: number | null; + readonly reportedTimelineId: string | null; + readonly reportedSelectionId: string | null; + readonly reportedProviderId: string | null; + readonly reportedContributionId: string | null; + readonly offlineReady: boolean; + readonly cachedAssetCount: number; + readonly lastError: { + readonly code: string; + readonly detail: string | null; + readonly atMs: number; + } | null; +} + +/** What management sees. Everything is either Signage's own record or a value the display itself + * reported through the bounded heartbeat contract — never a credential and never a host internal. */ +export function describeViewerHealth(viewer: StoredViewer, atMs: number): ViewerHealth { + const lastSeenAtMs = viewer.lastSeenAt?.getTime() ?? null; + return { + cachedAssetCount: viewer.cachedAssetCount, + channelId: viewer.channelId, + deviceId: viewer.deviceId, + id: viewer.id, + lastError: + viewer.lastError === null + ? null + : { + atMs: viewer.lastError.at.getTime(), + code: viewer.lastError.code, + detail: viewer.lastError.detail, + }, + lastSeenAtMs, + name: viewer.name, + offlineReady: viewer.offlineReady, + online: lastSeenAtMs !== null && atMs - lastSeenAtMs < SIGNAGE_VIEWER_OFFLINE_AFTER_MS, + reportedContributionId: viewer.reportedContributionId, + reportedProviderId: viewer.reportedProviderId, + reportedSelectionId: viewer.reportedSelectionId, + reportedTimelineId: viewer.reportedTimelineId, + }; +} + +/** Assigns a viewer to a channel, or renames it. Refuses a channel that does not exist rather than + * silently leaving a screen unassigned. */ +export async function updateViewerAssignment(input: { + readonly store: SignageStore; + readonly viewerId: string; + readonly name?: string | undefined; + readonly channelId?: string | undefined; +}): Promise { + const viewer = await input.store.findViewer(input.viewerId); + if (!viewer) { + throw new SignageError("SIGNAGE_NOT_FOUND"); + } + const changes: ViewerChanges = {}; + if (input.name !== undefined && input.name !== viewer.name) { + if (input.name.length === 0 || input.name.length > SIGNAGE_VIEWER_NAME_MAX) { + throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "name" }); + } + Object.assign(changes, { name: input.name }); + } + if (input.channelId !== undefined && input.channelId !== viewer.channelId) { + const groups = await input.store.listChannelGroups(); + const exists = groups.some((group) => + group.channels.some((channel) => channel.id === input.channelId), + ); + if (!exists) { + throw new SignageError("SIGNAGE_NOT_FOUND"); + } + Object.assign(changes, { channelId: input.channelId }); + } + if (Object.keys(changes).length === 0) { + return viewer; + } + await input.store.updateViewer(input.viewerId, changes); + const updated = await input.store.findViewer(input.viewerId); + if (!updated) { + throw new SignageError("SIGNAGE_OPERATION_FAILED"); + } + return updated; +} diff --git a/src/shared/native-images.ts b/src/shared/native-images.ts new file mode 100644 index 0000000..739e1f6 --- /dev/null +++ b/src/shared/native-images.ts @@ -0,0 +1,98 @@ +/** + * Everything about a native Signage image that both halves of the module must agree on. + * + * Import-free on purpose: this file is compiled into the Node server project *and* into the browser + * artifacts, so the server's stored presentation and the display's painted presentation are resolved + * by the same function rather than by two implementations that drift. + */ + +/** The identity Signage's own built-in content provider registers under. It is an ordinary provider + * from every other part of the system's point of view (ADR 0001), which is why there is exactly one + * selection pipeline instead of a native special case. */ +export const SIGNAGE_NATIVE_PROVIDER_ID = "signage.native-images"; + +/** The renderer identity a native image resolves to. Native images are drawn by Signage's own + * display code rather than by a contributed renderer, so this is deliberately not a contribution + * id — a display that sees it renders an image element instead of asking the host to mount + * anything. */ +export const SIGNAGE_NATIVE_RENDERER = "signage.native-image"; + +export const SIGNAGE_ASPECT_16_9 = 16 / 9; + +/** + * How far from exactly 16:9 an image may be and still be treated as filling the frame. + * + * 0.02 accepts the ratios real export pipelines actually produce — 1920×1080 exactly, 1918×1080 from + * a cropped canvas, 1280×720 — and rejects anything a viewer would see letterboxed. It is a + * documented tolerance rather than an exact comparison because an exact one would put a 1919-pixel + * export on a blurred background for no reason a person could see. + */ +export const SIGNAGE_ASPECT_TOLERANCE = 0.02; + +export function isSixteenByNine(width: number, height: number): boolean { + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return false; + } + return Math.abs(width / height - SIGNAGE_ASPECT_16_9) <= SIGNAGE_ASPECT_TOLERANCE; +} + +export type SignageImageFit = "fill" | "contain"; + +/** What a scene layer needs to paint one native image. Every value is already resolved: the layer + * makes no decisions of its own. */ +export interface SignageNativeScene { + readonly fit: SignageImageFit; + readonly backgroundMode: "blurred" | "solid" | "black" | "none"; + readonly backgroundColor: string; + readonly backgroundImage: boolean; + readonly blurPixels: number; + readonly darkenOpacity: number; + readonly shadow: boolean; +} + +/** The presentation values a slot may override, already merged with the installation defaults by + * the server. */ +export interface SignageNativePresentation { + readonly backgroundMode: "blurred" | "solid" | "black"; + readonly backgroundColor: string; + readonly backgroundDarkened: boolean; + readonly blurPixels: number; + readonly foregroundShadow: boolean; +} + +/** How much a darkened background is dimmed. One value rather than a setting: an operator asking for + * "darker" wants the text readable, not a dimmer they have to tune. */ +export const SIGNAGE_DARKEN_OPACITY = 0.25; + +/** + * Resolves one native image plus its slot's presentation into everything a scene layer needs. + * + * A true 16:9 image covers the whole frame on its own — no blurred duplicate, no pillarbox, and no + * floating-card shadow, all of which would be artefacts on an image that already fits. Anything else + * is shown **whole** and backed, because cropping a church's slide to fill a screen cuts words off it. + */ +export function resolveNativeScene( + image: { readonly width: number; readonly height: number }, + presentation: SignageNativePresentation, +): SignageNativeScene { + if (isSixteenByNine(image.width, image.height)) { + return { + backgroundColor: "#000000", + backgroundImage: false, + backgroundMode: "none", + blurPixels: 0, + darkenOpacity: 0, + fit: "fill", + shadow: false, + }; + } + return { + backgroundColor: presentation.backgroundColor, + backgroundImage: presentation.backgroundMode === "blurred", + backgroundMode: presentation.backgroundMode, + blurPixels: presentation.backgroundMode === "blurred" ? presentation.blurPixels : 0, + darkenOpacity: presentation.backgroundDarkened ? SIGNAGE_DARKEN_OPACITY : 0, + fit: "contain", + shadow: true, + }; +} diff --git a/src/shared/playback-timing.ts b/src/shared/playback-timing.ts new file mode 100644 index 0000000..52daec6 --- /dev/null +++ b/src/shared/playback-timing.ts @@ -0,0 +1,306 @@ +/** + * The arithmetic of authoritative playback (ADR 0005). + * + * Import-free, and compiled into **both** halves of the module on purpose: the server reconciles a + * channel group's clock with these functions, and a disconnected display continues from its last + * authoritative anchor with the *same* functions. Two implementations of this would drift, and the + * drift would only ever be visible on a wall, hours into an outage, with nobody watching. + * + * Three properties are what this file exists to guarantee, and each is the one a naive + * implementation loses: + * + * 1. **Absolute instants, never a countdown.** A slot has a start and a deadline; a timer is only a + * hint about when to look again. + * 2. **Reconcile forward from the previous authoritative deadline**, never from "now". A callback + * that fired 40 ms late costs 40 ms once, not 40 ms on every slide for the rest of the day. + * 3. **Catch up by arithmetic.** A screen that was off for a week must not step through a week of + * slides — and must not consume a provider's rotation doing it. + */ + +/** One slot, reduced to exactly what timing needs. */ +export interface TimingSlot { + readonly id: string; + readonly position: number; + readonly enabled: boolean; + /** Half-open `[from, until)`, in absolute epoch milliseconds. */ + readonly eligibleFromMs: number | null; + readonly eligibleUntilMs: number | null; + /** + * What this slot is *expected* to last, from its own override, its provider's registered default, + * or the native-image default. + * + * An estimate, deliberately: the real duration of a provider slot is only known once the provider + * has resolved, and resolving thousands of times to catch up is exactly what this file avoids. The + * estimate decides how far to skip; the resolution decides the deadline of the slot actually + * landed on. + */ + readonly estimatedDurationMs: number; +} + +export interface TimingTimeline { + readonly id: string; + readonly eligibleFromMs: number | null; + readonly eligibleUntilMs: number | null; + /** In position order. */ + readonly slots: readonly TimingSlot[]; +} + +/** Where a channel group's clock currently is. */ +export interface TimingCursor { + readonly slotId: string | null; + readonly startedAtMs: number | null; + readonly deadlineAtMs: number | null; +} + +export type TimingAdvance = + /** The current slot is still running. Nothing to do until its deadline. */ + | { readonly kind: "hold" } + /** Move to this slot, starting exactly here. */ + | { + readonly kind: "advance"; + readonly slotId: string; + readonly startedAtMs: number; + readonly deadlineAtMs: number; + /** How many slot boundaries were crossed to get here. Zero for an ordinary transition; large + * after an outage, and reported so a caller can tell "the next slide" from "catching up". */ + readonly skipped: number; + } + /** Nothing in this timeline is eligible right now. */ + | { readonly kind: "empty" }; + +/** A safety limit on any walk. Reaching it means a bug, not a legitimate schedule, and stopping is + * better than spinning on a wall. */ +export const TIMING_TRANSITION_LIMIT = 10_000; + +export function isTimelineEligible(timeline: TimingTimeline, atMs: number): boolean { + if (timeline.eligibleFromMs !== null && atMs < timeline.eligibleFromMs) { + return false; + } + return timeline.eligibleUntilMs === null || atMs < timeline.eligibleUntilMs; +} + +/** Half-open `[from, until)`: eligible exactly at `from`, ineligible exactly at `until`. It is the + * only convention under which two adjacent windows neither overlap nor leave a gap. */ +export function isSlotEligible(slot: TimingSlot, atMs: number): boolean { + if (!slot.enabled) { + return false; + } + if (slot.eligibleFromMs !== null && atMs < slot.eligibleFromMs) { + return false; + } + return slot.eligibleUntilMs === null || atMs < slot.eligibleUntilMs; +} + +export function eligibleSlots(timeline: TimingTimeline, atMs: number): readonly TimingSlot[] { + if (!isTimelineEligible(timeline, atMs)) { + return []; + } + return timeline.slots.filter((slot) => isSlotEligible(slot, atMs)); +} + +/** + * The first instant in `(after, through]` at which any slot's eligibility can change. + * + * Between two such instants the eligible set is constant, which is exactly what makes skipping whole + * passes of the pattern safe. Without this, a catch-up would happily skip past a slot that was + * scheduled to appear during the outage. + */ +export function firstEligibilityBoundary( + timeline: TimingTimeline, + afterMs: number, + throughMs: number, +): number | null { + let best: number | null = null; + const candidates: (number | null)[] = [timeline.eligibleFromMs, timeline.eligibleUntilMs]; + for (const slot of timeline.slots) { + candidates.push(slot.eligibleFromMs, slot.eligibleUntilMs); + } + for (const value of candidates) { + if (value === null || value <= afterMs || value > throughMs) { + continue; + } + if (best === null || value < best) { + best = value; + } + } + return best; +} + +/** A currently showing slot can be cut short by **its own** window, never by another slot's. */ +function ownInvalidBoundary( + slot: TimingSlot | undefined, + startedAtMs: number, + throughMs: number, +): number | null { + if (!slot) { + return null; + } + const value = slot.eligibleUntilMs; + if (value === null || value <= startedAtMs || value > throughMs) { + return null; + } + return value; +} + +function nextEligibleAfter( + timeline: TimingTimeline, + currentSlotId: string | null, + atMs: number, +): TimingSlot | null { + const eligible = eligibleSlots(timeline, atMs); + if (eligible.length === 0) { + return null; + } + if (currentSlotId === null) { + return eligible[0] as TimingSlot; + } + const current = timeline.slots.find((slot) => slot.id === currentSlotId); + if (!current) { + return eligible[0] as TimingSlot; + } + // The pattern repeats, so "after" wraps. Position — not eligibility order — is what an operator + // arranged, so the successor is the next *position* that happens to be eligible. + const after = eligible.find((slot) => slot.position > current.position); + return after ?? (eligible[0] as TimingSlot); +} + +/** + * The next instant a display or a channel group must look again. + * + * The current slot's deadline, or the next eligibility boundary when nothing is running. `null` + * means there is nothing scheduled to change — an empty timeline with no future windows — and a + * caller should wait to be told rather than poll. + */ +export function nextTimingBoundary( + timeline: TimingTimeline, + cursor: TimingCursor, + atMs: number, +): number | null { + const candidates: number[] = []; + if (cursor.deadlineAtMs !== null && cursor.deadlineAtMs > atMs) { + candidates.push(cursor.deadlineAtMs); + } + const boundaries: (number | null)[] = [timeline.eligibleFromMs, timeline.eligibleUntilMs]; + for (const slot of timeline.slots) { + boundaries.push(slot.eligibleFromMs, slot.eligibleUntilMs); + } + for (const value of boundaries) { + if (value !== null && value > atMs) { + candidates.push(value); + } + } + return candidates.length === 0 ? null : Math.min(...candidates); +} + +/** + * Walks a cursor forward to `atMs`. + * + * The heart of it is the boundary choice: when a slot's deadline has passed, the next slot starts at + * **that deadline**, not at `atMs`. That single decision is what keeps a loop from stretching by the + * accumulated lateness of every timer that ever fired. + * + * Long gaps are crossed with arithmetic. Within one stretch where the eligible set cannot change, + * whole passes of the pattern are skipped by division rather than by stepping — so a week offline + * costs one reconciliation and one provider call, not ten thousand of each. + */ +export function advanceCursor( + timeline: TimingTimeline, + cursor: TimingCursor, + atMs: number, +): TimingAdvance { + const current = + cursor.slotId === null ? undefined : timeline.slots.find((s) => s.id === cursor.slotId); + + // Still running, and still allowed to be: nothing to decide. + if ( + current && + cursor.deadlineAtMs !== null && + cursor.deadlineAtMs > atMs && + isSlotEligible(current, atMs) && + isTimelineEligible(timeline, atMs) + ) { + return { kind: "hold" }; + } + + // Where the next slot legitimately begins. + let boundary = atMs; + if (cursor.deadlineAtMs !== null && cursor.deadlineAtMs <= atMs) { + boundary = cursor.deadlineAtMs; + } + const invalidAt = ownInvalidBoundary(current, cursor.startedAtMs ?? boundary, atMs); + if (invalidAt !== null) { + boundary = Math.min(boundary, invalidAt); + } + + let slotId = cursor.slotId; + let startedAtMs = boundary; + let skipped = 0; + + for (let step = 0; step < TIMING_TRANSITION_LIMIT; step += 1) { + const next = nextEligibleAfter(timeline, slotId, startedAtMs); + if (!next) { + // Nothing eligible at this instant. If something becomes eligible later in the gap, jump + // straight to that instant rather than declaring the timeline empty for the whole outage. + const boundaryAhead = firstEligibilityBoundary(timeline, startedAtMs, atMs); + if (boundaryAhead === null) { + return { kind: "empty" }; + } + startedAtMs = boundaryAhead; + slotId = null; + continue; + } + + const deadlineAtMs = startedAtMs + next.estimatedDurationMs; + if (deadlineAtMs > atMs) { + return { deadlineAtMs, kind: "advance", skipped, slotId: next.id, startedAtMs }; + } + + // The landed-on slot is already over. Skip whole passes of the pattern with a division, but only + // as far as the millisecond before the next instant the eligible set could change. + const changeAt = firstEligibilityBoundary(timeline, startedAtMs, atMs); + const through = changeAt === null ? atMs : changeAt - 1; + const stable = eligibleSlots(timeline, startedAtMs); + const passDuration = stable.reduce((total, slot) => total + slot.estimatedDurationMs, 0); + if (passDuration > 0 && deadlineAtMs <= through) { + const passes = Math.floor((through - startedAtMs) / passDuration); + if (passes > 0) { + startedAtMs += passes * passDuration; + skipped += passes * stable.length; + continue; + } + } + + startedAtMs = deadlineAtMs; + slotId = next.id; + skipped += 1; + } + + // Unreachable for any legitimate schedule. Holding is safer on a wall than spinning. + return { kind: "hold" }; +} + +/** How much of a running slot is left. Never negative, and `null` when nothing is running. */ +export function remainingMs(cursor: TimingCursor, atMs: number): number | null { + if (cursor.deadlineAtMs === null) { + return null; + } + return Math.max(0, cursor.deadlineAtMs - atMs); +} + +/** + * Where a resumed slot's window sits. + * + * The deadline is `now + remainder`, and the start is reconstructed **backwards** from it so the + * whole-slot window stays consistent — a renderer computing progress from `start` and `deadline` + * gets the same answer it would have got had the slot never been paused. A remainder captured under + * a longer duration is clamped, so the reconstructed start can never land after the resume instant. + */ +export function resumeWindow( + atMs: number, + remainderMs: number, + fullDurationMs: number, +): { readonly startedAtMs: number; readonly deadlineAtMs: number } { + const remaining = Math.max(0, Math.min(remainderMs, fullDurationMs)); + const deadlineAtMs = atMs + remaining; + return { deadlineAtMs, startedAtMs: deadlineAtMs - fullDurationMs }; +} diff --git a/src/shared/renderer-input.ts b/src/shared/renderer-input.ts new file mode 100644 index 0000000..6d16d60 --- /dev/null +++ b/src/shared/renderer-input.ts @@ -0,0 +1,100 @@ +/** + * The versioned input Signage hands a provider's browser renderer (ADR 0002, ADR 0006). + * + * This is the domain contract behind Signage's `signage.slide-renderer` capability. The platform + * carries it as opaque JSON and validates only that it *is* JSON (ADR 0041); what it means is + * entirely between Signage and the contributing module, and it is versioned by Signage's own + * capability version so a renderer written against v1 keeps working when v2 exists. + * + * Import-free, because it is compiled into both the Node server that builds it and the browser + * artifacts that consume it. + * + * ## What is deliberately absent + * + * No API client, no realtime subscription, no principal, no permissions, no configuration, no + * database, no storage, no credential, and no URL that is not already inside `payload`. Mounting a + * renderer grants its module nothing: everything it needs arrives here, because Signage is the party + * that was authorized to obtain it. A renderer that needed to reach the network would need a later + * contract version, and this one does not weaken to accommodate it. + */ + +export const SIGNAGE_SLIDE_RENDERER_CONTRACT = "signage.slide-renderer"; + +/** Bumped when the shape below changes in a way a renderer can observe. */ +export const SIGNAGE_SLIDE_RENDERER_INPUT_VERSION = "1.0.0"; + +/** Both clocks at the moment this input was built, so a renderer can compute the authoritative time + * without trusting the local one. + * + * A television's clock is routinely wrong — sometimes by hours. `serverNowMs` is what the server + * said the time was; `localNowMs` is what `Date.now()` said at the same instant on this device. A + * renderer that wants the authoritative now computes `serverNowMs + (Date.now() - localNowMs)`, + * which is correct regardless of how far off the device's clock is, and stays correct across a + * pause because both anchors move together. */ +export interface SignageRendererClock { + readonly serverNowMs: number; + readonly localNowMs: number; +} + +/** The authoritative slot window. A renderer may do anything it likes inside it and may not extend + * it: the deadline was set by Signage before the renderer mounted. */ +export interface SignageRendererTiming { + /** Absolute epoch milliseconds, on the server's clock. */ + readonly startedAtMs: number; + readonly deadlineAtMs: number; + readonly durationMs: number; + readonly paused: boolean; + /** How much of the slot is left when paused. `null` while running — the deadline is the answer. */ + readonly remainingMs: number | null; +} + +export interface SignageSlideRendererInput { + readonly contract: typeof SIGNAGE_SLIDE_RENDERER_CONTRACT; + readonly version: string; + /** Which provider's content this is. Opaque; a renderer already knows its own. */ + readonly providerId: string; + /** The provider's own identity for this content, and the revision of it being rendered. */ + readonly itemId: string; + readonly itemRevision: string; + /** The audience this channel renders. A renderer that has nothing audience-specific ignores it. */ + readonly audienceKey: string; + /** The provider's own payload, exactly as it returned it. */ + readonly payload: unknown; + /** Presentation values the provider supplied to its own renderer, or `null`. Signage never + * interprets, merges, or overrides them, and never restyles a provider's output. */ + readonly presentation: unknown; + readonly timing: SignageRendererTiming; + readonly clock: SignageRendererClock; + /** + * Changes whenever anything in this input changed. + * + * A renderer that keeps internal state keyed on it can tell "the same slide, updated" from "a + * different slide" without comparing the whole input — which is exactly the distinction that lets + * a cancelled event update a mounted React tree instead of restarting its slot. + */ + readonly generation: string; +} + +/** Builds the generation token. Deterministic, so two displays that received the same state compute + * the same token and neither remounts when the other does not. */ +export function signageRendererGeneration(input: { + readonly selectionId: string; + readonly revision: number; + readonly audienceKey: string; + readonly paused: boolean; +}): string { + return `${input.selectionId}:${input.revision}:${input.audienceKey}:${input.paused ? "p" : "r"}`; +} + +/** Whether two inputs describe the same logical slide, so a consumer can update in place rather than + * remounting. Identity — not payload equality — is what decides. */ +export function isSameSignageSlide( + left: SignageSlideRendererInput, + right: SignageSlideRendererInput, +): boolean { + return ( + left.providerId === right.providerId && + left.itemId === right.itemId && + left.audienceKey === right.audienceKey + ); +} diff --git a/src/shared/settings.ts b/src/shared/settings.ts new file mode 100644 index 0000000..f1687c9 --- /dev/null +++ b/src/shared/settings.ts @@ -0,0 +1,69 @@ +/** + * Signage's own operator settings, as one plain, JSON-safe shape. + * + * Deliberately **import-free**. This file is compiled into both halves of the module: the Node + * server project and the browser project, whose TypeScript configuration declares `"types": []` so + * that `process.env` fails to compile. Importing `@ekklesia/module-sdk` here would pull the backend + * contracts barrel — and with it Drizzle's types and Node's globals — into every browser artifact, + * which is exactly the boundary ADR 0024 closed. Reading these values from the platform's + * configuration contract is therefore the server's job (`src/server/settings.ts`). + * + * Every value here is an **operator setting**: it survives restarts and is immutable for the life of + * the process (ADR 0029). Domain state — which timeline is active, which slot is on screen, whether + * playback is paused — lives in Signage's own tables instead, because those must change without one. + */ + +/** How a native image that is not 16:9 is backed. External providers own their own rendering + * completely and are never subject to these values. */ +export const SIGNAGE_BACKGROUND_MODES = ["blurred", "solid", "black"] as const; + +export type SignageBackgroundMode = (typeof SIGNAGE_BACKGROUND_MODES)[number]; + +export function isSignageBackgroundMode(value: unknown): value is SignageBackgroundMode { + return ( + typeof value === "string" && (SIGNAGE_BACKGROUND_MODES as readonly string[]).includes(value) + ); +} + +/** The presentation a native image falls back to when a slot overrides nothing. */ +export interface SignageNativePresentationDefaults { + readonly backgroundMode: SignageBackgroundMode; + readonly backgroundColor: string; + readonly backgroundDarkened: boolean; + readonly blurPixels: number; +} + +export interface SignageSettings { + /** IANA zone every schedule in this installation is written and displayed in. Signage never + * interprets an ambiguous local timestamp: stored instants are absolute, and this exists so a + * *person* reading or writing one sees the zone the church actually keeps. */ + readonly timezone: string; + readonly nativeImageDurationMs: number; + readonly crossfadeMs: number; + readonly nativePresentation: SignageNativePresentationDefaults; + /** How long one content-provider call may take before Signage treats it as a temporary failure. */ + readonly providerTimeoutMs: number; + /** How many resolved selections per channel group are retained as playback history. */ + readonly playbackHistoryLimit: number; + /** Shortest interval between two persisted heartbeat writes for one steady viewer. */ + readonly viewerHeartbeatWriteMs: number; +} + +export const SIGNAGE_HEX_COLOR = /^#[0-9a-f]{6}$/iu; + +/** What Signage falls back to when a host supplies no configuration at all — the same values the + * manifest declares as defaults, so the two can never disagree about what "unset" means. */ +export const SIGNAGE_DEFAULT_SETTINGS: SignageSettings = Object.freeze({ + crossfadeMs: 1000, + nativeImageDurationMs: 7000, + nativePresentation: Object.freeze({ + backgroundColor: "#000000", + backgroundDarkened: false, + backgroundMode: "blurred", + blurPixels: 20, + }), + playbackHistoryLimit: 50, + providerTimeoutMs: 2000, + timezone: "America/Chicago", + viewerHeartbeatWriteMs: 30_000, +}); diff --git a/src/ui/SignageDashboard.tsx b/src/ui/SignageDashboard.tsx new file mode 100644 index 0000000..216e815 --- /dev/null +++ b/src/ui/SignageDashboard.tsx @@ -0,0 +1,142 @@ +import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; +import { EkklesiaUiStyles, EmptyState, Tabs } from "@ekklesia/ui"; +import { type ReactElement, useMemo, useState } from "react"; +import { DesktopOnly } from "../browser/desktop-gate.js"; +import { createSignageApi } from "./api.js"; +import { useResource } from "./hooks.js"; +import { DisplaysPanel } from "./panels/DisplaysPanel.js"; +import { ImagesPanel } from "./panels/ImagesPanel.js"; +import { PlaybackPanel } from "./panels/PlaybackPanel.js"; +import { ProvidersPanel } from "./panels/ProvidersPanel.js"; +import { renderLoadable } from "./panels/shared.js"; +import { TimelinesPanel } from "./panels/TimelinesPanel.js"; +import type { ChannelGroupView, SettingsResponse } from "./types.js"; + +/** + * The Signage management screen. + * + * Host-neutral by construction: every value comes from the host context, and nothing here hardcodes + * a URL, an origin, or a global. Presentation is entirely the platform's shared elements plus + * Signage's own stylesheet, so this looks like the rest of Ekklesia without copying a colour. + * + * Permission checks here decide what an operator is *offered*. The server decides what happens: a + * hidden button is a courtesy, and every route is enforced regardless of what this believes. + */ +export function SignageDashboard({ + context, +}: { + readonly context: ModuleUiHostContext; +}): ReactElement { + const api = useMemo(() => createSignageApi(context.api), [context.api]); + // Read before the resources below, so a viewer who may not see Signage at all asks for nothing. + // A hidden screen is a usability choice; the server is what actually refuses, and making a + // request it will certainly refuse is noise in a log rather than security. + const mayView = context.permissions.has("signage.view"); + const settings = useResource( + () => (mayView ? api.get("settings") : Promise.resolve(undefined)), + [mayView], + ); + const groups = useResource( + () => + mayView + ? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups") + : Promise.resolve(undefined), + [mayView], + ); + const [tab, setTab] = useState("playback"); + const [groupId, setGroupId] = useState(undefined); + + const mayManageAssets = context.permissions.has("signage.assets.manage"); + const mayManageTimelines = context.permissions.has("signage.timelines.manage"); + const mayControl = context.permissions.has("signage.playback.control"); + const mayManageDisplays = context.permissions.has("signage.displays.manage"); + + if (!mayView) { + return ( + + ); + } + + const selectedGroupId = + groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined); + + return ( + <> + + + {renderLoadable( + settings.state, + (loaded) => + loaded === undefined ? undefined : ( + + ), + id: "playback", + label: "Playback", + }, + { + content: ( + { + void api + .send("POST", `playback/${selectedGroupId}/activate`, { + timelineId, + when: "now", + }) + .then(() => setTab("playback")) + .catch(() => setTab("playback")); + } + : undefined + } + settings={loaded} + /> + ), + id: "timelines", + label: "Timelines", + }, + { + content: ( + + ), + id: "images", + label: "Images", + }, + { + content: , + id: "displays", + label: "Displays", + }, + { + content: , + id: "providers", + label: "Providers", + }, + ]} + /> + ), + { label: "Loading Signage…", onRetry: settings.reload }, + )} + + + ); +} diff --git a/src/ui/api.ts b/src/ui/api.ts new file mode 100644 index 0000000..cbdc878 --- /dev/null +++ b/src/ui/api.ts @@ -0,0 +1,107 @@ +import type { ModuleUiApiClient } from "@ekklesia/module-ui-sdk"; + +/** + * Signage's dashboard talking to Signage's own backend. + * + * Every path here is **relative to this module's own API root**, and the host resolves it. Nothing + * in this file builds `/api/v1/modules/signage/...`: that prefix is Ekklesia's private deployment + * detail, and a UI that wrote it would be welded to Ekklesia and unhostable anywhere else. + * + * Failures become one small, typed shape. The server already sanitizes what it says; this keeps a + * network error and a refusal indistinguishable in the places where they should be — a screen that + * cannot load — and distinguishable where they matter, in a form that can show *why* a save was + * refused. + */ + +export interface SignageApiFailure { + readonly code: string; + readonly message: string; + readonly status: number; + readonly detail?: Readonly> | undefined; +} + +export class SignageRequestError extends Error { + readonly failure: SignageApiFailure; + + constructor(failure: SignageApiFailure) { + super(failure.message); + this.name = "SignageRequestError"; + this.failure = failure; + } +} + +const GENERIC: SignageApiFailure = { + code: "SIGNAGE_REQUEST_FAILED", + message: "Signage could not be reached.", + status: 0, +}; + +async function readFailure(response: Response): Promise { + try { + const body = (await response.json()) as { + code?: unknown; + message?: unknown; + detail?: unknown; + }; + if (typeof body.code === "string" && typeof body.message === "string") { + return { + code: body.code, + ...(typeof body.detail === "object" && body.detail !== null + ? { detail: body.detail as SignageApiFailure["detail"] } + : {}), + message: body.message, + status: response.status, + }; + } + } catch { + // A response that is not Signage's own error shape tells the operator nothing useful, and + // guessing at it would put someone else's words on the screen. + } + return { ...GENERIC, status: response.status }; +} + +export interface SignageApi { + get(path: string): Promise; + send( + method: "POST" | "PATCH" | "PUT" | "DELETE", + path: string, + body?: unknown, + ): Promise; + /** One image, one request. The queue that drives this lives in `browser/uploads.ts`. */ + upload(path: string, bytes: Blob, contentType: string): Promise; +} + +export function createSignageApi(client: ModuleUiApiClient): SignageApi { + async function request(path: string, init: RequestInit | undefined): Promise { + let response: Response; + try { + response = await client.request(path, init); + } catch { + throw new SignageRequestError(GENERIC); + } + if (!response.ok) { + throw new SignageRequestError(await readFailure(response)); + } + if (response.status === 204) { + return undefined as Result; + } + try { + return (await response.json()) as Result; + } catch { + throw new SignageRequestError({ ...GENERIC, status: response.status }); + } + } + + return { + get: (path) => request(path, undefined), + send: (method, path, body) => + request(path, { + ...(body === undefined + ? {} + : { body: JSON.stringify(body), headers: { "content-type": "application/json" } }), + method, + }), + upload: (path, bytes, contentType) => + request(path, { body: bytes, headers: { "content-type": contentType }, method: "POST" }), + }; +} diff --git a/src/ui/hooks.ts b/src/ui/hooks.ts new file mode 100644 index 0000000..f24f70f --- /dev/null +++ b/src/ui/hooks.ts @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { type SignageApiFailure, SignageRequestError } from "./api.js"; + +/** + * One loading state, used everywhere, so every panel in the dashboard fails the same way. + * + * The states are the ones an operator can actually act on: still loading, loaded, or failed with a + * reason Signage itself gave. There is no fourth state for "loaded but empty" — a panel decides what + * empty means for its own data, because "no timelines yet" and "no displays yet" are different + * sentences. + */ +export type Loadable = + | { readonly status: "loading" } + | { readonly status: "ready"; readonly value: Value } + | { readonly status: "failed"; readonly failure: SignageApiFailure }; + +export interface Resource { + readonly state: Loadable; + /** Re-reads without flashing the panel back to a spinner: the previous value stays on screen + * until the new one arrives, which is what makes a five-second refresh bearable to look at. */ + reload(): void; +} + +export function useResource( + load: () => Promise, + dependencies: readonly unknown[], +): Resource { + const [state, setState] = useState>({ status: "loading" }); + const generation = useRef(0); + const loadRef = useRef(load); + loadRef.current = load; + + const run = useCallback(() => { + generation.current += 1; + const token = generation.current; + void loadRef + .current() + .then((value) => { + // A response from a request the panel has already moved on from must never overwrite a + // newer one. + if (token === generation.current) { + setState({ status: "ready", value }); + } + }) + .catch((error: unknown) => { + if (token !== generation.current) { + return; + } + setState({ + failure: + error instanceof SignageRequestError + ? error.failure + : { + code: "SIGNAGE_REQUEST_FAILED", + message: "Signage could not be reached.", + status: 0, + }, + status: "failed", + }); + }); + }, []); + + // The caller's dependency list is spread deliberately: a resource reloads when *its* inputs + // change, which is the whole reason this takes one. + useEffect(run, [run, ...dependencies]); + + return { reload: run, state }; +} + +/** A mutation with a pending flag and a sanitized failure, so a button can disable itself and a form + * can say why a save was refused. */ +export interface Action { + readonly pending: boolean; + readonly failure: SignageApiFailure | undefined; + run(work: () => Promise): void; + clearFailure(): void; +} + +export function useAction(onSettled?: () => void): Action { + const [pending, setPending] = useState(false); + const [failure, setFailure] = useState(undefined); + const settled = useRef(onSettled); + settled.current = onSettled; + + const run = useCallback((work: () => Promise) => { + setPending(true); + setFailure(undefined); + void work() + .then(() => { + setPending(false); + settled.current?.(); + }) + .catch((error: unknown) => { + setPending(false); + setFailure( + error instanceof SignageRequestError + ? error.failure + : { + code: "SIGNAGE_REQUEST_FAILED", + message: "Signage could not be reached.", + status: 0, + }, + ); + }); + }, []); + + return { clearFailure: () => setFailure(undefined), failure, pending, run }; +} + +/** Re-renders on an interval, so a countdown or a "last seen" reads correctly without every panel + * inventing its own timer. Paused when the interval is zero. */ +export function useTick(intervalMs: number): number { + const [tick, setTick] = useState(0); + useEffect(() => { + if (intervalMs <= 0) { + return; + } + const handle = setInterval(() => setTick((value) => value + 1), intervalMs); + return () => clearInterval(handle); + }, [intervalMs]); + return tick; +} diff --git a/src/ui/index.tsx b/src/ui/index.tsx new file mode 100644 index 0000000..de715ba --- /dev/null +++ b/src/ui/index.tsx @@ -0,0 +1,25 @@ +import { defineModuleUi } from "@ekklesia/module-ui-sdk"; +import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui"; +import { createRoot } from "react-dom/client"; +import { SignageDashboard } from "./SignageDashboard.js"; +import ownStyles from "./styles.css"; + +/** + * Signage's compiled management-UI artifact. `pnpm build` bundles this file and everything it + * imports into the single browser artifact `entrypoints.ui` declares. + * + * `styles` is this artifact's complete stylesheet — the shared elements' rules plus Signage's own — + * compiled in as text and installed by the host into this artifact's isolated root at mount + * (ADR 0040). The design tokens both stylesheets reference are inherited from the host's own root + * rather than bundled (ADR 0039). + */ +export default defineModuleUi({ + id: "signage", + version: "0.1.0", + styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`, + mount(container, context) { + const root = createRoot(container); + root.render(); + return () => root.unmount(); + }, +}); diff --git a/src/ui/panels/DisplaysPanel.tsx b/src/ui/panels/DisplaysPanel.tsx new file mode 100644 index 0000000..241d77a --- /dev/null +++ b/src/ui/panels/DisplaysPanel.tsx @@ -0,0 +1,288 @@ +import { + Badge, + Button, + EmptyState, + Field, + Panel, + Select, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextInput, +} from "@ekklesia/ui"; +import { type ReactElement, useState } from "react"; +import type { SignageApi } from "../api.js"; +import { useAction, useResource, useTick } from "../hooks.js"; +import type { ChannelGroupView, ViewerHealthView } from "../types.js"; +import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js"; + +/** + * Channel groups, audience channels, and the display roster. + * + * A church starts with one group and one default channel. `in-person` and `online` are created here, + * when someone actually needs them — not shipped as rows nobody asked for and everybody has to + * understand and ignore (ADR 0004). + */ +export function DisplaysPanel({ + api, + canManage, +}: { + readonly api: SignageApi; + readonly canManage: boolean; +}): ReactElement { + const groups = useResource( + () => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), + [], + ); + const viewers = useResource( + () => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"), + [], + ); + const edit = useAction(() => { + groups.reload(); + viewers.reload(); + }); + const [groupName, setGroupName] = useState(""); + const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({ + groupId: "", + key: "", + name: "", + }); + // "Last seen" is a moving number: without a tick it would freeze at whatever it said when the + // roster loaded, and an operator would read a screen that went dark ten minutes ago as current. + useTick(5000); + const nowMs = Date.now(); + + const channels = + groups.state.status === "ready" + ? groups.state.value.groups.flatMap((group) => + group.channels.map((channel) => ({ ...channel, groupName: group.name })), + ) + : []; + + return ( +
+ + + {(control) => ( + setGroupName(event.target.value)} + placeholder="Nursery hallway" + value={groupName} + /> + )} + + +
+ ) : undefined + } + description="A group is one clock. An audience channel is one rendering of it." + title="Channel groups" + > + {renderLoadable( + groups.state, + (value) => ( +
+ {value.groups.map((group) => ( +
+
+

{group.name}

+ {group.playback.transport} +
+
    + {group.channels.map((channel) => ( +
  • + {channel.name} + + {channel.key} + + {canManage && !channel.isDefault ? ( + + ) : undefined} +
  • + ))} +
+ {canManage ? ( +
+ + {(control) => ( + + setChannelDraft({ + groupId: group.id, + key: event.target.value, + name: channelDraft.name, + }) + } + value={channelDraft.groupId === group.id ? channelDraft.key : ""} + /> + )} + + + {(control) => ( + + setChannelDraft({ + groupId: group.id, + key: channelDraft.key, + name: event.target.value, + }) + } + value={channelDraft.groupId === group.id ? channelDraft.name : ""} + /> + )} + + +
+ ) : undefined} +
+ ))} +
+ ), + { label: "Loading channel groups…", onRetry: groups.reload }, + )} + + + + {renderLoadable( + viewers.state, + (value) => + value.viewers.length === 0 ? ( + + ) : ( + + + + Name + State + Channel + Last seen + Offline ready + Most recent error + + + + {value.viewers.map((viewer) => ( + + + {canManage ? ( + + event.target.value !== viewer.name && + edit.run(() => + api.send("PATCH", `viewers/${viewer.id}`, { + name: event.target.value, + }), + ) + } + /> + ) : ( + viewer.name + )} + + + + + + {canManage ? ( + + ) : ( + (channels.find((channel) => channel.id === viewer.channelId)?.name ?? "—") + )} + + {formatSince(viewer.lastSeenAtMs, nowMs)} + + + {viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not ready"} + + + + {viewer.lastError === null ? ( + "—" + ) : ( + + {viewer.lastError.code} + + )} + + + ))} + +
+ ), + { label: "Loading displays…", onRetry: viewers.reload }, + )} + +
+ + ); +} diff --git a/src/ui/panels/ImagesPanel.tsx b/src/ui/panels/ImagesPanel.tsx new file mode 100644 index 0000000..2febcc7 --- /dev/null +++ b/src/ui/panels/ImagesPanel.tsx @@ -0,0 +1,226 @@ +import { Badge, Button, EmptyState, Field, Panel, TextInput } from "@ekklesia/ui"; +import { type ChangeEvent, type ReactElement, useCallback, useMemo, useRef, useState } from "react"; +import { + createUploadQueue, + type UploadFile, + type UploadItem, + type UploadQueue, +} from "../../browser/uploads.js"; +import type { SignageApi } from "../api.js"; +import { useAction, useResource } from "../hooks.js"; +import type { ImageRecord, SettingsResponse } from "../types.js"; +import { FailureNote, formatInstant, renderLoadable } from "./shared.js"; + +/** + * The native image library and its upload queue. + * + * The upload behaviour is the part worth reading: an operator drops twenty slides and gets twenty + * individually bounded requests, a few at a time. The page stays responsive because only a few are + * in flight; one bad file fails alone; and the operator learns about a file that is too large or of + * the wrong type before a byte is sent, while the server still checks both again. + */ +export function ImagesPanel({ + api, + settings, + canManage, +}: { + readonly api: SignageApi; + readonly settings: SettingsResponse; + readonly canManage: boolean; +}): ReactElement { + const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []); + const [uploads, setUploads] = useState([]); + const queueRef = useRef(undefined); + const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined); + const rename = useAction(() => { + setRenaming(undefined); + images.reload(); + }); + const remove = useAction(() => images.reload()); + + const queue = useMemo(() => { + const created = createUploadQueue({ + concurrency: settings.uploads.concurrency, + maxBytes: settings.uploads.maxBytes, + maxFiles: settings.uploads.batchMaxFiles, + onChange: (items) => setUploads(items), + upload: async (file) => { + // One image per request, with the label in the query string so the body is exactly the + // bytes. The server streams and counts them; nothing here buffers a second copy. + const query = new URLSearchParams({ displayName: file.name, filename: file.name }); + await api.upload(`images?${query.toString()}`, file as unknown as Blob, file.type); + }, + }); + queueRef.current = created; + return created; + }, [ + api, + settings.uploads.batchMaxFiles, + settings.uploads.concurrency, + settings.uploads.maxBytes, + ]); + + const onFiles = useCallback( + (event: ChangeEvent) => { + const files = Array.from(event.target.files ?? []) as unknown as UploadFile[]; + if (files.length === 0) { + return; + } + queue.enqueue(files); + void queue.drain().then(() => images.reload()); + event.target.value = ""; + }, + [images, queue], + ); + + const finished = uploads.filter( + (item) => item.status !== "queued" && item.status !== "uploading", + ); + + return ( + + Add images + + + ) : undefined + } + description={`PNG, JPEG, and WebP. Up to ${Math.round(settings.uploads.maxBytes / (1024 * 1024))} MiB each, ${settings.uploads.batchMaxFiles} at a time.`} + title="Images" + > + {uploads.length === 0 ? undefined : ( +
+
    + {uploads.map((item) => ( +
  • + {item.name} + + {item.status} + + {item.message === undefined ? undefined : ( + {item.message} + )} +
  • + ))} +
+ {finished.length === 0 ? undefined : ( + + )} +
+ )} + + {renderLoadable( + images.state, + (value) => + value.images.length === 0 ? ( + + ) : ( +
    + {value.images.map((image) => { + const thumbnail = image.renditions.find( + (rendition) => rendition.kind === "thumbnail", + ); + return ( +
  • + {thumbnail === undefined ? ( +
    No preview
    + ) : ( + {image.displayName} + )} +
    + {renaming?.id === image.id ? ( + + {(control) => ( + + setRenaming({ id: image.id, value: event.target.value }) + } + value={renaming.value} + /> + )} + + ) : ( +

    {image.displayName}

    + )} +

    + {`${image.width}×${image.height} · ${formatInstant(image.createdAt, settings.timezone)}`} +

    + {image.expiresAt === null ? undefined : ( + + {`Expires ${formatInstant(image.expiresAt, settings.timezone)}`} + + )} +
    + {canManage ? ( +
    + {renaming?.id === image.id ? ( + <> + + + + ) : ( + + )} + +
    + ) : undefined} +
  • + ); + })} +
+ ), + { label: "Loading images…", onRetry: images.reload }, + )} + +
+ ); +} diff --git a/src/ui/panels/PlaybackPanel.tsx b/src/ui/panels/PlaybackPanel.tsx new file mode 100644 index 0000000..4bf1f5f --- /dev/null +++ b/src/ui/panels/PlaybackPanel.tsx @@ -0,0 +1,274 @@ +import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; +import { Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui"; +import { type ReactElement, useState } from "react"; +import type { SignageApi } from "../api.js"; +import { useAction, useResource, useTick } from "../hooks.js"; +import type { + ChannelGroupView, + PlaybackDetailView, + SettingsResponse, + TimelineSummary, +} from "../types.js"; +import { PreviewPanel } from "./PreviewPanel.js"; +import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js"; + +/** + * Live transport for one channel group. + * + * Every control here is the same operation the `signage.playback` capability exposes, over the same + * serialized service — a volunteer with a phone and an operator at this screen are pressing the same + * button, and neither can produce a state the other did not ask for. + */ +export function PlaybackPanel({ + api, + context, + settings, + canControl, + groupId, + onGroupChange, +}: { + readonly api: SignageApi; + readonly context: ModuleUiHostContext; + readonly settings: SettingsResponse; + readonly canControl: boolean; + readonly groupId: string | undefined; + readonly onGroupChange: (groupId: string) => void; +}): ReactElement { + const groups = useResource( + () => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), + [], + ); + const timelines = useResource( + () => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"), + [], + ); + const detail = useResource( + () => + groupId === undefined + ? Promise.resolve(undefined) + : api.get(`playback/${groupId}`), + [groupId], + ); + const command = useAction(() => { + detail.reload(); + groups.reload(); + }); + const [activateId, setActivateId] = useState(""); + const previewChannelId = + groups.state.status === "ready" + ? (groups.state.value.groups + .find((group) => group.id === groupId) + ?.channels.find((channel) => channel.isDefault)?.id ?? undefined) + : undefined; + // A one-second tick so the remaining time counts down rather than sitting still between refreshes. + useTick(1000); + + function run(path: string, body?: unknown): void { + if (groupId === undefined) { + return; + } + command.run(() => api.send("POST", `playback/${groupId}/${path}`, body)); + } + + return ( +
+ ( + + {(control) => ( + + )} + + ), + { label: "Loading groups…" }, + )} + description="One clock for every channel in the group." + title="Playback" + > + {groupId === undefined ? ( + + ) : ( + renderLoadable( + detail.state, + (value) => + value === undefined ? ( + + ) : ( +
+
+ + {value.snapshot.transport} + + {value.snapshot.blackout ? Blackout : undefined} + {value.current?.stale === true ? ( + Stale + ) : undefined} + {value.current?.withdrawn === true ? ( + Withdrawn + ) : undefined} + {value.snapshot.pendingTimelineId === null ? undefined : ( + Activation queued + )} +
+ + {value.current === null ? ( + + ) : ( +
+
Provider
+
{value.current.providerId}
+
Item
+
{value.current.itemId}
+
Renderer
+
{value.current.rendererContributionId ?? "—"}
+
Duration
+
{formatDuration(value.current.durationMs)}
+
Remaining
+
+ {value.snapshot.remainingMs === null + ? "—" + : formatDuration(value.snapshot.remainingMs)} +
+
Started
+
{formatInstant(value.current.startedAtMs, settings.timezone)}
+
+ )} + + {canControl ? ( +
+ + {value.snapshot.transport === "paused" ? ( + + ) : ( + + )} + + + {value.snapshot.staticAssetId === null ? undefined : ( + + )} + +
+ ) : undefined} + + {canControl ? ( +
+ {renderLoadable( + timelines.state, + (loaded) => ( + <> + + {(control) => ( + + )} + + + + + ), + { label: "Loading timelines…" }, + )} +
+ ) : undefined} + + {value.history.length === 0 ? undefined : ( +
+

Recently played

+
    + {value.history.slice(0, 8).map((entry) => ( +
  • + {entry.itemId} + + {`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`} + +
  • + ))} +
+
+ )} +
+ ), + { label: "Loading playback…", onRetry: detail.reload }, + ) + )} + +
+ + +
+ ); +} diff --git a/src/ui/panels/PreviewPanel.tsx b/src/ui/panels/PreviewPanel.tsx new file mode 100644 index 0000000..09c8b66 --- /dev/null +++ b/src/ui/panels/PreviewPanel.tsx @@ -0,0 +1,92 @@ +import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; +import { EmptyState, Panel } from "@ekklesia/ui"; +import { type ReactElement, useEffect, useRef } from "react"; +import { SignageSlate } from "../../browser/slate.js"; +import { createDisplayController, type DisplayChannelStateView } from "../../display/controller.js"; +import type { SignageApi } from "../api.js"; + +/** + * The live preview. + * + * It mounts **the display controller itself** — the same scene stage, the same native-image + * presentation resolver, the same transition, the same renderer host — against the same channel + * state a wall reads. A preview that reimplemented any of that would drift from what the room + * actually sees, which makes it worse than no preview at all. + * + * It differs from a display in exactly two ways, both of them `telemetry: false`: it registers no + * viewer, so it never appears in the display roster, and it sends no heartbeat, so it never reports + * health for a screen that does not exist. It drives its own refresh instead of subscribing. + */ +export function PreviewPanel({ + api, + channelId, + context, + refreshMs = 2000, +}: { + readonly api: SignageApi; + readonly channelId: string | undefined; + readonly context: ModuleUiHostContext; + readonly refreshMs?: number; +}): ReactElement { + const stageRef = useRef(null); + + useEffect(() => { + const root = stageRef.current; + if (!root || channelId === undefined) { + return; + } + let stopped = false; + const controller = createDisplayController({ context, root, telemetry: false }); + let handle: ReturnType | undefined; + + async function pump(): Promise { + if (stopped) { + return; + } + try { + const state = await api.get(`channels/${channelId}/display-state`); + if (!stopped) { + await controller.apply(state); + } + } catch { + // A preview that cannot reach the server simply keeps its last frame, exactly as a wall + // does. Nothing about a failed poll belongs in the operator's face. + } + if (!stopped) { + handle = setTimeout(() => void pump(), refreshMs); + } + } + + void controller.start().then(() => pump()); + + return () => { + stopped = true; + if (handle !== undefined) { + clearTimeout(handle); + } + void controller.stop(); + }; + }, [api, channelId, context, refreshMs]); + + return ( + + {channelId === undefined ? ( + + ) : ( +
+
+
+
+ +
+ +
+
+ )} + + ); +} diff --git a/src/ui/panels/ProvidersPanel.tsx b/src/ui/panels/ProvidersPanel.tsx new file mode 100644 index 0000000..928ce99 --- /dev/null +++ b/src/ui/panels/ProvidersPanel.tsx @@ -0,0 +1,116 @@ +import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; +import { + Badge, + EmptyState, + Panel, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@ekklesia/ui"; +import type { ReactElement } from "react"; +import type { SignageApi } from "../api.js"; +import { useResource } from "../hooks.js"; +import type { ProviderHealth, SettingsResponse } from "../types.js"; +import { formatInstant, renderLoadable } from "./shared.js"; + +const SLIDE_RENDERER_CAPABILITY = "signage.slide-renderer"; + +/** + * Provider health, and the one thing only a browser can tell an operator. + * + * A registration is a **running fact**: a provider that was uninstalled or disabled simply is not + * here, and there is no stored row claiming otherwise. Everything shown is either the provider's own + * declared identity or a count Signage kept — never a provider's exception, message, or payload. + * + * Renderer availability is checked here rather than on the server because the server genuinely + * cannot know it: whether the platform published a module's contribution is a fact the *host* holds, + * and `context.contributions` is where it is legible. A provider whose renderer was not published — + * because the module is missing, disabled, or outside Signage's compatible range — is reported as + * exactly that, which is the difference between "this is broken" and "this needs an update". + */ +export function ProvidersPanel({ + api, + context, + settings, +}: { + readonly api: SignageApi; + readonly context: ModuleUiHostContext; + readonly settings: SettingsResponse; +}): ReactElement { + const providers = useResource( + () => api.get<{ providers: readonly ProviderHealth[] }>("providers"), + [], + ); + const published = new Set( + context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id), + ); + + return ( + + {renderLoadable( + providers.state, + (value) => + value.providers.length === 0 ? ( + + ) : ( + + + + Provider + Contract + Renderer + Health + Last success + + + + {value.providers.map((provider) => { + const rendererPublished = published.has(provider.renderer.contributionId); + const isNative = provider.renderer.contributionId === "signage.native-image"; + return ( + + + {provider.providerId} + {`v${provider.providerVersion}`} + + {provider.contractVersion} + + {isNative ? ( + Signage renders this + ) : rendererPublished ? ( + {provider.renderer.contributionId} + ) : ( + Renderer not published + )} + + + {provider.consecutiveFailures === 0 ? ( + Healthy + ) : ( + + {`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`} + + )} + + + {formatInstant(provider.lastSuccessAt, settings.timezone)} + + + ); + })} + +
+ ), + { label: "Loading providers…", onRetry: providers.reload }, + )} +
+ ); +} diff --git a/src/ui/panels/TimelinesPanel.tsx b/src/ui/panels/TimelinesPanel.tsx new file mode 100644 index 0000000..75209da --- /dev/null +++ b/src/ui/panels/TimelinesPanel.tsx @@ -0,0 +1,476 @@ +import { + Badge, + Button, + Checkbox, + EmptyState, + Field, + Panel, + Select, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextInput, +} from "@ekklesia/ui"; +import { type ReactElement, useState } from "react"; +import type { SignageApi } from "../api.js"; +import { useAction, useResource } from "../hooks.js"; +import type { + ImageRecord, + ProviderHealth, + ProviderItemDescriptor, + ProviderSourceDescriptor, + SettingsResponse, + TimelineDetail, + TimelineSummary, +} from "../types.js"; +import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js"; + +const NATIVE_PROVIDER_ID = "signage.native-images"; + +/** + * The timeline editor. + * + * The pattern an operator arranges here is the product: an ordered list of slots, each either a + * provider source that asks for the next item or one pinned item. Repeating a source is how a loop + * gets three announcements in a row — the five occurrences share one rotation, and this screen makes + * that visible by naming the source on every row rather than hiding it. + */ +export function TimelinesPanel({ + api, + settings, + canManage, + onActivate, +}: { + readonly api: SignageApi; + readonly settings: SettingsResponse; + readonly canManage: boolean; + readonly onActivate: ((timelineId: string) => void) | undefined; +}): ReactElement { + const timelines = useResource( + () => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"), + [], + ); + const providers = useResource( + () => api.get<{ providers: readonly ProviderHealth[] }>("providers"), + [], + ); + const [selectedId, setSelectedId] = useState(undefined); + const [newName, setNewName] = useState(""); + const create = useAction(() => { + setNewName(""); + timelines.reload(); + }); + + return ( +
+ + + {(control) => ( + setNewName(event.target.value)} + placeholder="Sunday morning" + value={newName} + /> + )} + + +
+ ) : undefined + } + description="Saved patterns an operator builds on demand. Signage ships none." + title="Timelines" + > + {renderLoadable( + timelines.state, + (value) => + value.timelines.length === 0 ? ( + + ) : ( + + + + Name + Eligible + Revision + Actions + + + + {value.timelines.map((timeline) => ( + + + + + + {timeline.eligibleFrom === null && timeline.eligibleUntil === null + ? "Always" + : `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`} + + {timeline.revision} + + {onActivate === undefined ? undefined : ( + + )} + + + ))} + +
+ ), + { label: "Loading timelines…", onRetry: timelines.reload }, + )} + + + + {selectedId === undefined ? undefined : ( + setSelectedId(undefined)} + providers={providers.state.status === "ready" ? providers.state.value.providers : []} + settings={settings} + timelineId={selectedId} + /> + )} +
+ ); +} + +function TimelineEditor({ + api, + canManage, + onChanged, + onClose, + providers, + settings, + timelineId, +}: { + readonly api: SignageApi; + readonly canManage: boolean; + readonly onChanged: () => void; + readonly onClose: () => void; + readonly providers: readonly ProviderHealth[]; + readonly settings: SettingsResponse; + readonly timelineId: string; +}): ReactElement { + const detail = useResource( + () => api.get(`timelines/${timelineId}`), + [timelineId], + ); + const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []); + const edit = useAction(() => { + detail.reload(); + onChanged(); + }); + const [providerId, setProviderId] = useState(""); + const [sources, setSources] = useState([]); + const [items, setItems] = useState([]); + + function loadProvider(nextProviderId: string): void { + setProviderId(nextProviderId); + setSources([]); + setItems([]); + if (nextProviderId === "") { + return; + } + void api + .get<{ sources: readonly ProviderSourceDescriptor[] }>( + `providers/${encodeURIComponent(nextProviderId)}/sources`, + ) + .then((response) => setSources(response.sources)) + .catch(() => setSources([])); + void api + .get<{ items: readonly ProviderItemDescriptor[] }>( + `providers/${encodeURIComponent(nextProviderId)}/items`, + ) + .then((response) => setItems(response.items)) + .catch(() => setItems([])); + } + + return ( + Close} + description="Slots run in order and repeat. Repeating one source is one rotation, not several." + title="Timeline pattern" + > + {renderLoadable( + detail.state, + (value) => ( +
+

{value.timeline.name}

+ + {canManage ? ( +
+ + {(control) => ( + + )} + + {sources.map((source) => ( + + ))} + {items.slice(0, 20).map((item) => ( + + ))} +
+ ) : undefined} + + {value.sources.length === 0 ? undefined : ( +
+

Configured sources

+ {value.sources.map((source) => ( +
+ {source.label} + {source.providerId} + {canManage ? ( + <> + + + + ) : undefined} +
+ ))} +
+ )} + + {value.slots.length === 0 ? ( + + ) : ( + + + + # + Content + Duration + Enabled + Order + + + + {value.slots.map((slot, index) => { + const source = value.sources.find( + (candidate) => candidate.id === slot.sourceId, + ); + const image = + slot.providerId === NATIVE_PROVIDER_ID && images.state.status === "ready" + ? images.state.value.images.find( + (candidate) => candidate.id === slot.itemId, + ) + : undefined; + return ( + + {slot.position + 1} + + {slot.kind === "provider-source" + ? (source?.label ?? "Removed source") + : (image?.displayName ?? `${slot.providerId} · ${slot.itemId}`)} + + + {slot.durationOverrideMs === null + ? `Provider default (${formatDuration(settings.nativeImageDurationMs)} for images)` + : formatDuration(slot.durationOverrideMs)} + + + + edit.run(() => + api.send("PATCH", `timelines/${timelineId}/slots/${slot.id}`, { + enabled: event.target.checked, + }), + ) + } + /> + + + {canManage ? ( +
+ + + +
+ ) : undefined} +
+
+ ); + })} +
+
+ )} + + {canManage ? ( +
+ + +
+ ) : undefined} +
+ ), + { label: "Loading timeline…", onRetry: detail.reload }, + )} + +
+ ); +} + +function swap(ids: readonly string[], from: number, to: number): string[] { + const next = [...ids]; + const moved = next[from]; + const target = next[to]; + if (moved === undefined || target === undefined) { + return next; + } + next[from] = target; + next[to] = moved; + return next; +} diff --git a/src/ui/panels/shared.tsx b/src/ui/panels/shared.tsx new file mode 100644 index 0000000..961ca61 --- /dev/null +++ b/src/ui/panels/shared.tsx @@ -0,0 +1,91 @@ +import { Badge, ErrorState, LoadingState } from "@ekklesia/ui"; +import type { ReactElement, ReactNode } from "react"; +import type { SignageApiFailure } from "../api.js"; +import type { Loadable } from "../hooks.js"; + +/** + * The one place a panel's loading, failure, and ready states are turned into elements. + * + * Every panel uses it, so a screen that cannot load looks the same wherever it happens — and the + * failure text is always Signage's own sanitized message, never a caught exception. + */ +export function renderLoadable( + state: Loadable, + render: (value: Value) => ReactNode, + options: { readonly label: string; readonly onRetry?: () => void }, +): ReactNode { + if (state.status === "loading") { + return ; + } + if (state.status === "failed") { + return ( + + Try again + + ) + } + description={state.failure.message} + title="Signage could not load this" + /> + ); + } + return render(state.value); +} + +/** A refused save, shown where the operator was working. The server's own sanitized message and + * nothing else: no status line, no URL, no request detail. */ +export function FailureNote({ failure }: { readonly failure: SignageApiFailure | undefined }) { + if (!failure) { + return undefined; + } + return ( +

+ {failure.message} +

+ ); +} + +export function formatDuration(milliseconds: number): string { + const seconds = milliseconds / 1000; + return seconds % 1 === 0 ? `${seconds}s` : `${seconds.toFixed(1)}s`; +} + +export function formatInstant(value: string | number | null, timezone: string): string { + if (value === null) { + return "—"; + } + const date = typeof value === "number" ? new Date(value) : new Date(value); + if (Number.isNaN(date.getTime())) { + return "—"; + } + // Always in the installation's own zone, and always labelled with it. A church reading "9:00" + // needs to know which nine o'clock, and a browser's local zone is not the answer. + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + timeZone: timezone, + }).format(date); +} + +export function formatSince(atMs: number | null, nowMs: number): string { + if (atMs === null) { + return "never"; + } + const seconds = Math.max(0, Math.round((nowMs - atMs) / 1000)); + if (seconds < 60) { + return `${seconds}s ago`; + } + const minutes = Math.round(seconds / 60); + return minutes < 60 ? `${minutes}m ago` : `${Math.round(minutes / 60)}h ago`; +} + +export function OnlineBadge({ online }: { readonly online: boolean }): ReactElement { + return {online ? "Online" : "Offline"}; +} diff --git a/src/ui/styles.css b/src/ui/styles.css new file mode 100644 index 0000000..353ae20 --- /dev/null +++ b/src/ui/styles.css @@ -0,0 +1,522 @@ +/* + * The Signage management UI's own stylesheet. + * + * Compiled into the declared browser artifact as text and installed by the host into this artifact's + * isolated root at mount (ADR 0040), so these selectors reach nothing outside Signage and nothing + * outside Signage reaches in. + * + * Every value is either a registered Ekklesia token or a private `--signage-*` alias derived from + * one. Signage declares no `--ekklesia-*` property: those belong to the platform's global theme, and + * the build rejects a stylesheet that invents one (ADR 0039). Nothing here recolours a provider's + * output or an uploaded image — the Dashboard's theme is the Dashboard's, and a television's content + * belongs to whoever made it. + */ + +/* + * The box model for the tree Signage owns. + * + * An isolated root inherits nothing from the page around it, and the platform's stylesheet declares + * no reset of its own — so under the initial `content-box` a `.ek-control`, which is `width: 100%`, + * is laid out wider than the field containing it by its own padding and border, and slides under + * whatever sits beside it. Setting the box model once here fixes that everywhere, rather than + * subtracting padding by hand in each panel. + */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +:host { + --signage-gap: var(--ekklesia-spacing-lg); + --signage-gap-tight: var(--ekklesia-spacing-sm); + --signage-panel-radius: var(--ekklesia-radius-lg); + --signage-thumbnail-width: 220px; +} + +.signage-desktop { + display: none; +} + +.signage-narrow-notice { + background: var(--ekklesia-background-surface); + border: var(--ekklesia-border-width) solid var(--ekklesia-border-base); + border-radius: var(--signage-panel-radius); + color: var(--ekklesia-text-primary); + display: grid; + font-family: var(--ekklesia-typography-family-sans); + gap: var(--signage-gap-tight); + padding: var(--ekklesia-spacing-xl); +} + +.signage-narrow-notice__title { + font-size: var(--ekklesia-typography-size-heading-3); + font-weight: var(--ekklesia-typography-weight-bold); + line-height: var(--ekklesia-typography-line-height-tight); + margin: 0; +} + +.signage-narrow-notice__body { + color: var(--ekklesia-text-secondary); + line-height: var(--ekklesia-typography-line-height-normal); + margin: 0; +} + +@media (min-width: 1024px) { + .signage-narrow-notice { + display: none; + } + + .signage-desktop { + display: grid; + gap: var(--signage-gap); + } +} + +.signage-columns { + display: grid; + gap: var(--signage-gap); +} + +.signage-inline-form { + align-items: end; + display: flex; + flex-wrap: wrap; + gap: var(--signage-gap-tight); +} + +/* + * A labelled control beside the button that submits it. The field takes the width its control asks + * for and is allowed to shrink below it, so a narrow header squeezes the control rather than sliding + * the button on top of it. + */ +.signage-inline-form > .ek-field { + min-width: 0; + position: relative; +} + +.signage-inline-form .ek-control { + min-width: 0; +} + +/* + * On one line, a field's hint hangs below the row rather than making that one field taller. The row + * is bottom-aligned so the controls share a line with the button, and a hint left in flow would push + * its own control above the others. + */ +.signage-inline-form > .ek-field > .ek-field__hint { + inset-block-start: 100%; + inset-inline-start: 0; + padding-block-start: var(--ekklesia-spacing-2xs); + position: absolute; +} + +.signage-inline-form:has(.ek-field__hint) { + padding-block-end: var(--ekklesia-spacing-lg); +} + +.signage-failure { + color: var(--ekklesia-text-danger); + font-size: var(--ekklesia-typography-size-sm); + margin: var(--signage-gap-tight) 0 0; +} + +/* + * A list row that carries a name beside its metadata. The platform's list item is a plain block, so + * without this the two run together into one unreadable word — and the row's own children already + * assume a flex line. + */ +.signage-row { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--ekklesia-spacing-sm); +} + +.signage-row__name { + flex: 1 1 auto; +} + +/* --------------------------------------------------------------------------------------------- + * Images. + * ------------------------------------------------------------------------------------------- */ + +.signage-upload-trigger { + cursor: pointer; + display: inline-flex; +} + +.signage-upload-input { + height: 1px; + opacity: 0; + position: absolute; + width: 1px; +} + +.signage-uploads { + display: grid; + gap: var(--signage-gap-tight); + margin-bottom: var(--signage-gap); +} + +.signage-uploads__message { + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); +} + +.signage-image-grid { + display: grid; + gap: var(--signage-gap); + grid-template-columns: repeat(auto-fill, minmax(var(--signage-thumbnail-width), 1fr)); + list-style: none; + margin: 0; + padding: 0; +} + +.signage-image { + /* Rows sized to their content, with the slack from a taller neighbour left at the bottom rather + than spread between the thumbnail and the name. */ + align-content: start; + background: var(--ekklesia-background-canvas); + border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); + border-radius: var(--ekklesia-radius-md); + display: grid; + gap: var(--ekklesia-spacing-xs); + overflow: hidden; + padding: var(--ekklesia-spacing-sm); +} + +/* One box, whatever shape the image is: a library of mixed aspect ratios still reads as a grid, and + a tall image is letterboxed here rather than reflowing the card it sits in. */ +.signage-image__thumbnail { + aspect-ratio: 16 / 9; + background: var(--ekklesia-background-inset); + border-radius: var(--ekklesia-radius-sm); + display: block; + height: auto; + max-width: 100%; + object-fit: contain; + width: 100%; +} + +.signage-image__missing { + align-items: center; + aspect-ratio: 16 / 9; + background: var(--ekklesia-background-inset); + border-radius: var(--ekklesia-radius-sm); + color: var(--ekklesia-text-muted); + display: flex; + font-size: var(--ekklesia-typography-size-sm); + justify-content: center; +} + +.signage-image__meta { + display: grid; + gap: var(--ekklesia-spacing-2xs); +} + +/* A badge is sized by what it says, not by the column it lands in. */ +.signage-image__expiry { + justify-self: start; +} + +.signage-image__name { + font-weight: var(--ekklesia-typography-weight-medium); + margin: 0; + overflow-wrap: anywhere; +} + +.signage-image__detail { + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); + margin: 0; +} + +.signage-image__actions { + display: flex; + flex-wrap: wrap; + gap: var(--ekklesia-spacing-2xs); +} + +/* --------------------------------------------------------------------------------------------- + * Timelines. + * ------------------------------------------------------------------------------------------- */ + +.signage-editor { + display: grid; + gap: var(--signage-gap); +} + +.signage-editor__title { + font-size: var(--ekklesia-typography-size-heading-3); + font-weight: var(--ekklesia-typography-weight-bold); + margin: 0; +} + +.signage-editor__subtitle { + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); + font-weight: var(--ekklesia-typography-weight-medium); + margin: 0; + text-transform: uppercase; +} + +.signage-editor__add, +.signage-editor__sources, +.signage-editor__footer { + display: flex; + flex-wrap: wrap; + gap: var(--signage-gap-tight); +} + +.signage-editor__sources { + display: grid; + gap: var(--ekklesia-spacing-xs); +} + +.signage-editor__source { + align-items: center; + display: flex; + gap: var(--signage-gap-tight); +} + +.signage-editor__order { + display: flex; + gap: var(--ekklesia-spacing-2xs); +} + +/* --------------------------------------------------------------------------------------------- + * Playback. + * ------------------------------------------------------------------------------------------- */ + +.signage-playback { + display: grid; + gap: var(--signage-gap); +} + +.signage-playback__status, +.signage-playback__controls, +.signage-playback__activate { + align-items: end; + display: flex; + flex-wrap: wrap; + gap: var(--signage-gap-tight); +} + +.signage-playback__current { + column-gap: var(--signage-gap); + display: grid; + grid-template-columns: max-content 1fr; + margin: 0; + row-gap: var(--ekklesia-spacing-2xs); +} + +.signage-playback__current dt { + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); +} + +.signage-playback__current dd { + font-variant-numeric: tabular-nums; + margin: 0; +} + +.signage-playback__history-meta { + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); +} + +/* --------------------------------------------------------------------------------------------- + * Displays and providers. + * ------------------------------------------------------------------------------------------- */ + +.signage-groups { + display: grid; + gap: var(--signage-gap); +} + +.signage-group { + border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); + border-radius: var(--ekklesia-radius-md); + display: grid; + gap: var(--signage-gap-tight); + padding: var(--ekklesia-spacing-md); +} + +.signage-group__header { + align-items: center; + display: flex; + gap: var(--signage-gap-tight); +} + +.signage-provider__id { + font-weight: var(--ekklesia-typography-weight-medium); +} + +.signage-provider__version { + color: var(--ekklesia-text-muted); + font-size: var(--ekklesia-typography-size-sm); + /* Far enough that the version reads as a second word, not the tail of the identifier. */ + margin-left: var(--ekklesia-spacing-sm); +} + +/* --------------------------------------------------------------------------------------------- + * Live preview. + * + * The frame is a 16:9 box with a black ground, because that is what a television is. Inside it the + * display's own scene layers render exactly as they do on a wall — this panel supplies a size and + * nothing else. + * ------------------------------------------------------------------------------------------- */ + +.signage-preview { + display: grid; + gap: var(--signage-gap-tight); +} + +.signage-preview__frame { + aspect-ratio: 16 / 9; + background: #000000; + border: var(--ekklesia-border-width) solid var(--ekklesia-border-strong); + border-radius: var(--ekklesia-radius-md); + overflow: hidden; + position: relative; + width: 100%; +} + +.signage-preview .signage-stage { + height: 100%; + inset: 0; + min-height: 0; + position: absolute; + width: 100%; +} + +.signage-preview .signage-layer { + height: 100%; + inset: 0; + opacity: 0; + position: absolute; + transition-duration: 0ms; + transition-property: opacity; + transition-timing-function: linear; + width: 100%; +} + +.signage-preview .signage-layer__background { + background: #000000; + inset: 0; + position: absolute; +} + +.signage-preview .signage-layer__background-image { + display: none; + height: 100%; + inset: 0; + object-fit: cover; + position: absolute; + transform: scale(1.08); + width: 100%; +} + +.signage-preview .signage-layer__darken { + background: #000000; + inset: 0; + opacity: 0; + position: absolute; +} + +.signage-preview .signage-layer__foreground { + height: 100%; + inset: 0; + object-fit: contain; + position: absolute; + width: 100%; +} + +.signage-preview .signage-layer__renderer { + inset: 0; + position: absolute; +} + +.signage-preview .signage-blackout { + background: #000000; + inset: 0; + opacity: 0; + pointer-events: none; + position: absolute; + transition: opacity 200ms linear; + z-index: 20; +} + +.signage-preview [data-blackout="true"] > .signage-blackout { + opacity: 1; +} + +.signage-preview .signage-slate-host { + inset: 0; + opacity: 0; + pointer-events: none; + position: absolute; + z-index: 10; +} + +.signage-preview [data-slate="true"] > .signage-slate-host { + opacity: 1; +} + +.signage-preview .signage-slate { + align-items: stretch; + display: flex; + flex-direction: column; + height: 100%; + inset: 0; + position: absolute; + width: 100%; +} + +.signage-preview .signage-slate__bars { + display: grid; + flex: 1 1 auto; + grid-template-columns: repeat(6, 1fr); +} + +.signage-preview .signage-slate__bar { + display: block; + height: 100%; +} + +.signage-preview .signage-slate__bar--one { + background: #6f6f6f; +} + +.signage-preview .signage-slate__bar--two { + background: #6f6f2f; +} + +.signage-preview .signage-slate__bar--three { + background: #2f6f6f; +} + +.signage-preview .signage-slate__bar--four { + background: #2f6f2f; +} + +.signage-preview .signage-slate__bar--five { + background: #6f2f6f; +} + +.signage-preview .signage-slate__bar--six { + background: #6f2f2f; +} + +.signage-preview .signage-slate__message { + background: #000000; + color: #ffffff; + flex: 0 0 auto; + font-size: 2vh; + letter-spacing: 0.08em; + margin: 0; + padding: 1.5vh 0; + text-align: center; + text-transform: uppercase; +} diff --git a/src/ui/types.ts b/src/ui/types.ts new file mode 100644 index 0000000..d222e07 --- /dev/null +++ b/src/ui/types.ts @@ -0,0 +1,198 @@ +/** + * The wire shapes Signage's dashboard reads. + * + * Signage owns both ends of every one of these, which is exactly why they are written down: the + * server's route and this file are one contract, and a drift between them is a bug an operator sees + * rather than a compiler does. + */ + +export interface SettingsResponse { + readonly crossfadeMs: number; + readonly moduleVersion: string; + readonly nativeImageDurationMs: number; + readonly nativePresentation: { + readonly backgroundMode: string; + readonly backgroundColor: string; + readonly backgroundDarkened: boolean; + readonly blurPixels: number; + }; + readonly rendererInputVersion: string; + readonly timezone: string; + readonly uploads: { + readonly batchMaxFiles: number; + readonly concurrency: number; + readonly maxBytes: number; + readonly maxPixels: number; + }; +} + +export interface ImageRendition { + readonly kind: string; + readonly url: string; + readonly expiresAt: string; + readonly width: number; + readonly height: number; +} + +export interface ImageRecord { + readonly id: string; + readonly displayName: string; + readonly originalFilename: string; + readonly contentType: string; + readonly byteSize: number; + readonly width: number; + readonly height: number; + readonly status: string; + readonly expiresAt: string | null; + readonly createdAt: string; + readonly renditions: readonly ImageRendition[]; +} + +export interface TimelineSummary { + readonly id: string; + readonly name: string; + readonly description: string | null; + readonly eligibleFrom: string | null; + readonly eligibleUntil: string | null; + readonly revision: number; + readonly updatedAt: string; +} + +export interface TimelineSource { + readonly id: string; + readonly label: string; + readonly providerId: string; + readonly sourceId: string; +} + +export interface TimelineSlot { + readonly id: string; + readonly position: number; + readonly kind: "provider-source" | "pinned-item"; + readonly sourceId: string | null; + readonly providerId: string | null; + readonly itemId: string | null; + readonly enabled: boolean; + readonly durationOverrideMs: number | null; + readonly eligibleFrom: string | null; + readonly eligibleUntil: string | null; + readonly presentation: { + readonly backgroundMode: string | null; + readonly backgroundColor: string | null; + readonly backgroundDarkened: boolean | null; + readonly blurPixels: number | null; + readonly foregroundShadow: boolean | null; + }; +} + +export interface TimelineDetail { + readonly timeline: TimelineSummary; + readonly sources: readonly TimelineSource[]; + readonly slots: readonly TimelineSlot[]; +} + +export interface ProviderHealth { + readonly providerId: string; + readonly providerVersion: string; + readonly contractVersion: string; + readonly renderer: { readonly contributionId: string; readonly contributionVersion: string }; + readonly defaultDurationMs: number; + readonly registeredAt: string; + readonly consecutiveFailures: number; + readonly lastFailureCode: string | null; + readonly lastFailureAt: string | null; + readonly lastSuccessAt: string | null; +} + +export interface ProviderSourceDescriptor { + readonly id: string; + readonly label: string; + readonly description?: string; +} + +export interface ProviderItemDescriptor { + readonly id: string; + readonly label: string; + readonly description?: string; + readonly availableUntil?: string | null; +} + +export type TransportState = "idle" | "playing" | "paused" | "static"; + +export interface PlaybackSnapshotView { + readonly groupId: string; + readonly revision: number; + readonly serverNowMs: number; + readonly transport: TransportState; + readonly blackout: boolean; + readonly activeTimelineId: string | null; + readonly pendingTimelineId: string | null; + readonly scheduledTimelineId: string | null; + readonly scheduledActivationAtMs: number | null; + readonly currentSelectionId: string | null; + readonly currentSlotId: string | null; + readonly startedAtMs: number | null; + readonly deadlineAtMs: number | null; + readonly remainingMs: number | null; + readonly selectionRevision: number | null; + readonly staticAssetId: string | null; +} + +export interface ChannelView { + readonly id: string; + readonly key: string; + readonly name: string; + readonly isDefault: boolean; +} + +export interface ChannelGroupView { + readonly id: string; + readonly name: string; + readonly revision: number; + readonly channels: readonly ChannelView[]; + readonly playback: PlaybackSnapshotView; +} + +export interface PlaybackDetailView { + readonly snapshot: PlaybackSnapshotView; + readonly current: { + readonly id: string; + readonly itemId: string; + readonly itemRevision: string; + readonly providerId: string; + readonly rendererContributionId: string | null; + readonly revision: number; + readonly stale: boolean; + readonly withdrawn: boolean; + readonly durationMs: number; + readonly startedAtMs: number | null; + readonly deadlineAtMs: number | null; + } | null; + readonly history: readonly { + readonly id: string; + readonly itemId: string; + readonly providerId: string; + readonly sequence: number; + readonly startedAtMs: number | null; + }[]; +} + +export interface ViewerHealthView { + readonly id: string; + readonly name: string; + readonly online: boolean; + readonly channelId: string | null; + readonly deviceId: string | null; + readonly lastSeenAtMs: number | null; + readonly reportedTimelineId: string | null; + readonly reportedSelectionId: string | null; + readonly reportedProviderId: string | null; + readonly reportedContributionId: string | null; + readonly offlineReady: boolean; + readonly cachedAssetCount: number; + readonly lastError: { + readonly code: string; + readonly detail: string | null; + readonly atMs: number; + } | null; +} diff --git a/tests/acceptance.test.ts b/tests/acceptance.test.ts new file mode 100644 index 0000000..4f82ae6 --- /dev/null +++ b/tests/acceptance.test.ts @@ -0,0 +1,269 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeEach, describe, expect, it } from "vitest"; +import { ingestNativeImage } from "../src/server/images/service.js"; +import { + createPlaybackService, + type SignagePlaybackService, +} from "../src/server/playback/service.js"; +import { + createNativeImageProvider, + SIGNAGE_NATIVE_LIBRARY_SOURCE, +} from "../src/server/providers/native-provider.js"; +import { + createProviderRegistry, + type SignageProviderRegistry, +} from "../src/server/providers/registry.js"; +import { addProviderSource, 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"; + +/** + * The scenarios a church would check before trusting this on a wall. + * + * Most of them are proven in the suite that owns the behaviour — provider policy in + * `providers.test.ts`, transport in `playback.test.ts`, transitions in `browser/scene.test.ts`. What + * lives here is the handful that cross those seams, plus the two sweeps that are only meaningful + * across the whole module. + */ + +const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)); +const sourceDirectory = path.join(path.dirname(moduleDirectory), "src"); + +/** + * Strips comments before scanning. + * + * These sweeps are about what the code *does*, and this module's comments deliberately name the + * things it refuses to use — "never reads `process.env`", "no MQTT" — which is exactly the prose + * that makes the refusal legible. Scanning it would punish saying so. + */ +function withoutComments(text: string): string { + return text.replace(/\/\*[\s\S]*?\*\//gu, " ").replace(/(^|[^:])\/\/.*$/gmu, "$1"); +} + +async function sourceFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await sourceFiles(full))); + } else if (/\.(?:ts|tsx|css)$/u.test(entry.name)) { + files.push(full); + } + } + return files; +} + +describe("Signage on its own, with no content module installed", () => { + let store: MemorySignageStore; + let storage: FakeStorage; + let registry: SignageProviderRegistry; + let service: SignagePlaybackService; + let clock: number; + let counter: number; + + const newId = () => { + counter += 1; + return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`; + }; + + beforeEach(async () => { + clock = Date.parse("2026-08-30T15:00:00.000Z"); + counter = 0; + store = createMemorySignageStore({ now: () => new Date(clock) }); + storage = createFakeStorage({ now: () => new Date(clock) }); + // Nothing but Signage's own image provider. No Announcements, no Trivia, no Scripture. + registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 }); + registry.capability.register( + createNativeImageProvider({ + defaultDurationMs: SIGNAGE_DEFAULT_SETTINGS.nativeImageDurationMs, + now: () => new Date(clock), + store, + }).registration, + ); + service = createPlaybackService({ + newId, + now: () => clock, + registry, + setTimer: () => undefined, + settings: SIGNAGE_DEFAULT_SETTINGS, + store, + }); + await service.ensureDefaultGroup(); + }); + + async function upload(name: string): Promise { + const result = await ingestNativeImage({ + bytes: await makeImage({ format: "png", height: 1080, width: 1920 }), + displayName: name, + expiresAt: null, + filename: `${name}.png`, + storage, + store, + }); + return result.image.id; + } + + it("plays a loop of native images, with nothing else installed", async () => { + const first = await upload("welcome"); + // A second image must have different bytes, or content addressing would deduplicate it. + const second = ( + await ingestNativeImage({ + bytes: await makeImage({ format: "png", height: 900, width: 1600 }), + displayName: "giving", + expiresAt: null, + filename: "giving.png", + storage, + store, + }) + ).image.id; + const groups = await store.listChannelGroups(); + const groupId = groups[0]?.group.id as string; + + let timeline = await createTimeline({ name: "Lobby loop", newId, store }); + timeline = await addProviderSource({ + label: "All Signage images", + newId, + providerId: SIGNAGE_NATIVE_PROVIDER_ID, + sourceId: SIGNAGE_NATIVE_LIBRARY_SOURCE, + store, + timelineId: timeline.timeline.id, + }); + const source = timeline.sources[0]; + if (!source) { + throw new Error("Expected a source."); + } + await addSlot({ + newId, + source: { sourceId: source.id }, + store, + timelineId: timeline.timeline.id, + }); + + await service.activate(groupId, timeline.timeline.id, "now"); + const played: (string | undefined)[] = []; + for (let index = 0; index < 3; index += 1) { + const snapshot = await service.snapshot(groupId); + const selection = await store.findSelection(snapshot.currentSelectionId ?? ""); + played.push(selection?.itemId); + clock += SIGNAGE_DEFAULT_SETTINGS.nativeImageDurationMs; + await service.reconcile(groupId, "timer"); + } + + // Signage works out of the box: its own provider, its own images, its own rotation. + expect(played).toEqual([first, second, first]); + }); + + it("gives two displays that connected at different moments the same slot and deadline", async () => { + await upload("welcome"); + const groups = await store.listChannelGroups(); + const groupId = groups[0]?.group.id as string; + const timeline = await createTimeline({ name: "Lobby loop", newId, store }); + await addSlot({ + newId, + pinned: { itemId: await upload("second"), providerId: SIGNAGE_NATIVE_PROVIDER_ID }, + store, + timelineId: timeline.timeline.id, + }); + await service.activate(groupId, timeline.timeline.id, "now"); + + // One display asks now; another asks two seconds later, mid-slot. + const early = await service.snapshot(groupId); + clock += 2000; + const late = await service.snapshot(groupId); + + // Both read the same persisted absolute instants. There is no countdown to drift, so two screens + // that joined at different moments transition together to the millisecond. + expect(late.currentSelectionId).toBe(early.currentSelectionId); + expect(late.startedAtMs).toBe(early.startedAtMs); + expect(late.deadlineAtMs).toBe(early.deadlineAtMs); + // And what each of them has left is computed from that one deadline, not from when it arrived. + expect((early.remainingMs ?? 0) - (late.remainingMs ?? 0)).toBe(2000); + }); +}); + +describe("what this release deliberately does not contain", () => { + it("names no excluded technology anywhere in its source", async () => { + const files = await sourceFiles(sourceDirectory); + const contents = await Promise.all( + files.map(async (file) => ({ file, text: withoutComments(await readFile(file, "utf8")) })), + ); + + // Every one of these is explicitly out of scope for this release. A single mention would mean + // something crept in that an operator would then have to support. + const forbidden = [ + "mqtt", + "home-assistant", + "homeassistant", + "socket.io", + "socketio", + "sqlite", + "better-auth", + "@aws-sdk", + "s3client", + "minio", + "seaweedfs", + "nginx", + "docker-compose", + ]; + for (const { file, text } of contents) { + const lowered = text.toLowerCase(); + for (const term of forbidden) { + expect(`${path.relative(sourceDirectory, file)}:${term}`).toBe( + lowered.includes(term) + ? `IMPOSSIBLE:${term}` + : `${path.relative(sourceDirectory, file)}:${term}`, + ); + } + } + }); + + it("hardcodes no platform URL, origin, or environment variable", async () => { + const files = await sourceFiles(sourceDirectory); + const contents = await Promise.all( + files.map(async (file) => ({ file, text: withoutComments(await readFile(file, "utf8")) })), + ); + + for (const { file, text } of contents) { + const relative = path.relative(sourceDirectory, file); + // Ekklesia's module API prefix is its private deployment detail; a module that wrote it would + // be unhostable anywhere else. + expect(`${relative}:${text.includes("/api/v1/")}`).toBe(`${relative}:false`); + // Configuration reaches Signage through one contract, and it is not the environment. + expect(`${relative}:${/process\.env/u.test(text)}`).toBe(`${relative}:false`); + // Only comments and documentation may mention a real host; nothing may fetch one. + const fetchesHost = /(?:fetch|request)\(\s*["'`]https?:\/\//u.test(text); + expect(`${relative}:${fetchesHost}`).toBe(`${relative}:false`); + } + }); + + it("declares no dependency this release excludes", async () => { + const manifest = JSON.parse( + await readFile(path.join(path.dirname(moduleDirectory), "package.json"), "utf8"), + ) as { dependencies?: Record; devDependencies?: Record }; + const names = [ + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.devDependencies ?? {}), + ]; + + for (const forbidden of [ + "mqtt", + "socket.io", + "socket.io-client", + "better-auth", + "better-sqlite3", + "@aws-sdk/client-s3", + "minio", + "pg", + ]) { + expect(names).not.toContain(forbidden); + } + // The database is reached only through the platform's facade; there is no client of Signage's own. + expect(names).toContain("drizzle-orm"); + }); +}); diff --git a/tests/browser/contributed-renderer.test.tsx b/tests/browser/contributed-renderer.test.tsx new file mode 100644 index 0000000..2cd1943 --- /dev/null +++ b/tests/browser/contributed-renderer.test.tsx @@ -0,0 +1,366 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createSceneStage, type SceneStage } from "../../src/browser/scene.js"; +import { rendererInputFor } from "../../src/browser/scene-request.js"; +import { createMemoryMediaCache, createMemoryStore } from "../../src/browser/storage.js"; +import { + createDisplayController, + type DisplayChannelStateView, +} from "../../src/display/controller.js"; +import { createFakeHost } from "./support/fake-host.js"; +import { + createFixtureRenderer, + createReadiness, + type FixtureRendererCounters, +} from "./support/fixture-renderer.js"; + +/** + * A provider's live React renderer, on a Signage slide. + * + * The renderer here is test-only and is built with the platform's own `defineModuleContribution`, so + * what these prove is the real contract: Signage hands it identity, timing, and an opaque payload, + * mounts it through the host, waits for it to say it is ready, updates it in place, and takes it + * down deterministically — and never learns anything about what it draws. + */ + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const SELECTION = { + availableUntilMs: null, + deadlineAtMs: 20_000, + durationMs: 20_000, + id: "sel-trivia", + itemId: "question-1", + itemRevision: "1", + payload: { answer: "Bethlehem", question: "Where was Jesus born?", revealAfterMs: 2000 }, + presentation: null, + providerId: "fixture.trivia", + renderer: { + contributionId: "fixture.trivia.slide", + contributionVersion: "1.0.0", + kind: "contribution" as const, + }, + revision: 1, + slotId: "slot-trivia", + startedAtMs: 0, + withdrawn: false, +}; + +const CONTEXT = { + audienceKey: "default", + localNowMs: 1_000_000, + paused: false, + remainingMs: null, + serverNowMs: 5000, +}; + +async function settle(): Promise { + for (let index = 0; index < 10; index += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +describe("a provider's contributed renderer", () => { + let root: HTMLElement; + let counters: FixtureRendererCounters; + let stage: SceneStage; + + function mountFixture(readiness?: ReturnType) { + const fixture = createFixtureRenderer({ counters, ...(readiness ? { readiness } : {}) }); + return createSceneStage({ + crossfadeMs: 0, + mountContribution: async ({ container, input }) => + (await fixture.definition.mount(container, { + contributionId: fixture.definition.id, + input: input as never, + })) ?? {}, + root, + setTimer: (handler) => { + handler(); + return undefined; + }, + }); + } + + beforeEach(() => { + root = document.createElement("div"); + document.body.append(root); + counters = { mounts: 0, unmounts: 0, updates: 0 }; + }); + + it("is not shown until it says it is ready", async () => { + const readiness = createReadiness(); + stage = mountFixture(readiness); + + const showing = stage.show({ + contributionId: "fixture.trivia.slide", + input: rendererInputFor(SELECTION, CONTEXT), + key: "sel-trivia:default", + kind: "contribution", + }); + await settle(); + + // Mounted, rendering, and still invisible: the stage waits for the renderer's own promise. + expect(counters.mounts).toBe(1); + const layers = [...root.querySelectorAll(".signage-layer")] as HTMLElement[]; + expect(layers.map((layer) => layer.style.opacity)).toEqual(["0", "0"]); + + readiness.ready(); + await act(async () => { + await showing; + }); + expect(layers.filter((layer) => layer.style.opacity === "1")).toHaveLength(1); + }); + + it("runs its own multi-stage presentation inside one Signage slide", async () => { + stage = mountFixture(); + + await act(async () => { + await stage.show({ + contributionId: "fixture.trivia.slide", + input: rendererInputFor(SELECTION, CONTEXT), + key: "sel-trivia:default", + kind: "contribution", + }); + }); + await settle(); + + expect(root.querySelector("[data-testid='fixture-trivia-question']")?.textContent).toBe( + "Where was Jesus born?", + ); + // The answer has not been revealed yet: that is the renderer's own timing, inside one slot whose + // deadline Signage set and the renderer cannot extend. + expect(root.querySelector("[data-testid='fixture-trivia-answer']")).toBeNull(); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 2100)); + }); + + expect(root.querySelector("[data-testid='fixture-trivia-answer']")?.textContent).toBe( + "Bethlehem", + ); + // Still one mount, one slide. Signage never learned that anything happened. + expect(counters.mounts).toBe(1); + expect(counters.updates).toBe(0); + }); + + it("computes its countdown from the clock anchor, not the device's clock", async () => { + stage = mountFixture(); + + await act(async () => { + await stage.show({ + contributionId: "fixture.trivia.slide", + input: rendererInputFor(SELECTION, { + ...CONTEXT, + // A television whose clock is a year out. The anchor is what makes this right. + localNowMs: Date.now(), + serverNowMs: 5000, + }), + key: "sel-trivia:default", + kind: "contribution", + }); + }); + await settle(); + + // Deadline 20s, server now 5s: fifteen seconds left, whatever the device believes. + expect(root.querySelector("[data-testid='fixture-trivia-remaining']")?.textContent).toBe("15s"); + }); + + it("renders a different audience differently, from one logical selection", async () => { + stage = mountFixture(); + + await act(async () => { + await stage.show({ + contributionId: "fixture.trivia.slide", + input: rendererInputFor(SELECTION, { ...CONTEXT, audienceKey: "online" }), + key: "sel-trivia:online", + kind: "contribution", + }); + }); + await settle(); + + expect(root.querySelector("[data-testid='fixture-trivia-audience']")?.textContent).toBe( + "online", + ); + }); + + it("takes new content in place, without remounting or restarting its stage", async () => { + stage = mountFixture(); + await act(async () => { + await stage.show({ + contributionId: "fixture.trivia.slide", + input: rendererInputFor(SELECTION, CONTEXT), + key: "sel-trivia:default", + kind: "contribution", + }); + }); + await settle(); + + await act(async () => { + await stage.show({ + contributionId: "fixture.trivia.slide", + input: rendererInputFor( + { + ...SELECTION, + payload: { answer: "Bethlehem", question: "Cancelled", revealAfterMs: 2000 }, + revision: 2, + }, + CONTEXT, + ), + key: "sel-trivia:default", + kind: "contribution", + }); + }); + await settle(); + + expect(root.querySelector("[data-testid='fixture-trivia-question']")?.textContent).toBe( + "Cancelled", + ); + expect(counters.mounts).toBe(1); + expect(counters.updates).toBe(1); + expect(counters.unmounts).toBe(0); + }); + + it("mounts and unmounts repeatedly without leaking", async () => { + stage = mountFixture(); + + for (let index = 0; index < 4; index += 1) { + await act(async () => { + await stage.show({ + contributionId: "fixture.trivia.slide", + input: rendererInputFor({ ...SELECTION, id: `sel-${index}` }, CONTEXT), + key: `sel-${index}:default`, + kind: "contribution", + }); + }); + await settle(); + } + await act(async () => { + await stage.destroy(); + }); + + // Four mounts, four unmounts, and nothing left in the DOM. + expect(counters.mounts).toBe(4); + expect(counters.unmounts).toBe(4); + expect(root.querySelector("[data-testid='fixture-trivia']")).toBeNull(); + }); + + it("keeps working from cached state with no network at all", async () => { + const store = createMemoryStore(); + const media = createMemoryMediaCache(); + const fixture = createFixtureRenderer({ counters }); + const host = createFakeHost({ + contributions: [ + { capability: "signage.slide-renderer", id: "fixture.trivia.slide", version: "1.0.0" }, + ], + mount: async (options) => { + const instance = await fixture.definition.mount(options.container, { + contributionId: options.contributionId, + input: options.input, + }); + return { + contributionId: options.contributionId, + mounted: true, + unmount: async () => { + await instance?.unmount?.(); + }, + update: async (input) => { + await instance?.update?.(input); + }, + version: "1.0.0", + }; + }, + permissions: [], + routes: [], + }); + const state: DisplayChannelStateView = { + channel: { groupId: "group-1", id: "chan-1", key: "default" }, + crossfadeMs: 0, + current: SELECTION, + playback: { + activeTimelineId: "timeline-1", + blackout: false, + currentSelectionId: SELECTION.id, + currentSlotId: SELECTION.slotId, + deadlineAtMs: 20_000, + pendingTimelineId: null, + remainingMs: 20_000, + revision: 1, + selectionRevision: 1, + startedAtMs: 0, + transport: "playing", + }, + selections: [SELECTION], + serverNowMs: 5000, + staticHold: null, + timeline: { + eligibleFromMs: null, + eligibleUntilMs: null, + id: "timeline-1", + revision: 1, + slots: [ + { + eligibleFromMs: null, + eligibleUntilMs: null, + enabled: true, + estimatedDurationMs: 20_000, + id: "slot-trivia", + position: 0, + }, + ], + }, + }; + + // One successful online state, persisted. + const online = createDisplayController({ + context: host.context, + decodeImage: () => Promise.resolve(), + media, + now: () => 1_000_000, + root, + setTimer: () => undefined, + store, + telemetry: false, + }); + await act(async () => { + await online.start(); + await online.apply(state); + }); + await settle(); + await act(async () => { + await online.stop(); + }); + + // A fresh controller with the same local storage and no server at all. + const offlineRoot = document.createElement("div"); + document.body.append(offlineRoot); + const offline = createDisplayController({ + context: host.context, + decodeImage: () => Promise.resolve(), + media, + now: () => 1_000_000, + root: offlineRoot, + setTimer: () => undefined, + store, + telemetry: false, + }); + await act(async () => { + await offline.start(); + }); + await settle(); + + // The renderer is on screen, from its cached artifact and this display's cached payload — with + // no request made and nothing invented. + expect(offlineRoot.querySelector("[data-testid='fixture-trivia-question']")?.textContent).toBe( + "Where was Jesus born?", + ); + expect(host.requests).toEqual([]); + expect(offline.diagnostics().offline).toBe(true); + await act(async () => { + await offline.stop(); + }); + }); +}); diff --git a/tests/browser/display.test.ts b/tests/browser/display.test.ts new file mode 100644 index 0000000..5d7031b --- /dev/null +++ b/tests/browser/display.test.ts @@ -0,0 +1,471 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it } from "vitest"; +import { createMemoryMediaCache, createMemoryStore } from "../../src/browser/storage.js"; +import { + createDisplayController, + type DisplayChannelStateView, + type DisplayController, +} from "../../src/display/controller.js"; +import { createFakeHost, jsonResponse } from "./support/fake-host.js"; + +/** + * The display, driven end to end against a host: register, fetch state, render, report. + * + * Every collaborator is injected, so what these prove is the display's own decisions — which frame + * it keeps when something fails, what it caches and under what key, what it tells the server, and + * what it never puts on a wall. + */ + +const NATIVE_SCENE = { + backgroundColor: "#000000", + backgroundImage: false, + backgroundMode: "none" as const, + blurPixels: 0, + darkenOpacity: 0, + fit: "fill" as const, + shadow: false, +}; + +function nativeSelection(id: string, itemRevision = "rev-1") { + return { + availableUntilMs: null as number | null, + deadlineAtMs: 5000, + durationMs: 5000, + id, + itemId: `image-${id}`, + itemRevision, + media: { + 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, + }, + }, + payload: { kind: "native-image" }, + presentation: null, + providerId: "signage.native-images", + renderer: { kind: "native-image" as const }, + revision: 1, + scene: NATIVE_SCENE, + slotId: "slot-1", + stale: false, + startedAtMs: 0, + withdrawn: false, + }; +} + +function contributionSelection(id: string, revision = 1) { + return { + availableUntilMs: null, + deadlineAtMs: 5000, + durationMs: 5000, + id, + itemId: "advent", + itemRevision: String(revision), + payload: { headline: revision === 1 ? "Sunday, 5pm" : "Cancelled" }, + presentation: null, + providerId: "fixture.announcements", + renderer: { + contributionId: "fixture.announcements.slide", + contributionVersion: "1.0.0", + kind: "contribution" as const, + }, + revision, + slotId: "slot-1", + stale: false, + startedAtMs: 0, + withdrawn: false, + }; +} + +function state(overrides: Partial = {}): DisplayChannelStateView { + // `??` would fall through on an explicit `null`, which is exactly the case these tests care about. + const current = "current" in overrides ? overrides.current : nativeSelection("sel-1"); + return { + channel: { groupId: "group-1", id: "chan-1", key: "default" }, + crossfadeMs: 0, + current, + playback: { + activeTimelineId: "timeline-1" as string | null, + blackout: false, + currentSelectionId: current?.id ?? null, + currentSlotId: current?.slotId ?? null, + deadlineAtMs: 5000 as number | null, + pendingTimelineId: null as string | null, + remainingMs: 5000 as number | null, + revision: 3, + selectionRevision: current?.revision ?? null, + startedAtMs: 0 as number | null, + transport: "playing", + ...overrides.playback, + }, + selections: overrides.selections ?? (current ? [current] : []), + serverNowMs: 1000, + staticHold: overrides.staticHold ?? null, + timeline: overrides.timeline ?? null, + ...(overrides.channel ? { channel: overrides.channel } : {}), + }; +} + +const SESSION = { + channelId: "chan-1", + channelKey: "default", + groupId: "group-1", + heartbeatIntervalMs: 12_000, + viewerId: "11111111-1111-4111-8111-111111111111", +}; + +describe("display controller", () => { + let root: HTMLElement; + + beforeEach(() => { + root = document.createElement("div"); + document.body.append(root); + }); + + async function settle(): Promise { + for (let index = 0; index < 10; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + function build(options: { + readonly host: ReturnType; + readonly media?: ReturnType; + readonly store?: ReturnType; + readonly telemetry?: boolean; + }): DisplayController { + return createDisplayController({ + context: options.host.context, + decodeImage: () => Promise.resolve(), + media: options.media ?? createMemoryMediaCache(), + newViewerKey: () => "viewer-fixed-key-0001", + root, + // No real timers: the refresh schedule is exercised by calling `apply` directly. + setTimer: () => undefined, + store: options.store ?? createMemoryStore(), + ...(options.telemetry === undefined ? {} : { telemetry: options.telemetry }), + }); + } + + it("registers with a persistent identity and keeps it across restarts", async () => { + const store = createMemoryStore(); + const keys: unknown[] = []; + const host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { + method: "POST", + path: "viewers/session", + respond: ({ init }) => { + keys.push(JSON.parse(String(init?.body))); + return jsonResponse(SESSION); + }, + }, + { + method: "GET", + path: "channels/chan-1/display-state", + respond: () => jsonResponse(state()), + }, + { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, + ], + }); + + const first = build({ host, store }); + await first.start(); + await settle(); + await first.stop(); + + const second = build({ host, store }); + await second.start(); + await settle(); + await second.stop(); + + // The same key both times: a television that reboots is the same screen, not a new one. + expect(keys).toEqual([ + { viewerKey: "viewer-fixed-key-0001" }, + { viewerKey: "viewer-fixed-key-0001" }, + ]); + }); + + it("renders the server's current selection and reports what it is showing", async () => { + const host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, + { + method: "GET", + path: "channels/chan-1/display-state", + respond: () => jsonResponse(state()), + }, + { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, + ], + }); + const controller = build({ host }); + + await controller.start(); + await settle(); + + const diagnostics = controller.diagnostics(); + expect(diagnostics.renderedSelectionId).toBe("sel-1"); + expect(diagnostics.slate).toBe(false); + expect(root.querySelectorAll(".signage-layer")).toHaveLength(2); + await controller.stop(); + }); + + it("caches image bytes under a stable key, so a freshly signed URL finds them again", async () => { + const media = createMemoryMediaCache(); + const stored: string[] = []; + const wrapped = { + ...media, + store: (key: string, url: string) => { + stored.push(key); + return media.store(key, url); + }, + }; + const host = createFakeHost({ permissions: [], routes: [] }); + const controller = build({ host, media: wrapped, telemetry: false }); + await controller.start(); + + await controller.apply(state()); + // The same bytes, a new signature — exactly what the next refresh carries. + const resigned = nativeSelection("sel-1"); + Object.assign(resigned.media.display, { url: "https://cdn.invalid/sel-1?sig=b" }); + await controller.apply(state({ current: resigned })); + await settle(); + + // One store, because the key is built from provider, item, and item revision — never from the + // URL, which changes every time. + expect(stored).toEqual(["signage-media/signage.native-images/image-sel-1/rev-1/display"]); + await controller.stop(); + }); + + it("updates a mounted renderer in place when only the payload changed", async () => { + const mounts: unknown[] = []; + const updates: unknown[] = []; + const host = createFakeHost({ + contributions: [ + { + capability: "signage.slide-renderer", + id: "fixture.announcements.slide", + version: "1.0.0", + }, + ], + mount: (options) => { + mounts.push(options.contributionId); + return { + contributionId: options.contributionId, + mounted: true, + unmount: () => Promise.resolve(), + update: (input) => { + updates.push(input); + return Promise.resolve(); + }, + version: "1.0.0", + }; + }, + permissions: [], + routes: [], + }); + const controller = build({ host, telemetry: false }); + await controller.start(); + + await controller.apply(state({ current: contributionSelection("sel-1", 1) })); + await controller.apply(state({ current: contributionSelection("sel-1", 2) })); + await settle(); + + // One mount, one update: a cancelled event changes the slide without restarting its slot. + expect(mounts).toEqual(["fixture.announcements.slide"]); + expect(updates).toHaveLength(1); + await controller.stop(); + }); + + it("keeps the last good frame and records a code when a renderer is not published", async () => { + const host = createFakeHost({ contributions: [], permissions: [], routes: [] }); + const controller = build({ host, telemetry: false }); + await controller.start(); + + await controller.apply(state()); + const good = controller.diagnostics().renderedSelectionId; + await controller.apply(state({ current: contributionSelection("sel-2", 1) })); + await settle(); + + const diagnostics = controller.diagnostics(); + // Nothing changed on screen, and the reason is a bounded code — never a message on a wall. + expect(diagnostics.renderedSelectionId).toBe(good); + expect(diagnostics.lastError?.code).toBe("SLIDE_UNRENDERABLE"); + expect(diagnostics.unusableSelectionIds).toEqual(["sel-2"]); + await controller.stop(); + }); + + it("does not retry a selection it already knows it cannot render", async () => { + let mountAttempts = 0; + const host = createFakeHost({ + contributions: [ + { + capability: "signage.slide-renderer", + id: "fixture.announcements.slide", + version: "1.0.0", + }, + ], + mount: () => { + mountAttempts += 1; + return Promise.reject(new Error("renderer exploded")); + }, + permissions: [], + routes: [], + }); + const controller = build({ host, telemetry: false }); + await controller.start(); + + await controller.apply(state({ current: contributionSelection("sel-2", 1) })); + await controller.apply(state({ current: contributionSelection("sel-2", 1) })); + await controller.apply(state({ current: contributionSelection("sel-2", 1) })); + await settle(); + + // One attempt. A broken item never becomes a tight retry loop on an unattended screen. + expect(mountAttempts).toBe(1); + await controller.stop(); + }); + + it("shows the slate only when it has nothing at all, and never over a good frame", async () => { + const host = createFakeHost({ permissions: [], routes: [] }); + const controller = build({ host, telemetry: false }); + await controller.start(); + + await controller.apply(state({ current: null, selections: [] })); + await settle(); + expect(root.dataset.slate).toBe("true"); + expect(controller.diagnostics().slate).toBe(true); + + await controller.apply(state()); + await settle(); + expect(root.dataset.slate).toBe("false"); + + // Content disappears again — but a good frame is up, so the wall keeps it rather than flashing + // the slate at a congregation. + await controller.apply(state({ current: null, selections: [] })); + await settle(); + expect(root.dataset.slate).toBe("false"); + expect(controller.diagnostics().renderedSelectionId).toBe("sel-1"); + await controller.stop(); + }); + + it("covers the output for a blackout while the frame underneath stays prepared", async () => { + const host = createFakeHost({ permissions: [], routes: [] }); + const controller = build({ host, telemetry: false }); + await controller.start(); + + await controller.apply(state()); + await controller.apply(state({ playback: { blackout: true } as never })); + await settle(); + + expect(root.dataset.blackout).toBe("true"); + // The hidden timeline is still what it was: ending the blackout shows this frame, not a slate. + expect(controller.diagnostics().renderedSelectionId).toBe("sel-1"); + await controller.stop(); + }); + + it("reports a bounded, sanitized heartbeat and nothing else", async () => { + const beats: unknown[] = []; + const host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, + { + method: "GET", + path: "channels/chan-1/display-state", + respond: () => jsonResponse(state()), + }, + { + method: "POST", + path: /^viewers\/.*\/heartbeat$/u, + respond: ({ init }) => { + beats.push(JSON.parse(String(init?.body))); + return jsonResponse({ acknowledged: true, recorded: true }); + }, + }, + ], + }); + const controller = build({ host }); + + await controller.start(); + await settle(); + + expect(beats.length).toBeGreaterThan(0); + const beat = beats[0] as Record; + expect(Object.keys(beat).sort()).toEqual([ + "cachedAssetCount", + "error", + "offlineReady", + "reportedContributionId", + "reportedPlaybackRevision", + "reportedProviderId", + "reportedSelectionId", + "reportedTimelineId", + ]); + // Identity, counts, and a code. No URL, no payload, no exception, no credential. + const serialized = JSON.stringify(beat); + expect(serialized).not.toContain("https://"); + expect(serialized).not.toContain("payload"); + await controller.stop(); + }); + + it("keeps the frame when the server cannot be reached, and says so quietly", async () => { + const host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, + { + method: "GET", + path: "channels/chan-1/display-state", + respond: () => jsonResponse(state()), + }, + { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, + ], + }); + const controller = build({ host }); + await controller.start(); + await settle(); + const before = controller.diagnostics().renderedSelectionId; + + // Every request now fails, exactly as a dropped uplink does. + Object.assign(host.context.api, { + request: () => Promise.reject(new Error("network is gone")), + }); + await controller.apply(state()); + await settle(); + + expect(controller.diagnostics().renderedSelectionId).toBe(before); + // Nothing about the failure is on screen: the slate is for having no content, not no network. + expect(root.dataset.slate).toBe("false"); + await controller.stop(); + }); + + it("registers no viewer and sends no heartbeat when telemetry is off", async () => { + const host = createFakeHost({ + permissions: [], + routes: [ + { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, + { method: "POST", path: /^viewers\//u, respond: () => jsonResponse({}) }, + ], + }); + const controller = build({ host, telemetry: false }); + + await controller.start(); + await controller.apply(state()); + await settle(); + + // The management preview renders exactly what a wall renders and appears in no roster. + expect(host.requests).toEqual([]); + expect(controller.diagnostics().renderedSelectionId).toBe("sel-1"); + await controller.stop(); + }); +}); diff --git a/tests/browser/management-ui.test.tsx b/tests/browser/management-ui.test.tsx new file mode 100644 index 0000000..40eaae0 --- /dev/null +++ b/tests/browser/management-ui.test.tsx @@ -0,0 +1,493 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SignageDashboard } from "../../src/ui/SignageDashboard.js"; +import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./support/fake-host.js"; + +/** + * The management UI's behaviour, mounted as React rather than inspected as source. + * + * The artifact-level proof lives in `module-ui.test.ts`; this exercises what an operator does — read + * a screen, press a control, be told why a save was refused — against a host that answers only the + * relative paths Signage actually asks for. + */ + +// React needs this to run effects the way a browser does. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLElement; +let root: Root; + +async function render(element: React.ReactElement): Promise { + await act(async () => { + root.render(element); + }); + // Let the resource loads settle. + for (let index = 0; index < 6; index += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +async function click(element: Element | null | undefined): Promise { + if (!element) { + throw new Error("Expected an element to click."); + } + await act(async () => { + (element as HTMLElement).click(); + }); + for (let index = 0; index < 6; index += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +/** + * Types into a controlled React input the way a person does. + * + * Assigning `input.value` directly and dispatching an event is not enough: React tracks the value on + * the element and would treat the change as already handled. Going through the prototype's setter is + * what makes React see it. + */ +async function type(input: HTMLInputElement, value: string): Promise { + const setter = Object.getOwnPropertyDescriptor( + globalThis.HTMLInputElement.prototype, + "value", + )?.set; + await act(async () => { + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +function buttonNamed(label: string): HTMLButtonElement | undefined { + return [...container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === label, + ) as HTMLButtonElement | undefined; +} + +const GROUP = { + channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }], + id: "group-1", + name: "Main", + playback: { + activeTimelineId: "timeline-1", + blackout: false, + currentSelectionId: "sel-1", + currentSlotId: "slot-1", + deadlineAtMs: 1_700_000_005_000, + groupId: "group-1", + pendingTimelineId: null, + remainingMs: 4000, + revision: 7, + scheduledActivationAtMs: null, + scheduledTimelineId: null, + selectionRevision: 1, + serverNowMs: 1_700_000_001_000, + startedAtMs: 1_700_000_000_000, + staticAssetId: null, + transport: "playing" as const, + }, + revision: 1, +}; + +const PLAYBACK_DETAIL = { + current: { + deadlineAtMs: 1_700_000_005_000, + durationMs: 5000, + id: "sel-1", + itemId: "welcome", + itemRevision: "1", + providerId: "fixture.announcements", + rendererContributionId: "fixture.announcements.slide", + revision: 1, + stale: false, + startedAtMs: 1_700_000_000_000, + withdrawn: false, + }, + history: [ + { + id: "sel-0", + itemId: "giving", + providerId: "fixture.announcements", + sequence: 1, + startedAtMs: 1_699_999_990_000, + }, + ], + snapshot: GROUP.playback, +}; + +beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); +}); + +describe("Signage dashboard", () => { + it("shows live transport for the default channel group and drives it with one command each", async () => { + const commands: string[] = []; + const host = createFakeHost({ + permissions: ["signage.view", "signage.playback.control"], + routes: [ + { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, + { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, + { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, + { + method: "GET", + path: "playback/group-1", + respond: () => jsonResponse(PLAYBACK_DETAIL), + }, + { + method: "POST", + path: /^playback\/group-1\//u, + respond: ({ path }) => { + commands.push(path); + return jsonResponse(GROUP.playback); + }, + }, + ], + }); + + await render(); + + expect(container.querySelector("[data-testid='signage-current']")?.textContent).toContain( + "welcome", + ); + expect(container.querySelector("[data-testid='signage-remaining']")?.textContent).toBe("4s"); + + await click(buttonNamed("Pause")); + await click(buttonNamed("Next")); + await click(buttonNamed("Blackout")); + + expect(commands).toEqual([ + "playback/group-1/pause", + "playback/group-1/next", + "playback/group-1/blackout", + ]); + }); + + it("offers no transport controls to an operator who may only view", async () => { + const host = createFakeHost({ + permissions: ["signage.view"], + routes: [ + { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, + { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, + { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, + { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, + ], + }); + + await render(); + + // The current slide is still legible; the buttons that would be refused are simply not offered. + expect(container.querySelector("[data-testid='signage-current']")).not.toBeNull(); + expect(container.querySelector("[data-testid='signage-controls']")).toBeNull(); + }); + + it("shows Signage's own sanitized reason when a save is refused", async () => { + const host = createFakeHost({ + permissions: ["signage.view", "signage.timelines.manage"], + routes: [ + { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, + { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, + { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, + { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, + { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, + { + method: "POST", + path: "timelines", + respond: () => errorResponse("SIGNAGE_INVALID_REQUEST", "The request is not valid.", 400), + }, + ], + }); + + await render(); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (tab) => tab.textContent === "Timelines", + ), + ); + + const input = container.querySelector("input[type='text']") as HTMLInputElement; + await type(input, "Sunday morning"); + await click(buttonNamed("Create")); + + const alert = container.querySelector("[role='alert']"); + expect(alert?.textContent).toBe("The request is not valid."); + // The server's own fixed message, and nothing else: no status code, no URL, no request detail. + expect(alert?.textContent).not.toMatch(/400|http|timelines/u); + }); + + it("says a timeline has no slots rather than showing an empty table", async () => { + const host = createFakeHost({ + permissions: ["signage.view", "signage.timelines.manage"], + routes: [ + { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, + { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, + { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, + { method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) }, + { + method: "GET", + path: "timelines", + respond: () => + jsonResponse({ + timelines: [ + { + description: null, + eligibleFrom: null, + eligibleUntil: null, + id: "timeline-1", + name: "Sunday morning", + revision: 3, + updatedAt: "2026-08-30T15:00:00.000Z", + }, + ], + }), + }, + { + method: "GET", + path: "timelines/timeline-1", + respond: () => + jsonResponse({ + slots: [], + sources: [], + timeline: { + description: null, + eligibleFrom: null, + eligibleUntil: null, + id: "timeline-1", + name: "Sunday morning", + revision: 3, + updatedAt: "2026-08-30T15:00:00.000Z", + }, + }), + }, + ], + }); + + await render(); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (tab) => tab.textContent === "Timelines", + ), + ); + await click(buttonNamed("Sunday morning")); + + expect(container.querySelector("[data-testid='signage-timeline-editor']")).not.toBeNull(); + expect(container.textContent).toContain("This timeline has no slots"); + }); + + it("reports a provider whose renderer the platform did not publish", async () => { + const host = createFakeHost({ + // The platform published nothing against Signage's renderer capability — the contributing + // module is absent, disabled, or outside the compatible range. + contributions: [], + permissions: ["signage.view"], + routes: [ + { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, + { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, + { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, + { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, + { + method: "GET", + path: "providers", + respond: () => + jsonResponse({ + providers: [ + { + consecutiveFailures: 0, + contractVersion: "1.0.0", + defaultDurationMs: 8000, + lastFailureAt: null, + lastFailureCode: null, + lastSuccessAt: "2026-08-30T15:00:00.000Z", + providerId: "fixture.announcements", + providerVersion: "1.0.0", + registeredAt: "2026-08-30T14:00:00.000Z", + renderer: { + contributionId: "fixture.announcements.slide", + contributionVersion: "1.0.0", + }, + }, + ], + }), + }, + ], + }); + + await render(); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (tab) => tab.textContent === "Providers", + ), + ); + + expect(container.textContent).toContain("Renderer not published"); + // Which is a fact only the browser holds: the server cannot know what the host published. + }); + + it("says Signage works without providers rather than looking broken", async () => { + const host = createFakeHost({ + permissions: ["signage.view"], + routes: [ + { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, + { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, + { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, + { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, + { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, + ], + }); + + await render(); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (tab) => tab.textContent === "Providers", + ), + ); + + expect(container.textContent).toContain("No content providers registered"); + expect(container.textContent).toContain("Signage works on its own with native images"); + }); + + it("shows the display roster with online state and offline readiness", async () => { + const host = createFakeHost({ + permissions: ["signage.view", "signage.displays.manage"], + routes: [ + { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, + { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, + { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, + { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, + { + method: "GET", + path: "viewers", + respond: () => + jsonResponse({ + viewers: [ + { + cachedAssetCount: 12, + channelId: "chan-1", + deviceId: null, + id: "viewer-1", + lastError: { atMs: Date.now(), code: "SLIDE_DECODE_FAILED", detail: null }, + lastSeenAtMs: Date.now() - 4000, + name: "Lobby north", + offlineReady: true, + online: true, + reportedContributionId: null, + reportedProviderId: "fixture.announcements", + reportedSelectionId: "sel-1", + reportedTimelineId: "timeline-1", + }, + ], + }), + }, + ], + }); + + await render(); + await click( + [...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"), + ); + + expect(container.textContent).toContain("Online"); + expect(container.textContent).toContain("12 cached"); + // A sanitized code, never a stack or a message a browser happened to have. + expect(container.textContent).toContain("SLIDE_DECODE_FAILED"); + }); + + it("uploads one request per file and reports each one's own outcome", async () => { + const uploaded: string[] = []; + const host = createFakeHost({ + permissions: ["signage.view", "signage.assets.manage"], + routes: [ + { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, + { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, + { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, + { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, + { method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) }, + { + method: "POST", + path: /^images\?/u, + respond: ({ path }) => { + uploaded.push(path); + return path.includes("broken") + ? errorResponse( + "SIGNAGE_IMAGE_MALFORMED", + "The uploaded file is not a decodable image.", + 422, + ) + : jsonResponse({ deduplicated: false, image: {} }, 201); + }, + }, + ], + }); + + await render(); + await click( + [...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"), + ); + + const input = container.querySelector( + "[data-testid='signage-upload-input']", + ) as HTMLInputElement; + // happy-dom's `FileList` is read-only, so the queue is driven through the same change handler a + // real picker fires, with file-shaped values. + Object.defineProperty(input, "files", { + configurable: true, + value: [ + { name: "welcome.png", size: 1024, type: "image/png" }, + { name: "broken.png", size: 1024, type: "image/png" }, + ], + }); + await act(async () => { + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + for (let index = 0; index < 10; index += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + + // Two files, two requests — each with its own name in the query string and the bytes as the body. + expect(uploaded).toHaveLength(2); + expect(uploaded.every((path) => path.startsWith("images?"))).toBe(true); + const uploads = container.querySelector("[data-testid='signage-uploads']"); + expect(uploads?.textContent).toContain("done"); + expect(uploads?.textContent).toContain("failed"); + expect(uploads?.textContent).toContain("The uploaded file is not a decodable image."); + }); + + it("stays on screen when one panel's data cannot be loaded", async () => { + const host = createFakeHost({ + permissions: ["signage.view"], + routes: [ + { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, + { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, + { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, + { + method: "GET", + path: "playback/group-1", + respond: () => + errorResponse("SIGNAGE_OPERATION_FAILED", "The operation could not be completed.", 500), + }, + ], + }); + + await render(); + + expect(container.textContent).toContain("Signage could not load this"); + expect(container.textContent).toContain("The operation could not be completed."); + // The rest of the dashboard is still usable: one failing panel is not a failed screen. + expect(container.querySelectorAll("[role='tab']").length).toBe(5); + }); +}); diff --git a/tests/browser/module-ui.test.ts b/tests/browser/module-ui.test.ts new file mode 100644 index 0000000..bd037d3 --- /dev/null +++ b/tests/browser/module-ui.test.ts @@ -0,0 +1,170 @@ +// @vitest-environment happy-dom +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { parseModuleManifest } from "@ekklesia/module-sdk"; +import { buildModuleUi } from "@ekklesia/module-ui-build"; +import { parseModuleUiExport } from "@ekklesia/module-ui-sdk"; +import { beforeAll, describe, expect, it } from "vitest"; +import { createFakeHost, FAKE_SETTINGS, jsonResponse } from "./support/fake-host.js"; + +/** + * The end-to-end proof for Signage's browser artifacts: + * + * React/TSX source -> `pnpm build` -> the artifacts the manifest declares -> `parseModuleUiExport` + * -> `mount(container, context)` -> `unmount()` + * + * Both artifacts are rebuilt from source here rather than read from a checked-in file, so this can + * never pass against stale output. `dist/` is gitignored; nothing generated is committed. + */ +// `happy-dom` replaces the global `URL` with the DOM implementation, which `node:fs` rejects, so +// this test works in plain filesystem paths and converts to a file URL only for dynamic import. +const moduleDirectory = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url)))); + +let identity: { id: string; version: string }; +let uiArtifactPath: string; +let uiArtifactHref: string; +let displayArtifactPath: string; +let displayArtifactHref: string; + +async function flush(): Promise { + for (let index = 0; index < 8; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +describe("Signage browser artifacts", () => { + beforeAll(async () => { + const manifest = parseModuleManifest( + JSON.parse( + await readFile(path.join(moduleDirectory, "ekklesia.module.json"), "utf8"), + ) as unknown, + ); + identity = { id: manifest.id, version: manifest.version }; + + const result = await buildModuleUi({ moduleDirectory }); + const ui = result.artifacts.find((artifact) => artifact.kind === "ui"); + const display = result.artifacts.find((artifact) => artifact.kind === "display"); + if (!ui || !display) { + throw new Error("The Signage build must produce both declared browser artifacts."); + } + uiArtifactPath = ui.outputFile; + uiArtifactHref = pathToFileURL(ui.outputFile).href; + displayArtifactPath = display.outputFile; + displayArtifactHref = pathToFileURL(display.outputFile).href; + }, 180_000); + + it("produces self-contained artifacts that bake in no Ekklesia deployment detail", async () => { + const sources = await Promise.all([ + readFile(uiArtifactPath, "utf8"), + readFile(displayArtifactPath, "utf8"), + ]); + + for (const source of sources) { + // React is bundled, so each artifact resolves nothing at run time (ADR 0023). + expect(source).not.toMatch(/^import\s.*from\s*["'][^.]/mu); + expect(source).toContain("export {"); + expect(source).not.toContain("/api/v1/modules"); + expect(source).not.toContain("/api/v1/module-ui"); + expect(source).not.toContain("process.env.NODE_ENV"); + // Nothing this release excludes may reach a browser artifact. + for (const forbidden of ["mqtt", "socket.io", "homeassistant", "sqlite"]) { + expect(source.toLowerCase()).not.toContain(forbidden); + } + } + }); + + it("mounts the management UI, asks for relative paths only, and unmounts", async () => { + const namespace = (await import(uiArtifactHref)) as unknown; + const definition = parseModuleUiExport(namespace, identity); + const container = document.createElement("div"); + document.body.append(container); + const host = createFakeHost({ + permissions: ["signage.view"], + routes: [ + { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, + { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [] }) }, + { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, + ], + }); + + const unmount = await definition.mount(container, host.context); + await flush(); + + expect(container.textContent).toContain("Playback"); + // Relative to Signage's own API root, always. A route table keyed on Ekklesia's private prefix + // would never have matched. + for (const request of host.requests) { + expect(request.path.startsWith("api/")).toBe(false); + expect(request.path.startsWith("/")).toBe(false); + } + // The desktop-only gate is a CSS decision, so both branches are always in the accessibility tree. + expect(container.querySelector("[data-testid='signage-narrow-notice']")).not.toBeNull(); + + await (unmount as () => Promise)(); + expect(container.textContent).toBe(""); + container.remove(); + }); + + it("renders a denied state from the host's presentation-only permission hint", async () => { + const namespace = (await import(uiArtifactHref)) as unknown; + const definition = parseModuleUiExport(namespace, identity); + const container = document.createElement("div"); + const host = createFakeHost({ permissions: [], routes: [] }); + + const unmount = await definition.mount(container, host.context); + await flush(); + + expect(container.textContent).toContain("No access to Signage"); + // A hidden control is a usability choice, not enforcement: Signage simply did not ask. + expect(host.requests).toEqual([]); + await (unmount as () => Promise)(); + }); + + it("mounts the display through the same ABI and shows the safe slate", async () => { + const namespace = (await import(displayArtifactHref)) as unknown; + const definition = parseModuleUiExport(namespace, identity); + const container = document.createElement("div"); + const host = createFakeHost({ permissions: ["signage.display.view"], routes: [] }); + + const unmount = await definition.mount(container, host.context); + await flush(); + + const slate = container.querySelector("[data-testid='signage-slate']"); + expect(slate?.textContent).toBe("Content unavailable"); + // The slate must never carry an exception, URL, identifier, or payload of any kind. + expect(slate?.textContent).not.toMatch(/https?:|\/api\/|[0-9a-f]{8}-[0-9a-f]{4}/u); + + await (unmount as () => Promise)(); + expect(container.textContent).toBe(""); + }); + + it("carries each artifact's own stylesheet inside the one declared file", async () => { + const ui = parseModuleUiExport((await import(uiArtifactHref)) as unknown, identity); + const display = parseModuleUiExport((await import(displayArtifactHref)) as unknown, identity); + + expect(ui.styles).toContain(".signage-narrow-notice"); + expect(ui.styles).toContain(".ek-panel"); + // Signage references the platform's global theme and never declares part of it (ADR 0039). + expect(ui.styles).not.toMatch(/--ekklesia-[a-z0-9-]+\s*:/u); + expect(display.styles).not.toMatch(/--ekklesia-[a-z0-9-]+\s*:/u); + // Two artifacts of one module: separate roots, separate stylesheets. + expect(display.styles).toContain(".signage-slate"); + expect(display.styles).not.toContain(".signage-narrow-notice"); + // No sidecar stylesheet is emitted beside either artifact. + await expect(readFile(path.join(path.dirname(uiArtifactPath), "index.css"))).rejects.toThrow(); + }); + + it("sets its own box model, because an isolated root inherits none", async () => { + const ui = parseModuleUiExport((await import(uiArtifactHref)) as unknown, identity); + const display = parseModuleUiExport((await import(displayArtifactHref)) as unknown, identity); + + // The host installs base rules and the platform's components, and neither declares `box-sizing`. + // Under the initial `content-box` a control that is `width: 100%` is laid out wider than the + // field around it by its own padding and border, and slides under whatever sits beside it. Each + // artifact says so for the tree it owns; nothing outside that tree is affected. + for (const styles of [ui.styles, display.styles]) { + expect(styles).toMatch(/\*,\s*\*::before,\s*\*::after\s*\{[^}]*box-sizing:\s*border-box/u); + } + }); +}); diff --git a/tests/browser/offline.test.ts b/tests/browser/offline.test.ts new file mode 100644 index 0000000..0a4915d --- /dev/null +++ b/tests/browser/offline.test.ts @@ -0,0 +1,562 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it } from "vitest"; +import { + type OfflineSnapshot, + resolveOffline, + serverNowFrom, + snapshotFrom, +} from "../../src/browser/offline.js"; +import type { SceneSelection } from "../../src/browser/scene-request.js"; +import { + createMemoryMediaCache, + createMemoryStore, + signageMediaKey, +} from "../../src/browser/storage.js"; +import { + createDisplayController, + type DisplayChannelStateView, +} from "../../src/display/controller.js"; +import { createFakeHost, jsonResponse } from "./support/fake-host.js"; + +/** + * Offline continuation: what a screen does when its building's uplink dies. + * + * Every case here is one an unattended television actually meets — a router reboot during a service, + * a switch power-cycled by a janitor, a week with the doors closed — and the property being proven is + * always the same one: the display continues *deterministically from the last authoritative anchor*, + * replaying only what the server already resolved, and never becomes a second authority. + */ + +const SCENE = { + backgroundColor: "#000000", + backgroundImage: false, + backgroundMode: "none" as const, + blurPixels: 0, + darkenOpacity: 0, + fit: "fill" as const, + shadow: false, +}; + +function selection(overrides: Partial & { id: string }): SceneSelection { + return { + availableUntilMs: null, + deadlineAtMs: null, + durationMs: 5000, + itemId: `item-${overrides.id}`, + itemRevision: "rev-1", + payload: { kind: "native-image" }, + presentation: null, + providerId: "signage.native-images", + renderer: { kind: "native-image" }, + revision: 1, + scene: SCENE, + slotId: null, + startedAtMs: null, + withdrawn: false, + ...overrides, + }; +} + +function slot(id: string, position: number, overrides: Record = {}) { + return { + eligibleFromMs: null, + eligibleUntilMs: null, + enabled: true, + estimatedDurationMs: 5000, + id, + position, + ...overrides, + }; +} + +function snapshot(overrides: Partial = {}): OfflineSnapshot { + return snapshotFrom({ + channel: { groupId: "group-1", id: "chan-1", key: "default" }, + crossfadeMs: 0, + localNowMs: 1_000_000, + playback: { + blackout: false, + currentSelectionId: "sel-a", + currentSlotId: "slot-a", + deadlineAtMs: 5000, + pendingTimelineId: null, + remainingMs: 5000, + revision: 3, + startedAtMs: 0, + transport: "playing", + ...overrides.playback, + }, + selections: overrides.selections ?? [ + selection({ id: "sel-c", slotId: "slot-c" }), + selection({ id: "sel-b", slotId: "slot-b" }), + selection({ id: "sel-a", slotId: "slot-a" }), + ], + serverNowMs: 0, + staticHold: overrides.staticHold ?? null, + timeline: + overrides.timeline === undefined + ? { + eligibleFromMs: null, + eligibleUntilMs: null, + id: "timeline-1", + revision: 2, + slots: [slot("slot-a", 0), slot("slot-b", 1), slot("slot-c", 2)], + } + : overrides.timeline, + }); +} + +describe("offline continuation", () => { + it("reconstructs the server's clock, however wrong the device's is", () => { + const persisted = snapshot(); + + // The device's clock jumped forward two hours while the screen was off. The anchor is what makes + // the evaluation right anyway: only the *elapsed* local time matters. + expect(serverNowFrom(persisted, 1_000_000 + 4000)).toBe(4000); + expect(serverNowFrom(persisted, 1_000_000 - 7_200_000 + 4000)).toBe(4000 - 7_200_000); + }); + + it("continues to the next slot at the deadline, using what the server already resolved", () => { + const evaluation = resolveOffline({ atMs: 5000, snapshot: snapshot() }); + + expect(evaluation.resolution).toMatchObject({ + kind: "selection", + startedAtMs: 5000, + }); + if (evaluation.resolution.kind !== "selection") { + throw new Error("Expected a selection."); + } + // The next slot in the pattern, rendered from its own cached answer. Nothing was invented. + expect(evaluation.resolution.selection.id).toBe("sel-b"); + expect(evaluation.nextBoundaryMs).toBe(10_000); + }); + + it("catches up a week in one evaluation rather than replaying every slide", () => { + const week = 7 * 24 * 60 * 60 * 1000; + const started = Date.now(); + + const evaluation = resolveOffline({ atMs: week, snapshot: snapshot() }); + + expect(evaluation.resolution.kind).toBe("selection"); + if (evaluation.resolution.kind !== "selection") { + throw new Error("Expected a selection."); + } + // Landed inside the current slot, on the server's own phase — and the arithmetic took no + // measurable time, because it is arithmetic. + expect(evaluation.resolution.deadlineAtMs).toBeGreaterThan(week); + expect(evaluation.resolution.deadlineAtMs - evaluation.resolution.startedAtMs).toBe(5000); + expect(Date.now() - started).toBeLessThan(200); + }); + + it("stays exactly where it was while paused, with its remainder preserved", () => { + const paused = snapshot({ + playback: { + blackout: false, + currentSelectionId: "sel-a", + currentSlotId: "slot-a", + deadlineAtMs: null, + pendingTimelineId: null, + remainingMs: 3000, + revision: 4, + startedAtMs: null, + transport: "paused", + }, + }); + + const evaluation = resolveOffline({ atMs: 600_000, snapshot: paused }); + + // Ten minutes later, on the same slide. Frozen means frozen, offline as much as online. + expect(evaluation.resolution).toMatchObject({ kind: "paused" }); + if (evaluation.resolution.kind !== "paused") { + throw new Error("Expected a paused resolution."); + } + expect(evaluation.resolution.selection?.id).toBe("sel-a"); + expect(paused.playback.remainingMs).toBe(3000); + }); + + it("keeps a static hold held, and does not let local age release it", () => { + const held = snapshot({ staticHold: selection({ id: "static-1" }) }); + + const evaluation = resolveOffline({ atMs: 86_400_000, snapshot: held }); + + expect(evaluation.resolution).toMatchObject({ kind: "static" }); + }); + + it("releases a static hold only when the held image genuinely expired", () => { + const held = snapshot({ + staticHold: selection({ availableUntilMs: 10_000, id: "static-1" }), + }); + + const before = resolveOffline({ atMs: 9000, snapshot: held }); + const after = resolveOffline({ atMs: 11_000, snapshot: held }); + + expect(before.resolution.kind).toBe("static"); + // An explicit expiry the server told it about — never a guess made locally. + expect(after.resolution.kind).toBe("selection"); + }); + + it("skips content the server said was withdrawn or expired", () => { + const persisted = snapshot({ + selections: [ + selection({ id: "sel-c", slotId: "slot-c" }), + selection({ id: "sel-b", slotId: "slot-b", withdrawn: true }), + selection({ id: "sel-a", slotId: "slot-a" }), + ], + }); + + const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted }); + + // Slot B's only cached answer is gone, so the slot is skipped exactly as the server skips an + // empty provider slot — no gap, no error, and certainly no fabricated replacement. + expect(evaluation.resolution).toMatchObject({ kind: "selection" }); + if (evaluation.resolution.kind !== "selection") { + throw new Error("Expected a selection."); + } + expect(evaluation.resolution.selection.id).toBe("sel-c"); + }); + + it("holds the last good frame rather than continuing into a timeline it never received", () => { + const pending = snapshot({ + playback: { + blackout: false, + currentSelectionId: "sel-a", + currentSlotId: "slot-a", + deadlineAtMs: 5000, + pendingTimelineId: "timeline-2", + remainingMs: 0, + revision: 5, + startedAtMs: 0, + transport: "playing", + }, + }); + + const evaluation = resolveOffline({ atMs: 6000, snapshot: pending }); + + // Going deeper into the old timeline would show content the operator has already replaced. + expect(evaluation.resolution).toEqual({ kind: "hold", reason: "pending-activation" }); + }); + + it("holds when no slot has a cached answer, rather than inventing one", () => { + const persisted = snapshot({ selections: [] }); + + const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted }); + + expect(evaluation.resolution).toMatchObject({ kind: "hold" }); + }); + + it("goes black only when the cached timeline genuinely has nothing eligible", () => { + const persisted = snapshot({ + timeline: { + eligibleFromMs: null, + eligibleUntilMs: null, + id: "timeline-1", + revision: 2, + slots: [slot("slot-a", 0, { enabled: false })], + }, + }); + + const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted }); + + expect(evaluation.resolution).toEqual({ kind: "empty" }); + }); + + it("persists no delivery URL, because a stored one is a credential that stops working", () => { + const persisted = snapshotFrom({ + channel: { groupId: "group-1", id: "chan-1", key: "default" }, + crossfadeMs: 0, + localNowMs: 0, + playback: { + blackout: false, + currentSelectionId: "sel-a", + currentSlotId: "slot-a", + deadlineAtMs: 5000, + pendingTimelineId: null, + remainingMs: 5000, + revision: 1, + startedAtMs: 0, + transport: "playing", + }, + selections: [ + { + ...selection({ id: "sel-a", slotId: "slot-a" }), + media: { + display: { url: "https://cdn.invalid/a?sig=secret" }, + thumbnail: { url: "https://cdn.invalid/a-t?sig=secret" }, + }, + }, + ], + serverNowMs: 0, + staticHold: null, + timeline: null, + }); + + expect(JSON.stringify(persisted)).not.toContain("sig=secret"); + expect(JSON.stringify(persisted)).not.toContain("https://"); + }); +}); + +describe("a display that loses its network", () => { + let root: HTMLElement; + + beforeEach(() => { + root = document.createElement("div"); + document.body.append(root); + }); + + async function settle(): Promise { + for (let index = 0; index < 12; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + function nativeSelection(id: string, slotId: string): SceneSelection { + return { + ...selection({ id, slotId }), + media: { + display: { url: `https://cdn.invalid/${id}?sig=a` }, + thumbnail: { url: `https://cdn.invalid/${id}-t?sig=a` }, + }, + } as SceneSelection; + } + + function state(currentId: string): DisplayChannelStateView { + const selections = [ + nativeSelection("sel-c", "slot-c"), + nativeSelection("sel-b", "slot-b"), + nativeSelection("sel-a", "slot-a"), + ]; + const current = selections.find((candidate) => candidate.id === currentId); + return { + channel: { groupId: "group-1", id: "chan-1", key: "default" }, + crossfadeMs: 0, + current: current ?? null, + playback: { + activeTimelineId: "timeline-1", + blackout: false, + currentSelectionId: currentId, + currentSlotId: current?.slotId ?? null, + deadlineAtMs: 5000, + pendingTimelineId: null, + remainingMs: 5000, + revision: 3, + selectionRevision: 1, + startedAtMs: 0, + transport: "playing", + }, + selections, + serverNowMs: 0, + staticHold: null, + timeline: { + eligibleFromMs: null, + eligibleUntilMs: null, + id: "timeline-1", + revision: 2, + slots: [slot("slot-a", 0), slot("slot-b", 1), slot("slot-c", 2)], + }, + }; + } + + it("keeps rendering from cached bytes and cached answers when the server goes away", async () => { + const store = createMemoryStore(); + const media = createMemoryMediaCache(); + let reachable = true; + const host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { + method: "POST", + path: "viewers/session", + respond: () => + jsonResponse({ + channelId: "chan-1", + channelKey: "default", + groupId: "group-1", + heartbeatIntervalMs: 12_000, + viewerId: "11111111-1111-4111-8111-111111111111", + }), + }, + { + method: "GET", + path: "channels/chan-1/display-state", + respond: () => { + if (!reachable) { + throw new Error("network is gone"); + } + return jsonResponse(state("sel-a")); + }, + }, + { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, + ], + }); + + let clock = 1_000_000; + // The armed timer is captured rather than run, so the test decides when the safety-net refresh + // and the offline evaluation happen — no real time passes. + let armed: (() => void) | undefined; + const controller = createDisplayController({ + context: host.context, + decodeImage: () => Promise.resolve(), + media, + newViewerKey: () => "viewer-fixed-key-0001", + now: () => clock, + root, + setTimer: (handler) => { + armed = handler; + return handler; + }, + store, + }); + + await controller.start(); + await settle(); + expect(controller.diagnostics().renderedSelectionId).toBe("sel-a"); + expect(controller.diagnostics().offline).toBe(false); + expect(controller.diagnostics().offlineReady).toBe(true); + + // The uplink dies, and the current slot's deadline passes. + reachable = false; + clock += 6000; + armed?.(); + await settle(); + + const diagnostics = controller.diagnostics(); + // Local continuation took over: the next slot in the pattern, from bytes and answers this screen + // already had. Nothing was invented, and the slate never appeared. + expect(diagnostics.offline).toBe(true); + expect(diagnostics.renderedSelectionId).toBe("sel-b"); + expect(root.dataset.slate).toBe("false"); + await controller.stop(); + }); + + it("puts the last known good frame up before the first request returns", async () => { + const store = createMemoryStore(); + const media = createMemoryMediaCache(); + await media.store( + signageMediaKey({ + itemId: "item-sel-a", + itemRevision: "rev-1", + providerId: "signage.native-images", + rendition: "display", + }), + "https://cdn.invalid/sel-a?sig=a", + ); + await store.write("last-known-good", snapshot()); + + let requested = 0; + const host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { + method: "POST", + path: "viewers/session", + respond: () => { + requested += 1; + throw new Error("network is gone"); + }, + }, + ], + }); + const controller = createDisplayController({ + context: host.context, + decodeImage: () => Promise.resolve(), + media, + now: () => 1_000_000, + root, + setTimer: () => undefined, + store, + }); + + await controller.start(); + await settle(); + + // A frame is up, from persisted state, with no server involved — and the slate never appeared. + expect(controller.diagnostics().renderedSelectionId).toBe("sel-a"); + expect(root.dataset.slate).toBe("false"); + expect(requested).toBe(1); + await controller.stop(); + }); + + it("hands authority straight back to the server on reconnect, with no replay and no black frame", async () => { + const store = createMemoryStore(); + const media = createMemoryMediaCache(); + await store.write("last-known-good", snapshot()); + for (const id of ["sel-a", "sel-b", "sel-c"]) { + await media.store( + signageMediaKey({ + itemId: `item-${id}`, + itemRevision: "rev-1", + providerId: "signage.native-images", + rendition: "display", + }), + `https://cdn.invalid/${id}?sig=a`, + ); + } + const host = createFakeHost({ permissions: [], routes: [] }); + const controller = createDisplayController({ + context: host.context, + decodeImage: () => Promise.resolve(), + media, + now: () => 1_000_000, + root, + setTimer: () => undefined, + store, + telemetry: false, + }); + + await controller.start(); + await settle(); + const offlineFrame = controller.diagnostics().renderedSelectionId; + expect(offlineFrame).toBe("sel-a"); + expect(controller.diagnostics().offline).toBe(true); + + // The server comes back and says something different. It wins immediately. + await controller.apply(state("sel-c")); + await settle(); + + const diagnostics = controller.diagnostics(); + expect(diagnostics.renderedSelectionId).toBe("sel-c"); + expect(diagnostics.offline).toBe(false); + // Straight to what the server says. Nothing in between was replayed, and the slate never showed. + expect(root.dataset.slate).toBe("false"); + await controller.stop(); + }); + + it("prunes superseded bytes only after the new state is usable", async () => { + const store = createMemoryStore(); + const media = createMemoryMediaCache(); + const pruned: readonly string[][] = []; + const order: string[] = []; + const wrapped = { + ...media, + prune: (keep: readonly string[]) => { + order.push("prune"); + (pruned as string[][]).push([...keep]); + return media.prune(keep); + }, + store: (key: string, url: string) => { + order.push(`store:${key}`); + return media.store(key, url); + }, + }; + const host = createFakeHost({ permissions: [], routes: [] }); + const controller = createDisplayController({ + context: host.context, + decodeImage: () => Promise.resolve(), + media: wrapped, + now: () => 1_000_000, + root, + setTimer: () => undefined, + store, + telemetry: false, + }); + + await controller.start(); + await controller.apply(state("sel-a")); + await settle(); + + // Every store happens before the prune: a screen never has less than it had. + expect(order.at(-1)).toBe("prune"); + expect(order.filter((entry) => entry.startsWith("store:"))).toHaveLength(3); + await controller.stop(); + }); +}); diff --git a/tests/browser/scene.test.ts b/tests/browser/scene.test.ts new file mode 100644 index 0000000..3a7c874 --- /dev/null +++ b/tests/browser/scene.test.ts @@ -0,0 +1,412 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it } from "vitest"; +import type { SceneRequest } from "../../src/browser/scene.js"; +import { createSceneStage, type SceneStage } from "../../src/browser/scene.js"; +import { rendererInputFor, sceneRequestFor } from "../../src/browser/scene-request.js"; + +/** + * The transition, exercised rather than asserted. + * + * Every collaborator is injected — the decode, the timer, the contribution mount — so what these + * prove is the sequence itself: readiness is awaited, the outgoing layer is never faded, a stale + * completion is discarded, and a failure leaves the last good frame exactly where it was. + */ + +const SCENE = { + backgroundColor: "#000000", + backgroundImage: false, + backgroundMode: "none" as const, + blurPixels: 0, + darkenOpacity: 0, + fit: "fill" as const, + shadow: false, +}; + +function image(key: string, url = `https://cdn.invalid/${key}.webp`): SceneRequest { + return { alt: "", key, kind: "native-image", scene: SCENE, url }; +} + +describe("scene stage", () => { + let root: HTMLElement; + let decodes: { url: string; resolve: () => void; reject: (error: Error) => void }[]; + let stage: SceneStage; + + function layers(): HTMLElement[] { + return [...root.querySelectorAll(".signage-layer")] as HTMLElement[]; + } + + function opacities(): string[] { + return layers().map((layer) => layer.style.opacity); + } + + async function settle(): Promise { + for (let index = 0; index < 8; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + function resolveDecodes(): void { + while (decodes.length > 0) { + decodes.shift()?.resolve(); + } + } + + beforeEach(() => { + root = document.createElement("div"); + document.body.append(root); + decodes = []; + stage = createSceneStage({ + crossfadeMs: 1000, + decodeImage: (element) => + new Promise((resolve, reject) => { + decodes.push({ + reject, + resolve, + url: element.getAttribute("src") ?? "", + }); + }), + root, + // Fires immediately, so a crossfade completes without the test waiting real time. The + // ordering the transition depends on is unaffected. + setTimer: (handler) => { + handler(); + return undefined; + }, + }); + }); + + it("creates exactly two layers, once, and never rebuilds them", async () => { + const before = layers(); + expect(before).toHaveLength(2); + + const first = stage.show(image("a")); + await settle(); + resolveDecodes(); + await first; + + const second = stage.show(image("b")); + await settle(); + resolveDecodes(); + await second; + + // The same two elements. A renderer that rebuilt them per slide is the `v1` defect. + expect(layers()[0]).toBe(before[0]); + expect(layers()[1]).toBe(before[1]); + }); + + it("waits for a real decode before anything becomes visible", async () => { + const shown = stage.show(image("a")); + await settle(); + + // The image is in the DOM and the decode has not resolved: nothing is visible yet. + expect(decodes).toHaveLength(1); + expect(opacities()).toEqual(["0", "0"]); + + resolveDecodes(); + await shown; + expect(opacities().filter((opacity) => opacity === "1")).toHaveLength(1); + }); + + it("never fades the outgoing layer, so the black root cannot show through", async () => { + const first = stage.show(image("a")); + await settle(); + resolveDecodes(); + await first; + const outgoing = layers().find((layer) => layer.style.opacity === "1") as HTMLElement; + + const second = stage.show(image("b")); + await settle(); + // The incoming layer is prepared; the outgoing one is still fully opaque, underneath. + expect(outgoing.style.opacity).toBe("1"); + resolveDecodes(); + await second; + + // Only after the fade completes does the old layer go to zero — and it is now behind, and empty. + expect(outgoing.style.opacity).toBe("0"); + expect(outgoing.style.zIndex).toBe("1"); + }); + + it("keeps the last good frame when incoming content cannot be decoded", async () => { + const first = stage.show(image("a")); + await settle(); + resolveDecodes(); + await first; + const goodKey = stage.currentKey(); + + const failing = stage.show(image("broken")); + await settle(); + decodes.shift()?.reject(new Error("decode failed")); + const outcome = await failing; + + expect(outcome).toEqual({ code: "SLIDE_DECODE_FAILED", kind: "failed" }); + // The wall still shows what it showed. A broken slide costs one slide. + expect(stage.currentKey()).toBe(goodKey); + expect(opacities().filter((opacity) => opacity === "1")).toHaveLength(1); + }); + + it("discards completion from a superseded transition", async () => { + const first = stage.show(image("a")); + await settle(); + resolveDecodes(); + await first; + + const slow = stage.show(image("b")); + await settle(); + const fast = stage.show(image("c")); + await settle(); + resolveDecodes(); + const [slowOutcome, fastOutcome] = await Promise.all([slow, fast]); + + // The newer transition owns the layers; the older one changes nothing. + expect(slowOutcome.kind).toBe("superseded"); + expect(fastOutcome.kind).toBe("shown"); + expect(stage.currentKey()).toBe("c"); + }); + + it("updates a mounted renderer in place rather than remounting it", async () => { + const updates: unknown[] = []; + let mounts = 0; + const contributionStage = createSceneStage({ + crossfadeMs: 0, + mountContribution: () => { + mounts += 1; + return Promise.resolve({ + unmount: () => undefined, + update: (input: unknown) => { + updates.push(input); + }, + }); + }, + root, + // Fires immediately, so a crossfade completes without the test waiting real time. The + // ordering the transition depends on is unaffected. + setTimer: (handler) => { + handler(); + return undefined; + }, + }); + + const input = rendererInputFor( + { + availableUntilMs: null, + deadlineAtMs: 5000, + durationMs: 5000, + id: "sel-1", + itemId: "advent", + itemRevision: "1", + payload: { headline: "Sunday" }, + presentation: null, + providerId: "fixture.announcements", + renderer: { + contributionId: "fixture.announcements.slide", + contributionVersion: "1.0.0", + kind: "contribution", + }, + revision: 1, + slotId: "slot-1", + startedAtMs: 0, + withdrawn: false, + }, + { audienceKey: "default", localNowMs: 0, paused: false, remainingMs: null, serverNowMs: 0 }, + ); + const request: SceneRequest = { + contributionId: "fixture.announcements.slide", + input, + key: "sel-1:1:default:r", + kind: "contribution", + }; + + await contributionStage.show(request); + const updated = await contributionStage.show({ + ...request, + input: { ...input, payload: { headline: "Cancelled" } }, + }); + + // Same key, new input: one mount, one update, no transition — which is what lets a long-running + // screen change content without restarting animations or reloading media. + expect(updated).toEqual({ kind: "updated" }); + expect(mounts).toBe(1); + expect(updates).toHaveLength(1); + await contributionStage.destroy(); + }); + + it("keeps the last good frame when a renderer refuses to mount", async () => { + const contributionStage = createSceneStage({ + crossfadeMs: 0, + mountContribution: () => Promise.reject(new Error("renderer exploded")), + root, + // Fires immediately, so a crossfade completes without the test waiting real time. The + // ordering the transition depends on is unaffected. + setTimer: (handler) => { + handler(); + return undefined; + }, + }); + + const outcome = await contributionStage.show({ + contributionId: "fixture.announcements.slide", + input: {} as never, + key: "sel-9", + kind: "contribution", + }); + + expect(outcome).toEqual({ code: "RENDERER_MOUNT_FAILED", kind: "failed" }); + expect(contributionStage.currentKey()).toBeUndefined(); + await contributionStage.destroy(); + }); + + it("unmounts a renderer deterministically when its layer is reused", async () => { + let unmounts = 0; + const contributionStage = createSceneStage({ + crossfadeMs: 0, + mountContribution: () => + Promise.resolve({ + unmount: () => { + unmounts += 1; + }, + }), + root, + // Fires immediately, so a crossfade completes without the test waiting real time. The + // ordering the transition depends on is unaffected. + setTimer: (handler) => { + handler(); + return undefined; + }, + }); + + await contributionStage.show({ + contributionId: "a.slide", + input: {} as never, + key: "one", + kind: "contribution", + }); + await contributionStage.show({ + contributionId: "a.slide", + input: {} as never, + key: "two", + kind: "contribution", + }); + await contributionStage.show({ + contributionId: "a.slide", + input: {} as never, + key: "three", + kind: "contribution", + }); + await contributionStage.destroy(); + + // Three mounts, three unmounts: repeated mount and unmount leaks nothing. + expect(unmounts).toBe(3); + }); + + it("reports blackout and slate as states of the root, so one slate implementation renders both", () => { + stage.setBlackout(true); + expect(root.dataset.blackout).toBe("true"); + stage.setBlackout(false); + expect(root.dataset.blackout).toBe("false"); + + stage.setSlate(true); + expect(root.dataset.slate).toBe("true"); + }); +}); + +describe("scene requests", () => { + const base = { + availableUntilMs: null, + deadlineAtMs: 5000, + durationMs: 5000, + id: "sel-1", + itemId: "welcome", + itemRevision: "abc", + payload: { headline: "Welcome" }, + presentation: null, + providerId: "fixture.announcements", + revision: 1, + slotId: "slot-1", + startedAtMs: 0, + withdrawn: false, + }; + const context = { + audienceKey: "default", + localNowMs: 1000, + paused: false, + remainingMs: null, + serverNowMs: 9000, + }; + + it("gives a renderer both clocks, so a television with a wrong clock still counts correctly", () => { + const input = rendererInputFor( + { + ...base, + renderer: { contributionId: "a.slide", contributionVersion: "1.0.0", kind: "contribution" }, + }, + context, + ); + + expect(input.clock).toEqual({ localNowMs: 1000, serverNowMs: 9000 }); + expect(input.timing).toMatchObject({ deadlineAtMs: 5000, paused: false, startedAtMs: 0 }); + expect(input.contract).toBe("signage.slide-renderer"); + // Everything the renderer is given, and nothing else: no API client, no principal, no + // credential, no URL. + expect(Object.keys(input).sort()).toEqual([ + "audienceKey", + "clock", + "contract", + "generation", + "itemId", + "itemRevision", + "payload", + "presentation", + "providerId", + "timing", + "version", + ]); + }); + + it("changes the key when the payload revision or pause state changes, and not when the clock does", () => { + const selection = { + ...base, + renderer: { contributionId: "a.slide", contributionVersion: "1.0.0", kind: "contribution" }, + } as const; + const first = rendererInputFor(selection, context).generation; + const reAnchored = rendererInputFor(selection, { + ...context, + localNowMs: 99_999, + serverNowMs: 99_999, + }).generation; + const revised = rendererInputFor({ ...selection, revision: 2 }, context).generation; + const paused = rendererInputFor(selection, { ...context, paused: true }).generation; + + // A reconnect re-anchors the clock. Remounting a renderer for that would restart every + // animation on the wall for no reason. + expect(reAnchored).toBe(first); + expect(revised).not.toBe(first); + expect(paused).not.toBe(first); + }); + + it("declines to build a scene for a renderer the platform did not publish", () => { + const request = sceneRequestFor( + { + ...base, + renderer: { + contributionId: "missing.slide", + contributionVersion: "1.0.0", + kind: "contribution", + }, + }, + context, + { canMountContribution: () => false }, + ); + + // Ordinary rather than exceptional: the contributing module is absent, disabled, or outside + // Signage's compatible range, and the caller keeps its last good frame. + expect(request).toBeUndefined(); + }); + + it("declines to build a native scene with no delivery URL", () => { + const request = sceneRequestFor({ ...base, renderer: { kind: "native-image" } }, context, { + canMountContribution: () => true, + }); + + expect(request).toBeUndefined(); + }); +}); diff --git a/tests/browser/support/fake-host.ts b/tests/browser/support/fake-host.ts new file mode 100644 index 0000000..2c9e898 --- /dev/null +++ b/tests/browser/support/fake-host.ts @@ -0,0 +1,122 @@ +import type { + ModuleContributionDescriptor, + ModuleUiApiClient, + ModuleUiContributions, + ModuleUiHostContext, + MountedModuleContribution, + MountModuleContributionOptions, +} from "@ekklesia/module-ui-sdk"; + +/** + * A host, as a module's artifact actually experiences one. + * + * Requests are matched on the **relative** path a module asks for, which is what proves the artifact + * never builds `/api/v1/modules/signage/...`: a route table keyed on Ekklesia's private prefix would + * simply never match. + */ +export interface FakeHostRoute { + readonly method: string; + readonly path: string | RegExp; + readonly respond: (request: { + readonly path: string; + readonly init: RequestInit | undefined; + }) => Response | Promise; +} + +export interface FakeHost { + readonly context: ModuleUiHostContext; + readonly requests: { method: string; path: string }[]; + /** Contributions this host published against Signage's renderer capability. */ + readonly mounted: { contributionId: string; input: unknown }[]; +} + +export function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); +} + +export function errorResponse(code: string, message: string, status = 400): Response { + return jsonResponse({ code, message }, status); +} + +export interface FakeHostOptions { + readonly permissions: readonly string[]; + readonly routes: readonly FakeHostRoute[]; + readonly contributions?: readonly ModuleContributionDescriptor[]; + readonly principal?: ModuleUiHostContext["principal"]; + readonly mount?: ( + options: MountModuleContributionOptions, + ) => Promise | MountedModuleContribution; +} + +export function createFakeHost(options: FakeHostOptions): FakeHost { + const requests: { method: string; path: string }[] = []; + const mounted: { contributionId: string; input: unknown }[] = []; + + const api: ModuleUiApiClient = { + async request(path, init) { + const method = (init?.method ?? "GET").toUpperCase(); + const normalized = path.replace(/^\//u, ""); + requests.push({ method, path: normalized }); + for (const route of options.routes) { + const matches = + typeof route.path === "string" + ? route.path === normalized.split("?")[0] + : route.path.test(normalized); + if (route.method === method && matches) { + return route.respond({ init, path: normalized }); + } + } + return errorResponse("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404); + }, + }; + + const contributions: ModuleUiContributions = { + list: (capabilityId) => + capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [], + async mount(mountOptions) { + mounted.push({ contributionId: mountOptions.contributionId, input: mountOptions.input }); + if (options.mount) { + return options.mount(mountOptions); + } + return { + contributionId: mountOptions.contributionId, + mounted: true, + unmount: () => Promise.resolve(), + update: () => Promise.resolve(), + version: "1.0.0", + }; + }, + }; + + return { + context: { + api, + contributions, + moduleId: "signage", + permissions: { has: (permission) => options.permissions.includes(permission) }, + principal: options.principal ?? { kind: "anonymous" }, + realtime: { subscribe: () => ({ close: () => undefined }) }, + }, + mounted, + requests, + }; +} + +/** The settings payload every screen loads first. */ +export const FAKE_SETTINGS = Object.freeze({ + crossfadeMs: 1000, + moduleVersion: "0.1.0", + nativeImageDurationMs: 7000, + nativePresentation: { + backgroundColor: "#000000", + backgroundDarkened: false, + backgroundMode: "blurred", + blurPixels: 20, + }, + rendererInputVersion: "1.0.0", + timezone: "America/Chicago", + uploads: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000 }, +}); diff --git a/tests/browser/support/fixture-renderer.tsx b/tests/browser/support/fixture-renderer.tsx new file mode 100644 index 0000000..3275e83 --- /dev/null +++ b/tests/browser/support/fixture-renderer.tsx @@ -0,0 +1,130 @@ +import { defineModuleContribution } from "@ekklesia/module-ui-sdk"; +import { type ReactElement, useEffect, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import type { SignageSlideRendererInput } from "../../../src/shared/renderer-input.js"; + +/** + * A **test-only** browser renderer, standing in for the contribution a real content module will + * publish. + * + * It is not an installed module and has no manifest: it lives under `tests/`, and the only way it + * reaches a screen is a test handing it to the stage the way the platform's contribution loader + * would. It is built with the platform's own `defineModuleContribution`, so what it proves is the + * actual contract rather than a convenient approximation. + * + * It deliberately does everything a demanding real renderer would: + * + * - resolves `mount` only when it is genuinely ready, so the stage's fade waits for it; + * - presents **internal multiple stages** — a question, then its answer — inside one Signage slide, + * which Signage neither knows about nor times; + * - renders time-based text from the input's clock anchor rather than the device's clock; + * - renders differently per audience; + * - updates in place, without remounting; + * - unmounts deterministically, releasing its timer and its React root. + * + * It receives no API client, no realtime, no principal, no storage, and no credential — because the + * contract gives a renderer none of those. Everything it draws comes from the consumer's input. + */ + +export interface TriviaPayload { + readonly question?: unknown; + readonly answer?: unknown; + readonly revealAfterMs?: unknown; +} + +function TriviaSlide({ input }: { readonly input: SignageSlideRendererInput }): ReactElement { + const payload = input.payload as TriviaPayload; + const question = typeof payload.question === "string" ? payload.question : "…"; + const answer = typeof payload.answer === "string" ? payload.answer : ""; + const revealAfterMs = typeof payload.revealAfterMs === "number" ? payload.revealAfterMs : 2000; + const [revealed, setRevealed] = useState(false); + + // The authoritative now, computed from both clocks. A television's own clock is routinely wrong; + // this is what makes a countdown on two screens agree. + const serverNow = input.clock.serverNowMs + (Date.now() - input.clock.localNowMs); + const remainingSeconds = Math.max(0, Math.ceil((input.timing.deadlineAtMs - serverNow) / 1000)); + + // `generation` is the dependency that matters and the linter cannot see why: it changes when the + // slide's content changes, which is exactly when this stage should restart — and it does *not* + // change when the clock is re-anchored on reconnect, which is exactly when it should not. + // biome-ignore lint/correctness/useExhaustiveDependencies: the generation token is the point. + useEffect(() => { + if (input.timing.paused) { + return; + } + // Internal multi-stage behaviour: Signage sees one slide with one deadline, and this decides + // what happens *within* it. It cannot extend that deadline and does not try. + setRevealed(false); + const handle = setTimeout(() => setRevealed(true), revealAfterMs); + return () => clearTimeout(handle); + }, [input.generation, input.timing.paused, revealAfterMs]); + + return ( +
+

{question}

+ {revealed ?

{answer}

: undefined} +

{input.audienceKey}

+

{`${remainingSeconds}s`}

+
+ ); +} + +/** Lets a test decide exactly when the renderer becomes ready, so "the stage waits for readiness" + * is a property that can be observed rather than assumed. */ +export interface RendererReadiness { + readonly whenReady: Promise; + ready(): void; +} + +export function createReadiness(): RendererReadiness { + let settle: (() => void) | undefined; + const whenReady = new Promise((resolve) => { + settle = resolve; + }); + return { + ready(): void { + settle?.(); + }, + whenReady, + }; +} + +export interface FixtureRendererCounters { + mounts: number; + updates: number; + unmounts: number; +} + +export function createFixtureRenderer(options: { + readonly readiness?: RendererReadiness; + readonly counters?: FixtureRendererCounters; +}) { + const counters = options.counters ?? { mounts: 0, unmounts: 0, updates: 0 }; + + const definition = defineModuleContribution({ + id: "fixture.trivia.slide", + version: "1.0.0", + async mount(container, context) { + counters.mounts += 1; + const root: Root = createRoot(container as Element); + root.render(); + // Asynchronous by contract: a renderer that has to decode media, measure a layout, or wait for + // a font resolves only when it is honestly ready, and the stage's fade waits for it. + if (options.readiness) { + await options.readiness.whenReady; + } + return { + unmount() { + counters.unmounts += 1; + root.unmount(); + }, + update(input) { + counters.updates += 1; + root.render(); + }, + }; + }, + }); + + return { counters, definition }; +} diff --git a/tests/images.test.ts b/tests/images.test.ts new file mode 100644 index 0000000..8993d00 --- /dev/null +++ b/tests/images.test.ts @@ -0,0 +1,502 @@ +import sharp from "sharp"; +import { beforeEach, describe, expect, it } from "vitest"; +import { SignageError } from "../src/server/errors.js"; +import { SIGNAGE_IMAGE_MAX_BYTES, SIGNAGE_IMAGE_MAX_PIXELS } from "../src/server/images/limits.js"; +import { processSignageImage, sha256Hex } from "../src/server/images/processing.js"; +import { + deleteNativeImage, + ingestNativeImage, + updateNativeImage, +} from "../src/server/images/service.js"; +import { readBoundedBody } from "../src/server/images/streaming.js"; +import { isSixteenByNine, resolveNativeScene } from "../src/shared/native-images.js"; +import { createFakeStorage, type FakeStorage } from "./support/fake-storage.js"; +import { + bytesOf, + GIF_DOCUMENT_BASE64, + makeAnimatedGif, + makeAnimatedWebp, + makeImage, + PDF_DOCUMENT, + SVG_DOCUMENT, +} from "./support/images.js"; +import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js"; + +async function expectSignageCode(promise: Promise, code: string): Promise { + try { + await promise; + } catch (error) { + expect(error).toBeInstanceOf(SignageError); + const signageError = error as SignageError; + expect(signageError.code).toBe(code); + return signageError; + } + throw new Error(`Expected ${code}, but the operation succeeded.`); +} + +describe("native image processing", () => { + it("accepts still PNG, JPEG, and WebP and reports the format the bytes actually are", async () => { + for (const format of ["png", "jpeg", "webp"] as const) { + const processed = await processSignageImage( + await makeImage({ format, height: 720, width: 1280 }), + ); + expect(processed.format).toBe(format); + expect(processed.width).toBe(1280); + expect(processed.height).toBe(720); + expect(processed.renditions.map((rendition) => rendition.kind)).toEqual([ + "display", + "thumbnail", + ]); + } + }); + + it("refuses SVG, PDF, GIF, animation, and anything that is not an image at all", async () => { + await expectSignageCode( + processSignageImage(bytesOf(SVG_DOCUMENT)), + "SIGNAGE_IMAGE_UNSUPPORTED_FORMAT", + ); + await expectSignageCode(processSignageImage(bytesOf(PDF_DOCUMENT)), "SIGNAGE_IMAGE_MALFORMED"); + await expectSignageCode( + processSignageImage(new Uint8Array(Buffer.from(GIF_DOCUMENT_BASE64, "base64"))), + "SIGNAGE_IMAGE_UNSUPPORTED_FORMAT", + ); + await expectSignageCode( + processSignageImage(bytesOf("this is a text file, not a slide")), + "SIGNAGE_IMAGE_MALFORMED", + ); + // Animation is detected before format, so an animated GIF reports why it is really refused. + await expectSignageCode( + processSignageImage(await makeAnimatedWebp()), + "SIGNAGE_IMAGE_ANIMATED", + ); + await expectSignageCode(processSignageImage(await makeAnimatedGif()), "SIGNAGE_IMAGE_ANIMATED"); + }); + + it("refuses an image whose decoded pixels exceed the budget, whatever the file size is", async () => { + // A decompression bomb is small on disk and enormous decoded, which is exactly why the budget + // is measured in pixels rather than in bytes. + const bomb = await makeImage({ format: "png", height: 9000, width: 9000 }); + expect(bomb.byteLength).toBeLessThan(SIGNAGE_IMAGE_MAX_BYTES); + expect(9000 * 9000).toBeGreaterThan(SIGNAGE_IMAGE_MAX_PIXELS); + + const error = await expectSignageCode(processSignageImage(bomb), "SIGNAGE_IMAGE_PIXEL_LIMIT"); + // The caller learns the limit Signage declared, and nothing about the decoder that enforced it. + expect(error.detail).toEqual({ maxPixels: SIGNAGE_IMAGE_MAX_PIXELS }); + expect(error.message).not.toMatch(/sharp|vips|pixel limit exceeded/iu); + }); + + it("fits renditions inside their bounds, and never crops or upscales", async () => { + const tall = await processSignageImage( + await makeImage({ format: "png", height: 2400, width: 1000 }), + ); + const display = tall.renditions.find((rendition) => rendition.kind === "display"); + // Inside-fit: the long edge reaches the bound, the short edge is proportional, nothing is cut. + expect(display?.height).toBe(1080); + expect(display?.width).toBe(450); + + const small = await processSignageImage( + await makeImage({ format: "png", height: 90, width: 160 }), + ); + const smallDisplay = small.renditions.find((rendition) => rendition.kind === "display"); + // Never upscaled: blowing a small image up to 1920 makes it soft, not better. + expect(smallDisplay?.width).toBe(160); + expect(smallDisplay?.height).toBe(90); + }); + + it("strips metadata from renditions and flattens transparency against black", async () => { + const transparent = await processSignageImage( + await makeImage({ alpha: true, format: "png", height: 200, width: 400 }), + ); + const display = transparent.renditions.find((rendition) => rendition.kind === "display"); + if (!display) { + throw new Error("Expected a display rendition."); + } + const metadata = await sharp(display.bytes).metadata(); + + expect(metadata.format).toBe("webp"); + expect(metadata.hasAlpha).toBe(false); + // EXIF, GPS coordinates, and camera serial numbers never travel with a published rendition. + expect(metadata.exif).toBeUndefined(); + expect(metadata.icc).toBeUndefined(); + expect(metadata.xmp).toBeUndefined(); + // The original's own facts are preserved in PostgreSQL instead. + expect(transparent.hasAlpha).toBe(true); + }); + + it("is deterministic, so a retry of the same bytes produces the same renditions", async () => { + const bytes = await makeImage({ format: "png", height: 600, width: 800 }); + const first = await processSignageImage(bytes); + const second = await processSignageImage(bytes); + + expect(first.contentSha256).toBe(second.contentSha256); + expect(first.contentSha256).toBe(sha256Hex(bytes)); + expect(first.renditions.map((rendition) => rendition.contentSha256)).toEqual( + second.renditions.map((rendition) => rendition.contentSha256), + ); + }); +}); + +describe("native image presentation", () => { + it("lets a true 16:9 image fill the frame, within a documented tolerance", () => { + expect(isSixteenByNine(1920, 1080)).toBe(true); + expect(isSixteenByNine(1280, 720)).toBe(true); + // A one-or-two-pixel export artefact is still 16:9 to a person looking at the wall. + expect(isSixteenByNine(1918, 1080)).toBe(true); + expect(isSixteenByNine(1080, 1080)).toBe(false); + expect(isSixteenByNine(2560, 1080)).toBe(false); + expect(isSixteenByNine(0, 0)).toBe(false); + }); + + it("shows a 16:9 image whole with no background, blur, or shadow", () => { + const scene = resolveNativeScene( + { height: 1080, width: 1920 }, + { + backgroundColor: "#112233", + backgroundDarkened: true, + backgroundMode: "blurred", + blurPixels: 20, + foregroundShadow: true, + }, + ); + + // An image that already fits needs none of the treatments that exist to frame one that does not. + expect(scene).toEqual({ + backgroundColor: "#000000", + backgroundImage: false, + backgroundMode: "none", + blurPixels: 0, + darkenOpacity: 0, + fit: "fill", + shadow: false, + }); + }); + + it("shows every other ratio whole, on the background the slot asked for", () => { + const blurred = resolveNativeScene( + { height: 1080, width: 1080 }, + { + backgroundColor: "#112233", + backgroundDarkened: true, + backgroundMode: "blurred", + blurPixels: 24, + foregroundShadow: true, + }, + ); + expect(blurred).toMatchObject({ + backgroundImage: true, + blurPixels: 24, + darkenOpacity: 0.25, + fit: "contain", + shadow: true, + }); + + const solid = resolveNativeScene( + { height: 1080, width: 1080 }, + { + backgroundColor: "#112233", + backgroundDarkened: false, + backgroundMode: "solid", + blurPixels: 24, + foregroundShadow: true, + }, + ); + // A solid background is not blurred, and an undarkened one is not dimmed. + expect(solid).toMatchObject({ + backgroundColor: "#112233", + backgroundImage: false, + blurPixels: 0, + darkenOpacity: 0, + fit: "contain", + }); + }); +}); + +describe("bounded upload reading", () => { + function streamedRequest(chunks: readonly Uint8Array[], contentLength?: number): Request { + const body = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); + return new Request("http://localhost/images?filename=a.png", { + body, + // Required by Node's fetch for a streaming request body. + duplex: "half", + headers: contentLength === undefined ? {} : { "content-length": String(contentLength) }, + method: "POST", + }); + } + + it("refuses a declared length over the limit before the body is even touched", async () => { + let bodyAccesses = 0; + // A stand-in rather than a real `Request`, and `body` is a getter: a `ReadableStream` pulls its + // first chunk as soon as it is constructed, so counting reads would measure the stream rather + // than the check. Counting *accesses to the body* measures exactly the property under test. + const request = { + get body(): ReadableStream { + bodyAccesses += 1; + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(16)); + controller.close(); + }, + }); + }, + headers: new Headers({ "content-length": String(SIGNAGE_IMAGE_MAX_BYTES + 1) }), + } as unknown as Request; + + await expectSignageCode( + readBoundedBody(request, SIGNAGE_IMAGE_MAX_BYTES), + "SIGNAGE_IMAGE_TOO_LARGE", + ); + expect(bodyAccesses).toBe(0); + }); + + it("counts what actually arrives, so a missing or lying declared length bounds nothing", async () => { + const chunk = new Uint8Array(1024); + const chunks = Array.from({ length: 8 }, () => chunk); + + // No `content-length` at all, and a total over a small limit. + await expectSignageCode( + readBoundedBody(streamedRequest(chunks), 4096), + "SIGNAGE_IMAGE_TOO_LARGE", + ); + // A declared length that lies about being small. + await expectSignageCode( + readBoundedBody(streamedRequest(chunks, 10), 4096), + "SIGNAGE_IMAGE_TOO_LARGE", + ); + }); + + it("reassembles a chunked body exactly", async () => { + const image = await makeImage({ format: "png", height: 90, width: 160 }); + const half = Math.floor(image.byteLength / 2); + const bytes = await readBoundedBody( + streamedRequest([image.slice(0, half), image.slice(half)]), + SIGNAGE_IMAGE_MAX_BYTES, + ); + + expect(sha256Hex(bytes)).toBe(sha256Hex(image)); + }); + + it("refuses an empty body rather than treating it as an image", async () => { + await expectSignageCode( + readBoundedBody(streamedRequest([]), SIGNAGE_IMAGE_MAX_BYTES), + "SIGNAGE_INVALID_REQUEST", + ); + }); +}); + +describe("native image ingest", () => { + let store: MemorySignageStore; + let storage: FakeStorage; + let counter: number; + + beforeEach(() => { + store = createMemorySignageStore(); + storage = createFakeStorage(); + counter = 0; + }); + + const newId = () => { + counter += 1; + return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`; + }; + + async function ingest(bytes: Uint8Array, filename = "welcome.png") { + return ingestNativeImage({ + bytes, + displayName: filename, + expiresAt: null, + filename, + newId, + storage, + store, + }); + } + + it("archives the original unchanged and stores both renditions beside it", async () => { + const bytes = await makeImage({ format: "png", height: 1080, width: 1920 }); + const result = await ingest(bytes); + + expect(result.deduplicated).toBe(false); + expect([...storage.objects.keys()].sort()).toEqual([ + `native-images/assets/${result.image.id}/display.webp`, + `native-images/assets/${result.image.id}/original.png`, + `native-images/assets/${result.image.id}/thumbnail.webp`, + ]); + // Byte for byte. It is the church's file, and Signage is not entitled to rewrite it. + const archived = storage.objects.get(`native-images/assets/${result.image.id}/original.png`); + expect(sha256Hex(archived?.bytes ?? new Uint8Array())).toBe(sha256Hex(bytes)); + expect(result.image.originalContentType).toBe("image/png"); + expect(result.image.renditions.map((rendition) => rendition.contentType)).toEqual([ + "image/webp", + "image/webp", + ]); + }); + + it("returns the existing asset for identical bytes, and writes nothing new", async () => { + const bytes = await makeImage({ format: "png", height: 1080, width: 1920 }); + const first = await ingest(bytes, "welcome.png"); + const objectCount = storage.objects.size; + + const second = await ingest(bytes, "welcome-copy.png"); + + expect(second.deduplicated).toBe(true); + expect(second.image.id).toBe(first.image.id); + expect(storage.objects.size).toBe(objectCount); + expect(store.images.size).toBe(1); + }); + + it("writes nothing at all when the bytes are not an image Signage accepts", async () => { + await expectSignageCode( + ingest(bytesOf(SVG_DOCUMENT), "logo.svg"), + "SIGNAGE_IMAGE_UNSUPPORTED_FORMAT", + ); + + expect(storage.objects.size).toBe(0); + expect(store.images.size).toBe(0); + }); + + it("removes every object it wrote when a later write fails", async () => { + const bytes = await makeImage({ format: "png", height: 1080, width: 1920 }); + storage.failPut(`assets/00000000-0000-4000-8000-000000000001/thumbnail.webp`); + + await expectSignageCode(ingest(bytes), "SIGNAGE_STORAGE_UNAVAILABLE"); + + // An aborted ingest leaves no orphan bytes and no half-formed asset. + expect(storage.objects.size).toBe(0); + expect(store.images.size).toBe(0); + }); + + it("treats a race with an identical upload as a success rather than a failure", async () => { + const bytes = await makeImage({ format: "png", height: 1080, width: 1920 }); + const first = await ingest(bytes); + // The second ingest is forced past the dedup check by clearing the hash lookup once, exactly as + // a concurrent commit would: the write happens, and the unique index refuses the insert. + const originalLookup = store.findReadyImageByContentHash.bind(store); + let lookups = 0; + store.findReadyImageByContentHash = (hash: string) => { + lookups += 1; + return lookups === 1 ? Promise.resolve(undefined) : originalLookup(hash); + }; + + const second = await ingest(bytes); + + expect(second.deduplicated).toBe(true); + expect(second.image.id).toBe(first.image.id); + // The losing attempt cleaned up after itself. + expect(storage.objects.size).toBe(3); + }); + + it("refuses bytes past Signage's own limit even if the caller read them", async () => { + await expectSignageCode( + ingestNativeImage({ + bytes: new Uint8Array(SIGNAGE_IMAGE_MAX_BYTES + 1), + displayName: "huge.png", + expiresAt: null, + filename: "huge.png", + storage, + store, + }), + "SIGNAGE_IMAGE_TOO_LARGE", + ); + }); +}); + +describe("native image maintenance", () => { + let store: MemorySignageStore; + let storage: FakeStorage; + + async function seed() { + return ingestNativeImage({ + bytes: await makeImage({ format: "png", height: 1080, width: 1920 }), + displayName: "welcome.png", + expiresAt: null, + filename: "welcome.png", + storage, + store, + }); + } + + beforeEach(() => { + store = createMemorySignageStore(); + storage = createFakeStorage(); + }); + + it("writes nothing for an exact no-op", async () => { + const { image } = await seed(); + let writes = 0; + const original = store.updateImage.bind(store); + store.updateImage = (id, changes) => { + writes += 1; + return original(id, changes); + }; + + const unchanged = await updateNativeImage({ + assetId: image.id, + displayName: image.displayName, + expiresAt: null, + store, + }); + + expect(unchanged.displayName).toBe(image.displayName); + expect(writes).toBe(0); + }); + + it("renames and expires an image, and treats a null expiration as permanent", async () => { + const { image } = await seed(); + const expires = new Date("2027-01-01T00:00:00.000Z"); + + const renamed = await updateNativeImage({ + assetId: image.id, + displayName: "Welcome slide", + expiresAt: expires, + store, + }); + expect(renamed.displayName).toBe("Welcome slide"); + expect(renamed.expiresAt?.toISOString()).toBe(expires.toISOString()); + + const permanent = await updateNativeImage({ assetId: image.id, expiresAt: null, store }); + expect(permanent.expiresAt).toBeNull(); + }); + + it("deletes an unreferenced image and every object it owned", async () => { + const { image } = await seed(); + + await deleteNativeImage({ assetId: image.id, storage, store }); + + expect(store.images.size).toBe(0); + expect(storage.objects.size).toBe(0); + }); + + it("refuses to delete an image a slot or a live static hold still refers to", async () => { + const { image } = await seed(); + store.addImageReference(image.id, "slot"); + store.addImageReference(image.id, "static-hold"); + + const error = await expectSignageCode( + deleteNativeImage({ assetId: image.id, storage, store }), + "SIGNAGE_ASSET_IN_USE", + ); + + // Counts an operator can act on — never a slot id, a timeline name, or a channel group's name. + expect(error.detail).toEqual({ heldByChannelGroups: 1, referencedBySlots: 1 }); + expect(store.images.size).toBe(1); + expect(storage.objects.size).toBe(3); + }); + + it("refuses an unknown image without saying whether it ever existed", async () => { + await expectSignageCode( + deleteNativeImage({ + assetId: "11111111-1111-4111-8111-111111111111", + storage, + store, + }), + "SIGNAGE_NOT_FOUND", + ); + }); +}); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts new file mode 100644 index 0000000..165c6c1 --- /dev/null +++ b/tests/manifest.test.ts @@ -0,0 +1,290 @@ +import { readFile } from "node:fs/promises"; +import { EMPTY_MODULE_CONFIGURATION, parseModuleManifest } from "@ekklesia/module-sdk"; +import { describe, expect, it } from "vitest"; +import moduleRuntime from "../src/server/index.js"; +import { readSignageSettings } from "../src/server/settings.js"; +import { SIGNAGE_DEFAULT_SETTINGS } from "../src/shared/settings.js"; +import { + fakeConfiguration, + fakeHandlerContext, + findRoute, + registerModule, +} from "./support/module-harness.js"; + +/** Signage's own copy of Core's overlap rule: two routes of one module under one method must never + * be able to match the same request path, because they may carry different permissions. */ +function overlappingRoutes(routes: readonly { method: string; path: string }[]): readonly string[] { + const segmentsOf = (path: string) => (path === "/" ? [] : path.slice(1).split("/")); + const collisions: string[] = []; + for (let left = 0; left < routes.length; left += 1) { + for (let right = left + 1; right < routes.length; right += 1) { + const first = routes[left]; + const second = routes[right]; + if (!first || !second || first.method !== second.method) { + continue; + } + const a = segmentsOf(first.path); + const b = segmentsOf(second.path); + if (a.length !== b.length) { + continue; + } + const overlaps = a.every((segment, index) => { + const other = b[index] as string; + return segment.startsWith(":") || other.startsWith(":") || segment === other; + }); + if (overlaps) { + collisions.push(`${first.method} ${first.path} ~ ${second.path}`); + } + } + } + return collisions; +} + +async function readManifest() { + const manifestUrl = new URL("../ekklesia.module.json", import.meta.url); + return parseModuleManifest(JSON.parse(await readFile(manifestUrl, "utf8")) as unknown); +} + +describe("Signage module manifest", () => { + it("declares the extension seams other modules reach Signage through", async () => { + const manifest = await readManifest(); + + // The backend seam and the browser seam are versioned separately on purpose: a provider's + // registration contract and its renderer's input contract have different lifecycles, and + // versioning them together would force every provider to rebuild whenever either moved. + expect(manifest.capabilities).toEqual([ + { id: "signage.content-providers", version: "1.0.0" }, + { id: "signage.slide-renderer", version: "1.0.0" }, + { id: "signage.playback", version: "1.0.0" }, + { id: "signage.displays", version: "1.0.0" }, + ]); + }); + + it("declares the module's product identity at the current manifest version", async () => { + const manifest = await readManifest(); + + expect(manifest.manifestVersion).toBe(7); + expect(manifest.id).toBe("signage"); + expect(manifest.name).toBe("Signage"); + expect(manifest.version).toBe("0.1.0"); + expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" }); + }); + + it("declares every Signage permission, and none of them publicly", async () => { + const manifest = await readManifest(); + + expect(manifest.permissions.map((permission) => permission.id)).toEqual([ + "signage.view", + "signage.assets.manage", + "signage.timelines.manage", + "signage.playback.control", + "signage.displays.manage", + "signage.display.view", + ]); + // Nothing Signage owns is readable by an unauthenticated caller: a timeline names a church's + // schedule, and a display is a screen someone paired. + expect(manifest.permissions.every((permission) => permission.baseline === "restricted")).toBe( + true, + ); + for (const permission of manifest.permissions) { + expect(permission.description.length).toBeGreaterThan(0); + } + }); + + it("declares one logical storage bucket and names no storage service anywhere", async () => { + const manifest = await readManifest(); + const raw = await readFile(new URL("../ekklesia.module.json", import.meta.url), "utf8"); + + expect(manifest.storage.buckets).toEqual([ + { + id: "native-images", + description: + "Archived original uploads and generated renditions for Signage's native images.", + }, + ]); + // A module that configured its own object store would be welded to one deployment (ADR 0030). + for (const forbidden of ["s3", "seaweed", "minio", "endpoint", "accessKey", "region"]) { + expect(raw.toLowerCase()).not.toContain(forbidden.toLowerCase()); + } + }); + + it("declares both browser artifacts, with the display gated on a non-public permission", async () => { + const manifest = await readManifest(); + + expect(manifest.entrypoints.server).toBe("./dist/server/index.js"); + expect(manifest.entrypoints.ui).toBe("./dist/ui/index.js"); + expect(manifest.entrypoints.display).toEqual({ + artifact: "./dist/display/index.js", + permission: "signage.display.view", + }); + }); + + it("declares only operator settings, every one of them with a safe default and no secret", async () => { + const manifest = await readManifest(); + + expect(manifest.configuration.map((entry) => entry.key)).toEqual([ + "timezone", + "nativeImageDurationSeconds", + "crossfadeMilliseconds", + "defaultBackgroundMode", + "defaultBackgroundColor", + "defaultBackgroundDarkened", + "defaultBlurPixels", + "providerTimeoutMilliseconds", + "playbackHistoryLimit", + "viewerHeartbeatWriteSeconds", + ]); + // Nothing Signage needs configured has no safe default, so nothing can block it from starting. + expect(manifest.configuration.every((entry) => entry.required === false)).toBe(true); + expect(manifest.configuration.some((entry) => entry.type === "secret")).toBe(false); + }); +}); + +describe("Signage server runtime", () => { + it("exports the typed runtime definition the compiled server entrypoint publishes", async () => { + expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.0" }); + }); + + it("registers its routes declaratively, with no database, storage, or realtime work", async () => { + const registration = await registerModule(); + + expect(registration.routes.map((route) => `${route.method} ${route.path}`).sort()).toEqual([ + "DELETE /channel-groups/:groupId", + "DELETE /channels/:channelId", + "DELETE /images/:imageId", + "DELETE /timelines/:timelineId", + "DELETE /timelines/:timelineId/slots/:slotId", + "DELETE /timelines/:timelineId/sources/:sourceId", + "DELETE /viewers/:viewerId", + "GET /channel-groups", + "GET /channels/:channelId/display-state", + "GET /images", + "GET /images/:imageId", + "GET /playback/:groupId", + "GET /providers", + "GET /providers/:providerId/items", + "GET /providers/:providerId/sources", + "GET /settings", + "GET /timelines", + "GET /timelines/:timelineId", + "GET /viewers", + "PATCH /images/:imageId", + "PATCH /timelines/:timelineId", + "PATCH /timelines/:timelineId/slots/:slotId", + "PATCH /viewers/:viewerId", + "POST /channel-groups", + "POST /channel-groups/:groupId/channels", + "POST /images", + "POST /playback/:groupId/activate", + "POST /playback/:groupId/blackout", + "POST /playback/:groupId/next", + "POST /playback/:groupId/pause", + "POST /playback/:groupId/previous", + "POST /playback/:groupId/reload", + "POST /playback/:groupId/resume", + "POST /playback/:groupId/static", + "POST /timelines", + "POST /timelines/:timelineId/duplicate", + "POST /timelines/:timelineId/slots", + "POST /timelines/:timelineId/sources", + "POST /viewers/:viewerId/heartbeat", + "POST /viewers/session", + "PUT /timelines/:timelineId/slot-order", + ]); + // Every route makes the body decision consciously, names a permission Signage's own manifest + // declares, and cannot collide with another — Core refuses a module whose routes overlap, and + // discovering that at API startup is discovering it in production. + for (const route of registration.routes) { + expect(route.permission.startsWith("signage.")).toBe(true); + expect(Number.isSafeInteger(route.maxBodyBytes)).toBe(true); + } + expect(overlappingRoutes(registration.routes)).toEqual([]); + // Every declared capability is registered during `register`, because Core refuses a module that + // declares one and does not. + expect([...registration.provided.keys()].sort()).toEqual([ + "signage.content-providers", + "signage.displays", + "signage.playback", + "signage.slide-renderer", + ]); + // Two realtime channels, each gated on a permission Signage owns: what a display needs, and + // what the dashboard needs, are different audiences and different permissions. + expect(registration.channels).toEqual([ + { channel: "display-state", permission: "signage.display.view" }, + { channel: "management-state", permission: "signage.view" }, + ]); + const upload = findRoute(registration, "POST", "/images"); + expect(upload).toMatchObject({ + bodyMode: "streamed", + maxBodyBytes: 26_214_400, + permission: "signage.assets.manage", + }); + // Exactly one route streams. Everything else takes a small buffered body, or none at all. + expect(registration.routes.filter((route) => route.bodyMode === "streamed")).toHaveLength(1); + expect(findRoute(registration, "GET", "/settings").maxBodyBytes).toBe(0); + }); + + it("serves its own effective settings, and never a configuration value it did not choose to expose", async () => { + const route = findRoute(await registerModule(), "GET", "/settings"); + + const response = await route.handler( + new Request("http://localhost/settings"), + fakeHandlerContext({ + config: fakeConfiguration({ + crossfadeMilliseconds: 400, + nativeImageDurationSeconds: 12, + timezone: "America/Denver", + }), + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + crossfadeMs: 400, + moduleVersion: "0.1.0", + nativeImageDurationMs: 12_000, + nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation, + rendererInputVersion: "1.0.0", + timezone: "America/Denver", + // The dashboard is told the limits the server enforces rather than hardcoding them: a UI that + // believed a stale limit would offer an upload the server refuses. + uploads: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000 }, + }); + }); +}); + +describe("Signage settings", () => { + it("falls back to exactly the defaults the manifest declares", async () => { + const manifest = await readManifest(); + const settings = readSignageSettings(EMPTY_MODULE_CONFIGURATION); + const declared = new Map(manifest.configuration.map((entry) => [entry.key, entry])); + + expect(settings).toEqual(SIGNAGE_DEFAULT_SETTINGS); + // The one place these two could drift is exactly the place a stale default would be invisible. + expect(declared.get("timezone")).toMatchObject({ default: settings.timezone }); + expect(declared.get("nativeImageDurationSeconds")).toMatchObject({ + default: settings.nativeImageDurationMs / 1000, + }); + expect(declared.get("crossfadeMilliseconds")).toMatchObject({ default: settings.crossfadeMs }); + expect(declared.get("defaultBlurPixels")).toMatchObject({ + default: settings.nativePresentation.blurPixels, + }); + expect(declared.get("playbackHistoryLimit")).toMatchObject({ + default: settings.playbackHistoryLimit, + }); + }); + + it("refuses a background colour that is not a plain six-digit hex value", () => { + const settings = readSignageSettings( + fakeConfiguration({ defaultBackgroundColor: "red; background: url(x)" }), + ); + + expect(settings.nativePresentation.backgroundColor).toBe("#000000"); + }); + + it("refuses a background mode Signage does not implement", () => { + const settings = readSignageSettings(fakeConfiguration({ defaultBackgroundMode: "rainbow" })); + + expect(settings.nativePresentation.backgroundMode).toBe("blurred"); + }); +}); diff --git a/tests/playback.test.ts b/tests/playback.test.ts new file mode 100644 index 0000000..ab9fda5 --- /dev/null +++ b/tests/playback.test.ts @@ -0,0 +1,622 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + createPlaybackService, + type PlaybackSnapshot, + type SignagePlaybackService, +} from "../src/server/playback/service.js"; +import { + createProviderRegistry, + type SignageProviderRegistry, +} from "../src/server/providers/registry.js"; +import type { StoredChannelGroupDetail, StoredTimelineDetail } from "../src/server/store/types.js"; +import { addProviderSource, addSlot, createTimeline } from "../src/server/timelines/service.js"; +import { SIGNAGE_DEFAULT_SETTINGS } from "../src/shared/settings.js"; +import { + announcementsFixture, + type FixtureProvider, + scriptureFixture, + triviaFixture, +} from "./support/fixture-provider.js"; +import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js"; + +const START = Date.parse("2026-08-30T15:00:00.000Z"); + +describe("authoritative playback", () => { + let store: MemorySignageStore; + let registry: SignageProviderRegistry; + let service: SignagePlaybackService; + let announcements: FixtureProvider; + let trivia: FixtureProvider; + let scripture: FixtureProvider; + let clock: number; + let counter: number; + let published: { snapshot: PlaybackSnapshot; reason: string }[]; + /** Timers are captured rather than run: a test drives the clock, so nothing depends on real time + * and a reconciliation is never racing a test's assertions. */ + let armed: { handler: () => void; delayMs: number } | undefined; + + const newId = () => { + counter += 1; + return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`; + }; + + beforeEach(() => { + clock = START; + counter = 0; + published = []; + armed = undefined; + store = createMemorySignageStore({ now: () => new Date(clock) }); + registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 }); + announcements = announcementsFixture(); + trivia = triviaFixture(); + scripture = scriptureFixture(); + registry.capability.register(announcements.registration); + registry.capability.register(trivia.registration); + registry.capability.register(scripture.registration); + service = createPlaybackService({ + clearTimer: () => { + armed = undefined; + }, + newId, + now: () => clock, + onSnapshot: (snapshot, reason) => published.push({ reason, snapshot }), + registry, + setTimer: (handler, delayMs) => { + armed = { delayMs, handler }; + return armed; + }, + settings: { ...SIGNAGE_DEFAULT_SETTINGS, playbackHistoryLimit: 20 }, + store, + }); + }); + + /** Advances the clock and fires whatever timer was armed for that moment, the way a real one + * would. Nothing here reaches a real timer, so a long outage costs nothing but arithmetic. */ + async function tick(ms: number): Promise { + clock += ms; + const pending = armed; + armed = undefined; + if (pending) { + pending.handler(); + // Let the reconciliation the timer started settle. + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + async function seedGroup(): Promise { + await service.start(); + return service.ensureDefaultGroup(); + } + + /** The pattern from the product definition: three announcements, one trivia, two announcements, + * one scripture — the five announcement occurrences sharing one source. */ + async function seedPattern(): Promise { + let detail = await createTimeline({ name: "Sunday morning", newId, store }); + const timelineId = detail.timeline.id; + for (const [providerId, sourceId, label] of [ + ["fixture.announcements", "active-slides", "Announcements"], + ["fixture.trivia", "open-questions", "Trivia"], + ["fixture.scripture", "reading-plan", "Scripture"], + ] as const) { + detail = await addProviderSource({ label, newId, providerId, sourceId, store, timelineId }); + } + const [a, t, s] = detail.sources; + if (!a || !t || !s) { + throw new Error("Expected three sources."); + } + for (const source of [a, a, a, t, a, a, s]) { + detail = await addSlot({ newId, source: { sourceId: source.id }, store, timelineId }); + } + return detail; + } + + async function currentItem(groupId: string): Promise { + const snapshot = await service.snapshot(groupId); + if (snapshot.currentSelectionId === null) { + return null; + } + const selection = await store.findSelection(snapshot.currentSelectionId); + return selection?.itemId ?? null; + } + + it("creates exactly one default channel group with one default channel, and nothing else", async () => { + const group = await seedGroup(); + + expect(group.channels).toHaveLength(1); + expect(group.channels[0]).toMatchObject({ isDefault: true, key: "default" }); + // No `in-person`, no `online`. Those are an administrator's decision, not an install-time one. + expect(group.channels.map((channel) => channel.key)).toEqual(["default"]); + // Calling again is idempotent: an installation has one default group, not one per call. + const again = await service.ensureDefaultGroup(); + expect(again.group.id).toBe(group.group.id); + }); + + it("plays the operator's pattern, asking each provider once per slot occurrence", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + + await service.activate(group.group.id, timeline.timeline.id, "now"); + const played: (string | null)[] = [await currentItem(group.group.id)]; + for (let index = 0; index < 6; index += 1) { + await tick(5000); + played.push(await currentItem(group.group.id)); + } + + // Three announcements, one trivia, two announcements, one scripture — with the five + // announcement occurrences walking **one** rotation, because they share one selection scope. + expect(played).toEqual(["welcome", "greeting", "giving", "q1", "kids", "welcome", "psalm-23"]); + expect(announcements.served).toEqual(["welcome", "greeting", "giving", "kids", "welcome"]); + expect(trivia.served).toEqual(["q1"]); + expect(scripture.served).toEqual(["psalm-23"]); + }); + + it("resolves one logical selection for every channel in the group, and one variant each", async () => { + const group = await seedGroup(); + await store.addChannel({ + groupId: group.group.id, + id: newId(), + isDefault: false, + key: "online", + name: "Online", + }); + const timeline = await seedPattern(); + + await service.activate(group.group.id, timeline.timeline.id, "now"); + // The second announcement is the one with an audience-specific variant. + await tick(5000); + const snapshot = await service.snapshot(group.group.id); + const selection = await store.findSelection(snapshot.currentSelectionId ?? ""); + + expect(selection?.itemId).toBe("greeting"); + // Asked once — never once per channel — which is what keeps two channels on the same slide. + expect( + announcements.resolveRequests.filter((request) => request.audienceKeys.includes("online")), + ).toHaveLength(announcements.resolveRequests.length); + expect(selection?.variants.map((variant) => variant.audienceKey).sort()).toEqual([ + "default", + "online", + ]); + expect( + selection?.variants.find((variant) => variant.audienceKey === "online")?.payload, + ).toEqual({ headline: "Watching from home" }); + // Both channels read the same start and the same deadline. There is one clock. + expect(snapshot.startedAtMs).not.toBeNull(); + expect(snapshot.deadlineAtMs).toBe((snapshot.startedAtMs ?? 0) + 5000); + }); + + it("falls back to the default variant for an audience the provider said nothing about", async () => { + const group = await seedGroup(); + await store.addChannel({ + groupId: group.group.id, + id: newId(), + isDefault: false, + key: "in-person", + name: "In person", + }); + const timeline = await seedPattern(); + + await service.activate(group.group.id, timeline.timeline.id, "now"); + const snapshot = await service.snapshot(group.group.id); + const selection = await store.findSelection(snapshot.currentSelectionId ?? ""); + + // "welcome" has no audience-specific payload at all. That is the contract, not a gap. + expect(selection?.variants).toHaveLength(1); + expect(selection?.variants[0]).toMatchObject({ audienceKey: "default", isDefault: true }); + }); + + it("chains each slot from the previous deadline, so a late timer costs nothing twice", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + await service.activate(group.group.id, timeline.timeline.id, "now"); + const first = await service.snapshot(group.group.id); + + // The timer fires 900 ms late, as a busy process's timer does. + await tick(5900); + const second = await service.snapshot(group.group.id); + + // The new slot starts at the old slot's deadline — not at "now" — so the lateness is absorbed + // once instead of stretching the loop by 900 ms on every slide for the rest of the day. + expect(second.startedAtMs).toBe(first.deadlineAtMs); + expect(second.deadlineAtMs).toBe((first.deadlineAtMs ?? 0) + 5000); + }); + + it("catches up a long outage with arithmetic, not by replaying every missed slide", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + await service.activate(group.group.id, timeline.timeline.id, "now"); + const callsBefore = + announcements.resolveRequests.length + + trivia.resolveRequests.length + + scripture.resolveRequests.length; + + // A week offline. Seven slots at five seconds each is more than a hundred thousand slides. + clock += 7 * 24 * 60 * 60 * 1000; + await service.reconcile(group.group.id, "startup"); + + const callsAfter = + announcements.resolveRequests.length + + trivia.resolveRequests.length + + scripture.resolveRequests.length; + // Exactly one provider call: the slot actually landed on. A replay would have burned the + // providers' rotations and taken longer than the outage. + expect(callsAfter - callsBefore).toBe(1); + const snapshot = await service.snapshot(group.group.id); + expect(snapshot.currentSelectionId).not.toBeNull(); + // And the slot it landed on is genuinely current rather than in the past. + expect(snapshot.deadlineAtMs).toBeGreaterThan(clock); + }); + + it("skips a slot whose provider has nothing right now, immediately and without an error", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + trivia.setItems([]); + + await service.activate(group.group.id, timeline.timeline.id, "now"); + await tick(5000); + await tick(5000); + // The fourth slot is Trivia, which has nothing. It is skipped in the same instant. + const snapshot = await service.snapshot(group.group.id); + const selection = await store.findSelection(snapshot.currentSelectionId ?? ""); + + await tick(5000); + const afterTrivia = await service.snapshot(group.group.id); + const next = await store.findSelection(afterTrivia.currentSelectionId ?? ""); + + expect(selection?.itemId).toBe("giving"); + // Straight past Trivia to the next announcement. No gap, no black frame, no error. + expect(next?.itemId).toBe("kids"); + expect(next?.startedAt?.getTime()).toBe(selection?.deadlineAt?.getTime()); + expect( + registry.health().find((p) => p.providerId === "fixture.trivia")?.consecutiveFailures, + ).toBe(0); + }); + + it("goes idle rather than inventing content when nothing at all is available", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + announcements.setItems([]); + trivia.setItems([]); + scripture.setItems([]); + + await service.activate(group.group.id, timeline.timeline.id, "now"); + const snapshot = await service.snapshot(group.group.id); + + expect(snapshot.transport).toBe("idle"); + expect(snapshot.currentSelectionId).toBeNull(); + // The display shows its slate. Signage did not fabricate a slide to fill the gap. + }); + + it("freezes on pause and continues the same slide from its remainder on resume", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + await service.activate(group.group.id, timeline.timeline.id, "now"); + const item = await currentItem(group.group.id); + + clock += 2000; + const paused = await service.pause(group.group.id); + + expect(paused.transport).toBe("paused"); + expect(paused.remainingMs).toBe(3000); + expect(paused.deadlineAtMs).toBeNull(); + + // Two minutes of a paused screen must not advance anything. + clock += 120_000; + expect(await currentItem(group.group.id)).toBe(item); + + const resumed = await service.resume(group.group.id); + expect(resumed.transport).toBe("playing"); + expect(await currentItem(group.group.id)).toBe(item); + // Three seconds left, and the reconstructed window is still a whole slot long. + expect(resumed.deadlineAtMs).toBe(clock + 3000); + expect((resumed.deadlineAtMs ?? 0) - (resumed.startedAtMs ?? 0)).toBe(5000); + }); + + it("holds a static image while preserving the cursor underneath, and continues on release", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + await service.activate(group.group.id, timeline.timeline.id, "now"); + const item = await currentItem(group.group.id); + const imageId = newId(); + store.images.set(imageId, { + contentSha256: "a".repeat(64), + createdAt: new Date(clock), + displayName: "Hold", + encodedHeight: 1080, + encodedWidth: 1920, + expiresAt: null, + hasAlpha: false, + height: 1080, + id: imageId, + orientation: null, + originalByteSize: 1, + originalContentType: "image/png", + originalFilename: "hold.png", + originalObjectKey: "assets/hold/original.png", + renditions: [], + status: "ready", + width: 1920, + }); + + clock += 1000; + const held = await service.setStaticHold(group.group.id, imageId); + expect(held.transport).toBe("static"); + expect(held.staticAssetId).toBe(imageId); + // The normal cursor is preserved, not discarded. + expect(held.remainingMs).toBe(4000); + expect(held.currentSelectionId).not.toBeNull(); + + clock += 60_000; + const released = await service.setStaticHold(group.group.id, null); + + expect(released.transport).toBe("playing"); + // The same slide continues with the remainder it had, rather than the loop restarting. + expect(await currentItem(group.group.id)).toBe(item); + expect(released.deadlineAtMs).toBe(clock + 4000); + }); + + it("keeps the hidden timeline running through a blackout", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + await service.activate(group.group.id, timeline.timeline.id, "now"); + + const dark = await service.setBlackout(group.group.id, true); + expect(dark.blackout).toBe(true); + // Blackout covers output. It does not pause, reset, or stop resolving. + expect(dark.transport).toBe("playing"); + expect(dark.deadlineAtMs).not.toBeNull(); + + await tick(5000); + const later = await service.snapshot(group.group.id); + expect(later.blackout).toBe(true); + expect(await currentItem(group.group.id)).toBe("greeting"); + + const lit = await service.setBlackout(group.group.id, false); + // Clearing it lands exactly where the hidden timeline had got to. + expect(lit.blackout).toBe(false); + expect(await currentItem(group.group.id)).toBe("greeting"); + }); + + it("walks actual resolved playback with previous, including past a skipped slot", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + trivia.setItems([]); + await service.activate(group.group.id, timeline.timeline.id, "now"); + await tick(5000); + await tick(5000); + await tick(5000); + expect(await currentItem(group.group.id)).toBe("kids"); + + const back = await service.previous(group.group.id); + // Trivia was never resolved, so it is not in history. "Previous" means the slide that was + // actually on screen, which is the third announcement. + expect(await currentItem(group.group.id)).toBe("giving"); + expect(back.deadlineAtMs).toBe(clock + 5000); + + await service.previous(group.group.id); + // And again, further back — not oscillating between two slides. + expect(await currentItem(group.group.id)).toBe("greeting"); + }); + + it("ends the current slot immediately on next, and still applies eligibility and wraparound", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + await service.activate(group.group.id, timeline.timeline.id, "now"); + + clock += 1000; + const skipped = await service.next(group.group.id); + + expect(await currentItem(group.group.id)).toBe("greeting"); + expect(skipped.startedAtMs).toBe(clock); + expect(skipped.deadlineAtMs).toBe(clock + 5000); + }); + + it("keeps the last known good item and marks it stale when a provider fails", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + await service.activate(group.group.id, timeline.timeline.id, "now"); + const before = await service.snapshot(group.group.id); + announcements.failNext(5); + + await service.reload(group.group.id); + const after = await service.snapshot(group.group.id); + const selection = await store.findSelection(after.currentSelectionId ?? ""); + + // The slide stays up, and the deadline is untouched: a failure is not a reason to blank a wall. + expect(after.currentSelectionId).toBe(before.currentSelectionId); + expect(after.deadlineAtMs).toBe(before.deadlineAtMs); + expect(selection?.stale).toBe(true); + expect(selection?.itemId).toBe("welcome"); + expect(await currentItem(group.group.id)).toBe("welcome"); + }); + + it("updates the current slide in place when a provider invalidates it, without restarting the slot", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + await service.activate(group.group.id, timeline.timeline.id, "now"); + const before = await service.snapshot(group.group.id); + const beforeSelection = await store.findSelection(before.currentSelectionId ?? ""); + + announcements.reviseItem("welcome", { headline: "Cancelled" }); + clock += 1500; + await service.invalidate({ itemId: "welcome", providerId: "fixture.announcements" }); + + const after = await service.snapshot(group.group.id); + const afterSelection = await store.findSelection(after.currentSelectionId ?? ""); + + // Same slide, same slot, same deadline — new payload and a new revision, which is exactly what + // updates a mounted React renderer in place. + expect(after.currentSelectionId).toBe(before.currentSelectionId); + expect(after.deadlineAtMs).toBe(before.deadlineAtMs); + expect(after.selectionRevision).toBe((before.selectionRevision ?? 0) + 1); + expect(afterSelection?.variants[0]?.payload).toEqual({ headline: "Cancelled" }); + expect(beforeSelection?.variants[0]?.payload).toEqual({ headline: "Welcome" }); + }); + + it("does not bump a revision when an invalidation changed nothing", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + await service.activate(group.group.id, timeline.timeline.id, "now"); + const before = await service.snapshot(group.group.id); + + await service.invalidate({ itemId: "welcome", providerId: "fixture.announcements" }); + + const after = await service.snapshot(group.group.id); + // A client polling on revision must not be woken by nothing. + expect(after.selectionRevision).toBe(before.selectionRevision); + }); + + it("marks an explicitly withdrawn item and removes it at the next boundary, not mid-slide", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + await service.activate(group.group.id, timeline.timeline.id, "now"); + const before = await service.snapshot(group.group.id); + + announcements.withdraw("welcome"); + clock += 1000; + await service.invalidate({ itemId: "welcome", providerId: "fixture.announcements" }); + const during = await service.snapshot(group.group.id); + const selection = await store.findSelection(during.currentSelectionId ?? ""); + + // Still on screen, still on its own deadline — marked, not cut. + expect(during.currentSelectionId).toBe(before.currentSelectionId); + expect(during.deadlineAtMs).toBe(before.deadlineAtMs); + expect(selection?.withdrawn).toBe(true); + + await tick(4000); + // Gone at the boundary — and *which* item follows is the provider's decision, made from a pool + // that no longer contains the withdrawn one. Signage neither chose it nor knows why. + const next = await currentItem(group.group.id); + expect(next).not.toBe("welcome"); + expect(["greeting", "giving", "kids"]).toContain(next); + }); + + it("activates immediately, or at the next slot boundary, exactly as asked", async () => { + const group = await seedGroup(); + const first = await seedPattern(); + const second = await createTimeline({ name: "Evening", newId, store }); + const withSource = await addProviderSource({ + label: "Scripture", + newId, + providerId: "fixture.scripture", + sourceId: "reading-plan", + store, + timelineId: second.timeline.id, + }); + const source = withSource.sources[0]; + if (!source) { + throw new Error("Expected a source."); + } + await addSlot({ + newId, + source: { sourceId: source.id }, + store, + timelineId: second.timeline.id, + }); + + await service.activate(group.group.id, first.timeline.id, "now"); + expect(await currentItem(group.group.id)).toBe("welcome"); + + clock += 1000; + const queued = await service.activate(group.group.id, second.timeline.id, "next-boundary"); + // Queued, and the current slide is not cut short. + expect(queued.pendingTimelineId).toBe(second.timeline.id); + expect(queued.activeTimelineId).toBe(first.timeline.id); + expect(await currentItem(group.group.id)).toBe("welcome"); + + await tick(4000); + const switched = await service.snapshot(group.group.id); + expect(switched.activeTimelineId).toBe(second.timeline.id); + expect(switched.pendingTimelineId).toBeNull(); + expect(await currentItem(group.group.id)).toBe("psalm-23"); + }); + + it("applies a scheduled activation at its own instant, not when the timer happened to fire", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + const at = new Date(clock + 60_000); + + const scheduled = await service.activate(group.group.id, timeline.timeline.id, { at }); + expect(scheduled.scheduledTimelineId).toBe(timeline.timeline.id); + expect(scheduled.activeTimelineId).toBeNull(); + + // The callback arrives two seconds late, as a real one does. + clock += 62_000; + const after = await service.reconcile(group.group.id, "timer"); + + expect(after.activeTimelineId).toBe(timeline.timeline.id); + expect(after.scheduledTimelineId).toBeNull(); + expect(await currentItem(group.group.id)).toBe("welcome"); + }); + + it("refuses a scheduled activation in the past rather than firing it instantly", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + + await expect( + service.activate(group.group.id, timeline.timeline.id, { at: new Date(clock - 1000) }), + ).rejects.toMatchObject({ code: "SIGNAGE_INVALID_REQUEST" }); + }); + + it("keeps history bounded and still lets previous work", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + await service.activate(group.group.id, timeline.timeline.id, "now"); + for (let index = 0; index < 30; index += 1) { + await tick(5000); + } + + const history = await store.listSelections(group.group.id, 100); + expect(history.length).toBeLessThanOrEqual(20); + const back = await service.previous(group.group.id); + expect(back.currentSelectionId).not.toBeNull(); + }); + + it("recovers a restart onto the item that was already showing, not a new one", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + await service.activate(group.group.id, timeline.timeline.id, "now"); + const before = await service.snapshot(group.group.id); + const callsBefore = announcements.resolveRequests.length; + + // A restart two seconds into a five-second slide: reconciliation runs with the persisted state. + clock += 2000; + const after = await service.reconcile(group.group.id, "startup"); + + expect(after.currentSelectionId).toBe(before.currentSelectionId); + expect(after.deadlineAtMs).toBe(before.deadlineAtMs); + // And no provider was asked, so no rotation was consumed by a restart. + expect(announcements.resolveRequests.length).toBe(callsBefore); + }); + + it("publishes a compact snapshot, with no payload or timeline in it", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + await service.activate(group.group.id, timeline.timeline.id, "now"); + + const latest = published.at(-1); + expect(latest?.reason).toBe("activated"); + const serialized = JSON.stringify(latest?.snapshot); + // Compact enough for the platform's realtime ceiling many times over, and carrying identity and + // revisions rather than content: large state is fetched over authorized HTTP keyed by revision. + expect(serialized.length).toBeLessThan(1024); + expect(serialized).not.toContain("Welcome"); + expect(serialized).not.toContain("headline"); + }); + + it("serializes commands so two at once cannot interleave", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + await service.activate(group.group.id, timeline.timeline.id, "now"); + + const [first, second] = await Promise.all([ + service.next(group.group.id), + service.next(group.group.id), + ]); + + // Two "next" presses land on two different slides, in order — never on the same one, and never + // on a state neither command asked for. + expect(first.currentSelectionId).not.toBe(second.currentSelectionId); + expect(second.revision).toBeGreaterThan(first.revision); + }); +}); diff --git a/tests/providers.test.ts b/tests/providers.test.ts new file mode 100644 index 0000000..5e6054d --- /dev/null +++ b/tests/providers.test.ts @@ -0,0 +1,516 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { SIGNAGE_PROVIDER_CONTRACT_VERSION } from "../src/server/providers/contract.js"; +import { createNativeImageProvider } from "../src/server/providers/native-provider.js"; +import { + createProviderRegistry, + type SignageProviderRegistry, +} from "../src/server/providers/registry.js"; +import { SignageProviderError } from "../src/server/providers/validation.js"; +import { SIGNAGE_NATIVE_PROVIDER_ID } from "../src/shared/native-images.js"; +import { createFakeStorage } from "./support/fake-storage.js"; +import { + announcementsFixture, + createFixtureProvider, + scriptureFixture, + triviaFixture, +} from "./support/fixture-provider.js"; +import { makeImage } from "./support/images.js"; +import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js"; + +const AT = "2026-08-27T15:00:00.000Z"; + +function request(overrides: Record = {}) { + return { + at: AT, + audienceKeys: ["default"], + channelGroupId: "group-1", + selectionScopeId: "scope-1", + sourceId: "active-slides", + timelineId: "timeline-1", + timezone: "America/Chicago", + ...overrides, + } as never; +} + +describe("content provider registry", () => { + let registry: SignageProviderRegistry; + let invalidations: { providerId: string; itemId?: string; sourceId?: string }[]; + + beforeEach(() => { + invalidations = []; + registry = createProviderRegistry({ + onInvalidate: (notice) => invalidations.push(notice), + timeoutMs: 200, + }); + }); + + it("registers a provider, reports it as healthy, and unregisters it cleanly", () => { + const announcements = announcementsFixture(); + + const handle = registry.capability.register(announcements.registration); + + expect(registry.capability.contractVersion).toBe(SIGNAGE_PROVIDER_CONTRACT_VERSION); + expect(registry.has("fixture.announcements")).toBe(true); + expect(registry.health()).toEqual([ + { + consecutiveFailures: 0, + contractVersion: "1.0.0", + defaultDurationMs: 5000, + lastFailureAt: null, + lastFailureCode: null, + lastSuccessAt: null, + providerId: "fixture.announcements", + providerVersion: "1.0.0", + registeredAt: expect.any(String), + renderer: { contributionId: "fixture.announcements.slide", contributionVersion: "1.0.0" }, + }, + ]); + + handle.unregister(); + + expect(registry.has("fixture.announcements")).toBe(false); + expect(registry.health()).toEqual([]); + }); + + it("unregisters idempotently, so a rollback after a partial start is safe", () => { + const handle = registry.capability.register(announcementsFixture().registration); + + handle.unregister(); + handle.unregister(); + // A late invalidation from a module being torn down is ordinary, not an error. + expect(() => handle.invalidate({ itemId: "welcome" })).not.toThrow(); + + expect(invalidations).toEqual([]); + // And the same provider may register again — a module restored after being disabled. + expect(() => registry.capability.register(announcementsFixture().registration)).not.toThrow(); + }); + + it("refuses a second live registration of the same provider id", () => { + registry.capability.register(announcementsFixture().registration); + + expect(() => registry.capability.register(announcementsFixture().registration)).toThrowError( + expect.objectContaining({ code: "SIGNAGE_PROVIDER_DUPLICATE" }), + ); + }); + + it("rejects a malformed registration with a sanitized code carrying none of the value", () => { + const cases: readonly [unknown, string][] = [ + [undefined, "SIGNAGE_PROVIDER_REGISTRATION_INVALID"], + ["announcements", "SIGNAGE_PROVIDER_REGISTRATION_INVALID"], + [ + { ...announcementsFixture().registration, providerId: "" }, + "SIGNAGE_PROVIDER_REGISTRATION_INVALID", + ], + [ + { ...announcementsFixture().registration, providerVersion: "one" }, + "SIGNAGE_PROVIDER_REGISTRATION_INVALID", + ], + [ + { ...announcementsFixture().registration, resolveNext: "not a function" }, + "SIGNAGE_PROVIDER_REGISTRATION_INVALID", + ], + [ + { ...announcementsFixture().registration, defaultDurationMs: Number.NaN }, + "SIGNAGE_PROVIDER_REGISTRATION_INVALID", + ], + [ + { ...announcementsFixture().registration, renderer: { contributionId: "x" } }, + "SIGNAGE_PROVIDER_REGISTRATION_INVALID", + ], + [ + { ...announcementsFixture().registration, contractVersion: "2.0.0" }, + "SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED", + ], + ]; + + for (const [value, code] of cases) { + let thrown: unknown; + try { + registry.capability.register(value); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(SignageProviderError); + const error = thrown as SignageProviderError; + expect(error.code).toBe(code); + // The message is fixed, and nothing of the rejected registration is in it. + expect(error.message).not.toMatch(/announcements|function|NaN|2\.0\.0/u); + } + expect(registry.health()).toEqual([]); + }); + + it("separates a contract-version mismatch from a malformed registration, so an operator can act", () => { + const outdated = createFixtureProvider({ + contractVersion: "2.0.0", + items: [{ id: "a", label: "A", payload: {} }], + policy: "cycle", + providerId: "fixture.future", + sourceId: "s", + sourceLabel: "S", + }); + + expect(() => registry.capability.register(outdated.registration)).toThrowError( + expect.objectContaining({ + code: "SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED", + providerId: "fixture.future", + }), + ); + }); + + it("passes invalidation through as a notification, never as a command", () => { + const handle = registry.capability.register(announcementsFixture().registration); + + handle.invalidate({ itemId: "greeting" }); + handle.invalidate({ sourceId: "active-slides" }); + handle.invalidate(); + + expect(invalidations).toEqual([ + { itemId: "greeting", providerId: "fixture.announcements" }, + { providerId: "fixture.announcements", sourceId: "active-slides" }, + { providerId: "fixture.announcements" }, + ]); + }); +}); + +describe("provider selection", () => { + let registry: SignageProviderRegistry; + + beforeEach(() => { + registry = createProviderRegistry({ timeoutMs: 200 }); + }); + + it("supplies a provider with a scope, a time, and an audience — and nothing else", async () => { + const announcements = announcementsFixture(); + registry.capability.register(announcements.registration); + + await registry.resolveNext( + "fixture.announcements", + request({ audienceKeys: ["default", "online"] }), + ); + + expect(announcements.resolveRequests).toHaveLength(1); + expect(Object.keys(announcements.resolveRequests[0] ?? {}).sort()).toEqual([ + "at", + "audienceKeys", + "channelGroupId", + "selectionScopeId", + "sourceId", + "timelineId", + "timezone", + ]); + // No principal, no session, no database, no storage, no URL, no permission. + }); + + it("lets an announcements-shaped provider cycle its own active set forever", async () => { + const announcements = announcementsFixture(); + registry.capability.register(announcements.registration); + + for (let index = 0; index < 6; index += 1) { + await registry.resolveNext("fixture.announcements", request()); + } + + // Signage asked six times and got the provider's own rotation, wrapping where the provider + // decided to wrap. Signage implements no repeat policy at all. + expect(announcements.served).toEqual([ + "welcome", + "greeting", + "giving", + "kids", + "welcome", + "greeting", + ]); + }); + + it("lets trivia and scripture refuse to repeat until their pools are exhausted", async () => { + const trivia = triviaFixture(); + const scripture = scriptureFixture(); + registry.capability.register(trivia.registration); + registry.capability.register(scripture.registration); + + for (let index = 0; index < 4; index += 1) { + await registry.resolveNext("fixture.trivia", request({ sourceId: "open-questions" })); + await registry.resolveNext("fixture.scripture", request({ sourceId: "reading-plan" })); + } + + // Every question appears once before any repeats; the fourth call starts a new pass. + expect(trivia.served).toEqual(["q1", "q2", "q3", "q1"]); + expect(scripture.served).toEqual(["psalm-23", "john-3", "psalm-23", "john-3"]); + }); + + it("keeps one rotation per selection scope, and two scopes independent", async () => { + const announcements = announcementsFixture(); + registry.capability.register(announcements.registration); + + await registry.resolveNext("fixture.announcements", request({ selectionScopeId: "lobby" })); + await registry.resolveNext("fixture.announcements", request({ selectionScopeId: "lobby" })); + await registry.resolveNext("fixture.announcements", request({ selectionScopeId: "atrium" })); + + // Two source instances an operator deliberately created as separate rotations stay separate. + expect(announcements.served).toEqual(["welcome", "greeting", "welcome"]); + }); + + it("carries one logical selection with a variant per audience, and a required default", async () => { + const announcements = announcementsFixture(); + registry.capability.register(announcements.registration); + await registry.resolveNext("fixture.announcements", request()); + + const outcome = await registry.resolveNext("fixture.announcements", request()); + + expect(outcome.kind).toBe("resolved"); + if (outcome.kind !== "resolved" || !outcome.resolution) { + throw new Error("Expected a resolution."); + } + expect(outcome.resolution.itemId).toBe("greeting"); + // One resolution, two renderings. The provider was asked once. + expect([...outcome.resolution.variants.keys()].sort()).toEqual(["default", "online"]); + expect(outcome.resolution.variants.get("online")).toEqual({ headline: "Watching from home" }); + expect(outcome.resolution.variants.get("default")).toEqual({ headline: "Good morning" }); + expect(announcements.resolveRequests).toHaveLength(2); + }); + + it("treats an empty pool as nothing right now, not as a failure", async () => { + const trivia = triviaFixture(); + trivia.setItems([]); + registry.capability.register(trivia.registration); + + const outcome = await registry.resolveNext( + "fixture.trivia", + request({ sourceId: "open-questions" }), + ); + + expect(outcome).toEqual({ kind: "resolved", resolution: null }); + // A skip is not a failure, so nothing about provider health changes. + expect(registry.health()[0]?.consecutiveFailures).toBe(0); + expect(registry.health()[0]?.lastFailureCode).toBeNull(); + }); + + it("reports a provider that throws as temporarily failed, and recovers when it stops", async () => { + const announcements = announcementsFixture(); + registry.capability.register(announcements.registration); + announcements.failNext(2); + + const first = await registry.resolveNext("fixture.announcements", request()); + const second = await registry.resolveNext("fixture.announcements", request()); + + expect(first).toEqual({ code: "SIGNAGE_PROVIDER_UNAVAILABLE", kind: "failed" }); + expect(second).toEqual({ code: "SIGNAGE_PROVIDER_UNAVAILABLE", kind: "failed" }); + expect(registry.health()[0]).toMatchObject({ + consecutiveFailures: 2, + lastFailureCode: "SIGNAGE_PROVIDER_UNAVAILABLE", + }); + + const recovered = await registry.resolveNext("fixture.announcements", request()); + + expect(recovered.kind).toBe("resolved"); + // Restoring a compatible provider allows a safe refresh without restarting the module. + expect(registry.health()[0]).toMatchObject({ consecutiveFailures: 0, lastFailureCode: null }); + }); + + it("bounds a provider that never answers, so one provider cannot freeze a channel group", async () => { + const announcements = announcementsFixture(); + registry.capability.register(announcements.registration); + announcements.hang(true); + + const outcome = await registry.resolveNext("fixture.announcements", request()); + + expect(outcome).toEqual({ code: "SIGNAGE_PROVIDER_UNAVAILABLE", kind: "failed" }); + }); + + it("distinguishes an explicit withdrawal from an item that is merely unavailable", async () => { + const announcements = announcementsFixture(); + registry.capability.register(announcements.registration); + + const missing = await registry.refresh( + "fixture.announcements", + request({ itemId: "not-a-real-item" }), + ); + announcements.withdraw("welcome"); + const gone = await registry.refresh("fixture.announcements", request({ itemId: "welcome" })); + + expect(missing).toEqual({ kind: "resolved", resolution: null }); + expect(gone.kind).toBe("resolved"); + if (gone.kind !== "resolved") { + throw new Error("Expected a resolution."); + } + expect(gone.resolution?.withdrawn).toBe(true); + }); + + it("reports an unregistered provider as unregistered, never as failed", async () => { + const outcome = await registry.resolveNext("fixture.nothing", request()); + + // A temporarily missing registration is not an error, and must not erase anything Signage has + // already persisted about what that provider resolved. + expect(outcome).toEqual({ kind: "unregistered" }); + }); + + it("refuses a resolution this contract cannot store or render, without echoing it", async () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + const malformed = [ + { itemId: "a", itemRevision: "1", variants: [] }, + { itemId: "a", itemRevision: "1", variants: [{ audienceKey: "online", payload: {} }] }, + { itemId: "a", itemRevision: "1", variants: [{ audienceKey: null, payload: cyclic }] }, + { + durationMs: Number.POSITIVE_INFINITY, + itemId: "a", + itemRevision: "1", + variants: [{ audienceKey: null, payload: {} }], + }, + { + itemId: "a", + itemRevision: "1", + variants: [{ audienceKey: null, payload: { when: new Date() } }], + }, + { itemId: "", itemRevision: "1", variants: [{ audienceKey: null, payload: {} }] }, + ]; + + for (const resolution of malformed) { + const provider = createFixtureProvider({ + items: [], + policy: "cycle", + providerId: "fixture.malformed", + sourceId: "s", + sourceLabel: "S", + }); + const registration = { + ...provider.registration, + resolveNext: () => resolution as never, + }; + const handle = registry.capability.register(registration); + + const outcome = await registry.resolveNext("fixture.malformed", request({ sourceId: "s" })); + + expect(outcome).toEqual({ code: "SIGNAGE_PROVIDER_RESULT_INVALID", kind: "failed" }); + handle.unregister(); + } + }); + + it("returns nothing at all for discovery when a provider is absent or failing", async () => { + expect(await registry.listSources("fixture.absent", { at: AT, timezone: "UTC" })).toEqual([]); + + const announcements = announcementsFixture(); + registry.capability.register(announcements.registration); + announcements.failNext(1); + + expect( + await registry.listSources("fixture.announcements", { at: AT, timezone: "UTC" }), + ).toEqual([]); + // The editor says "no sources right now"; provider health says why. + expect(registry.health()[0]?.consecutiveFailures).toBe(1); + }); + + it("discovers a provider's own sources and items for the timeline editor", async () => { + const announcements = announcementsFixture(); + registry.capability.register(announcements.registration); + + const sources = await registry.listSources("fixture.announcements", { + at: AT, + timezone: "UTC", + }); + const items = await registry.listItems("fixture.announcements", { at: AT, timezone: "UTC" }); + + expect(sources).toEqual([{ id: "active-slides", label: "Active announcements" }]); + expect(items.map((item) => item.id)).toEqual(["welcome", "greeting", "giving", "kids"]); + }); +}); + +describe("Signage's own image provider", () => { + let store: MemorySignageStore; + + async function seedImage(name: string, width = 1920, height = 1080): Promise { + const { ingestNativeImage } = await import("../src/server/images/service.js"); + const result = await ingestNativeImage({ + bytes: await makeImage({ format: "png", height, width }), + displayName: name, + expiresAt: null, + filename: `${name}.png`, + storage: createFakeStorage(), + store, + }); + return result.image.id; + } + + beforeEach(() => { + store = createMemorySignageStore(); + }); + + it("registers through the identical capability an external provider uses", async () => { + const registry = createProviderRegistry({ timeoutMs: 200 }); + const native = createNativeImageProvider({ defaultDurationMs: 7000, store }); + + registry.capability.register(native.registration); + + expect(registry.health()).toEqual([ + expect.objectContaining({ + contractVersion: SIGNAGE_PROVIDER_CONTRACT_VERSION, + defaultDurationMs: 7000, + providerId: SIGNAGE_NATIVE_PROVIDER_ID, + // Not a browser contribution: a display seeing this identity paints an image itself. + renderer: { contributionId: "signage.native-image", contributionVersion: "1.0.0" }, + }), + ]); + }); + + it("cycles the library in the order images were added", async () => { + const first = await seedImage("first"); + const second = await seedImage("second", 1600, 900); + const registry = createProviderRegistry({ timeoutMs: 500 }); + registry.capability.register( + createNativeImageProvider({ defaultDurationMs: 7000, store }).registration, + ); + + const served: (string | undefined)[] = []; + for (let index = 0; index < 3; index += 1) { + const outcome = await registry.resolveNext( + SIGNAGE_NATIVE_PROVIDER_ID, + request({ sourceId: "library" }), + ); + served.push(outcome.kind === "resolved" ? outcome.resolution?.itemId : undefined); + } + + expect(served).toEqual([first, second, first]); + }); + + it("carries object keys in its payload, never a delivery URL", async () => { + const imageId = await seedImage("welcome"); + const registry = createProviderRegistry({ timeoutMs: 500 }); + registry.capability.register( + createNativeImageProvider({ defaultDurationMs: 7000, store }).registration, + ); + + const outcome = await registry.refresh( + SIGNAGE_NATIVE_PROVIDER_ID, + request({ itemId: imageId }), + ); + + if (outcome.kind !== "resolved" || !outcome.resolution) { + throw new Error("Expected a resolution."); + } + const payload = outcome.resolution.variants.get("default") as Record; + expect(payload).toMatchObject({ imageId, kind: "native-image" }); + expect(JSON.stringify(payload)).not.toMatch(/https?:|token=/u); + }); + + it("treats a deleted image as an explicit withdrawal and an expired one as nothing right now", async () => { + const imageId = await seedImage("welcome"); + const registry = createProviderRegistry({ timeoutMs: 500 }); + registry.capability.register( + createNativeImageProvider({ defaultDurationMs: 7000, store }).registration, + ); + + await store.updateImage(imageId, { expiresAt: new Date("2020-01-01T00:00:00.000Z") }); + const expired = await registry.refresh( + SIGNAGE_NATIVE_PROVIDER_ID, + request({ itemId: imageId }), + ); + + await store.deleteImage(imageId); + const deleted = await registry.refresh( + SIGNAGE_NATIVE_PROVIDER_ID, + request({ itemId: imageId }), + ); + + expect(expired).toEqual({ kind: "resolved", resolution: null }); + expect(deleted.kind === "resolved" && deleted.resolution?.withdrawn).toBe(true); + }); +}); diff --git a/tests/schema.test.ts b/tests/schema.test.ts new file mode 100644 index 0000000..d04c81e --- /dev/null +++ b/tests/schema.test.ts @@ -0,0 +1,139 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { + parseDrizzleMigrationJournal, + parseModuleManifest, + parseModuleMigrationPolicy, +} from "@ekklesia/module-sdk"; +import { getTableConfig, type PgTable } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import moduleSchema from "../src/schema/index.js"; + +const tables = moduleSchema.tables as Readonly>; + +async function readSql(): Promise { + return readFile(new URL("../drizzle/0000_initial.sql", import.meta.url), "utf8"); +} + +describe("Signage schema", () => { + it("declares every table Signage's data model needs, and nothing outside its namespace", () => { + const physicalNames = Object.values(tables) + .map((table) => getTableConfig(table).name) + .sort(); + + expect(physicalNames).toEqual([ + "signage_channel_groups", + "signage_channels", + "signage_image_assets", + "signage_image_renditions", + "signage_playback_states", + "signage_provider_sources", + "signage_selection_variants", + "signage_selections", + "signage_timeline_slots", + "signage_timelines", + "signage_viewers", + ]); + for (const table of Object.values(tables)) { + const config = getTableConfig(table); + // The platform-managed default schema, never a custom PostgreSQL schema. + expect(config.schema).toBeUndefined(); + expect(config.name.length).toBeLessThanOrEqual(63); + } + expect(moduleSchema.moduleId).toBe("signage"); + }); + + it("never points a foreign key outside this module", () => { + const owned = new Set(Object.values(tables).map((table) => getTableConfig(table).name)); + + for (const table of Object.values(tables)) { + for (const foreignKey of getTableConfig(table).foreignKeys) { + const reference = foreignKey.reference(); + expect(owned).toContain(getTableConfig(reference.foreignTable).name); + } + } + }); + + it("stores another module's identities as opaque text rather than as a reference", () => { + // A provider, its sources, and its items belong to another module. Signage stores what it was + // told and can never join to it — which is exactly what keeps the two independently installable. + const opaque = [ + ["providerSources", "provider_id"], + ["providerSources", "source_id"], + ["timelineSlots", "provider_id"], + ["timelineSlots", "item_id"], + ["selections", "provider_id"], + ["selections", "item_id"], + ["viewers", "device_id"], + ] as const; + + for (const [tableName, columnName] of opaque) { + const table = tables[tableName]; + if (!table) { + throw new Error(`Signage's schema is missing ${tableName}.`); + } + const config = getTableConfig(table); + const column = config.columns.find((candidate) => candidate.name === columnName); + expect(column?.getSQLType()).toBe("text"); + const referenced = config.foreignKeys.some((foreignKey) => + foreignKey.reference().columns.some((candidate) => candidate.name === columnName), + ); + expect(referenced).toBe(false); + } + }); + + it("stores logical object keys and never a signed delivery URL", async () => { + const sql = await readSql(); + + expect(sql).toContain('"original_object_key" text NOT NULL'); + expect(sql).toContain('"object_key" text NOT NULL'); + // A read URL is a bearer capability with an expiry in it. Nothing in this schema can hold one. + for (const forbidden of ["url", "signed_url", "presigned", "delivery_url"]) { + expect(sql.toLowerCase()).not.toContain(`"${forbidden}"`); + } + }); + + it("makes the invariants playback depends on the database's job, not a convention", async () => { + const sql = await readSql(); + + // Content-addressed identity: the same bytes are the same asset. + expect(sql).toContain("signage_image_assets_content_sha256_key"); + // A slot position is unique within its timeline. + expect(sql).toContain("signage_timeline_slots_position_key"); + // Exactly one default channel per group. + expect(sql).toContain("signage_channels_group_default_key"); + // A slot is a provider source or a pinned item, never both and never neither. + expect(sql).toContain("signage_timeline_slots_reference_check"); + // History is ordered and gapless per group. + expect(sql).toContain("signage_selections_group_sequence_key"); + // One payload per audience per selection. + expect(sql).toContain("signage_selection_variants_selection_audience_key"); + // Deleting a slot must not erase what already happened on screen. + expect(sql).toMatch(/signage_selections_slot_id_.*ON DELETE set null/su); + }); + + it("packages one reviewed, non-destructive, forward-only migration", async () => { + const [journalInput, policyInput, manifestInput, bytes] = await Promise.all([ + readFile(new URL("../drizzle/meta/_journal.json", import.meta.url), "utf8"), + readFile(new URL("../drizzle/meta/_ekklesia.json", import.meta.url), "utf8"), + readFile(new URL("../ekklesia.module.json", import.meta.url), "utf8"), + readFile(new URL("../drizzle/0000_initial.sql", import.meta.url)), + ]); + const journal = parseDrizzleMigrationJournal(JSON.parse(journalInput) as unknown); + const policy = parseModuleMigrationPolicy(JSON.parse(policyInput) as unknown); + const manifest = parseModuleManifest(JSON.parse(manifestInput) as unknown); + + expect(journal.entries.map((entry) => entry.tag)).toEqual(["0000_initial"]); + expect(policy).toMatchObject({ + moduleId: "signage", + entries: [{ tag: "0000_initial", destructive: { mode: "none" } }], + }); + expect(manifest.entrypoints.migrations).toBe("./drizzle"); + expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js"); + // Pinned deliberately. Core compares the exact bytes against its applied history and refuses a + // changed migration, so an accidental edit here must fail in this repository first. + expect(createHash("sha256").update(bytes).digest("hex")).toBe( + "fbbc448f9a951818bf37e89849ccdbfb55f3221260db0f59d1a2b245cc126838", + ); + }); +}); diff --git a/tests/support/fake-storage.ts b/tests/support/fake-storage.ts new file mode 100644 index 0000000..77d557e --- /dev/null +++ b/tests/support/fake-storage.ts @@ -0,0 +1,130 @@ +import { + type ModuleStorage, + type ModuleStorageBucket, + ModuleStorageError, + type ModuleStorageListing, + type ModuleStorageObject, + type ModuleStorageObjectMetadata, + type ModuleStorageReadUrl, +} from "@ekklesia/module-sdk"; + +/** + * An in-memory `ModuleStorage`, scoped exactly as the real one is. + * + * `ModuleStorage` is a `@ekklesia/contracts` interface, so a module-layer test may legitimately + * construct one — and doing so demonstrates the portability claim directly: the host supplying this + * object could be Ekklesia over S3-compatible storage, or a future standalone host over something + * else entirely, and Signage cannot tell. + * + * It refuses an undeclared bucket exactly as the real facade does, so a test cannot accidentally + * pass by reaching a bucket the manifest never declared. + */ +export interface FakeStorage extends ModuleStorage { + readonly objects: Map; + /** Test-only: make the next `put` for this key fail, so cleanup can be exercised. */ + failPut(key: string): void; + readonly readUrlsIssued: string[]; +} + +export function createFakeStorage( + options: { readonly declared?: readonly string[]; readonly now?: () => Date } = {}, +): FakeStorage { + const declared = new Set(options.declared ?? ["native-images"]); + const now = options.now ?? (() => new Date()); + const objects = new Map(); + const failingPuts = new Set(); + const readUrlsIssued: string[] = []; + + function makeBucket(id: string): ModuleStorageBucket { + return { + createReadUrl(key: string): Promise { + readUrlsIssued.push(key); + // Opaque by contract: shape, host, and query are the host's business. A module that parsed + // one would be welded to a deployment. + return Promise.resolve({ + expiresAt: new Date(now().getTime() + 15 * 60 * 1000), + url: `https://storage.invalid/${encodeURIComponent(key)}?token=opaque`, + }); + }, + + delete(key: string): Promise { + objects.delete(`${id}/${key}`); + return Promise.resolve(); + }, + + get(key: string): Promise { + const stored = objects.get(`${id}/${key}`); + if (!stored) { + return Promise.reject(new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key)); + } + return Promise.resolve({ + body: new ReadableStream({ + start(controller) { + controller.enqueue(stored.bytes); + controller.close(); + }, + }), + contentLength: stored.bytes.byteLength, + key, + ...(stored.contentType === undefined ? {} : { contentType: stored.contentType }), + }); + }, + + head(key: string): Promise { + const stored = objects.get(`${id}/${key}`); + if (!stored) { + return Promise.reject(new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key)); + } + return Promise.resolve({ contentLength: stored.bytes.byteLength, key }); + }, + + id, + + list(options_?: { readonly prefix?: string }): Promise { + const prefix = `${id}/${options_?.prefix ?? ""}`; + return Promise.resolve({ + objects: [...objects.entries()] + .filter(([key]) => key.startsWith(prefix)) + .map(([key, value]) => ({ + contentLength: value.bytes.byteLength, + key: key.slice(id.length + 1), + })), + }); + }, + + put( + key: string, + body: unknown, + putOptions?: { readonly contentType?: string }, + ): Promise { + if (failingPuts.has(key)) { + failingPuts.delete(key); + return Promise.reject(new ModuleStorageError("STORAGE_PROVIDER_UNAVAILABLE", key)); + } + if (!(body instanceof Uint8Array)) { + return Promise.reject(new ModuleStorageError("STORAGE_OPERATION_FAILED", key)); + } + objects.set(`${id}/${key}`, { + bytes: body, + ...(putOptions?.contentType === undefined ? {} : { contentType: putOptions.contentType }), + }); + return Promise.resolve({ contentLength: body.byteLength, key }); + }, + }; + } + + return { + bucket(logicalBucketId: string): ModuleStorageBucket { + if (!declared.has(logicalBucketId)) { + throw new ModuleStorageError("STORAGE_BUCKET_UNDECLARED", logicalBucketId); + } + return makeBucket(logicalBucketId); + }, + buckets: () => [...declared].sort(), + failPut(key: string): void { + failingPuts.add(key); + }, + objects, + readUrlsIssued, + }; +} diff --git a/tests/support/fixture-provider.ts b/tests/support/fixture-provider.ts new file mode 100644 index 0000000..63eef53 --- /dev/null +++ b/tests/support/fixture-provider.ts @@ -0,0 +1,294 @@ +import type { + SignageJsonValue, + SignageProviderDiscoveryRequest, + SignageProviderItemDescriptor, + SignageProviderRefreshRequest, + SignageProviderRegistration, + SignageProviderResolution, + SignageProviderResolveRequest, + SignageProviderSourceDescriptor, +} from "../../src/server/providers/contract.js"; + +/** + * A **test-only** content provider, standing in for the real modules that do not exist yet. + * + * It is not an installed module, it has no manifest, and nothing about it is reachable from a + * running Signage: it lives under `tests/`, and the only way it enters the system is a test calling + * the same `register` a real provider module would call from its own `start` hook. + * + * The point is to prove the seam with something that behaves like the three real providers will: + * an announcements-shaped provider that cycles continuously, and trivia- and scripture-shaped + * providers that refuse to repeat until their pool is exhausted. **Signage implements none of those + * policies** — that is the whole demonstration. + */ + +export interface FixtureItem { + readonly id: string; + readonly label: string; + /** Payload for the default audience. */ + readonly payload: Record; + /** Payload for one specific audience, when this item has one. */ + readonly audiencePayloads?: Readonly>>; + readonly availableUntil?: string | null; + readonly durationMs?: number; +} + +export type FixtureRepeatPolicy = + /** Announcements: cycle the active set forever, in order, wrapping when it runs out. */ + | "cycle" + /** Trivia and Scripture: never repeat until every currently available item has appeared. */ + | "no-repeat-until-exhausted"; + +export interface FixtureProviderOptions { + readonly providerId: string; + readonly sourceId: string; + readonly sourceLabel: string; + readonly items: readonly FixtureItem[]; + readonly policy: FixtureRepeatPolicy; + readonly defaultDurationMs?: number; + readonly contractVersion?: string; + readonly rendererVersion?: string; +} + +export interface FixtureProvider { + readonly registration: SignageProviderRegistration; + /** Every item id this provider has handed out, in order, across every scope. */ + readonly served: string[]; + /** Every `resolveNext` request it received, so a test can prove what Signage supplied. */ + readonly resolveRequests: SignageProviderResolveRequest[]; + readonly refreshRequests: SignageProviderRefreshRequest[]; + /** Replace the available set at run time — an announcement expiring, a question pool refilling. */ + setItems(items: readonly FixtureItem[]): void; + /** Make the next `n` calls fail the way a briefly unavailable provider does. */ + failNext(count: number): void; + /** Make every call hang, so the deadline is what ends it. */ + hang(hanging: boolean): void; + /** Declare one item explicitly gone. */ + withdraw(itemId: string): void; + /** Change an item's payload without changing which item it is — a cancellation, a countdown. */ + reviseItem(itemId: string, payload: Record): void; +} + +export function createFixtureProvider(options: FixtureProviderOptions): FixtureProvider { + let items = [...options.items]; + const revisions = new Map(); + const withdrawn = new Set(); + /** Per selection scope: which items have already been served in the current pass. This is the + * provider's own state, exactly where the contract says it belongs — Signage stores none of it. */ + const seen = new Map>(); + const cursors = new Map(); + const served: string[] = []; + const resolveRequests: SignageProviderResolveRequest[] = []; + const refreshRequests: SignageProviderRefreshRequest[] = []; + let failures = 0; + let hanging = false; + + function guard(): void { + if (failures > 0) { + failures -= 1; + throw new Error("fixture provider is briefly unavailable"); + } + } + + function available(at: string): readonly FixtureItem[] { + const instant = Date.parse(at); + return items.filter((item) => { + if (withdrawn.has(item.id)) { + return false; + } + if (typeof item.availableUntil !== "string") { + return true; + } + return instant < Date.parse(item.availableUntil); + }); + } + + function revisionOf(item: FixtureItem): string { + return String(revisions.get(item.id) ?? 1); + } + + function resolutionFor(item: FixtureItem): SignageProviderResolution { + const variants = [ + { audienceKey: null, payload: item.payload }, + ...Object.entries(item.audiencePayloads ?? {}).map(([audienceKey, payload]) => ({ + audienceKey, + payload, + })), + ]; + return { + availableUntil: item.availableUntil ?? null, + itemId: item.id, + itemRevision: revisionOf(item), + variants, + ...(item.durationMs === undefined ? {} : { durationMs: item.durationMs }), + }; + } + + function pickNext(scope: string, at: string): FixtureItem | null { + const pool = available(at); + if (pool.length === 0) { + return null; + } + if (options.policy === "cycle") { + const index = cursors.get(scope) ?? 0; + const item = pool[index % pool.length] as FixtureItem; + cursors.set(scope, index + 1); + return item; + } + // No repeat until exhausted: only when every currently available item has appeared does the + // pass reset. An item added mid-pass joins the current pass rather than waiting for the next. + let usedInPass = seen.get(scope); + if (!usedInPass) { + usedInPass = new Set(); + seen.set(scope, usedInPass); + } + let remaining = pool.filter((item) => !usedInPass.has(item.id)); + if (remaining.length === 0) { + usedInPass.clear(); + remaining = [...pool]; + } + const item = remaining[0] as FixtureItem; + usedInPass.add(item.id); + return item; + } + + const registration: SignageProviderRegistration = { + contractVersion: options.contractVersion ?? "1.0.0", + defaultDurationMs: options.defaultDurationMs ?? 5000, + + listItems(request: SignageProviderDiscoveryRequest): readonly SignageProviderItemDescriptor[] { + guard(); + return available(request.at).map((item) => ({ + id: item.id, + label: item.label, + ...(item.availableUntil === undefined ? {} : { availableUntil: item.availableUntil }), + })); + }, + + listSources(): readonly SignageProviderSourceDescriptor[] { + guard(); + return [{ id: options.sourceId, label: options.sourceLabel }]; + }, + + providerId: options.providerId, + providerVersion: "1.0.0", + + async refresh( + request: SignageProviderRefreshRequest, + ): Promise { + refreshRequests.push(request); + if (hanging) { + await new Promise(() => undefined); + } + guard(); + if (withdrawn.has(request.itemId)) { + // Explicitly gone — distinct from "nothing right now", and Signage removes it at the next + // safe boundary rather than mid-slide. + return { + itemId: request.itemId, + itemRevision: revisionOf({ id: request.itemId } as FixtureItem), + variants: [{ audienceKey: null, payload: { withdrawn: true } }], + withdrawn: true, + }; + } + const item = available(request.at).find((candidate) => candidate.id === request.itemId); + return item ? resolutionFor(item) : null; + }, + + renderer: { + contributionId: `${options.providerId}.slide`, + contributionVersion: options.rendererVersion ?? "1.0.0", + }, + + async resolveNext( + request: SignageProviderResolveRequest, + ): Promise { + resolveRequests.push(request); + if (hanging) { + await new Promise(() => undefined); + } + guard(); + if (request.sourceId !== options.sourceId) { + return null; + } + const item = pickNext(request.selectionScopeId, request.at); + if (!item) { + return null; + } + served.push(item.id); + return resolutionFor(item); + }, + }; + + return { + failNext(count: number): void { + failures = count; + }, + hang(value: boolean): void { + hanging = value; + }, + refreshRequests, + registration, + resolveRequests, + reviseItem(itemId: string, payload: Record): void { + items = items.map((item) => (item.id === itemId ? { ...item, payload } : item)); + revisions.set(itemId, (revisions.get(itemId) ?? 1) + 1); + }, + served, + setItems(next: readonly FixtureItem[]): void { + items = [...next]; + }, + withdraw(itemId: string): void { + withdrawn.add(itemId); + }, + }; +} + +/** The three provider shapes the real modules will have, so a test can name them the way an operator + * would. Nothing about their policies lives in Signage. */ +export function announcementsFixture(): FixtureProvider { + return createFixtureProvider({ + items: [ + { id: "welcome", label: "Welcome", payload: { headline: "Welcome" } }, + { + audiencePayloads: { online: { headline: "Watching from home" } }, + id: "greeting", + label: "Greeting", + payload: { headline: "Good morning" }, + }, + { id: "giving", label: "Giving", payload: { headline: "Giving" } }, + { id: "kids", label: "Kids check-in", payload: { headline: "Kids check-in" } }, + ], + policy: "cycle", + providerId: "fixture.announcements", + sourceId: "active-slides", + sourceLabel: "Active announcements", + }); +} + +export function triviaFixture(): FixtureProvider { + return createFixtureProvider({ + items: [ + { id: "q1", label: "Question 1", payload: { question: "one" } }, + { id: "q2", label: "Question 2", payload: { question: "two" } }, + { id: "q3", label: "Question 3", payload: { question: "three" } }, + ], + policy: "no-repeat-until-exhausted", + providerId: "fixture.trivia", + sourceId: "open-questions", + sourceLabel: "Open questions", + }); +} + +export function scriptureFixture(): FixtureProvider { + return createFixtureProvider({ + items: [ + { id: "psalm-23", label: "Psalm 23", payload: { reference: "Psalm 23" } }, + { id: "john-3", label: "John 3", payload: { reference: "John 3" } }, + ], + policy: "no-repeat-until-exhausted", + providerId: "fixture.scripture", + sourceId: "reading-plan", + sourceLabel: "Reading plan", + }); +} diff --git a/tests/support/images.ts b/tests/support/images.ts new file mode 100644 index 0000000..8be25b3 --- /dev/null +++ b/tests/support/images.ts @@ -0,0 +1,80 @@ +import sharp from "sharp"; + +/** Real encoded images, generated rather than checked in, so a fixture can never drift from what the + * pipeline actually accepts. */ +export async function makeImage(options: { + readonly width: number; + readonly height: number; + readonly format: "png" | "jpeg" | "webp"; + readonly alpha?: boolean; + readonly colour?: { r: number; g: number; b: number }; +}): Promise { + const colour = options.colour ?? { b: 40, g: 120, r: 200 }; + const pipeline = sharp({ + create: { + background: { ...colour, alpha: options.alpha === true ? 0.5 : 1 }, + channels: options.alpha === true ? 4 : 3, + height: options.height, + width: options.width, + }, + }); + const encoded = + options.format === "png" + ? await pipeline.png().toBuffer() + : options.format === "jpeg" + ? await pipeline.jpeg().toBuffer() + : await pipeline.webp().toBuffer(); + return new Uint8Array(encoded); +} + +/** A genuine two-frame animated WebP. Signage refuses animation: a signage slide is a still frame, + * and an animated one would loop out of step with the authoritative slot deadline. */ +export async function makeAnimatedWebp(): Promise { + const frame = (red: number) => + sharp({ + create: { background: { alpha: 1, b: 0, g: 0, r: red }, channels: 4, height: 8, width: 8 }, + }) + .raw() + .toBuffer(); + const frames = Buffer.concat([await frame(255), await frame(0)]); + const encoded = await sharp(frames, { + raw: { channels: 4, height: 16, pageHeight: 8, width: 8 }, + }) + .webp({ delay: [100, 100], loop: 0 }) + .toBuffer(); + return new Uint8Array(encoded); +} + +/** A genuine two-frame animated GIF. GIF is refused outright, but animation is detected first, so + * this proves the *animation* rule rather than the format rule. */ +export async function makeAnimatedGif(): Promise { + const frame = (red: number) => + sharp({ + create: { background: { alpha: 1, b: 0, g: 0, r: red }, channels: 4, height: 8, width: 8 }, + }) + .raw() + .toBuffer(); + const frames = Buffer.concat([await frame(255), await frame(0)]); + const encoded = await sharp(frames, { + raw: { channels: 4, height: 16, pageHeight: 8, width: 8 }, + }) + .gif({ delay: [100, 100], loop: 0 }) + .toBuffer(); + return new Uint8Array(encoded); +} + +export function bytesOf(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +/** A minimal, genuinely well-formed SVG. Signage refuses it: an SVG is a document with script and + * external-reference capability, not a raster image. */ +export const SVG_DOCUMENT = + ''; + +/** The first bytes of a PDF. Refused for the same reason as everything else that is not a still + * raster image. */ +export const PDF_DOCUMENT = "%PDF-1.7\n1 0 obj<>endobj\ntrailer<>"; + +/** A GIF89a header with one tiny frame. */ +export const GIF_DOCUMENT_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; diff --git a/tests/support/memory-playback.ts b/tests/support/memory-playback.ts new file mode 100644 index 0000000..c736183 --- /dev/null +++ b/tests/support/memory-playback.ts @@ -0,0 +1,249 @@ +import { + type NewStoredSelection, + type PlaybackChanges, + type SelectionChanges, + type SignagePlaybackStore, + SignageStoreConflict, + type StoredChannel, + type StoredChannelGroup, + type StoredChannelGroupDetail, + type StoredPlaybackState, + type StoredSelection, +} from "../../src/server/store/types.js"; + +/** + * The playback half of the in-memory store. + * + * It enforces the guarantees the database enforces — one default channel that cannot be removed, a + * sequence that is never handed out twice, variants replaced wholesale — so a decision proven here + * holds against PostgreSQL too. + */ +export interface MemoryPlaybackStore extends SignagePlaybackStore { + readonly groups: Map; + readonly channels: Map; + readonly playback: Map; + readonly selections: Map; +} + +export function createMemoryPlaybackStore( + options: { readonly now?: () => Date } = {}, +): MemoryPlaybackStore { + const now = options.now ?? (() => new Date()); + const groups = new Map(); + const channels = new Map(); + const playback = new Map(); + const selections = new Map(); + + function detail(groupId: string): StoredChannelGroupDetail | undefined { + const group = groups.get(groupId); + const state = playback.get(groupId); + if (!group || !state) { + return undefined; + } + return { + channels: [...channels.values()] + .filter((channel) => channel.groupId === groupId) + .sort((left, right) => + left.isDefault === right.isDefault + ? left.key.localeCompare(right.key) + : left.isDefault + ? -1 + : 1, + ), + group, + playback: state, + }; + } + + return { + addChannel(channel: StoredChannel): Promise { + channels.set(channel.id, channel); + const group = groups.get(channel.groupId); + if (group) { + groups.set(group.id, { ...group, revision: group.revision + 1 }); + } + return Promise.resolve(); + }, + + channels, + + createChannelGroup(input: { + readonly groupId: string; + readonly name: string; + readonly channelId: string; + readonly channelName: string; + }): Promise { + groups.set(input.groupId, { id: input.groupId, name: input.name, revision: 1 }); + channels.set(input.channelId, { + groupId: input.groupId, + id: input.channelId, + isDefault: true, + key: "default", + name: input.channelName, + }); + playback.set(input.groupId, { + activeTimelineId: null, + blackout: false, + currentSelectionId: null, + currentSlotId: null, + groupId: input.groupId, + nextSequence: 1, + paused: false, + pausedRemainingMs: null, + pendingRequestedAt: null, + pendingTimelineId: null, + revision: 1, + scheduledActivationAt: null, + scheduledTimelineId: null, + slotDeadlineAt: null, + slotStartedAt: null, + staticAssetId: null, + updatedAt: now(), + }); + return Promise.resolve(); + }, + + deleteChannelGroup(groupId: string): Promise { + groups.delete(groupId); + playback.delete(groupId); + for (const [id, channel] of channels) { + if (channel.groupId === groupId) { + channels.delete(id); + } + } + for (const [id, selection] of selections) { + if (selection.groupId === groupId) { + selections.delete(id); + } + } + return Promise.resolve(); + }, + + findChannelGroup(groupId: string): Promise { + return Promise.resolve(detail(groupId)); + }, + + findLatestSelectionForScope( + groupId: string, + selectionScope: string, + ): Promise { + const matching = [...selections.values()] + .filter( + (selection) => + selection.groupId === groupId && selection.selectionScope === selectionScope, + ) + .sort((left, right) => right.sequence - left.sequence); + return Promise.resolve(matching[0]); + }, + + findSelection(selectionId: string): Promise { + return Promise.resolve(selections.get(selectionId)); + }, + + groups, + + insertSelection(selection: NewStoredSelection): Promise { + selections.set(selection.id, { ...selection, resolvedAt: now(), revision: 1 }); + return Promise.resolve(); + }, + + listChannelGroups(): Promise { + return Promise.resolve( + [...groups.values()] + .sort((left, right) => left.name.localeCompare(right.name)) + .map((group) => detail(group.id)) + .filter((value): value is StoredChannelGroupDetail => value !== undefined), + ); + }, + + listSelections(groupId: string, limit: number): Promise { + return Promise.resolve( + [...selections.values()] + .filter((selection) => selection.groupId === groupId) + .sort((left, right) => right.sequence - left.sequence) + .slice(0, limit), + ); + }, + + playback, + + pruneSelections(groupId: string, limit: number): Promise { + const ordered = [...selections.values()] + .filter((selection) => selection.groupId === groupId) + .sort((left, right) => right.sequence - left.sequence); + for (const selection of ordered.slice(limit)) { + selections.delete(selection.id); + } + return Promise.resolve(); + }, + + removeChannel(channelId: string): Promise { + const channel = channels.get(channelId); + if (!channel) { + return Promise.resolve(); + } + if (channel.isDefault) { + return Promise.reject(new SignageStoreConflict("channel.default")); + } + channels.delete(channelId); + const group = groups.get(channel.groupId); + if (group) { + groups.set(group.id, { ...group, revision: group.revision + 1 }); + } + return Promise.resolve(); + }, + + selections, + + takeSelectionSequence(groupId: string): Promise { + const state = playback.get(groupId); + if (!state) { + return Promise.reject(new SignageStoreConflict("playback.sequence")); + } + playback.set(groupId, { ...state, nextSequence: state.nextSequence + 1 }); + return Promise.resolve(state.nextSequence); + }, + + updatePlayback( + groupId: string, + changes: PlaybackChanges, + updateOptions?: { readonly bumpRevision?: boolean }, + ): Promise { + const state = playback.get(groupId); + if (!state) { + return Promise.resolve(); + } + const next: Record = { ...state }; + for (const key of Object.keys(changes)) { + next[key] = (changes as Record)[key] ?? null; + } + playback.set(groupId, { + ...(next as unknown as StoredPlaybackState), + revision: updateOptions?.bumpRevision === false ? state.revision : state.revision + 1, + updatedAt: now(), + }); + return Promise.resolve(); + }, + + updateSelection(selectionId: string, changes: SelectionChanges): Promise { + const selection = selections.get(selectionId); + if (!selection) { + return Promise.resolve(); + } + selections.set(selectionId, { + ...selection, + ...(changes.availableUntil === undefined ? {} : { availableUntil: changes.availableUntil }), + ...(changes.deadlineAt === undefined ? {} : { deadlineAt: changes.deadlineAt }), + ...(changes.durationMs === undefined ? {} : { durationMs: changes.durationMs }), + ...(changes.itemRevision === undefined ? {} : { itemRevision: changes.itemRevision }), + ...(changes.presentation === undefined ? {} : { presentation: changes.presentation }), + ...(changes.stale === undefined ? {} : { stale: changes.stale }), + ...(changes.startedAt === undefined ? {} : { startedAt: changes.startedAt }), + ...(changes.withdrawn === undefined ? {} : { withdrawn: changes.withdrawn }), + ...(changes.variants === undefined ? {} : { variants: changes.variants }), + revision: changes.bumpRevision === true ? selection.revision + 1 : selection.revision, + }); + return Promise.resolve(); + }, + }; +} diff --git a/tests/support/memory-store.ts b/tests/support/memory-store.ts new file mode 100644 index 0000000..dcb3b39 --- /dev/null +++ b/tests/support/memory-store.ts @@ -0,0 +1,120 @@ +import { + type NewStoredImage, + type SignageStore, + SignageStoreConflict, + type StoredImage, + type StoredImageReferences, +} from "../../src/server/store/types.js"; +import { createMemoryPlaybackStore, type MemoryPlaybackStore } from "./memory-playback.js"; +import { createMemoryTimelineStore, type MemoryTimelineStore } from "./memory-timelines.js"; +import { createMemoryViewerStore, type MemoryViewerStore } from "./memory-viewers.js"; + +/** + * An in-memory implementation of Signage's data port. + * + * This is what makes Signage's *decisions* testable without PostgreSQL: deduplication, no-op + * detection, reference-aware deletion, provider resolution, and playback reconciliation are all + * behaviour, and behaviour proven against a real database for every case would be slow, flaky, and + * therefore rare. The Drizzle implementation is proven separately, against a live disposable + * database, where what is being proven is the row mapping rather than the decision. + * + * It enforces the same uniqueness guarantees the database does, so a test cannot pass here and fail + * there. + */ +export interface MemorySignageStore + extends SignageStore, + MemoryTimelineStore, + MemoryPlaybackStore, + MemoryViewerStore { + /** Test-only: seed a reference so reference-aware deletion can be exercised. */ + addImageReference(imageId: string, kind: "slot" | "static-hold"): void; + readonly images: Map; +} + +export function createMemorySignageStore( + options: { readonly now?: () => Date } = {}, +): MemorySignageStore { + const now = options.now ?? (() => new Date()); + const images = new Map(); + const references = new Map(); + const timelineStore = createMemoryTimelineStore(options); + const playbackStore = createMemoryPlaybackStore(options); + const viewerStore = createMemoryViewerStore(options); + + return { + ...timelineStore, + ...playbackStore, + ...viewerStore, + + addImageReference(imageId: string, kind: "slot" | "static-hold"): void { + const current = references.get(imageId) ?? { slots: 0, staticHolds: 0 }; + references.set( + imageId, + kind === "slot" + ? { slots: current.slots + 1, staticHolds: current.staticHolds } + : { slots: current.slots, staticHolds: current.staticHolds + 1 }, + ); + }, + + countImageReferences(imageId: string): Promise { + return Promise.resolve(references.get(imageId) ?? { slots: 0, staticHolds: 0 }); + }, + + deleteImage(imageId: string): Promise { + images.delete(imageId); + references.delete(imageId); + return Promise.resolve(); + }, + + findImage(imageId: string): Promise { + return Promise.resolve(images.get(imageId)); + }, + + findReadyImageByContentHash(contentSha256: string): Promise { + for (const image of images.values()) { + if (image.contentSha256 === contentSha256 && image.status === "ready") { + return Promise.resolve(image); + } + } + return Promise.resolve(undefined); + }, + + images, + + insertImage(image: NewStoredImage): Promise { + for (const existing of images.values()) { + if (existing.contentSha256 === image.contentSha256) { + // Exactly what the unique index does, so a race is exercised here and not only in + // production. + return Promise.reject(new SignageStoreConflict("image.contentSha256")); + } + } + images.set(image.id, { ...image, createdAt: now() }); + return Promise.resolve(); + }, + + listImages(): Promise { + return Promise.resolve( + [...images.values()].sort( + (left, right) => right.createdAt.getTime() - left.createdAt.getTime(), + ), + ); + }, + + updateImage( + imageId: string, + changes: { readonly displayName?: string; readonly expiresAt?: Date | null }, + ): Promise { + const existing = images.get(imageId); + if (!existing) { + return Promise.resolve(); + } + images.set(imageId, { + ...existing, + ...(changes.displayName === undefined ? {} : { displayName: changes.displayName }), + ...(changes.expiresAt === undefined ? {} : { expiresAt: changes.expiresAt }), + }); + return Promise.resolve(); + }, + }; +} diff --git a/tests/support/memory-timelines.ts b/tests/support/memory-timelines.ts new file mode 100644 index 0000000..149aabf --- /dev/null +++ b/tests/support/memory-timelines.ts @@ -0,0 +1,238 @@ +import { + type NewStoredProviderSource, + type NewStoredSlot, + type NewStoredTimeline, + SignageStoreConflict, + type SignageTimelineStore, + type SlotChanges, + type StoredProviderSource, + type StoredSlot, + type StoredTimeline, + type StoredTimelineDetail, + type StoredTimelineUsage, + type TimelineChanges, +} from "../../src/server/store/types.js"; + +/** + * The timeline half of the in-memory store. + * + * It enforces the same invariants the database does — contiguous positions, an exhaustive reorder, + * a revision that increments once per semantic change — so a test cannot pass here and fail against + * PostgreSQL. Where the production store uses a transaction, this uses the fact that JavaScript does + * not interleave synchronous work; the property being proven is the same. + */ +export interface MemoryTimelineStore extends SignageTimelineStore { + /** Test-only: pretend a channel group has this timeline active, pending, or scheduled. */ + setTimelineUsage(timelineId: string, usage: StoredTimelineUsage): void; + readonly timelines: Map; + readonly slots: Map; + readonly sources: Map; +} + +export function createMemoryTimelineStore( + options: { readonly now?: () => Date } = {}, +): MemoryTimelineStore { + const now = options.now ?? (() => new Date()); + const timelines = new Map(); + const slots = new Map(); + const sources = new Map(); + const usage = new Map(); + + function slotsOf(timelineId: string): StoredSlot[] { + return [...slots.values()] + .filter((slot) => slot.timelineId === timelineId) + .sort((left, right) => left.position - right.position); + } + + function bump(timelineId: string): void { + const timeline = timelines.get(timelineId); + if (timeline) { + timelines.set(timelineId, { + ...timeline, + revision: timeline.revision + 1, + updatedAt: now(), + }); + } + } + + function renumber(timelineId: string, orderedSlotIds: readonly string[]): void { + for (const [index, slotId] of orderedSlotIds.entries()) { + const slot = slots.get(slotId); + if (slot) { + slots.set(slotId, { ...slot, position: index }); + } + } + bump(timelineId); + } + + return { + addSlot(slot: NewStoredSlot, position?: number): Promise { + const existing = slotsOf(slot.timelineId).map((entry) => entry.id); + const index = + position === undefined ? existing.length : Math.max(0, Math.min(position, existing.length)); + slots.set(slot.id, { ...slot, position: -1 }); + renumber(slot.timelineId, [...existing.slice(0, index), slot.id, ...existing.slice(index)]); + return Promise.resolve(); + }, + + addSource(source: NewStoredProviderSource): Promise { + sources.set(source.id, { ...source, createdAt: now() }); + return Promise.resolve(); + }, + + createTimeline(input: { + readonly timeline: NewStoredTimeline; + readonly sources: readonly NewStoredProviderSource[]; + readonly slots: readonly NewStoredSlot[]; + }): Promise { + const createdAt = now(); + timelines.set(input.timeline.id, { + ...input.timeline, + createdAt, + revision: 1, + updatedAt: createdAt, + }); + for (const source of input.sources) { + sources.set(source.id, { ...source, createdAt }); + } + for (const [index, slot] of input.slots.entries()) { + slots.set(slot.id, { ...slot, position: index }); + } + return Promise.resolve(); + }, + + deleteTimeline(timelineId: string): Promise { + timelines.delete(timelineId); + for (const [id, slot] of slots) { + if (slot.timelineId === timelineId) { + slots.delete(id); + } + } + for (const [id, source] of sources) { + if (source.timelineId === timelineId) { + sources.delete(id); + } + } + return Promise.resolve(); + }, + + findTimeline(timelineId: string): Promise { + const timeline = timelines.get(timelineId); + if (!timeline) { + return Promise.resolve(undefined); + } + return Promise.resolve({ + slots: slotsOf(timelineId), + sources: [...sources.values()] + .filter((source) => source.timelineId === timelineId) + .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()), + timeline, + }); + }, + + listTimelines(): Promise { + return Promise.resolve( + [...timelines.values()].sort((left, right) => left.name.localeCompare(right.name)), + ); + }, + + removeSlot(slotId: string): Promise { + const slot = slots.get(slotId); + if (!slot) { + return Promise.resolve(); + } + slots.delete(slotId); + renumber( + slot.timelineId, + slotsOf(slot.timelineId).map((entry) => entry.id), + ); + return Promise.resolve(); + }, + + removeSource(sourceId: string): Promise { + const source = sources.get(sourceId); + if (!source) { + return Promise.resolve(); + } + sources.delete(sourceId); + for (const [id, slot] of slots) { + if (slot.sourceId === sourceId) { + slots.delete(id); + } + } + renumber( + source.timelineId, + slotsOf(source.timelineId).map((entry) => entry.id), + ); + return Promise.resolve(); + }, + + reorderSlots(timelineId: string, orderedSlotIds: readonly string[]): Promise { + const current = slotsOf(timelineId).map((slot) => slot.id); + const given = new Set(orderedSlotIds); + if (given.size !== orderedSlotIds.length || given.size !== current.length) { + return Promise.reject(new SignageStoreConflict("timeline.slotOrder")); + } + for (const slotId of current) { + if (!given.has(slotId)) { + return Promise.reject(new SignageStoreConflict("timeline.slotOrder")); + } + } + renumber(timelineId, orderedSlotIds); + return Promise.resolve(); + }, + + setTimelineUsage(timelineId: string, value: StoredTimelineUsage): void { + usage.set(timelineId, value); + }, + + slots, + + sources, + + timelineUsage(timelineId: string): Promise { + return Promise.resolve( + usage.get(timelineId) ?? { activeGroups: 0, pendingGroups: 0, scheduledGroups: 0 }, + ); + }, + + timelines, + + updateSlot(slotId: string, changes: SlotChanges): Promise { + const slot = slots.get(slotId); + if (!slot) { + return Promise.resolve(); + } + slots.set(slotId, { + ...slot, + ...(changes.enabled === undefined ? {} : { enabled: changes.enabled }), + ...(changes.durationOverrideMs === undefined + ? {} + : { durationOverrideMs: changes.durationOverrideMs }), + ...(changes.eligibleFrom === undefined ? {} : { eligibleFrom: changes.eligibleFrom }), + ...(changes.eligibleUntil === undefined ? {} : { eligibleUntil: changes.eligibleUntil }), + ...(changes.itemId === undefined ? {} : { itemId: changes.itemId }), + presentation: { ...slot.presentation, ...(changes.presentation ?? {}) }, + }); + bump(slot.timelineId); + return Promise.resolve(); + }, + + updateTimeline(timelineId: string, changes: TimelineChanges): Promise { + const timeline = timelines.get(timelineId); + if (!timeline) { + return Promise.resolve(); + } + timelines.set(timelineId, { + ...timeline, + ...(changes.name === undefined ? {} : { name: changes.name }), + ...(changes.description === undefined ? {} : { description: changes.description }), + ...(changes.eligibleFrom === undefined ? {} : { eligibleFrom: changes.eligibleFrom }), + ...(changes.eligibleUntil === undefined ? {} : { eligibleUntil: changes.eligibleUntil }), + revision: timeline.revision + 1, + updatedAt: now(), + }); + return Promise.resolve(); + }, + }; +} diff --git a/tests/support/memory-viewers.ts b/tests/support/memory-viewers.ts new file mode 100644 index 0000000..2219853 --- /dev/null +++ b/tests/support/memory-viewers.ts @@ -0,0 +1,72 @@ +import type { + NewStoredViewer, + SignageViewerStore, + StoredViewer, + ViewerChanges, +} from "../../src/server/store/types.js"; + +export interface MemoryViewerStore extends SignageViewerStore { + readonly viewers: Map; + /** Test-only: how many times a viewer row was actually written, which is what heartbeat + * coalescing exists to keep small. */ + readonly viewerWrites: { count: number }; +} + +export function createMemoryViewerStore( + options: { readonly now?: () => Date } = {}, +): MemoryViewerStore { + const now = options.now ?? (() => new Date()); + const viewers = new Map(); + const viewerWrites = { count: 0 }; + + return { + deleteViewer(viewerId: string): Promise { + viewers.delete(viewerId); + return Promise.resolve(); + }, + + findViewer(viewerId: string): Promise { + return Promise.resolve(viewers.get(viewerId)); + }, + + findViewerByKey(viewerKey: string): Promise { + for (const viewer of viewers.values()) { + if (viewer.viewerKey === viewerKey) { + return Promise.resolve(viewer); + } + } + return Promise.resolve(undefined); + }, + + insertViewer(viewer: NewStoredViewer): Promise { + viewerWrites.count += 1; + viewers.set(viewer.id, { ...viewer, createdAt: now(), lastError: null, lastSeenAt: null }); + return Promise.resolve(); + }, + + listViewers(): Promise { + return Promise.resolve( + [...viewers.values()].sort((left, right) => left.name.localeCompare(right.name)), + ); + }, + + updateViewer(viewerId: string, changes: ViewerChanges): Promise { + const viewer = viewers.get(viewerId); + if (!viewer) { + return Promise.resolve(); + } + viewerWrites.count += 1; + const next: Record = { ...viewer }; + for (const [key, value] of Object.entries(changes)) { + if (value !== undefined) { + next[key] = value; + } + } + viewers.set(viewerId, next as unknown as StoredViewer); + return Promise.resolve(); + }, + + viewers, + viewerWrites, + }; +} diff --git a/tests/support/module-harness.ts b/tests/support/module-harness.ts new file mode 100644 index 0000000..a0ab553 --- /dev/null +++ b/tests/support/module-harness.ts @@ -0,0 +1,106 @@ +import { + ANONYMOUS_PRINCIPAL, + type CapabilityId, + EMPTY_MODULE_CONFIGURATION, + EMPTY_MODULE_STORAGE, + type ModuleConfiguration, + type ModuleRouteDefinitionInput, + type ModuleRouteHandlerContext, + type ModuleRuntimeServices, + type RegisteredCapability, +} from "@ekklesia/module-sdk"; +import moduleRuntime from "../../src/server/index.js"; + +/** + * The module-layer test harness: everything a Signage unit test needs to exercise the real runtime + * definition without a platform. + * + * Every object here is a `@ekklesia/contracts` interface constructed by hand, which is exactly what + * a module-layer test may legitimately do — a module may never import Core. It also demonstrates the + * portability claim directly: the host producing these objects could be Ekklesia, or a future + * standalone host reading YAML from disk, and Signage cannot tell the difference. + */ +export interface CapturedRegistration { + readonly channels: { channel: string; permission: string }[]; + readonly provided: Map; + readonly routes: ModuleRouteDefinitionInput[]; +} + +export async function registerModule( + config: ModuleConfiguration = EMPTY_MODULE_CONFIGURATION, +): Promise { + const channels: { channel: string; permission: string }[] = []; + const provided = new Map(); + const routes: ModuleRouteDefinitionInput[] = []; + await moduleRuntime.register?.({ + capabilities: { + provide(capabilityId, implementation) { + provided.set(capabilityId, implementation); + }, + resolve(_capabilityId: CapabilityId): RegisteredCapability | undefined { + return undefined; + }, + }, + config, + http: { + route(definition) { + routes.push(definition); + }, + }, + moduleId: "signage", + realtime: { + channel(definition) { + channels.push({ channel: definition.channel, permission: definition.permission }); + }, + }, + }); + return { channels, provided, routes }; +} + +export function findRoute( + registration: CapturedRegistration, + method: string, + path: string, +): ModuleRouteDefinitionInput { + const route = registration.routes.find( + (candidate) => candidate.method === method && candidate.path === path, + ); + if (!route) { + throw new Error(`Signage did not register ${method} ${path}.`); + } + return route; +} + +export function fakeConfiguration(values: Readonly>): ModuleConfiguration { + const read = (key: string, kind: string): Value | undefined => { + const value = values[key]; + return typeof value === kind ? (value as Value) : undefined; + }; + return Object.freeze({ + get: (key: string) => values[key] as never, + getBoolean: (key: string) => read(key, "boolean"), + getEnum: (key: string) => read(key, "string"), + getInteger: (key: string) => read(key, "number"), + getSecret: (key: string) => read(key, "string"), + getString: (key: string) => read(key, "string"), + getUrl: (key: string) => read(key, "string"), + has: (key: string) => Object.hasOwn(values, key), + keys: () => Object.freeze(Object.keys(values)), + }); +} + +export function fakeHandlerContext( + overrides: Partial = {}, +): ModuleRouteHandlerContext { + const services = { database: undefined } as unknown as ModuleRuntimeServices; + return Object.freeze({ + config: EMPTY_MODULE_CONFIGURATION, + moduleId: "signage", + params: Object.freeze({}), + principal: ANONYMOUS_PRINCIPAL, + realtime: { publish: () => undefined }, + services, + storage: EMPTY_MODULE_STORAGE, + ...overrides, + }); +} diff --git a/tests/timelines.test.ts b/tests/timelines.test.ts new file mode 100644 index 0000000..ef5914c --- /dev/null +++ b/tests/timelines.test.ts @@ -0,0 +1,486 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { SignageError } from "../src/server/errors.js"; +import type { StoredTimelineDetail } from "../src/server/store/types.js"; +import { + addProviderSource, + addSlot, + createTimeline, + deleteTimeline, + duplicateTimeline, + removeProviderSource, + removeSlot, + reorderSlots, + updateSlot, + updateTimeline, +} from "../src/server/timelines/service.js"; +import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js"; + +async function expectSignageCode(promise: Promise, code: string): Promise { + try { + await promise; + } catch (error) { + expect(error).toBeInstanceOf(SignageError); + const signageError = error as SignageError; + expect(signageError.code).toBe(code); + return signageError; + } + throw new Error(`Expected ${code}, but the operation succeeded.`); +} + +describe("timeline management", () => { + let store: MemorySignageStore; + let counter: number; + + const newId = () => { + counter += 1; + return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`; + }; + + beforeEach(() => { + store = createMemorySignageStore(); + counter = 0; + }); + + async function seedTimeline(name = "Sunday morning"): Promise { + return createTimeline({ name, newId, store }); + } + + /** The pattern from the product definition: three announcement slots, one trivia, two more + * announcements, one scripture — with the five announcement occurrences sharing one source. */ + async function seedPattern(): Promise { + let detail = await seedTimeline(); + const timelineId = detail.timeline.id; + detail = await addProviderSource({ + label: "Active announcements", + newId, + providerId: "fixture.announcements", + sourceId: "active-slides", + store, + timelineId, + }); + detail = await addProviderSource({ + label: "Open questions", + newId, + providerId: "fixture.trivia", + sourceId: "open-questions", + store, + timelineId, + }); + detail = await addProviderSource({ + label: "Reading plan", + newId, + providerId: "fixture.scripture", + sourceId: "reading-plan", + store, + timelineId, + }); + const [announcements, trivia, scripture] = detail.sources; + if (!announcements || !trivia || !scripture) { + throw new Error("Expected three sources."); + } + for (const source of [ + announcements, + announcements, + announcements, + trivia, + announcements, + announcements, + scripture, + ]) { + detail = await addSlot({ + newId, + source: { sourceId: source.id }, + store, + timelineId, + }); + } + return detail; + } + + it("creates a timeline on demand, with no preset pattern of any kind", async () => { + const detail = await seedTimeline("Christmas Eve"); + + expect(detail.timeline.name).toBe("Christmas Eve"); + expect(detail.timeline.revision).toBe(1); + // No slots, no sources, no "Sunday Morning" a church never asked for. + expect(detail.slots).toEqual([]); + expect(detail.sources).toEqual([]); + expect(await store.listTimelines()).toHaveLength(1); + }); + + it("lets five slots share one source, so they are one rotation rather than five", async () => { + const detail = await seedPattern(); + const announcements = detail.sources[0]; + + expect(detail.slots).toHaveLength(7); + expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1, 2, 3, 4, 5, 6]); + // Five occurrences, one selection scope. The provider advances that one scope each time. + const announcementSlots = detail.slots.filter((slot) => slot.sourceId === announcements?.id); + expect(announcementSlots).toHaveLength(5); + expect(new Set(announcementSlots.map((slot) => slot.sourceId)).size).toBe(1); + }); + + it("keeps positions unique and contiguous through every kind of edit", async () => { + let detail = await seedPattern(); + const timelineId = detail.timeline.id; + + // Insert in the middle. + const source = detail.sources[1]; + if (!source) { + throw new Error("Expected a source."); + } + detail = await addSlot({ + newId, + position: 2, + source: { sourceId: source.id }, + store, + timelineId, + }); + expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); + expect(detail.slots[2]?.sourceId).toBe(source.id); + + // Remove from the middle. + const removed = detail.slots[3]; + if (!removed) { + throw new Error("Expected a slot."); + } + detail = await removeSlot({ slotId: removed.id, store, timelineId }); + expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1, 2, 3, 4, 5, 6]); + expect(detail.slots.some((slot) => slot.id === removed.id)).toBe(false); + + // Reverse the whole order. + const reversed = [...detail.slots].reverse().map((slot) => slot.id); + detail = await reorderSlots({ orderedSlotIds: reversed, store, timelineId }); + expect(detail.slots.map((slot) => slot.id)).toEqual(reversed); + expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1, 2, 3, 4, 5, 6]); + }); + + it("keeps a slot's identity across a reorder, so history and a display cursor survive it", async () => { + let detail = await seedPattern(); + const before = detail.slots.map((slot) => slot.id); + + detail = await reorderSlots({ + orderedSlotIds: [...before].reverse(), + store, + timelineId: detail.timeline.id, + }); + + // Every id still exists. Only position changed. + expect(new Set(detail.slots.map((slot) => slot.id))).toEqual(new Set(before)); + }); + + it("refuses a reorder that is not exactly this timeline's slots", async () => { + const detail = await seedPattern(); + const ids = detail.slots.map((slot) => slot.id); + const first = ids[0] as string; + + for (const bad of [ + ids.slice(1), // missing one + [...ids, first], // duplicated + [...ids.slice(1), "11111111-1111-4111-8111-111111111111"], // a stranger + ]) { + await expectSignageCode( + reorderSlots({ orderedSlotIds: bad, store, timelineId: detail.timeline.id }), + "SIGNAGE_INVALID_REQUEST", + ); + } + // Nothing moved. + const after = await store.findTimeline(detail.timeline.id); + expect(after?.slots.map((slot) => slot.id)).toEqual(ids); + }); + + it("removes every slot that referenced a deleted source, and closes the gap", async () => { + let detail = await seedPattern(); + const announcements = detail.sources[0]; + if (!announcements) { + throw new Error("Expected a source."); + } + + detail = await removeProviderSource({ + sourceId: announcements.id, + store, + timelineId: detail.timeline.id, + }); + + // The five announcement occurrences went with it; trivia and scripture remain, renumbered. + expect(detail.slots).toHaveLength(2); + expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1]); + expect(detail.sources).toHaveLength(2); + }); + + it("bumps the revision on a semantic change and not on an exact no-op", async () => { + let detail = await seedTimeline("Sunday morning"); + const initial = detail.timeline.revision; + + detail = await updateTimeline({ + changes: { name: "Sunday morning" }, + store, + timelineId: detail.timeline.id, + }); + expect(detail.timeline.revision).toBe(initial); + + detail = await updateTimeline({ + changes: { name: "Sunday evening" }, + store, + timelineId: detail.timeline.id, + }); + expect(detail.timeline.revision).toBe(initial + 1); + + // A slot edit is a timeline change too: a client comparing revisions must be woken by it. + const withSource = await addProviderSource({ + label: "Active announcements", + newId, + providerId: "fixture.announcements", + sourceId: "active-slides", + store, + timelineId: detail.timeline.id, + }); + const source = withSource.sources[0]; + if (!source) { + throw new Error("Expected a source."); + } + const withSlot = await addSlot({ + newId, + source: { sourceId: source.id }, + store, + timelineId: detail.timeline.id, + }); + const slot = withSlot.slots[0]; + if (!slot) { + throw new Error("Expected a slot."); + } + const beforeSlotEdit = withSlot.timeline.revision; + + const unchanged = await updateSlot({ + changes: { enabled: true }, + slotId: slot.id, + store, + timelineId: detail.timeline.id, + }); + expect(unchanged.timeline.revision).toBe(beforeSlotEdit); + + const disabled = await updateSlot({ + changes: { enabled: false }, + slotId: slot.id, + store, + timelineId: detail.timeline.id, + }); + expect(disabled.timeline.revision).toBe(beforeSlotEdit + 1); + expect(disabled.slots[0]?.enabled).toBe(false); + }); + + it("duplicates a timeline as a genuinely separate program", async () => { + const original = await seedPattern(); + + const copy = await duplicateTimeline({ + name: "Sunday evening", + newId, + store, + timelineId: original.timeline.id, + }); + + expect(copy.timeline.name).toBe("Sunday evening"); + expect(copy.slots).toHaveLength(original.slots.length); + expect(copy.slots.map((slot) => slot.position)).toEqual(original.slots.map((s) => s.position)); + // Every identity is new: a copy that shared them would share history and, worse, a rotation. + expect(copy.timeline.id).not.toBe(original.timeline.id); + for (const slot of copy.slots) { + expect(original.slots.some((other) => other.id === slot.id)).toBe(false); + } + const originalScopes = new Set( + [...store.sources.values()] + .filter((source) => source.timelineId === original.timeline.id) + .map((source) => source.selectionScope), + ); + const copyScopes = [...store.sources.values()] + .filter((source) => source.timelineId === copy.timeline.id) + .map((source) => source.selectionScope); + expect(copyScopes).toHaveLength(3); + for (const scope of copyScopes) { + // The copy rotates independently: an evening loop does not continue the morning's cursor. + expect(originalScopes.has(scope)).toBe(false); + } + // The copy's slots still share one source the same way the original's did. + const copyAnnouncements = copy.slots.filter((slot) => slot.sourceId === copy.sources[0]?.id); + expect(copyAnnouncements).toHaveLength(5); + }); + + it("refuses to delete a timeline playback still points at", async () => { + const detail = await seedTimeline(); + store.setTimelineUsage(detail.timeline.id, { + activeGroups: 1, + pendingGroups: 0, + scheduledGroups: 1, + }); + + const error = await expectSignageCode( + deleteTimeline({ store, timelineId: detail.timeline.id }), + "SIGNAGE_CONFLICT", + ); + + expect(error.detail).toEqual({ activeGroups: 1, pendingGroups: 0, scheduledGroups: 1 }); + expect(store.timelines.size).toBe(1); + }); + + it("deletes an unused timeline and everything inside it", async () => { + const detail = await seedPattern(); + + await deleteTimeline({ store, timelineId: detail.timeline.id }); + + expect(store.timelines.size).toBe(0); + expect(store.slots.size).toBe(0); + expect(store.sources.size).toBe(0); + }); + + it("refuses a slot that is both a source and a pinned item, or neither", async () => { + const detail = await seedTimeline(); + const withSource = await addProviderSource({ + label: "Active announcements", + newId, + providerId: "fixture.announcements", + sourceId: "active-slides", + store, + timelineId: detail.timeline.id, + }); + const source = withSource.sources[0]; + if (!source) { + throw new Error("Expected a source."); + } + + await expectSignageCode( + addSlot({ newId, store, timelineId: detail.timeline.id }), + "SIGNAGE_INVALID_REQUEST", + ); + await expectSignageCode( + addSlot({ + newId, + pinned: { itemId: "welcome", providerId: "fixture.announcements" }, + source: { sourceId: source.id }, + store, + timelineId: detail.timeline.id, + }), + "SIGNAGE_INVALID_REQUEST", + ); + }); + + it("refuses a slot referencing a source configured on a different timeline", async () => { + const first = await seedTimeline("First"); + const second = await seedTimeline("Second"); + const withSource = await addProviderSource({ + label: "Active announcements", + newId, + providerId: "fixture.announcements", + sourceId: "active-slides", + store, + timelineId: first.timeline.id, + }); + const source = withSource.sources[0]; + if (!source) { + throw new Error("Expected a source."); + } + + await expectSignageCode( + addSlot({ newId, source: { sourceId: source.id }, store, timelineId: second.timeline.id }), + "SIGNAGE_INVALID_REQUEST", + ); + }); + + it("accepts a pinned item as an opaque identity from another module", async () => { + const detail = await seedTimeline(); + + const withPinned = await addSlot({ + newId, + pinned: { itemId: "advent-service", providerId: "fixture.announcements" }, + store, + timelineId: detail.timeline.id, + }); + + expect(withPinned.slots[0]).toMatchObject({ + itemId: "advent-service", + kind: "pinned-item", + providerId: "fixture.announcements", + sourceId: null, + }); + }); + + it("refuses an inverted or empty eligibility window rather than silently never showing a slot", async () => { + const detail = await seedTimeline(); + const from = new Date("2026-12-24T18:00:00.000Z"); + const until = new Date("2026-12-24T17:00:00.000Z"); + + await expectSignageCode( + createTimeline({ eligibleFrom: from, eligibleUntil: until, name: "Bad", newId, store }), + "SIGNAGE_INVALID_REQUEST", + ); + await expectSignageCode( + addSlot({ + eligibleFrom: from, + eligibleUntil: from, + newId, + pinned: { itemId: "x", providerId: "p" }, + store, + timelineId: detail.timeline.id, + }), + "SIGNAGE_INVALID_REQUEST", + ); + }); + + it("refuses presentation values it cannot render, and stores the ones it can", async () => { + const detail = await seedTimeline(); + + await expectSignageCode( + addSlot({ + newId, + pinned: { itemId: "x", providerId: "p" }, + presentation: { backgroundColor: "red; background: url(x)" }, + store, + timelineId: detail.timeline.id, + }), + "SIGNAGE_INVALID_REQUEST", + ); + + const stored = await addSlot({ + newId, + pinned: { itemId: "x", providerId: "p" }, + presentation: { backgroundColor: "#112233", backgroundMode: "solid", blurPixels: 30 }, + store, + timelineId: detail.timeline.id, + }); + + expect(stored.slots[0]?.presentation).toEqual({ + backgroundColor: "#112233", + backgroundDarkened: null, + backgroundMode: "solid", + blurPixels: 30, + foregroundShadow: null, + }); + }); + + it("refuses a duration outside what a slide can usefully be", async () => { + const detail = await seedTimeline(); + + for (const durationOverrideMs of [0, 999, 3_600_001, 1.5]) { + await expectSignageCode( + addSlot({ + durationOverrideMs, + newId, + pinned: { itemId: "x", providerId: "p" }, + store, + timelineId: detail.timeline.id, + }), + "SIGNAGE_INVALID_REQUEST", + ); + } + }); + + it("reports an unknown timeline as not found without saying whether it ever existed", async () => { + await expectSignageCode( + updateTimeline({ + changes: { name: "x" }, + store, + timelineId: "11111111-1111-4111-8111-111111111111", + }), + "SIGNAGE_NOT_FOUND", + ); + }); +}); diff --git a/tests/uploads.test.ts b/tests/uploads.test.ts new file mode 100644 index 0000000..26fb361 --- /dev/null +++ b/tests/uploads.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { createUploadQueue, type UploadFile, type UploadItem } from "../src/browser/uploads.js"; + +function file(name: string, size: number, type = "image/png"): UploadFile { + return { name, size, type }; +} + +async function settle(): Promise { + for (let index = 0; index < 8; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +describe("upload queue", () => { + it("sends one request per file, and never more than the concurrency at once", async () => { + let inFlight = 0; + let peak = 0; + const requests: string[] = []; + const release: (() => void)[] = []; + const queue = createUploadQueue({ + concurrency: 3, + maxBytes: 25 * 1024 * 1024, + maxFiles: 20, + newId: () => `id-${requests.length}-${release.length}`, + onChange: () => undefined, + upload: (candidate) => { + requests.push(candidate.name); + inFlight += 1; + peak = Math.max(peak, inFlight); + return new Promise((resolve) => { + release.push(() => { + inFlight -= 1; + resolve(); + }); + }); + }, + }); + + queue.enqueue(Array.from({ length: 20 }, (_, index) => file(`slide-${index}.png`, 1024))); + await settle(); + + // Twenty files are twenty requests — never one aggregate upload, which the platform's per-request + // ceiling could not express and whose failure would waste every byte already sent. + expect(peak).toBe(3); + expect(requests).toHaveLength(3); + + while (release.length > 0) { + release.shift()?.(); + await settle(); + } + await queue.drain(); + + expect(requests).toHaveLength(20); + expect(new Set(requests).size).toBe(20); + }); + + it("fails one file alone and finishes the rest of the batch", async () => { + const items: UploadItem[][] = []; + const queue = createUploadQueue({ + concurrency: 2, + maxBytes: 25 * 1024 * 1024, + maxFiles: 20, + newId: () => `id-${items.length}-${Math.random()}`, + onChange: (next) => items.push([...next]), + upload: (candidate) => + candidate.name === "broken.png" + ? Promise.reject({ failure: { code: "SIGNAGE_IMAGE_MALFORMED", message: "Bad image." } }) + : Promise.resolve(), + }); + + queue.enqueue([file("a.png", 10), file("broken.png", 10), file("c.png", 10)]); + await queue.drain(); + await settle(); + + const final = items.at(-1) ?? []; + expect(final.filter((item) => item.status === "done")).toHaveLength(2); + const failed = final.find((item) => item.status === "failed"); + expect(failed).toMatchObject({ code: "SIGNAGE_IMAGE_MALFORMED", name: "broken.png" }); + }); + + it("refuses an oversized or unsupported file before a byte is sent", async () => { + const sent: string[] = []; + const seen: UploadItem[][] = []; + const queue = createUploadQueue({ + concurrency: 3, + maxBytes: 1024, + maxFiles: 20, + newId: () => `id-${seen.length}-${sent.length}-${Math.random()}`, + onChange: (next) => seen.push([...next]), + upload: (candidate) => { + sent.push(candidate.name); + return Promise.resolve(); + }, + }); + + queue.enqueue([ + file("huge.png", 2048), + file("document.pdf", 10, "application/pdf"), + file("fine.png", 10), + ]); + await queue.drain(); + await settle(); + + // Only the acceptable one was ever sent. The server checks both rules again — a browser is not a + // security boundary — but telling the operator now costs them nothing. + expect(sent).toEqual(["fine.png"]); + const final = seen.at(-1) ?? []; + expect(final.find((item) => item.name === "huge.png")).toMatchObject({ + code: "SIGNAGE_IMAGE_TOO_LARGE", + status: "rejected", + }); + expect(final.find((item) => item.name === "document.pdf")).toMatchObject({ + code: "SIGNAGE_UPLOAD_UNSUPPORTED_TYPE", + status: "rejected", + }); + }); + + it("refuses the overflow of a batch larger than the dashboard offers", async () => { + const sent: string[] = []; + const seen: UploadItem[][] = []; + const queue = createUploadQueue({ + concurrency: 5, + maxBytes: 1024, + maxFiles: 2, + newId: () => `id-${sent.length}-${seen.length}-${Math.random()}`, + onChange: (next) => seen.push([...next]), + upload: (candidate) => { + sent.push(candidate.name); + return Promise.resolve(); + }, + }); + + queue.enqueue([file("a.png", 10), file("b.png", 10), file("c.png", 10)]); + await queue.drain(); + await settle(); + + expect(sent).toEqual(["a.png", "b.png"]); + expect((seen.at(-1) ?? []).find((item) => item.name === "c.png")).toMatchObject({ + code: "SIGNAGE_UPLOAD_BATCH_TOO_LARGE", + status: "rejected", + }); + }); +}); diff --git a/tests/viewers.test.ts b/tests/viewers.test.ts new file mode 100644 index 0000000..289162a --- /dev/null +++ b/tests/viewers.test.ts @@ -0,0 +1,603 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { buildDisplayChannelState } from "../src/server/display/state.js"; +import { SignageError } from "../src/server/errors.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 type { StoredChannel } from "../src/server/store/types.js"; +import { addProviderSource, addSlot, createTimeline } from "../src/server/timelines/service.js"; +import { createDisplaysCapability } from "../src/server/viewers/capability.js"; +import { + describeViewerHealth, + openViewerSession, + recordViewerHeartbeat, + SIGNAGE_VIEWER_OFFLINE_AFTER_MS, + updateViewerAssignment, +} from "../src/server/viewers/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 { announcementsFixture, type FixtureProvider } from "./support/fixture-provider.js"; +import { makeImage } from "./support/images.js"; +import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js"; + +const START = Date.parse("2026-08-30T15:00:00.000Z"); + +async function expectSignageCode(promise: Promise, code: string): Promise { + try { + await promise; + } catch (error) { + expect(error).toBeInstanceOf(SignageError); + const signageError = error as SignageError; + expect(signageError.code).toBe(code); + return signageError; + } + throw new Error(`Expected ${code}, but the operation succeeded.`); +} + +describe("viewers", () => { + let store: MemorySignageStore; + let registry: SignageProviderRegistry; + let service: SignagePlaybackService; + let announcements: FixtureProvider; + let clock: number; + let counter: number; + + const newId = () => { + counter += 1; + return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`; + }; + + beforeEach(async () => { + clock = START; + counter = 0; + store = createMemorySignageStore({ now: () => new Date(clock) }); + registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 }); + announcements = announcementsFixture(); + registry.capability.register(announcements.registration); + registry.capability.register( + createNativeImageProvider({ defaultDurationMs: 7000, now: () => new Date(clock), store }) + .registration, + ); + service = createPlaybackService({ + newId, + now: () => clock, + registry, + setTimer: () => undefined, + settings: SIGNAGE_DEFAULT_SETTINGS, + store, + }); + await service.ensureDefaultGroup(); + }); + + async function defaultChannel(): Promise { + const groups = await store.listChannelGroups(); + const channel = groups[0]?.channels[0]; + if (!channel) { + throw new Error("Expected a default channel."); + } + return channel; + } + + it("registers a display on the default channel, and never lets it pick one", async () => { + const session = await openViewerSession({ + at: new Date(clock), + deviceId: null, + newId, + store, + viewerKey: "kiosk-lobby-0001", + }); + + const channel = await defaultChannel(); + expect(session.channel.id).toBe(channel.id); + expect(session.channel.isDefault).toBe(true); + // There is no input by which a display could have asked for another channel: `openViewerSession` + // takes an identity and nothing else. + expect(session.viewer.name).toBe("New display"); + }); + + it("recognizes a returning display rather than creating a ghost", async () => { + const first = await openViewerSession({ + at: new Date(clock), + deviceId: null, + newId, + store, + viewerKey: "kiosk-lobby-0001", + }); + await updateViewerAssignment({ name: "Lobby north", store, viewerId: first.viewer.id }); + + clock += 60_000; + const second = await openViewerSession({ + at: new Date(clock), + deviceId: null, + newId, + store, + viewerKey: "kiosk-lobby-0001", + }); + + // Same screen, same row, same name. A television that reboots does not fill the roster. + expect(second.viewer.id).toBe(first.viewer.id); + expect(second.viewer.name).toBe("Lobby north"); + expect(store.viewers.size).toBe(1); + }); + + it("stores only the safe device identifier a paired principal carries", async () => { + const session = await openViewerSession({ + at: new Date(clock), + deviceId: "3f2c1a9e-1111-4222-8333-444455556666", + newId, + store, + viewerKey: "kiosk-lobby-0002", + }); + + expect(session.viewer.deviceId).toBe("3f2c1a9e-1111-4222-8333-444455556666"); + // Nothing about pairing, and no credential of any kind, is anywhere in the record. + const serialized = JSON.stringify(session.viewer); + for (const forbidden of ["token", "secret", "credential", "password", "pairing"]) { + expect(serialized.toLowerCase()).not.toContain(forbidden); + } + }); + + it("refuses a viewer key that is not one Signage issued the shape of", async () => { + for (const viewerKey of ["", "short", "has spaces in it", "x".repeat(200)]) { + await expectSignageCode( + openViewerSession({ at: new Date(clock), deviceId: null, newId, store, viewerKey }), + "SIGNAGE_INVALID_REQUEST", + ); + } + }); + + it("coalesces a steady display's heartbeats into almost no writes", async () => { + const session = await openViewerSession({ + at: new Date(clock), + deviceId: null, + newId, + store, + viewerKey: "kiosk-lobby-0001", + }); + const before = store.viewerWrites.count; + + // Ten heartbeats over forty seconds, saying exactly the same thing each time. + for (let index = 0; index < 10; index += 1) { + clock += 4000; + await recordViewerHeartbeat({ + at: new Date(clock), + heartbeat: { cachedAssetCount: 0, offlineReady: false, reportedSelectionId: null }, + minimumWriteIntervalMs: SIGNAGE_DEFAULT_SETTINGS.viewerHeartbeatWriteMs, + store, + viewerId: session.viewer.id, + }); + } + + // Forty seconds at a thirty-second floor is one write, not ten. A wall of televisions is a wall + // of these, and writing every one would say nothing new very expensively. + expect(store.viewerWrites.count - before).toBe(1); + }); + + it("writes immediately when a display says something an operator would notice", async () => { + const session = await openViewerSession({ + at: new Date(clock), + deviceId: null, + newId, + store, + viewerKey: "kiosk-lobby-0001", + }); + const before = store.viewerWrites.count; + + clock += 1000; + const changed = await recordViewerHeartbeat({ + at: new Date(clock), + heartbeat: { reportedSelectionId: "sel-1" }, + minimumWriteIntervalMs: SIGNAGE_DEFAULT_SETTINGS.viewerHeartbeatWriteMs, + store, + viewerId: session.viewer.id, + }); + clock += 1000; + const failing = await recordViewerHeartbeat({ + at: new Date(clock), + heartbeat: { + error: { code: "SLIDE_DECODE_FAILED", detail: "one image could not be decoded" }, + reportedSelectionId: "sel-1", + }, + minimumWriteIntervalMs: SIGNAGE_DEFAULT_SETTINGS.viewerHeartbeatWriteMs, + store, + viewerId: session.viewer.id, + }); + + expect(changed.written).toBe(true); + expect(failing.written).toBe(true); + expect(store.viewerWrites.count - before).toBe(2); + expect(failing.viewer.lastError).toMatchObject({ code: "SLIDE_DECODE_FAILED" }); + }); + + it("refuses an unbounded or malformed error report from a display", async () => { + const session = await openViewerSession({ + at: new Date(clock), + deviceId: null, + newId, + store, + viewerKey: "kiosk-lobby-0001", + }); + + await expectSignageCode( + recordViewerHeartbeat({ + at: new Date(clock), + heartbeat: { error: { code: "x".repeat(200) } }, + minimumWriteIntervalMs: 0, + store, + viewerId: session.viewer.id, + }), + "SIGNAGE_INVALID_REQUEST", + ); + await expectSignageCode( + recordViewerHeartbeat({ + at: new Date(clock), + heartbeat: { error: { code: "OK", detail: "y".repeat(5000) } }, + minimumWriteIntervalMs: 0, + store, + viewerId: session.viewer.id, + }), + "SIGNAGE_INVALID_REQUEST", + ); + }); + + it("reports a display as offline once it stops checking in", async () => { + const session = await openViewerSession({ + at: new Date(clock), + deviceId: null, + newId, + store, + viewerKey: "kiosk-lobby-0001", + }); + + expect(describeViewerHealth(session.viewer, clock).online).toBe(true); + expect( + describeViewerHealth(session.viewer, clock + SIGNAGE_VIEWER_OFFLINE_AFTER_MS + 1).online, + ).toBe(false); + }); + + it("moves a display between channels, and refuses a channel that does not exist", async () => { + const groups = await store.listChannelGroups(); + const group = groups[0]; + if (!group) { + throw new Error("Expected a group."); + } + const onlineChannelId = newId(); + await store.addChannel({ + groupId: group.group.id, + id: onlineChannelId, + isDefault: false, + key: "online", + name: "Online", + }); + const session = await openViewerSession({ + at: new Date(clock), + deviceId: null, + newId, + store, + viewerKey: "kiosk-lobby-0001", + }); + + const moved = await updateViewerAssignment({ + channelId: onlineChannelId, + store, + viewerId: session.viewer.id, + }); + expect(moved.channelId).toBe(onlineChannelId); + + await expectSignageCode( + updateViewerAssignment({ + channelId: "11111111-1111-4111-8111-111111111111", + store, + viewerId: session.viewer.id, + }), + "SIGNAGE_NOT_FOUND", + ); + }); + + it("exposes a sanitized roster through the displays capability", async () => { + const session = await openViewerSession({ + at: new Date(clock), + deviceId: "3f2c1a9e-1111-4222-8333-444455556666", + newId, + store, + viewerKey: "kiosk-lobby-0001", + }); + const capability = createDisplaysCapability( + () => store, + () => new Date(clock), + ); + + const viewers = await capability.listViewers(); + const channels = await capability.listChannels(); + + expect(viewers).toHaveLength(1); + expect(viewers[0]).toMatchObject({ id: session.viewer.id, online: true }); + expect(channels[0]).toMatchObject({ isDefault: true, key: "default" }); + // A consuming module sees identity and health. It never sees a credential, an object key, a + // delivery URL, or a provider payload. + const serialized = JSON.stringify({ channels, viewers }); + for (const forbidden of ["token", "secret", "objectKey", "https://", "payload"]) { + expect(serialized).not.toContain(forbidden); + } + }); +}); + +describe("display channel state", () => { + let store: MemorySignageStore; + let storage: FakeStorage; + let registry: SignageProviderRegistry; + let service: SignagePlaybackService; + let announcements: FixtureProvider; + let clock: number; + let counter: number; + + const newId = () => { + counter += 1; + return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`; + }; + + beforeEach(async () => { + clock = START; + counter = 0; + store = createMemorySignageStore({ now: () => new Date(clock) }); + storage = createFakeStorage({ now: () => new Date(clock) }); + registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 }); + announcements = announcementsFixture(); + registry.capability.register(announcements.registration); + registry.capability.register( + createNativeImageProvider({ defaultDurationMs: 7000, now: () => new Date(clock), store }) + .registration, + ); + service = createPlaybackService({ + newId, + now: () => clock, + registry, + setTimer: () => undefined, + settings: SIGNAGE_DEFAULT_SETTINGS, + store, + }); + await service.ensureDefaultGroup(); + }); + + async function channelsOf(): Promise { + const groups = await store.listChannelGroups(); + return groups[0]?.channels ?? []; + } + + async function stateFor(channel: StoredChannel) { + return buildDisplayChannelState({ + atMs: clock, + channel, + playback: await service.snapshot(channel.groupId), + registry, + settings: SIGNAGE_DEFAULT_SETTINGS, + storage, + store, + }); + } + + it("carries the frame, the timeline's shape, and recent selections — and no object key", async () => { + const groups = await store.listChannelGroups(); + const groupId = groups[0]?.group.id as string; + let timeline = await createTimeline({ name: "Loop", newId, store }); + timeline = await addProviderSource({ + label: "Announcements", + newId, + providerId: "fixture.announcements", + sourceId: "active-slides", + store, + timelineId: timeline.timeline.id, + }); + const source = timeline.sources[0]; + if (!source) { + throw new Error("Expected a source."); + } + await addSlot({ + newId, + source: { sourceId: source.id }, + store, + timelineId: timeline.timeline.id, + }); + await addSlot({ + newId, + source: { sourceId: source.id }, + store, + timelineId: timeline.timeline.id, + }); + await service.activate(groupId, timeline.timeline.id, "now"); + + const [channel] = await channelsOf(); + if (!channel) { + throw new Error("Expected a channel."); + } + const state = await stateFor(channel); + + expect(state.current?.itemId).toBe("welcome"); + expect(state.current?.renderer).toEqual({ + contributionId: "fixture.announcements.slide", + contributionVersion: "1.0.0", + kind: "contribution", + }); + // The shape a disconnected display advances its own cursor over, with the same estimates the + // server skips passes with. + expect(state.timeline?.slots).toHaveLength(2); + expect(state.timeline?.slots[0]?.estimatedDurationMs).toBe(5000); + expect(state.selections.length).toBeGreaterThan(0); + // A display is handed identity, timing, and payloads — never an object key. + expect(JSON.stringify(state)).not.toContain("objectKey"); + }); + + it("gives each channel its own audience's payload, from one logical selection", async () => { + const groups = await store.listChannelGroups(); + const groupId = groups[0]?.group.id as string; + await store.addChannel({ + groupId, + id: newId(), + isDefault: false, + key: "online", + name: "Online", + }); + let timeline = await createTimeline({ name: "Loop", newId, store }); + timeline = await addProviderSource({ + label: "Announcements", + newId, + providerId: "fixture.announcements", + sourceId: "active-slides", + store, + timelineId: timeline.timeline.id, + }); + const source = timeline.sources[0]; + if (!source) { + throw new Error("Expected a source."); + } + await addSlot({ + newId, + source: { sourceId: source.id }, + store, + timelineId: timeline.timeline.id, + }); + await service.activate(groupId, timeline.timeline.id, "now"); + // Move to the announcement that has an audience-specific variant. + clock += 5000; + await service.reconcile(groupId, "timer"); + + const channels = await channelsOf(); + const base = channels.find((channel) => channel.key === "default") as StoredChannel; + const online = channels.find((channel) => channel.key === "online") as StoredChannel; + const baseState = await stateFor(base); + const onlineState = await stateFor(online); + + expect(baseState.current?.payload).toEqual({ headline: "Good morning" }); + expect(onlineState.current?.payload).toEqual({ headline: "Watching from home" }); + // One selection, one slot, one deadline — two renderings. + expect(onlineState.current?.id).toBe(baseState.current?.id); + expect(onlineState.current?.deadlineAtMs).toBe(baseState.current?.deadlineAtMs); + expect(onlineState.playback.startedAtMs).toBe(baseState.playback.startedAtMs); + }); + + it("resolves a native image into a scene and fresh delivery URLs", async () => { + const groups = await store.listChannelGroups(); + const groupId = groups[0]?.group.id as string; + const ingested = await ingestNativeImage({ + bytes: await makeImage({ format: "png", height: 1080, width: 1080 }), + displayName: "Square", + expiresAt: null, + filename: "square.png", + storage, + store, + }); + let timeline = await createTimeline({ name: "Images", newId, store }); + await addSlot({ + newId, + pinned: { itemId: ingested.image.id, providerId: SIGNAGE_NATIVE_PROVIDER_ID }, + presentation: { backgroundMode: "blurred", blurPixels: 24 }, + store, + timelineId: timeline.timeline.id, + }); + timeline = (await store.findTimeline(timeline.timeline.id)) as never; + await service.activate(groupId, timeline.timeline.id, "now"); + + const [channel] = await channelsOf(); + if (!channel) { + throw new Error("Expected a channel."); + } + const state = await stateFor(channel); + + expect(state.current?.renderer).toEqual({ kind: "native-image" }); + // A square image is not 16:9, so it is shown whole on the background the slot asked for. + expect(state.current?.scene).toMatchObject({ + backgroundImage: true, + blurPixels: 24, + fit: "contain", + shadow: true, + }); + expect(state.current?.media?.display.url).toContain("https://storage.invalid/"); + expect(state.current?.media?.display.expiresAtMs).toBeGreaterThan(clock); + // The URL was minted for this response and is nowhere in the database. + expect(JSON.stringify([...store.selections.values()])).not.toContain("https://"); + }); + + it("shapes a static hold exactly like a selection, so a display has one renderer path", async () => { + const groups = await store.listChannelGroups(); + const groupId = groups[0]?.group.id as string; + const ingested = await ingestNativeImage({ + bytes: await makeImage({ format: "png", height: 1080, width: 1920 }), + displayName: "Hold", + expiresAt: null, + filename: "hold.png", + storage, + store, + }); + await service.setStaticHold(groupId, ingested.image.id); + + const [channel] = await channelsOf(); + if (!channel) { + throw new Error("Expected a channel."); + } + const state = await stateFor(channel); + + expect(state.playback.transport).toBe("static"); + expect(state.staticHold?.renderer).toEqual({ kind: "native-image" }); + // A true 16:9 image fills the frame on its own: no blurred duplicate, no shadow. + expect(state.staticHold?.scene).toMatchObject({ + backgroundImage: false, + fit: "fill", + shadow: false, + }); + }); + + it("keeps a display's state usable when storage is briefly unreachable", async () => { + const groups = await store.listChannelGroups(); + const groupId = groups[0]?.group.id as string; + const ingested = await ingestNativeImage({ + bytes: await makeImage({ format: "png", height: 1080, width: 1920 }), + displayName: "Slide", + expiresAt: null, + filename: "slide.png", + storage, + store, + }); + const timeline = await createTimeline({ name: "Images", newId, store }); + await addSlot({ + newId, + pinned: { itemId: ingested.image.id, providerId: SIGNAGE_NATIVE_PROVIDER_ID }, + store, + timelineId: timeline.timeline.id, + }); + await service.activate(groupId, timeline.timeline.id, "now"); + + const [channel] = await channelsOf(); + if (!channel) { + throw new Error("Expected a channel."); + } + const broken = { + ...storage, + bucket: () => { + throw new Error("storage is down"); + }, + }; + const state = await buildDisplayChannelState({ + atMs: clock, + channel, + playback: await service.snapshot(groupId), + registry, + settings: SIGNAGE_DEFAULT_SETTINGS, + storage: broken as never, + store, + }); + + // The frame's identity, timing, and scene all survive. Only the URLs are missing, which costs a + // display that already cached the bytes nothing at all. + expect(state.current?.itemId).toBe(ingested.image.id); + expect(state.current?.scene).toBeDefined(); + expect(state.current?.media).toBeUndefined(); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..521e877 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "src/**/*.test.ts", + "src/browser/**", + "src/contributions/**", + "src/display/**", + "src/ui/**", + "tests" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cc8373d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@ekklesia/typescript-config/node.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["drizzle.config.ts", "src/**/*.ts", "tests/**/*.ts"], + "exclude": [ + "src/browser/**", + "src/contributions/**", + "src/display/**", + "src/ui/**", + "tests/browser/**" + ] +} diff --git a/tsconfig.ui-test.json b/tsconfig.ui-test.json new file mode 100644 index 0000000..82d2f29 --- /dev/null +++ b/tsconfig.ui-test.json @@ -0,0 +1,8 @@ +{ + "extends": "@ekklesia/typescript-config/browser-react.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/module-styles.d.ts", "tests/browser/**/*.ts", "tests/browser/**/*.tsx"] +} diff --git a/tsconfig.ui.json b/tsconfig.ui.json new file mode 100644 index 0000000..557ebfd --- /dev/null +++ b/tsconfig.ui.json @@ -0,0 +1,19 @@ +{ + "extends": "@ekklesia/typescript-config/browser-react.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "include": [ + "src/module-styles.d.ts", + "src/browser/**/*.ts", + "src/browser/**/*.tsx", + "src/contributions/**/*.ts", + "src/contributions/**/*.tsx", + "src/display/**/*.ts", + "src/display/**/*.tsx", + "src/shared/**/*.ts", + "src/ui/**/*.ts", + "src/ui/**/*.tsx" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1f388a6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +/** + * Signage's own test configuration. + * + * It exists because this module's browser tests are `.tsx` — they mount real React — and the + * repository root's configuration includes only `.test.ts`. Without this file those tests are + * silently not run, which is the worst possible failure mode for a test suite. + */ +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"], + passWithNoTests: false, + }, +});