mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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.
This commit is contained in:
parent
56da64d0cd
commit
fc29843035
34
server/lib/socket-rooms.js
Normal file
34
server/lib/socket-rooms.js
Normal file
|
|
@ -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 };
|
||||||
|
|
@ -138,6 +138,28 @@ function resolveTenancy(req, res, next) {
|
||||||
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 = {
|
module.exports = {
|
||||||
resolveTenancy,
|
resolveTenancy,
|
||||||
// Exported for testing / direct use by routes that need ad-hoc checks.
|
// Exported for testing / direct use by routes that need ad-hoc checks.
|
||||||
|
|
@ -145,4 +167,5 @@ module.exports = {
|
||||||
membershipOf,
|
membershipOf,
|
||||||
orgMembershipOf,
|
orgMembershipOf,
|
||||||
firstAccessibleWorkspace,
|
firstAccessibleWorkspace,
|
||||||
|
accessibleWorkspaceIds,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 video_wall_devices WHERE device_id = ?').run(req.params.id);
|
||||||
db.prepare('DELETE FROM devices WHERE 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');
|
const io = req.app.get('io');
|
||||||
if (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 });
|
res.json({ success: true });
|
||||||
|
|
|
||||||
|
|
@ -53,13 +53,14 @@ router.get('/', (req, res) => {
|
||||||
res.json(walls);
|
res.json(walls);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify dashboard clients to re-fetch walls/devices. Re-fetches re-apply
|
// Notify dashboard clients to re-fetch walls/devices. Phase 2.3: scoped to
|
||||||
// per-user visibility filtering, so a broadcast is safe.
|
// the wall's workspace room so other tenants don't get a stray refresh ping.
|
||||||
function notifyDashboards(req) {
|
function notifyDashboards(req, workspaceId) {
|
||||||
try {
|
try {
|
||||||
const io = req.app.get('io');
|
const io = req.app.get('io');
|
||||||
if (!io) return;
|
if (!io || !workspaceId) return;
|
||||||
io.of('/dashboard').emit('dashboard:wall-changed');
|
const { workspaceRoom, emitToWorkspace } = require('../lib/socket-rooms');
|
||||||
|
emitToWorkspace(io.of('/dashboard'), workspaceRoom(workspaceId), 'dashboard:wall-changed', null);
|
||||||
} catch (e) { /* silent */ }
|
} catch (e) { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,7 +126,7 @@ router.post('/', (req, res) => {
|
||||||
bezel_h_mm || 0, bezel_v_mm || 0, playlist_id || null);
|
bezel_h_mm || 0, bezel_v_mm || 0, playlist_id || null);
|
||||||
|
|
||||||
const wall = loadWallWithDevices(id);
|
const wall = loadWallWithDevices(id);
|
||||||
notifyDashboards(req);
|
notifyDashboards(req, req.workspaceId);
|
||||||
res.status(201).json(wall);
|
res.status(201).json(wall);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -181,13 +182,14 @@ router.put('/:id', requireWallWrite, (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
pushToWallMembers(req, req.params.id);
|
pushToWallMembers(req, req.params.id);
|
||||||
notifyDashboards(req);
|
notifyDashboards(req, req.wall.workspace_id);
|
||||||
res.json(loadWallWithDevices(req.params.id));
|
res.json(loadWallWithDevices(req.params.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete wall — clear playlists + wall_id on every former member (matches
|
// Delete wall — clear playlists + wall_id on every former member (matches
|
||||||
// group-dissolve semantics: leaving the wall returns devices to ungrouped).
|
// group-dissolve semantics: leaving the wall returns devices to ungrouped).
|
||||||
router.delete('/:id', requireWallWrite, (req, res) => {
|
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 members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(req.params.id);
|
||||||
const tx = db.transaction(() => {
|
const tx = db.transaction(() => {
|
||||||
db.prepare("UPDATE devices SET wall_id = NULL, playlist_id = NULL WHERE wall_id = ?").run(req.params.id);
|
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
|
// Push fresh (now wall-less, playlist-less) payloads to ex-members so they
|
||||||
// exit wall mode and clear content immediately.
|
// exit wall mode and clear content immediately.
|
||||||
for (const m of members) pushWallPayloadToDevice(req, m.device_id);
|
for (const m of members) pushWallPayloadToDevice(req, m.device_id);
|
||||||
notifyDashboards(req);
|
notifyDashboards(req, wallWorkspaceId);
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
@ -276,7 +278,7 @@ router.put('/:id/devices', requireWallWrite, (req, res) => {
|
||||||
// ex-members so they exit wall mode.
|
// ex-members so they exit wall mode.
|
||||||
for (const id of removedIds) pushWallPayloadToDevice(req, id);
|
for (const id of removedIds) pushWallPayloadToDevice(req, id);
|
||||||
pushToWallMembers(req, req.params.id);
|
pushToWallMembers(req, req.params.id);
|
||||||
notifyDashboards(req);
|
notifyDashboards(req, req.wall.workspace_id);
|
||||||
|
|
||||||
res.json(loadWallWithDevices(req.params.id));
|
res.json(loadWallWithDevices(req.params.id));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
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);
|
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);
|
res.json(updated);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const { db } = require('../db/database');
|
const { db } = require('../db/database');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
|
const { deviceRoom, emitToWorkspace } = require('../lib/socket-rooms');
|
||||||
|
|
||||||
// Track connected device sockets: deviceId -> { socketId, lastHeartbeat }
|
// Track connected device sockets: deviceId -> { socketId, lastHeartbeat }
|
||||||
const deviceConnections = new Map();
|
const deviceConnections = new Map();
|
||||||
|
|
@ -21,8 +22,8 @@ function startHeartbeatChecker(io) {
|
||||||
.run(device.id);
|
.run(device.id);
|
||||||
deviceConnections.delete(device.id);
|
deviceConnections.delete(device.id);
|
||||||
|
|
||||||
// Notify dashboard
|
// Notify dashboard (workspace-scoped via the device's room).
|
||||||
dashboardNs.emit('dashboard:device-status', {
|
emitToWorkspace(dashboardNs, deviceRoom(device.id), 'dashboard:device-status', {
|
||||||
device_id: device.id,
|
device_id: device.id,
|
||||||
status: 'offline',
|
status: 'offline',
|
||||||
telemetry: null
|
telemetry: null
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,39 @@
|
||||||
const heartbeat = require('../services/heartbeat');
|
const heartbeat = require('../services/heartbeat');
|
||||||
const { verifyToken } = require('../middleware/auth');
|
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) {
|
module.exports = function setupDashboardSocket(io) {
|
||||||
const dashboardNs = io.of('/dashboard');
|
const dashboardNs = io.of('/dashboard');
|
||||||
const deviceNs = io.of('/device');
|
const deviceNs = io.of('/device');
|
||||||
|
|
||||||
// Authenticate dashboard WebSocket connections
|
|
||||||
dashboardNs.use((socket, next) => {
|
dashboardNs.use((socket, next) => {
|
||||||
const token = socket.handshake.auth?.token;
|
const token = socket.handshake.auth?.token;
|
||||||
if (!token) return next(new Error('Authentication required'));
|
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) => {
|
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) => {
|
socket.on('dashboard:request-screenshot', (data) => {
|
||||||
const { device_id } = data;
|
const { device_id } = data;
|
||||||
if (!checkDeviceOwnership(socket, device_id)) return;
|
if (!canActOnDevice(socket, device_id, 'read')) return;
|
||||||
const conn = heartbeat.getConnection(device_id);
|
const conn = heartbeat.getConnection(device_id);
|
||||||
if (conn) {
|
if (conn) deviceNs.to(device_id).emit('device:screenshot-request', {});
|
||||||
deviceNs.to(device_id).emit('device:screenshot-request', {});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remote control: touch forwarding
|
|
||||||
socket.on('dashboard:remote-touch', (data) => {
|
socket.on('dashboard:remote-touch', (data) => {
|
||||||
const { device_id, x, y, action } = 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 });
|
deviceNs.to(device_id).emit('device:remote-touch', { x, y, action });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remote control: key forwarding
|
|
||||||
socket.on('dashboard:remote-key', (data) => {
|
socket.on('dashboard:remote-key', (data) => {
|
||||||
const { device_id, keycode } = 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}`);
|
console.log(`Remote key: ${keycode} -> ${device_id}`);
|
||||||
deviceNs.to(device_id).emit('device:remote-key', { keycode });
|
deviceNs.to(device_id).emit('device:remote-key', { keycode });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start remote screenshot streaming
|
|
||||||
socket.on('dashboard:remote-start', (data) => {
|
socket.on('dashboard:remote-start', (data) => {
|
||||||
const { device_id } = 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);
|
const room = deviceNs.adapter.rooms.get(device_id);
|
||||||
console.log(`Remote start for ${device_id}, room has ${room?.size || 0} socket(s)`);
|
console.log(`Remote start for ${device_id}, room has ${room?.size || 0} socket(s)`);
|
||||||
deviceNs.to(device_id).emit('device:remote-start', {});
|
deviceNs.to(device_id).emit('device:remote-start', {});
|
||||||
console.log(`Remote session started for device ${device_id}`);
|
console.log(`Remote session started for device ${device_id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stop remote screenshot streaming
|
|
||||||
socket.on('dashboard:remote-stop', (data) => {
|
socket.on('dashboard:remote-stop', (data) => {
|
||||||
const { device_id } = 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', {});
|
deviceNs.to(device_id).emit('device:remote-stop', {});
|
||||||
console.log(`Remote session stopped for device ${device_id}`);
|
console.log(`Remote session stopped for device ${device_id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send command to device (reboot, refresh, etc.)
|
|
||||||
socket.on('dashboard:device-command', (data) => {
|
socket.on('dashboard:device-command', (data) => {
|
||||||
const { device_id, type, payload } = 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 });
|
deviceNs.to(device_id).emit('device:command', { type, payload });
|
||||||
console.log(`Command sent to device ${device_id}: ${type}`);
|
console.log(`Command sent to device ${device_id}: ${type}`);
|
||||||
});
|
});
|
||||||
|
|
@ -89,3 +106,4 @@ module.exports = function setupDashboardSocket(io) {
|
||||||
|
|
||||||
return dashboardNs;
|
return dashboardNs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,13 @@ const { db, pruneTelemetry, pruneScreenshots } = require('../db/database');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const heartbeat = require('../services/heartbeat');
|
const heartbeat = require('../services/heartbeat');
|
||||||
const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription');
|
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)
|
// In-memory store for latest screenshot per device (avoids disk writes during streaming)
|
||||||
let lastScreenshots = {};
|
let lastScreenshots = {};
|
||||||
|
|
@ -247,7 +254,7 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
heartbeat.registerConnection(existing.device_id, socket.id);
|
heartbeat.registerConnection(existing.device_id, socket.id);
|
||||||
socket.join(existing.device_id);
|
socket.join(existing.device_id);
|
||||||
logDeviceStatus(existing.device_id, 'online');
|
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
|
// Send playlist
|
||||||
const access = checkDeviceAccess(existing.device_id);
|
const access = checkDeviceAccess(existing.device_id);
|
||||||
if (!access.allowed) {
|
if (!access.allowed) {
|
||||||
|
|
@ -343,7 +350,7 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
socket.emit('device:playlist-update', buildPlaylistPayload(device_id));
|
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}`);
|
console.log(`Device reconnected: ${device_id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -376,7 +383,12 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
socket.join(id);
|
socket.join(id);
|
||||||
socket.emit('device:registered', { device_id: id, device_token: newToken, status: 'provisioning' });
|
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}`);
|
console.log(`New device registered: ${id} with pairing code: ${pairing_code}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -422,7 +434,7 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
);
|
);
|
||||||
pruneTelemetry(device_id);
|
pruneTelemetry(device_id);
|
||||||
|
|
||||||
dashboardNs.emit('dashboard:device-status', {
|
emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:device-status', {
|
||||||
device_id,
|
device_id,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
telemetry
|
telemetry
|
||||||
|
|
@ -444,7 +456,7 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
|
|
||||||
// Relay directly to dashboard - no disk write
|
// Relay directly to dashboard - no disk write
|
||||||
try {
|
try {
|
||||||
dashboardNs.emit('dashboard:screenshot-ready', {
|
emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:screenshot-ready', {
|
||||||
device_id,
|
device_id,
|
||||||
image_data: `data:image/jpeg;base64,${image_b64}`,
|
image_data: `data:image/jpeg;base64,${image_b64}`,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
|
|
@ -460,13 +472,15 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
const { device_id, content_id, status } = data;
|
const { device_id, content_id, status } = data;
|
||||||
if (device_id !== currentDeviceId) return;
|
if (device_id !== currentDeviceId) return;
|
||||||
console.log(`Device ${device_id} content ${content_id}: ${status}`);
|
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
|
// Playback state update
|
||||||
socket.on('device:playback-state', (data) => {
|
socket.on('device:playback-state', (data) => {
|
||||||
if (!requireDeviceAuth()) return;
|
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)
|
// 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');
|
`).run(device_id, content_id || null, zone_id || null, content_name || 'Unknown');
|
||||||
// Forward to dashboard so it can render a per-device progress bar.
|
// Forward to dashboard so it can render a per-device progress bar.
|
||||||
// Server-side timestamp avoids clock-skew between player and dashboard.
|
// Server-side timestamp avoids clock-skew between player and dashboard.
|
||||||
dashboardNs.emit('dashboard:playback-progress', {
|
emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:playback-progress', {
|
||||||
device_id,
|
device_id,
|
||||||
content_id: content_id || null,
|
content_id: content_id || null,
|
||||||
content_name: content_name || null,
|
content_name: content_name || null,
|
||||||
|
|
@ -561,7 +575,7 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
.run(currentDeviceId);
|
.run(currentDeviceId);
|
||||||
heartbeat.removeConnection(currentDeviceId);
|
heartbeat.removeConnection(currentDeviceId);
|
||||||
logDeviceStatus(currentDeviceId, 'offline');
|
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
|
// If this device was leading a wall, reassign leadership to the next
|
||||||
// online member so playback stays driven. Without this the wall freezes
|
// online member so playback stays driven. Without this the wall freezes
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue