From fc29843035782c776b78b91d5b005598f0aa37b5 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 12 May 2026 11:34:24 -0500 Subject: [PATCH] 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. --- server/lib/socket-rooms.js | 34 ++++++++++++++++++ server/lib/tenancy.js | 23 ++++++++++++ server/routes/devices.js | 7 ++-- server/routes/video-walls.js | 20 ++++++----- server/server.js | 4 ++- server/services/heartbeat.js | 5 +-- server/ws/dashboardSocket.js | 70 ++++++++++++++++++++++-------------- server/ws/deviceSocket.js | 32 ++++++++++++----- 8 files changed, 146 insertions(+), 49 deletions(-) create mode 100644 server/lib/socket-rooms.js diff --git a/server/lib/socket-rooms.js b/server/lib/socket-rooms.js new file mode 100644 index 0000000..8467bd4 --- /dev/null +++ b/server/lib/socket-rooms.js @@ -0,0 +1,34 @@ +// 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 }; diff --git a/server/lib/tenancy.js b/server/lib/tenancy.js index 62574f6..a86368f 100644 --- a/server/lib/tenancy.js +++ b/server/lib/tenancy.js @@ -138,6 +138,28 @@ function resolveTenancy(req, res, next) { next(); } +// Enumerate every workspace_id the given user has any path into: +// - direct workspace_members rows +// - any workspace in an org where they are org_owner / org_admin +// - platform_admin / superadmin: every workspace in the system +// Used by socket.io rooms (Phase 2.3) to scope outbound broadcasts. Also a +// candidate to broaden /me's accessible_workspaces query - currently /me only +// returns direct workspace_members for non-admins, missing the org-admin +// path. Future cleanup tracked in the handoff doc. +function accessibleWorkspaceIds(userId, role) { + if (!userId) return []; + if (role === 'platform_admin' || role === 'superadmin') { + return db.prepare('SELECT id FROM workspaces').all().map(r => r.id); + } + return db.prepare(` + SELECT workspace_id AS id FROM workspace_members WHERE user_id = ? + UNION + SELECT w.id FROM workspaces w + JOIN organization_members om ON om.organization_id = w.organization_id + WHERE om.user_id = ? AND om.role IN ('org_owner', 'org_admin') + `).all(userId, userId).map(r => r.id); +} + module.exports = { resolveTenancy, // Exported for testing / direct use by routes that need ad-hoc checks. @@ -145,4 +167,5 @@ module.exports = { membershipOf, orgMembershipOf, firstAccessibleWorkspace, + accessibleWorkspaceIds, }; diff --git a/server/routes/devices.js b/server/routes/devices.js index 2aebfc9..1d36374 100644 --- a/server/routes/devices.js +++ b/server/routes/devices.js @@ -175,10 +175,13 @@ router.delete('/:id', (req, res) => { db.prepare('DELETE FROM video_wall_devices WHERE device_id = ?').run(req.params.id); db.prepare('DELETE FROM devices WHERE id = ?').run(req.params.id); - // Notify dashboard in real-time + // Notify dashboard in real-time. Phase 2.3: scope to the device's + // (now-deleted but still-known) workspace room. `device.workspace_id` + // came from checkDeviceOwnership() above. const io = req.app.get('io'); if (io) { - io.of('/dashboard').emit('dashboard:device-removed', { device_id: req.params.id }); + const { workspaceRoom, emitToWorkspace } = require('../lib/socket-rooms'); + emitToWorkspace(io.of('/dashboard'), workspaceRoom(device.workspace_id), 'dashboard:device-removed', { device_id: req.params.id }); } res.json({ success: true }); diff --git a/server/routes/video-walls.js b/server/routes/video-walls.js index d55e9d7..d711bd4 100644 --- a/server/routes/video-walls.js +++ b/server/routes/video-walls.js @@ -53,13 +53,14 @@ router.get('/', (req, res) => { res.json(walls); }); -// Notify dashboard clients to re-fetch walls/devices. Re-fetches re-apply -// per-user visibility filtering, so a broadcast is safe. -function notifyDashboards(req) { +// Notify dashboard clients to re-fetch walls/devices. Phase 2.3: scoped to +// the wall's workspace room so other tenants don't get a stray refresh ping. +function notifyDashboards(req, workspaceId) { try { const io = req.app.get('io'); - if (!io) return; - io.of('/dashboard').emit('dashboard:wall-changed'); + if (!io || !workspaceId) return; + const { workspaceRoom, emitToWorkspace } = require('../lib/socket-rooms'); + emitToWorkspace(io.of('/dashboard'), workspaceRoom(workspaceId), 'dashboard:wall-changed', null); } catch (e) { /* silent */ } } @@ -125,7 +126,7 @@ router.post('/', (req, res) => { bezel_h_mm || 0, bezel_v_mm || 0, playlist_id || null); const wall = loadWallWithDevices(id); - notifyDashboards(req); + notifyDashboards(req, req.workspaceId); res.status(201).json(wall); }); @@ -181,13 +182,14 @@ router.put('/:id', requireWallWrite, (req, res) => { } pushToWallMembers(req, req.params.id); - notifyDashboards(req); + notifyDashboards(req, req.wall.workspace_id); res.json(loadWallWithDevices(req.params.id)); }); // Delete wall — clear playlists + wall_id on every former member (matches // group-dissolve semantics: leaving the wall returns devices to ungrouped). router.delete('/:id', requireWallWrite, (req, res) => { + const wallWorkspaceId = req.wall.workspace_id; // capture before the DELETE const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(req.params.id); const tx = db.transaction(() => { db.prepare("UPDATE devices SET wall_id = NULL, playlist_id = NULL WHERE wall_id = ?").run(req.params.id); @@ -198,7 +200,7 @@ router.delete('/:id', requireWallWrite, (req, res) => { // Push fresh (now wall-less, playlist-less) payloads to ex-members so they // exit wall mode and clear content immediately. for (const m of members) pushWallPayloadToDevice(req, m.device_id); - notifyDashboards(req); + notifyDashboards(req, wallWorkspaceId); res.json({ success: true }); }); @@ -276,7 +278,7 @@ router.put('/:id/devices', requireWallWrite, (req, res) => { // ex-members so they exit wall mode. for (const id of removedIds) pushWallPayloadToDevice(req, id); pushToWallMembers(req, req.params.id); - notifyDashboards(req); + notifyDashboards(req, req.wall.workspace_id); res.json(loadWallWithDevices(req.params.id)); }); diff --git a/server/server.js b/server/server.js index 30b53b3..bfd702c 100644 --- a/server/server.js +++ b/server/server.js @@ -467,7 +467,9 @@ app.post('/api/provision/pair', requireAuth, resolveTenancy, checkDeviceLimit, ( deviceNs.to(device.id).emit('device:paired', { device_id: device.id, name: deviceName }); const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(device.id); - dashboardNs.emit('dashboard:device-added', updated); + // Phase 2.3: scope to the workspace the device was just claimed into. + const { workspaceRoom, emitToWorkspace } = require('./lib/socket-rooms'); + emitToWorkspace(dashboardNs, workspaceRoom(updated.workspace_id), 'dashboard:device-added', updated); res.json(updated); }); diff --git a/server/services/heartbeat.js b/server/services/heartbeat.js index dea864d..1347b9b 100644 --- a/server/services/heartbeat.js +++ b/server/services/heartbeat.js @@ -1,5 +1,6 @@ const { db } = require('../db/database'); const config = require('../config'); +const { deviceRoom, emitToWorkspace } = require('../lib/socket-rooms'); // Track connected device sockets: deviceId -> { socketId, lastHeartbeat } const deviceConnections = new Map(); @@ -21,8 +22,8 @@ function startHeartbeatChecker(io) { .run(device.id); deviceConnections.delete(device.id); - // Notify dashboard - dashboardNs.emit('dashboard:device-status', { + // Notify dashboard (workspace-scoped via the device's room). + emitToWorkspace(dashboardNs, deviceRoom(device.id), 'dashboard:device-status', { device_id: device.id, status: 'offline', telemetry: null diff --git a/server/ws/dashboardSocket.js b/server/ws/dashboardSocket.js index 66d152d..f941a7f 100644 --- a/server/ws/dashboardSocket.js +++ b/server/ws/dashboardSocket.js @@ -1,11 +1,39 @@ 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'); - // Authenticate dashboard WebSocket connections dashboardNs.use((socket, next) => { const token = socket.handshake.auth?.token; if (!token) return next(new Error('Authentication required')); @@ -19,65 +47,54 @@ module.exports = function setupDashboardSocket(io) { } }); - // Verify the user owns the device or is admin/superadmin - function checkDeviceOwnership(socket, device_id) { - if (['admin', 'superadmin'].includes(socket.userRole)) return true; - const { db } = require('../db/database'); - const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id); - if (!device) return false; - return device.user_id === socket.userId; - } - dashboardNs.on('connection', (socket) => { - console.log(`Dashboard client connected: ${socket.id} (user: ${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})`); - // Request screenshot from a device socket.on('dashboard:request-screenshot', (data) => { const { device_id } = data; - if (!checkDeviceOwnership(socket, device_id)) return; + if (!canActOnDevice(socket, device_id, 'read')) return; const conn = heartbeat.getConnection(device_id); - if (conn) { - deviceNs.to(device_id).emit('device:screenshot-request', {}); - } + if (conn) deviceNs.to(device_id).emit('device:screenshot-request', {}); }); - // Remote control: touch forwarding socket.on('dashboard:remote-touch', (data) => { const { device_id, x, y, action } = data; - if (!checkDeviceOwnership(socket, device_id)) return; + if (!canActOnDevice(socket, device_id, 'write')) return; deviceNs.to(device_id).emit('device:remote-touch', { x, y, action }); }); - // Remote control: key forwarding socket.on('dashboard:remote-key', (data) => { const { device_id, keycode } = data; - if (!checkDeviceOwnership(socket, device_id)) return; + if (!canActOnDevice(socket, device_id, 'write')) return; console.log(`Remote key: ${keycode} -> ${device_id}`); deviceNs.to(device_id).emit('device:remote-key', { keycode }); }); - // Start remote screenshot streaming socket.on('dashboard:remote-start', (data) => { const { device_id } = data; - if (!checkDeviceOwnership(socket, device_id)) return; + 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}`); }); - // Stop remote screenshot streaming socket.on('dashboard:remote-stop', (data) => { const { device_id } = data; - if (!checkDeviceOwnership(socket, device_id)) return; + if (!canActOnDevice(socket, device_id, 'write')) return; deviceNs.to(device_id).emit('device:remote-stop', {}); console.log(`Remote session stopped for device ${device_id}`); }); - // Send command to device (reboot, refresh, etc.) socket.on('dashboard:device-command', (data) => { const { device_id, type, payload } = data; - if (!checkDeviceOwnership(socket, device_id)) return; + 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}`); }); @@ -89,3 +106,4 @@ module.exports = function setupDashboardSocket(io) { return dashboardNs; }; + diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index 43231c0..089f609 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -6,6 +6,13 @@ const { db, pruneTelemetry, pruneScreenshots } = require('../db/database'); const config = require('../config'); const heartbeat = require('../services/heartbeat'); const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription'); +// Phase 2.3: deviceRoom() resolves a device_id to its workspace room so +// dashboardNs.emit can be scoped instead of broadcast platform-wide. +const { deviceRoom, emitToWorkspace } = require('../lib/socket-rooms'); + +function emitToDeviceWorkspace(dashboardNs, deviceId, event, payload) { + emitToWorkspace(dashboardNs, deviceRoom(deviceId), event, payload); +} // In-memory store for latest screenshot per device (avoids disk writes during streaming) let lastScreenshots = {}; @@ -247,7 +254,7 @@ module.exports = function setupDeviceSocket(io) { heartbeat.registerConnection(existing.device_id, socket.id); socket.join(existing.device_id); logDeviceStatus(existing.device_id, 'online'); - dashboardNs.emit('dashboard:device-status', { device_id: existing.device_id, status: 'online' }); + emitToDeviceWorkspace(dashboardNs, existing.device_id, 'dashboard:device-status', { device_id: existing.device_id, status: 'online' }); // Send playlist const access = checkDeviceAccess(existing.device_id); if (!access.allowed) { @@ -343,7 +350,7 @@ module.exports = function setupDeviceSocket(io) { socket.emit('device:playlist-update', buildPlaylistPayload(device_id)); } - dashboardNs.emit('dashboard:device-status', { device_id, status: 'online' }); + emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:device-status', { device_id, status: 'online' }); console.log(`Device reconnected: ${device_id}`); return; } @@ -376,7 +383,12 @@ module.exports = function setupDeviceSocket(io) { socket.join(id); socket.emit('device:registered', { device_id: id, device_token: newToken, status: 'provisioning' }); - dashboardNs.emit('dashboard:device-added', db.prepare('SELECT * FROM devices WHERE id = ?').get(id)); + // Newly-provisioned devices have no workspace_id yet (they'll get one + // on pair claim). emitToDeviceWorkspace silently drops when there's no + // workspace; that's safer than the previous platform-wide broadcast. + // Dashboards refresh /api/devices/unassigned on poll for the + // platform_admin pairing view. + emitToDeviceWorkspace(dashboardNs, id, 'dashboard:device-added', db.prepare('SELECT * FROM devices WHERE id = ?').get(id)); console.log(`New device registered: ${id} with pairing code: ${pairing_code}`); } }); @@ -422,7 +434,7 @@ module.exports = function setupDeviceSocket(io) { ); pruneTelemetry(device_id); - dashboardNs.emit('dashboard:device-status', { + emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:device-status', { device_id, status: 'online', telemetry @@ -444,7 +456,7 @@ module.exports = function setupDeviceSocket(io) { // Relay directly to dashboard - no disk write try { - dashboardNs.emit('dashboard:screenshot-ready', { + emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:screenshot-ready', { device_id, image_data: `data:image/jpeg;base64,${image_b64}`, timestamp: Date.now() @@ -460,13 +472,15 @@ module.exports = function setupDeviceSocket(io) { const { device_id, content_id, status } = data; if (device_id !== currentDeviceId) return; console.log(`Device ${device_id} content ${content_id}: ${status}`); - dashboardNs.emit('dashboard:content-ack', { device_id, content_id, status }); + emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:content-ack', { device_id, content_id, status }); }); // Playback state update socket.on('device:playback-state', (data) => { if (!requireDeviceAuth()) return; - dashboardNs.emit('dashboard:playback-state', data); + // currentDeviceId is the authenticated device for this socket; use it + // for the workspace lookup since data may not carry device_id consistently. + emitToDeviceWorkspace(dashboardNs, currentDeviceId, 'dashboard:playback-state', data); }); // Play event logging (proof-of-play) @@ -482,7 +496,7 @@ module.exports = function setupDeviceSocket(io) { `).run(device_id, content_id || null, zone_id || null, content_name || 'Unknown'); // Forward to dashboard so it can render a per-device progress bar. // Server-side timestamp avoids clock-skew between player and dashboard. - dashboardNs.emit('dashboard:playback-progress', { + emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:playback-progress', { device_id, content_id: content_id || null, content_name: content_name || null, @@ -561,7 +575,7 @@ module.exports = function setupDeviceSocket(io) { .run(currentDeviceId); heartbeat.removeConnection(currentDeviceId); logDeviceStatus(currentDeviceId, 'offline'); - dashboardNs.emit('dashboard:device-status', { device_id: currentDeviceId, status: 'offline' }); + emitToDeviceWorkspace(dashboardNs, currentDeviceId, 'dashboard:device-status', { device_id: currentDeviceId, status: 'offline' }); // If this device was leading a wall, reassign leadership to the next // online member so playback stays driven. Without this the wall freezes