screentinker/server/ws/dashboardSocket.js
ScreenTinker 0082191f9b Show only the controls a display can actually honour
Every device control was offered to every display. A browser tab was shown
"Reboot device", a Tizen TV was shown screen power, a player with no
framebuffer read was shown a live view that stayed black. They all looked
like working buttons and did nothing — the "reports success and changes
nothing" shape that keeps costing people days.

Players now declare what they can do at registration, because only the
player knows at runtime: an Android panel gains real screenshots when
accessibility is switched on and loses Tier-2 when device owner is revoked.
The dashboard hides what is not supported rather than disabling it, and the
Info tab lists the capability set so a missing control is explainable.

The declaration is three-state and the middle state is load bearing: NULL
means "has never told us anything" and falls back to a per-platform
baseline, because several hundred displays in the field will not update
before this deploys and blanking their controls would be a far worse bug.
An empty array means "I genuinely can do nothing" and is honoured.

Hiding a button is not enforcement, so unsupported commands are also
refused server-side — the socket is reachable directly and a stale tab
still renders the old controls. Group sends report skipped devices
separately from sent ones; counting an unreachable member as "sent" is how
an operator walks away believing the whole group rebooted.

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

176 lines
8.8 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');
// 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})`);
socket.on('dashboard:request-screenshot', (data) => {
const { device_id } = data;
if (!canActOnDevice(socket, device_id, 'read')) return;
const conn = heartbeat.getConnection(device_id);
if (conn) deviceNs.to(device_id).emit('device:screenshot-request', {});
});
socket.on('dashboard:remote-touch', (data) => {
const { device_id, x, y, x2, y2, duration, action } = data;
if (!canActOnDevice(socket, device_id, 'write')) 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 });
});
socket.on('dashboard:remote-key', (data) => {
const { device_id, keycode } = data;
if (!canActOnDevice(socket, device_id, 'write')) return;
console.log(`Remote key: ${keycode} -> ${device_id}`);
deviceNs.to(device_id).emit('device:remote-key', { keycode });
});
// 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) => {
const { device_id } = data;
if (!canActOnDevice(socket, device_id, 'write')) 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}`);
});
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;
};