screentinker/server/routes/devices.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

191 lines
8.7 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { db } = require('../db/database');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Phase 2.2a: workspace-aware access. accessContext returns { workspaceRole, actingAs }
// or null based on the caller's reach into a specific workspace.
const { accessContext } = require('../lib/tenancy');
// List devices in the caller's current workspace.
// Phase 2.2a: filter by workspace_id instead of user_id. The caller's current
// workspace is resolved by resolveTenancy middleware from JWT or query/header
// override. Platform_admin and org_owner/admin see whichever workspace they
// are currently switched into (cross-workspace visibility comes from
// switch-workspace, not from a special list filter).
router.get('/', (req, res) => {
if (!req.workspaceId) return res.json([]);
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
const offset = parseInt(req.query.offset) || 0;
const devices = db.prepare(`
SELECT d.*,
t.battery_level, t.battery_charging, t.storage_free_mb, t.storage_total_mb,
t.ram_free_mb, t.ram_total_mb, t.wifi_ssid, t.wifi_rssi, t.uptime_seconds,
t.cpu_usage,
s.filepath as screenshot_path, s.captured_at as screenshot_at,
u.email as owner_email, u.name as owner_name
FROM devices d
LEFT JOIN users u ON d.user_id = u.id
LEFT JOIN (
SELECT dt.* FROM device_telemetry dt
INNER JOIN (SELECT device_id, MAX(reported_at) as max_at FROM device_telemetry GROUP BY device_id) latest
ON dt.device_id = latest.device_id AND dt.reported_at = latest.max_at
) t ON d.id = t.device_id
LEFT JOIN (
SELECT sc.* FROM screenshots sc
INNER JOIN (SELECT device_id, MAX(captured_at) as max_at FROM screenshots GROUP BY device_id) latest
ON sc.device_id = latest.device_id AND sc.captured_at = latest.max_at
) s ON d.id = s.device_id
WHERE d.workspace_id = ?
ORDER BY d.created_at ASC
LIMIT ? OFFSET ?
`).all(req.workspaceId, limit, offset);
res.json(devices);
});
// List unclaimed provisioning devices (admin only)
router.get('/unassigned', (req, res) => {
if (!ELEVATED_ROLES.includes(req.user.role)) {
return res.status(403).json({ error: 'Admin access required' });
}
const devices = db.prepare(`
SELECT id, pairing_code, status, ip_address, android_version, app_version,
screen_width, screen_height, created_at, last_heartbeat
FROM devices WHERE user_id IS NULL
ORDER BY created_at DESC
`).all();
res.json(devices);
});
// Get single device with telemetry history
router.get('/:id', (req, res) => {
const device = db.prepare('SELECT d.*, u.email as owner_email, u.name as owner_name FROM devices d LEFT JOIN users u ON d.user_id = u.id WHERE d.id = ?').get(req.params.id);
if (!device) return res.status(404).json({ error: 'Device not found' });
// Phase 2.2a: workspace-aware read check. accessContext returns null when
// the caller has no path (direct member, org-level acting-as, or platform_admin)
// to the device's workspace.
if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) return res.status(403).json({ error: 'Access denied' });
if (ctx.workspaceRole) device._workspaceRole = ctx.workspaceRole; // Pass to frontend
if (ctx.actingAs) device._actingAs = true;
const telemetry = db.prepare(
'SELECT * FROM device_telemetry WHERE device_id = ? ORDER BY reported_at DESC LIMIT 20'
).all(req.params.id);
const screenshot = db.prepare(
'SELECT * FROM screenshots WHERE device_id = ? ORDER BY captured_at DESC LIMIT 1'
).get(req.params.id);
// Get playlist items and status if device has an assigned playlist
let assignments = [];
let playlist_status = null;
let playlist_has_published = false;
if (device.playlist_id) {
assignments = db.prepare(`
SELECT pi.id, pi.content_id, pi.widget_id, pi.sort_order, pi.duration_sec,
pi.created_at, pi.updated_at,
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path,
c.duration_sec as content_duration, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
FROM playlist_items pi
LEFT JOIN content c ON pi.content_id = c.id
LEFT JOIN widgets w ON pi.widget_id = w.id
WHERE pi.playlist_id = ?
ORDER BY pi.sort_order ASC
`).all(device.playlist_id);
const pl = db.prepare('SELECT status, published_snapshot FROM playlists WHERE id = ?').get(device.playlist_id);
if (pl) {
playlist_status = pl.status;
playlist_has_published = pl.published_snapshot !== null;
}
}
// Uptime timeline: get status change events for last 24 hours
const dayAgo = Math.floor(Date.now() / 1000) - 86400;
let statusLog = [];
try {
statusLog = db.prepare(
'SELECT status, timestamp FROM device_status_log WHERE device_id = ? AND timestamp > ? ORDER BY timestamp ASC'
).all(req.params.id, dayAgo);
} catch (_) {}
// Also get telemetry timestamps as heartbeat proof (fills gaps between status events)
const uptimeData = db.prepare(
'SELECT reported_at FROM device_telemetry WHERE device_id = ? AND reported_at > ? ORDER BY reported_at ASC'
).all(req.params.id, dayAgo).map(r => r.reported_at);
res.json({ ...device, telemetry, screenshot, assignments, playlist_status, playlist_has_published, uptimeData, statusLog });
});
// Helper: check device write access via the workspace the device belongs to.
// Phase 2.2a: replaces user_id + team_members check. Allows: platform_admin,
// org_owner/admin of the device's org (acting-as), workspace_admin/editor of
// the device's workspace. Denies workspace_viewer and non-members.
function checkDeviceOwnership(req, res) {
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (!device) { res.status(404).json({ error: 'Device not found' }); return null; }
if (!device.workspace_id) { res.status(403).json({ error: 'Device not assigned to a workspace' }); return null; }
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
// ctx.actingAs covers platform_admin and org_owner/admin paths (always writable).
// Direct workspace members: workspace_viewer is read-only.
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
res.status(403).json({ error: 'Read-only access' }); return null;
}
return device;
}
// Update device
router.put('/:id', (req, res) => {
const device = checkDeviceOwnership(req, res);
if (!device) return;
const { name, notes, timezone, orientation, default_content_id } = req.body;
// Whitelist allowed fields to prevent SQL injection via field names
const ALLOWED_FIELDS = ['name', 'notes', 'timezone', 'orientation', 'default_content_id'];
const updates = [];
const values = [];
Object.entries({ name, notes, timezone, orientation, default_content_id }).forEach(([key, val]) => {
if (val !== undefined && ALLOWED_FIELDS.includes(key)) {
updates.push(`${key} = ?`);
values.push(val);
}
});
if (updates.length > 0) {
values.push(req.params.id);
db.prepare(`UPDATE devices SET ${updates.join(', ')}, updated_at = strftime('%s','now') WHERE id = ?`).run(...values);
}
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
res.json(updated);
});
// Delete device
router.delete('/:id', (req, res) => {
const device = checkDeviceOwnership(req, res);
if (!device) return;
// Clean up related data (playlist is NOT deleted — may be shared with other devices)
db.prepare('DELETE FROM schedules WHERE device_id = ?').run(req.params.id);
db.prepare('DELETE FROM screenshots WHERE device_id = ?').run(req.params.id);
db.prepare('DELETE FROM device_telemetry 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);
// 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) {
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 });
});
module.exports = router;