feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.turbo/
coverage/
*.log
.DS_Store
+140
View File
@@ -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> ekklesia
git clone <this repository> 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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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/<asset-id>/…`), 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.
+20
View File
@@ -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
+343
View File
@@ -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<readonly SourceDescriptor[]>;
listItems?(request: DiscoveryRequest & { sourceId?: string }): Awaitable<readonly ItemDescriptor[]>;
resolveNext(request: ResolveRequest): Awaitable<Resolution | null>;
refresh(request: RefreshRequest): Awaitable<Resolution | null>;
}
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<ResolveRequest, "sourceId" | "selectionScopeId"> {
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(<Slide input={input} />);
return {
update(next) { root.render(<Slide input={next as SignageSlideRendererInput} />); },
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.
+11
View File
@@ -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,
});
+215
View File
@@ -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");
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"version": 1,
"moduleId": "signage",
"entries": [
{
"tag": "0000_initial",
"destructive": {
"mode": "none"
}
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1787887037559,
"tag": "0000_initial",
"breakpoints": true
}
]
}
+158
View File
@@ -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"
}
}
}
+55
View File
@@ -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"
}
}
+31
View File
@@ -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 (
<>
<div className="signage-narrow-notice" data-testid="signage-narrow-notice" role="note">
<h2 className="signage-narrow-notice__title">Signage needs a desktop screen</h2>
<p className="signage-narrow-notice__body">
{`Open the Ekklesia Dashboard on a display at least ${SIGNAGE_MIN_DESKTOP_WIDTH_PX} pixels wide to manage timelines, images, channels, and playback.`}
</p>
</div>
<div className="signage-desktop" data-testid="signage-desktop">
{children}
</div>
</>
);
}
+288
View File
@@ -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<string>): 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<string>,
): 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<string>;
}): OfflineEvaluation {
const { snapshot, atMs } = input;
const unusable = input.unusableSelectionIds ?? new Set<string>();
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;
}
+177
View File
@@ -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",
};
}
+360
View File
@@ -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> | void;
unmount?(): PromiseLike<void> | 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<MountedRenderer>;
/** How an image is awaited. A browser's `decode()` by default; a test supplies its own. */
readonly decodeImage?: (image: HTMLImageElement) => Promise<void>;
/** 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<SceneOutcome>;
/** 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<void>;
}
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<SceneOutcome> {
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" };
},
};
}
+32
View File
@@ -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 (
<section aria-live="polite" className="signage-slate" data-testid="signage-slate">
<div aria-hidden="true" className="signage-slate__bars">
<span className="signage-slate__bar signage-slate__bar--one" />
<span className="signage-slate__bar signage-slate__bar--two" />
<span className="signage-slate__bar signage-slate__bar--three" />
<span className="signage-slate__bar signage-slate__bar--four" />
<span className="signage-slate__bar signage-slate__bar--five" />
<span className="signage-slate__bar signage-slate__bar--six" />
</div>
<p className="signage-slate__message">Content unavailable</p>
</section>
);
}
+251
View File
@@ -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<Value>(key: string): Promise<Value | undefined>;
write(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<void>;
}
/**
* 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<string | undefined>;
/** A local URL for previously stored bytes, or `undefined` if they were never stored. */
find(key: string): Promise<string | undefined>;
/** 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<void>;
count(): Promise<number>;
}
/** 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<string, unknown>();
return {
delete(key: string): Promise<void> {
values.delete(key);
return Promise.resolve();
},
read<Value>(key: string): Promise<Value | undefined> {
return Promise.resolve(values.get(key) as Value | undefined);
},
write(key: string, value: unknown): Promise<void> {
// 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<string, string>();
return {
count: () => Promise.resolve(stored.size),
find: (key: string) => Promise.resolve(stored.get(key)),
prune(keep: readonly string[]): Promise<void> {
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<string | undefined> {
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<IDBDatabase> {
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<Result>(
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest,
): Promise<Result | undefined> {
let database: IDBDatabase;
try {
database = await open();
} catch {
return undefined;
}
return new Promise<Result | undefined>((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<void> {
await transact("readwrite", (store) => store.delete(key));
},
read<Value>(key: string): Promise<Value | undefined> {
return transact<Value>("readonly", (store) => store.get(key));
},
async write(key: string, value: unknown): Promise<void> {
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<Cache | undefined> {
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<string | undefined> {
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<number> {
const cache = await open();
if (!cache) {
return 0;
}
try {
return (await cache.keys()).length;
} catch {
return 0;
}
},
async find(key: string): Promise<string | undefined> {
const cache = await open();
return cache ? localUrl(cache, cacheRequest(key)) : undefined;
},
async prune(keep: readonly string[]): Promise<void> {
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<string | undefined> {
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;
}
},
};
}
+192
View File
@@ -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<void>;
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<void>;
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<string> = 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<void> = Promise.resolve();
let settleIdle: (() => void) | undefined;
function publish(): void {
options.onChange([...items]);
}
function replace(id: string, changes: Partial<UploadItem>): 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<void> {
if (active === 0 && pending.length === 0) {
return Promise.resolve();
}
idle = new Promise<void>((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],
};
}
+676
View File
@@ -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<void>;
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<void>;
stop(): Promise<void>;
/** Applies one channel state. Exposed so a test — and the preview — can drive it directly. */
apply(state: DisplayChannelStateView): Promise<void>;
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<string>();
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<string> {
const existing = await store.read<string>(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<string | undefined> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<typeof sceneRequestFor>;
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<void> {
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<void> {
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<void> {
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<void> {
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<OfflineSnapshot>(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<void> {
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<string, unknown>[] } | 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();
}
}
+69
View File
@@ -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<HTMLDivElement | null>(null);
useEffect(() => {
const root = stageRef.current;
if (!root) {
return;
}
const controller = createDisplayController({ context, root });
void controller.start();
return () => {
void controller.stop();
};
}, [context]);
return (
<main className="signage-display" data-testid="signage-display">
<div className="signage-stage" data-testid="signage-stage" ref={stageRef}>
{/* Shown only when the stage says there is nothing renderable *and* no good frame. */}
<div className="signage-slate-host">
<SignageSlate />
</div>
{/* A deliberate blackout: pure black over everything, while the hidden timeline runs on. */}
<div aria-hidden="true" className="signage-blackout" data-testid="signage-blackout" />
{/* Continuing from this screen's own last authoritative state. Small and in a corner: a
congregation is looking at this wall, and a network is not their problem. */}
<p className="signage-offline-note" data-testid="signage-offline-note">
Offline — continuing from the last update
</p>
</div>
</main>
);
}
/**
* 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(<SignageDisplay context={context} />);
return () => root.unmount();
},
});
+227
View File
@@ -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;
}
+17
View File
@@ -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;
}
+50
View File
@@ -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,
},
});
+485
View File
@@ -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),
],
);
+370
View File
@@ -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<typeof resolveNativeScene>;
}
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<DisplayChannelState> {
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<DisplaySelection> {
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<DisplaySelection["media"]> {
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<DisplaySelection | null> {
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,
};
}
+118
View File
@@ -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<Record<SignageErrorCode, string>> = 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<Record<SignageErrorCode, number>> = 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<Record<string, number | string | null>>;
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<Response>): Promise<Response> {
try {
return await run();
} catch (error) {
if (error instanceof SignageError) {
return signageErrorResponse(error);
}
return signageErrorResponse(new SignageError("SIGNAGE_OPERATION_FAILED"));
}
}
+126
View File
@@ -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<Record<string, unknown>> {
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<string, unknown>;
}
export function optionalString(
body: Record<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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;
}
+46
View File
@@ -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,
});
+199
View File
@@ -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<string> = new Set(["jpeg", "png", "webp"]);
const CONTENT_TYPES: Readonly<Record<string, string>> = Object.freeze({
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
});
const FILE_EXTENSIONS: Readonly<Record<string, string>> = 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<Metadata> {
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<SignageImageRendition> {
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<SignageProcessedImage> {
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,
};
}
+278
View File
@@ -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<IngestNativeImageResult> {
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<readonly SignageImageDelivery[]> {
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<StoredImage> {
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<void> {
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)));
}
+76
View File
@@ -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<Uint8Array> {
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;
}
+11
View File
@@ -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;
+68
View File
@@ -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<readonly SignagePlaybackState[]>;
readGroup(groupId: string): Promise<SignagePlaybackState>;
/** Immediately, or queued for the next safe slot boundary. */
activate(
groupId: string,
timelineId: string,
when: "now" | "next-boundary",
): Promise<SignagePlaybackState>;
pause(groupId: string): Promise<SignagePlaybackState>;
resume(groupId: string): Promise<SignagePlaybackState>;
next(groupId: string): Promise<SignagePlaybackState>;
previous(groupId: string): Promise<SignagePlaybackState>;
setBlackout(groupId: string, on: boolean): Promise<SignagePlaybackState>;
/** `null` clears the hold and continues the slide underneath from its preserved remainder. */
setStaticHold(groupId: string, imageId: string | null): Promise<SignagePlaybackState>;
/** Re-resolves provider availability and the current selection without disturbing the frame. */
reload(groupId: string): Promise<SignagePlaybackState>;
}
export function createPlaybackCapability(
playback: () => SignagePlaybackService,
listGroupIds: () => Promise<readonly string[]>,
): 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<readonly SignagePlaybackState[]> {
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);
}
File diff suppressed because it is too large Load Diff
+201
View File
@@ -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<SignageProviderResolveRequest, "sourceId" | "selectionScopeId"> {
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[]>
| readonly SignageProviderSourceDescriptor[];
/** Optional. Without it, an operator cannot pin one of this provider's items. */
listItems?(
request: SignageProviderDiscoveryRequest & { readonly sourceId?: string },
): Promise<readonly SignageProviderItemDescriptor[]> | 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> | SignageProviderResolution | null;
/** Re-resolves one already-known item without advancing. */
refresh(
request: SignageProviderRefreshRequest,
): Promise<SignageProviderResolution | null> | 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;
}
+210
View File
@@ -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<string, string>();
async function available(at: Date): Promise<readonly StoredImage[]> {
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<readonly SignageProviderItemDescriptor[]> {
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<SignageProviderResolution | null> {
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<SignageProviderResolution | null> {
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);
},
};
}
+362
View File
@@ -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<readonly SignageProviderSourceDescriptor[]>;
listItems(
providerId: string,
request: SignageProviderDiscoveryRequest & { readonly sourceId?: string },
): Promise<readonly SignageProviderItemDescriptor[]>;
resolveNext(
providerId: string,
request: SignageProviderResolveRequest,
): Promise<ProviderCallOutcome>;
refresh(providerId: string, request: SignageProviderRefreshRequest): Promise<ProviderCallOutcome>;
/** 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<string, RegisteredProvider>();
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<Result>(
entry: RegisteredProvider,
invoke: () => Promise<Result> | Result,
): Promise<{ ok: true; value: Result } | { ok: false; code: SignageProviderErrorCode }> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const value = await Promise.race([
Promise.resolve().then(invoke),
new Promise<never>((_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<readonly SignageProviderItemDescriptor[]> {
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<readonly SignageProviderSourceDescriptor[]> {
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<ProviderCallOutcome> {
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<ProviderCallOutcome> {
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 };
+366
View File
@@ -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<Record<SignageProviderErrorCode, string>> = 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<object>()): 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<string, unknown> {
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<string, SignageJsonValue>;
}
/**
* 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<string, SignageJsonValue>();
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<string, SignageJsonValue> {
const parsed = parseProviderResolution({
itemId: "x",
itemRevision: "1",
variants,
});
if (!parsed) {
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
}
return parsed.variants;
}
+209
View File
@@ -0,0 +1,209 @@
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import { SignageError, signageBoundary } from "../errors.js";
import { optionalInstant, optionalString, readJsonObject, requireUuidParam } from "../http.js";
import { SIGNAGE_IMAGE_MAX_BYTES } from "../images/limits.js";
import {
createImageDeliveryUrls,
deleteNativeImage,
ingestNativeImage,
updateNativeImage,
} from "../images/service.js";
import { readBoundedBody } from "../images/streaming.js";
import { createSignageStore } from "../store/drizzle.js";
import type { StoredImage } from "../store/types.js";
const FILENAME_MAX_LENGTH = 200;
/**
* A filename is a label an operator sees, never a path and never an object key.
*
* Path separators and control characters are refused rather than escaped, because a display name has
* no business containing either — and the value is echoed back to a browser, where a control
* character is a rendering hazard and a separator invites something to read it as a location.
*/
function isSafeLabel(value: string): boolean {
if (value.length === 0 || value.length > FILENAME_MAX_LENGTH) {
return false;
}
for (const character of value) {
const codePoint = character.codePointAt(0) ?? 0;
if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f)) {
return false;
}
if (character === "/" || character === "\\") {
return false;
}
}
return true;
}
function queryValue(request: Request, name: string): string | undefined {
const value = new URL(request.url).searchParams.get(name);
return value === null ? undefined : value;
}
function requireLabel(request: Request, field: string, fallback?: string): string {
const value = queryValue(request, field)?.trim();
if (value === undefined || value.length === 0) {
if (fallback !== undefined) {
return fallback;
}
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
}
if (!isSafeLabel(value)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
}
return value;
}
function readExpiresAt(request: Request): Date | null {
const raw = queryValue(request, "expiresAt");
if (raw === undefined || raw === "") {
// Null is permanent. Local age never invents an expiration for a church's own slide.
return null;
}
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime()) || !/[Zz]|[+-]\d{2}:?\d{2}$/u.test(raw)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "expiresAt" });
}
return parsed;
}
/** What a browser is given: the record plus fresh, short-lived delivery URLs. The logical object key
* never leaves the server — a browser has no use for it, and it is the one durable thing Signage
* stores about where bytes live. */
async function describe(
context: ModuleRouteHandlerContext,
image: StoredImage,
): Promise<Record<string, unknown>> {
const delivery = await createImageDeliveryUrls(context.storage, image.renditions);
return {
byteSize: image.originalByteSize,
contentSha256: image.contentSha256,
contentType: image.originalContentType,
createdAt: image.createdAt.toISOString(),
displayName: image.displayName,
expiresAt: image.expiresAt?.toISOString() ?? null,
hasAlpha: image.hasAlpha,
height: image.height,
id: image.id,
originalFilename: image.originalFilename,
renditions: delivery,
status: image.status,
width: image.width,
};
}
export function registerImageRoutes(http: ModuleHttpRouteRegistrar): void {
http.route({
method: "GET",
path: "/images",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const images = await createSignageStore(context.services.database).listImages();
return Response.json({
images: await Promise.all(images.map((image) => describe(context, image))),
});
});
},
});
http.route({
method: "POST",
path: "/images",
permission: "signage.assets.manage",
// One image, streamed. The platform counts bytes as the handler consumes them and never
// materializes the body; Signage counts them too and refuses at its own smaller limit first.
// The filename and display name travel in the query string, so the body is exactly the image.
maxBodyBytes: SIGNAGE_IMAGE_MAX_BYTES,
bodyMode: "streamed",
handler(request, context) {
return signageBoundary(async () => {
const filename = requireLabel(request, "filename");
const displayName = requireLabel(request, "displayName", filename);
const expiresAt = readExpiresAt(request);
// The whole body is read, and only then validated and committed. A `413` can arrive after a
// streamed handler has started, which is exactly why nothing is written until this returns.
const bytes = await readBoundedBody(request, SIGNAGE_IMAGE_MAX_BYTES);
const result = await ingestNativeImage({
bytes,
displayName,
expiresAt,
filename,
storage: context.storage,
store: createSignageStore(context.services.database),
});
return Response.json(
{ deduplicated: result.deduplicated, image: await describe(context, result.image) },
{ status: result.deduplicated ? 200 : 201 },
);
});
},
});
http.route({
method: "GET",
path: "/images/:imageId",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const imageId = requireUuidParam(context, "imageId");
const image = await createSignageStore(context.services.database).findImage(imageId);
if (!image) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
return Response.json({ image: await describe(context, image) });
});
},
});
http.route({
method: "PATCH",
path: "/images/:imageId",
permission: "signage.assets.manage",
// A rename and an expiration date. Nothing about this route needs a large body.
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const imageId = requireUuidParam(context, "imageId");
const body = await readJsonObject(request);
const displayName = optionalString(body, "displayName", {
maxLength: FILENAME_MAX_LENGTH,
});
if (displayName !== undefined && !isSafeLabel(displayName)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "displayName" });
}
const image = await updateNativeImage({
assetId: imageId,
displayName,
expiresAt: optionalInstant(body, "expiresAt"),
store: createSignageStore(context.services.database),
});
return Response.json({ image: await describe(context, image) });
});
},
});
http.route({
method: "DELETE",
path: "/images/:imageId",
permission: "signage.assets.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const imageId = requireUuidParam(context, "imageId");
await deleteNativeImage({
assetId: imageId,
storage: context.storage,
store: createSignageStore(context.services.database),
});
return new Response(null, { status: 204 });
});
},
});
}
+291
View File
@@ -0,0 +1,291 @@
import { randomUUID } from "node:crypto";
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import { SignageError, signageBoundary } from "../errors.js";
import {
isUuid,
optionalInstant,
readJsonObject,
requiredString,
requireUuidParam,
} from "../http.js";
import type { SignagePlaybackService } from "../playback/service.js";
import { createSignageStore } from "../store/drizzle.js";
import { SignageStoreConflict, type StoredChannelGroupDetail } from "../store/types.js";
/** An audience key is what a provider receives, so it uses the same flat lowercase grammar the rest
* of the platform uses for a local identifier. */
const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
function describeGroup(detail: StoredChannelGroupDetail): Record<string, unknown> {
return {
channels: detail.channels.map((channel) => ({
id: channel.id,
isDefault: channel.isDefault,
key: channel.key,
name: channel.name,
})),
id: detail.group.id,
name: detail.group.name,
revision: detail.group.revision,
};
}
export function registerPlaybackRoutes(
http: ModuleHttpRouteRegistrar,
playback: () => SignagePlaybackService,
): void {
http.route({
method: "GET",
path: "/channel-groups",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const groups = await createSignageStore(context.services.database).listChannelGroups();
return Response.json({
groups: await Promise.all(
groups.map(async (group) => ({
...describeGroup(group),
playback: await playback().snapshot(group.group.id),
})),
),
});
});
},
});
http.route({
method: "POST",
path: "/channel-groups",
permission: "signage.displays.manage",
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const body = await readJsonObject(request);
const store = createSignageStore(context.services.database);
const groupId = randomUUID();
// A group is created with its default channel and its playback row together: a group
// without a default channel has nowhere for a viewer to land.
await store.createChannelGroup({
channelId: randomUUID(),
channelName: "Default",
groupId,
name: requiredString(body, "name", { maxLength: 120 }),
});
const created = await store.findChannelGroup(groupId);
if (!created) {
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
return Response.json(describeGroup(created), { status: 201 });
});
},
});
http.route({
method: "DELETE",
path: "/channel-groups/:groupId",
permission: "signage.displays.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const groupId = requireUuidParam(context, "groupId");
const store = createSignageStore(context.services.database);
const groups = await store.listChannelGroups();
if (groups.length <= 1) {
// An installation always has somewhere for a viewer to land.
throw new SignageError("SIGNAGE_CONFLICT", { reason: "last-group" });
}
await store.deleteChannelGroup(groupId);
return new Response(null, { status: 204 });
});
},
});
http.route({
method: "POST",
path: "/channel-groups/:groupId/channels",
permission: "signage.displays.manage",
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const groupId = requireUuidParam(context, "groupId");
const body = await readJsonObject(request);
const key = requiredString(body, "key", { maxLength: 64 });
if (!AUDIENCE_KEY.test(key) || key === "default") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "key" });
}
const store = createSignageStore(context.services.database);
const group = await store.findChannelGroup(groupId);
if (!group) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
if (group.channels.some((channel) => channel.key === key)) {
throw new SignageError("SIGNAGE_CONFLICT", { field: "key" });
}
await store.addChannel({
groupId,
id: randomUUID(),
isDefault: false,
key,
name: requiredString(body, "name", { maxLength: 120 }),
});
const updated = await store.findChannelGroup(groupId);
if (!updated) {
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
return Response.json(describeGroup(updated), { status: 201 });
});
},
});
http.route({
method: "DELETE",
path: "/channels/:channelId",
permission: "signage.displays.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const channelId = requireUuidParam(context, "channelId");
try {
await createSignageStore(context.services.database).removeChannel(channelId);
} catch (error) {
if (error instanceof SignageStoreConflict) {
// A group's default channel is the one a viewer lands on. It cannot be removed.
throw new SignageError("SIGNAGE_CONFLICT", { reason: "default-channel" });
}
throw error;
}
return new Response(null, { status: 204 });
});
},
});
http.route({
method: "GET",
path: "/playback/:groupId",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const groupId = requireUuidParam(context, "groupId");
const store = createSignageStore(context.services.database);
const snapshot = await playback().snapshot(groupId);
const selection =
snapshot.currentSelectionId === null
? undefined
: await store.findSelection(snapshot.currentSelectionId);
const history = await store.listSelections(groupId, 20);
return Response.json({
current:
selection === undefined
? null
: {
deadlineAtMs: selection.deadlineAt?.getTime() ?? null,
durationMs: selection.durationMs,
id: selection.id,
itemId: selection.itemId,
itemRevision: selection.itemRevision,
providerId: selection.providerId,
rendererContributionId: selection.rendererContributionId,
revision: selection.revision,
stale: selection.stale,
startedAtMs: selection.startedAt?.getTime() ?? null,
withdrawn: selection.withdrawn,
},
// Identity and timing only. A payload belongs to a display, which asks for the channel
// state it is authorized for.
history: history.map((entry) => ({
id: entry.id,
itemId: entry.itemId,
providerId: entry.providerId,
sequence: entry.sequence,
startedAtMs: entry.startedAt?.getTime() ?? null,
})),
snapshot,
});
});
},
});
function command(
path: string,
run: (
service: SignagePlaybackService,
groupId: string,
body: Record<string, unknown>,
context: ModuleRouteHandlerContext,
) => Promise<unknown>,
options: { readonly maxBodyBytes: number },
): void {
http.route({
method: "POST",
path,
permission: "signage.playback.control",
maxBodyBytes: options.maxBodyBytes,
handler(request, context) {
return signageBoundary(async () => {
const groupId = requireUuidParam(context, "groupId");
const body = options.maxBodyBytes === 0 ? {} : await readJsonObject(request);
return Response.json(await run(playback(), groupId, body, context));
});
},
});
}
command(
"/playback/:groupId/activate",
async (service, groupId, body) => {
const timelineId = body.timelineId;
if (!isUuid(timelineId)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "timelineId" });
}
const at = optionalInstant(body, "at");
if (at) {
return service.activate(groupId, timelineId, { at });
}
const when = body.when === "next-boundary" ? "next-boundary" : "now";
return service.activate(groupId, timelineId, when);
},
{ maxBodyBytes: 2048 },
);
command("/playback/:groupId/pause", (service, groupId) => service.pause(groupId), {
maxBodyBytes: 0,
});
command("/playback/:groupId/resume", (service, groupId) => service.resume(groupId), {
maxBodyBytes: 0,
});
command("/playback/:groupId/next", (service, groupId) => service.next(groupId), {
maxBodyBytes: 0,
});
command("/playback/:groupId/previous", (service, groupId) => service.previous(groupId), {
maxBodyBytes: 0,
});
command("/playback/:groupId/reload", (service, groupId) => service.reload(groupId), {
maxBodyBytes: 0,
});
command(
"/playback/:groupId/blackout",
(service, groupId, body) => {
if (typeof body.on !== "boolean") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "on" });
}
return service.setBlackout(groupId, body.on);
},
{ maxBodyBytes: 1024 },
);
command(
"/playback/:groupId/static",
(service, groupId, body) => {
const assetId = body.assetId;
if (assetId === null) {
return service.setStaticHold(groupId, null);
}
if (!isUuid(assetId)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "assetId" });
}
return service.setStaticHold(groupId, assetId);
},
{ maxBodyBytes: 1024 },
);
}
+90
View File
@@ -0,0 +1,90 @@
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import { SignageError, signageBoundary } from "../errors.js";
import type { SignageProviderRegistry } from "../providers/registry.js";
import { readSignageSettings } from "../settings.js";
/** A provider id is another module's opaque identifier, and arrives here as a decoded path value —
* which is untrusted input whatever the declared path grammar was. Validated against Signage's own
* bound before any lookup. */
const PROVIDER_ID = /^[\w][\w.:-]{0,199}$/u;
function requireProviderId(context: ModuleRouteHandlerContext): string {
const value = context.params.providerId;
if (value === undefined || !PROVIDER_ID.test(value)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { parameter: "providerId" });
}
return value;
}
/**
* Provider health and discovery.
*
* Everything these routes return is either a provider's own declared identity or a count Signage
* itself kept. A provider's exception, message, stack, or payload never appears — which is what
* makes "an incompatible provider is clearly reported in management" true without also making a
* provider's internals an operator-visible surface.
*/
export function registerProviderRoutes(
http: ModuleHttpRouteRegistrar,
registry: () => SignageProviderRegistry,
): void {
http.route({
method: "GET",
path: "/providers",
permission: "signage.view",
maxBodyBytes: 0,
handler() {
return signageBoundary(() =>
Promise.resolve(
Response.json({
// Only what is actually registered right now. A registration is a running fact, not a
// stored one, so a provider that was uninstalled leaves nothing behind claiming it
// exists.
providers: registry().health(),
}),
),
);
},
});
http.route({
method: "GET",
path: "/providers/:providerId/sources",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const providerId = requireProviderId(context);
const settings = readSignageSettings(context.config);
// A provider that is not registered, is failing, or answers with something unusable yields
// an empty list rather than an error: the editor should say "no sources right now", not
// break, and provider health already reports why.
const sources = await registry().listSources(providerId, {
at: new Date().toISOString(),
timezone: settings.timezone,
});
return Response.json({ providerId, sources });
});
},
});
http.route({
method: "GET",
path: "/providers/:providerId/items",
permission: "signage.view",
maxBodyBytes: 0,
handler(request, context) {
return signageBoundary(async () => {
const providerId = requireProviderId(context);
const settings = readSignageSettings(context.config);
const sourceId = new URL(request.url).searchParams.get("sourceId");
const items = await registry().listItems(providerId, {
at: new Date().toISOString(),
timezone: settings.timezone,
...(sourceId === null ? {} : { sourceId }),
});
return Response.json({ items, providerId });
});
},
});
}
+420
View File
@@ -0,0 +1,420 @@
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import { SignageError, signageBoundary } from "../errors.js";
import {
isUuid,
optionalBoolean,
optionalInstant,
optionalInteger,
optionalString,
readJsonObject,
requiredString,
requireUuidParam,
} from "../http.js";
import { createSignageStore } from "../store/drizzle.js";
import type { SignageStore, StoredSlotPresentation, StoredTimelineDetail } from "../store/types.js";
import {
addProviderSource,
addSlot,
createTimeline,
deleteTimeline,
duplicateTimeline,
removeProviderSource,
removeSlot,
reorderSlots,
requireTimeline,
SIGNAGE_TIMELINE_LIMITS,
updateSlot,
updateTimeline,
} from "../timelines/service.js";
const PROVIDER_ID = /^[\w][\w.:-]{0,199}$/u;
function store(context: ModuleRouteHandlerContext): SignageStore {
return createSignageStore(context.services.database);
}
/** The wire shape of a timeline. Instants are ISO 8601 strings, which is what a browser should
* receive — never an invented epoch or a locale-formatted representation. */
function describe(detail: StoredTimelineDetail): Record<string, unknown> {
return {
slots: detail.slots.map((slot) => ({
durationOverrideMs: slot.durationOverrideMs,
eligibleFrom: slot.eligibleFrom?.toISOString() ?? null,
eligibleUntil: slot.eligibleUntil?.toISOString() ?? null,
enabled: slot.enabled,
id: slot.id,
itemId: slot.itemId,
kind: slot.kind,
position: slot.position,
presentation: slot.presentation,
providerId: slot.providerId,
sourceId: slot.sourceId,
})),
sources: detail.sources.map((source) => ({
id: source.id,
label: source.label,
providerId: source.providerId,
sourceId: source.sourceId,
// The selection scope is deliberately **not** exposed. It is an internal stable identity a
// provider receives; a dashboard that showed it would invite someone to depend on its shape.
})),
timeline: {
description: detail.timeline.description,
eligibleFrom: detail.timeline.eligibleFrom?.toISOString() ?? null,
eligibleUntil: detail.timeline.eligibleUntil?.toISOString() ?? null,
id: detail.timeline.id,
name: detail.timeline.name,
revision: detail.timeline.revision,
updatedAt: detail.timeline.updatedAt.toISOString(),
},
};
}
function readPresentation(
body: Record<string, unknown>,
): Partial<StoredSlotPresentation> | undefined {
const raw = body.presentation;
if (raw === undefined) {
return undefined;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "presentation" });
}
const value = raw as Record<string, unknown>;
const mode = value.backgroundMode;
if (
mode !== undefined &&
mode !== null &&
mode !== "blurred" &&
mode !== "solid" &&
mode !== "black"
) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "backgroundMode" });
}
return {
...(mode === undefined
? {}
: { backgroundMode: mode as StoredSlotPresentation["backgroundMode"] }),
...(value.backgroundColor === undefined
? {}
: { backgroundColor: optionalString(value, "backgroundColor", { maxLength: 7 }) ?? null }),
...(value.backgroundDarkened === undefined
? {}
: { backgroundDarkened: optionalBoolean(value, "backgroundDarkened") ?? null }),
...(value.blurPixels === undefined
? {}
: { blurPixels: optionalInteger(value, "blurPixels", { maximum: 120, minimum: 0 }) ?? null }),
...(value.foregroundShadow === undefined
? {}
: { foregroundShadow: optionalBoolean(value, "foregroundShadow") ?? null }),
};
}
function requireOpaqueId(body: Record<string, unknown>, field: string): string {
const value = body[field];
if (typeof value !== "string" || !PROVIDER_ID.test(value)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
}
return value;
}
export function registerTimelineRoutes(http: ModuleHttpRouteRegistrar): void {
http.route({
method: "GET",
path: "/timelines",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const timelines = await store(context).listTimelines();
return Response.json({
timelines: timelines.map((timeline) => ({
description: timeline.description,
eligibleFrom: timeline.eligibleFrom?.toISOString() ?? null,
eligibleUntil: timeline.eligibleUntil?.toISOString() ?? null,
id: timeline.id,
name: timeline.name,
revision: timeline.revision,
updatedAt: timeline.updatedAt.toISOString(),
})),
});
});
},
});
http.route({
method: "POST",
path: "/timelines",
permission: "signage.timelines.manage",
maxBodyBytes: 8192,
handler(request, context) {
return signageBoundary(async () => {
const body = await readJsonObject(request);
const detail = await createTimeline({
description: optionalString(body, "description", { maxLength: 500 }) ?? null,
eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null,
eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null,
name: requiredString(body, "name", {
maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength,
}),
store: store(context),
});
return Response.json(describe(detail), { status: 201 });
});
},
});
http.route({
method: "GET",
path: "/timelines/:timelineId",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
return Response.json(describe(await requireTimeline(store(context), timelineId)));
});
},
});
http.route({
method: "PATCH",
path: "/timelines/:timelineId",
permission: "signage.timelines.manage",
maxBodyBytes: 8192,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const body = await readJsonObject(request);
const detail = await updateTimeline({
changes: {
...(body.name === undefined
? {}
: {
name: requiredString(body, "name", {
maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength,
}),
}),
...(body.description === undefined
? {}
: { description: optionalString(body, "description", { maxLength: 500 }) ?? null }),
...(body.eligibleFrom === undefined
? {}
: { eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null }),
...(body.eligibleUntil === undefined
? {}
: { eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null }),
},
store: store(context),
timelineId,
});
return Response.json(describe(detail));
});
},
});
http.route({
method: "DELETE",
path: "/timelines/:timelineId",
permission: "signage.timelines.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
await deleteTimeline({ store: store(context), timelineId });
return new Response(null, { status: 204 });
});
},
});
http.route({
method: "POST",
path: "/timelines/:timelineId/duplicate",
permission: "signage.timelines.manage",
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const body = await readJsonObject(request);
const detail = await duplicateTimeline({
name: requiredString(body, "name", { maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength }),
store: store(context),
timelineId,
});
return Response.json(describe(detail), { status: 201 });
});
},
});
http.route({
method: "POST",
path: "/timelines/:timelineId/sources",
permission: "signage.timelines.manage",
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const body = await readJsonObject(request);
const detail = await addProviderSource({
label: requiredString(body, "label", { maxLength: 200 }),
providerId: requireOpaqueId(body, "providerId"),
sourceId: requireOpaqueId(body, "sourceId"),
store: store(context),
timelineId,
});
return Response.json(describe(detail), { status: 201 });
});
},
});
http.route({
method: "DELETE",
path: "/timelines/:timelineId/sources/:sourceId",
permission: "signage.timelines.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const sourceId = requireUuidParam(context, "sourceId");
return Response.json(
describe(await removeProviderSource({ sourceId, store: store(context), timelineId })),
);
});
},
});
http.route({
method: "POST",
path: "/timelines/:timelineId/slots",
permission: "signage.timelines.manage",
maxBodyBytes: 8192,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const body = await readJsonObject(request);
const sourceId = body.sourceId;
const detail = await addSlot({
durationOverrideMs: optionalInteger(body, "durationOverrideMs", {
maximum: SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs,
minimum: SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs,
}),
eligibleFrom: optionalInstant(body, "eligibleFrom"),
eligibleUntil: optionalInstant(body, "eligibleUntil"),
enabled: optionalBoolean(body, "enabled") ?? true,
...(sourceId === undefined
? {}
: {
source: {
sourceId: isUuid(sourceId)
? sourceId
: (() => {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "sourceId" });
})(),
},
}),
...(body.itemId === undefined
? {}
: {
pinned: {
itemId: requireOpaqueId(body, "itemId"),
providerId: requireOpaqueId(body, "providerId"),
},
}),
position: optionalInteger(body, "position", { maximum: 10_000, minimum: 0 }) ?? undefined,
presentation: readPresentation(body),
store: store(context),
timelineId,
});
return Response.json(describe(detail), { status: 201 });
});
},
});
http.route({
method: "PATCH",
path: "/timelines/:timelineId/slots/:slotId",
permission: "signage.timelines.manage",
maxBodyBytes: 8192,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const slotId = requireUuidParam(context, "slotId");
const body = await readJsonObject(request);
const presentation = readPresentation(body);
const detail = await updateSlot({
changes: {
...(body.enabled === undefined
? {}
: { enabled: optionalBoolean(body, "enabled") ?? true }),
...(body.durationOverrideMs === undefined
? {}
: {
durationOverrideMs: optionalInteger(body, "durationOverrideMs", {
maximum: SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs,
minimum: SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs,
}) as number | null,
}),
...(body.eligibleFrom === undefined
? {}
: { eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null }),
...(body.eligibleUntil === undefined
? {}
: { eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null }),
...(body.itemId === undefined ? {} : { itemId: requireOpaqueId(body, "itemId") }),
...(presentation === undefined ? {} : { presentation }),
},
slotId,
store: store(context),
timelineId,
});
return Response.json(describe(detail));
});
},
});
http.route({
method: "DELETE",
path: "/timelines/:timelineId/slots/:slotId",
permission: "signage.timelines.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const slotId = requireUuidParam(context, "slotId");
return Response.json(
describe(await removeSlot({ slotId, store: store(context), timelineId })),
);
});
},
});
http.route({
method: "PUT",
path: "/timelines/:timelineId/slot-order",
permission: "signage.timelines.manage",
maxBodyBytes: 16_384,
handler(request, context) {
return signageBoundary(async () => {
const timelineId = requireUuidParam(context, "timelineId");
const body = await readJsonObject(request);
const ids = body.slotIds;
if (
!Array.isArray(ids) ||
ids.length > SIGNAGE_TIMELINE_LIMITS.maxSlotsPerTimeline ||
!ids.every((id) => isUuid(id))
) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "slotIds" });
}
return Response.json(
describe(
await reorderSlots({
orderedSlotIds: ids as string[],
store: store(context),
timelineId,
}),
),
);
});
},
});
}
+259
View File
@@ -0,0 +1,259 @@
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
import { buildDisplayChannelState } from "../display/state.js";
import { SignageError, signageBoundary } from "../errors.js";
import {
optionalBoolean,
optionalInteger,
optionalString,
readJsonObject,
requireUuidParam,
} from "../http.js";
import type { SignagePlaybackService } from "../playback/service.js";
import type { SignageProviderRegistry } from "../providers/registry.js";
import { readSignageSettings } from "../settings.js";
import { createSignageStore } from "../store/drizzle.js";
import type { SignageStore, StoredChannel } from "../store/types.js";
import {
describeViewerHealth,
openViewerSession,
recordViewerHeartbeat,
SIGNAGE_VIEWER_NAME_MAX,
updateViewerAssignment,
} from "../viewers/service.js";
const OPAQUE_ID = /^[\w][\w.:-]{0,199}$/u;
function store(context: ModuleRouteHandlerContext): SignageStore {
return createSignageStore(context.services.database);
}
/**
* The safe Core device identifier, when the caller is a paired display.
*
* A device principal is deliberately **not** an authenticated person (ADR 0036), and Signage treats
* it as exactly what it is: an opaque identifier to correlate its own roster with Core's. Pairing,
* revocation, and every credential remain entirely Core's — nothing in Signage reads, stores, or
* forwards one.
*/
function deviceIdOf(context: ModuleRouteHandlerContext): string | null {
return context.principal.kind === "device" ? context.principal.deviceId : null;
}
async function requireChannel(
signageStore: SignageStore,
channelId: string,
): Promise<StoredChannel> {
const groups = await signageStore.listChannelGroups();
for (const group of groups) {
const channel = group.channels.find((candidate) => candidate.id === channelId);
if (channel) {
return channel;
}
}
throw new SignageError("SIGNAGE_NOT_FOUND");
}
export function registerViewerRoutes(
http: ModuleHttpRouteRegistrar,
playback: () => SignagePlaybackService,
registry: () => SignageProviderRegistry,
): void {
http.route({
method: "POST",
path: "/viewers/session",
permission: "signage.display.view",
maxBodyBytes: 2048,
handler(request, context) {
return signageBoundary(async () => {
const body = await readJsonObject(request);
const viewerKey = body.viewerKey;
if (typeof viewerKey !== "string") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "viewerKey" });
}
// A display registers with the identity it keeps in its own IndexedDB and lands on the
// default channel. It never chooses one: a public screen that could would let anyone who
// reaches the page decide what a wall shows (ADR 0004).
const session = await openViewerSession({
at: new Date(),
deviceId: deviceIdOf(context),
store: store(context),
viewerKey,
});
return Response.json({
channelId: session.channel.id,
channelKey: session.channel.key,
groupId: session.groupId,
heartbeatIntervalMs: 12_000,
viewerId: session.viewer.id,
viewerName: session.viewer.name,
});
});
},
});
http.route({
method: "POST",
path: "/viewers/:viewerId/heartbeat",
permission: "signage.display.view",
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const viewerId = requireUuidParam(context, "viewerId");
const body = await readJsonObject(request);
const settings = readSignageSettings(context.config);
const rawError = body.error;
// Telemetry only. Nothing a display reports here changes authoritative playback, and a
// local cursor is never accepted as truth.
const result = await recordViewerHeartbeat({
at: new Date(),
heartbeat: {
cachedAssetCount:
optionalInteger(body, "cachedAssetCount", {
maximum: 100_000,
minimum: 0,
}) ?? undefined,
offlineReady: optionalBoolean(body, "offlineReady") ?? undefined,
reportedContributionId:
optionalString(body, "reportedContributionId", { maxLength: 200 }) ?? null,
reportedPlaybackRevision:
optionalInteger(body, "reportedPlaybackRevision", {
maximum: Number.MAX_SAFE_INTEGER,
minimum: 0,
}) ?? null,
reportedProviderId:
optionalString(body, "reportedProviderId", { maxLength: 200 }) ?? null,
reportedSelectionId:
optionalString(body, "reportedSelectionId", { maxLength: 64 }) ?? null,
reportedTimelineId:
optionalString(body, "reportedTimelineId", { maxLength: 64 }) ?? null,
...(rawError === undefined
? {}
: {
error:
rawError === null
? null
: {
code: readErrorCode(rawError),
detail: readErrorDetail(rawError),
},
}),
},
minimumWriteIntervalMs: settings.viewerHeartbeatWriteMs,
store: store(context),
viewerId,
});
// The response says what a display needs to keep itself honest, and nothing about other
// displays or the installation.
return Response.json({ acknowledged: true, recorded: result.written });
});
},
});
http.route({
method: "GET",
path: "/channels/:channelId/display-state",
permission: "signage.display.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const channelId = requireUuidParam(context, "channelId");
const signageStore = store(context);
const channel = await requireChannel(signageStore, channelId);
const snapshot = await playback().snapshot(channel.groupId);
return Response.json(
await buildDisplayChannelState({
atMs: Date.now(),
channel,
playback: snapshot,
registry: registry(),
settings: readSignageSettings(context.config),
storage: context.storage,
store: signageStore,
}),
);
});
},
});
http.route({
method: "GET",
path: "/viewers",
permission: "signage.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const atMs = Date.now();
const viewers = await store(context).listViewers();
return Response.json({
viewers: viewers.map((viewer) => describeViewerHealth(viewer, atMs)),
});
});
},
});
http.route({
method: "PATCH",
path: "/viewers/:viewerId",
permission: "signage.displays.manage",
maxBodyBytes: 4096,
handler(request, context) {
return signageBoundary(async () => {
const viewerId = requireUuidParam(context, "viewerId");
const body = await readJsonObject(request);
const channelId = body.channelId;
if (channelId !== undefined && typeof channelId !== "string") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "channelId" });
}
const viewer = await updateViewerAssignment({
...(channelId === undefined ? {} : { channelId }),
name: optionalString(body, "name", { maxLength: SIGNAGE_VIEWER_NAME_MAX }),
store: store(context),
viewerId,
});
return Response.json({ viewer: describeViewerHealth(viewer, Date.now()) });
});
},
});
http.route({
method: "DELETE",
path: "/viewers/:viewerId",
permission: "signage.displays.manage",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const viewerId = requireUuidParam(context, "viewerId");
const signageStore = store(context);
if (!(await signageStore.findViewer(viewerId))) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
// Removing a screen from the roster is a record-keeping action. A display that checks in
// again is simply registered afresh, because its own identity is the one it keeps.
await signageStore.deleteViewer(viewerId);
return new Response(null, { status: 204 });
});
},
});
}
function readErrorCode(value: unknown): string {
if (typeof value !== "object" || value === null) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error" });
}
const code = (value as { code?: unknown }).code;
if (typeof code !== "string" || !OPAQUE_ID.test(code)) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.code" });
}
return code;
}
function readErrorDetail(value: unknown): string | null {
const detail = (value as { detail?: unknown }).detail;
if (detail === undefined || detail === null) {
return null;
}
if (typeof detail !== "string") {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.detail" });
}
return detail;
}
+267
View File
@@ -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<typeof createSignageStore> | 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<string, PlaybackSnapshot>();
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<typeof createSignageStore> {
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();
},
};
}
+52
View File
@@ -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,
});
}
+371
View File
@@ -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<Map<string, StoredSelectionVariant[]>> {
const grouped = new Map<string, StoredSelectionVariant[]>();
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<StoredSelection[]> {
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<StoredChannelGroupDetail | undefined> {
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<void> {
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<void> {
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<void> {
await database.delete(channelGroups).where(eq(channelGroups.id, groupId));
},
async findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined> {
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<StoredSelection | undefined> {
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<StoredSelection | undefined> {
const rows = await database.select().from(selections).where(eq(selections.id, selectionId));
return (await hydrateSelections(rows))[0];
},
async insertSelection(selection: NewStoredSelection): Promise<void> {
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<readonly StoredChannelGroupDetail[]> {
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<readonly StoredSelection[]> {
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<void> {
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<void> {
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<number> {
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<void> {
const values: Record<string, unknown> = { 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<void> {
await database.transaction(async (transaction) => {
const values: Record<string, unknown> = {};
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,
})),
);
}
}
});
},
};
}
+399
View File
@@ -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<void> {
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<void> {
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<string[]> {
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<string | undefined> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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<StoredTimelineDetail | undefined> {
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<readonly StoredTimeline[]> {
const rows = await database.select().from(timelines).orderBy(asc(timelines.name));
return rows.map(toTimeline);
},
async removeSlot(slotId: string): Promise<void> {
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<void> {
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<void> {
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<StoredTimelineUsage> {
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<void> {
await database.transaction(async (transaction) => {
const timelineId = await timelineIdForSlot(transaction, slotId);
if (timelineId === undefined) {
return;
}
const values: Record<string, unknown> = { 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<void> {
const values: Record<string, unknown> = {
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));
},
};
}
+99
View File
@@ -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<void> {
await database.delete(viewers).where(eq(viewers.id, viewerId));
},
async findViewer(viewerId: string): Promise<StoredViewer | undefined> {
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<StoredViewer | undefined> {
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<void> {
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<readonly StoredViewer[]> {
const rows = await database.select().from(viewers).orderBy(asc(viewers.name));
return rows.map(toViewer);
},
async updateViewer(viewerId: string, changes: ViewerChanges): Promise<void> {
const values: Record<string, unknown> = { 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));
},
};
}
+269
View File
@@ -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<Map<string, StoredImageRendition[]>> {
const grouped = new Map<string, StoredImageRendition[]>();
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<StoredImage[]> {
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<StoredImageReferences> {
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<void> {
// 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<StoredImage | undefined> {
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<StoredImage | undefined> {
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<void> {
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<readonly StoredImage[]> {
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<void> {
const values: Record<string, unknown> = { 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),
};
}
+447
View File
@@ -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<StoredImage, "createdAt" | "status"> {
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<readonly StoredImage[]>;
findImage(imageId: string): Promise<StoredImage | undefined>;
/** 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<StoredImage | undefined>;
/**
* 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<void>;
/** 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<void>;
deleteImage(imageId: string): Promise<void>;
countImageReferences(imageId: string): Promise<StoredImageReferences>;
}
/** 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<StoredTimeline, "createdAt" | "revision" | "updatedAt">;
export type NewStoredProviderSource = Omit<StoredProviderSource, "createdAt">;
/** 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<StoredSlot, "position">;
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<StoredSlotPresentation>;
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<readonly StoredTimeline[]>;
findTimeline(timelineId: string): Promise<StoredTimelineDetail | undefined>;
/**
* 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<void>;
/** 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<void>;
deleteTimeline(timelineId: string): Promise<void>;
timelineUsage(timelineId: string): Promise<StoredTimelineUsage>;
addSource(source: NewStoredProviderSource): Promise<void>;
/** Removes the source and every slot that referenced it, then renumbers what remains. */
removeSource(sourceId: string): Promise<void>;
/** Appends a slot at the end, or inserts it at `position` and shifts the rest. */
addSlot(slot: NewStoredSlot, position?: number): Promise<void>;
updateSlot(slotId: string, changes: SlotChanges): Promise<void>;
/** Removes the slot and renumbers the remainder so positions stay contiguous. */
removeSlot(slotId: string): Promise<void>;
/**
* 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<void>;
}
/** 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<StoredPlaybackState, "groupId" | "revision" | "updatedAt">
>;
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<StoredSelection, "resolvedAt" | "revision">;
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<readonly StoredChannelGroupDetail[]>;
findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined>;
/** 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<void>;
deleteChannelGroup(groupId: string): Promise<void>;
addChannel(channel: StoredChannel): Promise<void>;
/** Refuses to remove a group's default channel: something must remain for a viewer to join. */
removeChannel(channelId: string): Promise<void>;
/** Applies only the fields present, and bumps the group's playback revision. */
updatePlayback(
groupId: string,
changes: PlaybackChanges,
options?: { readonly bumpRevision?: boolean },
): Promise<void>;
/** Takes the next sequence number for this group and increments it, atomically. */
takeSelectionSequence(groupId: string): Promise<number>;
insertSelection(selection: NewStoredSelection): Promise<void>;
findSelection(selectionId: string): Promise<StoredSelection | undefined>;
updateSelection(selectionId: string, changes: SelectionChanges): Promise<void>;
/** Most recent first, bounded. This is the history `previous` walks. */
listSelections(groupId: string, limit: number): Promise<readonly StoredSelection[]>;
/** 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<StoredSelection | undefined>;
/** Keeps the newest `limit` selections for a group and removes the rest. */
pruneSelections(groupId: string, limit: number): Promise<void>;
}
/** 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<StoredViewer, "createdAt" | "lastSeenAt" | "lastError">;
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<readonly StoredViewer[]>;
findViewer(viewerId: string): Promise<StoredViewer | undefined>;
findViewerByKey(viewerKey: string): Promise<StoredViewer | undefined>;
insertViewer(viewer: NewStoredViewer): Promise<void>;
updateViewer(viewerId: string, changes: ViewerChanges): Promise<void>;
deleteViewer(viewerId: string): Promise<void>;
}
/** The complete port. It grows one section at a time as Signage's behaviour does. */
export type SignageStore = SignageImageStore &
SignagePlaybackStore &
SignageTimelineStore &
SignageViewerStore;
+429
View File
@@ -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<StoredSlotPresentation> | 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<StoredTimelineDetail> {
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<StoredTimelineDetail> {
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<StoredTimelineDetail> {
const original = await requireTimeline(input.store, input.timelineId);
const newId = input.newId ?? randomUUID;
const timelineId = newId();
const sourceIds = new Map<string, string>();
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<StoredTimelineDetail> {
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<void> {
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<StoredTimelineDetail> {
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<StoredTimelineDetail> {
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<StoredSlotPresentation> | 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<StoredTimelineDetail> {
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<StoredTimelineDetail> {
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<StoredTimelineDetail> {
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<StoredTimelineDetail> {
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);
}
+86
View File
@@ -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<readonly SignageChannelSummary[]>;
listViewers(): Promise<readonly ViewerHealth[]>;
/** 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<ViewerHealth>;
renameViewer(viewerId: string, name: string): Promise<ViewerHealth>;
}
export function createDisplaysCapability(
store: () => SignageStore,
now: () => Date,
): SignageDisplaysCapability {
async function health(
viewerId: string,
name?: string,
channelId?: string,
): Promise<ViewerHealth> {
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<ViewerHealth> {
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<readonly SignageChannelSummary[]> {
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<readonly ViewerHealth[]> {
const atMs = now().getTime();
const viewers = await store().listViewers();
return viewers.map((viewer) => describeViewerHealth(viewer, atMs));
},
renameViewer(viewerId: string, name: string): Promise<ViewerHealth> {
return health(viewerId, name, undefined);
},
};
return Object.freeze(capability);
}
+324
View File
@@ -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<ViewerSession> {
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<ViewerSession> {
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<Value>(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<StoredViewer> {
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;
}
+98
View File
@@ -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,
};
}
+306
View File
@@ -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 };
}
+100
View File
@@ -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
);
}
+69
View File
@@ -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,
});
+142
View File
@@ -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<SettingsResponse>("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<string | undefined>(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 (
<EmptyState
description="Ask an administrator for the signage.view permission."
title="No access to Signage"
/>
);
}
const selectedGroupId =
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined);
return (
<>
<EkklesiaUiStyles />
<DesktopOnly>
{renderLoadable(
settings.state,
(loaded) =>
loaded === undefined ? undefined : (
<Tabs
label="Signage sections"
onSelect={setTab}
selectedId={tab}
tabs={[
{
content: (
<PlaybackPanel
api={api}
canControl={mayControl}
context={context}
groupId={selectedGroupId}
onGroupChange={setGroupId}
settings={loaded}
/>
),
id: "playback",
label: "Playback",
},
{
content: (
<TimelinesPanel
api={api}
canManage={mayManageTimelines}
onActivate={
mayControl && selectedGroupId !== undefined
? (timelineId) => {
void api
.send("POST", `playback/${selectedGroupId}/activate`, {
timelineId,
when: "now",
})
.then(() => setTab("playback"))
.catch(() => setTab("playback"));
}
: undefined
}
settings={loaded}
/>
),
id: "timelines",
label: "Timelines",
},
{
content: (
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
),
id: "images",
label: "Images",
},
{
content: <DisplaysPanel api={api} canManage={mayManageDisplays} />,
id: "displays",
label: "Displays",
},
{
content: <ProvidersPanel api={api} context={context} settings={loaded} />,
id: "providers",
label: "Providers",
},
]}
/>
),
{ label: "Loading Signage…", onRetry: settings.reload },
)}
</DesktopOnly>
</>
);
}
+107
View File
@@ -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<Record<string, number | string | null>> | 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<SignageApiFailure> {
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<Result>(path: string): Promise<Result>;
send<Result>(
method: "POST" | "PATCH" | "PUT" | "DELETE",
path: string,
body?: unknown,
): Promise<Result>;
/** One image, one request. The queue that drives this lives in `browser/uploads.ts`. */
upload<Result>(path: string, bytes: Blob, contentType: string): Promise<Result>;
}
export function createSignageApi(client: ModuleUiApiClient): SignageApi {
async function request<Result>(path: string, init: RequestInit | undefined): Promise<Result> {
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" }),
};
}
+122
View File
@@ -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<Value> =
| { readonly status: "loading" }
| { readonly status: "ready"; readonly value: Value }
| { readonly status: "failed"; readonly failure: SignageApiFailure };
export interface Resource<Value> {
readonly state: Loadable<Value>;
/** 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<Value>(
load: () => Promise<Value>,
dependencies: readonly unknown[],
): Resource<Value> {
const [state, setState] = useState<Loadable<Value>>({ 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<unknown>): void;
clearFailure(): void;
}
export function useAction(onSettled?: () => void): Action {
const [pending, setPending] = useState(false);
const [failure, setFailure] = useState<SignageApiFailure | undefined>(undefined);
const settled = useRef(onSettled);
settled.current = onSettled;
const run = useCallback((work: () => Promise<unknown>) => {
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;
}
+25
View File
@@ -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(<SignageDashboard context={context} />);
return () => root.unmount();
},
});
+288
View File
@@ -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 (
<div className="signage-columns">
<Panel
actions={
canManage ? (
<div className="signage-inline-form">
<Field label="New channel group">
{(control) => (
<TextInput
{...control}
onChange={(event) => setGroupName(event.target.value)}
placeholder="Nursery hallway"
value={groupName}
/>
)}
</Field>
<Button
disabled={edit.pending || groupName.trim().length === 0}
onClick={() =>
edit.run(async () => {
await api.send("POST", "channel-groups", { name: groupName.trim() });
setGroupName("");
})
}
variant="primary"
>
Create
</Button>
</div>
) : undefined
}
description="A group is one clock. An audience channel is one rendering of it."
title="Channel groups"
>
{renderLoadable(
groups.state,
(value) => (
<div className="signage-groups" data-testid="signage-groups">
{value.groups.map((group) => (
<div className="signage-group" key={group.id}>
<div className="signage-group__header">
<h4 className="signage-editor__subtitle">{group.name}</h4>
<Badge tone="info">{group.playback.transport}</Badge>
</div>
<ul className="ek-list">
{group.channels.map((channel) => (
<li className="ek-list__item signage-row" key={channel.id}>
<span className="signage-row__name">{channel.name}</span>
<Badge tone={channel.isDefault ? "success" : "neutral"}>
{channel.key}
</Badge>
{canManage && !channel.isDefault ? (
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() => api.send("DELETE", `channels/${channel.id}`))
}
variant="danger"
>
Remove
</Button>
) : undefined}
</li>
))}
</ul>
{canManage ? (
<div className="signage-inline-form" data-testid="signage-add-channel">
<Field hint="Lowercase, e.g. online" label="Audience key">
{(control) => (
<TextInput
{...control}
onChange={(event) =>
setChannelDraft({
groupId: group.id,
key: event.target.value,
name: channelDraft.name,
})
}
value={channelDraft.groupId === group.id ? channelDraft.key : ""}
/>
)}
</Field>
<Field label="Channel name">
{(control) => (
<TextInput
{...control}
onChange={(event) =>
setChannelDraft({
groupId: group.id,
key: channelDraft.key,
name: event.target.value,
})
}
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
/>
)}
</Field>
<Button
disabled={
edit.pending ||
channelDraft.groupId !== group.id ||
channelDraft.key.trim() === "" ||
channelDraft.name.trim() === ""
}
onClick={() =>
edit.run(async () => {
await api.send("POST", `channel-groups/${group.id}/channels`, {
key: channelDraft.key.trim(),
name: channelDraft.name.trim(),
});
setChannelDraft({ groupId: "", key: "", name: "" });
})
}
>
Add audience channel
</Button>
</div>
) : undefined}
</div>
))}
</div>
),
{ label: "Loading channel groups…", onRetry: groups.reload },
)}
</Panel>
<Panel
description="Every screen Signage has seen, and what it last reported."
title="Displays"
>
{renderLoadable(
viewers.state,
(value) =>
value.viewers.length === 0 ? (
<EmptyState
description="Open a Signage display on a screen and it will register itself here."
title="No displays yet"
/>
) : (
<Table label="Display health">
<TableHead>
<TableRow>
<TableCell header>Name</TableCell>
<TableCell header>State</TableCell>
<TableCell header>Channel</TableCell>
<TableCell header>Last seen</TableCell>
<TableCell header>Offline ready</TableCell>
<TableCell header>Most recent error</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.viewers.map((viewer) => (
<TableRow key={viewer.id}>
<TableCell>
{canManage ? (
<TextInput
aria-label={`Name for ${viewer.name}`}
defaultValue={viewer.name}
onBlur={(event) =>
event.target.value !== viewer.name &&
edit.run(() =>
api.send("PATCH", `viewers/${viewer.id}`, {
name: event.target.value,
}),
)
}
/>
) : (
viewer.name
)}
</TableCell>
<TableCell>
<OnlineBadge online={viewer.online} />
</TableCell>
<TableCell>
{canManage ? (
<Select
aria-label={`Channel for ${viewer.name}`}
onChange={(event) =>
edit.run(() =>
api.send("PATCH", `viewers/${viewer.id}`, {
channelId: event.target.value,
}),
)
}
value={viewer.channelId ?? ""}
>
{channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{`${channel.groupName} · ${channel.name}`}
</option>
))}
</Select>
) : (
(channels.find((channel) => channel.id === viewer.channelId)?.name ?? "—")
)}
</TableCell>
<TableCell>{formatSince(viewer.lastSeenAtMs, nowMs)}</TableCell>
<TableCell>
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
{viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not ready"}
</Badge>
</TableCell>
<TableCell>
{viewer.lastError === null ? (
"—"
) : (
<span title={viewer.lastError.detail ?? undefined}>
{viewer.lastError.code}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
{ label: "Loading displays…", onRetry: viewers.reload },
)}
<FailureNote failure={edit.failure} />
</Panel>
</div>
);
}
+226
View File
@@ -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<readonly UploadItem[]>([]);
const queueRef = useRef<UploadQueue | undefined>(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<HTMLInputElement>) => {
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 (
<Panel
actions={
canManage ? (
<label className="signage-upload-trigger">
<span className="ek-button ek-button--primary">Add images</span>
<input
accept="image/png,image/jpeg,image/webp"
className="signage-upload-input"
data-testid="signage-upload-input"
multiple
onChange={onFiles}
type="file"
/>
</label>
) : 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 : (
<div className="signage-uploads" data-testid="signage-uploads">
<ul className="ek-list">
{uploads.map((item) => (
<li className="ek-list__item signage-row" key={item.id}>
<span className="signage-row__name">{item.name}</span>
<Badge
tone={
item.status === "done"
? "success"
: item.status === "failed" || item.status === "rejected"
? "danger"
: "info"
}
>
{item.status}
</Badge>
{item.message === undefined ? undefined : (
<span className="signage-uploads__message">{item.message}</span>
)}
</li>
))}
</ul>
{finished.length === 0 ? undefined : (
<Button onClick={() => queue.clearFinished()}>Clear finished</Button>
)}
</div>
)}
{renderLoadable(
images.state,
(value) =>
value.images.length === 0 ? (
<EmptyState
description="Add a PNG, JPEG, or WebP and it will be available to every timeline."
title="No images yet"
/>
) : (
<ul className="signage-image-grid" data-testid="signage-image-grid">
{value.images.map((image) => {
const thumbnail = image.renditions.find(
(rendition) => rendition.kind === "thumbnail",
);
return (
<li className="signage-image" key={image.id}>
{thumbnail === undefined ? (
<div className="signage-image__missing">No preview</div>
) : (
<img
alt={image.displayName}
className="signage-image__thumbnail"
height={thumbnail.height}
src={thumbnail.url}
width={thumbnail.width}
/>
)}
<div className="signage-image__meta">
{renaming?.id === image.id ? (
<Field label="Name">
{(control) => (
<TextInput
{...control}
onChange={(event) =>
setRenaming({ id: image.id, value: event.target.value })
}
value={renaming.value}
/>
)}
</Field>
) : (
<p className="signage-image__name">{image.displayName}</p>
)}
<p className="signage-image__detail">
{`${image.width}×${image.height} · ${formatInstant(image.createdAt, settings.timezone)}`}
</p>
{image.expiresAt === null ? undefined : (
<Badge className="signage-image__expiry" tone="warning">
{`Expires ${formatInstant(image.expiresAt, settings.timezone)}`}
</Badge>
)}
</div>
{canManage ? (
<div className="signage-image__actions">
{renaming?.id === image.id ? (
<>
<Button
disabled={rename.pending}
onClick={() =>
rename.run(() =>
api.send("PATCH", `images/${image.id}`, {
displayName: renaming.value,
}),
)
}
variant="primary"
>
Save
</Button>
<Button onClick={() => setRenaming(undefined)}>Cancel</Button>
</>
) : (
<Button
onClick={() => setRenaming({ id: image.id, value: image.displayName })}
>
Rename
</Button>
)}
<Button
disabled={remove.pending}
onClick={() => remove.run(() => api.send("DELETE", `images/${image.id}`))}
variant="danger"
>
Delete
</Button>
</div>
) : undefined}
</li>
);
})}
</ul>
),
{ label: "Loading images…", onRetry: images.reload },
)}
<FailureNote failure={rename.failure ?? remove.failure} />
</Panel>
);
}
+274
View File
@@ -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<PlaybackDetailView>(`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 (
<div className="signage-columns">
<Panel
actions={renderLoadable(
groups.state,
(value) => (
<Field label="Channel group">
{(control) => (
<Select
{...control}
onChange={(event) => onGroupChange(event.target.value)}
value={groupId ?? ""}
>
{value.groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</Select>
)}
</Field>
),
{ label: "Loading groups…" },
)}
description="One clock for every channel in the group."
title="Playback"
>
{groupId === undefined ? (
<EmptyState title="No channel group selected" />
) : (
renderLoadable(
detail.state,
(value) =>
value === undefined ? (
<EmptyState title="No channel group selected" />
) : (
<div className="signage-playback" data-testid="signage-playback">
<div className="signage-playback__status">
<Badge
tone={
value.snapshot.transport === "playing"
? "success"
: value.snapshot.transport === "idle"
? "warning"
: "info"
}
>
{value.snapshot.transport}
</Badge>
{value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined}
{value.current?.stale === true ? (
<Badge tone="warning">Stale</Badge>
) : undefined}
{value.current?.withdrawn === true ? (
<Badge tone="warning">Withdrawn</Badge>
) : undefined}
{value.snapshot.pendingTimelineId === null ? undefined : (
<Badge tone="info">Activation queued</Badge>
)}
</div>
{value.current === null ? (
<EmptyState
description="Nothing is currently resolvable. A display shows its safe slate."
title="Nothing on screen"
/>
) : (
<dl className="signage-playback__current" data-testid="signage-current">
<dt>Provider</dt>
<dd>{value.current.providerId}</dd>
<dt>Item</dt>
<dd>{value.current.itemId}</dd>
<dt>Renderer</dt>
<dd>{value.current.rendererContributionId ?? "—"}</dd>
<dt>Duration</dt>
<dd>{formatDuration(value.current.durationMs)}</dd>
<dt>Remaining</dt>
<dd data-testid="signage-remaining">
{value.snapshot.remainingMs === null
? "—"
: formatDuration(value.snapshot.remainingMs)}
</dd>
<dt>Started</dt>
<dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd>
</dl>
)}
{canControl ? (
<div className="signage-playback__controls" data-testid="signage-controls">
<Button disabled={command.pending} onClick={() => run("previous")}>
Previous
</Button>
{value.snapshot.transport === "paused" ? (
<Button
disabled={command.pending}
onClick={() => run("resume")}
variant="primary"
>
Resume
</Button>
) : (
<Button disabled={command.pending} onClick={() => run("pause")}>
Pause
</Button>
)}
<Button disabled={command.pending} onClick={() => run("next")}>
Next
</Button>
<Button
disabled={command.pending}
onClick={() => run("blackout", { on: !value.snapshot.blackout })}
variant={value.snapshot.blackout ? "primary" : "secondary"}
>
{value.snapshot.blackout ? "End blackout" : "Blackout"}
</Button>
{value.snapshot.staticAssetId === null ? undefined : (
<Button
disabled={command.pending}
onClick={() => run("static", { assetId: null })}
>
Release hold
</Button>
)}
<Button disabled={command.pending} onClick={() => run("reload")}>
Reload content
</Button>
</div>
) : undefined}
{canControl ? (
<div className="signage-playback__activate">
{renderLoadable(
timelines.state,
(loaded) => (
<>
<Field label="Activate timeline">
{(control) => (
<Select
{...control}
onChange={(event) => setActivateId(event.target.value)}
value={activateId}
>
<option value="">Choose a timeline…</option>
{loaded.timelines.map((timeline) => (
<option key={timeline.id} value={timeline.id}>
{timeline.name}
</option>
))}
</Select>
)}
</Field>
<Button
disabled={command.pending || activateId === ""}
onClick={() =>
run("activate", { timelineId: activateId, when: "now" })
}
variant="primary"
>
Now
</Button>
<Button
disabled={command.pending || activateId === ""}
onClick={() =>
run("activate", { timelineId: activateId, when: "next-boundary" })
}
>
At next slot
</Button>
</>
),
{ label: "Loading timelines…" },
)}
</div>
) : undefined}
{value.history.length === 0 ? undefined : (
<div className="signage-playback__history">
<h4 className="signage-editor__subtitle">Recently played</h4>
<ul className="ek-list">
{value.history.slice(0, 8).map((entry) => (
<li className="ek-list__item signage-row" key={entry.id}>
<span className="signage-row__name">{entry.itemId}</span>
<span className="signage-playback__history-meta">
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
</span>
</li>
))}
</ul>
</div>
)}
</div>
),
{ label: "Loading playback…", onRetry: detail.reload },
)
)}
<FailureNote failure={command.failure} />
</Panel>
<PreviewPanel api={api} channelId={previewChannelId} context={context} />
</div>
);
}
+92
View File
@@ -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<HTMLDivElement | null>(null);
useEffect(() => {
const root = stageRef.current;
if (!root || channelId === undefined) {
return;
}
let stopped = false;
const controller = createDisplayController({ context, root, telemetry: false });
let handle: ReturnType<typeof setTimeout> | undefined;
async function pump(): Promise<void> {
if (stopped) {
return;
}
try {
const state = await api.get<DisplayChannelStateView>(`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 (
<Panel
description="Exactly what a display on this channel is showing, rendered by the display's own code."
title="Live preview"
>
{channelId === undefined ? (
<EmptyState title="No channel to preview" />
) : (
<div className="signage-preview">
<div className="signage-preview__frame">
<div className="signage-stage" data-testid="signage-preview-stage" ref={stageRef}>
<div className="signage-slate-host">
<SignageSlate />
</div>
<div aria-hidden="true" className="signage-blackout" />
</div>
</div>
</div>
)}
</Panel>
);
}
+116
View File
@@ -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 (
<Panel
description={`Renderer input contract ${settings.rendererInputVersion}. Only providers that are actually running appear here.`}
title="Content providers"
>
{renderLoadable(
providers.state,
(value) =>
value.providers.length === 0 ? (
<EmptyState
description="Signage works on its own with native images. Install a content module to add more."
title="No content providers registered"
/>
) : (
<Table label="Provider health">
<TableHead>
<TableRow>
<TableCell header>Provider</TableCell>
<TableCell header>Contract</TableCell>
<TableCell header>Renderer</TableCell>
<TableCell header>Health</TableCell>
<TableCell header>Last success</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.providers.map((provider) => {
const rendererPublished = published.has(provider.renderer.contributionId);
const isNative = provider.renderer.contributionId === "signage.native-image";
return (
<TableRow key={provider.providerId}>
<TableCell>
<span className="signage-provider__id">{provider.providerId}</span>
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
</TableCell>
<TableCell>{provider.contractVersion}</TableCell>
<TableCell>
{isNative ? (
<Badge tone="success">Signage renders this</Badge>
) : rendererPublished ? (
<Badge tone="success">{provider.renderer.contributionId}</Badge>
) : (
<Badge tone="warning">Renderer not published</Badge>
)}
</TableCell>
<TableCell>
{provider.consecutiveFailures === 0 ? (
<Badge tone="success">Healthy</Badge>
) : (
<Badge tone="danger">
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
</Badge>
)}
</TableCell>
<TableCell>
{formatInstant(provider.lastSuccessAt, settings.timezone)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
),
{ label: "Loading providers…", onRetry: providers.reload },
)}
</Panel>
);
}
+476
View File
@@ -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<string | undefined>(undefined);
const [newName, setNewName] = useState("");
const create = useAction(() => {
setNewName("");
timelines.reload();
});
return (
<div className="signage-columns">
<Panel
actions={
canManage ? (
<div className="signage-inline-form">
<Field label="New timeline">
{(control) => (
<TextInput
{...control}
onChange={(event) => setNewName(event.target.value)}
placeholder="Sunday morning"
value={newName}
/>
)}
</Field>
<Button
disabled={create.pending || newName.trim().length === 0}
onClick={() =>
create.run(() => api.send("POST", "timelines", { name: newName.trim() }))
}
variant="primary"
>
Create
</Button>
</div>
) : undefined
}
description="Saved patterns an operator builds on demand. Signage ships none."
title="Timelines"
>
{renderLoadable(
timelines.state,
(value) =>
value.timelines.length === 0 ? (
<EmptyState
description="Create one and add slots to it. There are no presets, on purpose."
title="No timelines yet"
/>
) : (
<Table label="Saved timelines">
<TableHead>
<TableRow>
<TableCell header>Name</TableCell>
<TableCell header>Eligible</TableCell>
<TableCell header>Revision</TableCell>
<TableCell header>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.timelines.map((timeline) => (
<TableRow key={timeline.id} selected={timeline.id === selectedId}>
<TableCell>
<Button onClick={() => setSelectedId(timeline.id)}>{timeline.name}</Button>
</TableCell>
<TableCell>
{timeline.eligibleFrom === null && timeline.eligibleUntil === null
? "Always"
: `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`}
</TableCell>
<TableCell>{timeline.revision}</TableCell>
<TableCell>
{onActivate === undefined ? undefined : (
<Button onClick={() => onActivate(timeline.id)} variant="primary">
Activate
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
{ label: "Loading timelines…", onRetry: timelines.reload },
)}
<FailureNote failure={create.failure} />
</Panel>
{selectedId === undefined ? undefined : (
<TimelineEditor
api={api}
canManage={canManage}
key={selectedId}
onChanged={timelines.reload}
onClose={() => setSelectedId(undefined)}
providers={providers.state.status === "ready" ? providers.state.value.providers : []}
settings={settings}
timelineId={selectedId}
/>
)}
</div>
);
}
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<TimelineDetail>(`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<readonly ProviderSourceDescriptor[]>([]);
const [items, setItems] = useState<readonly ProviderItemDescriptor[]>([]);
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 (
<Panel
actions={<Button onClick={onClose}>Close</Button>}
description="Slots run in order and repeat. Repeating one source is one rotation, not several."
title="Timeline pattern"
>
{renderLoadable(
detail.state,
(value) => (
<div className="signage-editor" data-testid="signage-timeline-editor">
<h3 className="signage-editor__title">{value.timeline.name}</h3>
{canManage ? (
<div className="signage-editor__add" data-testid="signage-add-source">
<Field
hint="A provider's own source. Repeat its slot to give it more turns in one rotation."
label="Content provider"
>
{(control) => (
<Select
{...control}
onChange={(event) => loadProvider(event.target.value)}
value={providerId}
>
<option value="">Choose a provider…</option>
{providers.map((provider) => (
<option key={provider.providerId} value={provider.providerId}>
{provider.providerId}
</option>
))}
</Select>
)}
</Field>
{sources.map((source) => (
<Button
disabled={edit.pending}
key={source.id}
onClick={() =>
edit.run(() =>
api.send("POST", `timelines/${timelineId}/sources`, {
label: source.label,
providerId,
sourceId: source.id,
}),
)
}
>
{`Add source: ${source.label}`}
</Button>
))}
{items.slice(0, 20).map((item) => (
<Button
disabled={edit.pending}
key={item.id}
onClick={() =>
edit.run(() =>
api.send("POST", `timelines/${timelineId}/slots`, {
itemId: item.id,
providerId,
}),
)
}
>
{`Pin item: ${item.label}`}
</Button>
))}
</div>
) : undefined}
{value.sources.length === 0 ? undefined : (
<div className="signage-editor__sources">
<h4 className="signage-editor__subtitle">Configured sources</h4>
{value.sources.map((source) => (
<div className="signage-editor__source" key={source.id}>
<span>{source.label}</span>
<Badge tone="info">{source.providerId}</Badge>
{canManage ? (
<>
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send("POST", `timelines/${timelineId}/slots`, {
sourceId: source.id,
}),
)
}
>
Add slot
</Button>
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send("DELETE", `timelines/${timelineId}/sources/${source.id}`),
)
}
variant="danger"
>
Remove
</Button>
</>
) : undefined}
</div>
))}
</div>
)}
{value.slots.length === 0 ? (
<EmptyState
description="Add a provider source or pin an image, then repeat it as often as the loop needs."
title="This timeline has no slots"
/>
) : (
<Table label="Timeline slots">
<TableHead>
<TableRow>
<TableCell header>#</TableCell>
<TableCell header>Content</TableCell>
<TableCell header>Duration</TableCell>
<TableCell header>Enabled</TableCell>
<TableCell header>Order</TableCell>
</TableRow>
</TableHead>
<TableBody>
{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 (
<TableRow key={slot.id}>
<TableCell>{slot.position + 1}</TableCell>
<TableCell>
{slot.kind === "provider-source"
? (source?.label ?? "Removed source")
: (image?.displayName ?? `${slot.providerId} · ${slot.itemId}`)}
</TableCell>
<TableCell>
{slot.durationOverrideMs === null
? `Provider default (${formatDuration(settings.nativeImageDurationMs)} for images)`
: formatDuration(slot.durationOverrideMs)}
</TableCell>
<TableCell>
<Checkbox
checked={slot.enabled}
disabled={!canManage || edit.pending}
label={slot.enabled ? "On" : "Off"}
onChange={(event) =>
edit.run(() =>
api.send("PATCH", `timelines/${timelineId}/slots/${slot.id}`, {
enabled: event.target.checked,
}),
)
}
/>
</TableCell>
<TableCell>
{canManage ? (
<div className="signage-editor__order">
<Button
disabled={index === 0 || edit.pending}
onClick={() =>
edit.run(() =>
api.send("PUT", `timelines/${timelineId}/slot-order`, {
slotIds: swap(
value.slots.map((candidate) => candidate.id),
index,
index - 1,
),
}),
)
}
>
Up
</Button>
<Button
disabled={index === value.slots.length - 1 || edit.pending}
onClick={() =>
edit.run(() =>
api.send("PUT", `timelines/${timelineId}/slot-order`, {
slotIds: swap(
value.slots.map((candidate) => candidate.id),
index,
index + 1,
),
}),
)
}
>
Down
</Button>
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send("DELETE", `timelines/${timelineId}/slots/${slot.id}`),
)
}
variant="danger"
>
Remove
</Button>
</div>
) : undefined}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
{canManage ? (
<div className="signage-editor__footer">
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send("POST", `timelines/${timelineId}/duplicate`, {
name: `${value.timeline.name} (copy)`,
}),
)
}
>
Duplicate
</Button>
<Button
disabled={edit.pending}
onClick={() =>
edit.run(async () => {
await api.send("DELETE", `timelines/${timelineId}`);
onClose();
})
}
variant="danger"
>
Delete timeline
</Button>
</div>
) : undefined}
</div>
),
{ label: "Loading timeline…", onRetry: detail.reload },
)}
<FailureNote failure={edit.failure} />
</Panel>
);
}
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;
}
+91
View File
@@ -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<Value>(
state: Loadable<Value>,
render: (value: Value) => ReactNode,
options: { readonly label: string; readonly onRetry?: () => void },
): ReactNode {
if (state.status === "loading") {
return <LoadingState label={options.label} />;
}
if (state.status === "failed") {
return (
<ErrorState
action={
options.onRetry === undefined ? undefined : (
<button
className="ek-button ek-button--secondary"
onClick={options.onRetry}
type="button"
>
Try again
</button>
)
}
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 (
<p className="signage-failure" role="alert">
{failure.message}
</p>
);
}
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 <Badge tone={online ? "success" : "danger"}>{online ? "Online" : "Offline"}</Badge>;
}
+522
View File
@@ -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;
}
+198
View File
@@ -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;
}
+269
View File
@@ -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<string[]> {
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<string> {
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<string, string>; devDependencies?: Record<string, string> };
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");
});
});
+366
View File
@@ -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<void> {
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<typeof createReadiness>) {
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();
});
});
});
+471
View File
@@ -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> = {}): 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<void> {
for (let index = 0; index < 10; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
function build(options: {
readonly host: ReturnType<typeof createFakeHost>;
readonly media?: ReturnType<typeof createMemoryMediaCache>;
readonly store?: ReturnType<typeof createMemoryStore>;
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<string, unknown>;
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();
});
});
+493
View File
@@ -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<void> {
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<void> {
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<void> {
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(<SignageDashboard context={host.context} />);
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(<SignageDashboard context={host.context} />);
// 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(<SignageDashboard context={host.context} />);
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(<SignageDashboard context={host.context} />);
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(<SignageDashboard context={host.context} />);
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(<SignageDashboard context={host.context} />);
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(<SignageDashboard context={host.context} />);
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(<SignageDashboard context={host.context} />);
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(<SignageDashboard context={host.context} />);
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);
});
});
+170
View File
@@ -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<void> {
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<void>)();
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<void>)();
});
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<void>)();
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);
}
});
});
+562
View File
@@ -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<SceneSelection> & { 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<string, unknown> = {}) {
return {
eligibleFromMs: null,
eligibleUntilMs: null,
enabled: true,
estimatedDurationMs: 5000,
id,
position,
...overrides,
};
}
function snapshot(overrides: Partial<OfflineSnapshot> = {}): 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<void> {
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();
});
});
+412
View File
@@ -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<void> {
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<void>((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();
});
});
+122
View File
@@ -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<Response>;
}
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> | 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 },
});
+130
View File
@@ -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 (
<section className="fixture-trivia" data-testid="fixture-trivia">
<h1 data-testid="fixture-trivia-question">{question}</h1>
{revealed ? <p data-testid="fixture-trivia-answer">{answer}</p> : undefined}
<p data-testid="fixture-trivia-audience">{input.audienceKey}</p>
<p data-testid="fixture-trivia-remaining">{`${remainingSeconds}s`}</p>
</section>
);
}
/** 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<void>;
ready(): void;
}
export function createReadiness(): RendererReadiness {
let settle: (() => void) | undefined;
const whenReady = new Promise<void>((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(<TriviaSlide input={context.input as unknown as SignageSlideRendererInput} />);
// 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(<TriviaSlide input={input as unknown as SignageSlideRendererInput} />);
},
};
},
});
return { counters, definition };
}
+502
View File
@@ -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<unknown>, code: string): Promise<SignageError> {
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<Uint8Array>({
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<Uint8Array> {
bodyAccesses += 1;
return new ReadableStream<Uint8Array>({
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",
);
});
});
+290
View File
@@ -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");
});
});
+622
View File
@@ -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<void> {
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<StoredChannelGroupDetail> {
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<StoredTimelineDetail> {
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<string | null> {
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);
});
});
+516
View File
@@ -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<string, unknown> = {}) {
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<string, unknown> = {};
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<string> {
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<string, unknown>;
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);
});
});
+139
View File
@@ -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<Record<string, PgTable>>;
async function readSql(): Promise<string> {
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",
);
});
});
+130
View File
@@ -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<string, { bytes: Uint8Array; contentType?: string }>;
/** 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<string, { bytes: Uint8Array; contentType?: string }>();
const failingPuts = new Set<string>();
const readUrlsIssued: string[] = [];
function makeBucket(id: string): ModuleStorageBucket {
return {
createReadUrl(key: string): Promise<ModuleStorageReadUrl> {
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<void> {
objects.delete(`${id}/${key}`);
return Promise.resolve();
},
get(key: string): Promise<ModuleStorageObject> {
const stored = objects.get(`${id}/${key}`);
if (!stored) {
return Promise.reject(new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key));
}
return Promise.resolve({
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(stored.bytes);
controller.close();
},
}),
contentLength: stored.bytes.byteLength,
key,
...(stored.contentType === undefined ? {} : { contentType: stored.contentType }),
});
},
head(key: string): Promise<ModuleStorageObjectMetadata> {
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<ModuleStorageListing> {
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<ModuleStorageObjectMetadata> {
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,
};
}
+294
View File
@@ -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<string, SignageJsonValue>;
/** Payload for one specific audience, when this item has one. */
readonly audiencePayloads?: Readonly<Record<string, Record<string, SignageJsonValue>>>;
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<string, SignageJsonValue>): void;
}
export function createFixtureProvider(options: FixtureProviderOptions): FixtureProvider {
let items = [...options.items];
const revisions = new Map<string, number>();
const withdrawn = new Set<string>();
/** 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<string, Set<string>>();
const cursors = new Map<string, number>();
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<string>();
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<SignageProviderResolution | null> {
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<SignageProviderResolution | null> {
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<string, SignageJsonValue>): 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",
});
}
+80
View File
@@ -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<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
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 =
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>';
/** 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<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>";
/** A GIF89a header with one tiny frame. */
export const GIF_DOCUMENT_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
+249
View File
@@ -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<string, StoredChannelGroup>;
readonly channels: Map<string, StoredChannel>;
readonly playback: Map<string, StoredPlaybackState>;
readonly selections: Map<string, StoredSelection>;
}
export function createMemoryPlaybackStore(
options: { readonly now?: () => Date } = {},
): MemoryPlaybackStore {
const now = options.now ?? (() => new Date());
const groups = new Map<string, StoredChannelGroup>();
const channels = new Map<string, StoredChannel>();
const playback = new Map<string, StoredPlaybackState>();
const selections = new Map<string, StoredSelection>();
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<void> {
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<void> {
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<void> {
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<StoredChannelGroupDetail | undefined> {
return Promise.resolve(detail(groupId));
},
findLatestSelectionForScope(
groupId: string,
selectionScope: string,
): Promise<StoredSelection | undefined> {
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<StoredSelection | undefined> {
return Promise.resolve(selections.get(selectionId));
},
groups,
insertSelection(selection: NewStoredSelection): Promise<void> {
selections.set(selection.id, { ...selection, resolvedAt: now(), revision: 1 });
return Promise.resolve();
},
listChannelGroups(): Promise<readonly StoredChannelGroupDetail[]> {
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<readonly StoredSelection[]> {
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<void> {
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<void> {
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<number> {
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<void> {
const state = playback.get(groupId);
if (!state) {
return Promise.resolve();
}
const next: Record<string, unknown> = { ...state };
for (const key of Object.keys(changes)) {
next[key] = (changes as Record<string, unknown>)[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<void> {
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();
},
};
}
+120
View File
@@ -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<string, StoredImage>;
}
export function createMemorySignageStore(
options: { readonly now?: () => Date } = {},
): MemorySignageStore {
const now = options.now ?? (() => new Date());
const images = new Map<string, StoredImage>();
const references = new Map<string, StoredImageReferences>();
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<StoredImageReferences> {
return Promise.resolve(references.get(imageId) ?? { slots: 0, staticHolds: 0 });
},
deleteImage(imageId: string): Promise<void> {
images.delete(imageId);
references.delete(imageId);
return Promise.resolve();
},
findImage(imageId: string): Promise<StoredImage | undefined> {
return Promise.resolve(images.get(imageId));
},
findReadyImageByContentHash(contentSha256: string): Promise<StoredImage | undefined> {
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<void> {
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<readonly StoredImage[]> {
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<void> {
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();
},
};
}
+238
View File
@@ -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<string, StoredTimeline>;
readonly slots: Map<string, StoredSlot>;
readonly sources: Map<string, StoredProviderSource>;
}
export function createMemoryTimelineStore(
options: { readonly now?: () => Date } = {},
): MemoryTimelineStore {
const now = options.now ?? (() => new Date());
const timelines = new Map<string, StoredTimeline>();
const slots = new Map<string, StoredSlot>();
const sources = new Map<string, StoredProviderSource>();
const usage = new Map<string, StoredTimelineUsage>();
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<void> {
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<void> {
sources.set(source.id, { ...source, createdAt: now() });
return Promise.resolve();
},
createTimeline(input: {
readonly timeline: NewStoredTimeline;
readonly sources: readonly NewStoredProviderSource[];
readonly slots: readonly NewStoredSlot[];
}): Promise<void> {
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<void> {
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<StoredTimelineDetail | undefined> {
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<readonly StoredTimeline[]> {
return Promise.resolve(
[...timelines.values()].sort((left, right) => left.name.localeCompare(right.name)),
);
},
removeSlot(slotId: string): Promise<void> {
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<void> {
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<void> {
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<StoredTimelineUsage> {
return Promise.resolve(
usage.get(timelineId) ?? { activeGroups: 0, pendingGroups: 0, scheduledGroups: 0 },
);
},
timelines,
updateSlot(slotId: string, changes: SlotChanges): Promise<void> {
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<void> {
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();
},
};
}
+72
View File
@@ -0,0 +1,72 @@
import type {
NewStoredViewer,
SignageViewerStore,
StoredViewer,
ViewerChanges,
} from "../../src/server/store/types.js";
export interface MemoryViewerStore extends SignageViewerStore {
readonly viewers: Map<string, StoredViewer>;
/** 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<string, StoredViewer>();
const viewerWrites = { count: 0 };
return {
deleteViewer(viewerId: string): Promise<void> {
viewers.delete(viewerId);
return Promise.resolve();
},
findViewer(viewerId: string): Promise<StoredViewer | undefined> {
return Promise.resolve(viewers.get(viewerId));
},
findViewerByKey(viewerKey: string): Promise<StoredViewer | undefined> {
for (const viewer of viewers.values()) {
if (viewer.viewerKey === viewerKey) {
return Promise.resolve(viewer);
}
}
return Promise.resolve(undefined);
},
insertViewer(viewer: NewStoredViewer): Promise<void> {
viewerWrites.count += 1;
viewers.set(viewer.id, { ...viewer, createdAt: now(), lastError: null, lastSeenAt: null });
return Promise.resolve();
},
listViewers(): Promise<readonly StoredViewer[]> {
return Promise.resolve(
[...viewers.values()].sort((left, right) => left.name.localeCompare(right.name)),
);
},
updateViewer(viewerId: string, changes: ViewerChanges): Promise<void> {
const viewer = viewers.get(viewerId);
if (!viewer) {
return Promise.resolve();
}
viewerWrites.count += 1;
const next: Record<string, unknown> = { ...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,
};
}
+106
View File
@@ -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<CapabilityId, unknown>;
readonly routes: ModuleRouteDefinitionInput[];
}
export async function registerModule(
config: ModuleConfiguration = EMPTY_MODULE_CONFIGURATION,
): Promise<CapturedRegistration> {
const channels: { channel: string; permission: string }[] = [];
const provided = new Map<CapabilityId, unknown>();
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<Record<string, unknown>>): ModuleConfiguration {
const read = <Value>(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<boolean>(key, "boolean"),
getEnum: (key: string) => read<string>(key, "string"),
getInteger: (key: string) => read<number>(key, "number"),
getSecret: (key: string) => read<string>(key, "string"),
getString: (key: string) => read<string>(key, "string"),
getUrl: (key: string) => read<string>(key, "string"),
has: (key: string) => Object.hasOwn(values, key),
keys: () => Object.freeze(Object.keys(values)),
});
}
export function fakeHandlerContext(
overrides: Partial<ModuleRouteHandlerContext> = {},
): 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,
});
}
+486
View File
@@ -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<unknown>, code: string): Promise<SignageError> {
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<StoredTimelineDetail> {
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<StoredTimelineDetail> {
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",
);
});
});

Some files were not shown because too many files have changed in this diff Show More