screentinker/server/routes/device-groups.js
ScreenTinker 0082191f9b Show only the controls a display can actually honour
Every device control was offered to every display. A browser tab was shown
"Reboot device", a Tizen TV was shown screen power, a player with no
framebuffer read was shown a live view that stayed black. They all looked
like working buttons and did nothing — the "reports success and changes
nothing" shape that keeps costing people days.

Players now declare what they can do at registration, because only the
player knows at runtime: an Android panel gains real screenshots when
accessibility is switched on and loses Tier-2 when device owner is revoked.
The dashboard hides what is not supported rather than disabling it, and the
Info tab lists the capability set so a missing control is explainable.

The declaration is three-state and the middle state is load bearing: NULL
means "has never told us anything" and falls back to a per-platform
baseline, because several hundred displays in the field will not update
before this deploys and blanking their controls would be a far worse bug.
An empty array means "I genuinely can do nothing" and is honoured.

Hiding a button is not enforcement, so unsupported commands are also
refused server-side — the socket is reachable directly and a stale tab
still renders the old controls. Group sends report skipped devices
separately from sent ones; counting an unreachable member as "sent" is how
an operator walks away believing the whole group rebooted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 14:24:52 -05:00

428 lines
22 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Phase 2.2i: workspace-aware access. Same pattern as devices/content/widgets.
const { accessContext } = require('../lib/tenancy');
// #public-api: operational fleet commands (reboot/shutdown/...) need the 'full' token
// scope. No-op for JWT sessions; for tokens a read/write scope is rejected.
const { requireScope } = require('../middleware/apiToken');
const { resolveSyncBackend, BACKENDS } = require('../lib/sync-backend');
const playerCapabilities = require('../lib/player-capabilities');
const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
const ALLOWED_COMMANDS = [
'screen_on', 'screen_off', 'launch', 'update', 'reboot', 'shutdown',
// #161 Tier-2 (owner-gated on the panel; STPolicy no-ops off-tier so a stray send is inert):
'power_menu', 'lock_now', 'kiosk_lock', 'kiosk_unlock',
'set_time', 'set_timezone', 'status_bar', 'block_uninstall', 'unblock_uninstall',
// #161 device-owner tooling: remote shell (app-UID diagnostics) + push/install an APK from a URL.
'shell', 'install_apk',
// #160 Track-A system control (no device owner): media volume + per-window brightness (Tier 0),
// system brightness + screen-off timeout (Tier 1 / WRITE_SETTINGS). Panel no-ops if unsupported.
'set_volume', 'set_brightness', 'set_system_brightness', 'set_screen_timeout',
];
// Phase 2.2i: split read/write access checks. Both attach req.group on success.
function loadGroupAccessCtx(req, res) {
const group = db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id);
if (!group) { res.status(404).json({ error: 'group not found' }); return null; }
if (!group.workspace_id) { res.status(403).json({ error: 'Group not assigned to a workspace' }); return null; }
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(group.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
return { group, ctx };
}
function requireGroupRead(req, res, next) {
const access = loadGroupAccessCtx(req, res);
if (!access) return;
req.group = access.group;
next();
}
function requireGroupWrite(req, res, next) {
const access = loadGroupAccessCtx(req, res);
if (!access) return;
if (!access.ctx.actingAs && access.ctx.workspaceRole === 'workspace_viewer') {
return res.status(403).json({ error: 'Read-only access' });
}
req.group = access.group;
next();
}
// What the group's sync_backend setting actually RESOLVES to, for the dashboard. The stored value
// is only a request: 'brightsign' on a mixed fleet, or on players spread across subnets, is refused
// by the resolver. Sending the decision alongside the request is what lets the UI explain the
// refusal instead of showing a setting that quietly isn't in force.
function syncDecisionFor(group) {
if (!group?.playlist_id) return { sync_effective: null, sync_reason: null, sync_downgraded: false };
const members = db.prepare(`
SELECT d.id, d.platform, d.ip_address FROM devices d
JOIN device_group_members dgm ON dgm.device_id = d.id
WHERE dgm.group_id = ? AND d.playlist_id = ?
`).all(group.id, group.playlist_id);
const d = resolveSyncBackend(group.sync_backend, members);
return { sync_effective: d.backend, sync_reason: d.reason, sync_downgraded: d.downgraded };
}
// List groups in the caller's current workspace.
router.get('/', (req, res) => {
if (!req.workspaceId) return res.json([]);
const groups = db.prepare(`
SELECT g.*, COUNT(dgm.device_id) as device_count
FROM device_groups g
LEFT JOIN device_group_members dgm ON g.id = dgm.group_id
WHERE g.workspace_id = ?
GROUP BY g.id
ORDER BY g.name ASC
`).all(req.workspaceId);
res.json(groups.map(g => ({ ...g, ...syncDecisionFor(g) })));
});
// Create group in the caller's current workspace.
router.post('/', (req, res) => {
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before creating groups.' });
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'name required' });
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
const id = uuidv4();
db.prepare('INSERT INTO device_groups (id, user_id, workspace_id, name, color) VALUES (?, ?, ?, ?, ?)')
.run(id, req.user.id, req.workspaceId, name, color || '#3B82F6');
res.status(201).json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(id));
});
// Update group
router.put('/:id', requireGroupWrite, (req, res) => {
const { name, color, sync_enabled, leader_device_id, reboot_schedule, sync_backend } = req.body;
// Reject an unknown backend rather than storing it: the resolver reads anything unrecognised as
// 'auto', so a typo would silently give the operator a different protocol from the one they
// picked, with the UI still showing their typo back to them.
if (sync_backend !== undefined && !BACKENDS.includes(sync_backend)) {
return res.status(400).json({ error: `sync_backend must be one of: ${BACKENDS.join(', ')}` });
}
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
// #12 scheduled reboot: group-level default nightly-reboot time ("HH:MM" or null/'' = off).
// A member device's own reboot_schedule overrides this in the scheduler.
if (reboot_schedule !== undefined) {
let val = null;
if (reboot_schedule !== null && reboot_schedule !== '') {
if (!/^([01]\d|2[0-3]):([0-5]\d)$/.test(String(reboot_schedule))) {
return res.status(400).json({ error: 'reboot_schedule must be "HH:MM" (24h) or null' });
}
val = String(reboot_schedule);
}
db.prepare('UPDATE device_groups SET reboot_schedule = ? WHERE id = ?').run(val, req.params.id);
}
if (name) db.prepare('UPDATE device_groups SET name = ? WHERE id = ?').run(name, req.params.id);
if (color) db.prepare('UPDATE device_groups SET color = ? WHERE id = ?').run(color, req.params.id);
// #group-sync: enable synchronized playback + optional pinned leader.
if (sync_enabled !== undefined) {
db.prepare('UPDATE device_groups SET sync_enabled = ? WHERE id = ?').run(sync_enabled ? 1 : 0, req.params.id);
}
if (leader_device_id !== undefined) {
if (leader_device_id !== null) {
const isMember = db.prepare('SELECT 1 FROM device_group_members WHERE group_id = ? AND device_id = ?').get(req.params.id, leader_device_id);
if (!isMember) return res.status(400).json({ error: 'leader_device_id must be a member of this group' });
}
db.prepare('UPDATE device_groups SET leader_device_id = ? WHERE id = ?').run(leader_device_id || null, req.params.id);
}
if (sync_backend !== undefined) {
db.prepare('UPDATE device_groups SET sync_backend = ? WHERE id = ?').run(sync_backend, req.params.id);
}
// Re-push to every member so they enter/exit sync mode and refresh their is_leader flag.
// sync_backend belongs here too: switching protocol has to reach the players, or the group keeps
// running the old one until something unrelated happens to re-push.
if (sync_enabled !== undefined || leader_device_id !== undefined || sync_backend !== undefined) {
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
for (const m of members) pushPlaylistToDevice(req, m.device_id);
}
const updated = db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id);
res.json({ ...updated, ...syncDecisionFor(updated) });
});
// #group-sync: manual "Resync now" — nudge every member to re-snap to the shared schedule
// immediately. Sync is clock/schedule based (no leader), so this just forces an instant re-align
// (handy after a content change or if an operator wants to eyeball alignment).
router.post('/:id/resync', requireGroupWrite, (req, res) => {
const io = req.app.get('io');
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
if (io) {
const deviceNs = io.of('/device');
for (const m of members) deviceNs.to(m.device_id).emit('group:resync', { group_id: req.params.id });
}
res.json({ ok: true, notified: members.length });
});
// Delete group — converts group schedules to per-device schedules first
router.delete('/:id', requireGroupWrite, (req, res) => {
const groupId = req.params.id;
const convert = db.transaction(() => {
// Find group schedules that need conversion
const groupSchedules = db.prepare('SELECT * FROM schedules WHERE group_id = ?').all(groupId);
// Find current group members
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(groupId);
let converted = 0;
if (groupSchedules.length > 0 && members.length > 0) {
// workspace_id MUST be carried over. It is nullable with no default, so omitting it landed
// every converted schedule with workspace_id = NULL — and a null workspace does not merely
// look untidy, it makes the row unreachable in three directions at once:
// - the schedule list and the all-screens calendar filter on workspace_id: invisible
// - PUT and DELETE refuse a row with no workspace (403): undeletable
// - services/scheduler.js has NO workspace filter: it keeps firing every 60 seconds
// i.e. "I deleted the group but the screens still switch at 9am and there is nothing in the
// calendar to remove". The only way out was direct database access.
const insert = db.prepare(`
INSERT INTO schedules (id, user_id, workspace_id, device_id, group_id, zone_id, content_id,
widget_id, layout_id, playlist_id, title, start_time, end_time, timezone,
recurrence, recurrence_end, priority, enabled, color, created_at, updated_at)
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const schedule of groupSchedules) {
for (const member of members) {
insert.run(
// Prefer the schedule's own workspace, falling back to the group's, so a legacy
// group schedule predating workspace_id still converts into a reachable row.
uuidv4(), schedule.user_id, schedule.workspace_id || req.group.workspace_id, member.device_id,
schedule.zone_id, schedule.content_id, schedule.widget_id,
schedule.layout_id, schedule.playlist_id, schedule.title,
schedule.start_time, schedule.end_time, schedule.timezone,
schedule.recurrence, schedule.recurrence_end, schedule.priority,
schedule.enabled, schedule.color, schedule.created_at, schedule.updated_at
);
}
converted++;
}
}
// Delete group schedules explicitly (before group delete turns group_id to NULL via ON DELETE SET NULL)
db.prepare('DELETE FROM schedules WHERE group_id = ?').run(groupId);
// Delete the group (cascades to device_group_members)
db.prepare('DELETE FROM device_groups WHERE id = ?').run(groupId);
return { converted, devices: members.length };
});
const result = convert();
res.json({ success: true, schedules_converted: result.converted, devices: result.devices });
});
// Get devices in a group
router.get('/:id/devices', requireGroupRead, (req, res) => {
const devices = db.prepare(`
SELECT d.* FROM devices d
JOIN device_group_members dgm ON d.id = dgm.device_id
WHERE dgm.group_id = ?
ORDER BY d.name ASC
`).all(req.params.id);
res.json(devices);
});
// Add device to group. If the group has a playlist set (via the assign-playlist
// dropdown on the dashboard), the new device inherits it — both for drag-drop
// onto the group section and for the Manage modal's checkboxes, which both
// hit this endpoint. Without this, joining a group never auto-assigned the
// group's playlist, leaving the new device on whatever it had before.
//
// Phase 2.2i: closes a pre-existing cross-tenant leak. Today the gate only
// checked device.user_id == caller; a workspace_admin who happened to own a
// device in another workspace could add it to a group in this workspace.
// Now: the device must belong to the same workspace as the group.
router.post('/:id/devices', requireGroupWrite, (req, res) => {
const { device_id } = req.body;
if (!device_id) return res.status(400).json({ error: 'device_id required' });
const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' });
if (device.workspace_id !== req.group.workspace_id) {
return res.status(403).json({ error: 'Device is not in this group\'s workspace' });
}
try {
db.prepare('INSERT OR IGNORE INTO device_group_members (device_id, group_id) VALUES (?, ?)').run(device_id, req.params.id);
// Sync device's playlist to the group's: a defined playlist is inherited,
// a group with no playlist clears the device's. The user's mental model
// is "joining a group means using its playlist (or none)" — staying on a
// stale playlist after joining a no-playlist group was the bug we just hit.
const group = db.prepare('SELECT playlist_id FROM device_groups WHERE id = ?').get(req.params.id);
const newPlaylist = group?.playlist_id || null;
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(newPlaylist, device_id);
pushPlaylistToDevice(req, device_id);
res.status(201).json({ success: true, playlist_id: newPlaylist });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
// Remove device from group. Sync the device's playlist to whatever its
// current group membership implies — symmetric with the join sync above.
// - No remaining groups → clear playlist (Ungrouped).
// - Remaining group with a playlist → adopt that playlist.
// - Remaining group(s) but none have a playlist → clear playlist.
// Without this, a device dragged out of a group keeps stale playlist state
// from the group it just left.
router.delete('/:id/devices/:deviceId', requireGroupWrite, (req, res) => {
const deviceId = req.params.deviceId;
db.prepare('DELETE FROM device_group_members WHERE device_id = ? AND group_id = ?').run(deviceId, req.params.id);
const remaining = db.prepare(`
SELECT g.playlist_id FROM device_groups g
JOIN device_group_members dgm ON g.id = dgm.group_id
WHERE dgm.device_id = ?
ORDER BY g.playlist_id IS NULL, g.name ASC
LIMIT 1
`).get(deviceId);
const newPlaylist = remaining?.playlist_id || null;
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(newPlaylist, deviceId);
pushPlaylistToDevice(req, deviceId);
res.json({ success: true });
});
// Ensure a device has a playlist; auto-create one if missing.
// Phase 2.2i: pre-emptive loop-closer for the future playlists.js migration.
// The auto-created playlist lives in the same workspace as the device, so
// once playlists.js scopes by workspace_id this helper's rows remain visible.
function ensureDevicePlaylist(deviceId, userId) {
const device = db.prepare('SELECT playlist_id, workspace_id, name FROM devices WHERE id = ?').get(deviceId);
if (device?.playlist_id) return device.playlist_id;
const playlistId = uuidv4();
db.prepare('INSERT INTO playlists (id, user_id, workspace_id, name, is_auto_generated) VALUES (?, ?, ?, ?, 1)')
.run(playlistId, userId, device?.workspace_id || null, `${device?.name || 'Display'} playlist`);
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, deviceId);
return playlistId;
}
// Mark playlist as draft (called after any item mutation)
function markDraft(playlistId) {
db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(playlistId);
}
// Push playlist update to a device (used by assign-playlist which doesn't modify items)
function pushPlaylistToDevice(req, deviceId) {
try {
const io = req.app.get('io');
if (!io) return;
const { buildPlaylistPayload } = require('../ws/deviceSocket');
const commandQueue = require('../lib/command-queue');
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), deviceId, buildPlaylistPayload);
} catch (e) { /* silent */ }
}
// Bulk assign content to all devices in a group (adds to each device's playlist).
// Phase 2.2i: closes a pre-existing cross-tenant leak. Today the gate only
// checked content.user_id == caller; the content could live in any workspace
// the caller had any reach into. Now: content must live in the group's
// workspace (or be a platform-template content row, workspace_id IS NULL).
router.post('/:id/assign-content', requireGroupWrite, (req, res) => {
const { content_id, duration_sec } = req.body;
if (!content_id) return res.status(400).json({ error: 'content_id required' });
// Verify content lives in the same workspace as the group (or is a
// platform-template row).
const content = db.prepare('SELECT id, workspace_id FROM content WHERE id = ?').get(content_id);
if (!content) return res.status(404).json({ error: 'Content not found' });
if (content.workspace_id && content.workspace_id !== req.group.workspace_id) {
return res.status(403).json({ error: 'Content is not in this group\'s workspace' });
}
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
const transaction = db.transaction(() => {
for (const m of members) {
const playlistId = ensureDevicePlaylist(m.device_id, req.user.id);
const max = db.prepare('SELECT COALESCE(MAX(sort_order),0)+1 as next FROM playlist_items WHERE playlist_id = ?').get(playlistId);
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, ?, ?)')
.run(playlistId, content_id, max.next, duration_sec || 10);
markDraft(playlistId);
}
});
transaction();
res.json({ success: true, devices_updated: members.length });
});
// Assign an existing playlist to all devices in a group, and persist the
// choice on the group itself so future joiners inherit it (see POST /:id/devices).
//
// Phase 2.2i: closes a pre-existing cross-tenant leak. Today the gate only
// checked playlist.user_id == caller; the playlist could live in any
// workspace the caller could reach. Now: playlist must live in the group's
// workspace. Playlists don't currently have a NULL/template path - playlists.js
// migration is deferred, so this check uses the raw workspace_id column that
// 2.2i's ensureDevicePlaylist loop-closer also writes to.
router.post('/:id/assign-playlist', requireGroupWrite, (req, res) => {
const { playlist_id } = req.body;
if (!playlist_id) return res.status(400).json({ error: 'playlist_id required' });
const playlist = db.prepare('SELECT id, workspace_id FROM playlists WHERE id = ?').get(playlist_id);
if (!playlist) return res.status(404).json({ error: 'Playlist not found' });
if (playlist.workspace_id && playlist.workspace_id !== req.group.workspace_id) {
return res.status(403).json({ error: 'Playlist is not in this group\'s workspace' });
}
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
const stmt = db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?');
const transaction = db.transaction(() => {
db.prepare('UPDATE device_groups SET playlist_id = ? WHERE id = ?').run(playlist_id, req.params.id);
for (const m of members) stmt.run(playlist_id, m.device_id);
});
transaction();
for (const m of members) pushPlaylistToDevice(req, m.device_id);
res.json({ success: true, devices_updated: members.length });
});
// Send command to all devices in a group (reboot/shutdown/screen on/off etc.)
router.post('/:id/command', requireScope('full'), requireGroupWrite, (req, res) => {
const { type, payload } = req.body;
if (!type) return res.status(400).json({ error: 'command type required' });
if (!ALLOWED_COMMANDS.includes(type)) return res.status(400).json({ error: 'invalid command type' });
// SELECT * because the capability check needs the platform/declaration columns, not just the
// three fields the response uses.
const devices = db.prepare(`
SELECT d.* FROM devices d
JOIN device_group_members dgm ON d.id = dgm.device_id
WHERE dgm.group_id = ?
`).all(req.params.id);
const deviceNs = req.app.get('io').of('/device');
const results = [];
for (const device of devices) {
// A group is the mixed-platform case by definition — a lobby group holding two Android panels
// and a BrightSign gets "reboot" sent to all three, and the one that cannot honour it used to
// report 'sent'. Reporting per-device rather than refusing the whole command: the operator's
// intent is valid for the members that can do it, and failing the lot because one member is a
// browser tab would be its own bug.
const verdict = playerCapabilities.commandAllowed(device, type);
if (!verdict.ok) {
results.push({ device_id: device.id, name: device.name, status: 'unsupported', capability: verdict.capability });
continue;
}
const room = deviceNs.adapter.rooms.get(device.id);
if (room && room.size > 0) {
deviceNs.to(device.id).emit('device:command', { type, payload: payload || {} });
results.push({ device_id: device.id, name: device.name, status: 'sent' });
} else {
results.push({ device_id: device.id, name: device.name, status: 'offline' });
}
}
const sent = results.filter(r => r.status === 'sent').length;
const offline = results.filter(r => r.status === 'offline').length;
const unsupported = results.filter(r => r.status === 'unsupported').length;
console.log(`Group command '${type}' sent to group '${req.group.name}': ${sent} sent, ${offline} offline, ${unsupported} unsupported`);
res.json({ success: true, sent, offline, unsupported, total: devices.length, results });
});
module.exports = router;