screentinker/server/ws/dashboardSocket.js
ScreenTinker fc29843035 feat(socket): Phase 2.3 workspace-scoped dashboard socket rooms + per-command permission gates. Dashboard namespace was previously a flat broadcast - every connected dashboard received every device's status/screenshot/playback events platform-wide (foreign device names + IPs included). Inbound socket commands gated by a legacy admin/superadmin role check that was dead code post-Phase-1 rename.
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.
2026-05-12 11:34:24 -05:00

110 lines
4.8 KiB
JavaScript

const heartbeat = require('../services/heartbeat');
const { verifyToken } = require('../middleware/auth');
const { db } = require('../db/database');
const { accessContext, accessibleWorkspaceIds } = require('../lib/tenancy');
const { workspaceRoom } = require('../lib/socket-rooms');
// 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'));
try {
const decoded = verifyToken(token);
socket.userId = decoded.id;
socket.userRole = decoded.role;
next();
} catch {
next(new Error('Invalid token'));
}
});
dashboardNs.on('connection', (socket) => {
// 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, action } = data;
if (!canActOnDevice(socket, device_id, 'write')) return;
deviceNs.to(device_id).emit('device:remote-touch', { x, y, 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 });
});
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)`);
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;
deviceNs.to(device_id).emit('device:remote-stop', {});
console.log(`Remote session stopped for device ${device_id}`);
});
socket.on('dashboard:device-command', (data) => {
const { device_id, type, payload } = data;
if (!canActOnDevice(socket, device_id, 'write')) return;
deviceNs.to(device_id).emit('device:command', { type, payload });
console.log(`Command sent to device ${device_id}: ${type}`);
});
socket.on('disconnect', () => {
console.log(`Dashboard client disconnected: ${socket.id}`);
});
});
return dashboardNs;
};