mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-15 14:53:18 -06:00
237 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
16b3dd949c | Merge: BrightSign real telemetry and hardware identity | ||
|
|
8fd6eb75d5 |
BrightSign: cache content for offline, and let the package update itself
Two gaps that both end the same way — a panel nobody can fix without a van. OFFLINE. Content bytes were never persistently cached. The service worker skipped /uploads/content/ and leaned on the browser's HTTP cache, which is reasonable on a desktop and is not a documented-persistent store here: BrightSign guarantees survival across reboots for IndexedDB, localStorage and SQLite, and their own answer for offline video is to cache the bytes explicitly. A panel could come back from a power cut with its playlist intact — that lives in localStorage — and no media to play it with. The reason content was skipped is real, and player-cache-policy.js is what makes intercepting it safe. Seeking video issues range requests, and naive caching is worse than none: storing a 206 as the whole file means every later full request gets a fragment, and answering a range request with a 200 makes some media stacks fail outright. So only complete 200s are stored, and ranges are served by slicing the stored body into a correct 206. The content cache survives shell re-versioning, or every deploy would re-download the playlist over a link that may be exactly what is broken. SELF-UPDATE. The package can replace autorun.brs, so a truncated file is a dark panel with no app underneath. The safety is the ordering: download to .part, verify sha256 AND size, then delete the .done marker, rename, reboot. Marker first is not stylistic — leaving it makes the next boot skip the archive and the update silently never happens. A failed extract parks the zip as .bad instead of retrying every boot, which would be a loop indistinguishable from a hardware fault. sha256 because that is what roMessageDigest can compute; a checksum the player cannot verify is an unverifiable package. The decision lives on the server and is unit-tested, and the host only executes it — re-implementing the version comparison in BrightScript would put the prerelease trap somewhere untestable. That trap is honoured directly: a player on 1.9.29-rc1 is running something semver-OLDER than 1.9.29, so an opted-in player HOLDS a prerelease of its own core rather than being pulled off the build it was given to test. Narrowly — a newer core still lands, so opting in never means never updating again. Both loop conditions are closed by construction. The manifest and the download come from one buffer hashed once, so a checksum cannot describe bytes we are not serving. And the version is stamped into autorun.brs at build time by both builders, so the script reports the version it actually is — otherwise the player applies the update, still reports the old version, and is offered the same package forever. Failure always degrades to "keep running the old version": an unreachable manifest, a missing checksum, a failed verification, a full attempt counter and an unbuildable package all resolve to skip. 998 tests pass (was 954). |
||
|
|
5a7277523a |
Wire BrightSign native sync end to end, chosen per group
st-sync.js wrapped SyncManager but nothing drove it. The player now does: the
leader opens a new sync session on each advance, and every member — the leader
included — binds the video with attachVideo() on a NEW id only.
The leader binds from its own broadcast rather than at announce() time on
purpose. Starting when it announces would put it ahead of its followers by the
width of the network, which is the one desync nobody would think to look for
because the leader always looks correct.
Item selection stays clock-derived under both backends. Native sync replaces
only the seek/nudge drift correction, because setSyncParams has the element hold
its own alignment and correcting it ourselves would fight the platform — every
frame we moved is one it then has to undo. Keeping selection on the shared clock
is also what keeps images and widgets, which have no setSyncParams, advancing
with the videos instead of drifting off alone.
LEADER RULE: reuse the existing election (resolveGroupLeader) rather than adding
a column. It already resolves pinned-if-online, else first online member on the
shared playlist, else first by id — deterministic, stable, and already what the
group-sync payload reports. A second mechanism could only disagree with it.
Added on top: a group whose elected leader is OFFLINE falls back to our
protocol. Ours is leaderless and carries on; native sync has exactly one
broadcaster, so those members would sit waiting for an announcement that never
comes, with the dashboard showing a healthy group throughout.
device_groups.sync_backend ('auto'|'screentinker'|'brightsign') is the operator's
REQUEST; the answer comes from the existing pure resolveSyncBackend() so the
players, the dashboard and the stored setting cannot disagree. The resolved
backend, reason and downgraded flag ride in the group_sync payload and in the
group API, and the dashboard shows the refusal reason instead of a setting that
quietly isn't in force. An unrecognised value is rejected rather than stored,
because the resolver reads anything unknown as 'auto' — a typo would otherwise
return 200 and run a different protocol than the UI displayed.
FIXED WHILE HERE: the player re-entered group sync only when the group ID
changed, with a comment noting the clock protocol has no leader role. Native
sync has one, and neither a protocol switch nor leadership moving alters the
group id — so a player promoted to leader kept behaving as a follower, nobody
announced, and the group sat unsynchronised. The re-enter key now includes the
backend and the leader flag.
971 pass (+17).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
46b2227dfd |
BrightSign: real telemetry and hardware identity, not a block of nulls
The web player sent battery/storage/RAM/CPU as nulls and 'Web Player' in the
wifi_ssid column, and the bs_model / bs_os_version / bs_serial / bs_screen fields
the player already reported at registration were consumed by nothing. A browser
tab genuinely has none of that. A BrightSign has some of it, and was reporting
none.
Telemetry comes from a CACHE the heartbeat reads synchronously. The beat builds
its payload every 15s without awaiting, but the one real sensor here —
deviceInfo.getTemperature() — returns a promise; awaiting inside the beat would
either block it or serialise a pending Promise into the payload, which is exactly
how device_id once became "[object Promise]". The cache starts EMPTY rather than
null-filled and is spread last, so off-platform nothing changes and a null here
can never clobber a value another player family legitimately supplied.
wifi_ssid was actively false on a PoE Ethernet appliance — an operator reading
that column was told an SSID that does not exist. It is null there now, and the
device view shows a real hardware block instead. Android's WiFi display is
untouched.
Hardware identity is a SEPARATE writer from applyDeviceInfo, deliberately. That
function is a blind full-row overwrite, and an empty device_info once nulled
seventeen columns every five minutes because {} is truthy. These fields arrive
only on a full register, so the same shape would wipe them on every lightweight
refresh in between; COALESCE makes "no news" mean "unchanged".
The OS build gets its own column rather than reusing android_version, which is
load-bearing as a TYPE discriminator: device-detail chooses between the Android
and browser layouts with android_version.startsWith('Web/'), so writing
"BrightSign OS 9.0.189" there would have rendered a BrightSign with battery and
WiFi cards — and applyDeviceInfo would have clobbered it on the next refresh.
Storage is labelled "Player Storage", not "Storage": on this family the number is
the widget's cache quota, not the device filesystem, and it lands in the same
column as Android's real disk figures.
Schema: device_telemetry.temperature_c REAL; devices.hardware_model,
hardware_serial, hardware_os_version, output_index. All nullable, all idempotent
in the existing migration array.
973 pass (+19). The temperature tests drive a real socket into a real server,
because changing the arity of the telemetry INSERT would break every player's
heartbeat, not just BrightSign's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
e606cc83d1 |
Screenshots: prove pixels arrived instead of assuming the draw worked
A BrightSign emitted BLANK screenshots and logged "Screenshot sent". With hwz
enabled the video decodes onto a hardware plane outside the browser compositor
— BrightSign's docs say the HTML/JS layer "doesn't see the pixels" — so
drawImage(video) produces a fully TRANSPARENT image and throws nothing.
Chromium 87, which this XT245 reports, fails the same way.
Both capture paths set captured/drawn = true purely because drawMediaFit() had
not thrown. So the dashboard showed a dead screen while the panel played
perfectly, and the zone path painted a black rectangle in place of the labelled
placeholder drawZonePlaceholder() exists to guarantee ("never a transparent
hole"). Success reported, nothing done.
isMediaReadable() does not catch this. It answers "am I ALLOWED to read this"
(same-origin / CORS), which is a different question from "did any pixels
arrive".
videoFrameIsCapturable() probes a 16x16 scratch canvas before committing to a
full-size draw. ALPHA is the discriminator, not colour: a scratch canvas starts
transparent and a real decoded frame writes alpha=255 even when the frame is
pure black, so a legitimate fade-to-black still reads as captured while
"nothing arrived" does not. A tainted canvas counts as captured, because
tainting only happens once cross-origin pixels have actually been drawn.
Probing BEFORE the draw matters twice: it avoids a wasted full-size drawImage on
every frame of a 1fps stream, and in the zone path it stops a black rectangle
being painted underneath the placeholder.
When a video is on screen but unreadable the status card now says so, because
that card is also what shows for "no content" — without the line an operator
would reasonably conclude the screen was blank.
Not gated on BrightSign: the same silent failure exists for any stalled decoder
or engine that declines to hand back frames.
10 tests, 964 pass.
|
||
|
|
c743aa4b81 |
BrightSign: real display power, reboot and volume — command parity
The web player handles four of the ~20 fleet commands, because a browser tab genuinely cannot do more. A BrightSign can, and was inheriting the browser's limits for no reason. screen_on/screen_off now send CEC Image View On (0x0D) / Standby (0x36) so the display actually sleeps. The overlay only painted the screen black: the panel stayed lit, drawing power and at risk of burn-in. Best effort by design — some displays ignore broadcast CEC and need direct addressing — so displayPower() returns false when unavailable and the overlay is applied either way, meaning something visible always happens. reboot was silently ignored: the dashboard button did nothing on a web player. It now goes through the host to RebootSystem, and still logs a clear "not supported" off-platform rather than failing quietly. set_volume applies to whatever is playing AND is re-applied on every subsequent 'play' event, caught in the capture phase because media events do not bubble. Media elements are created per item across fullscreen, zone and preload paths, so setting volume once would otherwise last only until the playlist advanced. Wall followers stay silent throughout — that is deliberate, not an oversight. A dual-output player addresses HDMI-N for the screen it actually paints, so output 2 sleeps its own display rather than output 1's. 954 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
58641e7bbe |
Persist the device token, not just the device id
The bridge stored device_id in the registry and the display still came back as a NEW device on the next boot. The id is not an identity on its own: the server authenticates a claim to an existing display with the token, so an id presented without one reads as a brand-new player and gets a fresh row. device_token now sits alongside device_id in the registry, getConfig adopts both, and clearIdentity forgets both — a stale token must not outlive the identity it belongs to. Found on an XT245, not in a test, which is why the three new cases name the symptom rather than the mechanism. 951 pass. Also worth recording from the same session: the duplicate rows had a second cause. The widget's storage_path was pointing nowhere useful, so localStorage had no persistent home and the per-install fingerprint salt was regenerated on every boot. With storage_path set correctly the cache directory now exists on the player and the fingerprint is stable, which is what stopped the churn; the registry identity is the belt to that pair of braces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
5901067d8a |
Finish the BrightSign port: native sync, offline fallback, multicast guard
st-sync.js wraps SyncManager, the native protocol. Three properties drove the shape of it. It repeats the sync broadcast at 1Hz so a player powered on late still joins, which means acting on every repeat would reload the video once a second forever — on screen that reads as a stutter, not as a sync fault, so the id dedupe is mandatory rather than an optimisation. The leader starts from its OWN broadcast rather than at announce() time, or it runs ahead of the group by the width of the network. And attachVideo refuses an element with no setSyncParams instead of half-syncing it. offline.html is the local fallback the host falls back to after three failed loads. It names the server, keeps probing with capped backoff so a site full of panels cannot storm a server that is coming back, and asks the HOST to restart the player when it answers — never navigating itself, for the same reason the player never reloads itself here. The resolver now models multicast reach. All-BrightSign groups spread across subnets no longer get native sync: each subnet would sync neatly within itself while drifting from the others, and the dashboard would show a healthy group throughout. The IP comparison is a heuristic so it is used in one direction only — differing networks are evidence against, matching ones are never proof for, and unknown addresses block nothing. st-sync.js is served from its single source like the bridge, and the SD card deliberately carries neither: the player pulls both from the server so a stale copy on a card can never skew from the player using it. 948 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
7fb94fbf70 |
Correct the BrightSign port against the dev-cookbook examples
Reviewed autorun.brs and st-bridge.js line-by-line against the real examples
instead of the prose docs. Five defects, three of which would have been silent.
The registry API is asynchronous and section-oriented: read(section, key)
returns a Promise and writes take an object, write(section, {k: v}). The bridge
treated both as synchronous, so deviceId() returned a Promise object — truthy
and non-empty — and a panel would have registered as "[object Promise]" while
its real row sat unclaimed. It now prefetches into a cache behind onReady(), and
connect() waits for that before registering.
brightsign_js_objects_enabled: true is required alongside nodejs_enabled for
require("@brightsign/*"). Without it the bridge degrades to no-ops and the
player loses identity and restart delegation — which would have read as
"BrightSign doesn't work" rather than as one missing flag.
storage_path is a directory name, not a volume, and storage_quota is a string;
the local fallback URL needs its volume (file:/SD:/offline.html). Added
security_params and hwz_default to match the examples.
SyncManager does not work unless networking/ptp_domain is "0", which needs a
reboot to apply. Done only when this player is configured for native sync, and
read-before-write so it reboots once rather than on every boot.
Confirmed correct as written: messageport, the roHtmlWidgetEvent loop, and
RebootSystem(). The notes also state a widget URL may be an externally hosted
page with the same JS API access — the favourable answer to the question the
original probe was built to ask.
Bridge tests now model the async section-oriented registry, so a synchronous
stand-in can never hide this class of bug again. 931 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
ce854ff2d8 |
Wire the BrightSign bridge into the web player
The bridge and the host existed but nothing loaded them. Now the player does. restartPlayer() replaces every location.reload() call site. On BrightSign a page-initiated reload does not reliably bring the roHtmlWidget back, so the page asks the host to rebuild it and only falls back to reload() when no host is there to take the request. That covers the deploy path, the operator refresh, the service-worker activation and the manual reset. Identity now round-trips through the registry, which outlives localStorage on this platform: getConfig() adopts a registry identity when local storage comes back empty, instead of re-pairing and spawning a second row for a panel that is already provisioned. The operator reset clears the registry too — otherwise it would clear localStorage, get the same identity straight back on the next boot, and reset nothing. Registration reports platform 'brightsign' rather than "Chrome 120", which is what sync-backend.js resolves native-vs-ours from, plus model, OS, serial and which output this widget paints. Dual output needed a collision fix: autorun.brs gives the second HDMI output its own widget, and both widgets share an origin, a registry and one SD storage_path. Un-namespaced, output 2 would read output 1's config, install salt and device id and the two would collapse into a single device row. Storage keys and registry keys are now suffixed per output; screen 1 keeps the bare names so nothing existing moves. The bridge is served from its single source so the copy the player loads can never skew from the one on the SD card next to autorun.brs, and it is served to every player rather than gated on a user agent — a panel reporting an unexpected UA would otherwise silently lose restart-instead-of-reload. Two test harnesses extract player functions and run them in an isolated scope, so they now supply SCREEN_SUFFIX; one gained a case proving two outputs of one player get distinct identities. 927 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
6f5907a1d4 |
BrightSign: supervised player host, JS bridge, and per-group sync backend
The player is the unmodified web player in an roHtmlWidget — that already runs
on real hardware. What was missing is everything a page cannot do for itself.
autorun.brs becomes a host rather than a URL wrapper. It owns the widget
lifecycle, because a page-initiated location.reload() does not reliably bring an
roHtmlWidget back: a deploy on 2026-07-28 reloaded every connected player and
the BrightSign was the only one that never returned. The page now posts
{type:"restart"} and the host rebuilds the widget. It also retries load-error
with backoff, falls back to a local page, and runs a heartbeat watchdog that
catches the case load-error never reports — a page that loaded fine and then
wedged on a dead socket or a stalled decoder.
st-bridge.js is the page's half over @brightsign/messageport: registry-backed
identity (localStorage is origin- and quota-bound, the registry is not),
restart-instead-of-reload, heartbeat, and sync-backend reporting. Every method
degrades to a no-op off-platform, so it is safe to load unconditionally.
sync-backend.js decides whose synchronisation a group runs. Ours is
clock-derived and spans any mix of Android, web, Tizen and BrightSign; BrightWall
is frame-accurate and BrightSign-only. auto picks native when every member is a
BrightSign. The refusal that matters: native sync selected for a mixed group
downgrades and says why, because a half-synced group would look perfectly
synchronised on the dashboard while one panel drifted alone.
Dual output via output_mode single|dual|clone — a second widget loads the same
player with &screen=2 so the server can give it its own playlist.
Written against the BrightDeveloper docs; not yet run on hardware. The README
lists what is unimplemented, including the BrightWall runtime API, which that
doc set does not cover.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
a310d7d5b6 |
Stop the onboarding checklist counting a field no player reads
"Default Content" is persisted by the device route, snapshotted and restored by the settings layer,
offered in the device form in five languages — and read by nothing. Grep the whole tree and it
appears only in those places, the schema, and this checklist. It is absent from assemblePayload,
from every socket payload, and from all four players.
Counting it as "content assigned" therefore told the operator their screen was set up while the
screen itself went on showing "waiting for content" — the checklist confirming the one thing it
exists to confirm, incorrectly. It now counts only a playlist or a layout, both of which really do
put something on a display.
An existing test asserted the opposite ("any of the three ways of assigning counts"). It encoded the
same false premise, so it is replaced by one that pins the corrected behaviour along with the
evidence for it. The column and the form field are left alone — whether to implement or remove the
feature is a product decision, and this change only stops the checklist making a claim on its
behalf.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
e3b01a5c7f |
Make a content-only schedule actually put that content on the screen
The schedule dialog offers "Content (single item, optional)". The value was cross-tenancy validated and stored faithfully, and then read by nothing. services/scheduler.js acts on exactly two columns, layout_id and playlist_id; content_id is consulted nowhere in the codebase. So picking a file and saving produced a schedule that fired and changed nothing — while the calendar drew a block labelled with that filename, as confirmation that it would. Rather than thread a third override type through the engine and every player, the schedule now gets a playlist containing that one item. That is the shape the entire pipeline already understands: publish, assign, push, snapshot, offline cache and all four players work on it unchanged. It is published through the shared publishPlaylist path rather than by hand-rolling the snapshot, because players read denormalized fields out of published_snapshot (filename, mime_type, filepath, remote_url, per-item schedules) and a second copy of that shape here would rot the first time it changed. An explicit playlist override still wins and no throwaway playlist is created; a schedule with neither content nor playlist is untouched. 5 tests covering all of those, including that the generated playlist lands in the right workspace and that its snapshot carries the fields the players need rather than just the id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
3c1b2f62ee |
Make a recurring schedule respect its start and end dates
A recurring schedule ran forever. The engine compared weekday and HH:MM and dropped the date component entirely, so recurrence_end was never read: a campaign set to finish on the 1st was still switching screens weeks later. The same omission made a recurring schedule live before its start date. The calendar does read recurrence_end, so it drew the campaign as finished while the screens kept obeying it — the two views disagreeing is what made this hard to see from the dashboard. The end date is offered on the form, so it has to mean something. The date window is inclusive at both ends: an end date of the 5th means the 5th runs to its normal end time, which is what someone choosing that date means. An open-ended recurring schedule is untouched and still runs indefinitely. NOTE, because this one really does change live screens: any recurring schedule that has been running past its end date will now stop. That is the intended behaviour and was confirmed before making the change, but it is the difference between this commit and the calendar fix alongside it, which changes only what is drawn. 6 tests: stops after the end date, the final day still runs in full, does not run before the start date, unchanged inside the window, open-ended schedules unaffected, one-offs unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
416984d56b |
Draw a recurring schedule on every day it actually fires
The calendar is the operator's only view of what is scheduled, and it disagreed with the engine in
both directions for the two most-used repeat presets.
The expansion stepped by the recurrence unit from the schedule's original start:
- WEEKLY advanced a whole week at a time, so dayOfWeek never changed and a
FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR rule could only ever match its start day. Created on a Monday
it drew one event a week; created on a Saturday it drew nothing at all.
- The walk began at the original start under a 366-iteration cap, so a schedule begun more than a
year ago never reached the current week and drew nothing.
The engine evaluates day-of-week directly, so those schedules were running Mon-Fri the whole time.
Screens switched content the calendar said was not scheduled.
The expansion now walks the visible range day by day and applies the same rule the engine does, so
the drawing follows what actually happens. Cost is bounded by the window being displayed rather than
by how long ago the schedule was created, and the loop re-anchors the time of day on each step so a
DST boundary does not drift the instances.
Overlap is left to resolve as it already does: a shorter, higher-priority schedule takes over while
it is active and the recurring one resumes underneath when it ends. Nothing here changes what fires
— only what is shown — so this cannot alter live screens.
8 tests: five events for a Mon-Fri rule whichever day it was created on, a two-year-old daily
schedule drawing again, WEEKLY-without-byDay still meaning the start's weekday, INTERVAL honoured,
recurrence_end stopping the drawing, one-offs unaffected, and durations preserved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
a3b668d32f |
Treat an empty device_info as "nothing new", not as "forget what you know"
Every web and BrightSign player nulled seventeen of its own device columns every five minutes.
The browser player's refresh-register sends `device_info: {}` on a 300-second timer — it has nothing
new to report, it just wants a fresh playlist. But `{}` is truthy, and applyDeviceInfo is a blind
full-row overwrite with no per-field presence check, so it bound undefined for every column.
better-sqlite3 stores undefined as NULL rather than throwing, so the write succeeded and the row was
quietly emptied: android_version, app_version, screen_width/height, render_*, ota_status and
attempts, tier, the four capability flags and the four volume/brightness columns.
Android never hit it, because it always sends the full object. So this degraded exactly the client
family that cannot be inspected any other way — a browser player has no adb, and the dashboard row
is all there is. Fleet view, resolution diagnostics and any version-based logic read blank for them,
which also makes evaluating a browser-based platform look worse than it is.
The surrounding code already anticipates the refresh shape: recordReconnect and persistIdentity are
both gated behind `if (!isPlaylistRefresh)`. This call was the one that was not.
5 tests, including one pinning the driver behaviour the bug depended on — undefined binds as NULL
rather than throwing, which is why this was a silent five-minutely wipe instead of a loud error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
9c6b80c411 |
Apply a saved device snapshot only inside the workspace it was taken in
Per-device settings are saved against the hardware fingerprint so a panel that is deleted and paired
again comes back configured — name, orientation, playlist, blocked flag — without anyone visiting
it. That is deliberate and worth keeping.
A fingerprint is hardware-derived, so the same physical panel presents the same one whoever pairs
it. applyToDevice looked the snapshot up on fingerprint alone with no workspace comparison, and its
per-field guards only check that the referenced row still EXISTS, never who it belongs to:
if (s.playlist_id && db.prepare('SELECT 1 FROM playlists WHERE id = ?').get(s.playlist_id))
So a screen removed from one workspace and paired into another inherited the first workspace's
playlist and displayed its content, and `blocked` crossed the same way — a device arriving blocked
with nothing the new owner could see to explain it. The manual restore route already compares
workspaces before calling this, so the automatic re-pair path was the only place the check was
missing.
A mismatch is a quiet no-op rather than an error: re-pairing a second-hand panel into a different
workspace is a legitimate thing to do, it just must not carry the previous configuration along. A
snapshot with no workspace recorded still applies, so rows predating the column keep working.
5 tests: neither playlist nor block crosses, a mismatch does not throw, restore still works in full
inside the owning workspace (including a genuine block surviving a re-pair), and legacy rows are
unaffected. 882 server tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
14367af5f1 |
Keep a workspace on schedules that outlive their device group
Deleting a device group converts its group schedules into per-device ones so the screens keep their programming. That INSERT omitted workspace_id, which is nullable with no default, so every converted row landed with workspace_id = NULL. A null workspace does not merely look untidy — it makes the row unreachable in three directions at once, and they compound into the worst possible combination: invisible the schedule list and the all-screens calendar both filter on workspace_id undeletable PUT and DELETE refuse a row with no workspace (403) still live services/scheduler.js has no workspace filter, so it keeps firing every 60 seconds "I deleted the group but the screens still switch content at 9am, and there is nothing in the calendar to remove." The only way out was direct database access. The conversion now carries the workspace, preferring the schedule's own and falling back to the group's so a legacy group schedule that itself predates workspace_id still converts into a reachable row. A boot migration repairs rows already orphaned in the field by recovering the workspace from the device each one targets; anything still unresolvable is left alone rather than guessed at. 4 tests: the converted row keeps its workspace, is visible to the query the list and calendar use, preserves the actual programming rather than just the ownership, and the repair recovers a row orphaned before this fix existed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
9958c7c7be |
Save a layout by diffing its zones, not by deleting and re-inserting them
Nudging one zone in the layout editor and pressing Save destroyed unrelated tenant data across the
whole workspace, and returned 200.
The handler deleted every zone and re-inserted the same ids. Its comment claimed that was safe —
"Reuse each zone's id when supplied so device->zone assignments survive an edit (a fresh uuid per
save would orphan them)" — but reusing the id does not help, because SQLite runs the referential
actions on the DELETE and re-inserting the same primary key afterwards resurrects nothing. Two
things point at those rows:
playlist_items.zone_id ON DELETE SET NULL -> every multi-zone playlist item un-assigned, so
those playlists silently fell back to fullscreen
schedules.zone_id ON DELETE CASCADE -> every zone-bound schedule permanently deleted
No warning, no undo, and nothing in the UI to suggest a geometry tweak had touched schedules at all.
Zones are now updated in place, inserted when new, and deleted only when the editor actually removed
them. An update touches no foreign key, so nothing pointing at a surviving zone is affected. The
cascades are left exactly as they are: on a genuinely removed zone they are the correct behaviour,
and the tests pin that too.
4 tests: a moved zone keeps item assignments and zone-bound schedules, the geometry change is really
applied, adding a zone disturbs nothing, and removing a zone still un-assigns its items and removes
its schedules.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
c393cf8ab3 |
Hold overlay pushes to the same write check as every other fleet action
A PiP overlay renders across a live screen — an arbitrary web page, at full resolution, for as long
as the operator wants. That is a fleet-affecting write, but the three routes that perform it carried
only requireScope('full'), which gates API tokens and is a deliberate pass-through for dashboard
sessions. The file's own comment says so ("No-op for JWT sessions"), on the assumption that
something else covered that case. Nothing did.
Every sibling route pairs the two checks — device-groups.js gates POST /:id/command with
`requireScope('full'), requireGroupWrite`. These had only the half that does nothing for a logged-in
user, so a member who is refused on every other device mutation was accepted here.
requireFleetWrite restores the pairing on POST /, POST /clear and DELETE /, resolving the caller's
context against the workspace the same way the rest of the codebase does.
5 tests pin both directions: refused for a read-only member on all three routes and for an
unauthenticated caller, still allowed for a workspace_editor and for an org owner acting into the
workspace (actingAs, whose workspaceRole is null and must not read as a viewer).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
81f5d4f9f3 |
Stop shrinking hand-written text widgets into illegibility
A person typing font-size:16px into the Text/HTML widget got 0.15vw — 2.8px on a 1080p screen, 1.9px at 1280 wide, smaller again on anything narrower. Not clipped, not hidden: rendered at a size nobody can read, in the one widget whose entire purpose is hand-written HTML. renderText converted every px font size to vw (px/108). That conversion exists to rescue LEGACY Content Designer output, which used to publish absolute sizes as fontSize*10.8 px — dividing by 108 recovers the author's intended size and lets those widgets scale to any screen. Today's designer emits cqw and no px at all (frontend/js/views/designer.js), so the conversion only ever needed to apply to that legacy output. It was applied to everything. Now it runs only on designer-authored markup, identified by its absolutely-positioned elements — the same signal the dashboard already uses to decide whether a text widget can be reopened in the designer. Hand-written markup keeps its px exactly as typed, and legacy designer widgets are unchanged. Found by looking at the screen. The rendered HTML and the widget URL both looked correct in every check I ran; only a screenshot showed the text was microscopic. 5 tests covering both directions, including that a hand-written absolutely-positioned element without the designer's left-first shape keeps its px. Verified on an Android screen: a 60px heading and 24px body now render at their authored sizes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
b44f9d4f03 |
Serve a beta APK alongside the stable one, and let a display move between them
1.9.26's opt-in was passive: it stopped a sideloaded build being reverted, but there was still one APK slot and latest_version was the server's own VERSION, so a beta had to be installed by hand on every display. This makes it a real channel. - apk-cache tracks two slots. ScreenTinker-beta.apk is optional and reaches only displays with ota_beta = 1. - A beta must DECLARE its version in a sidecar ScreenTinker-beta.apk.version. The server cannot infer it — stable's version is the server's own constant because the two ship together, and reading it from the APK means parsing binary AndroidManifest.xml on the request path. If the sidecar is missing or unparseable the channel does not activate at all and opted-in displays keep getting stable. Failing closed matters: advertising a version that does not match the bytes served is the OTA-loop condition this fleet has been bitten by before. - The check and the download resolve the channel identically and fall back to stable identically, so apk_size always describes the bytes actually delivered. No APK change was needed — the client already fetches whatever download_url it is handed, so displays in the field can be moved between channels from the dashboard today. Switching back needed care. Stable is semver-OLDER than the beta it replaces, so the ordinary "never offer a downgrade" rule stranded the display and unticking the box would have been another silent no-op. The first attempt returned any non-opted-in display running a pre-release — which broke a #144 test, correctly: that would have dragged every existing pre-release tester back to stable the moment their server upgraded, the exact harm the opt-in exists to prevent. So the return now requires evidence we actually served that display the beta channel (devices.ota_channel_served, written once on change, not per check). A tester ahead of the server on their own build is left alone exactly as before. Documented in the README, including the constraint that makes the switch-back physically possible: beta builds must carry a versionCode no higher than the stable they branch from, because Android refuses to install a lower one. Equal numbers install in both directions. Verified end to end against a live server with two real signed APKs: stable serves 1.9.26, beta serves 1.9.27-rc1, an unknown channel falls back to stable, removing the version file deactivates the channel, and the full opt-in -> serve -> switch-back lifecycle produces offer / up-to-date / channel-return in order. 859 server tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
234bff795d | Merge docs/api-device-network-fields: document device network fields, pin the spec version | ||
|
|
301c76c3f7 |
Let a display opt in to pre-release builds, so a test build is not reverted under the tester
Handing someone a test build was a trap. A prerelease sorts BELOW its own release — 1.9.25-fix234d is semver-older than 1.9.25 — so a sideloaded display asked "anything newer?", was correctly told yes, and updated itself straight back off the build we had asked someone to test. Same versionCode, so Android installed it without complaint. Silent, and within minutes. That is what happened on #234: the reporter installed the fix, tested for an evening, and reported nothing had changed. They were right. Their tablet was running the old code again by then, and I had told them it was fixed without ever checking what the device reported. Adds a per-display opt-in (devices.ota_beta, default 0, checkbox next to the OTA toggle). When set, the display keeps a prerelease of the CURRENT core instead of being pulled back to its release. Deliberately narrow in one direction and deliberately wide in the other: - Narrow: it only holds a prerelease of the core already installed. A plain release, a -patchN build, an upgrade to a newer core, and a display ahead of the server all behave exactly as before, and the flag defaults off so a fleet that never sets it is unaffected. - Wide: an opted-in display is exempted from the superseded-prerelease guard. That guard would otherwise pin a tester on an old test build permanently — an older-core prerelease is never offered anything, so they would have to notice and sideload their way out. Writing the test is what surfaced that; opting in must never mean never updating again. 9 tests covering both directions, including that shipping a newer release pulls a beta display back onto the release line. 845 server tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
5297f091af |
Let a display's playlist actually be cleared
"No playlist" was an option you could select that did nothing. The picker offered it, and the change handler opened with `if (!newPlaylistId) return; // Don't allow deselecting for now` — so choosing it sent no request, changed nothing, and said nothing. The guard was honest about why: there was no way to do it. PUT /devices/:id has never read playlist_id (200, ignored), and POST /playlists/:id/assign can only ever set one. Reported on #234 as "I also selected No playlist ... it still showed the same video". It did, and my first explanation blamed the playlist-swap deferral. The deferral would have stranded it too — that is fixed separately and tested — but on this path nothing was ever sent, so the deferral never got the chance. DELETE /api/devices/:id/playlist, device-scoped rather than playlist-scoped because there is no playlist to authorize against when clearing. Ownership goes through checkDeviceOwnership like every other device mutation, so a viewer and a stranger are refused. Clearing an already-clear display is a no-op success, since it lives in a dropdown someone can pick twice. The now-empty playlist is pushed to the device so the screen stops, rather than leaving the old content up until something else happens to refresh it. Validated on an Android 12 emulator against the reporter's shape: cleared while a YouTube item was on screen, zero plays afterwards, device row cleared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
c483ef34dd |
docs(api): document a device's WAN/LAN addresses and SSID sentinel, and stop the spec version drifting
The published API reference (frontend/api-docs.html renders docs/openapi.yaml through Redoc) said version 1.9.0 while 1.9.25 was shipping. bump-version.sh updates VERSION, server/package.json, android versionName/versionCode and tizen/config.xml — the spec was simply never added to it, so it had been frozen since the public API landed and integrators were reading a version identity that no longer existed. Spec changes: - info.version -> 1.9.25. - Device gains its two network addresses, which are easy to confuse and are now described so they cannot be: ip_address is the PUBLIC/WAN address the server observed on connect (X-Forwarded-For aware, normally shared by every device at a site), local_ip is the device's OWN LAN address as reported by the player, which is the one that reaches a panel on site. local_ip is new; both were returned by GET /devices and neither was documented. - Device gains its flattened latest-telemetry block (wifi_ssid, wifi_rssi, battery, storage, ram, cpu_usage, uptime_seconds) — all returned already, none documented, all nullable because a web player does not report what Android does. - wifi_ssid's "permission" value is called out as a sentinel, not a network name: Android 10+ withholds the SSID without a location permission ScreenTinker only requests if an operator opts in. An integrator who does not know that renders "permission" to an end user as their Wi-Fi name. Drift prevention, because a wrong version number is silent and nobody re-reads one they trust: - bump-version.sh now writes the spec version too, anchored to info.version (operation- and schema-level version keys are indented deeper and untouched; openapi: 3.1.0 is unaffected). - Three contract tests: the spec version tracks package.json, the two addresses stay documented and distinct, and the SSID sentinel stays explained. No new endpoints — audited every public router's routes against the spec and all are documented. 830 server tests + the 5 contract tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
a25c6827a7 |
Show every plan on the admin tab, with who is on each
The admin plan table read /api/subscription/plans, which filters `active = 1` because that endpoint feeds the public pricing page. So the one screen meant to show the operator what plans exist could not show a hidden one — a comped or beta tier was invisible to us as well as to customers, with no way to see it existed or who was on it. Found immediately after creating exactly such a plan. GET /api/admin/plans (platform-admin only) returns every plan plus, per plan, the number of accounts, organisations and screens on it. Visible plans sort first so the list still reads like the pricing ladder, with hidden ones after and badged. The public endpoint is deliberately untouched: hiding a plan has to keep working, and the test pins BOTH directions because they pull against each other — the admin list must include an inactive plan, and the public list must never leak one. Counts are the point, not decoration: "how many people are on what plan" is the question you actually ask of this screen, and it was answerable only by hand in SQLite. Also carries a warning for accounts whose plan no longer resolves. Both users.plan_id and organizations.plan_id are FK-enforced to plans.id and there is no delete-plan route, so this should be unreachable — but migrations here do rebuild tables with foreign keys off (the tenant-cascade one rebuilt thirteen), and that is exactly how a row would be orphaned. Six lines for a state that would otherwise be silent. Strings added to en/de/es/fr/it/pt. Not hi: it has no admin translations at all, lookup falls back to English, and four Hindi strings among forty English ones would read worse than consistent English. |
||
|
|
3159f94107 |
Make unblock stick, and say so when a device is refused
A customer blocked a screen once to see what the button did, then spent an evening
unable to get it back. Three separate faults stacked up.
1. Unblock did not stick. applyToDevice() restores `blocked` on re-pair — deliberately,
so a block cannot be shrugged off by deleting the device — which makes the SAVED copy
the real authority. Unblock only ever wrote `devices`, so the saved row stayed 1 and the
next delete + re-pair silently re-blocked. There was no way out from the dashboard at
all: unblock, re-pair, refused, repeat. Block and unblock now both mirror to the saved
copy, so the survives-a-re-pair property is deliberate rather than a leftover.
2. The refusal was invisible. handleServerRejection() clears credentials and calls
onUnpaired, but only ProvisioningActivity ever assigned that callback — and it is long
gone by the time playback is running. So the screen sat on "Connecting to server" and
the player eventually blamed the URL, sending the operator off checking their network
while the server had already said exactly what was wrong. MainActivity now handles it.
(This half was mine: clearing those leaked callbacks to stop the relaunch loop removed
the only thing that surfaced a rejection. It was a broken path — it fired into a
destroyed Activity — but it was the only one, and MainActivity should have owned it.)
3. The reason was thrown away. The server sends device:auth-error {error: "Device
blocked"} and the client discarded it. It is kept now, and a blocked screen says so
instead of implying a network fault. Localised in all six languages, matching the other
on-screen status strings.
Also ran on prod: one stale saved block cleared (fingerprint ef6540376599, the reporter's
tablet), DB backed up first. It was the only such row.
Tests pin both directions, because the two are easy to confuse: unblock must clear the
saved copy, AND a genuine block must still survive a delete + re-pair.
|
||
|
|
c779d62d63 |
Add an operator override for self-update on MDM-managed panels
A player stands down from self-updating when another device owner manages the panel, on the assumption that the MDM distributes packages instead. That assumption does not always hold: an operator may run an MDM for policy alone and still want ScreenTinker's OTA to own the player. Until now there was no way to say so — the stand-down was a client-side decision with no operator input. OTA_ALLOW_MANAGED_DEVICES=1 makes the server advertise `allow_managed: true` in /api/update/check, and players skip the stand-down. Default off: the safe behaviour stays the default, and only an explicit opt-in changes it. Absence is not consent. The client parses the field with a false default, so a newer player against an older server that has never heard of it still stands down; and the server always emits the key, so a player can tell "the operator said no" from "this server has no opinion". Config parsing is strict for the same reason — only 1/true enable it, and anything else, including a plausible typo like "ture" or "yes", lands on the safe side rather than riding JavaScript truthiness. This deliberately does NOT grant silent install. Off device-owner, and without DELEGATION_PACKAGE_INSTALLATION delegated by the MDM, Android still raises a confirm dialog somebody has to accept, so the override alone will not fix a fleet whose installs are failing at that dialog — delegating the scope is the real fix there. The README says so at the point of use, because reaching for this flag is the natural mistake. Only reachable because the stand-down now runs after the version check rather than before it; it needs the server's answer in hand to consult. |
||
|
|
0df7f58b26 |
Parse MAX_FILE_SIZE, and document what else caps an upload
Follow-up to #233, which made the upload ceiling configurable — the right call, 500MB is genuinely too low for video. An environment variable is a string, so the value reached multer's limits.fileSize as text where a number is expected. That survives some comparisons through coercion and misbehaves in others, which is the worst kind of bug to find later; the line directly above it already used parseInt for the same reason. It is parsed properly now, and a suffix is accepted — someone raising a limit for video is choosing "about 2GB", and 2147483648 is easy to mistype by a factor of ten. An unparseable value falls back to the default rather than becoming NaN or zero. Either would reject every upload on the instance, from a typo in an env file, with nothing on screen to explain it. The documentation matters as much as the code here. MAX_FILE_SIZE is the LAST limit in the chain: nginx caps the request body with client_max_body_size and returns 413 before the app is reached — our own deployment sets 500M — and Cloudflare caps uploads per plan at the edge. Raising the variable alone often changes nothing, so the README now says so, with the nginx directive and a note that an upload failing with nothing in the server log never reached the server. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
3e3d0081fe |
Keep the smoke test out of npm test, and update the lockfile
Two mistakes in the previous commit, both of which broke CI. The lockfile was not regenerated after adding puppeteer-core to devDependencies, and `npm ci` requires the two to agree — so every job that installs dependencies failed before running anything. The smoke test was also placed in test/, which I described as keeping it out of `npm test`. It does not: `node --test` globs that directory, so the runner picked it up regardless of intent, tried to drive a browser as a unit test, and failed. It now lives beside the server as smoke-ui.js, with a note saying why, so the next person does not put it back. Verified the way it should have been the first time: npm ci succeeds, native modules still load, npm test is 807/807 with no browser involved, and `npm run smoke` is 32/32 on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
29ae184b14 |
Add an opt-in browser smoke test
A whole class of defect found today was invisible to the unit suite, to a syntax check and to review, and appeared only in front of a browser: a context menu whose only item read "schedule.ctx_new", pointer handlers stacking on every calendar render so one drop fired five PUTs, and a week grid that scrolled sideways on a phone. Nothing in the repo could have caught any of them. This keeps the checks that earned their place and throws away the scratch scripts around them. It boots a server, drives every view, and asserts each view renders, none raises an uncaught error, no untranslated key reaches the screen, the calendar binds its handlers once however many times it re-renders, and nothing overflows horizontally at phone width. Deliberately NOT part of `npm test`. It needs a real browser, which CI does not have, so it is `npm run smoke` and exits 0 with an explanation when puppeteer or Chrome is missing — a test that fails for want of tooling teaches people to ignore failures. puppeteer-core rather than puppeteer, so installing it does not pull down a private copy of Chrome; it drives whichever one is already there. Verified both ways: 32/32 against current main, and it fails on the listener stacking when that fix is reverted. The missing-key case is covered by the unit guard instead, since a context menu only exists once it has been opened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
7747d7e051 |
Put Members in the nav, reveal titles on touch, and stop a stale heartbeat killing a socket
Three loose ends from the interface review. Inviting a colleague is a core action and had no entry in the navigation at all. The only route was an unlabelled icon beside the workspace name, or typing the URL. There is now a Members item, translated, which resolves to the active workspace so the static link needs no id. The Teams entry it sits near stays hidden, since that feature is still switched off. A native title= is hover-only, so the icon-only buttons — rename a wall, remove a device from one, manage members — explained themselves on a desktop and said nothing on a touchscreen. Long-pressing one now shows its label. The text was already there and already translated; it simply had no way to reach a finger. The last one is the bug that took a real screen dark. A device row can vanish while its socket is still heartbeating, and the telemetry insert then failed a foreign key. That throw was fatal in a way that is hard to guess: the safe-socket wrapper reads a throwing handler as a broken one and disconnects the socket server-side, and socket.io deliberately does not retry that kind of disconnect — so the player sat doing nothing until a person reloaded it. A heartbeat for a device that no longer exists is an ordinary race, not a fault worth ending a connection over; the write is skipped and the register path answers unpaired, which is the reply that actually helps the client recover. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
618af0811a |
Translate the labels that never went through t()
A title= is a tooltip the user reads and an aria-label is what a screen reader says, but fourteen of them were hardcoded English. They were invisible to the key checks added earlier precisely because they never call t() — so a French user hovering the only route to workspace members read "Manage members", and a German screen reader announced every modal's close button as "Close". The user-visible ones matter most: the workspace switcher's Manage members and Rename, the video wall's rename and remove, and the dashboard's select-for-wall. All are translated into every active locale, along with the close buttons. A test now rejects a capitalised literal in a title or aria-label, since that is the shape this takes and nothing else catches it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
0a9a749475 |
Make help tips reachable, and explain the pages that had none
An audit of every view turned up two problems with the in-product help. The tips only appeared on :hover. On a tablet or a phone there is no hover, so the entire explanation layer was invisible to touch users — a large share of the people administering signage — and unreachable from a keyboard. Tapping a marker now opens it, Escape or a tap elsewhere closes it, and the marker is focusable so Tab reaches it and a screen reader announces it. Bound once at the document level and applied by observing the DOM, because views render from about twenty call sites and modals appear later still; hooking each one would have left the next new route silently unreachable again. Four views had no tip at all. Playlists is the important one: a playlist is the concept the reported confusion was actually about, and the page said nothing about what one is or how it reaches a screen. Activity and Settings now have one too. Help does not, because it is the help. The schedule tip described a product that no longer exists — it said to click Add Schedule, predating the drag, resize and right-click gestures. Rewritten. All four are translated into every active locale rather than left to fall back to English, since a tip falling back is a non-English user being handed an English paragraph at the moment they are confused. hi.js stays deliberately empty per the note in that file. Tests now check that every tip is translated everywhere, and that a tip marker never names a string that does not exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
832a9c9bb2 |
Draw a schedule that runs past midnight
10pm to 4am is an ordinary signage schedule and the playback engine has always understood it — schedule-eval treats an end before a start as a wrap. The calendar did not. It computed four minus twenty-two, got negative eighteen hours, and drew an eighteen-pixel sliver at 10pm with nothing at all after midnight. The schedule played correctly while appearing broken. An overnight window is now split into the pieces a week grid can draw: the part before midnight on its own day, the part after it on the next, squared off where they meet so they read as one window rather than two schedules. The tooltip names the whole span, since neither half shows it alone. A Saturday night spill is simply not drawn rather than wrapped round to Sunday, where it would appear to have played six days early. Dragging one is refused. A drag describes a window inside a single day, so applying it to a wrap would clamp it into that day and silently destroy the schedule — the same reason a recurring schedule's day cannot be dragged. Verified in a browser against a real 22:00 to 04:00 schedule: 88px on Tuesday night, 176px on Wednesday morning, alongside an ordinary daytime block. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
a62396c2dd |
Attribute a widget play to the widget that played
A widget playlist item carries its id in widget_id and has no content_id at all. The player sent only content_id, so a widget play arrived with nothing identifiable and was written with both columns null — and play_end bound content_id to BOTH columns, so that row could never match itself and was never closed or given a duration. Nothing looked broken: a row existed for every play. It just named neither what had played nor which widget, and never ended. Reports read empty for any screen showing a widget, which is most of the interesting ones. Seen on a live screen playing a single widget: one open row, both columns null. The player now sends widget_id alongside content_id, and a name falling back through the fields a widget item actually has, so the event records what played even when neither id resolves. The server prefers an explicit widget_id and keeps the old content_id sniff as the fallback for players that predate this, so an older client that puts a widget id in content_id still attributes correctly. Found by reading a real screen's proof-of-play rather than the code. The first attempt at the fix broke the statement outright — the explanatory comment was placed inside the SQL template literal, where a JS comment becomes SQL, and the server logged `near "/": syntax error` on every play_end. Comments now sit above db.prepare(), with a note saying why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
268bd5e7fb |
Stop shipping untranslated keys as user-facing text
Driving the app in a real browser showed a context menu whose only item read
"schedule.ctx_new". t() returns the KEY when a string is missing — it never
returns undefined — so a missing key renders literally, and the common
`t('x') || 'A readable default'` guard is dead code: the key is truthy, the
default can never fire, and the pattern hides the problem instead of covering
it. Every occurrence of it in the app was doing exactly that.
Nineteen strings were affected, most of them predating this work: fifteen in
the self-hosted update panel and four in video walls, all of which have been
showing raw keys to users. The intended text was recovered from the dead
defaults, so the wording is the authors' own, and the defaults are removed
rather than left to imply a safety net that does not exist.
A test now walks the views for the keys they actually ask for and fails on any
that English does not define, and separately rejects the `|| default` pattern.
Neither problem is visible to a syntax check, a unit test, or review — only to
someone looking at the screen — so the guard is the only thing that keeps them
from coming back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
68dd1b3e05 |
Tell people what to do next, from what the account actually contains
A user reported not knowing how to get content onto a screen. There was already onboarding — a modal wizard — but it is gated on a localStorage flag: skip it once and it never comes back, and it never knew whether you succeeded at anything. Someone who closed it was left with no thread to pull, which is exactly what was described. A second tour would repeat that mistake. Tours are dismissed and forgotten, and they describe the product rather than the account. This is a checklist on the dashboard that reads real state, so it cannot claim you have done something you have not, it is still there tomorrow, and it names the one thing to do next rather than everything the product can do. The steps are the shortest true path to a screen showing something: connect a screen, add content, put it in a playlist, send it to the screen. Only the last one cannot be satisfied by creating an object and walking away — a screen has to actually be pointed at something — so an account full of playlists with nothing playing is correctly reported as unfinished, which is the failure that was reported. Steps stay in dependency order, so nobody is sent to a page they cannot use yet. It disappears on its own once the first screen is live and can be hidden before then, so it never nags someone who already knows the product. Once hidden or finished it costs no extra request at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
ce7d8642fa |
Make the calendar's gestures work on a touchscreen
The drag gestures did nothing on a phone. touch-action was set to none only once the pointer had already travelled far enough to count as a drag, and by then it is too late: a browser decides at touch-START whether a gesture scrolls the page, so the page scrolled, the pointer stream was cancelled, and the block never moved. The rule that works for a mouse cannot work for a finger. Touch now arms by HOLDING. A press that stays put for a moment takes the gesture over — at which point scrolling is suppressed and the block dims — while a press that moves first is left alone as the scroll it plainly is. Everything that is not a drag still scrolls exactly as a phone user expects. A mouse or pen is unchanged and arms as soon as it has travelled. Tapping empty space now creates a default one-hour slot at that time. On a phone that is the only practical way to create, since drawing a range with a finger is awkward, and on a desktop it is a shortcut worth having anyway. The arming rule is a function rather than a pointerType check at each site, so the touch and mouse paths cannot drift apart, and it is tested — including that the hold is long enough to mean intent without feeling stuck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
d2d7911efb |
Make the calendar's blocks easy to grab and move
Direct manipulation existed but was awkward, and one part of it was outright broken. A drag was recognised on ANY pointer movement, so the pixel or two of travel in an ordinary click counted as a drag and suppressed click-to-edit — the most common interaction on the calendar would have felt broken. A press now has to travel a few pixels before it becomes a drag. At 28px per hour a fifteen-minute block was seven pixels tall. Legible, but not something a pointer can reliably hit, and its resize grip would have covered the whole block. Rows are 44px, which makes the smallest block an 11px target while still fitting a full day on a laptop screen; a test pins both halves of that trade so neither can be tuned away silently. That height had been written as a bare 28 in five places in the view that all had to agree with the module — it is now one constant. The rest is feedback. A block shows a grab cursor, dims while it is being moved so it is clear what is travelling, and its grip is taller with a visible edge. While dragging, the grid switches to a grabbing cursor and suppresses touch scrolling, so the gesture works on a touchscreen instead of panning the page. Pointer capture is released and the chrome reset on every exit path, including a cancelled drag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
98bde220ff |
Make the week calendar directly manipulable
The calendar rendered schedules but could not be used to change them. Creating or moving anything meant opening a dialog and typing times, which is the wrong instrument on a week grid: the grid already shows exactly where a thing goes, so the grid should be where it is put. My previous change made the grid easier to READ — all screens at once, a colour and a name per target — and left the interaction untouched, which was only half of what was asked for. Three gestures now share one pointer loop. Dragging empty space draws a slot and opens the dialog prefilled with the time drawn, so the gesture supplies the times and the dialog supplies only what it alone knows. Dragging a block moves it. Dragging its bottom grip resizes the end. A live ghost shows the range as a readable time while dragging, and nothing is committed until release, so an accidental nudge costs nothing. Right-click acts on what is under the pointer: new here, or edit, duplicate and delete on a block. Dragging a repeating schedule sideways is refused. A one-off's day IS its date, but a repeating one's day comes from its rule, so moving an instance across columns would rewrite the recurrence for every other occurrence — a different operation, and not one a mouse gesture should perform silently. Changing a repeating schedule's TIME does still edit the whole series, since a series has one time of day, so that is confirmed out loud rather than assumed. The arithmetic is a separate module of pure functions, because it is the part that fails quietly: a block that ends before it starts, a move near midnight truncated instead of slid back, or a stamp built with toISOString() putting anyone west of Greenwich on the previous day. Tests pin each of those. That last one was already present in the create path and is fixed here too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
433fbef191 |
Re-establish a player socket the server closed
socket.io does not retry every disconnect. On 'io server disconnect' it stands down deliberately and waits to be told to reconnect. The player assumed the opposite in two places: the disconnect handler stopped the watchdog because "socket.io owns the reconnect once it KNOWS it's down", and verifyLivenessSoon skipped a present-but-disconnected socket for the same stated reason. So when the server closed a socket — a handler throwing, a deploy, an eviction — nothing was left watching and the player stayed down until someone reloaded the page. That is what it does on a wall: nothing, indefinitely, with no error on screen. It happened to a live panel whose heartbeat hit a constraint error; the server dropped the socket and the display sat dark until reloaded by hand. A supervisor now backs up every disconnect the client did not itself initiate. It re-establishes only a socket that is genuinely not connected, and only after a grace longer than socket.io's maximum backoff, so the reconnection socket.io does own is never raced. Our own teardown is excluded, since connect() closes the previous socket before opening the next and supervising that would fight the attempt already in flight. A resume now hands a stranded socket to the supervisor rather than assuming someone else has it. The decisions are pure functions alongside the existing watchdogShouldReconnect, so they are testable without a browser, and a test asserts the grace still exceeds the configured backoff ceiling if either is ever retuned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
6268c1a4c0 |
Let a screen-only panel clear its identity from the URL
A display panel has no keyboard, no pointer and usually no way to clear site data, but the URL it loads is configurable from whatever manages it. Loading the player with ?reset=<token> now discards this install's identity so the panel returns as a new device with a fresh pairing code — the recovery path when a panel is holding an identity that belongs to a different screen, and the ordinary path when redeploying a panel to another site. It applies once per token, which is the whole design. A configured URL is permanent; nobody goes back and removes the parameter. A reset that fired on every load would drop the pairing on every reboot and present as a screen that cannot hold its pairing at all — which reads as an intermittent server fault rather than the URL doing exactly what it was told. The applied token is remembered, so ?reset=1 left in place forever resets exactly once; any other value resets again. The server URL is deliberately kept, since clearing it would strand a panel that cannot be typed into, and the cached playlist and layout are dropped so the new device does not come up showing the previous screen's content. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
2bcc46bc26 |
Give each player install its own identity
The web player derived its fingerprint entirely from hardware traits: user agent, screen geometry, colour depth, timezone, core count, platform and a canvas raster. Every one of those describes a model rather than a unit, so two identical panels produced the same value and the server, which matches on that value globally, treated them as one device. Two UniFi Pro Displays at different sites both produced web-m73u8w-5f; the second could not be brought online, and the row ended up shared, each display evicting the other every thirty seconds. The identity a player presents is now hardware plus a random per-install salt kept in localStorage, so two identical panels differ from their first connection. This is what the Tizen player has always done; the web player is brought in line with it rather than given a new scheme. The hardware value is still sent, but only as a hint, and only to move a caller that has ALREADY authenticated with a device id and token onto its own row — which is how an existing player carries its identity across this change. A caller without credentials never resolves through it, however few rows it appears to match: one row recorded does not mean one display exists, and that distinction is the whole bug. Such a caller is provisioned a new device, which costs one pairing code and cannot be wrong. Older clients are unaffected. They send no hardware value, so they take the exact-match path exactly as before, and both keep working: the APK's fingerprint already includes ANDROID_ID and the Tizen player's is already a stored random id, so neither ever shared an identity between units. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
f09dee810c |
Record where a player crashed, not just what it said
Three players died with "Cannot set properties of null (setting 'textContent')" and it could not be traced. The message names no file, and every candidate line in the current player was ruled out by inspection: the unguarded writes all build their element with createElement, every getElementById target exists in the markup, and the script runs after the markup. That points at an older cached build still served by the service worker, which is exactly the case where reading current source proves nothing. The ErrorEvent already carried filename, lineno and colno. They were being discarded. Keeping them makes the next occurrence name its own line. Composed to fit the 200 characters the server stores, so the location is not truncated away: message plus one location, basename only since the origin is already known from the device. A promise rejection has no filename, so it falls back to the first stack frame. A cross-origin script, which reports a bare "Script error." with nothing else, says so rather than emitting :0:0 as if that were an answer. A resource load failure still is not a crash; a test guards that, since this touched the handler that decides it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
b34d73dbb9 |
Report zero event-loop lag when a window recorded no samples
A sampling window that recorded nothing leaves the histogram empty, and an empty IntervalHistogram reports its mean as NaN. Its percentiles return a floor instead, which is why only the mean was affected and why this went unnoticed. NaN then survives every arithmetic step in the sampler without complaint and becomes visible only at the edge, where JSON.stringify renders it as null. So /api/status served "mean_ms": null while nothing raised an error anywhere, and any consumer of that gauge read null instead of a number. Non-finite readings now report 0, which is the honest value: no samples means no measured delay. Applied to every field so a later change to the histogram source cannot reintroduce this one field at a time. Found by CI rather than locally, because an idle window is far likelier on a loaded runner with several test servers in flight. The failure was real; the new tests establish the NaN premise and the null serialisation directly rather than relying on that timing to reproduce. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
792013e36c |
Record auth rate-limit rejections so they can be measured
The auth limiters are app.use middleware that return 429 before the handler that writes activity_log, so a rejection left no trace anywhere — the limit suppressed the record of itself. Four production IPs sit at exactly ten logins a minute and there was no way to tell whether that is one attacker or an office whose staff share an egress address, which is the difference between the limiter working and the limiter locking out customers. The rejection count does not answer that. The number of distinct accounts per IP does: one account hammered is the limiter doing its job, several accounts each denied a few times is a shared egress. Both are now recorded, and a platform-admin-only endpoint reads the tally back. Identifiers are salted-hashed with a per-process salt and only ever counted, so this cannot accumulate into a roster of a customer's addresses. Memory is bounded per key and overall, and says when a count was capped rather than silently undercounting. Behaviour is unchanged: same status, same body, and the recording is wrapped so telemetry can never break the limiter. A test asserts ten through then 429 with the identical response shape, since a diagnostic that alters what it measures is worse than none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
a93f65b20a |
Only store a device fingerprint against a device that still exists
A player that reconnects after its row was deleted sends the id it still has cached. device_fingerprints.device_id has a foreign key to devices(id), so writing that id back fails the constraint. The throw was caught, which is why this looked harmless, but the catch abandons the whole fingerprint block: last_seen is not updated, the reinstall link is not made, and the settings restore never runs. That restore exists specifically for the post-delete re-pair, so the failure landed exactly where the feature was meant to help and a re-paired panel came back with its orientation, name and playlist reset. Production shows 37 of these, timestamped identically to the "sending unpaired" log lines — the same event seen from the other side. The incoming id is preferred, then whatever is already stored, and only an id that still resolves is written; otherwise NULL, which the column allows and which ON DELETE SET NULL already leaves behind. The INSERT path a few lines below had this guard; the UPDATE was missed, and it is the one that fires. Tests cover the deleted-id reconnect, that last_seen still advances, and that live ids are unaffected. One asserts the raw unguarded statement really does raise FOREIGN KEY constraint failed, and another asserts the guard is present in the handler itself, since the others exercise a mirror of that statement. Also ignores *.sqlite / *.sqlite3, which the existing *.db rules missed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
8f2195a6e3 |
Recover an unpaired player without needing a keyboard
A display panel usually has no keyboard and no pointer, so a recovery path that waits for input is not a recovery path. When the server stopped recognising a device, the player revealed the server-URL form — typing that cannot happen on a screen-only panel — and hid the pairing section, which was the one thing that would have rescued it. The screen then sat on "Device was removed from server" until someone physically reloaded it, even though the player was still connected to the right server and could have asked for a new code itself. Both handlers now drop the stale credentials and reconnect on a short countdown. Reconnecting re-registers with no device_id, so the server issues a fresh pairing code and the existing registered handler puts it on screen. config.serverUrl is known-good by construction — we are talking to that server at the moment we are rejected — so there is nothing for a human to re-enter. The URL field stays editable throughout, and typing cancels the countdown, so someone who does have a remote and wants to repoint the player is not yanked mid-edit. The countdown is the same helper the first-boot path already used, lifted out and shared rather than duplicated; its input listener is bound once at setup instead of per countdown, which would have stacked a listener each time. The Android player already behaved this way (ProvisioningActivity repair mode); this brings the web player in line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
e9cf7801c3 |
Alert once per outage instead of once per dedup window
A display going offline is one event, but the alert loop re-evaluated every still-offline device on each 60s tick, so the 2-hour dedup window re-qualified the same outage over and over. One closed browser tab produced six "your display is offline" mails overnight, and would have kept going to the 24h cap. Repeat suppression now keys on devices.offline_alert_heartbeat: the heartbeat value an alert was already sent for. A device can only come back by sending a heartbeat, so a later outage always carries a later value and the marker invalidates itself on recovery — no cleanup, no state to reset. Keeping it on the row also fixes a second source of duplicates: the in-memory window used to empty on restart and re-alert the whole offline fleet. The window stays, doing the job it is actually suited to — bounding how often a flapping device can alert — and is checked before the marker is written, so a rate-limited alert is deferred rather than marked and dropped. The backfill runs once, via schema_migrations rather than the migrations array: statements there re-run every boot, and an IS NULL backfill would then swallow the first alert of any outage beginning after the last restart. It marks currently-offline devices as already-alerted so upgrading does not itself mail about outages the owner has already been told about six times. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
9bcdaacd2c |
Show every screen's schedule on one calendar
The week view could only answer "what plays on THIS screen". With one screen at a time an empty grid is ambiguous — nothing scheduled, or the schedule points at a different screen? That ambiguity is what a user actually hit. Adds an "All screens" scope alongside the per-screen one. Every block now names its target, with a stable per-target colour and a legend, so a full grid stays readable. The scope for all=1 comes from the request's resolved tenancy and is filtered on nothing else, so the tenant boundary rests entirely on that resolution. Tests pin both halves: an ordinary tenant gains nothing by naming another workspace in the query string, and the platform-admin act-as path still resolves the workspace it asks for — the two are easy to mistake for each other, so they are asserted separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
0030acc526 |
Store a schedule in the timezone its screen runs in
Creation and playback disagreed about which clock a schedule's hours are on. The player evaluated blocks in the device's zone — an operator override, else whatever the player's OS reported. Creation defaulted to a bare 'UTC', because the dialog never asked for a zone and the server filled the silence with one. So hours typed as "09:00 to 17:00" were stored as UTC and evaluated somewhere else. For anyone outside UTC the schedule was correct and appeared to do nothing, opening hours later than intended, with nothing on screen to explain why. A user in Asia/Tokyo hit exactly this and reported it as "I added something and it didn't appear". Both sides now resolve through lib/device-timezone, so they cannot drift: an explicit device override wins, then the OS-reported zone, then null. A legacy 'UTC' override counts as unset, since that was the old default rather than a deliberate choice and a genuine UTC deployment is indistinguishable from an unconfigured one. A new schedule inherits its target's zone — the device's, or for a group its leader's, falling back to the oldest member that reports one. A zone named explicitly by the caller still wins; this only fills the silence. A target that has never reported one still lands on UTC, which is the previous behaviour made explicit rather than assumed. The dialog now states which clock the hours are on, and says so differently when that clock is not the operator's own. Stating it is the other half of the fix: the server can pick the right zone, but the user still has to be able to see it. Tests pin both directions and, most importantly, that creation and playback resolve identically from the same device row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f4595f017a |
Validate kiosk style values as CSS rather than as HTML
The kiosk page interpolates style.fontFamily and style.background into a <style>
block, escaped with escapeHtml. That is the wrong tool twice over: it escapes
& < > " ' but not { } ;, and inside a raw-text <style> element the entities it does
produce are never decoded, so it neither contains the value nor renders it correctly.
A value could therefore close the declaration, close the rule, and append its own —
putting an attacker-chosen rule on every panel showing the page. There is no XSS,
since </style> stays unreachable, but a url() in an injected rule is an outbound
request from every display, which is a beacon and a cross-site tracking channel.
Both values are now checked structurally rather than against a value allowlist,
because background is a free-text field: linear-gradient(), rgb() and url() are all
legitimate and keep working. Only characters that could terminate the declaration or
open a new rule are refused, along with comment syntax (which can swallow the
declarations that follow) and control characters. font-family needs no parentheses,
so it gets a tighter allowlist.
Tests cover both directions — injection refused and falling back to the default, and
ordinary gradients, colours and font stacks passing through untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
866e35a2b2 |
Clear a device's OTA rate state when it proves its identity
The update check is deliberately unauthenticated — every client version has to be able to ask, including old ones that never learned to send a token — and it keys the rate breaker on the caller-supplied device_id. Keying on IP is not available either: the fleet SNATs behind one address, so per-IP would collapse a whole site into a single bucket. The result was that the bucket belonged to whoever cited the id rather than to the device that owns it. A handful of requests naming a panel's UUID left that panel in rate-backoff, un-updatable for up to half an hour at a time and renewable indefinitely, while every other device stayed healthy. Rather than adding auth (which would strand old clients) the state is now self-healing: when a device registers on the /device socket with a valid device_token its bucket is cleared. Noise is still possible, but it now lasts until the panel's next genuine reconnect instead of as long as someone keeps poking. This is not an escape hatch from the breaker's real job. A device stuck in an update loop is re-registering legitimately, and clearing its rate state on each genuine reconnect is what a healthy device looks like; the loop protection that matters is the download guard. The version-keyed bucket that covers old clients sending only ?version= is a separate namespace and is deliberately not reachable this way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b255f2bfe1 |
Resolve proof-of-play references instead of trusting the reported id
Players replay a cached playlist, so the id reported on play_start can outlive the row it names. play_logs.content_id carries a foreign key to content(id), and the id went straight into the INSERT — so deleting a piece of content made every subsequent play of it throw, and the whole event was discarded by a catch that logged no identifiers. On production this fired roughly 360 times in six hours and wrote zero rows in 24h: Reports was recording nothing at all, for everyone. Widgets had a quieter version of the same bug. play_logs.widget_id exists and was never written, so a widget play could not be attributed even when it did insert, and play_end matched on content_id alone and so could never close a widget's open row. The reported id is now looked up before use and written to whichever column it belongs to. An id matching neither degrades to null references rather than losing the event — content_name still records what played. A play event for a device that does not exist is still refused; that foreign key is a real invariant, not an obstacle. play_end matches on either column, and breaks ties on id: started_at has second granularity, so two plays inside one second tie on it and the wrong row could be closed. The new tests caught exactly that as flakiness before it was pinned. The catch now logs the event, device, content and zone. Without them this was undiagnosable in production. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
76a8a16130 |
Update sharp to 0.35.x, and repair the corrupt PNG fixture it exposed
sharp decodes uploaded files directly (lib/content-ingest.js, routes/content.js both call sharp(file.path) on whatever a user uploaded), so its bundled libvips is part of the request path rather than a build-time detail. Moves 0.33.5 -> 0.35.3, libvips 8.15 -> 8.18. Validated against the calls this codebase actually makes, because it is a major bump: metadata() still reports EXIF orientation (1/3/6/8 all round-trip, which is what lib/media-orientation.js exifSwapsWH and the rotation-aware dimensions depend on), a bare .rotate() still auto-orients, and resize().jpeg().toFile() is unchanged. png/webp/jpeg/gif/avif all still encode and decode, and malformed input still throws rather than crashing. The new libpng is stricter, which surfaced a latent problem in the AUTH-01 test: its 1x1 PNG literal had a corrupt IDAT chunk whose stored CRC did not match its data. The old decoder accepted it; the new one refuses with "vipspng: libpng read error", so no thumbnail was written and the content-gate assertions failed with a 404 that reads like an auth regression. Replaced with a PNG whose every chunk CRC verifies. The stricter decode is the correct behaviour and is kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cbc00515e2 |
Scope device serialization to what each endpoint actually needs
A device row carries two fields that are not ordinary data: device_token, the credential the player proves with on the /device socket, and settings_pin, which unlocks the player's on-device settings menu and so hands physical control of the panel to anyone holding it. device_token was already stripped everywhere. settings_pin was not — it went out on both the collection and the detail endpoint. The dashboard does show it, but on one screen only: the device detail page, which fetches a single device. The collection endpoint had no consumer for it and was returning the PIN for every device in the workspace on every load. The detail endpoint keeps it, so that page is unchanged. The list no longer sends it. Same data, much smaller blast radius, no feature lost. Tests pin the split in both directions — absent from the list, present on the detail, and the socket credential absent from both (asserted on the whole serialized payload, not just the top-level key, so a nested echo would fail too). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
59c536c923 |
Keep a solo widget mounted, and size its keyboard to the viewport
Two problems on a panel showing one fullscreen widget, both visible as flashing. The player re-navigated the WebView every duration_sec. PlaylistController.next() requests a playlist refresh between plays and playCurrentItem() re-issues the item unconditionally, so a one-item playlist reloaded the same URL forever. The existing dedupe guard only covers the playlist-update path, so it logged "not restarting" AFTER the reload had already happened. On an interactive widget that also discarded whatever the viewer had typed. showWidget() is now idempotent: same URL with the widget already on screen returns without re-navigating, and the cached URL is cleared at every media-type transition so switching away and back still reloads. The refresh itself is untouched — schedule re-evaluation and dayparting still run on the timer, and widgets keep refreshing their own data client-side (directory-search polls its board every 30s and preserves the current query). The web player already behaved this way via reevaluateHeldWidget; this brings the Android player to parity. Separately, the directory-search keyboard was laid out in fixed pixels for a 1920-wide viewport. A panel's CSS viewport is its resolution over its density, so a 1080p screen at 240dpi presents 1280x720 — where four rows of 56px keys took ~37% of the height instead of ~24%, and the lone max-width:700px breakpoint never fired to correct it. Key metrics are now clamped against vh. The clamp maxima are the previous fixed values and both vh terms exceed them at 1080 tall, so a 1080 viewport renders pixel-identically; shorter viewports scale down. The breakpoint no longer re-pins .key, which would have undone the clamp. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d4cf1d4123 | Merge branch 'feat/self-service-password-reset' | ||
|
|
b7d55595af |
feat(auth): self-service password reset
Until now the only ways back into an account were an admin setting your password for you
or shell access to run scripts/reset-admin.js. A self-hosted operator who forgot their
password had no path at all, and the admin-reset route explicitly refuses to reset a
platform admin's password — so a single-admin instance was unrecoverable without a shell.
The per-account login lockout added recently makes that sharper: a user who forgets their
password will hit the lockout and see the same generic error, with no way out.
Two unauthenticated endpoints (they must be — the user cannot log in):
POST /api/auth/forgot-password { email } -> always the same 200
POST /api/auth/reset-password { token, password } -> 200 / 400
The properties that matter, each covered by a test:
- NO ENUMERATION. The request endpoint answers identically — same status, same body —
for a real address, an unknown one, an SSO identity with no local password, and a
malformed string. The frontend shows the same confirmation even on a network error,
so the client cannot leak what the server refused to.
- NO MFA BYPASS. Completing a reset does NOT issue a session; the user signs in
afterwards, so a TOTP-enabled account still clears its second factor. Returning a token
here would turn "read one email" into a full session without the second factor.
- SINGLE USE, SHORT LIVED. 32 random bytes, stored only as a SHA-256 hash (same
discipline as email verification, recovery codes and API tokens), 1h TTL, and the
redeeming UPDATE is conditioned on the hash still being present so concurrent
redemptions cannot both win.
- LOCAL ACCOUNTS ONLY. SSO identities have no local password; no token is minted.
- IT ACTUALLY UNBLOCKS YOU. A completed reset clears the per-account login lockout and
must_change_password, otherwise someone who locked themselves out would reset and still
be locked out.
Rate limited: 5/min on the request (it sends mail to a caller-supplied address), 10/min
on the redeem. If no email transport is configured the response is unchanged — no oracle —
but the server logs loudly, because the user will otherwise wait for mail that cannot
arrive and the generic response cannot tell them.
Frontend: a "Forgot your password?" link on the sign-in card, a request card, and a
new-password card. app.js had to learn #/reset-password explicitly — the auth guard
rewrites any unauthenticated hash to #/login, which would have discarded the one-time
token in the emailed link and made it silently do nothing.
Migration adds users.password_reset_hash / password_reset_expires: additive, nullable,
idempotent; a code-only rollback leaves two dead columns.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
090b6c12cb |
fix(pairing): expire a pairing code on device liveness, not row age
A screen that was still connected and still displaying its pairing code could not be
paired. Reloading the player produced the same code, and the on-screen instruction
("restart the display to get a new code") could not help.
devices.created_at is written once, at first registration, and the row is never recreated:
a player persists its device_id and its pairing code in local storage and re-registers
with them forever. Expiry was measured from created_at, so 15 minutes after first boot the
row became permanently unclaimable while the device kept heartbeating — and a restart
reused the stored identity and reproduced the same code, so there was no way out.
Observed in production: an unclaimed web player, still online and heartbeating, whose row
was created 4 days 20 hours earlier and had been unpairable for all but its first 15
minutes. Prod is carrying several such rows; alpha has some 13 days old.
Key expiry on last_heartbeat instead, falling back to created_at for a row that has never
checked in. That answers the question the operator actually has — is this screen still
there showing me this code? — while keeping the property the expiry exists for: a device
that has genuinely gone away still expires.
Trade-off, taken deliberately: a code stays claimable while its screen is connected rather
than for a fixed 15 minutes. That is what the product implies, since the code is on the
screen the whole time, and guessing is bounded by lib/pair-lockout (5 failures per IP per
15 min) and the 5/min route limit rather than by this TTL.
SERVER-ONLY. The player's device:registered handler reads only device_id and device_token
and has no way to display a server-issued code, so reissuing one would have left fielded
players showing a stale code — strictly worse. This fix needs no player update and
un-strands every already-affected device in the field on deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
8e3dd3ae14 | Merge branch 'fix/recovery-grants' into release/auth-campaign | ||
|
|
d23a5205d4 |
Merge branch 'fix/pin-generation-csprng' into release/auth-campaign
# Conflicts: # server/server.js |
||
|
|
0e9a842eb3 | Merge branch 'fix/screenshot-workspace-authz' into release/auth-campaign | ||
|
|
b1092d0d62 | Merge branch 'fix/login-lockout' into release/auth-campaign | ||
|
|
8a651ebfcb | Merge branch 'fix/widget-telemetry-bounded' into release/auth-campaign | ||
|
|
c588f40243 | Merge branch 'fix/client-ip-attribution' into release/auth-campaign | ||
|
|
f289609380 |
fix(auth): back break-glass recovery with a revocable, auditable grant
scripts/reset-admin.js mints a JWT carrying `recovery: true`, and middleware/auth.js
accepted that claim on its own with no database involvement. Three consequences:
- NOT REVOCABLE. The only way to invalidate an outstanding recovery token was to rotate
JWT_SECRET, which logs out every user on the instance.
- NOT ENUMERABLE. Nobody could answer "is a recovery token outstanding right now?"
- NOT AUDITED. The synthetic id ('recovery-<nonce>') is not a users row, so every
activity_log insert for it failed the user_id foreign key and was swallowed by a catch —
a break-glass session left no trace at all.
A `recovery_grants` row per minted token turns all three around: DELETE revokes, SELECT
enumerates, expires_at bounds, and used_at + source_ip record when and from where it was
first exercised. The migration is additive and idempotent, so re-running is a no-op and a
code-only rollback just leaves an unused table.
The grant is session-scoped, NOT single-use-per-request. Recovery means many requests —
load the dashboard, list users, reset a password — so consuming the grant on the first
would make break-glass unusable, a worse outcome than the narrow replay window it closes.
Revocation and expiry are the controls; used_at is the audit stamp.
Also fixed, because it is the mechanism that hid this: logActivity now rewrites a
'recovery-*' id to a NULL user_id with the identity in `details`, so break-glass actions
are actually recorded instead of failing the FK; and a dropped audit row now logs a loud
[AUDIT-DROP] line naming the action and increments a counter, rather than vanishing into
console.error.
The token is written to a 0600 file instead of stdout — under systemd or Docker, printing
it meant journald captured a live admin credential well past its lifetime. Added --list
and --revoke-all.
In-flight recovery tokens minted before this change stop working; they live one hour and
were unrevocable, which is the problem being fixed. Minting already required a working DB,
so redeeming against one is not a new dependency.
test/session-token-resolution.test.js now mints a real grant for its recovery token, so
its assertions keep testing that break-glass is refused on those surfaces for lack of a
users row — not for the unrelated new reason that the token is invalid.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
dce0bc6f54 |
fix(devices): generate access-gating six-digit codes with a CSPRNG
The on-device settings PIN (devices.settings_pin, minted at pairing) and the pairing code assigned to imported devices both came from `Math.floor(100000 + Math.random() * 900000)`. Math.random is not a CSPRNG. V8 implements it as xorshift128+, whose internal state is recoverable from a handful of consecutive outputs, and every call in a process draws from that one shared stream. Both values are also observable by ordinary users — settings_pin is returned in device API responses today — so a user who collects a few outputs could predict the values minted around them, including for other tenants. lib/numeric-code.sixDigitCode() uses crypto.randomInt, which is CSPRNG-backed and rejection-samples so the distribution stays uniform. Range is 100000..999999 inclusive, identical to the old expression, so codes are still exactly six digits with no leading zero — the on-device keypad and pairing UI are unchanged. Deliberately NOT converted, because neither gates access: the image-generation seed in lib/image-gen.js, and the anti-burn-in pixel jitter inside generated widget HTML. Also unchanged: the settings_pin backfill in db/database.js, which uses SQLite's random() — that is ChaCha20 seeded from OS entropy, not a weak PRNG. This is the generator half of the finding only. The separate half — that settings_pin is returned to every workspace member, including read-only roles — is a response-shape change and waits on the consumer enumeration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
dda6f5b41e |
fix(devices): authorize the screenshot route on the device's workspace
GET /api/devices/:id/screenshot returns a live picture of what a screen is showing, but it was still authorized pre-tenancy: `device.user_id !== user.id`, with a role bypass listing 'admin'/'superadmin'. Three consequences, all now covered by tests: - `device.user_id &&` SHORT-CIRCUITED. A device with no user_id — never paired, or its owner deleted — skipped the ownership test entirely, so any authenticated account on the instance could read it. An unpaired panel displays its pairing code on screen, so that image is also a route to claiming the device (AUTH-10, out of scope here but connected). - 'platform_admin' was absent from the bypass list. #14 renamed 'superadmin' to 'platform_admin', so an actual platform admin fell through to the ownership test and was denied unless they happened to own the row. - Workspace members other than the owner were denied a device they administer through every other endpoint. Now uses accessContext() against the device's workspace — the same helper routes/devices.js uses — which covers direct membership, org-level access and platform staff in one call. A device with no workspace is denied outright rather than defaulting open. Deliberately unchanged: the ?token= query-parameter mechanism on this route, which is a separate finding with its own blast radius. No response shape change: still 200 / 401 / 403 / 404 with the same bodies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9130aa5f7d |
feat(auth): bound password login per account, not only per IP
The only throttle on POST /api/auth/login was the per-IP limiter in server.js. That bounds one noisy source and nothing else: it does not bound a distributed attempt, and it is only as accurate as a deployment's proxy configuration. Nothing counted failures against the account actually being attacked, and nothing cleared such a count on success because no such count existed. lib/login-lockout.js mirrors lib/totp-lockout.js and lib/pair-lockout.js so there is one lockout idiom here rather than three. 10 failed passwords lock an account for 15 minutes. Keyed on user.id, never on the submitted email: the email is attacker-supplied and unbounded, so keying on it would let anyone grow the Map without limit — the same class of bug fixed elsewhere in this campaign. A user id only exists for a real account, so the key space is bounded by the user table and needs no eviction sweep, exactly like totp-lockout. A locked account returns the SAME 401 and body as a wrong password. A distinct 429 would tell an attacker "this account exists and is under attack", turning login into an account-existence oracle; the test asserts the locked response is byte-identical to both the wrong-password and unknown-account responses. The trade is that a locked-out legitimate user sees the generic message, so the trip is recorded in activity_log (auth:login_locked) for the operator instead. The counter is cleared as soon as the password verifies — before the TOTP and email-verification branches, which return early and never reach issueSession, so a reset placed there would never fire for those accounts. SSO paths do not share this code and are unaffected. Frontend needs no change: login.js renders any non-ok body's `error` string verbatim, and the body is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8a28761b12 |
fix(widgets): bound the unauthenticated telemetry store, and stop it writing rows
The diag widget runs in a null-origin sandboxed iframe, so it cannot carry a session and
its telemetry POST must stay unauthenticated. But the handler stored into a plain Map
keyed on a value taken from the request body, with no cap, no TTL and no eviction — an
unauthenticated caller could add entries until the process died. On this product a dead
server is a fleet-wide reconnect, so a bound here is a fleet-safety control.
Two changes:
- lib/bounded-snapshot-store.js: a "latest snapshot per key" store with a global entry cap
and a TTL, evicting least-recently-WRITTEN. The cap is GLOBAL rather than per-IP on
purpose — signage sites egress through one NAT address, so a per-IP limit punishes a
whole venue for one noisy panel and does nothing about a distributed writer. Same
reasoning the OTA download guard already documents ("NEVER per-IP (SNAT)"). A live panel
rewrites its key every 2.5s, so only entries the dashboard already treats as stale
(>15s) are ever eligible for eviction.
- The POST now answers 204 instead of res.json({ok:true}). The reporting widget ignores
the response (fetch(...).catch()), and services/activity.js activityLogger wraps
res.json — so this also stops an anonymous caller from writing one activity_log row, and
running two synchronous statements, per report.
Read contract unchanged: a live key returns its object, an unknown OR expired key returns
null — the shape frontend/js/views/device-detail.js already handles ("no report yet"), and
it treats anything older than 15s as stale regardless, so the 60s TTL is 4x looser than
what the UI honours. No client change; no rate limiter added.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
4b13dadb4d |
fix(logging): gate CF-Connecting-IP on a Cloudflare peer, not any trusted proxy
getClientIp() decides the value every per-IP control keys on — the auth/pairing rate limiters, lib/pair-lockout, and activity_log.ip_address — so a caller must never be able to choose it. It believed CF-Connecting-IP whenever the immediate peer was in the `trust proxy` list, which includes loopback/linklocal/uniquelocal. Those entries are correct for X-Forwarded-For: a proxy APPENDS to that header and Express walks the chain right-to-left, so a client-supplied value cannot become the resolved address. CF-Connecting-IP has no chain — a local reverse proxy passes through whatever single value the client sent — so treating a loopback peer as evidence the request came through Cloudflare means trusting the client. Gate it on the published Cloudflare ranges alone. This is also the portable behaviour: most self-hosted installs are not behind Cloudflare, and for them the header is now simply ignored, with attribution falling back to req.ip under whatever `trust proxy` the operator configured. Installs that do front with Cloudflare are unaffected — their peer really is a CF edge. Documented the distinction at config/cloudflareIps.js so the two lists are not conflated again. No response shape or DB change; no client impact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6b082cfad0 |
fix(uploads): derive stored type from file content, and never serve uploads as documents
Uploaded files are served from the SAME ORIGIN as the dashboard, so how a browser
interprets them is a security boundary. Two things decided that interpretation, and
both were caller-controlled: the stored extension came from
`path.extname(originalname)`, and the only type check read `file.mimetype` — a request
header. A caller could therefore choose to have their bytes served as an active
document from the app origin.
Two independent invariants now hold the boundary:
1. INGEST — lib/upload-sniff.js sniffs magic bytes after multer writes a neutral
`.part` file (diskStorage names the file before any bytes exist, so the sniff cannot
happen there), maps the result through a hardcoded mime->extension allowlist, renames
accordingly, and stores the sniffed mime. Unsupported bytes are refused with a 400.
2. SERVING — upload responses carry `Content-Security-Policy: sandbox`, so if a response
is ever treated as a document it lands in an opaque origin with scripts disabled.
Anything outside the inline-safe extension set is additionally forced to download.
This holds regardless of how a file reached disk, so a future gap in (1) is contained
rather than exploitable.
Applied at every instance of the pattern, not just the first: lib/content-ingest.js,
the /replace route, the four content-serving paths across server.js and routes/content.js
(the latter pair currently shadowed by mount order, which is not a guarantee), and the
ZIP-import path in routes/status.js, which took its extension from the archive entry.
SVG stays accepted and stays inline: white-label logos are SVG, and octet-stream +
nosniff makes <img> fail. Scripts in an SVG never run in an image context, and the
sandbox CSP covers the one case where they would — a direct navigation. SVG is also no
longer handed to sharp, which removes the librsvg path where the open libvips CVEs live.
Existing rows are untouched — no migration. The four upload fixtures in agency.test.js
uploaded `Buffer.from('x')` declared as image/png; that is the exact "declared type is a
lie" case this closes, so the fixtures now use real PNG bytes. No assertion changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
c4b5a8679e |
refactor(auth): centralise session token resolution across manual verify sites
Six places verified a session JWT inline instead of going through requireAuth, each repeating a slightly different subset of its checks. Introduce resolveSessionUser() in middleware/auth.js as the single definition of "this token is a usable session, and here is whose it is", and route all of them through it: the three /api/status token routes, the screenshot route, the content-reference gate, and the /dashboard socket handshake. requireAuth is now a thin wrapper over the same helper, so the two cannot drift. Also: - Give the pre-TOTP token a distinct audience so it is redeemable only through verifyMfaPendingToken (POST /api/auth/totp/verify). verifyToken refuses any token carrying an audience, so a token minted for one purpose cannot be redeemed on another path. - The dashboard socket handshake now takes userId/userRole from the live users row rather than from the token claim, so role changes take effect on the next connection instead of riding the token's remaining lifetime. - Add test/session-token-resolution.test.js covering all six surfaces, including the socket handshake. Every call site keeps the status code and error body it returned before. Net query cost: the content-reference gate and the socket handshake each gain one users-by-id lookup (the same one requireAuth already does per request); the other four are unchanged or replace an equivalent lookup. In-flight pre-TOTP tokens are invalidated by the audience change; they live 5 minutes, so the window is a re-login at worst. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c3a5261057
|
fix(subscription): make the trial-expiry auto-downgrade actually fire (#228)
getUserPlan()'s auto-downgrade was guarded on `subscription_status !== 'active'`, but that column DEFAULTs to 'active' and is only ever changed by Stripe webhook events. For trial users who never touch Stripe — the entire population it's meant to catch — the condition was always false, so the downgrade never ran and every signup kept Pro free forever. Re-key the guard on the real signals: - trial expired (!trial_active), AND - stripe_subscription_id IS NULL (never paid), AND - plan_id === trial_plan (still on the plan the trial granted), AND - plan_name !== 'free' The plan_id === trial_plan clause is load-bearing: it protects comped / hand- granted plans (e.g. a manually-set enterprise plan, where plan_id !== trial_plan) from being silently downgraded. Grandfathered accounts (trial_started IS NULL) never enter the block at all, so the ~home cohort is untouched. Added a comment documenting the subscription_status-default trap so it isn't reintroduced. Enforcement stays forward-only/lazy — the downgrade happens in the resolver on a user's next request; no mass update here. Downstream (deviceSocket.checkDeviceAccess, traced, unchanged): a genuinely- expired free-tier trial now resolves to free and its device-limit block correctly caps it to 1 device; grandfathered home (2 devices) and paid users are not blocked. NOTE: the separate "Trial Expired" screen branch there is a pre-existing dead condition (it needs trial_started set AND plan_name='free' at once, but the downgrade clears trial_started) — left as-is per scope; flagged for follow-up. Tests (new trial-expiry.test.js — there was none, which is how this shipped): lapsed trial downgrades; comped enterprise (plan_id!=trial_plan) not downgraded; grandfathered home (trial_started NULL) not downgraded; paid user not downgraded; in-window trial not downgraded; plus a regression pinning that subscription_status ='active' no longer shields a lapsed trial. Suite 563/563. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e91d87fbfd
|
feat(stripe): enable promotion codes on checkout sessions (#227)
Add allow_promotion_codes: true to the checkout.sessions.create call in POST /checkout. This is what renders the "Add promotion code" field on Stripe's hosted checkout page; for API-created sessions there is no Dashboard equivalent (that toggle only exists for Payment Links, which we don't use), so a comment warns against removing it as "redundant". The billingPortal branch is untouched — portal sessions handle discounts separately. Testing: no Stripe-SDK test/mock existed (the billing-*.test.js files cover the #146 usage-metering path, not Stripe). Added stripe-checkout.test.js using the repo's in-process router-mount convention with a minimal `stripe` stub injected via require.cache, asserting the checkout payload carries allow_promotion_codes:true (and still builds a subscription session for the requested price). Full suite 557/557. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8529be5a30
|
feat(content): subtitle/caption support as a content property (#223)
Subtitles/captions are set once in the content library and applied
automatically by the player — no in-player controls (the player stays bare).
- DB: 4 new content columns (captions_enabled, captions_lang for YouTube;
subtitle_url, subtitle_lang for uploaded videos), all default off/NULL.
- buildSnapshotItems: denormalize the 4 fields into published_snapshot so the
player receives them (enumerated query).
- content.js PUT: accept the 4 fields (subtitle_url only clearable here).
- POST /:id/subtitle: dedicated .vtt uploader (separate multer, since the main
filter is video/image-only); stores the file in the content dir, records
subtitle_url + subtitle_lang. Old subtitle file replaced; DELETE cleans up
the sidecar.
- Player: YouTube -> loadModule('captions') + setOption(...languageCode) in
onReady (best-effort, wrapped). Uploaded video -> a <track kind="subtitles">
appended to the <video>, forced mode='showing' on load (same-origin, so
CORS-clean like the video).
- Edit modal: YouTube gets an enable-captions checkbox + language; uploaded
video gets a .vtt file picker + language + a remove-subtitle option. en/es.
Honest limitation (in the PR): YouTube caption control via the IFrame API is
undocumented/version-dependent and only works if the video actually has
captions — hence best-effort and wrapped so it can never break playback.
Test: content-subtitles.test.js — the 4 fields survive publish -> snapshot,
the .vtt upload endpoint stores + records the file, and a non-.vtt is rejected.
Suite 550/550.
Closes #216
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
8b661a7347
|
feat(content): batch operations — multi-select, batch delete, batch move (#224)
The content library had no batch operations — every item was managed one at a time. Add multi-select with batch delete and batch move. Backend (content.js): - POST /content/batch/delete — array of ids, atomic: validates + authorizes EVERY id first (malformed/missing/forbidden rejects the whole batch), then deletes in one transaction. Reuses the single-delete teardown. - POST /content/batch/move — array of ids + target folder_id, same atomic validate-all-first; target folder must share each item's workspace. Folder is organizational (not in the snapshot), so no device push. - Refactor: extract purgeContentRow() (file removal + snapshot scrub + row delete + affected-device collection) and pushContentUpdates(); DELETE /:id now uses them, so single + batch share one scrub path (no duplication). Add a boolean contentWritable() mirroring checkContentWrite's authorization. - 500-item cap per batch; UUID validation guards the snapshot-scrub LIKE. Frontend (content-library): - Per-card selection checkbox, select-all/none (visible), shift-click range. - Selection persists across folders/pages (issue-aligned cross-page selection); cleared after a successful batch op. - Batch toolbar (shown when >0 selected): count, move-to-folder picker, delete with click-again confirm. Selected cards get an outline. - api.batchDeleteContent / batchMoveContent; en/es i18n. Not included: batch "set expiry" (listed in the issue's toolbar sketch but only delete/move had endpoint specs) — deferred; PUT already does per-item expiry. Test: content-batch-ops.test.js — batch delete removes rows+files+scrubs snapshots; atomic rejection leaves valid rows intact; malformed id -> 400; batch move reassigns folder; cross-workspace folder refused; empty batch -> 400. Suite 553/553. Closes #213 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5c6d508032
|
feat(content): multi-file upload (#222)
Uploading N files fired N sequential XHRs (one POST per file). Select-many now goes up in a single request. - Server POST /api/content: upload.array-style `files` field (up to 20) via upload.fields, looping ingestUploadedFile per file. Keeps the legacy single `file` field so older clients / API callers are unaffected. Response shape is backward-compatible: a single file returns the content object (what every existing caller reads), a batch returns the array. - api.uploadContent: accepts a File, FileList, or array; appends all under `files`; aggregate upload progress; resolves to object (single) or array (batch). - content-library handleFiles: one batched request with aggregate progress and a "N files uploaded" toast instead of a per-file loop. - en/es i18n for the count-based progress/toast strings. checkStorageLimit is left as-is — it's a coarse pre-gate (blocks only when already at/over the limit), same as before; per-file aggregate sizing was a listed "consideration", not required, and is out of scope here. Test: content-multi-upload.test.js drives the real router+multer over HTTP — 3-file batch creates 3 rows and returns an array, legacy single `file` returns an object, single `files` returns an object, empty -> 400. Suite 545/545. Closes #212 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
792b105035
|
feat(content): server-side search, type filter, and sort (#221)
Content discovery was client-side only, scoped to the items already rendered
on the current page — searching "logo" on page 1 couldn't find logos on page
2 or in another folder.
Server (GET /api/content):
- ?q= text search on filename (LIKE, workspace-wide — a search ignores the
open folder so nothing is missed). LIKE metacharacters are escaped so a
filename with % or _ matches literally.
- ?type=video|image|youtube|web — youtube (video/youtube) and web (other
remote_url) are split from plain uploaded video/image so the four UI buckets
map cleanly.
- ?sort=date_desc|date_asc|name|size — whitelisted (never interpolates user
input into ORDER BY); default keeps the legacy newest-first ordering.
Frontend (content-library):
- Type filter + sort dropdowns; search debounced (300ms) and now hits the
server instead of filtering the DOM.
- Result count shown while a search/type filter is active.
- en/es i18n.
api.getContent gains an opts arg ({q,type,sort}); folder_id is omitted while
searching to match the server's workspace-wide behaviour.
Test: content-search-filter-sort.test.js mounts the real router and covers
substring match, LIKE-escape (literal %), the type buckets, name/size sort,
the ORDER BY injection guard, and combined filters. Suite 541/541.
Closes #214
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
ad03a5ec0a
|
feat(content): unstable-connection mode — cap YouTube at 720p for weak WiFi (#220)
On Android TV with weak/unstable WiFi, YouTube embeds auto-select 1080p+
and buffer/stall. Add a per-item "Unstable connection" flag that biases the
YouTube embed toward a 720p ceiling.
- DB: content.unstable_connection INTEGER NOT NULL DEFAULT 0 (non-destructive,
existing rows unchanged).
- buildSnapshotItems: denormalize the flag into published_snapshot so it
reaches the player (that query enumerates columns, so it had to be added
explicitly — covered by a new test). preview-payload reuses the same query.
- content.js PUT: accept + coerce unstable_connection to 0/1.
- Player: playerVars.vq='hd720' + best-effort setPlaybackQuality('hd720') in
onReady when the flag is set. Both are hints YouTube may still override, but
together they bias the initial selection down to 720p.
- Edit modal: YouTube-only checkbox + hint; en/es i18n.
Scoped to the core ask; the issue's optional "Shorts get inverse hd1080"
refinement is intentionally left out (dubious for signage, and forcing higher
quality on a weak link is the opposite of the goal).
Closes #217
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
b938fce368 |
feat(auth,tizen): TOTP 2FA UI, email verification on signup, Tizen SSSP install
Three features from this session, full server suite green (535/535). TOTP 2FA (#100) — backend shipped without a UI; add it: - Login: mfa_required -> 6-digit challenge (recovery codes accepted) -> /totp/verify. - Settings > Account: enable (QR + confirm -> recovery codes once), regenerate, disable; SSO accounts see "managed by your identity provider". - /totp/setup returns a server-rendered qr_data_url (bundled qrcode dep). keyuri folds the request Host into the issuer so multi-instance accounts are distinguishable in the authenticator app. Email verification on signup — hosted HARD-block / self-host SOFT-nudge: - email_verified column; existing users asked on first login (SSO + platform admins grandfathered); single-use 24h tokens (SHA-256 hashed). - Gate engages only when email is configured (never locks out a no-mail instance). GET /verify-email + POST /resend-verification (generic, no account enumeration). - Client: "confirm your email" flow + resend, verified/error toasts, self-host banner; onAuthSuccess refuses a tokenless response (defensive). Tizen SSSP URL-Launcher install — Fusion-style one-URL native install: - Server hosts /tizen/sssp_config.xml (dynamic <size>, always matches the served .wgt) + /tizen/ScreenTinker.wgt + a human landing. lib/wgt-cache.js resolves the signed .wgt (/data mount wins, mirroring the APK). - build-wgt.sh also emits a static sssp_config.xml for CDN hosting. - Retail panels require a Samsung Partner cert; dev-mode is SDB self-signed only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
96b71a0d56
|
feat: transition engine — GL wipes across web, Tizen & Android (+ image↔video) (#204)
Client-side GL Transitions v1 across all three players with never-blank degradation: shader lib + generated manifest + real-WebGL CI, transition-as-widget normalization, persistent WebGL/GLES2 compositors (web/Tizen/native Android), image↔video wipes, SSRF-hardened media proxy, decode-gated image preload, dashboard picker. Fixes the playlist change-fingerprint that dropped transition edits. Alpha + on-device soak validated. |
||
|
|
178af029a4
|
Directory board: JSON/CSV import + logo-replaces-title + fix images on player (#195)
* feat(widgets): bulk import for the directory board (JSON / CSV / TSV / text)
Adds an "Import from JSON / CSV" button to the directory-board editor. Paste JSON
(the { company, tenantsByFloor, advertisements, backgroundImages } shape plus
categories[]/floors[]/flat-array/bare-floor-map variants), a CSV/TSV/pipe/semicolon
table (with or without a header — vacant/yes/1 => available, quoted fields), or a
sectioned "room name" text list, and it auto-fills title, footer, floors->categories,
rooms/names/details/availability, and background-image URLs. "Replace / append" toggle.
Tolerant key matching (room/suite/unit/id, name/tenant/company, details/subtitle, …);
warns on things it can't use (bare-filename background images, headerless columns).
parseDirectoryImport is pure and was unit-tested in node across every format.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(widgets): directory board — logo replaces title, and images load on the player
Two on-screen bugs on the directory board:
1. A logo did not remove the title text — both rendered, stacking the wordmark over
the name. renderDirectoryBoard (and the directory-search header) now gate the title
h1 behind !logoSrc, so a logo replaces the title. New render test guards it.
2. Logo + background images did not show on the player (NS_ERROR_DOM_CORP_FAILED,
0 bytes). The player embeds widgets in a sandbox="allow-scripts" (opaque-origin)
iframe, so /api/content image requests are cross-origin, and the helmet default
Cross-Origin-Resource-Policy: same-origin blocks them. Set CORP: cross-origin (+
ACAO:*) on the content file + thumbnail routes, matching the existing /uploads/content
static route. Content already serves publicly, so no new exposure. Verified in a real
sandboxed iframe: same-origin blocks, cross-origin loads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
76f072bf4b
|
fix(tizen): decode-gated image double-buffer to kill the black flash between stills [#187] (#193)
The Tizen .wgt player black-flashed between IMAGE items on slow decode HW
(Samsung OM55B / SSSP). Root cause: playCurrent() called clearStage() before
renderImage() set img.src, so the stage was empty (black) until the new image
decoded. Images had no decode-gated double-buffer — video gained one in #167
(
|
||
|
|
ba0663edc1
|
fix(pairing): close deferred-offline reclaim race + idempotent same-code adopt (#192)
A fresh unclaimed player that reconnects (same fingerprint) INSIDE the server's ~5s deferred-offline grace hit a false 'active on another connection' reclaim reject, then collided on UNIQUE(devices.pairing_code) on the fall-through INSERT and wedged unclaimed with no content. Real trial customer (web player) hit it. server/ws/deviceSocket.js: - Fix A (guard): gate the liveConn reclaim reject on !inDeferredOffline (pendingOfflines.has(id)). A device mid-deferred-offline is a zombie, not live, so a same-fingerprint reconnect is a legit reconnect, not a hijack. A genuinely live socket (never disconnected -> no pending-offline) still rejects a cloned fingerprint -> anti-hijack boundary preserved (documented). - Fix B (idempotency): when the unclaimed old row holds the SAME pairing_code the reconnecting player presents, ADOPT/refresh it (mirror the claimed-reclaim path, but no device:paired) instead of INSERT-colliding. Differing-code case unchanged. - deferOffline is NOT shrunk (it exists to prevent transient-blip flapping). server/player/index.html: - The cold-boot flap source: an unfiltered pageshow handler ran verifyLivenessSoon() on every load, opening+registering a socket early, which the boot connect() then tore down and rebuilt (connect->register->disconnect->reconnect). Guard it with ev.persisted (mirror the pagehide guard) so only real bfcache restores trigger it. server/test/pairing-race.test.js: - Forces the race against the real socket server (log-gated reconnect inside the deferred-offline window), asserts no false reject / no UNIQUE collision / single claimable row; + a hijack case asserting a cloned fingerprint on a genuinely-live display is still rejected. Web- and android-shaped fingerprints. Fails 2/4 on pre-fix code, 4/4 with the fix. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
bc9e72ec0b
|
fix(content): render YouTube Shorts in 9:16 instead of a landscape frame (#184) (#189)
Vertical Shorts were played in a player forced to 100%x100% on a landscape frame, so they looked wrong (pillarboxed/small). Option A: detect vertical at ingest, persist it, and have every player honor it. - Ingest (routes/content.js): detect a Short from the /shorts/ URL form OR portrait oEmbed dims (oEmbed now queried with the ORIGINAL url so /shorts/ reports its true dimensions), and persist it as st_aspect=vertical on the stored embed URL. That's the only signal players get (remote_url), so it must be captured at ingest, not re-derived per loop. YouTube ignores the unknown param; players read the video id, not the full URL, to build the embed. - Players read st_aspect=vertical and center a 9:16 box (fills a portrait screen, pillarboxes cleanly on landscape) instead of 100%x100%: web (player/index.html), Android (WebViewSupport.youtubeEmbedHtml), Tizen (player.js single-zone + zone paths). Dashboard library uses a static thumbnail, so it's unaffected. Not doing Option B (yt-dlp): runtime dep + storage/bandwidth + maintenance + YouTube ToS; embed-disabled Shorts already skip gracefully. Tests: youtube-shorts.test.js (4) — /shorts/ and portrait-dims tag vertical, landscape stays untagged, /shorts/ tags even if oEmbed fails. Android compiles; web player inline JS + Tizen player.js parse. Note: pre-existing Shorts added before this aren't retagged (would need an oEmbed backfill) — re-add to fix, or a follow-up migration. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
a15086540f
|
feat(widgets): directory-search widget (interactive search of a directory board, live-sync) (#188)
* feat(widgets): add directory-search widget
An interactive, walk-up search view of an existing directory-board. It
references a source board by id (no data copy), so a venue can run the
scrolling board on a main screen and a search view on a tablet, letting
people find an entry instantly.
Server (routes/widgets.js):
- register 'directory-search' + renderDirectorySearch(): resolves the source
board, inlines its categories as one \u003c-guarded JSON blob, renders all
text via textContent (XSS-safe), live case-insensitive filter over
identifier/name/subtitle (debounced), grouped results, available styling,
optional touch on-screen QWERTY keyboard that drives the same filter path.
- missing / non-directory-board source -> friendly full-page fallback, not a 500.
- live-sync while open is out of scope; left a // TODO for a poll hook.
Frontend editor (views/widgets.js): type + magnifier icon, source-board
dropdown (from loaded widgets, filtered to directory-board), title, logo
(reuses the board's picker), placeholder text, theme, on-screen-keyboard toggle;
getConfigFromForm case. i18n: widget.dirsearch.* + type keys in en/es/it/de/pt/fr.
docs: openapi widget_type enum. Tests: server/test/directory-search.test.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(widgets): live-sync for directory-search (poll source board, no reload)
Reflect directory-board edits on an open directory-search page without a reload.
- New public GET /api/widgets/:id/data.json returns { categories } for a
directory-board (404 for missing/wrong-type so the page keeps last-good data
on a transient miss). CORS-open (ACAO:*) + no-store so a null-origin sandboxed
widget iframe can read it; exposes only data already public via /render.
Exempted from CSP + auth in server.js alongside /render.
- directory-search page inlines its source_widget_id and polls the board's
data.json every 30s via a relative URL (works behind a proxy/base path and
from a null-origin iframe). Only rebuilds + rerenders when the data actually
changed, so a mid-search view isn't disturbed; skips while document.hidden;
keeps last-good data on any fetch error. Flatten logic factored into
buildFlat() and reused by the poll.
Tests: data.json feed (categories, CORS header, 404s) + poll wiring in
directory-search.test.js (13/13). Verified live in a browser (page.clock
fast-forward): editing the board updates the search page with no reload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(android): let player WebViews take touch focus for interactive widgets
directory-search is served through the existing generic widget path
(loadUrl <server>/api/widgets/:id/render), so it already renders on Android
with JS + DOM storage + mixed-content enabled, same-origin (so its live-sync
fetch of the source board's data.json works), and no touch blocking.
Add isFocusable/isFocusableInTouchMode to the shared WebView config so the
search field reliably takes a tap/cursor inside the kiosk lock-task WebView.
Harmless for passive widgets (board/YouTube have no focusable inputs); the
widget's own on-screen keyboard still drives the filter when the system IME
is suppressed. Verified with :app:compileDebugKotlin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
51b0b006b1
|
fix(pairing): reinstalled panel reclaims its device row instead of being blocked [Bold] (#180)
Bold Media Group's fleet broke on the 1.9.3->1.9.6 upgrade. Their MDM does an
uninstall/reinstall (app data wiped), so the player registers with
{ pairing_code, fingerprint } and NO device_id and shows a pairing code — but the
dashboard reported "code does not exist". Deleting the device_fingerprints row fixed
it, which pinpointed the fingerprint-reclaim guard in server/ws/deviceSocket.js.
Root cause: the reclaim guard was
`stillAlive = !!liveConn || secondsSince < reclaimSettleSeconds; if (stillAlive) reject`.
On an in-place reinstall the old row heartbeat seconds ago, so `secondsSince < 300` is
ALWAYS true -> it emitted device:auth-error and returned BEFORE the pairing_code INSERT,
so the code the player displayed never existed server-side.
The settle window's real purpose was to REMATCH an existing fingerprint back to its
device row on reinstall — not to force a fresh re-pair. So the fix keys off claim status,
not the timer (server-only; no APK change — reviewed and confirmed unnecessary):
- Reject ONLY when the old row has a genuinely LIVE socket (liveConn) — the real anti-
hijack boundary. Unchanged.
- CLAIMED old row (user_id set) -> RECLAIM it regardless of the settle window: reuse the
row, rotate the token, emit device:registered{online} + device:paired. The panel returns
straight to paired (no operator re-pair, no orphaned duplicate row), preserving name /
claim / playlist / content. device:paired drives the app off the pairing screen, so the
fresh code it showed is irrelevant.
- UNCLAIMED old row -> fall through to the pairing_code path and PROVISION FRESH with the
shown code (reclaiming would leave a stale/null code -> "code does not exist"). #150
relinks the fingerprint to the new row.
`reclaimSettleSeconds` is now vestigial for this path. Trade-off: a fingerprint-only reclaim
of a CLAIMED-but-offline device is no longer delayed ~300s — not a new attack class (the old
code already granted it once the window elapsed); liveConn remains the hard boundary. Truly
closing that window without a re-pair needs client keystore attestation (a future APK).
Also fixes a latent crash this newly exercises: middleware/subscription.js getUserPlan()
dereferenced an undefined user in its else branch ("Cannot set properties of undefined
(setting 'trial_active')") when the user/plan JOIN missed. Under the claimed-reclaim path
that ran checkDeviceAccess->getUserPlan, the throw was swallowed by the reclaim try/catch and
silently dropped the device to provision-fresh. Guard: `if (!user) return null`.
Tests (server/test/fingerprint-reclaim.test.js):
- NEW: a CLAIMED reinstall reclaims the SAME row, emits device:paired, creates no duplicate,
keeps the fingerprint linked — regardless of the settle window (the Bold repro, fixed right).
- NEW: recent heartbeat + no live socket, UNCLAIMED -> provisions fresh with the shown code.
- NEW: a LIVE old socket still rejects and creates no new row (security preserved).
- Updated the #143 gone-device test to expect provision-fresh for an unclaimed row, and the
log-noise assertion to the "reclaim rejected" message.
465/465 server tests pass. Server-only: NOT deployed, no version bump, Android untouched.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
cf4c71d7d0
|
feat(email): SMTP transport as an alternative to Microsoft Graph [#173] (#179)
Adds a pluggable email transport so self-hosters without Azure/M365 can send
mail through any standard SMTP server (Postfix, Gmail, Mailgun, SendGrid, corp
relay). Graph stays the default; behavior is byte-for-byte unchanged when
EMAIL_TRANSPORT is unset or "graph".
- config: EMAIL_TRANSPORT ("graph"|"smtp", default graph) + SMTP_HOST/PORT/
SECURE/USER/PASSWORD/FROM.
- services/email.js: branch by transport behind the SAME public sendEmail()/
isConfigured() surface. SMTP via nodemailer (lazy-required, like MSAL).
Shared across both transports: the "[ScreenTinker] " subject prefix (unless
rawSubject), the GRAPH_DEV_RESTRICT_TO allow-list, html-from-text derivation,
and the never-throws contract (failures log + return sent:false). SMTP_SECURE
true=implicit TLS(465)/false=STARTTLS(587). Auth optional (unauthenticated
relay ok); SMTP_USER without SMTP_PASSWORD is flagged. SMTP_FROM parses
"Name <addr>". New emailConfigStatus() for startup diagnostics.
- server.js: startup logs the transport and a LOUD error when the selected
transport is partially configured (some fields set, others missing) or when
EMAIL_TRANSPORT is invalid (falls back to graph). A fully-unset transport
stays a silent stdout fallback (unchanged dev behavior).
- nodemailer ^6.9.16 added as a production dep (bundled in the Docker image).
- .env.example + README: SMTP config section, Gmail example, transport table.
- test/email-transport.test.js: 15 tests — transport selection, config
validation (missing/partial/invalid), SMTP message building (from/prefix/
fromName override/text alt), sendEmail routing (mocked nodemailer), rawSubject,
dev-restrict on smtp, and the smtp_error never-throws path.
462/462 server tests pass. Boot verified for all four states (configured,
misconfigured, invalid, default).
Closes #173
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
9c70fcc790
|
feat(diagnostics): device incident log — offline cause, network-vs-reboot, display-sleep (#175)
* feat(diagnostics): device incident log — why a screen went offline/black, with device-attested cause
Field request (Bold/s_t_r_o_b_e): "screens go offline randomly — let us see the cause." Answers it
across the fleet with a unified incident log, and — the key insight — lets the DEVICE disambiguate
the cause the server can't: if the app process survived the gap it was a NETWORK problem (not a
reboot), and it can even tell a dropped Wi-Fi/Ethernet link from a link-up-but-server-unreachable
(router/upstream) failure.
Schema:
- device_status_log gains reason + detail (WHY each offline transition happened).
- NEW device_events table (unified incident feed): type (offline/online/display_off/display_on/
crash/reboot/network/app_error) + reason + detail, indexed, age-pruned + per-device capped.
Server:
- Capture the socket.io disconnect REASON (transport_close/ping_timeout/transport_error) instead of
discarding it — recorded in the offline-cause log. devices.offline_reason stays on the EXIT-SIGNAL
contract (crashed/clean_exit/silent) — a separate axis, preserved (violent death = 'silent').
- device:event handler (typed incidents) + device:connectivity-report handler (device-attested).
lib/incident-classify.js (pure, unit-tested) composes reason+detail: cold_start->reboot;
link_lost->network "Wi-Fi/Ethernet link lost"; else network "LAN up, server unreachable
(router/upstream)"; appends SSID / weak-signal (rssi<-75) / IP-changed. On a report it upgrades the
most-recent offline row from the server's guess to the device's ground truth.
- heartbeat timeout -> 'heartbeat_timeout'; retention/cap for device_events.
- Device-detail API returns statusLog.reason/detail + the last 50 device_events.
Device (Android WebSocketService): ConnectivityManager default-network callback (link-lost during a
gap) + Wi-Fi SSID/RSSI + IP snapshot -> device:connectivity-report on reconnect (app survived =>
network); ACTION_SCREEN_ON/OFF receiver -> device:event display_on/off ("screen went black"). All
guarded/feature-detected; no manifest change; compiles clean.
Web + Tizen players: reconnect connectivity-report (link_lost from navigator.onLine during the gap)
+ visibilitychange -> display_off/on. Best-effort (no wifi detail in a browser). Tizen exit-signal
marker slice untouched.
CMS (device-detail): the offline cause on the uptime-timeline hover + a new "Recent incidents" panel
(merged offline periods + typed events, friendly labels, detail, relative time + down-duration).
Built as a 4-way parallel agent fan-out over disjoint domains against a locked contract, then
integrated. Verified: full server suite 443/443 (incl. the seam fix keeping the exit-signal contract
intact), Android compileDebugKotlin clean, all players + CMS node -c clean.
Refs #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(diagnostics): internet-reachability probe — split "our server down" from "no internet" (#170)
Follow-up to the incident log: when a device's link is UP but it's offline, "router/upstream" was a
catch-all. The device now probes a public host (1.1.1.1 / 8.8.8.8 :443) DURING the gap, so the cause
pinpoints blame:
- link_lost=true -> Wi‑Fi/Ethernet link lost (device's own link)
- link up, internet_ok=true -> server_down: internet reachable, OUR server was unreachable
- link up, internet_ok=false -> no_internet: router/ISP down
- link up, no probe result -> generic router/upstream (unchanged fallback)
- Android WebSocketService: fire a short daemon-thread TCP probe (443, either host) at disconnect;
the result rides the connectivity-report as internet_ok (omitted if the gap ends before it finishes).
- lib/incident-classify.js: 3-way split on internet_ok; new reasons server_down / no_internet.
- Frontend i18n: device.event.server_down / .no_internet labels.
- Tests: +3 classify cases (server_down, no_internet, link_lost wins over internet_ok). 12/12.
Verified: classify 12/12, Android compileDebugKotlin clean, node -c clean. Refs #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(diagnostics): log an 'upgrade' incident (old → new app_version) — server-side (#170)
When a device reports an app_version different from the stored one, applyDeviceInfo logs an
'upgrade' device_events row (detail 'old → new'). Server-side, so it covers Android/Tizen/web with
no client change; a fresh pair (no prior version) isn't counted. Adds device.event.upgrade label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
12c0004245
|
test(ci): OS-assigned ephemeral ports for subprocess suites — kill the port-collision flake (#176)
The subprocess-booting test suites hand-picked fixed ports in a cramped ~3955-4021 range, and 156-schedule-read-path deviated to a RANDOM port (3900 + rand%90) that overlapped those fixed ports. Under CI load two servers could race on the same port, surfacing as flaky "no such table: devices" / "FOREIGN KEY constraint failed" (a server answering a request against a half-migrated or wrong DB). It's environmental — the suites pass locally and in isolation. Fix: a shared test/helpers/free-port.js (bind :0 on loopback, read the OS-assigned port, release) called in before() so every suite gets a guaranteed-unique ephemeral port — concurrent suites can no longer collide, and no one has to hand-assign ports. - Codemod converted 30 suites: const PORT = <fixed|random> -> let PORT (+ BASE) assigned via `PORT = await freePort()` at the top of before(). - 3 hand-fixed (different structure): 148-eviction-storm (lowercase `base`), boot-health (no before() — allocates PORT + a throwaway SEED_PORT inside the test, replacing the hardcoded 3894), totp-keyrotation (no before() — allocates at the test start before bootServer()). No fixed 39xx/40xx ports remain. Full server suite 435/435; the 4 hand-touched suites pass in isolation. Pure test-infra change — no app code touched. |
||
|
|
837f65e634
|
fix(content+android): rotation-aware media — portrait upright on dashboard AND player (#170) (#172)
* fix(content): rotation-aware media dimensions — portrait no longer stored landscape (#170) Ingest recorded CODED width/height and ignored rotation, so a portrait phone video (coded 1920x1080 + 90° Display-Matrix) or a portrait photo (EXIF orientation 6) was stored LANDSCAPE. The player then rendered it wrong-aspect and letterboxed — the "portrait content degraded + blue bar at the bottom" symptom in #170. The reporter's workaround (pre-rotate + mark Landscape) is exactly what this bug forces. - lib/media-orientation.js (new): pure, unit-tested display-dimension helpers = single source of truth for ingest AND the backfill. videoDisplayDims() reads the modern Display-Matrix side_data rotation (falls back to the legacy tags.rotate, sign-normalized); imageDisplayDims() honors EXIF orientation 5..8. Odd quarter-turns swap W/H. - lib/content-ingest.js: use the helpers for stored dims; add sharp .rotate() so image THUMBNAILS are auto-oriented too (video thumbs were already auto-rotated by ffmpeg). - scripts/backfill-rotation-dims.js (new): idempotent, dry-run-by-default maintenance to correct already-uploaded portrait media (re-probe -> fix dims -> regenerate image thumbs). - test/media-orientation.test.js: 5 bites (tag + Display-Matrix, sign/normalize, EXIF 5..8, the blue-bar landscape->portrait case, null-safety). Scopes #170 to its residual-on-1.9.4 issues; the 1.9.3 "never displays" slice was #162 + the remote_url-null download fix, already shipped in 1.9.4. The slow low-res/orientation- cycling first load is tracked separately in #170 pending repro data. Refs #170. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(android): honor EXIF orientation in ImageLoader so portrait photos render upright (#170) Completes the rotation-aware media fix on the PLAYER side. The server ingest fix (this branch) corrects stored dimensions + auto-orients the thumbnail, but the panel draws the full-res original via BitmapFactory, which ignores EXIF — so a portrait photo (landscape pixels tagged "rotate 90") still rendered sideways on the screen. QA root-cause pass on #170 caught this gap: the Android player reads no stored dims and applied no EXIF. ImageLoader now reads the EXIF orientation (from the file for cached content, from the byte stream for remote_url images — ExifInterface(stream) is API 24+, minSdk is 24) and rotates/ flips the decoded bitmap via a Matrix (all 8 orientations). NORMAL/UNDEFINED is a no-op (no extra allocation); a transformed copy recycles the source; OOM falls back to the source rather than crashing. Videos were already correct (ExoPlayer honors the rotation matrix). Verified: :app:compileDebugKotlin clean. Refs #170. Rides with the server rotation-dims fix on this branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
2f3dd80881
|
feat(agency): per-token upload folder — auto-created, subtree-confined (#158) (#171)
Agency-portal uploads previously all landed at the workspace library root, unsorted. Instead of the issue's whole-workspace folder dropdown (which would leak every folder name to an external party), bind ONE folder per agency token — admin-controlled and agency-invisible — and scope the portal picker strictly to that folder's own subtree (Hybrid-C). Fully backwards-compatible: no bound folder -> root, exactly as before. Model / multi-workspace: an agency token is bound to ONE workspace at issuance, so the token key IS that workspace's private link and the bound folder lives in that workspace. An admin with N workspaces mints one token per workspace (each with its own auto-folder). No workspace-switcher in the portal — the token is the tenant boundary. Backend: - api_tokens.upload_folder_id (additive; ON DELETE SET NULL -> deleting the folder falls back to root). - lib/agency-targets.folderSubtree(): recursive-CTE helper = the SINGLE confinement source shared by GET /api/agency/folders AND the POST /api/agency/content target check, so the set the agency can SEE and the set it may WRITE to can never drift. Workspace-guarded at the anchor row; descendants inherit the workspace (folders.js forbids cross-ws parents). - routes/agency.js: GET /folders (bound subtree only); POST /content defaults to the bound folder and 403s any folder_id outside the subtree. - routes/tokens.js: create auto-creates "Agency — <name>" (or binds a picked folder, validated same-workspace, respecting the 100-folder cap) inside the token tx; new PUT /:id/upload-folder to rebind; listing surfaces the bound folder name. - middleware/apiToken.js + lib/content-ingest.js: upload_folder_id onto req.apiToken; ingest writes folder_id. Frontend: - Agency portal: folder <select> shown only when a real subfolder choice exists (identifies the "Main folder" root client-side without learning the token's folder id). - Settings: folder pick at token creation, bound-folder display, rebind modal. - i18n: 7 new apitoken.* keys across all 5 locales. Tests (429/429): - test/agency-folder.test.js: 5 folderSubtree confinement bites (subtree in, siblings out, workspace guard, null -> root). - test/agency.test.js (+1 e2e): auto-create, default-to-bound, in-subtree pick lands there, sibling -> 403, admin-pick, unknown-pick -> 400, rebind-to-root. Closes #158. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
501ffb11c1
|
feat(device-owner): tier foundation + QR provisioning + content-expiry & device enhancements (#168)
Device-owner tier substrate + silent install, end-to-end QR provisioning (Android 12+ compliance, APK-derived checksum, URL pre-seed, zero-touch onboarding, guided a11y screen), content-expiry (#157) with player no-restart deferral, and the device-enhancement batch (#10/#12/#13/#14). Backward-compatible with 1.9.3 clients; all autonomous behaviors opt-in. QA + security review green. Closes #161, #157, #159. |
||
|
|
938a43a466
|
Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167)
* feat(group-sync): synchronized playback per group (server + Android) [stage 1]
Play a group's shared playlist in lockstep across its displays — start/end items
together — by reusing the video-wall sync primitive with the spatial transform
removed and keyed on group_id instead of wall_id.
Server:
- device_groups += sync_enabled + leader_device_id (optional pin).
- buildPlaylistPayload emits a group_sync:{group_id, is_leader} block ONLY for a
member whose playlist matches the group's shared playlist (playlist-match guard —
a mismatched member is ignored, never synced). Membership via device_group_members.
- Leader auto-election: pinned-if-online -> first online matching member -> stable
fallback; computed (never persisted, so the operator's pin is preserved).
- group:sync / group:sync-request relay among eligible members (mirrors wall:sync,
guarded on membership + playlist match).
- Self-heal: re-push payloads to group members on (re)connect so is_leader refreshes.
- PUT /api/device-groups/:id accepts sync_enabled + leader_device_id and re-pushes.
Android:
- WallController is now mode-aware. WALL = transform + object-fit:fill + forced
follower-mute (UNCHANGED — every wall branch is byte-equivalent when isGroup=false).
GROUP = same leader/follower timing incl. the full video drift controller, but
full-screen (no transform), normal fit, and per-item mute honored (no forced mute).
- WebSocketService: emitGroupSync/emitGroupSyncRequest + onGroupSync/onGroupSyncRequest.
- MainActivity: dispatch emit by mode, parseGroupConfig, onPlaylistUpdate group hook.
Wall path is provably unchanged (additive mode, defaults to WALL). Kotlin compiles;
server suite 407/407.
Stage 2 (follow-up): web + Tizen parity, dashboard sync toggle + leader picker,
offline-sweep leader promotion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): web + Tizen player parity [stage 2]
Mirror the Android group-sync generalization in the two web-based players so a group
with a shared playlist syncs across mixed player types, not just Android.
Web player (server/player/index.html):
- group:sync / group:sync-request handlers (index jump + latency-compensated video
drift, same maths as wall:sync) — no audio policy here, per-item mute honored.
- emitGroupSync() + applyGroupSync() (4Hz leader broadcast; follower sync-request).
NO CSS transform, NO forced mute (unlike applyWallMode).
- isFollower now also true for a group follower (suppresses self-advance).
- handlePlaylistUpdate handles data.group_sync (mutually exclusive with wall).
Tizen (tizen/js/player.js WallController + app.js):
- WallController is mode-aware: WALL styles the slice + forced follower semantics
(UNCHANGED when mode !== 'group'); GROUP clears any wall styling (full-screen) and
drives leader/follower timing only. syncId()/clearStageStyle() helpers; emitSync/
onSync/onSyncRequest key the event + id off the mode.
- app.js: group:sync/request socket handlers; onPlaylist enters group sync on a
group_sync block, else exits — content renders through the normal single-zone path.
Realigned the v4-exit-signal-phase3 TIZEN slice (682->696) shifted by the app.js edits.
Wall path is provably unchanged in both (additive group mode). Server suite 407/407;
both players' JS parse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): dashboard UI — sync toggle + leader picker [stage 3]
On each group's header row:
- "Sync" checkbox -> PUT /api/groups/:id { sync_enabled } enables synchronized
playback; server re-pushes to members so they enter/exit sync mode. A hint notes
it needs a group playlist and that a display on a different playlist is ignored.
- When on, a "Leader: Auto / <display>" picker -> { leader_device_id } (null = auto-
elect, which self-heals; or pin a specific member to always lead when online).
Adds api.updateGroup(id, data) and the en i18n strings (en-only, mirroring the
existing per-group UI keys; the i18n parity test is apitoken-scoped).
Frontend parses (ESM); server suite 407/407.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): rework to clock/schedule sync + double-buffer + polish
Replace the leader/follower relay model with clock/schedule sync. Every
same-playlist member derives the identical (index, position) locally from a
server-disciplined clock + the deterministic playlist schedule, so sync:
- needs no server at play-time (offline-native), and
- has no leader role to double-elect (kills the split-brain class the leaked
WallController tick produced).
Server
- heartbeat-ack now carries server_ms + echoes client_ms for NTP-style clock
discipline; the client caches the offset (survives an outage).
- POST /groups/:id/resync -> group:resync (manual "Resync now").
- (kept: group_sync payload; leader machinery is now vestigial/ignored.)
Clients (web / Tizen / Android)
- Clock offset disciplined over the heartbeat, cached (localStorage / prefs).
- Schedule engine: pos = (syncedNow mod Sum(duration_sec)) with a CANONICAL
slot formula identical across platforms so mixed-platform groups can't drift.
- Snap-on-load: a fresh clip hard-seeks ONCE to the exact position (was ~5s of
gentle nudge to eat a ~0.3s load offset); steady-state keeps the gentle nudge.
- Double buffer: warm the next clip a few s before the boundary -> instant
switch, no black hold. Android pre-decodes on a throwaway surface so the swap
doesn't flash one wrong-aspect (landscape-stretched) frame.
- In-place duration edits: duration_sec dropped from the change signature and
applied in place, so a duration edit re-anchors the schedule WITHOUT a restart.
- Live-log shows discrete corrections (jump/align/seek) immediately; only the
steady-state line is throttled.
Android
- Fix leaked WallController leader tick: onDestroy() now stops it (Handler on the
main looper outlived the Activity -> zombie broadcaster / split-brain).
Dashboard
- Group leader picker -> "Resync now" button.
Tests: heartbeat-ack clock fields + resync route; exit-signal .wgt slice realigned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
34f1cb9e7c
|
feat(dashboard): version indicator + GHCR update check (#165)
* feat(dashboard): version indicator + GHCR update check with admin panel - Add server/lib/ghcr-check.js: GHCR tag poller (36h cache, semver filter) - Extend /api/version with latest_version and update_available - Add POST /api/admin/check-update (force GHCR poll) - Add POST /api/admin/trigger-update (Docker compose or manual instructions) - Sidebar footer: version label + amber badge when update available - Admin > System: version comparison card with Check/Update buttons - 14 new tests (10 unit + 4 integration), 68/68 passing Closes #163 * fix(dashboard): gate trigger-update to platform-admin + add GHCR fetch timeout Review follow-up on #165 (the two blockers): - trigger-update runs `docker compose up -d` on the HOST via docker.sock (root-equivalent) but was behind requireAdmin, i.e. reachable by any workspace-level admin. On a multi-tenant host that's a customer, not the infra operator. Gate it with requirePlatformAdmin (DOCKER_UPDATE_ENABLED still gates it further). check-update stays requireAdmin — it's a read-only GHCR poll. - ghcr-check.checkNow had no fetch timeout. Node's global fetch has no default timeout, so a hung GHCR connection never settled — leaving `inFlight` set forever (the finally never ran), which wedged the background poller AND hung any awaited checkNow (/api/admin/check-update). Add a 10s AbortController timeout on both requests so the try/catch/finally always fire. All 405 server tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: ScreenTinker <hello@screentinker.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1ebdb1f7a9
|
feat(ota): self-update kill switch — global, per-device, and MDM auto-detect (#166)
Lets an operator (or an MDM) own updates instead of the app self-installing, which on managed panels shows a self-install confirm dialog over customer content (#155). Three layered controls: - GLOBAL (server): config.otaEnabled from OTA_ENABLED (default on). When off, /api/update/check returns update_available:false, reason:ota_disabled_global — the whole instance stops offering updates. - PER-DEVICE (server + dashboard): new devices.ota_enabled column (default 1). When 0, that device is never offered an update (reason:ota_disabled_device). A "Self-update (OTA)" toggle in the device settings flips it via PUT /api/devices/:id. - AUTO-DETECT (Android): UpdateChecker stands down entirely when a foreign device owner (an MDM/DPC) manages the panel — detected via getActiveAdmins() + not being device owner ourselves. Pure client-side, errs safe, needs no server change. The two server gates are enforced server-side so they cover EVERY client version, not just ones with the client-side stand-down. When OTA is off the device still reports its version (dashboard sees state); the MDM/operator owns the actual update. For an MDM-managed fleet (e.g. Pivot/MAXHUB), turn OTA off and let the MDM push the APK — the install-dialog race disappears from every angle. Tests: +2 (per-device gate + a real OTA_ENABLED=false server for the global gate); full server suite 393 pass; Android compiles. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |