screentinker/server/ws/dashboardSocket.js
ScreenTinker 1ec32197b2 Let a BrightSign host COLLECT its capture request over HTTP
Server side of the inverted capture path. The host half is not here — see the
end of this message.

Every other player is TOLD to capture: the server emits device:screenshot-request
over the device socket and the page photographs itself. A BrightSign cannot
photograph itself. Video decodes onto a hardware plane the DOM cannot read, so
an in-page canvas returns a frame with the content missing — which is why that
platform has been answering screenshot requests with a card explaining that the
video is uncapturable. Only the host, through the player's own DWS, can get a
real frame.

The obvious way to ask the host is through the page, and it does not work. On an
XT245 (BOS 9.1.93.2) page->host messaging is dead after load: instrumenting the
host to echo the `reason` of EVERY roHtmlWidgetEvent produced nothing at all
while the page was posting, though the boot-time probe round-trips. The registry
is not an alternative either — a running BrightScript does not observe registry
writes made by anyone else, proven by writing the key externally through the DWS
and watching the host ignore it.

What the host CAN do is HTTP; it already fetches its own package updates that
way. So the direction is inverted: the request waits here and the host collects
it. The image comes back over a plain POST, which means a capture will work even
when the page is wedged — exactly when an operator most wants to see the screen.

Held in memory on purpose. A capture request is worthless a minute after it was
made — someone clicked a button and is watching for the result — so persisting
it would only add a way to deliver a stale screenshot after a restart. Bounded
and TTL'd so a fleet going offline mid-request cannot grow it, and a repeat
request REPLACES rather than queues so a 1fps stream builds no backlog.

Authenticated with the same device_id + device_token pair the socket uses.
/api/brightsign/package is public because a player fetches it before it has any
identity; a screenshot is a picture of a customer's screen and belongs to one
display.

deviceSocket now exposes ONE ingestScreenshot() used by both the socket handler
and the HTTP route, so a BrightSign screenshot reaches the dashboard by exactly
the route every other player's does rather than becoming a second, subtly
different feature. Note those exports must be attached AFTER
`module.exports = function setupDeviceSocket`, which reassigns the object —
attaching above it silently wipes them, which cost a debugging round.

NOT INCLUDED, deliberately: the host-side poll. Adding it to autorun.brs's main
loop kills the BrightScript script within seconds of boot — the page keeps
playing, because the widget outlives the script, so from the dashboard it looks
healthy. Cause unidentified; BrightScript runtime faults do not reach
/api/v1/logs, so there is no error text to read. Half a feature that silently
takes down the host is worse than none, so the server waits for a host that can
safely ask.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 14:15:06 -05:00

222 lines
12 KiB
JavaScript

const heartbeat = require('../services/heartbeat');
const { resolveSessionUser } = require('../middleware/auth');
const { db } = require('../db/database');
const { accessContext, accessibleWorkspaceIds } = require('../lib/tenancy');
const { workspaceRoom } = require('../lib/socket-rooms');
const { protectSocket } = require('../lib/safe-socket');
const playerCapabilities = require('../lib/player-capabilities');
const bsSnapshotQueue = require('../lib/brightsign-snapshot-queue');
// Phase 2.3: workspace-scoped socket rooms + per-command permission gates.
// Replaces the previous flat dashboardNs.emit broadcast (which leaked every
// device's status/screenshot/playback events to every connected dashboard)
// and the legacy admin/superadmin role bypass (dead code post-Phase-1
// rename - admin -> user, superadmin -> platform_admin).
//
// On connect: enumerate the user's accessible workspace_ids and socket.join
// a room per workspace. Outbound broadcasts route via dashboardNs.to(room).
// Inbound commands check permission against the target device's workspace.
// Permission gate for inbound socket commands. Read tier = workspace_viewer+;
// write tier = workspace_editor+. Platform_admin and org_owner/admin always
// pass via actingAs.
function canActOnDevice(socket, deviceId, tier /* 'read' | 'write' */) {
const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(deviceId);
if (!device || !device.workspace_id) return false;
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
if (!ws) return false;
const ctx = accessContext(socket.userId, socket.userRole, ws);
if (!ctx) return false;
if (ctx.actingAs) return true; // platform_admin or org admin
if (tier === 'read') return !!ctx.workspaceRole; // viewer/editor/admin all OK
// write tier: workspace_editor or workspace_admin
return ctx.workspaceRole === 'workspace_editor' || ctx.workspaceRole === 'workspace_admin';
}
module.exports = function setupDashboardSocket(io) {
const dashboardNs = io.of('/dashboard');
const deviceNs = io.of('/device');
dashboardNs.use((socket, next) => {
const token = socket.handshake.auth?.token;
if (!token) return next(new Error('Authentication required'));
let session;
try {
// Same resolver as requireAuth, so the socket inherits the pre-TOTP refusal and the
// forced-password-change gate that the HTTP surface enforces.
session = resolveSessionUser(token);
} catch (err) {
if (err.code === 'mfa_required') return next(new Error('mfa_required'));
if (err.code === 'password_change_required') return next(new Error('password_change_required'));
return next(new Error('Invalid token'));
}
// Break-glass identities have no users row and no workspace membership, so
// canActOnDevice -> accessContext already denied them every command. Refuse the
// handshake rather than hold open a socket that can do nothing.
if (session.viaRecovery) return next(new Error('Invalid token'));
socket.userId = session.user.id;
// Role + existence come from the LIVE users row, not the token claim: a deleted or
// demoted user no longer keeps fleet control for the remainder of a 7-day JWT.
socket.userRole = session.user.role;
next();
});
dashboardNs.on('connection', (socket) => {
// #146: same per-connection fail-fast as the device namespace — a throwing
// dashboard handler disconnects only that client, never crashes the server.
protectSocket(socket, () => socket.userId);
// Note on workspace-switch lifecycle: the switcher (Phase 3 MVP) calls
// window.location.reload() after switching, which forces a new socket
// connection with fresh JWT claims. So workspace memberships are
// re-evaluated at connect time and we don't need to re-evaluate per-emit.
const wsIds = accessibleWorkspaceIds(socket.userId, socket.userRole);
for (const wsId of wsIds) socket.join(workspaceRoom(wsId));
console.log(`Dashboard client connected: ${socket.id} (user: ${socket.userId}, rooms: ${wsIds.length})`);
/*
* The capability gate for the remote-view handlers.
*
* dashboard:device-command below has always checked this; these four did not, and the
* reasoning that justifies it there applies here word for word — this socket is reachable
* directly, and a dashboard tab left open still renders the controls the panel had when the
* page was drawn. Measured: a display declaring `[]` still received screenshot-request,
* remote-touch, remote-key and remote-start, silently, and the operator got a toast saying
* the screenshot was on its way.
*
* The ack is OPTIONAL by design: the current dashboard senders (frontend/js/socket.js) pass
* no callback, and a newer one that does gets told which capability is missing instead of
* watching a spinner. Refusing loudly is the whole point of the mechanism.
*/
// Silent by design, unlike the command path: the fleet view asks EVERY visible card for a
// screenshot every 30s, so a log line per refusal would be hundreds every half-minute on a
// real fleet. The ack carries the reason to anyone who asked for one.
function capabilityRefused(device_id, cap, ack) {
const devRow = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id);
if (playerCapabilities.supports(devRow, cap)) return false;
if (typeof ack === 'function') ack({ delivered: false, reason: 'unsupported', capability: cap });
return true;
}
socket.on('dashboard:request-screenshot', (data, ack) => {
const { device_id } = data;
if (!canActOnDevice(socket, device_id, 'read')) return;
if (capabilityRefused(device_id, 'remote.screenshot', ack)) return;
const conn = heartbeat.getConnection(device_id);
if (conn) deviceNs.to(device_id).emit('device:screenshot-request', {});
// BrightSign additionally leaves the request where its HOST can collect it. The page there
// can capture only the graphics plane — video lives on a hardware plane the DOM cannot read —
// and it cannot forward the request to the host either, because page->host messaging is dead
// after load on that platform. So the host polls for this over HTTP, the one direction that
// works. See lib/brightsign-snapshot-queue.js.
try {
const row = db.prepare('SELECT platform FROM devices WHERE id = ?').get(device_id);
if (row && String(row.platform || '').toLowerCase() === 'brightsign') {
bsSnapshotQueue.request(device_id, { width: 960, height: 540 });
}
} catch (e) { /* the socket path already fired; queueing is the bonus, never the blocker */ }
if (typeof ack === 'function') ack({ delivered: !!conn, reason: conn ? undefined : 'offline' });
});
socket.on('dashboard:remote-touch', (data, ack) => {
const { device_id, x, y, x2, y2, duration, action } = data;
if (!canActOnDevice(socket, device_id, 'write')) return;
if (capabilityRefused(device_id, 'remote.input', ack)) return;
// #159: a swipe/drag carries an end point + duration (for scrolling); tap is just x/y.
deviceNs.to(device_id).emit('device:remote-touch', { x, y, x2, y2, duration, action });
if (typeof ack === 'function') ack({ delivered: true });
});
socket.on('dashboard:remote-key', (data, ack) => {
const { device_id, keycode } = data;
if (!canActOnDevice(socket, device_id, 'write')) return;
if (capabilityRefused(device_id, 'remote.input', ack)) return;
console.log(`Remote key: ${keycode} -> ${device_id}`);
deviceNs.to(device_id).emit('device:remote-key', { keycode });
if (typeof ack === 'function') ack({ delivered: true });
});
// Track which devices THIS dashboard socket has a live remote (screenshot-stream) session on, so
// we can stop them if the tab closes / the socket drops — an orphaned stream keeps the device
// capturing every second and can starve a weak panel's decoder (the black-screen we hit).
socket.remoteSessions = new Set();
socket.on('dashboard:remote-start', (data, ack) => {
const { device_id } = data;
if (!canActOnDevice(socket, device_id, 'write')) return;
if (capabilityRefused(device_id, 'remote.stream', ack)) return;
const room = deviceNs.adapter.rooms.get(device_id);
console.log(`Remote start for ${device_id}, room has ${room?.size || 0} socket(s)`);
socket.remoteSessions.add(device_id);
deviceNs.to(device_id).emit('device:remote-start', {});
console.log(`Remote session started for device ${device_id}`);
});
// Deliberately NOT capability-gated, for the same reason set_debug isn't: stopping is the
// thing you need most when a panel's declaration has changed underneath a live stream, and
// refusing it would strand that panel capturing forever.
socket.on('dashboard:remote-stop', (data) => {
const { device_id } = data;
if (!canActOnDevice(socket, device_id, 'write')) return;
socket.remoteSessions.delete(device_id);
deviceNs.to(device_id).emit('device:remote-stop', {});
console.log(`Remote session stopped for device ${device_id}`);
});
socket.on('dashboard:device-command', (data, ack) => {
const { device_id, type, payload } = data;
if (!canActOnDevice(socket, device_id, 'write')) {
if (typeof ack === 'function') ack({ delivered: false, reason: 'forbidden' });
return;
}
// Hiding the button is not enforcement. This socket is reachable directly, group sends fan
// out to mixed-platform fleets, and an older dashboard tab left open still renders the old
// controls. A command the panel cannot honour is refused HERE, with the capability named, so
// it fails loudly instead of being delivered and silently ignored — which is the failure
// this whole mechanism exists to end.
const devRow = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id);
const verdict = playerCapabilities.commandAllowed(devRow, type);
if (!verdict.ok) {
console.warn(`Command ${type} refused for device ${device_id}: needs ${verdict.capability}`);
if (typeof ack === 'function') {
ack({ delivered: false, reason: 'unsupported', capability: verdict.capability });
}
return;
}
const room = deviceNs.adapter.rooms.get(device_id);
if (room && room.size > 0) {
deviceNs.to(device_id).emit('device:command', { type, payload });
console.log(`Command delivered to device ${device_id}: ${type}`);
if (typeof ack === 'function') ack({ delivered: true });
return;
}
// Device offline at emit time. Try to queue (lazy require so reverting
// the queue commit doesn't break this commit - MODULE_NOT_FOUND on the
// first try gets cached by Node's module loader, giving consistent
// queued=false behavior on every subsequent call).
let queued = false;
try {
const queue = require('../lib/command-queue');
queued = queue.queueCommand(device_id, type, payload);
} catch (e) { /* command-queue module absent; fall through to lost */ }
console.log(`Command for offline device ${device_id}: ${type} (queued=${queued})`);
if (typeof ack === 'function') ack({ delivered: false, queued, reason: 'offline' });
});
socket.on('disconnect', () => {
console.log(`Dashboard client disconnected: ${socket.id}`);
// Stop any remote screenshot streams this socket left running (tab closed / navigated away),
// so the device isn't left capturing forever.
for (const device_id of socket.remoteSessions) {
deviceNs.to(device_id).emit('device:remote-stop', {});
console.log(`Auto-stopped orphaned remote session for ${device_id} (dashboard socket ${socket.id} gone)`);
}
socket.remoteSessions.clear();
});
});
return dashboardNs;
};