Ctrl K
ComunidadGuías…

CHANGELOG.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Changelog

Notable changes per release, with the operator actions each one requires.
Versions follow the root `package.json`; a release is cut with
`npm run version:patch && git push --follow-tags`.

## 0.50.0

### Added — two-factor authentication (TOTP)

Users can now protect their account with an authenticator app under
_User settings → Security_: scan a QR code, confirm a code, and keep ten
single-use recovery codes that are shown once. Signing in then asks for a code
after the password. A recovery code works in its place — also for moving the
factor to a new phone when the old one is lost — and the user is emailed
whenever one is used. Ten wrong codes within 15 minutes pause further
attempts for that user.

MFA belongs to the user. It is **mandatory for every super-admin**, and for
every member of an organisation that requires it, in all of their
organisations. Organisation admins switch the requirement on in the
organisation settings (only from an MFA-confirmed session), and members
without MFA are emailed. A user for whom it is mandatory but who has not set
it up can still sign in with the password, but reaches nothing except the
set-up wizard until it is done. The first-run setup continues straight into
that wizard.

An organisation admin can reset a member's second factor from the member list,
unless the member is a super-admin or also belongs to an organisation the admin
does not administer. A super-admin can reset anyone's from the user list.
A reset ends all of the user's sessions at once, emails them and is written to
the audit log of each of their organisations. Member and user lists show who
has MFA.

Setting up, disabling or resetting MFA, changing or resetting a password and
deleting an account now also invalidate access tokens that were already
issued. Before, those tokens stayed valid until they expired.

See _Two-factor authentication (MFA)_ in `docs/configuration.md`, including
the database-only recovery for a sole super-admin who lost both the
authenticator and the recovery codes.

### Changed — no secret is stored in plaintext any more

Per-organisation SMTP passwords, ntfy tokens, TV Developer Mode passphrases and
the API key held for a pairing screen are now always encrypted with
`SECRETS_ENCRYPTION_KEY`, and PostgreSQL rejects plaintext in those columns.
On its first start the backend encrypts what older releases stored in
plaintext, before it serves a request. See _Secrets at rest_ in
`docs/configuration.md`.

**Operator action (breaking):**

1. Set `SECRETS_ENCRYPTION_KEY` (`openssl rand -base64 32`; with Ansible the
   vault variable `secrets_encryption_key`) **before** deploying. The backend
   and the playbook refuse to start without it. Back the key up apart from
   database dumps — the TOTP secrets of every user depend on it as well.
2. Stop the old backend before starting the new one; do not run both versions
   side by side across this upgrade. Migrations `0031_secrets_encrypted_only`
   and `0032_mfa_totp` run at boot.
3. Have an authenticator app ready: every super-admin is taken into the MFA
   set-up on their next request after the update, including sessions that
   were already open, and cannot use the dashboard until it is done.

## 0.42.0

### Changed — grouped side navigation

The side navigation now sorts its entries into groups: Content (media library,
playlists) and Playout (schedules, live streams, screens). Pages that are set
up once and rarely visited — screen groups, sources, site agents, audit log and
settings — sit in a Setup group that is folded away by default; each browser
remembers whether it is open, and it opens by itself while one of its pages is
showing. The narrow icon-only rail still lists every entry, separated by
dividers.

### Fixed

- The preview of a dynamic playlist no longer scrolls inside the scrolling
  page; its event list grows with its content.

## 0.41.0

### Added — a playlist of its own per genre group

A genre group of an event schedule rule can now play its own playlist instead
of the rule's. A dynamic one is narrowed to the group's genres, as the rule's
playlist is; a static one plays as it is. Groups without their own playlist
behave as before, and events no group matches still play the rule's playlist.
With every group on a playlist of its own, the rule's playlist may now be
static. A playlist a genre group plays cannot be deleted until the group stops
using it; the error names the rule.

## 0.40.0

### Added — site agent backup and restore

An agent's page can now export an encrypted backup of what the agent knows
about its displays — SSAP pairing keys, cached SSH keys, Developer Mode
passphrases, addresses and MACs — and import it on another agent, so replacing
the box at a venue no longer means pairing every TV again. The agent encrypts
and decrypts the file itself under a password it generates and shows once; the
server only relays sealed data.

The import is a four-step dialog (file, password, mapping, result). Existing
displays are completed, never overwritten. Displays the organisation no longer
knows can be mapped onto an existing display — whose settings and keys are then
replaced, with a warning — or skipped. Restored displays resume onboarding at the
final check. A running backup is shown on the agent page to every organisation
admin, and the agent's backup buttons stay disabled until it ends. See
_Replacing the box_ in `docs/site-agent.md`.

**Operator action:** deploy the server before updating the agents; an agent
older than the server ignores the new commands and the dialog times out.

## 0.30.0

### Added — genre groups for event schedule rules

A rule whose playlist is dynamic can list genre groups (e.g. "Rock, Metal",
then "Jazz"). For each event the first group sharing one of its genres wins
and the slot plays the playlist narrowed to that group's genres; an event no
group matches plays the playlist as it is. The narrowed copies are hidden
playlists the rule keeps in step with its base playlist: they never appear in
the playlist list, search or pickers. The calendar names the matched group on
each entry, and the entry's dialog says why.

### Added — schedule page rework

- Calendar blocks lead with the event or entry name, then playlist and genre
  group; status icons sit in the time row and a tooltip carries everything.
- Week and month use the full width (the day panel stays in the day view), the
  time grid scrolls on its own and opens at the first entry, and fallback gaps
  are a faint hatch explained once.
- A new **List** view shows upcoming entries grouped by day and keeps loading
  as you scroll. It can show **all screens and groups** at once and marks
  entries that overlap on a screen, saying which one plays.
- Automatic entries a manual one displaces no longer vanish: they appear as
  dashed ghosts (strip, chip, faded row) naming who holds the slot. Nothing
  plays them; when the holder goes, the rule schedules the slot again.
- Each entry can show what its playlist plays right now — the events a dynamic
  playlist selects with their genres, or a static playlist's items — from the
  automatic-entry dialog or "View content" in the edit form.

New endpoints: `GET /api/schedules/displaced`, `GET /api/schedules/:id/preview`.

**Operator action:** update the server; migration `0030_event_schedule_genre_variants`
runs on startup. Displaced slots show once each rule has reconciled after the
update (at the latest on the hourly tick).

## 0.26.7

### Added — audit log shows old → new values

Edits now record what changed and how: one line per changed field, such as
"Name: Foyer → Lobby", "Role: viewer → editor" or "Default playlist: – → Show".
This covers screens, playlists, schedule entries, screen groups, live streams,
content sources, event schedule rules (including their target screens and
groups), organisations, member roles, site agents and screen remote control.
Fields sent along unchanged are left out. Records named in a change show by
name, dates and storage sizes are formatted, and switches read yes/no.

The Developer Mode passphrase and a content source's parameters and style are
recorded only as "changed", never with their values; a stream URL is logged
without any credentials. Setting an organisation's default playlist is now
audited too, with who did it. The details no longer repeat the entry's own
resource (e.g. the stream next to its name).

Entries written before this release keep their earlier details.

**Operator action:** none beyond updating the server. There is no migration.

## 0.26.6

### Changed — readable audit log

The audit log shows names instead of ids: who acted (also super admins and
people who have left), the screen, playlist, content, group, stream, source,
rule or site agent concerned, and the records named in the details, such as
"Screen: Foyer Left · Playlist: Metal". This works for existing entries too.
A record that has since been deleted shows the name stored with the entry, or
its short id marked "deleted". Resource types and the remaining actions
(site agents, remote control) are translated, and the CSV export has readable
user, resource and details columns next to the ids.

Uploading, replacing and deleting content, changing playlists and schedule
entries, pairing and editing screens, and creating or editing organisations
now record who did it. Entries written before this release still show
"System" for these actions.

**Operator action:** none beyond updating the server. There is no migration.

## 0.26.5

### Added — invited users enter their name

Someone who was invited only chose a password, so they showed up with just
their email address. The link in the invitation email now opens "Activate
account", which asks for the name as well as the password. The link for
resetting a password is unchanged.

Invitations sent before this release still work, without the name field; the
name can be added later in the profile.

**Operator action:** none beyond updating the server. There is no migration.

## 0.26.4

### Fixed — pages showing another organisation

For users in more than one organisation, the playlists, media library,
screens, screen groups, live streams and user management pages, and the
live-stream and screen-group detail pages, ignored the organisation picked in
the top bar. They showed the first organisation in which the user was an org
admin, with that organisation's role. Every page now loads the selected
organisation, and switching reloads the page from scratch. A switch on a
detail page first returns to its list, since the open record belongs to the
previous organisation.

### Fixed — default playlist for everyone but super admins

The playlists page read the default playlist from an endpoint only super
admins may call. Everyone else saw no "Default" badge, and an org admin could
not clear the default from the UI because the toggle never showed it as set.
Every member can now read it; changing it is still for org admins only.

### Fixed — self-signup organisations in UTC

A self-signup organisation was always created in UTC, so its schedules and
event rules ran two hours off in Germany. Signup now uses the browser's time
zone. Organisation time zones are also validated now: an unknown zone is
rejected instead of breaking the first schedule calculation.

**Operator action:** organisations that signed up before this release are
still in UTC. As super admin, set their time zone once under Instance admin →
Organisations. There is no migration.

## 0.26.3

### Fixed — paired screens asking to be paired again

A paired screen could come up with the pairing dialog after being switched
off, although its credentials were still valid. Two cases did this:

- The screen started before its network, TLS or the server was ready. It now
  keeps its credentials and retries every minute instead of giving up.
- The screen was switched off right after renewing its session, before the TV
  had saved the new token. The server now recognises the old token and issues
  a new one, as long as the new token was never used. Such a recovery is
  logged as a warning ("Recovered a lost refresh rotation").

Screens that already show the pairing dialog because of the second case
recover by themselves after the update, unless they were paired again in the
meantime; restarting the app on the TV helps in the first case.

**Operator action:** none beyond updating the server. There is no migration.

## 0.26.2

### Fixed — calendar after switching organisation

Switching organisation in the top bar now reloads the schedules page with the
new organisation's screens, groups, playlists, entries and time zone. Before,
it kept showing the previous organisation until the page was reloaded.

### Fixed — video walls stuck waiting for slicing

A split group only switches to a new playlist once its slices are ready. When
the slice job could not be queued, for example while Redis restarted, the
group waited for good and saving the schedule entry reported an error although
it was saved. The entry now saves normally, and every five minutes the server
re-queues slice jobs that have been waiting for more than ten minutes without
a job in the queue.

**Operator action:** none beyond updating the server. There is no migration.

## 0.26.1

### Fixed — live stream dialog

The two buttons at the bottom of the "New live stream" dialog now share the
width evenly, and "Create" stays disabled until a name and a source URL are
filled in.

**Operator action:** none beyond updating the server. There is no migration.

## 0.26.0

### Added — event schedule rules

Event nights no longer have to be placed in the calendar by hand. On a
source's page an editor adds a rule: venues (canonical names), a playlist
(static or dynamic), a local time window such as 18:00–01:00 (an end at or
before the start means the next day), the screens and groups to play on,
whether to skip cancelled events, and a colour. On every event day at one of
those venues the server creates the schedule entry itself and keeps it in
line with the source: after every sync, after an alias change, after rule or
calendar edits, and once an hour. The preview lists the upcoming events with
their status per screen or group, and single events can be left out.

Manual entries always win. A slot that overlaps a manual entry, or the entry
of an earlier rule, is skipped and shown as a conflict; a manual entry created
later pushes the generated one out, and the slot returns once the manual entry
is gone. Generated entries are locked in the calendar: they cannot be moved,
resized, edited or deleted there, and link to their rule instead.

### Fixed — schedules in the organisation's time zone

The calendar laid entries out in UTC for everyone but the super admin, and
ignored the organisation picked in the shell. It now uses the selected
organisation and its time zone.

Recurring entries were expanded in UTC on the server: a daily 20:00 entry
created in winter played at 21:00 in summer, and weekday rules matched the UTC
weekday. They now repeat at the same local time in the organisation's time
zone, in the calendar and on the screens. When the clocks go back, a time in
the repeated hour means its second occurrence; a time in the hour skipped in
spring moves to just after the jump. A time between 01:00 and 01:59 on the
night summer time ends no longer resolves an hour late (this also affected
event start times in that hour). Changing an organisation's time zone moves
its recurring entries to the same local time in the new zone.

### Security — patched dependencies

The backend image ships multer 2.5.0 (GHSA-q7qp-rjg7-q26m), body-parser 2.4.0
(GHSA-27r3-jxmh-p828) and nodemailer 10.2.1 (GHSA-8c8m-p3m8-cjg8,
GHSA-m943-m63m-w62w, GHSA-85x2-q946-cp5h). `@nestjs/platform-express` pins
multer exactly, so it is lifted through an override.

**Operator action:** update the server. The backend migration `0029` adds
`event_schedule_rules`, `event_schedule_rule_targets`,
`event_schedule_rule_exclusions` and the `schedule_entries` columns
`managed_by_rule_id` and `managed_source_event_id`, and runs on start. No new
environment variable. Check recurring entries in organisations outside UTC:
entries that were moved by hand to compensate for the old drift now play at
their stored local time.

## 0.25.1

### Security — patched dependencies

The backend image ships follow-redirects 1.16.1 (pulled in by axios), which
closes GHSA-q3jh-wrc6-jgq8 and GHSA-8r9p-f939-6h3c, and nodemailer 10.1.2,
which bounds the work a single message can make the mailer do. The content
source client follows no redirect through axios, so the follow-redirects
advisories were not reachable here, but they failed the production audit.
vite (8.3.3) and the undici copy used by Nx (7.29.1) are build tooling and do
not reach the images; they are lifted through scoped overrides because
`@angular/build` and `nx` pin the vulnerable versions exactly. Angular CLI,
ESLint and the CI actions moved up by patch and minor versions.

**Operator action:** none beyond updating the server. There is no migration.

## 0.25.0

### Added — content sources and dynamic playlists

Event slides no longer have to be made by hand. Under **Sources** an editor
adds a website once (the first adapter covers concertbuero-franken.de), picks
the slide formats (1920×1080, 1080×1920, 3840×2160), the background (black
bars or blurred image) and an accent colour. The server scrapes the site on
the source's interval (default six hours) and renders a slide per event and
format: image, artist, weekday and date, venue, a badge for _sold out_,
_moved_, _new date_ or _cancelled_, and a QR code to the ticket shop. A changed
event is re-rendered under the same content item. Slides count against the
storage limit but stay out of the media library, search and bulk actions.

A playlist can now be created as **dynamic**: instead of picking items, the
editor sets a rule (sources, genres, venues, date window, without cancelled or
sold-out events, sort order, limit, format, item duration) and sees the result
in a live preview. The playlist updates itself after every sync, every 15
minutes by default, and the moment an event ends: at the end of its day plus a
grace period (default four hours) in the organisation's time zone. Its items
cannot be edited by hand, and a playlist keeps the kind it was created with.

Websites spell the same venue in several ways ("Der Hirsch" next to "Hirsch").
On a source's page an editor maps such spellings to one **canonical venue**:
the list shows every venue name the current events use, with its event count,
and aliases can be added for spellings the site does not use right now. The
venue pickers, the event pool, rule previews and summaries then show only the
canonical name, a rule on "Hirsch" also picks the "Der Hirsch" events, and
the source's dynamic playlists are recalculated right after saving. The
slides keep the website's spelling. The event pool can be filtered by venue,
genre and date and pages with "load more", showing how many events match.

Portrait slides (1080×1920) centre the image and the text below it as one
block. Square and upright images are shown whole and as large as fits; a
landscape press photo grows taller than a full-width fit by cropping at most
15 % of its width, trimming the least interesting edge first, so the lower
half of the slide no longer stays empty. Landscape and 4K slides are unchanged.
Every portrait slide is re-rendered on the next sync.

Once a day the server deletes the slides of events that ended more than 14
days ago or that the site no longer lists, unless a playlist still shows them.

### Fixed — screens show a replaced file

A re-uploaded file kept its old URL, and screens kept playing the cached old
version for up to a day, also because nothing told them to reload. Media URLs
now carry the content's version, and once the new file is transcoded every
screen showing it reloads its playlist. The same holds for re-rendered slides,
and thumbnails in the dashboard follow the new file as well, the screen
cards on the screens page included.

While the new file is being transcoded, screens keep getting the previous one.
Before, the media URL answered 404 until the transcode finished, and a screen
that loaded the item in that window skipped it. The new file goes live in one
step once it is complete; if transcoding fails, the previous file stays on air
and the item shows the error. The transcoded storage limit only has to fit the
difference between the old and the new file.

**Operator action:** update the server. The backend migrations (`0027`,
`0028`) add `content_sources` (with `venue_aliases`), `source_events`,
`source_event_slides`,
`contents.managed_by_source_id` and the playlist columns `kind`, `rule`,
`next_resolve_at`, `last_resolved_at`, and runs on start. The backend image now
ships `fontconfig` with the DejaVu and Liberation fonts; a backend run outside
the image needs fonts installed for the slide text to render. No new
environment variable: the scraper reaches only the hosts its adapter allows.

## 0.24.2

### Fixed — the media library is sorted by title

The media library listed items in the order they were uploaded, and a fresh
upload appeared at the top. It now sorts by title the way screens, groups and
site agents already do: numbers in numeric order ("Bild 2" before "Bild 10"),
ignoring case and accents. New uploads and renamed items move to their place
at once.

**Operator action:** none beyond updating the server. There is no migration.

## 0.24.1

### Fixed — screens moved to another group stop with that group's schedule

When a group's scheduled playlist ended, only screens that had never been in a
group switched to the fallback. Screens that had been moved over from another
group kept playing until they reconnected. The server had kept the group they
were in when they connected and sent the end of the schedule there.

Adding a screen to a group, removing it, or moving it to another cell of a
video wall now takes effect at once on a connected screen: it switches to the
group's current playlist, or back to its own schedule, without a reconnect.

**Operator action:** none beyond updating the server. There is no migration.
The restart during the update reconnects every screen and clears the stale
group.

## 0.24.0

### Added — faster playlist editing

- **Typed durations.** An image's duration can now be typed straight into its
  row. The −/+ buttons stay. Enter or leaving the field saves the value,
  Escape discards it, and anything that is not a whole number of at least one
  second snaps back.
- **Settings for all items.** A bar above the sequence sets the duration, the
  transition or the transition duration on every item at once, each with its
  own **Apply** button. The duration only reaches images; videos keep playing
  their own length.
- **Adding several items in order.** The _Add content_ dialog lists titles in
  natural order (_Bild 2_ before _Bild 10_). Clicking items numbers them in the
  order they were clicked, and **Add N items** appends them to the end of the
  playlist in exactly that order. The selection survives search and filter
  changes.

### Fixed — a quick second edit no longer drops the first

Changing two settings of the same item within a second saved only the second
change. Both are now saved together.

**Operator action:** none beyond updating the server. There is no migration.

## 0.23.2

### Fixed — screens, groups and agents are listed in order

Screens, screen groups and site agents came back in the order they were
created, so the screen page and every picker for them looked shuffled. All
three are now sorted by name, with numbers in numeric order: _Löwensaal 2_
comes before _Löwensaal 10_. The screens inside a group follow the same order.

**Operator action:** none beyond updating the server.

## 0.23.1

### Fixed — the agent update hint shows on servers running a main build

Every build from `main` reports its version with build metadata, such as
`0.23.0+sha.1bbf9f7`. The server refused that suffix when comparing versions,
took itself for a development build and never flagged an older agent, so the
**update now** button did not appear. Build metadata is now ignored for the
comparison, as SemVer prescribes, and the hint shows the bare version.

**Operator action:** none beyond updating the server. Agents still on 0.22.0
then show the hint, and **update now** pulls the current `stable` image.

## 0.23.0

### Changed — the agent lets a TV settle before starting the app

A TV that was just switched on answers on the network before it has fetched the
time over NTP. Until then every certificate looks invalid to it, and an app the
agent started straight away failed with SSL errors. The agent now leaves a set
alone for three minutes after it starts answering again before it launches the
app or extends Developer Mode. The agent page shows the set as _TV detected ·
app starts around HH:MM_ in the meantime. The same wait applies after the agent
itself restarts, since it cannot tell how long a set has been on; the onboarding
wizard and the manual buttons do not wait.

**Operator action:** update the server **before** the agents. The backend
migration (`0026`) adds `screen_remote_controls.app_launch_planned_at` and runs
on start. An updated agent sends a field an older server rejects, which would
drop all of that agent's reports until the server catches up. Once the server
runs 0.23.0, update the agents with **update now** on the dashboard.

## 0.22.0

### Added — update a site agent from the dashboard

Each agent runs in a hardened container and cannot restart itself, so until now
an update meant an SSH session and a manual re-run. The agent detail view now
has an **update now** button, enabled when a newer image is available and the
agent is online. It pushes an `update_agent` command over the existing agent SSE
channel; the agent writes a sentinel file into its bind-mounted state directory,
a host-side systemd path unit runs `podman auto-update`, pulls the rolling image
tag and restarts the container with healthcheck-gated rollback. The request is
audited as `site_agent.update_requested`; the server answers `409` when the
agent is offline or already current.

**Operator action:** re-run `ansible/site-agent.yml` once so the agent follows
the rolling `stable` tag and the new `update.path`/`update.service` units are
installed (a specific version can still be pinned via `-e`). After that,
updates are one click from the dashboard.

## 0.21.0

### Added — a display that changes its address is found again by its MAC

Where the router cannot be configured, every DHCP lease renewal may move a TV,
and the agent kept knocking on the old address. When a display with a MAC
stops answering, the agent now looks for it: it reads its neighbour table, asks
every webOS set to announce itself over SSDP and checks who answered. It adopts
a new address only where the set actually answers, acts on it in the same round
and reports it. The server stores it, writes `screen.remote_address_changed` to
the audit log and the dashboard updates the row live.

While a set stays missing — typically off overnight — the search backs off from
two minutes to 30. **Check now** searches straight away. A TV in standby answers
neither SSDP nor ARP, so it is found once it is on again.

### Added — subnet sweep, off unless switched on

If SSDP does not turn a moved display up, the agent can touch every address of
the subnet the set was last seen in on port 3001. That can look like a port
scan, so it is a per-agent switch on the agent's page (**Allow subnet sweep**)
and off by default. With it off, the agent contacts only sets that answered
SSDP themselves.

**Operator action:** update the server first, then the agents by re-running
`ansible/site-agent.yml`. The backend migration (`0025`) adds
`site_agents.subnet_sweep_enabled` and runs on start. Store the MAC of the
interface each TV actually uses (wired and wireless differ); the IP is still
entered once during setup. The server accepts a new address only for a display
with a MAC stored.

## 0.20.0

### Added — check interval and "Check now" for site agents

How often an agent checks its displays is now set per agent on its page
(**Check interval**, 1–10 minutes, default 1). The agent applies a change
straight away, without a restart. **Check now** runs a round immediately and
clears every backoff, for example right after switching a set back on.

### Changed — a TV that comes back is noticed within one interval

The agent used to stop probing a display while it backed off after failures,
which doubled up to 30 minutes. A set switched on after a long time off could
go unnoticed for that long. The probe now runs every interval. Only the actions
(app launch, Developer Mode extension, wake) back off. A display that does not
answer no longer counts as a failure, and a display that comes back gets a fresh
start.

**Operator action:** update the server first, then the agents by re-running
`ansible/site-agent.yml`. The backend migration (`0024`) adds
`site_agents.probe_interval_minutes` and runs on start. `SITE_AGENT_PROBE_INTERVAL_MS`
is no longer read; remove it from the backend environment. `MNS_PROBE_INTERVAL_MS`
was never read by the agent and is gone from the Ansible template. An agent older
than this release ignores **Check now** and picks up a changed interval only
after a restart.

## 0.19.0

### Added — how a site agent is connected

The agent's card and page show how its machine reaches the venue network:
**LAN** or **Wi-Fi** with the network name, and the address the interface holds.
The agent reads this on every heartbeat from the interface carrying the default
route; the SSID comes from `iw`, which is now part of the agent image and needs
no capability. A bridge, VPN tunnel or bond shows as "network unknown" rather
than as LAN. Whether the address came from DHCP cannot be told from inside the
container and is not shown.

### Added — update hint for site agents

An agent running an older release than the server is flagged with
**Update available** on its card and page, together with how to update it.
Server and agent ship from the same release, so the server's own version is the
reference; no registry or GitHub call is involved.

**Operator action:** update the server first, then the agents by re-running
`ansible/site-agent.yml`. The backend migration (`0023`) adds four columns to
`site_agents` and runs on start. An agent that reaches a server older than
itself still checks in; it just drops the network report until the server is
updated.

### Changed — smaller things

- `smol-toml` 1.9.0 under nx (GHSA-r4xh-jqrq-34v2).
- Development dependencies: `eslint`, `@types/node`.

## 0.18.0

### Added — Prometheus metrics

The backend exposes `GET /api/metrics`: HTTP requests and latency, BullMQ queues
and job durations, SSE connections, screens, live streams and the Postgres pool.
Site agents push their own series (uptime, memory, connection, per-display
reachability, Dev Mode, launches, wakes) over the session they already hold, so
one scrape target covers the backend and every venue. See
[Observability](docs/observability.md).

HTTP series are recorded when the response finishes, so `401`/`403` from guards
and `500`s from exception filters are counted with the status that went out.

**Operator action:** the endpoint stays disabled (`404`) until a token is set.
Set `metrics_scrape_token` (Ansible Vault) and run `ansible/deploy.yml`, then
scrape the backend on the host at `localhost:50002` with that token as Bearer.
Caddy answers `/api/metrics` with `404` on both public hosts; this needs the
playbook run too, an image update alone does not change the Caddy config.

### Changed — site agents join with a setup code from the dashboard

**Site Agents → Add an agent** shows a short-lived, single-use setup code; it is
pasted on the agent's setup page. The boot PIN in the container log is gone.
`MNS_SERVER_URL` is now required and must be `https://`, because it is the only
thing between an agent and a server impersonating yours on the venue network.
Resetting an agent also needs a fresh setup code.

**Operator action:** an agent container without `MNS_SERVER_URL`, or with an
`http://` address, no longer starts. Set it to the server's `https://` address.
`MNS_ALLOW_INSECURE_SERVER_URL=true` exists for local development only. Agents
that are already connected keep their session.

### Added — unattended agent rollout

An agent started with `MNS_ENROLMENT_TOKEN` (a setup code) and
`MNS_SETUP_PORT=0` joins on its own, without a browser. The code is redeemed
only while no session is stored, so a restart with the variable still set does
not fail on a spent code. `ansible/site-agent.yml` deploys the agent as a
rootless Podman Quadlet on venue hardware, with host networking for
Wake-on-LAN.

### Added — the admin interface in German and English

Every page of the admin SPA is translated, with a DE/EN switch in the top bar.
The choice is remembered per browser; the first visit follows the browser
language. Dates and numbers switch with it, and the date and time fields show
`TT.MM.JJJJ` / 24-hour in German and `MM/DD/YYYY` / AM/PM in English regardless
of the browser's own language, with a calendar picker in the app language.

### Fixed — a new agent shows as online straight away

The dashboard showed a freshly connected agent as offline for up to a minute,
and after every agent restart, because the first heartbeat waited for the
interval. The agent now checks in at start-up and right after enrolment, and
"last seen" updates with the status instead of reading "never" until a reload.

### Changed — smaller things

- The setup code has a copy button right beside it, also for a re-issued code
  on the agent's page.
- `source-map-js` 1.2.2 (GHSA-68fv-2mgg-jv7q).
- Development: the compose stack runs under rootless Podman on SELinux hosts,
  and passes `METRICS_SCRAPE_TOKEN` through to the backend.

## 0.16.0

Everything here came out of running the Site Agent against a real LG television
for the first time. Most of it could not have been found any other way: the
faults were assumptions about the set that no test could contradict.

### Added — the agent installs the player app on a TV

Setting up a display no longer means building a package and pushing it from a
developer machine. The agent fetches it from the server, copies it onto the set
over the Developer Mode connection and hands it to the install service. The
onboarding wizard has a step of its own for it, between the SSH check and the
pairing prompt.

The gain is not the first install, it is every one after: an app installed in
Developer Mode is deleted when the session expires, and the agent can now put it
back without anyone driving to the venue.

**Operator action:** the wizard has nine steps instead of eight, and the numbers
of the later ones shifted. A display part-way through setup continues from a
step one further along than it was; re-run that step if it looks out of place.

### Added — the dashboard shows which app version a TV is running

A screen carries a badge with the version the set reports, and offers the update
when it differs from the one this server has. Nothing updates on its own: an
install restarts the app and interrupts whatever is playing, so it stays
something a person asks for.

### Added — a display configures itself from the launch parameters

The agent hands over the server address when it starts the app, so a freshly
installed display no longer needs a URL typed in with a remote. Only when
nothing is stored — an address that was typed or already confirmed is never
overwritten, so a second agent cannot silently re-point a working display.

### Added — standby from the dashboard

A screen can be sent to standby and woken again. Screen-off is not offered: the
television refuses those calls over the remote-control protocol, and the
Developer Mode account cannot reach them either.

**Operator action:** waking a set over the network needs **Quick Start+** on the
TV (Settings → General → Energy Saving). Without it, only the remote brings it
back — the dialog says so, and does not block the choice, because the setting
cannot be read remotely.

**Operator action:** the TV shows its pairing prompt once more, on every display
already paired with an agent. This is expected. Controlling power needs a
permission the agent did not ask for before, and the set re-confirms when the
request changes. Confirm it with the remote once per display.

### Added — an Extend Dev Mode button

The command existed; the button did not. Expect the Developer Mode app to appear
on screen when it runs — that is how the extension works, not a fault.

### Fixed — manual commands did nothing at all

Wake, Start app and Extend Dev Mode were handed to the logic that decides what
is _due_ rather than being carried out. All three refused silently: a wake
unless automatic waking was switched on, a launch unless the player had already
stopped reporting, an extension unless it had fallen due. The button did
nothing, not even fail.

### Fixed — Wake-on-LAN reached the wrong network

The magic packet went only to the global broadcast address, which leaves the
machine over its default route — not necessarily the network the television is
on. It is now also sent to the broadcast address of the interface that covers
the set.

### Fixed — Developer Mode was extended every minute

Whether an extension was due was read from a cached value that never changed, so
it fell due again a minute later, every minute. Each one launched the Developer
Mode app on the screen, and because one action runs per round, the app launch
behind it never happened — which is why pairing never completed on a set that
had Developer Mode switched on.

### Fixed — the setup wizard could not be completed

Three separate faults, each enough on its own. The key-server step asked for the
Developer Mode passphrase one step before the wizard offers to enter it. The
last two steps did their work but reported it in a form the server does not
count, so they never advanced. And the final step read "in progress" forever,
because "done" was derived from being past the furthest step reached — which the
last step cannot be.

### Fixed — a display could not be enrolled where the server address was pinned

Deployments that fix the address in the environment showed no server on the
agent's setup page and rejected the enrolment, because the form posts the field
empty when it cannot be changed.

### Fixed — the player rejected the webOS shell's handover

The player accepted the server address only from an origin that real sets do not
use, so every television fell back to guessing the address from its own
hostname — which cannot work for an address with a port. No display ever got a
pairing code this way.

### Fixed — the screen saver started with the app in the foreground

The television has no switch for it, only a veto the app holds. A registration
left behind by an earlier run of the app blocked the new one, and the old one
belonged to a page that could no longer answer — so nothing vetoed anything.
webOS offers no way to drop such a registration, so each run now registers under
a name of its own.

## 0.15.0

### Fixed — a screen stopped switching after a restart

A player that restarted ran one item and stayed on it. The boundary that ends
an item was armed only from a media `load` event, and the re-anchor that runs
when the clock sync completes puts back the item that is already on screen —
same object, so nothing re-rendered, nothing loaded, and the timer that had
just been cleared was never re-armed. Timing now comes from the shared clock
alone; when a picture happens to decode is not a timing input.

Three more ways the timeline hung on a DOM event are gone with it: one expired
media URL used to stall a screen permanently, a boundary that arrived during a
transition was dropped instead of retried, and a clock correction only took
effect an item later. A screen also no longer anchors to a raw `Date.now()` at
boot — on a TV that has not reached NTP yet, that was minutes off.

### Fixed — a restarted screen in a group played out of step

A screen that reconnected resolved its own anchor while its peers kept theirs,
and nothing reconciled the two: the set showed the right playlist at the wrong
moment, indefinitely. A group's state is now resolved from the group's own
schedule rather than by asking whichever member answered first, a member
arriving re-aligns the whole group, and a recurring schedule rolling into its
next occurrence re-anchors every player instead of only the ones that reconnect.

Nothing to do on the screens: a push that changes neither playlist nor timing
is ignored by the player, so re-alignment is invisible unless a set is actually
out of step.

### Fixed — a fade faded through black

A fade ramped both layers at once, which leaves a quarter of the screen showing
the container behind them at the midpoint — on a TV that reads as two fades
through black rather than one dissolve. Fade, zoom-in and zoom-out now animate
the incoming picture over the outgoing one, which stays put underneath.

Zoom-in and zoom-out were identical to fade in effect, because the scale sat on
the layer the incoming picture covers completely. Cut showed black for as long
as the next picture took to decode. A one-item playlist took twice the
configured duration for a single transition. And editing any playlist setting
restarted playback and replayed an animation, so trying a transition out always
looked like a fade whatever was picked.

### Changed — the transition setting says which end of the item it belongs to

The transition on a row is that item's own way in, not the way the row above it
leaves. Setting it on one of two items therefore left every second switch on
the default, which reads as the setting doing nothing. The field now says so in
its label and tooltip.

## 0.14.1

### Fixed — a display could not actually be given to an agent

0.14.0 shipped the agent and the wizard that connects a TV to it, but nothing in
the dashboard created the row that ties the two together. An agent's page showed
an empty list and there was no way to fill it. Each display now has a remote
control setting reachable from its own tile and from the agent, and that is what
assigns it.

### Fixed — a display or an agent could not be taken back out

The only way to detach a display was to clear the agent in a dropdown, which
left its address, its passphrase and its onboarding progress behind — so the
next agent inherited a previous installation's values and the wizard started
half-done against a set it had never seen. Removing a display is now an explicit
**Remove** on its row, and it forgets all of it: connecting the same TV again
starts at step one with nothing filled in.

An agent can be deleted outright. Its displays are released the same way, so
nothing is left pointing at a service that no longer exists. Both actions, and
revoking an agent's access, ask first in a modal that says what will happen.

### Changed — the onboarding wizard is a stepper

It was a list of instructions with a Check button, and every value it asked for
had to be typed somewhere else. Connecting a display meant walking between two
dialogs while standing in front of a television. The steps are now a menu down
the left with their state on them, and each step carries the fields it needs, so
saving and checking is one action.

Steps ahead of the furthest one reached cannot be clicked; ones already passed
can, so a wrong IP is corrected in place. The four Developer Mode steps are
marked optional and can be skipped together: they exist only so the agent can
keep the session from expiring, and an installation that does not want that
needs none of them.

### Added — a screen group says what it measures as one picture

A video wall of two 1920×1080 displays is a 3840×1080 canvas, and that is the
number you need when you pick content for it. The group card now reads
"2 screens · 3840 × 1080", and the group's own page carries it beside the mode
and the screen count.

Split mode adds the columns' widths and the rows' heights, so portrait walls and
mixed screen sizes come out right; a cell with nothing in it yet counts as the
largest screen the group has, because that is what the wall becomes once it is
filled. Mirror mode has nothing to add up, so it reports the resolution its
screens share and says nothing when they disagree.

### Changed — the edit-screen dialog fits on a laptop

Editing a display stacked the form, what the display reports and two maintenance
sections in one 520px column, which meant scrolling before you could see what
the Save button was attached to. The two kinds of content are side by side now,
in an 880px dialog that needs no scrolling at 1280×800 and up.

And when a dialog does have to scroll, only its body does: the title and the
close button stay where they are. Every other dialog in the app already worked
that way.

### Changed — the same control everywhere it is the same thing

**Going back out of a detail view** was four different controls across five
pages, three of them hand-rolled from raw classes and labelled four different
ways. They are all the site agent's outline button now.

**A dropdown with more than eight options** scrolls and gets a filter box,
focused as it opens, that takes Enter to pick the first match. The time-zone
picker rendered some 400 entries in one unbounded list, which no amount of
scrolling made usable.

**Creating or editing an organisation** is a modal rather than a view that took
over the page, hiding the list it was started from.

**The instance-admin tab bar** was four copies of the same markup. It is one
component, like the settings tabs, and it no longer shows a scrollbar on a strip
that has nothing to scroll.

### Fixed — things that looked right in the markup and did nothing

**A dropdown opened inside a modal was cut off after the first option.** A modal
panel clips its overflow and, because it carries an entry animation on
`transform`, is the containing block for anything positioned inside it — fixed
positioning included. The menu now opens in an overlay outside that subtree, at
the width of its field.

**Escape closed nothing.** The handler sat on the backdrop, which is never
focused when a dialog opens, so the keypress landed on `<body>` and was lost.
It is caught on the document now, and only the innermost open layer acts — a
dropdown over a confirmation over a form closes one at a time.

**The close button was a plus sign rotated 45 degrees.** It reads as an X at
16px and nowhere else; the icon set has a real one now.

**Gaps that the markup asked for never appeared** — on the agent pages, between
a screen group's cards, and on the settings and admin pages. Card and form
components put `display: contents` on their host, which leaves no box for a
margin to sit on, and the `block` that was supposed to undo that could not win:
Tailwind's utilities are layered and an Angular component's styles are not.

**The active instance-admin tab was not marked**, and the sidebar entry went
dark the moment you opened any settings or admin tab other than the first.

### Fixed — status colours on the agent pages were not rendering

Twenty-five components referred to colour tokens that do not exist in the
stylesheet, so online, offline and warning states fell back to inherited text
colour. They now use the tokens the design system actually defines.

## 0.14.0

### Added — a service in the venue that looks after the displays

An LG consumer TV has no autostart, and its Developer Mode session expires —
when it does, the set deletes the installed app. Neither can be dealt with from
the server, and the player heartbeat alone cannot tell _the TV is off_ from _the
TV is on and the app is not running_. Those are different problems with
different fixes, and until now both looked like "offline".

A **site agent** is a new service that runs inside a venue's network and is
paired to one organisation. It probes the displays assigned to it, starts the
app over SSAP when the player is not reporting, wakes a set with Wake-on-LAN
before a schedule begins, and extends the Developer Mode session over SSH — only
while the TV is on, because there is no other way, and before launching the app,
because an expired session means the set has already deleted it.

Everything it needs comes from a configuration it caches on disk, so a venue
keeps being looked after when the uplink is down, including waking a set for a
schedule it already knew about. A command an operator triggers by hand is
refused with a clear error when the agent is not connected rather than queued: a
"start the app now" that fires six hours later is worse than none.

**The TVs' private keys never leave the venue.** The server stores only the
Developer Mode passphrase, encrypted; the agent fetches each key from the set's
own key server and keeps it locally. That passphrase is the only value in the
system delivered to one caller in the clear and masked for another, so the two
payloads are separate types rather than one with a flag.

**Operator actions.** Create the agent under **Site Agents** in the dashboard,
run the container in the venue, and connect it on its own setup page with the
PIN printed to its log. Then connect each display with the eight-step wizard,
which checks every step against the actual TV. Two settings on each set have to
be made on the TV itself — **Quick Start+**, without which the network stack is
dead in standby, and **Mobile TV On** for Wake-on-LAN.

**Set `MNS_SERVER_URL` on every agent you care about.** Without it the server
address lives only in the agent's state file, and the agent presents its real
refresh token to whatever address is written there. With it, the deployment
decides and the setup page cannot be used to move the agent.

Full walkthrough, including what each status means and what to do about it:
[docs/site-agent.md](docs/site-agent.md).

### Changed — the LG helper scripts are no longer the autostart answer

`player-applications/lg-tvos/tools/` told operators to run `launch-tv.mjs
--watch` as a systemd service on the backend host. The site agent does that
properly, for every display in a house, from inside the venue. The scripts stay
for what they are still the answer to: poking at one set by hand, before an
agent is set up or when one cannot reach a TV and you need to find out why.

## 0.13.0

### Fixed — the settings overlay could not be crossed with a TV remote

Entering a server URL on an LG screen needed a Magic Remote pointer. The arrow
keys did nothing: webOS runs a plain web app without spatial navigation, so they
arrive as ordinary keydown events, and a browser only ever moves focus with Tab
— which no remote has.

Moving the focus was not the fix either. On webOS a text field that takes the
focus pops the on-screen keyboard open, and that keyboard then takes the arrow
keys for itself, so every attempt to step to the next control reopened the
keyboard and the remote ended up toggling between the two fields.

The overlay now moves a selection that is not the focus. Arrow keys walk a
highlight over the fields and the buttons, **OK** opens the keyboard on the
highlighted field or presses the highlighted button, **Back** gives the keyboard
up again and leaves the highlight where it was. Nothing is focused until OK asks
for it, so the keyboard only ever appears on request — including when the
overlay opens, which used to focus the Server URL field straight away.

### Added — LG screens no longer fall into the TV's screen saver

Displays blanked after a few idle minutes. webOS gives a web app no switch for
the screen saver, and since webOS 6 it cannot be turned off in the TV settings
either; it starts whenever nothing is playing back full screen, which is exactly
what a playlist of images, or a pairing code, looks like to the TV.

The app now takes the veto the power service does offer: it subscribes to
`com.webos.service.tvpower/power/registerScreenSaverRequest` and answers every
announced start with `ack: false`, the way Kodi and RetroArch hold the screen.
Sent to the background it acknowledges instead, so a TV that someone is watching
still behaves like a TV.

**Operator action:** LG screens do not update themselves. Rebuild the package
(`ares-package player-applications/lg-tvos --outdir ./build`) and install it on
each screen with `ares-install`; pairing survives, the two URLs stay in place.

After installing, confirm the veto took: `ares-inspect --device <tv> --app
com.mynextscreen.webos --open` reports `[keep-awake] the power service refused
the subscription` if the TV denied it. `com.webos.service.tvpower` has no public
ACG, so a screen outside developer mode may refuse the call.

The screen saver is not the TV's only timer. **Auto power off** and the **sleep
timer** under Settings → General switch the set off no matter what an app asks
for, and have to be disabled on the TV itself.

## 0.12.1

### Fixed — invitation links were dead the moment they were sent

An invited user who clicked the link in their email got "Invalid or expired
token", however fresh the link was. Inviting someone wrote the set-password
token into the user row in **plaintext**, while redeeming it looks the token up
by its SHA-256 fingerprint, the way every other mailed token in the codebase is
stored. The two never matched, so `POST /api/auth/set-password` answered 404 for
every invite.

There was no way around it either: an invitee starts unverified, and
forgot-password deliberately no-ops for unverified accounts, so the account
could not be activated at all.

Inviting now persists the token through `UserService.setPasswordResetToken()`,
the single writer that hashes. A regression test redeems the raw token from the
emitted invite event and fails against the old code.

**Operator action:** anyone invited before this release must be **invited again**
— their stored token is a plaintext value that the fixed lookup will not match.
Remove the pending member and add them back; the new invite email works.

## 0.12.0

### Added — unpairing a screen from the remote

The disconnect existed but no TV could reach it. It lives in the player's info
panel, which opens with the **i** key, and a TV remote has no **i**. Nor could a
plain remote have pressed the button: nothing in the player takes focus, so
there was no way to move to it without a Magic Remote pointer.

The webOS shell now owns the entry point. Its settings overlay — **Settings** or
the **Blue** button, the same key that has always opened it — has a **Disconnect
screen** button, and the shell asks the player to unpair over the same
`postMessage` channel it already uses to hand over the server URL.

- The button takes **two presses**: the first arms it for five seconds. Unpairing
  cannot be undone from the remote; the screen has to be claimed again with a new
  code.
- The per-screen **`showDisconnectButton`** toggle governs this too. A screen
  with the disconnect switched off in the dashboard refuses the request, and the
  overlay says so instead of failing silently.
- The unpair request passes the same sender checks as the server-URL handoff
  (allow-listed origin, the embedding window, and that window being the
  top-level document). The one deliberate difference: a handoff is refused once
  a display is paired, an unpair is not — the shell may reset a display, never
  silently re-point it at another server.
- The player answers with the outcome, so the overlay can distinguish a
  successful unpair from a locked screen, an unpaired one, or a player that
  never answered.

**Operator action:** none for the backend. To use it on a TV, repackage and
reinstall the webOS app (`player-applications/lg-tvos/`); older shells keep
working and simply have no disconnect button.

## 0.11.1

### Fixed — the webOS app could not be downloaded

The LG webOS `.ipk` had no download button in the screens help panel, on any
installation. Renaming the project in 0.11.0 changed the application id in
`player-applications/lg-tvos/appinfo.json` to `com.mynextscreen.webos`, and
`ares-package` names the package after it. The backend kept a second, hardcoded
copy of the old id and looked for `com.cbf.webos_0.11.0_all.ipk`, a file CI
never produced, so it reported the download as unavailable and the button never
rendered. The id and version are now read from the manifest — the same file the
packaged name comes from — and cannot drift apart again. A manifest that is
missing or malformed now only costs the download, is logged, and leaves the
setup guide reachable.

Second cause, for arm64 hosts only: the arm64 backend image contained no `.ipk`
at all. The package is architecture independent, but it is baked into the image,
and CI built it for the amd64 job alone when the arm64 images were introduced.
Both backend images now carry it.

The module had no tests, which is why a rename could remove a feature unnoticed.
It now has sixteen, including one that fails if the packaged id and the id the
backend expects ever diverge again.

**Operator action:** pull the new backend image. Nothing else changes; no
migration, no configuration.

## 0.11.0

The release that made the repository public: one name, no borrowed marks, and
documentation that matches the code.

### Changed — the project is called myNextScreen

The admin interface has said so for months; everything else still said "Signage
Server", and the webOS app called itself "Digital Signage" with the application
id `com.cbf.webos`. Four names for one product, none of which matched the logo
in the sidebar. They are now one.

Renamed: the product name in emails, `SMTP_FROM`, the package, the Nx path
mapping (`@mynextscreen/shared-types`), browser-storage keys, the postMessage
handoff type, the JWT issuer and audiences, container and unit names, the podman
network, the Caddy access log, the fail2ban jail, the database role and
database, the service user, and the repository itself
(`Flo-Schilli/mynextscreen`), which moves the GHCR image paths with it.

Dropped: `screen.mynextscreen.app` as the built-in default player URL. An
instance that has no `PLAYER_BASE_URL` now says so in the add-screen hint
instead of naming a deployment it has nothing to do with.

Deleted: `docs/`, 54 files of planning and design-handoff material that
documented how the software came to be rather than how to use it. The history
still has them.

Corrected: README, ARCHITECTURE and VISION still described screens as
authenticating with a long-lived API key issued by an org admin. That stopped
being true in 0.10.0.

### Changed — the launcher icon, the favicon and the idle screen are our own

The webOS launcher icon was a third-party logo, and `default-screen.png` — what
a display shows when nothing is scheduled — was a band's. Permission to use a
mark is not permission to ship it to everyone who installs the software, and
AGPL passes on rights to the code, not to a bundled image. All three are now one
drawing, optically sized per use: 80 px for the launcher, 16 px for the tab, and
full screen with the wordmark for an idle display, which also now says it is
idle rather than leaving a bystander to guess it is broken.

Both web apps ship a favicon that survives 16 px. The player referenced one that
never existed and served a 404.

### Fixed — two things a staged demo made visible

- **Dates followed the browser locale, the rest of the UI did not.** Nothing
  here is translated, so `toLocaleDateString()` without a locale localised
  nothing; it mixed languages, rendering "Montag, 28. September" inside a panel
  headed "Schedules". Fifteen call sites now share one locale constant.
- **The unread-notification count fetched before it knew which organisation it
  was counting for**, so the request went out without `X-Organisation-Id` and
  the backend answered 400 — on every page load, in every browser console. It
  now follows the selected organisation, and re-reads when someone switches,
  which it never did before.

### Documentation

The README is a page for someone deciding whether to look at this at all:
screenshots of the running system, three commands to start it. Everything an
operator needs moved into `docs/` (installation, configuration, screens).
ARCHITECTURE and VISION were corrected against the code — both still described
an email provider, a screen registration flow and a local content cache that do
not exist.

### CI

`actions/checkout` and `actions/setup-node` moved to v7, pinned by SHA with the
exact tag written beside each one.

### Required operator actions

This release renames deployment identity. A running instance does not migrate
itself — plan a rebuild rather than an upgrade.

1. **Pull from the new image path.** `ghcr.io/flo-schilli/mynextscreen/{backend,frontend,player}`.
   The old path keeps its existing tags and receives nothing further.
2. **The database role, the database and the service user are now `mynextscreen`.**
   Back up first (`ansible/download_db.yml` against the old host), then restore
   into the new instance.
3. **Quadlet units, the podman network and the env files are renamed.** Remove
   the `signage-*` units before deploying, or the host runs both sets.
4. **Everyone is logged out once.** The JWT issuer and audiences changed, so
   existing access tokens are refused. Users simply log in again.
5. **Every screen re-pairs, and every webOS TV needs the new app.** The
   application id changed, which makes it a new app to the TV: the old one stays
   installed and the new one starts empty. Browser-storage keys changed too, so
   a paired display starts over.

## 0.10.1

### Changed — the webOS shell stops asking for an API key

0.10.0 left the key field in the shell's settings overlay and kept the key on
the TV, on the argument that the shell's storage survives an app update of the
player iframe. That argument no longer holds: the dashboard stopped handing out
keys at all, so there is nothing for an operator to type into that field. A
screen is enrolled by the six-digit code it shows on the display.

- The settings overlay asks for the **Server URL** and an optional Player URL,
  and explains that the code on screen is what enrols the display.
- The key written by 0.9.x is **deleted from the TV** on the first start.
- The handoff message carries only `serverUrl`. The player takes it, persists it
  and pairs against it — which also fixes the case where the player guessed
  `api.<its own hostname>` and got it wrong.
- **The handoff can no longer enrol a display.** An `apiKey` in the message is
  ignored. It used to be honoured for shells from 0.9.x, which also meant any
  sender past the trust check could enrol a display with a key it invented.
- **A handoff is accepted only from the top-level embedding window.** The shell
  is one; a page faking the shell's opaque origin by framing through a
  `sandbox="allow-scripts"` document is not. Deployments without the webOS shell
  should drop the opaque origin entirely — `window.__SIGNAGE_TRUSTED_ORIGINS__`
  in the player's `index.html`, which now documents itself.

### Changed — disconnect moves into the info panel

It was a button pinned to the top-right corner of the content at all times, on a
device whose entire purpose is to show content. It now sits in the info panel
(the **i** key), next to the screen, playlist and status it belongs with. The
per-screen `showDisconnectButton` toggle still governs it.

The panel no longer auto-hides once it has been opened deliberately; only the
informational flash on start does. Five seconds is not enough to find and hit a
button with a TV remote.

### Required operator actions

1. **Reinstall the webOS app on every TV that runs the 0.9.x shell.** Its handoff
   can no longer enrol a display. Screens that are already paired keep running on
   their session and are not affected; a screen that has to enrol again with an
   old shell will sit on a pairing code until the app is updated.

A screen whose storage is cleared shows a code again; enrol it with **Repair**
on the existing screen, which keeps its name, playlists and schedules.

## 0.10.0

The screen API key stops being a permanent credential, and the backend container
stops running as root. Eight commits; `v0.9.0..v0.10.0` for the full list.

### Required operator actions

1. **Add `UserNS=keep-id:uid=1000,gid=1000` to the backend unit.** The image now
   runs as a non-root user, and under rootless podman a plain non-root uid lands
   in the subuid range and cannot write the media bind mount. The shipped quadlet
   template has the line; a hand-written unit without it fails every upload,
   transcode and slice with a permission error. `CHOWN` and `DAC_OVERRIDE` are
   dropped from its capabilities.
2. **Screens re-enrol themselves, but only if they can reach the new session
   route.** The API key is now accepted on `POST /screens/session` and nowhere
   else. A player from before 0.10.0 cannot authenticate at all against a
   0.10.0 backend — deploy both together.

### Changed — the screen API key is now an enrolment credential

Previously it was issued once at pairing, never renewed, had no expiry and no
revocation, and travelled on every request. It sat in the player's localStorage,
in the webOS shell's storage — shown in cleartext in a settings overlay
reachable from the remote — and as `?token=` in every media URL, which put it in
the DOM, the `Referer` chain and every access log line.

- **Media URLs carry a signed grant** instead of a credential: HMAC over screen
  id and path, key derived from `JWT_ACCESS_SECRET`. Bucketed to a day with a
  per-screen offset so the URL stays byte-stable and `max-age=86400` keeps
  working; only the path is signed.
- **Screens hold a session**: a 15-minute access token with its own JWT
  audience, plus a rotating refresh token in Postgres (not Redis — a lost
  snapshot would strand the fleet). A 60-second grace window makes concurrent
  refreshes from the player's five independent consumers legitimate rather than
  a replay; a real replay is refused and logged without revoking the family.
- **The player keeps no key on disk** once a session exists, renews on a 401
  rather than on a clock a TV cannot be trusted with, and jitters its SSE
  reconnect so a fleet does not come back in lockstep.
- **The heartbeat reports the player build**, shown in the admin screen detail.
- The webOS key field is a password input and is never pre-filled; empty means
  "keep the stored key". The shell still holds the key on purpose — its storage
  is what survives an app update of the player iframe.
- `?token=` is gone as an authentication mechanism.

### Fixed

- The backend container runs as a non-root user. FFmpeg parses user-uploaded
  media in it, so a parser bug was root inside the container.
- SSRF: the per-org SMTP send path and live-stream start now run the resolved
  address check that until then only the test endpoints did.
- The screen API key is redacted from the Caddy and nginx access logs.
- Two player bugs surfaced by the above: hls.js froze the credential in a
  closure at attach time, and the Safari native-HLS branch could never have
  worked.

## 0.9.0

Security and dependency hardening across the whole stack, from a full audit of
0.8.4. 27 commits; the ones worth naming are referenced below.

### Required operator actions

Read this before deploying. Four of these will stop a deployment that ignores
them.

1. **Set `vault_signage_postgres_password`.** The production database password
   no longer falls back to `signage`; the playbook now aborts without the vault
   variable (`bc38ebf`). **Rotate the password on the live host while doing
   this** — until now it was very likely still the default.
2. **`JWT_ACCESS_SECRET` must be at least 32 characters.** The backend refuses
   to start otherwise (`8f0f5b0`). Generate with `openssl rand -base64 48`.
3. **`PUBLIC_BASE_URL` is required in production.** CORS now fails closed
   rather than reflecting any origin alongside `credentials: true` (`8f0f5b0`).
   Set `PLAYER_BASE_URL` too if the player runs on its own origin.
4. **The nginx images listen on 8080 and run as UID 101.** The frontend and
   player containers no longer run as root (`3cf4933`). The shipped quadlets
   publish `127.0.0.1:50001:8080` / `127.0.0.1:50003:8080`; a hand-written unit
   or compose file that still maps to container port 80 has to be updated.
5. **All published container ports bind to loopback.** Anything reaching the
   backend, frontend or player other than through Caddy has to go through the
   reverse proxy from now on (`3cf4933`).
6. **Migration `0016` invalidates links that are in flight.** Email
   verification, password reset and email change tokens are stored hashed, so
   the plaintext values already mailed out stop working (`0842f4a`). Affected
   users request a new link; nothing else is lost.
7. **Set `SECRETS_ENCRYPTION_KEY`** (`openssl rand -base64 32`) to encrypt the
   per-org SMTP passwords and ntfy tokens at rest (`8706fa8`). Optional: without
   it they are stored in plaintext as before, and the backend warns at boot.
   Existing rows are read either way and re-encrypted on their next write.
8. **Deployments pin the image tag to the version in `package.json`**, not
   `latest` (`11bfbdc`). Deploy from the checkout of the release you want, or
   pass `-e signage_image_tag=1.2.3`.

### Fixed — tenancy

- Cross-tenant takeover through `organisations/:orgId/members`: an org admin of
  any organisation could add themselves to any other one. The `RolesGuard` now
  resolves the organisation from route params and is default-deny, so a route
  without an access declaration is refused instead of allowed (`76199ff`).
- Cross-tenant read/write of the notification config (including SMTP
  credentials), `schedules/current` without org scoping, unauthorised screen SSE
  subscriptions, live-stream HLS routes without a tenancy check, and screen
  impersonation inside an organisation (`76199ff`).
- Super-admin member routes returned raw user rows — password hashes and live
  reset tokens included (`c616646`).

### Fixed — authentication

- Password change and reset now revoke every existing session. The acting
  device keeps working; all others are signed out (`da00a0e`).
- Verification, reset and email-change tokens are stored as SHA-256
  fingerprints (`0842f4a`).
- JWTs pin algorithm, issuer and audience on both sign and verify; passwords are
  length-bounded; failed logins spend the same time whether or not the account
  exists (`8f0f5b0`).
- Screen authentication looks up an indexed key fingerprint instead of
  bcrypt-comparing against every screen row — a scaling defect and a cheap CPU
  exhaustion vector (`5ce6602`).

### Fixed — input and outbound traffic

- SSRF through org-configurable targets: `ntfyUrl`, per-org `smtpHost` and
  live-stream `sourceUrl` are checked for scheme and for private, loopback,
  link-local and CGNAT addresses — at save time and again against the resolved
  addresses before each request. Internal hosts can be allow-listed with
  `OUTBOUND_ALLOWED_HOSTS` (`2ab90d8`).
- Stored XSS through `image/svg+xml`: uploads are typed from their magic bytes,
  SVG cannot match, and downloads send a derived content type with `nosniff`
  (`4758534`).
- Upload size is enforced while the body is read (Multer) and capped in nginx
  via `MAX_UPLOAD_SIZE` (`4758534`).
- Storage quota accounting is atomic; parallel uploads can no longer overshoot
  the limit or corrupt the counter (`995a55f`).

### Fixed — player, media and runtime

- The player accepts a `signage-connect` handoff only from an allow-listed
  origin, only from the embedding window, and only while unconnected. The LG
  webOS shell posts to the player's own origin instead of `*` (`42a178a`).
- FFmpeg processes are terminated on shutdown, escalated to SIGKILL when they
  ignore SIGTERM, capped in number (`MAX_CONCURRENT_LIVE_STREAMS`, default 4)
  and given an input timeout; protocol allow-lists are explicit (`dc94924`).
- Video-wall slices are written inside the organisation's media directory, so
  they count against the quota and are removed with the organisation
  (`d5f34d9`).
- `helmet` sets security headers on the API itself, and rate limiting keys on
  the real client IP instead of the proxy — the login limit was platform-wide,
  which made a five-request lockout of every user possible (`14f0523`).

### Changed — dependencies and build

- All runtime advisories closed: Angular 21.2.24, axios 1.20.0, NestJS 11.2.6
  (multer 2.4.0), nodemailer 10, postcss, body-parser, qs, form-data, nanoid,
  Nx 22.7.12 (`9d7678b`, `abfdaa6`, `dd6eded`). `npm audit --omit=dev` is empty.
- **`npm ci` requires npm 11.6.2**, the version pinned in `package.json`. CI and
  all three Dockerfiles install it first; npm 10 cannot install this lockfile
  (`3cf4933`).
- CI gates on `npm audit --omit=dev --audit-level=high` and a Trivy image scan
  that runs before the push, publishes an SBOM and provenance, and pins every
  action to a commit SHA. Dependabot and `SECURITY.md` added (`11bfbdc`). A
  CodeQL workflow is in place but stays skipped: code scanning needs GitHub
  Advanced Security on a private repository, so it activates by itself if this
  repository is made public.
- Fonts are self-hosted. They were loaded from Google and blocked by the
  production CSP, so the UI had been falling back to system fonts (`3cf4933`).
- Dev compose binds Postgres, Redis and Mailpit to `127.0.0.1` (`ec91148`).

### New environment variables

All optional unless noted.

| Variable                      | Purpose                                                                    |
| ----------------------------- | -------------------------------------------------------------------------- |
| `SECRETS_ENCRYPTION_KEY`      | Encrypts per-org SMTP passwords and ntfy tokens at rest (32 bytes, base64) |
| `OUTBOUND_ALLOWED_HOSTS`      | Comma-separated hosts allowed past the SSRF guard (internal ntfy/SMTP)     |
| `MAX_CONCURRENT_LIVE_STREAMS` | Cap on simultaneous FFmpeg encoders (default 4)                            |
| `MAX_UPLOAD_SIZE`             | nginx request-body cap in the frontend image (default `100m`)              |