screentinker/server/lib/socket-rooms.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

35 lines
1.3 KiB
JavaScript

// Phase 2.3: helpers for resolving socket.io room names per workspace /
// device / wall. Extracted from ws/dashboardSocket.js to break a circular
// dependency: dashboardSocket already requires services/heartbeat, so
// heartbeat can't require dashboardSocket. Everything goes through this
// neutral module instead.
const { db } = require('../db/database');
const ROOM_PREFIX = 'workspace:';
function workspaceRoom(workspaceId) {
return workspaceId ? ROOM_PREFIX + workspaceId : null;
}
function deviceRoom(deviceId) {
if (!deviceId) return null;
const d = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(deviceId);
return d?.workspace_id ? workspaceRoom(d.workspace_id) : null;
}
function wallRoom(wallId) {
if (!wallId) return null;
const w = db.prepare('SELECT workspace_id FROM video_walls WHERE id = ?').get(wallId);
return w?.workspace_id ? workspaceRoom(w.workspace_id) : null;
}
// Emit to a workspace room with no-op on missing room. Centralized so callers
// don't have to remember the "skip if null room" guard - silent drop is safer
// than the pre-2.3 platform-wide broadcast.
function emitToWorkspace(ns, room, event, payload) {
if (!room) return;
ns.to(room).emit(event, payload);
}
module.exports = { workspaceRoom, deviceRoom, wallRoom, emitToWorkspace };