Continuously samples event-loop delay via perf_hooks.monitorEventLoopDelay()
(C++-backed histogram; cheap). Each window persists mean/p50/p99/max to a new
event_loop_lag table and recomputes a coarse load band (normal/elevated/critical)
from the window p99. Standalone value: current lag is exposed on /api/status and
band changes are logged, so site lag is diagnosable independent of throttling.
The band feeds the #142 reconnect throttle (next commit) but ships first as its
own subsystem.
- event_loop_lag is bounded from day one: indexed on sampled_at + scheduled prune
(LAG_TELEMETRY_RETENTION_DAYS, small default) modeled on the play_logs prune.
Deliberately NOT another unbounded-growth table.
- Band transitions are asymmetric: jump up immediately (tighten fast), release one
level at a time after N calm samples below a deadband (release slow, no flap).
Pure nextBand() function, unit-tested deterministically.
- config: LAG_SAMPLE_INTERVAL_MS, LAG_RESOLUTION_MS, LAG_TELEMETRY_RETENTION_DAYS,
LAG_PRUNE_INTERVAL_MS, LAG_ELEVATED_MS, LAG_CRITICAL_MS, LAG_RELEASE_SAMPLES.
- tests: band-transition unit tests; integration proves sampling persists, stays
bounded under the prune, and surfaces on /api/status.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dashboard uptime query (WHERE device_id=? AND timestamp>?) and the
per-device retention prune (WHERE device_id=? AND timestamp<?) were both full
table scans. At 1M+ rows (the outage report) this was the dashboard-degradation
cause that persisted even after the reconnect storm stopped.
- schema.sql: add idx_device_status_log_device_ts(device_id, timestamp); both
queries now SEARCH ... USING INDEX instead of SCAN (verified via EXPLAIN).
- database.js: same index as a migration for existing DBs (idempotent).
- schema.sql defined device_status_log twice; drop the duplicate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A mute toggle wrote the draft playlist_items + emitted a live device:mute-changed but only markDraft()'d — it never updated playlists.published_snapshot, the copy the device actually plays. So the device's item.muted stayed 0 and every loop/reload re-applied full volume: dashboard icon red but audio kept playing (Android; web's native <video> loop masked it). emitMuteChanged now surgically patches the matching item's muted (0/1) inside the published_snapshot and re-pushes the playlist, so loops re-apply the correct flag. Surgical patch (not publishPlaylist) so a mute toggle can't prematurely publish other draft edits or flip publish state. Adds a regression test that fails without the patch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the cache/backoff loop fix (aa23cf0): make a device that can't
self-install visible to operators, and fix the signature-verify bug that kept the
whole #139 fix from engaging on the actual Fire OS target.
Dashboard surface (Phase 2):
- devices gains ota_status / ota_target_version / ota_attempts / ota_updated_at
via the idempotent ALTER TABLE ADD COLUMN migration (non-destructive,
default-backfilled, idempotent on re-run).
- The device reports ota_status (OtaThrottle.statusFor -> none | pending |
manual_update_required) in device_info; the server persists it on register
(the reconnect backstop). devices d.* already surfaces it to the dashboard.
- Dashboard shows a non-blocking amber badge when manual_update_required
("Update available (vX) - install failed N times, manual update required");
i18n key in en.js (non-en inherits via the en fallback). Server suite +1 test.
Event-driven status (Option B):
- New device:ota-status WS message, emitted on STATE TRANSITIONS only
(enter-backoff -> manual_update_required, clear -> none), so the badge updates
promptly without waiting for a reconnect and without per-poll/heartbeat chatter.
Server handler persists the same fields; an unknown/forged device_id is a safe
no-op. The register-path persist stays as the reconnect backstop.
Signature-verify fix (the critical piece):
verifyApkSignature read the downloaded APK's signer via
getPackageArchiveInfo(GET_SIGNING_CERTIFICATES).signingInfo, but that field is
null for ARCHIVE files on API 28/29 (populated only from API 30). On Fire OS 8
(Android 9 / API 28) - the actual deployment target - this returned 0 certs from
a correctly-signed APK, so every OTA was refused as "tampered," the cache was
deleted, and the full APK re-downloaded every check cycle. This was the real
cause of the #139 re-download loop, NOT a silent-install failure: the cache and
backoff added in this branch sit behind this verify gate and never engaged on
the target.
Fix: below API 30, read the archive's signer via the legacy GET_SIGNATURES +
.signatures (its v1/JAR cert, which IS populated on 28/29). Keep
GET_SIGNING_CERTIFICATES + signingInfo for API >= 30 and for the installed-app
read (which works on 28+). The archive's signer is still extracted and compared
to the installed app's signer; a mismatch or zero-cert APK is still rejected.
This reads the cert correctly on old APIs - it does not weaken verification.
Verified on emulators:
- API 28: verify now passes for a legit APK (was: 0 certs, refused). Full backoff
then engages - 8.5MB pulled once, cache-hit on retries, backoff after 3,
manual_update_required emitted once; clears on successful update.
- API 28 negative: a re-signed (different-key) APK is still refused on cert
MISMATCH - no hole opened.
- API 30: unchanged path still passes (no regression).
- server suite 173/173, OtaThrottleTest 7/7.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Devices that download an OTA APK but cannot silently install it (Fire TV: no
device-owner path) re-downloaded the full APK every check cycle indefinitely -
install never completes, version never advances, next check re-triggers.
Client (UpdateChecker.kt, ServerConfig.kt, OtaThrottle.kt):
- Reuse a cached, signature-verified APK instead of re-downloading every cycle;
delete leftover invalid files; keep the verified APK on disk as the
manual-install artifact.
- Persisted per-version attempt budget (EncryptedSharedPreferences) so it
survives the Fire OS app restarts that drive the loop. An attempt is counted
only when an install is launched - a download/verify failure does not consume
the budget, so a transient network problem cannot park a healthy device in
backoff. After 3 failed installs, back off to one retry per 24h.
- Clear OTA state and caches when a check returns update_available=false while
state is pending (app relaunched as the new version).
- Report OTA status to the dashboard via device:log (tag ota) on state
transitions only (enter-backoff, clear) to avoid flooding the channel.
- Extract throttle decision logic into a pure OtaThrottle object (no Android
deps) with JUnit coverage (OtaThrottleTest) for the state transitions.
Server (server.js):
- Reword /download/apk log from "OTA update in progress" to "APK served" and
rate-limit to once per IP / 10 min so a looping device cannot flood the log.
Note: client-cooperative fix - prevents the loop in cohorts running this APK.
Currently-stuck beta4 devices still require a one-time manual update.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Version bump for the beta6 strobe artifact: VERSION, server package + lock, and
the Android client (versionName 1.9.1-beta6, versionCode 26 so OTA sees it as an
upgrade over beta5/25). Also removes two leftover untracked PiP test scratch files
(frontend/alert-overlay.html, frontend/overlay.js) from a prior session.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
captureAndSend() grabbed a single querySelector('video'|'img') and stretched it
across a fixed 960x540 canvas, so multi-zone Now-Playing screenshots and the 1fps
remote stream showed one zone stretched fullscreen instead of the actual layout.
- Multi-zone layouts now composite each zone from its REAL rendered geometry
(getBoundingClientRect relative to the container, scaled proportionally onto the
canvas), so positions/sizes stay true to the layout.
- Canvas height derives from the container aspect (not a hardcoded 540); media is
drawn honouring its object-fit (cover/contain/fill) instead of being stretched.
- Cross-origin / iframe zones (YouTube, widgets) can't be read back without
CORS-tainting the whole canvas (which makes toDataURL throw and kills the entire
capture). They now get a deliberate, labelled placeholder ("YouTube"/"Widget"/
"Video") so the shot still shows the layout structure with that zone marked,
instead of a transparent hole or a failed capture.
- Split rendering into renderCaptureCanvas() (socket-free, headlessly verifiable)
and captureAndSend() (encode + emit). One full-quality path serves BOTH the
on-demand screenshot and the 1fps stream — the composite is only a few drawImage
calls over already-decoded media, so no separate low-quality stream path.
Web player only; Android (view.draw already composites correctly) untouched.
Verified headlessly on a 3-zone device: red/green image zones render in their
correct positions, the YouTube zone shows a labelled placeholder, and the capture
succeeds with no CORS taint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two independent multi-zone bugs, plus operator-facing warnings, i18n, and
regression tests guarding the data contracts.
Bug 1 — per-item mute was a no-op end to end:
- GET /api/devices/:id dropped the `muted` column from its assignments SELECT,
so the dashboard toggle never reflected state (the muted=false case in
particular). Column restored to the device payload.
- Android player now honours the per-item mute flag for YouTube (initial state
+ live via the IFrame JS API).
Bug 2 — items whose zone_id belongs to a different layout were silently dropped:
- Player fallback (web + Android): an orphaned zone_id is recovered into the
largest zone instead of vanishing, with telemetry.
- server/lib/zone-validate.js is the single source of truth for the orphan rule
(zone not in the device's active layout); used by the device payload
(per-item `orphan` flag + `active_layout_zones`) and the device list
(`orphan_count`).
- Assign-time hardening: a stale zone_id (not in the device's active layout) is
cleared to null on POST/PUT rather than persisted as a new orphan.
- scripts/find-orphan-zone-items.js: read-only sweep for existing orphans.
Dashboard warnings (operator-facing, never on the live player):
- Per-item badge + reassign affordance, device-list glance, preview banner.
- Graceful degradation: the zone selector falls back to /api/layouts/:id so it
can't vanish on a stale payload.
i18n: orphan-zone strings added to en/es/fr/de/pt/it (hi falls back by design;
count strings interpolate through tn()).
Tests: server/test/device-zone-contract.test.js adds 5 regression tests for the
data contracts above (muted true/false round-trip, active_layout_zones, orphan
flag + count, orphan-clears-on-reassign, assign-time clearing). 172/172 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two device-REPORTING fixes from the #134 investigation (the PiP rendering itself
was #135).
1) "Device reconnects every ~45s" was a logging artifact, not instability. The
player re-emits a full device:register on the SAME socket every ~45-60s
(requestPlaylistRefresh) to pull a fresh playlist; the server logged
"Device reconnected" for every register of a known device. The attached 4-day
log showed 1415 "reconnected" vs 30 real socket connects and 0 heartbeat
timeouts — the socket never dropped, so #134's "PiP lost between reconnects"
was a misdiagnosis. Fix: only log a genuine reconnect (new socket); a
same-socket re-register is a refresh (currentDeviceId === device_id) and stays
quiet. The playlist still refreshes.
2) Device reported 720p while the monitor showed a 1080 signal. DeviceInfo
reported getRealMetrics() — the UI RENDER SURFACE — but TV boxes render the UI
at 720p and upscale to a 1080p HDMI signal. Now report BOTH: screen_width/height
= the output mode (Display.Mode.physicalWidth/Height), render_width/height =
the render surface (getRealMetrics). Two new nullable devices columns, stored on
pairing INSERT + reconnect UPDATE, exposed via the device API, shown on the
dashboard as "1920x1080 (UI 1280x720)" when they differ.
Backward compatible (required + verified on emulator): a device that omits
render_* — or sends no device_info at all — still registers, with render_* = null,
on both the INSERT and UPDATE paths. New columns nullable; stores use
`?? null` / `|| null`. All 167 server tests pass.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(#109): render Android PiP overlay above the YouTube WebView video plane
The PiP overlay (#109) returned sent:1 and showed its title in `uiautomator
dump`, but nothing painted on screen while YouTube was playing. By elimination
(YouTube-specific, landscape so no off-screen transform, real on-screen bounds
in the dump) the cause is surface occlusion: pipLayout sat as the last child of
rootLayout — the SAME compositing band as R.id.youtubeWebView — so the playing
video surface drew over it.
Fix (task option 1a): reparent pipLayout out of rootLayout to the window
content (android.R.id.content) as a top-level sibling drawn after rootLayout, so
it composites above the WebView. MainActivity.mirrorTransformToPip() copies
rootView's orientation/wall transform onto it so corner positions still track
the rotated content (web/Tizen parity). show() also bringToFront()+
requestLayout()+invalidate() on attach (covers the cause-3 measure/visibility
path). Remote-view screenshots now capture the content root so the PiP is still
included.
Instrumentation (Phase 1, default OFF): PipOverlay.pipDebug paints a solid
magenta box + border with media on top (box paints even if media never loads)
and logs box/pipLayout/rootView/youtubeWebView geometry over device:log tag
"pip"; loadImageInto also logs on success. Toggled via device:command
{type:"pip_debug"} (routed through MainActivity.onCommand).
Server: POST /api/pip and the clear handler log one concise [pip] dispatch line
(target + sent/offline) so journalctl shows PiP activity.
Validated end-to-end on an emulator (pixel10/API34) paired to an isolated local
server with YouTube playing: no crash, the PiP box composites above the live
video frame (center + top-right), clear removes it, and the portrait transform
mirror rotates the overlay with the stage (no off-screen). The Fire TV
hardware-overlay punch-through still needs real hardware (emulator composites
video inline); pipDebug + docs/109-android-pip-visibility.md cover that.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(#109): image PiPs never painted — set slot token before decode
Emulator e2e of an image PiP (a QR PNG) found the image area always blank (box
background + title only). Pre-existing defect, also on main, independent of the
occlusion reparent.
Root cause in PipOverlay.show(): teardown() clears `current` to null, then
loadImageInto() captured `token = current` (null) as its drop-if-replaced guard,
but `current` was set to the new pip_id AFTER the media was built. The image
decode finishes on a background thread and posts back after show() returns, so
`token != current` (null != pip_id) was always true and every decoded bitmap was
dropped. Web PiPs and the box/title were unaffected, which masked it.
Fix: set `current = pip_id` before building media so loadImageInto's token
matches. Verified on emulator — a QR image PiP now renders over both a static
image and live YouTube (hardware screencap + the app's software view.draw
capture both show it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(#109): record web PiP (HTML+JS) verification on emulator
Web PiP type loads its WebView and executes JS (a page stamping JS OK · <time>
rendered over live YouTube). No code change — web PiPs don't use the image path
that had the token bug. Completes the image/web/box content-type verification.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(#109): implement PiP close_button on Android (was a documented no-op)
The server forwarded close_button (routes/pip.js) and it's in openapi.yaml, but
no player rendered it — Tizen deferred "close-button focus" as non-MVP, the web
player has none, and Android's PipOverlay never read the flag. So the documented
field did nothing on any device.
Implement it on Android: when close_button:true, a tappable ✕ floats at the box's
top-right in a FrameLayout wrapper that is a SIBLING of the box — so it isn't
clipped by the box outline or dimmed by the overlay opacity. Tapping it clears
THIS overlay (id-matched via the captured token). Only the ✕ is clickable; the
rest of the full-screen pipLayout stays touch-transparent, so taps elsewhere
fall through to the playing content (no input regression).
Verified on the emulator over live YouTube: the ✕ renders at the corner, and
tapping it removes the overlay while the video keeps playing.
Parity note: web/Tizen players still don't implement close_button; D-pad focus
of the ✕ on non-touch TV hardware is intentionally not wired (MVP = touch/pointer,
matching the Tizen focus deferral).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The PiP endpoints and the per-item mute field shipped without OpenAPI coverage.
- openapi.yaml: add POST /pip (show), DELETE /pip + POST /pip/clear (clear), all
x-required-scope: full; add the `muted` boolean to PUT /assignments/{id}; add a `pip` tag.
- openapi-contract.test.js: the scope heuristic only treated `command` paths as full-scope,
so a full-scope non-command route (/pip) would fail it — extend it to recognize /pip.
Docs-only as far as the running build goes (no route/behavior change). Lands on main; not
in the frozen v1.9.1-beta4 tag — ships in the next tag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(server): proxy remote YouTube thumbnails instead of ENOENT on a local path
YouTube content stores thumbnail_path as a REMOTE URL
(https://img.youtube.com/vi/<id>/hqdefault.jpg), but the thumbnail-serving route
path.resolve'd it into contentDir -> a local file that never existed -> ENOENT logged
a few times a minute (the tester-log spam). Recreating content didn't help (new rows
store the same remote URL).
- GET /api/content/:id/thumbnail now proxies a remote http(s) thumbnail_path
server-side (same-origin, so dashboard CSP img-src is unaffected) via a non-throwing
helper: upstream 404 -> 404, other failure/timeout -> 502, image/* only (modest SSRF
hardening; the URL is server-set at ingest). Local thumbnails keep the sendFile path;
the playlist/widget/workspace access gating is unchanged for both branches.
- routes/widgets.js inlineUserContent skips the disk read for a remote thumbnail and
leaves the /api/content/:id/thumbnail reference in place (the proxy serves it).
- routes/content.js ingest unchanged; a comment notes the future download-at-ingest +
backfill option for CDN independence.
- New test/thumbnail-proxy.test.js: local sendFile still works; a remote thumbnail is
proxied (mock upstream, no local read, no ENOENT); upstream 404 -> clean 404. Full
server suite 164/164.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(server): boot banner shows the real version, not a hardcoded v1.2.0
The startup ASCII banner printed "ScreenTinker Server v1.2.0". Use the already-imported
VERSION (require('./version'), the single source of truth that reads the root VERSION
file) in a fixed-width field (VERSION.padEnd(22).slice(0, 22) — the same padEnd
discipline the port line uses) so the fixed-width box border stays aligned for any
version length. No other behavior changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(server): persist + ship + real-time per-item mute (#129)
The dashboard mute toggle was a no-op end to end. The active model is playlist_items
(the device payload is its published_snapshot); the legacy `assignments` table the bug
report cited is unused for devices. Three breaks:
- PUT /api/assignments/:id silently dropped `muted` (only read sort_order/duration_sec/
zone_id). It now accepts muted (coerced 0/1) and ITEM_SELECT returns it, so the toggle
persists and its on/off state sticks.
- playlist_items had no `muted` column — added (schema + idempotent migration).
- buildSnapshotItems didn't select muted, so it never reached the published_snapshot /
device payload — now included.
Real-time: on a mute change, emit device:mute-changed { content_id, widget_id, muted } to
every device on that playlist so the player toggles the matching item's volume live,
decoupled from publish (the value is also in the next snapshot, so it persists). Adds a
[mute] log line (the report noted zero mute log entries).
Test: test/mute.test.js — PUT persists + returns muted, it reaches the published
snapshot, and a non-mute update doesn't reset it. Server suite 164/164.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(player): apply per-item mute live on Android + web (#129)
Honor the new per-item mute from the server, both in real time and on reload.
Android:
- WebSocketService: onMuteChanged callback + main-thread device:mute-changed handler.
- MediaPlayerManager.setVideoMuted(): flips the live ExoPlayer volume on the current
video (YouTube autoplays muted; images/widgets are silent).
- MainActivity: on device:mute-changed, apply immediately if the toggled item is the
one playing now.
- PlaylistController.sig(): include muted so a published mute change re-renders/persists
instead of being de-duped.
Web player (server/player/index.html):
- device:mute-changed handler toggles the current <video>; the video mount now also
honors item.muted so a published mute sticks across reloads.
Tizen intentionally not included: its player mutes ALL video for autoplay, so per-item
unmute isn't achievable there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* PiP overlay MVP: push image/web overlays to a device or group (#109)
Implements the #109 MVP from docs proposal: a floating overlay PUSHED to a device or
group in real time, rendered above the playlist without disturbing it. Scope is the
MVP only — video/RTSP, MQTT, offline-queue, and the priority/stacking system are
deferred to follow-up PRs as the proposal specifies.
Protocol (/device socket, player-agnostic):
- device:pip-show { pip_id, type:image|web, uri, position, width, height, duration,
title?, title_color?, background_color?, opacity?, border_radius?, close_button? }
- device:pip-clear { pip_id? }
The player fetches uri itself (same trust model as remote_url content; server never
proxies). type:web is full-trust by design, hence the 'full' token scope.
Server (server/routes/pip.js, new; mounted in config/api-surface.js PUBLIC_ROUTERS):
- POST /api/pip and POST /api/pip/clear + DELETE /api/pip, all requireScope('full').
- Resolves device_id to a device OR a group, expands a group to members, and emits
per-device — reusing the group command route's room-size online check and
{device_id, name, status: sent|offline} result shape. Generates pip_id.
- Validates type/position allowlists, uri http(s), numeric bounds on
width/height/duration/opacity/border_radius, colors via the existing VALID_COLOR
(#RRGGBB; transparency is the separate opacity field).
- Workspace-isolated: every target query is scoped to req.workspaceId, so a token
bound to workspace A can't address workspace B (404). Offline devices are reported,
never queued (PiP is ephemeral).
Player overlay layer (Tizen; tizen/js/pip-overlay.js, new):
- A #pip sibling ABOVE #stage that PlaylistPlayer/ZoneRenderer never touch.
- applyOrientation now applies the SAME transform to #pip as #stage, so corner
positions track the visible CONTENT in all four orientations.
- image -> <img>, web -> <iframe> (muted by default: empty allow= denies autoplay),
sized/positioned/styled per payload, optional title bar.
- Single overlay slot, last-show-wins; duration timer (0 = until cleared); pip-clear
(id-aware) or timer tears down; teardown wrapped so a malformed payload can't wedge
the layer. Reports show/clear over device:log (tag 'pip').
Dashboard: a minimal "Send overlay" / "Clear overlay" tester on the device-detail
controls (device/group via the open device, type, uri, position, duration), calling
POST /api/pip through the api helper.
Tests (server suite green, 161/161):
- api.test.js: PiP tier — authz (read/write 403, full passes), workspace isolation
(wsA token -> wsB device 404), payload validation, device + group targeting, clear;
plus the PUBLIC_ROUTERS snapshot-firewall updated for /api/pip.
- pip-overlay.test.js: loads the real player.js + pip-overlay.js in a vm with a DOM
shim; proves the overlay shows, auto-dismisses on the duration timer, and never
changes the playlist signature / touches #stage; web->iframe, last-show-wins,
id-aware clear, malformed-payload safety.
Not in this PR (intentional):
- Android player overlay — fast-follow. Protocol + server are player-agnostic; the
Android layer (an overlay View above the player, orientation-matched to MainActivity's
rootView rotation) is the same shape and lands next.
- OpenAPI docs for POST /api/pip — the contract test's scope heuristic only treats
'command' paths as full-scope, so documenting a full-scope non-command route there
needs that heuristic extended first; deferred with the docs item (proposal §8.6).
- video/rtsp types, MQTT, offline queue-on-reconnect, priority/stacking, arbitrary
(x,y)/selector positioning (proposal §6).
Refs #109
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* PiP overlay: add Android + web players (#109)
Extends the #109 PiP MVP to the other two players so the protocol (device:pip-show /
device:pip-clear) is honored fleet-wide, not just on Tizen. No server/protocol changes —
the route and socket messages are player-agnostic; these are the two missing surfaces.
Web player (server/player/index.html):
- New #pipContainer layer above #playerContainer, pointer-transparent, that the playlist
render never touches. The same orientation transform is applied to it as to
#playerContainer (extended to also reset width/height on landscape so a
portrait->landscape switch realigns), so corner positions track the visible content.
- Inline PiP logic mirroring tizen/js/pip-overlay.js: image -> <img>, web -> <iframe>
(muted by default via empty allow=), position/size/bg/opacity/radius/title, single slot
last-show-wins, duration timer (0 = until cleared), id-aware clear, wrapped teardown.
- device:pip-show/clear handlers; reports show/clear over device:log (tag "pip").
Android player:
- activity_main.xml: a pipLayout FrameLayout as the LAST child of rootLayout — it draws
above the content AND inherits rootView's orientation rotation/translation, so corner
positioning is orientation-matched for free.
- PipOverlay.kt (new): builds the overlay box into pipLayout. image -> ImageView (decoded
off-thread via ImageLoader, dropped if torn down mid-decode); web -> WebView with
mediaPlaybackRequiresUserGesture=true (mute-by-default). Gravity-based corner/center
placement with a 4% inset, GradientDrawable bg + corner radius, alpha=opacity, optional
title bar. Single slot last-show-wins; duration timer; id-aware clear; teardown wrapped
and also run on activity destroy (WebView cleanup).
- WebSocketService: onPipShow/onPipClear callbacks + safeOn handlers posted to the main
thread (they build Views) + a sendLog(tag, level, message) emitter for device:log.
- MainActivity: instantiate PipOverlay (log -> wsService.sendLog("pip", ...)), wire the
callbacks, tear down on destroy.
Verified: Android assembleDebug builds clean; web player inline JS parses; server suite
still 161/161 (no server changes this commit). Not yet validated on real hardware —
four-orientation corner positioning mirrors the player container/rootView transform but
should be eyeballed on a panel.
Refs #109
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opt-in, default-off UI gate (per strobe's spec; verified his file refs first).
When set, hides the Subscription sidebar item + billing view and bounces
#/billing to the dashboard. Billing shown by default -> existing deployments
unchanged. UI-only: /api/subscription/* untouched (internal usage reads stay).
- config.js: config.hideBilling from HIDE_BILLING (mirrors selfHosted).
- auth.js: surface hide_billing on GET /api/auth/me (client already fetches it
at boot, stored on the user object).
- index.html: id="billingNavItem" on the Subscription <li> (mirrors adminNavItem).
- app.js: toggle billingNavItem in updateSidebarUser (next to the admin toggle);
guard #/billing -> history.replaceState('#/') + render dashboard (replaceState
so the back button doesn't loop into the guard).
- .env.example + README documented.
Spec assumptions verified against code: adminNavItem toggle pattern exists;
/me is fetched at boot and updateSidebarUser runs both at boot (cached user)
and post-/me, so no-flash holds on warm loads (one-time flash possible on the
first load after the flag flips — same as the admin nav, minor); route dispatch
is an if/else chain. Nav label is static (no data-i18n) so no i18n change.
Validated (headless Chrome, both states):
- flag unset -> Subscription tab present, #/billing renders (backward-compat).
- HIDE_BILLING=true -> tab hidden, #/billing redirects to #/.
- config maps HIDE_BILLING both ways; live /me default hide_billing=false.
- 149 server tests green. Default-off = zero change for existing deployments.
Known cosmetic (harmless): after the redirect the billing nav LINK keeps its
'active' class, but the nav item is display:none so it's never visible.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A FK constraint violation crashed the whole process on 1.9.1-beta2 with a
bare "FOREIGN KEY constraint failed" and NO stack — so it couldn't be root-
caused. better-sqlite3 is synchronous, so such a throw inside a socket.io
handler (no local try/catch) propagates to uncaughtException, and with no
handler Node exits traceless.
Add a small top-of-server.js net that logs the FULL err.stack (file:line of
the offending write) + timestamp, best-effort closes the DB (WAL flush), then
exits(1) so systemd restarts fresh. NOT catch-and-continue — after an uncaught
throw the process state is undefined, so we never keep serving. This is the
investigation tool the root-cause fix is blocked on, plus the fleet-wide-crash
net #114 asked for.
Verified (not assumed):
- A synthetic synchronous FK throw inside a real socket.io handler IS caught by
uncaughtException, logs the full stack incl. the throwing file:line, exits 1.
- Non-over-reach: a FK throw in an Express route -> Express handles it (500), a
throw in a local try/catch -> caught (200); the global net does NOT fire and
the process stays alive. Last resort, not a catch-all.
- 149 server tests green; server boots clean (net doesn't trip on startup).
The root-cause FK fix is SEPARATE and waits on the stack trace this produces.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Option A: tile-on-tile (same section) reorders; tile-on-section / cross-
section stays group-assign (existing behavior untouched). Ordering is
cosmetic (dashboard only — nothing the device/player reads).
Backend:
- Migration: devices.sort_order column (idempotent ALTER; default 0).
- GET /api/devices ordering: sort_order ASC, created_at ASC (was created_at).
- POST /api/devices/reorder — ordered id array -> transactional
UPDATE sort_order=index, scoped WHERE workspace_id = caller's workspace
(forged cross-workspace ids are no-ops). Write-gated (viewer read-only).
Mirrors the playlist items reorder.
Frontend (the collision):
- Card-level dragover/drop: reorder ONLY when target is another card in the
SAME section; otherwise no-op so the event bubbles to the section's
group-assign handler. stopPropagation on the same-section drop prevents
the section handler also firing. Drop indicator (inset box-shadow).
Native HTML5 DnD; no library.
Validated (headless Chrome, synthetic DnD + a section-level drop spy):
- SAME-section reorder: section drop suppressed (sectionDrops=0), POST
/devices/reorder fires, NO group call, sort_order persists in DB.
- CROSS-section: section drop fires (sectionDrops=1), POST /groups/:id/
devices fires and membership actually changes — group-assign unbroken.
- The 0-vs-1 contrast proves stopPropagation disambiguates the shared gesture.
- 149 server tests green; migration applies clean on the prod-copy DB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes #104's two surfaces by reusing the now-generalized player preview
for devices, seam-safe (device-bound layout, NOT playlist-derived).
Server:
- GET /api/devices/:id/preview-payload returns buildPlaylistPayload(deviceId)
— the device's OWN layout/orientation (device row) + its published items —
with wall_config forced null (v1: wall members preview full-frame; a
socket-free follower would otherwise freeze waiting for leader wall:sync).
Device-READ gate (mirrors GET /:id, viewers allowed); NOT requirePlaylistRead.
Player (generalized, shared seam):
- Boot dispatch now accepts ?preview=1 with EITHER playlist=ID OR device=ID.
- bootPreview(qs) builds the right URL; shared body factored into
renderPreviewFromUrl(url) used by both. Renderer still UNTOUCHED.
- derivePreviewLayout stays PLAYLIST-only; never touches the device path.
Dashboard:
- Device manager gets a Preview button -> /player?preview=1&device=ID
(modal iframe, aspect from device orientation). Playlist-view button as-is.
- i18n x6 (device.preview_btn).
Validated (not just tests): 149 server tests green (generalization didn't
break the playlist path); device preview renders socket-free in headless
Chrome; layout proven device-bound on real data (device playlist has 0 zoned
items -> playlist-derivation would give NULL, but payload returns the device
row's "Vertical Full HD"); wall-member device previews full-frame (inWallMode
false) without freezing; auth gate outsider->403, no-token->401; playlist
path still renders the webpage note post-refactor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Duplicate and Replace per-item actions, both leaning on the normalized
playlist_items schema (only content_id/widget_id/zone_id/sort_order/
duration_sec; type-specific fields are JOINed at snapshot time).
- Replace: extend PUT /:id/items/:itemId to accept a content_id/widget_id
swap. Clean FK swap across ANY content type (image<->video<->youtube<->
widget) — sets one, nulls the other, preserving zone_id/duration/
sort_order/schedule rows. Only acts when content_id|widget_id is present,
so partial PUTs are unaffected. Workspace-validated; markDraft.
- Duplicate: new POST /:id/items/:itemId/duplicate — copies the row +
its schedule blocks (new ids) in one transaction, appended (sort_order
MAX+1). markDraft.
- Frontend: Replace + Duplicate icon buttons per item; Replace reuses the
add-item picker in a replaceItemId mode (PUT instead of POST). i18n x6.
Validated end-to-end against the live API: duplicate (incl. schedule copy
with distinct ids), replace same-type and cross-type both directions,
preservation of duration/schedule/zone, and validation (both->400,
missing->404). 149 server tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the broken/fragmented preview with a single surface that renders a
DRAFT playlist exactly as a device does, by reusing the player's renderer in a
same-origin iframe. Fixes "not all items load" (one renderer, full type union)
and inherits the player's YouTube correctness (YT.Player handshake).
Server:
- deviceSocket: extract assemblePayload() (zone-reset + canonical shape) from
buildPlaylistPayload so the device path and preview can't drift. Pure refactor
(all 149 tests green).
- playlists: GET /:id/preview-payload (requirePlaylistRead, workspace-scoped).
Draft-aware via buildSnapshotItems (live items, not published_snapshot);
derivePreviewLayout() resolves layout from the playlist's own zone-bound items
(0 zoned -> fullscreen; 1 -> use it; >1 -> dominant + ambiguous flag, never
crashes). orientation validated/passthrough; wall_config/timezone null.
Player (renderer UNTOUCHED):
- ?preview=1&playlist=ID boot branch: fetch preview-payload (same-origin Bearer
token) and call handlePlaylistUpdate(). Gated before the pairing/socket path
so the unpaired auto-connect never fires. All socket emits already guarded.
- Webpage widgets: always-visible honest note (no auto-detection — an XFO
refusal is provably indistinguishable client-side from a working embed).
Dashboard:
- playlists: Preview button + player-iframe modal with landscape/portrait toggle.
- widgets: same honest note on the existing widget preview modal (the surface the
bug was reported on).
- i18n x6 (en/es/fr/de/it/pt) + player i18n x5.
Validated end-to-end (headless Chrome + CDP): preview boots, webpage note
renders, 3-zone layout derives+renders, shape parity with device snapshot proven
on real data, auth gate returns 401. The world-readable /uploads finding is
tracked separately as #107 (not a #104 concern — same path the device uses).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Agencies can only be designated FULL-SCREEN playlists (no item with zone_id) - a full-screen
agency upload can't safely target a zone, so the ambiguous case is excluded rather than
solved. Checked at THREE points:
- Designation (tokens.js create + PUT /:id/targets) -> 400: reject a zoned target.
- Upload (agency.js item-add) -> 409: block if the playlist BECAME zoned after designation.
MANDATORY because auto-publish has no draft net - a full-screen playlist designated to an
auto-publish token, then zone-assigned, would otherwise auto-publish a full-screen upload
into a zoned playlist. The upload check is the only thing that catches it.
- Picker (settings.js): zoned playlists greyed/disabled with the reason (GET /playlists now
returns a zoned flag); backend reject is the guard if the UI is bypassed. i18n x5.
isZonedPlaylist = EXISTS(playlist_items WHERE zone_id IS NOT NULL). Pure restriction - no
zone structure, no api_token_target_zones.
Bite-test (the exact sequence) GREEN and re-proven to bite: full-screen -> designate to an
auto-publish token -> zone-assign the playlist -> agency upload is BLOCKED (409), not
auto-published; neutralizing the upload check makes it go red. 149 suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Settings → API Tokens: each agency token gets an "Edit playlists" control that opens the
playlist picker pre-checked with the token's CURRENT designations (from the list GET's
tok.targets), lets the admin add/remove, and calls the existing PUT /:id/targets to
atomically re-designate. Reuses the creation picker pattern; common.save/cancel reused;
edit_targets + targets_updated i18n across all 5 locales. No security-model change - the
endpoint was already proven.
Test (integration): PUT /:id/targets re-designates (add + remove) and the confinement
follows the NEW set - a re-designated token reaches only its new playlists (router.param
403s the removed one). 148 suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Investigation found zone placement is a DEVICE property (device.layout_id), not a playlist
property: a normal playlist has no derivable layout (zone_id is NULL unless set in the
device-assignment flow), so a playlist-scoped zone grant can't reach the normal flow. The
right model: placement belongs to the device (same playlist can be full-screen on one screen,
a zone on another); the agency just gets whole-playlist grants + size-guidance.
Removed the zone-grant machinery (security-adjacent dead surface is a liability, not dormant
convenience): api_token_target_zones (schema + a DROP migration for the dev DB where the
short-lived CREATE ran), resolveGrantedZone, grantableZoneIds, buildZoneGrantRows, the
create/PUT zone validation, GET /api/playlists/:id/zones, getPlaylistZones, the settings
zone-picker + its i18n, and the zone-grant bite-test.
KEPT (model-agnostic, good): the reactive per-playlist size-guidance card - GET
/api/agency/playlists/:playlistId/layout (router.param-confined) now reports the zones the
playlist actually feeds (where/what-size content lands), or full-screen when it has no layout.
Whole-playlist grants = today's working model. 147 suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Issuance (on the proven seam):
- tokens.js create + PUT /:id/targets accept per-playlist zone grants (target_zones), inserted
into api_token_target_zones inside the same transaction as the playlist grants (FK requires
the parent, so order matters and is correct).
- Issuance validation (the mirror of runtime confinement): grantableZoneIds() - can grant ONLY
a zone the playlist's layout actually feeds; can't grant one it doesn't have or one from
another playlist's layout. Bite-tested. PUT re-designate stays atomic: delete parent rows ->
zone grants cascade out (no manual child delete).
- settings.js: checking a designated playlist reveals its grantable zones (GET
/api/playlists/:id/zones, JWT); leave unchecked = whole-playlist. i18n across all 5 locales.
Card:
- GET /api/agency/playlists/:playlistId/layout (rides router.param - confined; a non-
designated playlist -> 403, asserted). "Your zone" = the GRANTED zones. Retired the
token-wide /layouts (the per-playlist card replaces the disconnected lump).
- Portal card reacts to the playlist selector: pick a playlist -> its layout renders, the
granted zone highlighted with px size, siblings as context.
Full suite + agency bite-suite green (154).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Placement-as-grant, replacing the inferred auto-place idea. api_token_target_zones is an
ADDITIVE second table (does NOT touch the proven api_token_targets), structurally anchored:
a composite FK to api_token_targets(token_id, playlist_id) makes a zone grant orphan-
impossible and cascade away when the playlist grant is revoked - "narrow" is structural, not
conventional. zone_id FK -> layout_zones cascades on zone/layout delete.
Confinement (lib/agency-targets.resolveGrantedZone, called in the item-add): grants exist ->
the item MUST land in a granted zone (a body zone_id picks among grants, never escapes them);
none -> whole-playlist/full-screen as before. The item-add stamps the granted zone_id.
Bite-tested (6, all proven incl. neutralize->red on the confinement): granted YES; non-
granted/cross-playlist/ambiguous blocked; orphan-grant rejected by the FK; cascade on
playlist-grant revoke, on playlist delete, on zone/layout delete; and foreign_keys=ON
asserted (a cascade that no-ops because FKs are off is the trap). 153 suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reuses the existing scheduler + sendEmail infra (no new scheduler). The agency endpoint
enqueues one agency_notifications row per item added; a 15-min flush groups unsent rows per
token+playlist+action and sends ONE digest per group to the workspace owner/admins + the
playlist owner (deduped via UNION). Draft -> "added N items, awaiting approval"; published ->
"updated <playlist>".
Two robustness rules, both tested:
- Queue never balloons when SMTP is off: the endpoint skips enqueue when !isConfigured(),
and the flush drains-and-discards unsent rows as a backstop.
- sent_at is stamped ONLY after a successful send, so a failed send retries next cycle
instead of silently dropping.
Wired into boot via startAgencyDigest(). 147 suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
So the agency can size/place content: returns the canvas size + zone positions/sizes for the
layouts its designated playlists feed, marking which zones are theirs. DEVICE-FREE BY
CONSTRUCTION - the query path is playlist_items.zone_id -> layout_zones -> layouts and never
touches devices/groups/schedules, so device names/locations/IPs/topology are structurally
absent, not filtered. Geometry only - no sibling-zone content. layout.name included (admin's
canvas name); thumbnail_data omitted (could render other zones' content).
Confinement query in lib/agency-layouts.js, bite-tested: own layout YES, a non-designated
playlist's layout NO, response has NO device fields (asserted on a db where a location-named
device exists), and neutralizing the t.token_id filter goes red. 142 suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
api_tokens.auto_publish (DEFAULT 0 = draft, the fail-safe). Admin sets it at token creation
in the designate UI (checkbox, agency scope only). The agency endpoint reads it from the
TOKEN ROW via req.apiToken (apiTokenAuth attaches it) - NEVER from req.body, so an agency
can't opt itself out of approval. 0 -> markDraft; 1 -> the shared publishPlaylist path.
Tests (integration): draft is the default; a draft token with auto_publish:true IN THE BODY
still lands draft (body ignored); an auto-publish token goes live; manual publish still works
(extraction regression). i18n across all 5 locales. 141 suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /:id/publish snapshots items into published_snapshot (what devices consume) + pushes
to devices. Extracted that into publishPlaylist(id, req) so the agency auto-publish path can
call the IDENTICAL logic - a "published" playlist that wasn't snapshotted would be live on no
screen. The manual endpoint now calls it; behavior-preserving (suite green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Agency-facing. A self-contained page at /agency (NOT the dashboard SPA - the agency has no
JWT, only the token). Entry: paste access key -> sessionStorage (cleared on tab close, not
localStorage) -> sent as Bearer. Flow: list designated playlists -> upload (shared ingest =
first-class content) -> date-bounded item on a chosen playlist (lands as draft for admin
re-publish). Graceful failure: any 401/403 resets to the entry screen with "key invalid,
paste it again" - never a wall of 403s. Blast radius of a leaked key stays bounded by the
narrow scope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Admin-facing. Extends the existing API-token UI: an 'agency' scope option reveals a
playlist picker (the workspace's playlists); creating the token binds the checked ones as
its allowlist (target_playlist_ids). The token list shows each agency token's designated
playlists (tokens GET now returns targets for agency-scoped tokens). i18n keys added across
all five locales (parity test).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The portal needs to show an agency which playlists it may post to. New read surface on the
security primitive, built with write-path rigor: the confinement query lives in
lib/agency-targets.js (own token + bound workspace only) and is bite-tested four ways -
own targets yes; another token's, outside the allowlist, and cross-workspace all NO;
neutralizing the t.token_id filter makes it go red. Real-path wiring + the portal's
graceful 401 trigger asserted in the integration suite. No :playlistId, so router.param
doesn't apply - the query is the seam.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agency capability behind the proven off-ladder/agencyGate primitive:
- agencyGate is now SCOPE-only at the mount; the per-target check is router.param
('playlistId') in routes/agency.js - it fires WITH the param before the handler, so no
:playlistId route can skip it (drift-proof). A mount-level target check was silently
bypassed (Express populates req.params only at route match); the integration bite-suite
caught it - this is the fix.
- routes/agency.js: POST /content (shared ingest) + POST /playlists/:id/items (date-bounded
#74/#75 item; lands as draft so the admin's re-publish is the approval gate).
- tokens.js: issue scope='agency' tokens bound to a non-empty in-workspace playlist
allowlist (atomic); PUT /:id/targets re-designates (JWT-only -> can't self-widen).
- server.js: AGENCY_ROUTERS mounted bearerAuth + resolveTenancy + agencyGate.
Full bite-suite (test/agency.test.js) GREEN and re-proven to bite on the SHIPPING path:
neutralizing the router.param check makes non-designated->403 go red. Four assertions at
three seams: target (router.param), off-ladder (tokenScopeGate), can't-widen (tokens
JWT-only), issuance cross-workspace (create validation). 139 suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
routes/content.js POST / processing (thumbnail/dimensions/duration) + insert moved to
lib/content-ingest.js so the agency router produces byte-identical first-class content.
content.js POST / is now a thin caller; behavior-preserving - the 52 content regression
tests (api/operator-permissions/config-paths) pass unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The capability/target-restricted token model for the agency portal (#73 option B),
proven before any endpoint sits on it:
- 'agency' scope value is OFF the read/write/full ladder, so the existing tokenScopeGate
rejects it on every public router by construction (auto-confinement, no new code).
- api_token_targets join table: which playlists an agency token may act on.
- agencyGate: THE single seam - agency-scope-only + (playlist in this token's allowlist
AND in the bound workspace), one query enforcing target + cross-workspace isolation.
- AGENCY_ROUTERS category in config/api-surface.js (mounted with agencyGate, not
tokenScopeGate) - declared; router/mount land with the endpoints.
Both bite-tested: spine (agency 403s on tokenScopeGate; read/write still pass) and the
gate (non-designated/cross-workspace/non-agency/JWT -> 403; neutralizing the target check
goes red). NARROW - not the general capability-scope system.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review caught the encrypted TOTP secret riding in the login/verify response body:
issueSession receives a SELECT * user row and only destructured out password_hash, so
totp_secret_enc (and the internal replay counter totp_last_step) leaked. Encrypted, so
not catastrophic, but it regresses the API work's "secrets never in responses" rule.
Strip both in issueSession (covers /login and /totp/verify); add an assertion that a
verify response carries no totp_secret_enc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unit: the mfa_pending BITE (db-injected so removing the rejection goes red), lockout, replay,
recovery-hash, decrypt-null graceful. Integration: enrollment, login->mfa_required, route-level
bite, recovery single-use, API-token bypass, verify lockout. Key-rotation: enroll under key A,
reboot under key B -> recovery still works, TOTP fails cleanly (no 500).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two-token login: /login returns an mfa_pending token when TOTP is on; requireAuth/optionalAuth
REJECT mfa_pending (tightening #1 - else password-alone is a session). /totp/verify exchanges
it + a TOTP or recovery code for a full session (per-user lockout; recovery checked
independently of the decryptable secret). Enrollment: setup -> enable (confirm-then-enable) ->
recovery codes shown once; disable/regenerate require re-auth; regenerate replaces atomically;
status surfaces codes-remaining (tightening #3). API tokens + SSO bypass TOTP by construction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
app.use('/api/auth/login', rateLimit(...)) etc. keyed on req.path, which Express strips to
'/' for mounted middleware - so /login, /register, /totp/verify shared ONE per-IP counter
(coupled limits; the new /totp/verify brute-force limit was not actually independent). Key
on originalUrl instead. Also adds the /api/auth/totp/verify 10/min limit (tightening #2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lib/totp.js: otplib wrapper; secret stored via secretbox (must be reversible to recompute
codes); recovery codes SHA-256-hashed (api_tokens discipline); verifyCode returns the
matched step and blocks intra-window replay via totp_last_step; decrypt failures return
null (no throw). lib/totp-lockout.js: per-user lockout for /totp/verify (#87 model).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
users.totp_secret_enc (secretbox-encrypted, reversible) + totp_enabled + totp_last_step
(replay guard), and the totp_recovery_codes table (SHA-256 hashed, single-use). Migrations
default everything off so existing accounts are untouched. otplib pinned ^12 (v13 is a
breaking plugin-rewrite with no authenticator/checkDelta).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The OTA was invisible server-side: /api/update/check and /download/apk returned without
logging, which is part of why the 1.9.0 auto-relaunch failure went unseen. Log every
version check (client version vs latest, update_available, whether an APK is staged) and
every APK download (a device actually applying an OTA), keyed on the CF-aware getClientIp
so production logs show the real per-device IP behind Cloudflare, not the edge.
Observability for the #96 auto-relaunch work (this is how we'll watch the OTA fire during
the relaunch testing). Part of #96.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 6-digit pairing code is generated client-side, so the server can't raise its entropy
without a player change. Instead, harden server-side (no client change):
- lib/pair-lockout.js: lock an IP out of POST /api/provision/pair after 5 failed claims
(15-min lockout), and expire stale provisioning codes after 15 min so a code is not
claimable indefinitely. A successful claim resets the IP.
- /pair enforces both. Only an UNKNOWN code (404) counts toward the lockout (a real guess);
an EXPIRED code (410) is a legitimate-but-stale code and does NOT count, so a slow bulk
rollout from one shared-NAT IP can't lock itself out. getClientIp is Cloudflare-aware
(CF-Connecting-IP validated against a trusted edge peer), so the lockout keys on the real
per-client IP, never a shared edge.
Unit-tested deterministically with injected time, incl. the bulk-rollout-never-locks case.
Closes#87
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/provision was a second pairing endpoint that paired a device by code but,
unlike POST /api/provision/pair, did NOT assign a workspace, enforce checkDeviceLimit, or
emit device:paired / dashboard:device-added - a silently-diverging duplicate that no
client ever called. It now returns 410 Gone and points callers at /pair, so
/api/provision/pair is the single, fully-protected pairing endpoint. The mount stays in
the JWT-only partition, so a Bearer st_ token still gets 401 (requireAuth) before the 410.
Closes#90
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The non-security gaps named in the public-API self-review:
- gap-fix: zone_id (playlist items) + layout_id (device PUT) accepted and returned on read,
INCLUDING the cross-tenant rejection (the is_template OR workspace_id guard - the
security-relevant one).
- docs serving: /openapi.yaml serves the spec, /docs returns the Redoc page.
- i18n drift-guard: apitoken.* keys have full parity across en/es/fr/de/pt (a key missing
in one locale fails CI).
- token lifecycle branches: token-create workspace-membership validation and last_used_at
stamping (integration), plus the must_change_password gate (unit test via the in-memory
DB injection - cross-process WAL visibility is unreliable for that branch in-process).
119 tests total (was 108), all in the existing node --test job.
Closes#92
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-review follow-ups, kept as a separate commit so the review trail is honest.
- Spec drift: POST /widgets/preview was documented scope 'read' but the method-based
tokenScopeGate enforces 'write' for any POST, so a read-token integrator following the
published docs would hit a surprise 403. The code is right; fix the SPEC to match it.
- Guard it forever: test/openapi-contract.test.js cross-checks every spec operation's
x-required-scope against the enforcement rule, and that every documented path is a
public (token-reachable) router - both derived from the same config/api-surface.js.
Adds js-yaml (devDep) to parse the spec. Spec/enforcement drift now fails CI.
- Vendored Redoc: add frontend/vendor/README.md (library, version 2.3.9, source, update
steps) and drop the dangling //# sourceMappingURL line so /docs doesn't 404 in devtools.
Remaining (non-security) test-coverage gaps tracked in #92.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A dedicated public-API suite (boots the real server as a subprocess) so CI green proves
the token layer, not just the pre-existing tests:
- Partition firewall, derived from the SAME config/api-surface.js server.js mounts from:
every JWT-only router 401s a token; a public-surface snapshot fails if any router is
added to the token door; known-privileged routers asserted JWT-only.
- Threat model: role-strip gates, workspace-binding both directions (token ignores
X-Workspace-Id, JWT honors it), the scope ladder, the render bypass, token lifecycle,
and JWT no-regression.
- Device WS round-trip via socket.io-client (added as a devDep): valid device_token
registers + receives its playlist; wrong token rejected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- docs/openapi.yaml: the public, token-reachable surface only, with the auth model
(Bearer st_) and a per-operation x-required-scope (read<write<full). JWT-only routers
are excluded by design.
- Serve /openapi.yaml + /docs (Redoc via a vendored standalone bundle, no CDN so it
works air-gapped; /docs is CSP-exempt). docs/ is bundled into the release tarball.
- CI: redocly lint + a public-only guard that fails loudly if a JWT-only path ever leaks
into the spec.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- playlists: accept zone_id on item create + update, validated against a template or a
layout in the playlist's workspace (no cross-tenant zone reference).
- devices: accept layout_id on PUT /api/devices/:id (symmetry with the layouts route),
validated the same way; null clears it. Both are already returned in the GET SELECTs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- routes/tokens.js: create (returns the full secret once), list (never the secret),
revoke. Mounted JWT-only via api-surface.js so an API token can never mint, list or
revoke tokens - no self-escalation.
- Settings "API Tokens" section: create form (name + read/write/full scope), one-time
secret reveal with copy, token list, revoke; i18n across en/es/fr/de/pt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce the public API's token layer and make the router partition data-driven.
- api_tokens table: SHA-256 hashed secret, st_ prefix, workspace-bound, read/write/full scope.
- middleware/apiToken.js: bearerAuth front door (Bearer st_ -> token auth, else the
unchanged requireAuth); apiTokenAuth acts as the owner with platform powers stripped
to 'user' and the workspace binding made authoritative (X-Workspace-Id ignored);
tokenScopeGate (read=GET, write=mutations) + requireScope('full') for commands.
- config/api-surface.js: single source of truth for the PUBLIC (token front door) vs
JWT-ONLY (requireAuth) router partition. server.js mounts from these lists so the
mount list and the partition firewall test cannot drift.
- device-groups: operational group commands (reboot/shutdown) require the full scope.
A Bearer st_ token fails jwt.verify on the JWT-only routers (401), so privileged
surfaces (admin, workspaces, ai, provision, white-label) are unreachable by exclusion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/provision (the routes/provisioning.js router endpoint) pairs a device
by pairing_code with no rate limit - the limit at server.js:287 was bound only to
the /api/provision/pair override. An authenticated user could brute-force 6-digit
pairing codes against the bare endpoint to claim devices in the unclaimed pool.
Bind the rate limit to the /api/provision mount so it covers both pairing paths.
Verified: 6 rapid POSTs to /api/provision now 429 on the 6th (was unlimited);
/api/provision/pair still 429s on the 6th.
Closes#88
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each playlist item can carry schedule blocks (active days, start/end
time-of-day, optional start/end dates). An item plays when the screen's
local "now" matches at least one block; an item with no blocks always
plays. #74 covers time-of-day/day-of-week windows including overnight
wrap; #75 covers inclusive date ranges (auto-expiry). Evaluation is
on-device, so dayparting and expiry work offline.
- Shared evaluator contract: shared/schedule-vectors.json (39 vectors —
DST US+AU, overnight-wrap anchoring, timezone correctness, date
boundaries). Canonical JS evaluator in server/lib/schedule-eval.js;
Kotlin and Tizen ports kept in lockstep by drift guards (Tizen byte-diff
test, Kotlin JUnit reads the shared JSON, new android-test CI job).
- All three players (web, Android, Tizen) filter by schedule against their
own clock, idle with a "Nothing scheduled" message + 30s re-check when
everything is filtered, and fail open on any evaluator error.
- Editor: per-item schedule modal + row badge in the playlist editor;
client validation mirrors the server; editing marks the playlist draft.
- Part B (behaviour change): device/group schedule overrides now evaluate
in each device's effective timezone instead of server-local time.
- Device detail shows the reported timezone + a clock-skew warning.
- i18n for en/es/fr/de/pt across all new strings (namespaced itemsched.*
to avoid colliding with the device-schedule calendar's schedule.*).
- CHANGELOG documents the feature, the Part B change, the fail-open
guarantee, and the scheduled-single-video re-render tradeoff.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A socket reconnecting with a device_id that no longer exists in `devices`
(e.g. the row was deleted server-side) hit the device_fingerprints insert
with an unknown foreign key. INSERT OR IGNORE does NOT suppress FOREIGN KEY
violations, so it threw a caught-but-noisy "Fingerprint tracking error" on
every such reconnect. Null out an unknown device_id before the insert; a
genuinely fresh device sends no device_id and was always fine.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A never-visited org had no cached white-label, so brand-prime fell through to the
ScreenTinker default baked into the static index.html and flashed it before
branding.js fetched the org brand. Now the /app route injects the resolved
instance / custom-domain branding into the shell as a <meta name="ssr-brand">
(CSP blocks inline <script>, so a meta carries it), and brand-prime applies that
as the fallback when the per-workspace brand is not cached yet - so the page
paints the configured brand on first load instead of ScreenTinker.
- server.js: /app resolves branding (publicBranding strips internal columns) and
injects the HTML-escaped JSON as a meta tag; falls back to plain sendFile on
any error so branding can never break the app shell.
- brand-prime.js: read meta[name=ssr-brand] when there is no rd_branding_<ws>.
Verified: the meta carries the resolved brand (default ScreenTinker and a
platform-default white-label), internal columns do not leak, 66 unit tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- .github/workflows/release.yml: on a v* tag - verify the tag matches VERSION
(fail-fast guard), run tests, build a source tarball + the unsigned Tizen .wgt
and publish a GitHub Release with generated notes, and build+push a multi-arch
(amd64 + arm64) image to ghcr.io/screentinker/screentinker:<version> + :latest.
The Release (artifacts) and the docker push are independent jobs, so an
arm64/QEMU docker failure does not block the GitHub Release and is re-runnable.
Nothing deploys to prod. APK-build-in-CI left as a TODO (keystore secret).
- Dockerfile + .dockerignore: multi-stage node:20-slim image with server +
frontend + VERSION + scripts; DATA_DIR=/data volume for db/uploads/jwt-secret.
Verified to build, boot, serve the dashboard + web player, and persist state.
- docker-compose.example.yml: /data volume, SELF_HOSTED, a node-fetch healthcheck
against /api/status, and an admin-lockout recovery note (reset-admin.js).
- server.js: resolve the OTA APK from DATA_DIR first (a container can mount one
at /data/ScreenTinker.apk), fall back to the legacy in-repo path, 404 gracefully.
- ci.yml: bump checkout/setup-node to v6 (clears the Node-20 action deprecation).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- server/version.js: shared version helper that reads the root VERSION file once
(fallback 0.0.0). Replaces the stale hardcoded 1.2.0 / 1.5.1 / 1.0.0 fallbacks
in /api/version, /api/update/check, and /api/status.
- config.js: DATA_DIR / DB_PATH / UPLOADS_DIR / CERTS_DIR env overrides for the
db, uploads, and certs/jwt-secret locations. Unset resolves to exactly the
legacy in-repo paths, so existing installs (including production) are
byte-for-byte unchanged. Guarded by test/config-paths.test.js.
- package.json: rename remote-display-server -> screentinker (+ lockfile name).
- scripts/bump-version.sh: one-shot bump across VERSION, package.json (+lock),
android (versionName and versionCode + 1), and the tizen widget version; makes
one commit plus an annotated tag; prints the push command, never pushes.
- .gitignore: global *.db / *.db-wal / *.db-shm / *.db.* so no database file
(including .db.devbak backups, at any path) can be committed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Image generation reused the single (text-endpoint) API key, which breaks the
common 'local LLM with no key + OpenAI for images' setup. Add an optional
image_api_key (encrypted, write-only, never returned); generate-design uses it
for image calls and falls back to the main key when blank (all-OpenAI setups).
Local sd.cpp / ComfyUI still need no key. Schema column + migration.
A prompt now produces a full sign: the LLM writes the design AND image prompts,
the server generates the images and composites them with the crisp text layer.
- lib/image-gen.js: text-to-image with 3 BYO/self-hostable backends, all behind
the SSRF guard: 'sdcpp' (local stable-diffusion.cpp OpenAI-compatible server,
exact small sizes that fit VRAM), 'openai' (cloud / OpenAI-compatible, snapped
sizes), 'comfyui' (prompt/history/view API).
- ai.js: prompt asks for a background_prompt (preferred — full-bleed atmosphere)
and an optional foreground image element; after the design is normalized, the
bg + fg images are generated best-effort (a failed image never fails the sign)
and returned as data URLs. New image_* settings (provider/base_url/model),
image_provider whitelist, schema column + migration.
- designer.js: AI-images section in settings; generate applies the background
image; publish bakes the background image into the HTML so it survives.
- server.js: raise JSON body limit to 12mb for embedded image data URLs.
Verified end-to-end on local Vulkan SDXL (RTX 5090): prompt -> bg+fg images on
the canvas -> publish creates a widget with the images embedded. 63/63.
Note: prod (not self-hosted) requires a PUBLIC image endpoint (e.g. OpenAI); the
SSRF guard blocks localhost there. Follow-up: upload generated images to the
content store and reference by URL to avoid multi-MB widget configs.
Models sometimes stacked text lines at the same y (unreadable) and emitted accent
shapes after text, so a band could hide the words.
- deoverlapTexts: push a line down only when it also overlaps horizontally
(leaves side-by-side text alone), with conservative line-height clearance so
real rendering doesn't re-overlap; shift the stack up if it ran past the bottom.
- Order shapes before text in the output so accent bands always render behind the
words.
Verified: 0 text-on-text overlaps across multiple prompts (Playwright DOM check);
unit test asserts overlapping lines get separated + shapes precede text. 63/63.
Text could run off the edge (long/large headlines, nowrap) and shapes placed at
the far edge (e.g. a bottom band at y=100) spilled over.
- Server-side fit pass on every generated element: shrink text fontSize so it
fits the canvas width (chars*fontSize*0.075, tuned for bold/uppercase
headlines) and height (incl. line-height), then nudge x/y within 4% margins;
clamp shapes so x+width<=100 and y+height<=100. Deterministic - doesn't rely on
the model getting layout right.
- Designer preview: vw -> cqw (+ container-type on the canvas) so text scales to
the canvas, not the browser window. The preview was overstating size vs what
actually publishes; now it matches. Published widget keeps vw (scales on the
player).
Verified: Playwright DOM check shows zero elements overflowing the canvas after
generation; unit test asserts long text is shrunk + repositioned in-bounds. 62/62.
- POST /api/ai/models lists the configured endpoint's models (OpenAI-compatible
/models) so the settings modal can populate a 'Load models' dropdown instead of
requiring users to type the model name. Combobox (datalist) so they can still
type a custom one. Admin only; same SSRF guard; uses the posted or saved key.
- Bump generate-design timeout 120s -> 180s for slow local endpoints.
Competitor pressure (Mandoe 'AI Magic Create'): prompt -> signage. We answer it
in a way that's actually BETTER for signage and costs the operator nothing.
Key idea: don't generate raw images (AI garbles text - fatal for menus/promos).
The LLM returns a STRUCTURED design spec (headline, supporting text, accent
shapes, palette) that the existing Designer renders with real fonts - crisp and
fully editable. Reuses the whole Designer.
BYOK, fully under the customer's control: each workspace configures its own
OpenAI-COMPATIBLE endpoint + key - OpenAI cloud OR self-hosted (Ollama / LM Studio
/ llama.cpp). Operator bears zero AI cost/liability.
- server/lib/secretbox.js: AES-256-GCM for the key at rest (never returned).
- routes/ai.js: GET/PUT /api/ai/settings (admin; key write-only) + POST
/generate-design (editor+). Output is strictly validated/normalized (cap count,
clamp ranges, px->%, strip HTML, validate colors) - never trust the model.
SSRF guard: hosted instances block private/internal targets; self-hosted (the
whole point of local AI) may point at localhost/LAN.
- Designer: an 'AI generate' panel (prompt + Generate) + a settings modal.
Verified end-to-end against local Ollama (llama3.1:8b): prompt -> editable design
on the canvas. Unit tests cover normalization + the SSRF guard. Suite 61/61.
Phase 2 (next): AI background images (OpenAI images / AUTOMATIC1111).
After uploading, content thumbnails were blank until the item was added to a
playlist/widget. The public /api/content/:id/thumbnail (and /file) endpoints are
reference-gated (an anonymous player with a UUID must not pull arbitrary tenants'
media), and a plain <img> can't send a Bearer token - so a just-uploaded item 403'd.
- Backend: add an authenticated bypass - a logged-in user who can access the
content's workspace (verified from the Bearer token) may view its file/thumbnail
even when unreferenced. Anonymous players still hit the reference gate.
- Frontend: the content library lazy-fetches thumbnails/previews WITH the token
and swaps in an object URL (IntersectionObserver keeps it under the rate limit;
the URL is revoked after load).
Verified: unreferenced thumbnail now 200 with a bearer token, still 403 anonymous.
Saving a layout grew its zone count on every server restart. Root cause: the
editor saved zones with a per-zone delete-then-POST loop, and POST /zones minted
a NEW uuid for every zone - so each save replaced the seeded ids (z-sh-1, ...)
with fresh uuids. schema.sql re-seeds template zones via INSERT OR IGNORE on every
boot, so the next restart re-added the now-missing canonical zone alongside the
renamed copy -> a 2-zone template became 4, 6, ... (worse for self-hosters who
rebuild often).
Fix:
- PUT /api/layouts/:id now accepts a zones[] and replaces them atomically in one
transaction, REUSING each zone's id when supplied. The editor sends the full
set in a single call, so the layout ends up with exactly those zones and ids
stay stable (also fixes fit_mode not persisting, and stops device->zone
assignments being orphaned by id churn).
- One-time dedupe migration removes positional-duplicate template zones, keeping
the canonical 'z-...' seeded id so the re-seed stays an idempotent no-op.
Verified: 2 atomic saves keep count + ids stable with fit updated; dedupe restores
a polluted 4-zone split template to its 2 canonical zones. Suite 56/56.
The boot summary counted any non-throwing statement, so UPDATE/index migrations
(which always succeed) made a healthy DB report 'applied N new column migration(s)'
every boot. Count only a successful ALTER ... ADD COLUMN (genuinely new), so the
line appears only when a column was actually added.
Self-hosters rebuilding could end up schema-behind-code, failing only at runtime
(a missing users.must_change_password locked out all logins). Two root causes:
1. The migration loop swallowed EVERY error (catch {}), so a real ALTER failure
was indistinguishable from the benign 'duplicate column' on an already-migrated
DB. Now only 'duplicate column'/'already exists' is treated as a no-op; any
other error is logged loudly, and a one-line summary reports how many new
column migrations actually applied this boot.
2. Nothing verified the schema after migrations. Added lib/schema-check.js:
verifyAndRepairSchema() checks the tables + columns the request path REQUIRES,
idempotently repairs missing repairable columns (logging each), and if anything
required is STILL missing, prints a loud FATAL block and exits - failing fast at
boot instead of at the first authed request.
Note: the reported 'audit_log missing' was a misdiagnosis - the code uses
activity_log (0 refs to audit_log), created by schema.sql on every boot.
Tests: healthy (no-op), auto-repair of must_change_password, missing-table report.
Platform admins can now cleanly remove a customer org (account ends) or a stray
workspace from the UI, instead of raw SQL that risks orphaning resources.
The tenant cascade isn't pure DB CASCADE - workspace-scoped tables (devices,
content, playlists, ...) are NO ACTION and must be purged before the workspace.
Extracted that logic out of deleteUserCascade into shared deleteWorkspaceCascade /
deleteOrgCascade helpers (one tested implementation; deleteUserCascade now reuses
the purgeWorkspaces extraction).
Backend (platform-admin only): GET /api/admin/orgs (list + owner + counts +
workspaces), DELETE /api/admin/orgs/:id, DELETE /api/admin/workspaces/:id.
UI: an Organizations section in Admin listing every org/workspace with a
type-the-name confirmation before the irreversible delete.
Tests: org/workspace cascade (real FKs) + endpoint gating/404. Suite 53/53.
MSPs onboarding customers as separate orgs had no way to create one with
AUTO_CREATE_ORG_ON_SIGNUP=false (the only path was signup auto-org). Add a
platform-admin 'Create organization' action.
POST /api/admin/orgs (requirePlatformAdmin) creates the org + its first 'Default'
workspace. organizations.owner_user_id is NOT NULL, so an org can't be ownerless;
the creating admin becomes org_owner + workspace_admin (mirrors the signup
bootstrap in routes/auth.js) - which also surfaces the org in their switcher.
Customer users are then added via the existing Add User / manage-memberships flow.
UI: 'Create organization' button + single-field modal in the Admin area (gated).
Tests: create (201 + memberships + audit), empty-name 400, non-admin/operator 403.
Multi-zone videos/images were cropped: every template zone inherited fit_mode
'cover' (fill+crop) and the layout editor had no control to change it, so a
landscape video in a tall split zone showed only a center strip. The player
already honors fit_mode (web object-fit, Android scaleType) - the gap was the UI
and the default. Add a per-zone Fit selector (Contain/Cover/Stretch) to the layout
editor, and make 'contain' (show the whole frame) the default for new zones, the
schema column, and the save fallbacks. Existing built-in templates are migrated
separately.
Follow-up to the layout cache. On a cold start with a cached playlist but no cached
layout yet (first run after shipping, or cleared cache), the player still rendered
fullscreen and flashed before the payload arrived. Now gate the optimistic cached
render on the layout being KNOWN (cache key present — null=fullscreen vs object=
zoned, both fine); if unknown, wait ~1s for the payload to drive the first render.
Eliminates the fullscreen flash on the very first pass too.
The player cached only the playlist, not the layout. On cold start it restored the
playlist and rendered immediately with layout=null -> fullscreen, then re-rendered
into zones once the server payload arrived (the 'fullscreen first, then split'
flash). Cache the layout alongside the playlist and restore it before the first
render; cleared on reset.
A widget (e.g. directory board) assigned to a 'content' zone rendered as a black
zone: showZoneItem gated the widget branch on zone.zone_type==='widget', so the
widget was skipped and (mime_type null) nothing else matched either. Key off the
assignment's widget_id instead - matching the Android ZoneManager, which is why
the same layout worked on the APK but not the web player.
The render had no Cache-Control. A copy cached before the X-Frame-Options fix keeps
showing blank, and widget data (clock/weather/rss/directory) is dynamic anyway, so
mark the render no-store. Pairs with the X-Frame-Options removal.
The web player embeds widget/kiosk renders in a sandboxed (allow-scripts, no
allow-same-origin) iframe = a null origin. The global helmet X-Frame-Options:
SAMEORIGIN refuses that (null != same-origin), so every widget rendered blank in
the web player (video worked since it isn't an iframe). Drop X-Frame-Options on
just the /render endpoints - the sandbox, not X-Frame-Options, is what isolates
the widget from the dashboard (it still can't read the JWT). Dashboard keeps its
clickjacking protection. Verified: directory board now renders in a sandboxed
iframe with no refusal.
Mirror of the Android fix. The web player showed only the FIRST assignment per
zone (playlist.find) and an image zone set the GLOBAL advanceTimer->nextItem, so
the whole layout re-rendered on one global tick instead of each zone cycling its
own content. Now each zone groups its assignments (by zone_id, sorted), renders
the first, and advances on its OWN timer (images/widgets/youtube: duration;
videos: on end; single-item zones loop). Cleared in teardown. Also render zones
before the single-item 'renderable?' bail so an empty current item can't blank
the screen.
- Server (deviceSocket buildPlaylistPayload): when a device's layout has <2 zones
(single or none), strip leftover zone_id from assignments. After switching a
device from multi-zone back to fullscreen, content was stuck bound to a gone
left/right zone_id and never played; nulling it lets both players fall back to
the default fullscreen renderer.
- Web player: render multi-zone zones BEFORE the single-item 'renderable?' bail,
so an empty/placeholder current rotation item can't blank the whole screen.
The public, CSP-exempt widget render (GET /api/widgets/:id/render) inlined
config values straight into <style>/CSS and (for the text widget) raw into the
same-origin document. A workspace editor could store `}</style><script>...` in a
color/background/size field (bypassing the UI pickers via the API) → stored XSS
executing in the app origin for anyone who opens the render URL (JWT theft).
- safeCss(): allow colors/gradients but reject CSS breakout / url() / @import /
expression / javascript:. Applied to background/color across clock, weather,
rss, social renders.
- safeNumber(): coerce font_size / scroll_speed / max_items to a finite number
so they can't smuggle markup.
- Text widget keeps its intentional raw HTML/CSS feature, but it now renders
inside an <iframe sandbox="allow-scripts"> (NO allow-same-origin) - scripts run
in a null origin that can't reach the dashboard's localStorage/JWT.
Tests: test/widget-render-xss.test.js (breakout rejected, numbers coerced, text
isolated, legit colors/gradients preserved). Full suite green.
Five low-risk, high-value fixes surfaced by the security review:
#3 Branding lockdown — `custom_domain`/`custom_css` (which feed the PUBLIC,
pre-auth branding resolver and the login-page <style>) are now settable only
by platform admins; a workspace_admin can no longer hijack the platform login
page by claiming its domain. The public /api/branding (+ /domain) now return
only presentational fields via publicBranding() (no id/user_id/workspace_id/
custom_domain/timestamps leak).
#6 Strip device_token — the device WS auth secret (validated with
timingSafeEqual) was returned in device list/get/update + pairing responses
(SELECT d.* / *). New lib/device-sanitize.js strips it everywhere; prevents
device impersonation by any workspace user.
#7 must_change_password enforced server-side — was a frontend-only redirect, so
a provisioned temp password worked indefinitely via the API. requireAuth now
403s every route except GET/PUT /api/auth/me (the password change, which
clears the flag) and logout while the flag is set.
#8 XSS — escape user data interpolated into innerHTML in teams.js, kiosk.js,
layout-editor.js (team/page/layout/zone names, member name/email, kiosk
config fields). scriptSrcAttr 'unsafe-inline' made these exploitable via
injected event handlers, not just markup.
#9 Thumbnail IDOR — /api/content/:id/thumbnail had no auth/scope gate (any UUID
served any tenant's thumbnail). Now mirrors the /file route's playlist/widget
workspace-scoped reference check.
Tests: new test/security-fixes.test.js (device strip, publicBranding field
allowlist, must_change_password gate). Full suite 41/41. Verified live against a
prod-data copy: device_token absent from /api/devices, /api/branding trimmed.
Not addressed here (tracked for follow-up): Android OTA signature verification
(Critical), public widget-render XSS, token revocation/logout, pairing-code
strength, validateRemoteUrl hardening, import quota.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
White-label is stored per-workspace (white_labels.workspace_id); unbranded and
new workspaces - and the login page - fell back to hardcoded ScreenTinker. Add a
single platform default that everything inherits beneath the per-workspace layer.
Resolution (lib/branding.js): workspace row -> custom-domain match -> platform
default -> hardcoded ScreenTinker. Row-level override: a workspace with its own
row keeps it (current behavior); only row-less workspaces inherit the default,
so editing the default propagates instantly (no row-copying at creation).
The platform default is a white_labels row with a FIXED id ('platform-default'),
not a "workspace_id IS NULL" sentinel - legacy pre-multitenancy rows can also
have a null workspace_id, which would be ambiguous.
- routes/admin.js: GET/PUT /api/admin/branding (requirePlatformAdmin) to read/
upsert the single platform-default row; audit-logged.
- server.js: public GET /api/branding (domain match -> platform default ->
hardcoded) for pre-login/pre-workspace contexts.
- routes/white-label.js: authed GET now falls back to the platform default
(was hardcoded) for row-less workspaces.
- Frontend: login page resolves + applies branding (logo, name, colors, favicon,
custom CSS) pre-auth; Admin page gets a "Default branding" form.
Tests: resolver order incl. legacy null-ws safety; admin GET/PUT (single row,
upsert, platform-admin-only 403). Full suite 37/37. Verified end-to-end:
public + authed + login-page all inherit the platform default; per-workspace
override preserved.
Closes#15.
The Workspace column on the platform Users page could only move a 0/1-workspace
user and showed a dead "N workspaces" label for multi-membership users. Replace
it with a "Manage workspaces" modal that handles the full picture.
Backend (routes/admin.js, requirePlatformAdmin):
- GET /api/admin/users/:id/workspaces list memberships (+org/ws names, role)
- POST /api/admin/users/:id/workspaces add to a workspace (upsert role)
- PUT /api/admin/users/:id/workspaces/:wsId change role in a workspace
- DELETE /api/admin/users/:id/workspaces/:wsId remove (last one allowed -> unassigned)
Roles validated against WORKSPACE_ROLES; each mutation writes an audit row.
Frontend:
- Workspace cell is now a summary (Unassigned / <name> / N workspaces /
"Platform (all)" for staff) + a Manage button.
- New admin-user-workspaces-modal: lists every membership with an inline role
dropdown + Remove, plus a type-to-filter "Add to workspace" picker (org-grouped,
excludes current memberships) with a role select. Staff get a note that they
already have platform-wide access. Refreshes the table on close if changed.
- Removed the old single-select inline move control (superseded by the modal).
Tests: 6 added (add to multiple workspaces, per-workspace role change, upsert,
remove incl. last->unassigned, validation 400/404, non-platform-admin 403).
Full suite 33/33. Verified headless: Manage opens, lists memberships, filtered
picker, add/role-change/remove round-trips persist (throwaway user, cleaned up).
The #18 user-delete bug was the first symptom of a broader gap: 13 tables
reference workspaces(id) (and activity_log also organizations(id)) with NO
ACTION, so deleting a workspace or organization fails the same FK wall once it
holds any content. SQLite can't ALTER an FK action, so this migration rebuilds
each table (the create-copy-rename pattern the assignments/schedules migrations
already use), changing only the tenant FK clause:
workspace_id -> ON DELETE CASCADE (resources belong to the workspace)
activity_log.workspace_id / organization_id -> ON DELETE SET NULL (keep audit)
user_id FKs are intentionally left as-is - user deletion stays handled app-side
by lib/user-deletion.js (the #18 fix).
- lib/tenant-cascade-migration.js: pure, idempotent core (table-existence
guarded; transforms the stored CREATE text, copies rows verbatim, recreates
indexes; fixes activity_log's AUTOINCREMENT sequence; baseline-vs-after
foreign_key_check so pre-existing orphan rows don't abort it but a botched
rebuild does).
- db/database.js: boot wrapper owns the pre-migration snapshot + process.exit
on failure, matching the other heavy migrations.
Tests (node:test): reproduces the workspace-delete FK failure, applies the
migration, verifies FK actions (CASCADE / SET NULL), index recreation, data
preserved, and that workspace/org delete now cascades (activity_log preserved).
Full suite 27/27. Verified on a copy of a real DB: 13 tables rebuilt,
integrity_check ok, workspace delete cascades, no new FK violations.
DELETE /api/auth/users/:id ran a bare `DELETE FROM users`, but 23 columns
reference users(id) and only 4 cascade, so with foreign_keys=ON the delete
fails the moment the user is referenced anywhere - and a real user always is
(owns an org, created a workspace, has login activity). Reproduces on a fresh
DB, exactly as reported.
The schema also lacks cascades from workspaces -> tenant resources, so the DB
can't clean up on its own. New lib/user-deletion.js resolves every reference in
one transaction (defer_foreign_keys=ON for forgiving order; table-existence
guard for resilience):
- Refuse (409) if the user OWNS an organization that has other members -
don't nuke a shared tenant; transfer ownership first.
- Hard-delete the organizations they SOLELY own (workspaces + all contents).
- In orgs they don't own, PRESERVE resources: SET NULL the nullable
creator/inviter columns, and reassign the NOT NULL legacy creator user_id to
the resource's org owner (fallback: the acting admin).
- Memberships (organization_members/workspace_members/team_members/
content_folders) cascade on the user delete; pending invites they sent and
legacy teams they own are removed.
The handler now 404s an unknown id and 409s the shared-org case.
Tests (node:test): reproduces the FK failure, then verifies provisioned-member
delete (resources preserved + unlinked/reassigned), solo-org-owner cascade,
shared-org refusal (409), self-delete 400, non-superadmin 403, unknown 404.
Full suite 22/22. Verified end-to-end on a copy of a real DB: deleted a user
owning 2 solo orgs, foreign_key_check clean.
Closes#18.
Adds a "Workspace" column (after Plan) to the platform Users admin table so a
platform_admin can see and reassign a user's workspace inline, alongside the
Role/Plan dropdowns. Single-workspace move/assign model.
Backend:
- GET /api/auth/users (platform branch): one aggregate query adds
workspace_count and, for exactly-one membership, the workspace id/name + org
name (no N+1).
- PUT /api/admin/users/:id/workspace (requirePlatformAdmin - operator excluded):
move (1 membership) or assign (0) into the chosen workspace, default role
workspace_viewer, in a transaction; no-op if already there; REFUSES (400) a
user with >1 membership (manage in the members view). logActivity
admin_set_user_workspace.
Frontend (admin.js):
- Editable <select> only for a 'user' with 0/1 membership; multi-membership ->
read-only "N workspaces", platform staff -> read-only "Platform (all)".
- Options grouped by org via <optgroup>, built ONCE from /me's
accessible_workspaces (same source as the Add User picker) and reused per row.
- Picking "Unassigned" or the same workspace is a no-op so a stray pick can't
strip a membership. Success -> toast + refresh. EN i18n only.
Tests: 4 added (single-membership move 200 + changed, zero-membership assign
200, multi-membership 400 refused, non-platform-admin/operator 403). npm test
16/16. Verified headless: column renders, selected value correct, "Platform
(all)" for staff, and a dropdown move persisted (throwaway user, cleaned up).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bug: #13 added 'platform_operator' to the frontend role dropdown
(PLATFORM_ROLE_OPTIONS) but #14's PUT /api/auth/users/:id/role whitelist
(ASSIGNABLE_PLATFORM_ROLES) only listed ['user','platform_admin'], so
selecting "Platform operator" returned 400 "Invalid role" - the role was
unassignable via the UI.
Fix: add 'platform_operator' to ASSIGNABLE_PLATFORM_ROLES. One line; the
self-demote guard is intentionally left untouched (a platform_admin still
cannot self-assign the non-owner operator role and lock themselves out).
Tests (node:test, isolated in-memory DB injection - no DB_PATH change):
- admin-users.test.js: platform_admin can PUT role=platform_operator on a
target user -> 200 and the row persists as platform_operator (regression
guard for the whitelist gap).
- operator-permissions.test.js (new): verify-then-test of the highest-blast
-radius deny. Operator CAN update/delete a workspace-scoped content row
(cross-org write works) but is denied (403) updating or deleting a shared
(workspace_id IS NULL) row - proving the separate PLATFORM_ROLES gate in
content.js's checkContentWrite still holds after canWrite was broadened to
isPlatformStaff.
Verified read-only (no leak): the other shared-asset write sites keep their
PLATFORM_ROLES gate that excludes operator - kiosk.js:57, widgets.js:110,
folders.js:31, layouts.js:59/117/133.
cd server && npm test -> 12 pass / 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds server/test/admin-users.test.js and a `npm test` (node --test) script.
No DB_PATH override: the suite mounts the real routers against an isolated
in-memory better-sqlite3 instance injected into the require cache, seeded by
the test itself. Node v20 built-ins only (node:test, node:assert, fetch).
Covers: Add User success (response omits password/hash, hash stored not
plaintext, membership written, hosted lifecycle sentinels stamped, audit row
without the password), duplicate-email 409 (no overwrite), non-admin 403,
platform_operator denied (403), org_admin scoped to their own org only,
input validation, and the must_change_password lifecycle (set on create,
surfaced on login, cleared on PUT /api/auth/me).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MSP-style deployments want self-service signups created WITHOUT a personal
org, so an admin/operator can assign them into an existing customer org
afterward.
- config.autoCreateOrgOnSignup (AUTO_CREATE_ORG_ON_SIGNUP env), default
true - single-tenant and the hosted self-service flow are unchanged.
- ensureDefaultOrgForUser gains { allowCreate }: an existing membership is
always returned (idempotent); the MINT path is gated. allowCreate=false +
no membership -> returns null (user created org-less).
- register accepts a per-request createOrg flag overriding the deployment
default; the first-ever user is always given an org (never headless).
login / Google / Microsoft pass allowCreate from the global config, so an
org-less user is not silently given an org on next sign-in.
Edge case: a non-platform user with zero workspaces now lands on a "no
workspaces yet" empty state (new no-workspace view) instead of being bounced
into onboarding (whose pairing step needs a workspace). route() redirects
them there, and refreshCurrentUser() redirects once /me reveals zero
accessible_workspaces (covers the first-load race). The workspace switcher
already rendered an empty placeholder and resource routes already return []
for a null workspace, so nothing crashes in between.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds POST /api/admin/users so an admin can create a user directly with a
known password and assign them to a workspace + role - for self-hosted
instances with no outbound email, where invites never deliver.
Server (routes/admin.js, mounted /api/admin with requireAuth + activityLogger):
- Gated by canAdminWorkspace(db, req.user, targetWorkspace): 404 if the
workspace is missing, 403 if not an admin of it. This scopes org_admins
to their own org and excludes platform_operator (no user/role mgmt, #13).
- Validates email (invite-create regex), role in WORKSPACE_ROLES, password
min-8 (the /me rule). 409 on duplicate email - never overwrites.
- One transaction: global users row (auth_provider 'local',
bcrypt.hashSync(pw,10), must_change_password from the flag) + a
workspace_members row written inline (same footprint as an accepted
invite; accept-invite left untouched).
- Explicit audit row admin_create_user; never logs the password; response
excludes password/hash.
- HOSTED_INSTANCE: never calls sendSignupEmails and stamps both
welcome_email_sent_at / activation_nudge_sent_at, so an admin-created
user gets no welcome email and never enters the activation-nudge sweep.
must_change_password (frontend-first enforcement, per spec):
- Migration adds users.must_change_password INTEGER NOT NULL DEFAULT 0;
surfaced via requireAuth + /me + login responses.
- route() in app.js forces users with the flag to a #/change-password
screen (new force-password-change view, reuses PUT /api/auth/me) and
blocks every other view until set. The /me update clears the flag.
Frontend: "Add User" button beside "Invite member" in the members view
(admin-only) opening a modal (email, name, password + generate, role,
must-change checkbox); invite and Add User coexist. api.adminCreateUser;
EN i18n only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
platform_operator is cross-org STAFF: it can see and act-as into every
org and read/write workspace-scoped resources (content, playlists,
layouts, schedules, devices, widgets, kiosk) anywhere - but holds NO
owner-level power.
Design is deny-by-default: operator is NEVER added to PLATFORM_ROLES /
isPlatformRole, so every owner capability (billing, org/workspace
deletion, user/role management, shared & template asset curation,
branding, workspace member mgmt/rename) stays denied, and any NEW owner
endpoint added later inherits that denial automatically.
Operator gets power from exactly two levers:
- middleware/auth.js: new PLATFORM_STAFF set + isPlatformStaff(); owner
guards (PLATFORM_ROLES, requireAdmin, requireSuperAdmin) unchanged.
- tenancy.js: accessContext + resolveTenancy treat staff as act-as
capable; new req.isPlatformStaff / req.isPlatformOperator (req.isPlatformAdmin
stays owner-only); accessibleWorkspaceIds + switch-workspace guard use staff.
- permissions.js: canRead/canWrite + canAccessWorkspace (read) grant staff;
canAdmin / canAdminWorkspace / isOrgAdmin / isOrgOwner stay owner-gated.
Read-only edges (per review): operator may VIEW workspace member lists
(canAccessWorkspace) and the unassigned device pool (devices.js), but
cannot mutate either.
Frontend: platform role dropdown adds "Platform operator"; the user-mgmt
view stays isPlatformAdmin-gated so operators can't open it. EN i18n only.
Behaviour identical under HOSTED_INSTANCE set or unset.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The legacy /api/auth/users dropdown could write 'superadmin' and 'admin'
role strings that not every code path recognized. Some checks matched only
'platform_admin' (tenancy accessContext/resolveTenancy), so a 'superadmin'
user could list orgs but not act-as into them.
Normalize to the current two-tier platform model (users.role holds the
PLATFORM role only; org/workspace roles live in the membership tables):
- Migration (idempotent, exact-string): superadmin -> platform_admin,
admin -> user. No-ops on rows already in the current model.
- Add isPlatformRole() helper in middleware/auth.js; route the two
superadmin-excluding checks in tenancy.js through it so a stray
'superadmin' is never treated as lower-privileged (fixes act-as).
- Remove the dead/stricter requirePlatformAdmin in permissions.js (bare
=== 'platform_admin'); the single guard is the one in middleware/auth.js.
- Recovery-token default role admin -> platform_admin so emergency
recovery keeps full access once 'admin' no longer implies elevation.
- PUT /api/auth/users/:id/role whitelist -> ['user','platform_admin'];
self-demote guard retargeted via isPlatformRole.
- Frontend: platform user-management dropdown now offers User / Platform
admin only; owner-delete guard and settings highlight use isPlatformAdmin.
EN i18n: add admin.role.platform_admin.
Behaviour is identical under HOSTED_INSTANCE set or unset; the migration
only touches exact legacy strings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A player stuck in a tight loop (playlist with 0-second item durations)
fires device:play-event 'play_start' ~3x/sec, inserting a play_logs row
each time. Three web players doing this generated ~909k rows (99.9% with
duration_sec=0) and grew the prod DB to 265 MB.
Throttle proof-of-play inserts to at most one per device per 2s (in-memory
lastPlayLogAt map). Skipped cycles create no row; the live dashboard
progress event still fires every time, so the UI is unaffected. The
play_end UPDATE only closes open rows, so throttling play_start is safe.
(Junk rows already pruned in prod: 909k deleted, DB 265 MB -> 9.8 MB.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Daily sweep (15:00 UTC) emails a warm, personal "checking in" message
to users who signed up 3-14 days ago and still have no paired screen,
nudging them toward activation. Once per user, reuses the Graph
transport (services/email.js) via the existing fromName/rawSubject
options.
- New service services/activationNudge.js, started from server.js.
Self-correcting daily scheduler (recompute next 15:00 UTC each run;
no node-cron dependency).
- Eligibility (Option B, workspace-aware): created 3-14 days ago,
activation_nudge_sent_at IS NULL, COALESCE(email_alerts,1)=1 (only
an explicit opt-out of 0 is excluded; NULL/unset still qualify), and
ZERO devices owned by the user OR present in any workspace they
belong to. The workspace check avoids nudging engaged team members.
- Idempotency: activation_nudge_sent_at, stamped after send; paired
sentinel-1 backfill so the first sweep can't blast the dormant
legacy base. Only genuinely-new signups become eligible.
- GATE: HOSTED_INSTANCE=true (positive hosted signal, NOT !selfHosted).
A daily bulk sweep would be far worse to leak than a single email, so
a self-hoster who configured Graph but missed SELF_HOSTED won't blast
their user base. Unset -> neither scheduled nor sent. Documented in
.env.example.
The admin signup-notify recipient was hardcoded to
support@screentinker.com and shipped in the open-source code. Combined
with the opt-out SELF_HOSTED gate, any self-hoster who configured their
own Graph credentials but forgot SELF_HOSTED=true would fire their
users' signup PII (email, IP, country) into our support inbox.
Source the recipient from ADMIN_NOTIFY_EMAIL instead, defaulting to
null. When unset, the admin notification is skipped entirely and logged
("[SIGNUP-EMAIL] admin notify skipped (ADMIN_NOTIFY_EMAIL unset)"); the
user's welcome email is unaffected. Hosted prod sets the env var so its
notifications continue; self-hosters send nothing to us by default, and
the .com address no longer ships in code.
Document ADMIN_NOTIFY_EMAIL (and the related mail/self-host vars) in a
new .env.example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Admin signup notifications were going to dw5304@gmail.com. Route them
to the monitored support@screentinker.com queue instead, so signups
land in the shared inbox rather than a personal account.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every new user now gets a personal welcome email from
"Dan at ScreenTinker" <support@screentinker.com>, and Dan gets an
admin notification, immediately after signup. Fired from all three
signup paths (local /register, Google, Microsoft) via a shared
helper (services/signupEmails.js) at the new-user branch only, so
OAuth logins of existing users don't re-trigger.
- Reuses the single Microsoft Graph transport (services/email.js).
Adds two optional, backward-compatible params: fromName (custom
From display name; address stays support@ so replies route there)
and rawSubject (skip the "[ScreenTinker] " prefix for clean
subjects "Welcome to ScreenTinker" / "New signup: <email>").
- Idempotency: users.welcome_email_sent_at, stamped after the send
block; non-null short-circuits so a user is only emailed once.
Paired backfill stamps all pre-existing users with sentinel 1 so
a future "IS NULL" sweep can't mistake the legacy base for
un-welcomed and blast them.
- Production-only: gated on !config.selfHosted so self-host
operators never emit mail from our domain or CC Dan.
- No retry logic by design (no re-trigger path on existing users);
per-email {sent, reason} is logged so a Graph hiccup is visible.
Admin notification includes workspace org name, email, UTC + Central
timestamp, client IP (CF-aware), CF-IPCountry, and user agent.
The webpage widget's inner iframe previously declared
sandbox="allow-scripts allow-same-origin", which was
functionally stripped to "allow-scripts" by the outer
iframe sandbox added in fe36c8c. This commit makes the
declared sandbox match the actual effective behavior.
Closes the remaining piece of issue #8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the primary finding from the May 27 security report (issue #8):
the admin widget preview modal (frontend/js/views/widgets.js) and the web
player widget renderer (server/player/index.html, 2 sites) loaded
user-authored widget HTML into unsandboxed iframes. Same-origin scripts
in the widget content could access window.parent.localStorage and
exfiltrate the JWT.
sandbox="allow-scripts" without allow-same-origin sandboxes the widget
into a unique origin: inline scripts (clock, RSS, weather widgets)
continue to work, but parent-origin access and same-origin requests are
blocked. Verified via Playwright probe against all 6 widget types in the
dev DB (clock, rss, social, text, weather, webpage): each renders
correctly under the new sandbox and contentDocument access from the
parent is blocked (opaque-origin enforcement working). Admin preview
unchanged in appearance; player display unchanged.
Webpage widget (server/routes/widgets.js) sandbox tightening (drop
allow-same-origin) is a separate forthcoming commit - needs test against
real embed URLs since some sites rely on same-origin behavior. The
sandbox-attribute intersection rule means today's outer-iframe sandbox
will cascade and strip allow-same-origin from the webpage widget's inner
iframe too; accepted as a narrow cosmetic regression (cookies/localStorage
stripped for embedded sites) until the deliberate inner-iframe handling
ships.
SECURITY.md added with reporting process (GitHub Security Advisories
primary, support@bytetinker.net fallback) and scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 1+3 (c4fbd2b) introduced PUBLIC_URL as the env var name for the
public-facing origin used to construct invite-accept URLs. The README
has long documented APP_URL as the canonical name for this concept
(used for Stripe callbacks in the existing codebase). The new code
should have read APP_URL from the start; PUBLIC_URL was unintentional
naming drift.
Caught during prod-deploy survey on 2026-05-17: APP_URL was set on the
production systemd unit and documented in the README, but read by no
code path on origin/main. PUBLIC_URL was read by slice-1 code but set
nowhere. The bug was masked in 99% of cases by the request-derived
fallback (${req.protocol}://${req.get('host')}) which produces the
correct URL when invites are triggered from browsers behind Cloudflare.
It would have manifested for any future non-browser-triggered invite
path.
README updated to note APP_URL covers both Stripe callbacks and
invite-accept URL generation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 2C: hash route #/accept-invite/{id} with full flow support across
all six auth entry points (login/register/Google/Microsoft/support/setup)
via app-boot consumer pattern rather than per-handler hooks. Stash
mechanism uses localStorage with timestamp + staleness check
(INVITE_EXPIRY_DAYS_FRONTEND = 7, mirrors backend default). On success:
switch workspace, reload, show toast post-reload via scoped
pending_invite_toast key. On error: showToast directly, no reload.
Non-reentrant guard prevents double-consume across the synthetic
hashchange that fires before reload completes.
Two bugs surfaced during Playwright-driven verification (slice 1 left
two latent issues that only manifested when the full accept-invite
flow ran end-to-end):
1. Email URL path: workspaces.js constructed
${publicBase}/#/accept-invite/X which lands on the marketing landing
page (the SPA is at /app). Fixed to use
${publicBase}/app#/accept-invite/X. Any invite email sent before
this fix would have produced an unfollowable link.
2. Synchronous hashchange race: location.hash = '#/' followed by
reload() fires hashchange BEFORE the reload unloads the page. The
intermediate route() call would consume the toast key against a DOM
about to be destroyed, so the post-reload page had no toast. Fixed
with history.replaceState which mutates hash without firing
hashchange.
Files:
- server/routes/workspaces.js (+4/-1, /app path fix + comment)
- frontend/js/api.js (+3 LOC, acceptInvite helper)
- frontend/js/app.js (+154 LOC, accept-invite plumbing)
- frontend/js/i18n/en.js (+9 LOC, accept.* keys)
Browser verification: 11/11 assertions PASS via Playwright suite
covering all 5 D-cases (unauthed flow, authed direct, wrong account,
stale stash, already-member). Script stashed at
~/Documents/screentinker-2c-playwright-2026-05.py.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 1 + 3 of the user-management feature from the May 12 plan.
Backend-only - no UI yet (slice 2 ships separately). Backend +
accept-handler together so the email accept link is functional
from day one without a half-state.
Endpoints added:
- GET /api/workspaces/:id/members (any member; via_org=true
for org-level entries,
read-only from ws context)
- GET /api/workspaces/:id/invites (workspace_admin)
- POST /api/workspaces/:id/invites (workspace_admin)
- DELETE /api/workspaces/:id/invites/:inviteId (workspace_admin)
- PUT /api/workspaces/:id/members/:userId (workspace_admin)
- DELETE /api/workspaces/:id/members/:userId (workspace_admin)
- POST /api/auth/accept-invite/:inviteId (requireAuth +
case-insensitive
email match)
Permission gating:
- canAdminWorkspace (existing) for admin-gated endpoints
- canAccessWorkspace (new helper in lib/permissions.js) for the
members read endpoint - mirrors canAdminWorkspace shape but
admits any workspace_members role plus org/platform paths
Security additions vs the original plan:
- Transaction-bounded collision check on POST /invites closes the
TOCTOU race between simultaneous duplicate POSTs (no UNIQUE
constraint on workspace_invites(workspace_id, email))
- Per-(inviter, workspace), hour-window rate limit on POST /invites
to prevent abuse / cost runaway. Env-configurable via
INVITE_RATE_LIMIT_PER_HOUR with conservative 50/hour default.
429 response is generic - does not echo the configured value.
- Invite expiry env-configurable via INVITE_EXPIRY_DAYS (default 7)
- PUBLIC_URL env var (optional) pins the accept-URL origin in prod;
falls back to request-derived for local dev
Rollback rule on email send: only graph_error (real send attempt
failed at Graph) deletes the row and returns 502. not_configured
and dev_restricted are intentional non-sends - keep the row, count
against rate limit, allow local accept-invite testing to proceed.
Other safety blocks:
- Cannot demote/remove the last workspace_admin (409)
- Cannot remove the parent-org's org_owner via workspace path (403)
- Accept-invite is idempotent if user already a member
- Expired invites delete-on-read and return 410
- Wrong-account accept returns 403 without touching the invite
Expired-invite cleanup added to services/heartbeat.js mirroring
the team_invites sweep pattern.
Verification: 9-case curl-driven E2E against the dev DB fixture
(switcher-test + invitee-existing + invitee-new mid-flow register).
All 9 pass: create / collision-409 / second-create / rate-limit-429 /
existing-user-accept / register-then-accept / wrong-account-403 /
expired-410 / viewer-cannot-invite-403.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The non-admin branch of /me's accessible_workspaces query drove
from workspace_members, so users with org_owner or org_admin on
an organization but no direct workspace_members row were missing
those workspaces from their /me response - and therefore from the
switcher dropdown. Mirrors the access logic in
accessibleWorkspaceIds() (lib/tenancy.js) while keeping the
full-row SELECT shape /me needs.
Verified end-to-end with switcher-test@local.test acting as
org_owner of Acme Studios with no workspace_members row on
Studio B - Studio B now appears in /me's accessible_workspaces
with workspace_role: null, can_admin: true.
Also updates the stale TODO comment in tenancy.js that flagged
this exact gap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Smart TVs (Tizen, WebOS, Fire TV, Bravia) have no accessible browser
devtools, so when the player misbehaves on those platforms we previously
had zero visibility. This adds two paths to fix that:
- Visible debug overlay rendered on the TV screen for phone-photo capture
- Automatic server-side telemetry sink for hands-off error reporting
Client side (server/player/):
- Inline ES5 error trap as first script in index.html captures errors
even from parse-time failures in later scripts. Captures into
window.__debugLog with 200-entry cap.
- debug-overlay.js renders a fixed-position overlay covering the top 40%
of the screen. Activates via ?debug=1, d-e-b-u-g key sequence, Samsung
red button (keyCode 403), or smart-TV UA + ?autodebug=1. Freeze toggle
(F key or Samsung green) with visible FROZEN badge for phone capture.
pointer-events: none so touches pass through to the player underneath.
- Reporter machinery posts captured errors to /api/player-debug with
5-second debounce batching, sendBeacon on unload (with payload size
capping to stay under 64KB), 5-minute backoff after 429 responses.
UA-gated: smart-TV allow-list first (handles Tizen-with-Chrome/108),
modern-desktop deny-list second, default-report for unknown UAs.
- Two-pass djb2 fingerprint (16 hex chars) per error for future grouping.
- Absolute script src (/player/debug-overlay.js) so the script loads
regardless of trailing-slash on the player URL.
Server side:
- New player_debug_logs table (10000-row FIFO cap, indexed on
fingerprint + created_at). Schema in schema.sql, idempotent via
CREATE TABLE IF NOT EXISTS.
- POST /api/player-debug unauthenticated (so unpaired players can also
report), rate-limited 10/min/IP, per-field length caps to prevent abuse.
- Dynamic /player HTML route injects window.__playerConfig.debugReporting
based on PLAYER_DEBUG_REPORTING env var (defaults on; =off suppresses
all client telemetry traffic). Other player assets still served static.
- Admin routes (requireAuth + requireSuperAdmin):
GET /api/player-debug/list with pagination and filters
GET /api/player-debug/summary for UA family counts
DELETE /api/player-debug/older-than for manual purge
Admin view (#/admin/player-debug):
- UA family summary at top (Tizen/WebOS/Fire TV/Bravia/Edge/Chrome/etc)
- Filter row: UA contains, date range, has-error checkbox
- Paginated table with expand-row JSON viewer for error_data and context
- device_id labeled (self-reported) since field is unauthenticated input
- Manual delete-older-than button with confirmation dialog
Verified end-to-end with Playwright + Chromium (17/17 checks pass) plus
manual real-browser verification including UA-spoofed Tizen flow landing
rows in the admin view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 73f41c3 (server-side zone_id wiring). With this commit
the zone feature is verified working end-to-end: dashboard zone
picker renders correctly, zone_id saves and persists, the per-row
zone dropdown reflects the saved zone after reload, and a live
player run with computed-style inspection confirmed zone divs and
video elements size correctly within their geometry.
Frontend (device-detail.js, en.js):
- Add-content modal: zone picker slot now renders in all four states
(has_zones / no_layout / fetch_failed / empty_layout) instead of
silently vanishing when zones.length === 0. Informational rows
match form-group styling and tell the user which control to use
next. Closes the gate-4 symptom where 38-of-42 devices (no layout
assigned) silently dropped zone_id on every assignment.
- Both /api/layouts/:id fetches (add modal, edit-path) now have
!res.ok throw guards and surface failures via console.warn instead
of swallowing them. The add modal additionally exposes the failure
state to the user via the fetch_failed info row.
- Edit-path zone dropdown: replaced brittle DOM-scraping (reading
the i18n label text and matching z.id.slice(0,8) against rendered
meta HTML) with a data-current-zone-id attribute stashed at row
render from a.zone_id. Removes the i18n-format coupling and gives
exact UUID match.
- 3 new i18n keys in en.js (other locales fall back).
Server (devices.js):
- The GET /api/devices/:id assignments query had its own ad-hoc
SELECT projection that was missed during the 73f41c3 site survey.
Without pi.zone_id in this projection, loadDevice() got assignments
without zone_id and the edit-path dropdown displayed "No zone"
after every save+reload even though the DB had the correct value.
One-line fix: add pi.zone_id, mirroring the ITEM_SELECT change in
routes/assignments.js. Listed as the 8th site that 73f41c3's
original survey missed; this commit closes it.
Verification:
- JS parse + en.js ESM load + server module load all clean.
- Live SQL probe: GET /api/devices/:id projection now returns zone_id
for the test rows (id=31 zone_id=z-sh-1, id=54 zone_id=z-sh-2).
- Browser test by hand: zone picker renders per state, zone_id
persists, reload shows saved zone, computed styles on rendered
.zone divs match expected geometry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 (assignments -> playlist_items) dropped zone_id during the
conversion: migrateAssignmentsToPlaylists INSERTed only (playlist_id,
content_id, widget_id, sort_order, duration_sec), and the new
playlist_items DDL omitted the zone_id column entirely. Every write
path on top of playlist_items inherited that omission - the
multi-zone layout assignment feature stopped working.
Frontend always sent zone_id correctly (device-detail.js:1015,1072
POST and PUT both include it; api.addAssignment and api.updateAssignment
forward the body verbatim). Server silently dropped it. The
assignments.js PUT route was the most direct evidence: it destructured
zone_id from req.body but never added it to the updates array.
Schema:
- schema.sql: add zone_id TEXT REFERENCES layout_zones(id) ON DELETE
SET NULL to fresh-install DDL.
- database.js migrations[]: add idempotent ALTER TABLE for existing
installs (the surrounding try/catch loop handles duplicate-column).
Backfill (new gated migration phase2_zone_id_backfill):
- Pre-migration snapshot copied to db/remote_display.pre-zone-id-
backfill-<ts>.db (one-off for this migration; the general
every-migration-snapshot framework is a separate concern, not built
here).
- Best-effort UPDATE playlist_items.zone_id from surviving
assignments rows via device.playlist_id + content_id/widget_id
match, LIMIT 1 for the multi-match edge case.
- Regenerates published_snapshot for every published playlist so the
JSON the player consumes carries zone_id going forward. Even with
zero rows backfilled (the common case post-Phase-2 cleanup) this
closes the snapshot-staleness gap.
- Stamps schema_migrations regardless so it won't re-run on next boot.
- On the live local DB: 0 playlist_items backfilled, 18
published_snapshots regenerated. On the April 13 prod fixture
(sandboxed copy): 0 backfilled, 7 regenerated. Expected and matches
our pre-flight finding that assignments was effectively scrubbed of
zone_id everywhere.
Route wiring (7 sites + 1 shared constant):
- assignments.js ITEM_SELECT: project pi.zone_id (read path so the
frontend display at device-detail.js:500 surfaces the value).
- assignments.js POST INSERT: include zone_id column + value.
- assignments.js PUT: actually use the already-destructured zone_id
in the updates allow-list. Treats undefined as "no change" so a PUT
that omits zone_id leaves the existing value intact; any explicit
value (including null) is written.
- assignments.js copy-to INSERT: preserve a.zone_id during
device-to-device playlist copy.
- playlists.js buildSnapshotItems: project pi.zone_id so the snapshot
JSON carries it. This is what the player's renderZones loop reads
(player/index.html:1338 matches a.zone_id === zone.id).
- playlists.js discard-revert INSERT: restore zone_id from snapshot
item on revert.
Out of scope (verified safe by SQL semantics + UI inspection):
- playlists.js POST item-add and PUT item-update in the playlist-detail
surface: the UI there doesn't expose zone editing, and their SQL
leaves zone_id NULL on insert / untouched on update. No regression.
- Other playlists.js SELECT projections (lines 141, 190, 240, 265, 334,
379, 419) all use SELECT pi.* and auto-pick zone_id once the column
exists.
- Kiosk-page assign at device-detail.js:1027 doesn't send zone_id;
separate pre-existing gap, not part of this regression.
Tests (all local, no push, no prod deploy):
- Migration boot on live local DB: clean, idempotent (second boot
skips the gated function).
- Migration boot on April 13 prod fixture (sandboxed copy at
/tmp/zone-fix-fixtures/test-run.db): cleanly runs the full migration
stack (multi-tenancy + 5 other phases the fixture predated) then
the new zone_id backfill. Live local DB untouched.
- 8 SQL-level route behavior tests pass: INSERT stores zone_id, PUT
changes/clears zone_id, ITEM_SELECT and buildSnapshotItems
projections include zone_id, copy-to preserves, discard-revert
restores from snapshot JSON, undefined zone_id in PUT leaves
existing value intact.
Not verified: end-to-end multi-zone playback on a real device. The
SQL + snapshot JSON layer is correct (player consumes
playlist.find(a => a.zone_id === zone.id) and now gets the right
zone_id back from the snapshot); confirming render-to-correct-zone
on actual hardware is the next step before prod deploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Custom" tier on the public pricing page was misrendering as a
better-than-Free tier: headline "Custom", price "Free", "Unlimited
devices/storage", "Get Started" button. Root cause is in DB data,
not markup - the 'enterprise' plan row has price_monthly=0 and
max_devices/storage=-1, and the dynamic render in landing.html maps
those to "Free" + "Unlimited" with the wrong CTA.
Fix: filter the 'enterprise' plan out of the public landing render
(client-side, in landing.html only) and replace it with a hardcoded
Enterprise / Custom marketing card whose Contact Us button opens a
new lead-capture modal.
The DB row itself stays - it is actively used elsewhere:
- auth.js: first user in SELF_HOSTED=true mode is assigned to it
- settings.js: white-label feature is gated on enterprise plan
- 1 user (the dev account) is currently assigned to it
- /api/subscription/plans is also consumed by billing.js, settings.js,
admin.js (logged-in surfaces); they keep getting the full plan list.
The filter is scoped to landing.html's render only.
The in-app billing page renders the same plan with the same cosmetic
bug; that's a logged-in admin surface, out of scope for this commit.
Other 4 cards (Free, Starter, Pro, Business) unchanged.
Frontend (landing.html):
- Filter 'enterprise' from public render
- Hardcoded Enterprise / Custom card. Uses .price class with "Let's
talk" + empty .yearly spacer to match Free card's vertical baseline
so the feature list aligns with the paid cards' baselines.
- Modal markup, CSS (mirrored from frontend/css/main.css conventions
since landing.html doesn't import main.css), and inline JS for
open/close/submit/escape/background-click.
- Honeypot field: hidden 'fax_number' input (off-screen + aria-hidden
+ tabindex=-1). Picked over the obvious 'website' name to catch
mid-tier bots that explicitly skip the well-known honeypot names.
Backend (new server/routes/contact.js):
- POST /api/contact/enterprise, public (unauthenticated)
- Rate limited 5/min/IP+path via the existing rateLimit middleware
- Honeypot check: populated fax_number returns 200 silently, no email
- Server-side validation: required fields, email format, screens
1-100000, multi_tenant in {single,multi}, hosting in {hosted,self,
unsure}. Length caps prevent textarea-bomb abuse.
- Sends via existing services/email.js (Microsoft Graph) to
dan@bytetinker.net from the support@screentinker.com Graph sender.
- Log lines: "[contact] enterprise inquiry from EMAIL (COMPANY)
delivered" or "[contact] honeypot triggered from IP; dropping".
Wired in server.js alongside other public routes (before requireAuth).
Build-time tests passed locally:
- Module loads, server boots clean
- Validation: missing fields, bad email, bad multi_tenant, bad
hosting, screens out of range - all return 400 with the right
error message
- Honeypot: populated fax_number returns 200 success, no email sent,
log line confirms drop
- Rate limit: kicks in at 6th request within a minute as expected
- Real end-to-end send: one test submission delivered to
dan@bytetinker.net via Graph (subject "[ScreenTinker] Enterprise
inquiry: ScreenTinker Build Verification", body formatted with all
fields). GRAPH_DEV_RESTRICT_TO was temporarily widened to include
the recipient for the test and restored to dw5304@gmail.com
immediately after.
- Card render order verified against live API: Free (outline,
Get Started) | Starter | Pro (featured, Most Popular badge) |
Business | Enterprise / Custom (Contact Us -> modal).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two dashboard-accuracy improvements for issue #3.
Disconnect debounce (5s):
- Brief transient flaps (Engine.IO ping miss, eviction-then-reconnect,
Wi-Fi blip) no longer immediately flip the device to offline in the
dashboard. Disconnect handler now defers the offline transition;
register handlers cancel the pending timer if reconnect lands in
window.
- Existing stale-disconnect guard kept as fast-path for the eviction
case (no timer scheduled at all when the active heartbeat conn is
already a different socket).
- Re-check at timer fire compares socketIds: aborts only if a
GENUINELY DIFFERENT socket reclaimed the device. Just the closing
socket's own (not-yet-cleaned-up) entry is treated as stale and
proceeds with offline transition.
- Server-restart mid-grace is handled by the heartbeat checker safety
net (existing component): any 'online' row with last_heartbeat
older than heartbeatTimeout gets marked offline on next sweep.
Truthful single-device command feedback:
- dashboard:device-command handler now checks deviceNs.adapter.rooms
for an active socket before emitting (matches the group-command
route's pattern).
- If room is empty, falls through to commandQueue.queueCommand (lazy
require - if commit C is reverted, MODULE_NOT_FOUND is cached and
every subsequent call gets consistent queued=false behavior).
- Returns three-state ack to caller: { delivered, queued, reason }.
- Server log line was misleading - now logs 'Command delivered to
device X' vs 'Command for offline device X (queued=true/false)'.
Frontend:
- sendCommand() takes optional callback. Without one, fires-and-forgets
(no behavior change for non-wired callers). With one, uses Socket.IO
.timeout(5000).emit so the callback always fires (ack or no_ack).
- Six device-detail command buttons wired to three-state toasts:
reboot, shutdown, screen_off, screen_on, launch, update.
- delivered: green/success toast (existing localized message)
- queued: amber/warning toast (new generic message)
- no_ack: red/error toast
- fallback: red/error toast
- Two callers intentionally left fire-and-forget:
- window._sendCmd (generic remote-overlay keypress/touch helper)
- enable_system_capture (has its own visual state machine; out of
scope for this commit)
Three new i18n keys (en.js only; other locales follow later):
- device.toast.command_queued
- device.toast.command_undeliverable
- device.toast.command_no_ack
Refs #3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Short-lived per-device queue covers the TV-flap window (issue #3):
when a device is mid-reconnect, prior code emitted to an empty room
and the event vanished. Now playlist-updates and commands targeting
an offline device are queued and flushed in order on the next
device:register for that device_id.
server/lib/command-queue.js (new):
- pendingPlaylistUpdate: per-device marker (rebuild via builder on
flush -> always fresh DB state, no stale snapshots)
- pendingCommands: per-device Map<type, payload> with last-of-type
dedup (most recent screen_off wins)
- TTL via COMMAND_QUEUE_TTL_MS env (default 30000)
- Active sweep every 30s prunes expired entries
Memory bounds: ~6 entries per device worst case (1 playlist marker
+ 5 command types), unref'd sweep timer.
Wired emit sites (8 total; the four direct socket.emit calls in
deviceSocket register handlers are intentionally NOT queued because
the socket is alive by definition at those points):
- server/routes/video-walls.js (pushWallPayloadToDevice)
- server/routes/device-groups.js (pushPlaylistToDevice)
- server/routes/content.js (content-delete fan-out)
- server/routes/playlists.js (pushToDevices + assign)
- server/services/scheduler.js (scheduled rotations)
- server/ws/deviceSocket.js x2 (wall leader reclaim/reassign)
server/ws/deviceSocket.js register paths now call flushQueue after
heartbeat.registerConnection + socket.join. Existing
socket.emit('device:playlist-update', ...) lines kept - they send
the initial state on register; the flush replays any queued events.
Player's handlePlaylistUpdate fingerprint check dedupes the
overlap.
Refs #3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Make HEARTBEAT_INTERVAL and HEARTBEAT_TIMEOUT env-tunable so
self-hosters with slow/jittery networks don't have to edit
config.js (issue #3 reporter did exactly this to confirm the
diagnosis). Defaults unchanged at 10000ms / 45000ms so existing
deployments keep current behavior.
Same parseInt(env) || default pattern as PORT/HTTPS_PORT/PING_*.
README env table extended.
Refs #3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Connection-stability layer for issue #3. LG webOS WebKit (and other
TV-grade clients) miss Engine.IO pongs under decode load with the
Socket.IO defaults of 25s ping / 20s timeout, causing spurious
transport drops and a connect/reconnect/evict/disconnect loop on
the device. Default polling-first transport adds another fragility
layer via the polling->WebSocket upgrade dance.
- pingInterval / pingTimeout default to 30000 / 30000 (worst-case
dead-socket detection 60s, up from ~45s). Both env-configurable
via PING_INTERVAL / PING_TIMEOUT.
- Player Socket.IO client: transports: ['websocket', 'polling'].
Tries WebSocket first; falls back to polling on the same connect
attempt if WebSocket fails. Polling fallback preserved for
firewall-restricted networks.
App-level heartbeat checker is unchanged and remains the safety net
for clients that miss the transport-level ping/pong window.
Tradeoffs documented in inline comments. README env table extended
with PING_INTERVAL and PING_TIMEOUT rows.
Refs #3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Deleting a content asset that was actively displayed on screens
caused affected players to go black and never recover; deleting an
actively-playing video also failed to stop playback (audio kept
going). Root cause: handlePlaylistUpdate never tore down the current
media element and could drive currentIndex to NaN when a late
onended fired during the playlist swap.
- Add teardownCurrentMedia() - pause, clear src, .load() to actually
release the decoder and kill audio; null event handlers to prevent
late onended races
- handlePlaylistUpdate: preserve continuity - if the playing item
survives the update keep it playing, otherwise walk forward from
the old position to the next surviving item; empty playlist tears
down to waiting state
- Guard playCurrentItem against empty playlist / non-finite index
- Remove dead device:content-delete socket handler (never emitted)
Resolves#4
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-hosters running internal-only deployments don't need the
marketing homepage. With DISABLE_HOMEPAGE=true, requests to /
302-redirect to /app instead of serving the landing page.
Unset/false preserves current behavior.
Requested via discord feedback.
Previously sendEmail() only logged on error/suppression paths; success
was silent. After prod deploy of c71c401 it was unclear whether the
first alert tick had actually delivered email or not - the answer was
yes but had to be derived from 'no error log + recipient query showed
matching device'. Add a log line on success so future observability
doesn't require detective work.
Replaces the unused EMAIL_WEBHOOK_URL stub with a real Microsoft Graph
Mail.Send pipeline via @azure/msal-node client-credentials flow. Prior
state on prod: every alert email was logged to journalctl and never
sent (21 fallback log lines per hour for the chronic-offline devices).
Four coordinated changes shipped as one commit since they're all part
of making email delivery actually work responsibly:
1. services/email.js (NEW): Graph send via plain HTTPS (no SDK), in-memory
MSAL token cache (refresh 60s pre-expiry), graceful stdout fallback
when GRAPH_* env vars absent. Drop-in replacement for the old webhook.
2. services/alerts.js refactored: sequential await around sendEmail (was
parallel fire-and-forget; first run hit Graph's MailboxConcurrency 429
ApplicationThrottled on a 30-device backlog). Sequential at ~250ms per
send takes 5-8s for the full backlog, well within the 60s tick. Also:
24h long-offline cutoff to stop nagging about chronic-offline devices
(the 20,000+ minute ones); 2-hour dedup window (was 1h) via a generic
shouldSendAlert(type, id, windowMs) helper that future alert types
(payment_failed, plan_limit_hit, etc.) can reuse.
3. Preferences UI: single checkbox in settings.js Account section bound
to users.email_alerts. Saved via the existing Save Profile button. PUT
/api/auth/me extended to accept email_alerts. requireAuth middleware
SELECT now includes email_alerts so it propagates via req.user.
4. Dev safety net: GRAPH_DEV_RESTRICT_TO env var as an allow-list. When
set, only listed recipients reach Graph; everyone else is suppressed
with a log line. Prevents local dev (which often runs against fresh
prod DB copies) from accidentally emailing real prod users. UNSET on
prod systemd unit so production fans out normally.
Also: package.json scripts use --env-file-if-exists=.env so local dev
picks up .env automatically (Node 20.6+ built-in, no dotenv dep). Prod
runs via systemd ExecStart and is unaffected. server/.gitignore added
to keep .env out of git.
Smoke verified end-to-end:
- Sequential send pattern verified (a prior parallel-send tick had hit
Graph's MailboxConcurrency 429 on 30 simultaneous sends; sequential
at ~250ms each completes the same backlog without throttling)
- 24h cutoff silenced 20/21 prod devices on the next tick
- Dev restrict suppressed the 1 within-24h send
- User-preference toggle flipped via UI -> DB -> alert path silently
continued before reaching even the suppression log
/me's accessible_workspaces query gains a device_count field via a
correlated subquery on workspaces.id - WHERE workspace_id = w.id
strictly excludes the unclaimed pair-pool (workspace_id IS NULL fails
equality). Added to both query branches (platform_admin LEFT JOIN and
regular INNER JOIN); microseconds per row at current scale (~37 rows
worst case), not optimizing.
Frontend appends the count to the muted org-name line with a middle-dot
separator: 'Acme Studios . 2 devices'. Singular/plural respected via the
existing tn() helper convention; 'No devices' for empty workspaces. New
formatResourceCount(n, keyBase, zeroKey) helper is generic so the same
shape can wire users/playlists/schedules counts later without refactor.
New i18n keys: switcher.devices_count_one, switcher.devices_count_other,
switcher.no_devices. Added to en.js only; other locales fall back to en
via the existing lookup chain (verified in i18n.js:19).
API smoke verified: switcher-test sees Studio A=2, Field Crew=2;
dw5304 (platform_admin) sees all 37 workspaces with their device counts
varying 0-4; single-workspace zero-device user (geoff.case) sees 0.
Teams in its pre-Workspaces form is being paused while the feature is
redesigned as a user-grouping primitive within the new Workspaces
architecture. The original Teams data model had no workspace-awareness
and was effectively non-functional after Phase 2.2 (every route migrated
away from team_id), but the UI remained reachable and allowed users to
accumulate orphan data while believing they were configuring access
control.
Hide the Teams sidebar nav entry to prevent new entries to the UI.
/api/teams now returns 503 Service Unavailable with a 'feature
redesign in progress' message. Existing teams/team_members/team_invites
table data is preserved indefinitely for forward migration to the
future teams design.
Bonus: requireAuth middleware fires before the catch-all so unauthenticated
callers see the standard 401 instead of the 503 redesign message - avoids
exposing the 'feature being redesigned' signal to unauthenticated probes
or fingerprint scanners.
The previous comment claimed defParamCharset:'utf8' fixed multipart
filename header decoding. It doesn't - that option only fires for the
RFC 5987 encoded filename*=utf-8''... form, which clients rarely send.
The actual UTF-8 recovery happens in the storage.filename callback
(added in d679ca8) via Buffer.from(name,'latin1').toString('utf8').
The option is kept set for the rare RFC 5987 case but the comment no
longer overclaims what it does.
busboy reads the Content-Disposition filename="..." header value as
latin1 by default - even with defParamCharset:'utf8' set, that option
only applies to RFC 5987 encoded filename*=... params, which most
clients (browsers, curl, programmatic HTTP) don't send. Modern clients
send raw UTF-8 bytes for non-ASCII filenames; busboy interprets those
bytes one-byte-per-char as latin1, producing a JS string like 'A-tilde
+ quarter-mark' for 'u-umlaut'. JS then re-encodes that string as UTF-8
on the way to SQLite, yielding 4 bytes (c3 83 c2 bc) for what should be
2 bytes (c3 bc). Classic double-encoding mojibake - shows up in the UI
as 'BegrA-tilde...' instead of 'Begru-umlaut...'.
Fix: in the multer filename callback, re-decode file.originalname from
latin1 to utf8 to recover the original byte sequence. Mutating
originalname here propagates to every route handler reading
req.file.originalname (POST /, PUT /:id/replace, and any future upload
route using the same middleware).
This is the actual visible-mojibake bug semetra22 reported. The prior
commit b677752 (NFC normalize in safeFilename) handles a separate but
related case (macOS NFD clients sending decomposed forms); both fixes
compose correctly - latin1->utf8 first restores the byte sequence,
then NFC normalize collapses NFD into composed form.
Smoke verified by sending raw UTF-8 multipart from a Node https client
(no shell escaping). NFC input 'Begru-umlaut-essungsscreens.jpg' with
bytes c3bc c39f arrives clean (was c383c2bc c383c29f before). NFD input
'u + combining diaeresis' arrives as composed NFC c3bc after both fixes.
Single line change to safeFilename() in routes/content.js: add
.normalize('NFC') before sanitizeString. Covers all 4 user-facing
filename storage sites (POST /, POST /remote, POST /embed, PUT /:id
rename) since they all flow through safeFilename.
Fixes macOS NFD vs Linux NFC mismatch on filename storage that mangled
umlauts (ae/oe/ue/ss) in displayed filenames. macOS clients send
NFD-decomposed names (e.g. 'u' + combining diaeresis U+0308 instead of
the precomposed U+00FC); Linux + most renderers expect NFC. Without
this, names like 'Begruessungsscreens.jpg' arrive with the combining
char floating and display as mojibake.
Reported by semetra22 in Discord with extraordinarily good debugging
narrowing (rename works, upload doesn't = bug is in upload path).
Single-point fix at the convergence of all user-facing filename flows.
Existing NFD-mangled rows in DB not backfilled; users can re-upload or
rename to repair. Optional one-time UPDATE backfill captured as follow-up
in handoff doc.
Smoke verified by invoking safeFilename directly on NFD + NFC inputs of
'Begruessungsscreens.jpg' - both produce identical NFC-normalized bytes
(42656772c3bcc39f756e677373637265656e732e6a7067).
Fix: at connect, enumerate the user's accessible workspace_ids (direct workspace_members + org_owner/admin paths + platform_admin 'all') via new accessibleWorkspaceIds() helper in lib/tenancy.js; socket.join one room per workspace. All 12 dashboardNs.emit sites across deviceSocket / heartbeat / server.js / devices route / video-walls route now route via dashboardNs.to(workspaceRoom(...)).emit() with the workspace looked up from the relevant device or wall. New lib/socket-rooms.js holds the helpers and breaks a circular dependency (dashboardSocket already requires heartbeat, so heartbeat can't require dashboardSocket).
Inbound 6 commands rewired to canActOnDevice(socket, deviceId, tier): request-screenshot is read tier (workspace_viewer+); remote-touch/key/start/stop and device-command are write tier (workspace_editor+). Platform_admin and org_owner/admin always pass via actingAs. Legacy admin/superadmin branch dropped.
Lifecycle note: workspace-switch already calls window.location.reload (Phase 3 switcher), which forces a fresh socket with updated memberships - no per-emit re-evaluation needed.
Smoke tested with 3 simultaneous socket.io-client connections (switcher-test, swninja, dw5304 platform_admin) + direct canActOnDevice invocation for 6 user/device/tier combinations. All 9 outbound isolation cells and all 6 permission gates pass. Fixture mutation: switcher-test's Field Crew membership flipped from workspace_editor to workspace_viewer to exercise the read/write tier split in one login.
KNOWN REGRESSION (Phase 3 fix): platform_admin / superadmin no longer has cross-workspace 'see everything' view. Every route migrated tonight (2.2a-2.2m) deliberately removed the role-based bypass per design doc - cross-workspace visibility will come via dedicated admin endpoints in Phase 3, not magic role bypasses. Until Phase 3 ships, platform admins must switch-workspace to see other workspaces' data.