A device row carries two fields that are not ordinary data: device_token, the
credential the player proves with on the /device socket, and settings_pin, which
unlocks the player's on-device settings menu and so hands physical control of the
panel to anyone holding it.
device_token was already stripped everywhere. settings_pin was not — it went out on
both the collection and the detail endpoint. The dashboard does show it, but on one
screen only: the device detail page, which fetches a single device. The collection
endpoint had no consumer for it and was returning the PIN for every device in the
workspace on every load.
The detail endpoint keeps it, so that page is unchanged. The list no longer sends it.
Same data, much smaller blast radius, no feature lost.
Tests pin the split in both directions — absent from the list, present on the detail,
and the socket credential absent from both (asserted on the whole serialized payload,
not just the top-level key, so a nested echo would fail too).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(diagnostics): device incident log — why a screen went offline/black, with device-attested cause
Field request (Bold/s_t_r_o_b_e): "screens go offline randomly — let us see the cause." Answers it
across the fleet with a unified incident log, and — the key insight — lets the DEVICE disambiguate
the cause the server can't: if the app process survived the gap it was a NETWORK problem (not a
reboot), and it can even tell a dropped Wi-Fi/Ethernet link from a link-up-but-server-unreachable
(router/upstream) failure.
Schema:
- device_status_log gains reason + detail (WHY each offline transition happened).
- NEW device_events table (unified incident feed): type (offline/online/display_off/display_on/
crash/reboot/network/app_error) + reason + detail, indexed, age-pruned + per-device capped.
Server:
- Capture the socket.io disconnect REASON (transport_close/ping_timeout/transport_error) instead of
discarding it — recorded in the offline-cause log. devices.offline_reason stays on the EXIT-SIGNAL
contract (crashed/clean_exit/silent) — a separate axis, preserved (violent death = 'silent').
- device:event handler (typed incidents) + device:connectivity-report handler (device-attested).
lib/incident-classify.js (pure, unit-tested) composes reason+detail: cold_start->reboot;
link_lost->network "Wi-Fi/Ethernet link lost"; else network "LAN up, server unreachable
(router/upstream)"; appends SSID / weak-signal (rssi<-75) / IP-changed. On a report it upgrades the
most-recent offline row from the server's guess to the device's ground truth.
- heartbeat timeout -> 'heartbeat_timeout'; retention/cap for device_events.
- Device-detail API returns statusLog.reason/detail + the last 50 device_events.
Device (Android WebSocketService): ConnectivityManager default-network callback (link-lost during a
gap) + Wi-Fi SSID/RSSI + IP snapshot -> device:connectivity-report on reconnect (app survived =>
network); ACTION_SCREEN_ON/OFF receiver -> device:event display_on/off ("screen went black"). All
guarded/feature-detected; no manifest change; compiles clean.
Web + Tizen players: reconnect connectivity-report (link_lost from navigator.onLine during the gap)
+ visibilitychange -> display_off/on. Best-effort (no wifi detail in a browser). Tizen exit-signal
marker slice untouched.
CMS (device-detail): the offline cause on the uptime-timeline hover + a new "Recent incidents" panel
(merged offline periods + typed events, friendly labels, detail, relative time + down-duration).
Built as a 4-way parallel agent fan-out over disjoint domains against a locked contract, then
integrated. Verified: full server suite 443/443 (incl. the seam fix keeping the exit-signal contract
intact), Android compileDebugKotlin clean, all players + CMS node -c clean.
Refs #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(diagnostics): internet-reachability probe — split "our server down" from "no internet" (#170)
Follow-up to the incident log: when a device's link is UP but it's offline, "router/upstream" was a
catch-all. The device now probes a public host (1.1.1.1 / 8.8.8.8 :443) DURING the gap, so the cause
pinpoints blame:
- link_lost=true -> Wi‑Fi/Ethernet link lost (device's own link)
- link up, internet_ok=true -> server_down: internet reachable, OUR server was unreachable
- link up, internet_ok=false -> no_internet: router/ISP down
- link up, no probe result -> generic router/upstream (unchanged fallback)
- Android WebSocketService: fire a short daemon-thread TCP probe (443, either host) at disconnect;
the result rides the connectivity-report as internet_ok (omitted if the gap ends before it finishes).
- lib/incident-classify.js: 3-way split on internet_ok; new reasons server_down / no_internet.
- Frontend i18n: device.event.server_down / .no_internet labels.
- Tests: +3 classify cases (server_down, no_internet, link_lost wins over internet_ok). 12/12.
Verified: classify 12/12, Android compileDebugKotlin clean, node -c clean. Refs #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(diagnostics): log an 'upgrade' incident (old → new app_version) — server-side (#170)
When a device reports an app_version different from the stored one, applyDeviceInfo logs an
'upgrade' device_events row (detail 'old → new'). Server-side, so it covers Android/Tizen/web with
no client change; a fresh pair (no prior version) isn't counted. Adds device.event.upgrade label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Lets an operator (or an MDM) own updates instead of the app self-installing, which
on managed panels shows a self-install confirm dialog over customer content
(#155). Three layered controls:
- GLOBAL (server): config.otaEnabled from OTA_ENABLED (default on). When off,
/api/update/check returns update_available:false, reason:ota_disabled_global —
the whole instance stops offering updates.
- PER-DEVICE (server + dashboard): new devices.ota_enabled column (default 1). When
0, that device is never offered an update (reason:ota_disabled_device). A
"Self-update (OTA)" toggle in the device settings flips it via PUT /api/devices/:id.
- AUTO-DETECT (Android): UpdateChecker stands down entirely when a foreign device
owner (an MDM/DPC) manages the panel — detected via getActiveAdmins() + not being
device owner ourselves. Pure client-side, errs safe, needs no server change.
The two server gates are enforced server-side so they cover EVERY client version,
not just ones with the client-side stand-down. When OTA is off the device still
reports its version (dashboard sees state); the MDM/operator owns the actual update.
For an MDM-managed fleet (e.g. Pivot/MAXHUB), turn OTA off and let the MDM push the
APK — the install-dialog race disappears from every angle.
Tests: +2 (per-device gate + a real OTA_ENABLED=false server for the global gate);
full server suite 393 pass; Android compiles.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delete+re-pair mints a new device row whose INSERT omits every setting, silently resetting
orientation/name/playlist/etc to defaults (Bold MDM churn). Add a fingerprint-keyed
device_settings table (no FK to devices -> survives the cascade): snapshot on DELETE, auto-
restore on fingerprint-match re-pair (relinking the fp to the new id), operator re-adopt API
(GET /devices/removed + POST /devices/:id/re-adopt) for the changed-fingerprint case. Purge on
workspace/user/org deletion (no cross-tenant bleed). Orientation enum-validated on PUT + restore.
blocked preserved (re-enforced by the register kill-switch). Wall membership deferred (TODO).
Backend only — frontend re-adopt UI NOT built (awaiting API review). Local only, no bump/tag.
- Enforcement (deviceSocket): resolve identity ONCE via the SNAT-safe chain and check
blocked against the RESOLVED device_id (device_id directly OR fingerprint->device_id),
so a blocked device that reconnects WITHOUT a device_id is still caught — the old
"if (device_id)" gate let a device_id-less reconnect slip past. Still the first gate,
before flap/throttle/DB/playlist. Nulling the token still does NOT block (it
re-provisions) — the blocked column is the lever.
- Dashboard toggle: POST /api/devices/:id/{block,unblock} (write-gated + workspace-scoped
via checkDeviceOwnership) writes devices.blocked; takes effect on the device's NEXT
register with no restart. api.js + a Block/Unblock button in device-detail.js.
- Outage procedure documented in-code: direct SQLite
"UPDATE devices SET blocked = 1 WHERE id = <id>" works with the dashboard down.
Tests: blocked refused at handshake with no playlist build; device_id-less reconnect
with a mapped fingerprint still refused; unblock effective on next register, no restart.
Suite 262/262.
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>
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>
- 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>
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>
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>
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>
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.
Schema: add status and published_snapshot columns to playlists table.
Migration snapshots all existing playlists as published (idempotent via schema_migrations).
Devices always receive the published_snapshot, not live playlist_items.
Edits from device-detail/groups auto-publish immediately (display updates instantly).
Edits from playlist detail page go to draft (requires explicit publish).
POST /playlists/:id/publish snapshots and pushes to all devices.
POST /playlists/:id/discard reverts playlist_items from published snapshot.
Content deletion scrubs references from all published snapshots.
Frontend: draft badge in playlist list, prominent yellow banner with publish/discard
buttons on playlist detail and device detail pages.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
GET /:id now queries playlist_items via device.playlist_id.
DELETE no longer cleans up assignments table (playlist survives device deletion).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Filter out devices with no user_id from the main device listing
- Add GET /api/devices/unassigned endpoint (admin only) for unclaimed devices
- Add "Updating" section to README with upgrade instructions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ScreenTinker - open source digital signage management software.
MIT License, all features included, no license gates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>