screentinker/server/lib/device-settings.js
ScreenTinker 3159f94107 Make unblock stick, and say so when a device is refused
A customer blocked a screen once to see what the button did, then spent an evening
unable to get it back. Three separate faults stacked up.

1. Unblock did not stick. applyToDevice() restores `blocked` on re-pair — deliberately,
so a block cannot be shrugged off by deleting the device — which makes the SAVED copy
the real authority. Unblock only ever wrote `devices`, so the saved row stayed 1 and the
next delete + re-pair silently re-blocked. There was no way out from the dashboard at
all: unblock, re-pair, refused, repeat. Block and unblock now both mirror to the saved
copy, so the survives-a-re-pair property is deliberate rather than a leftover.

2. The refusal was invisible. handleServerRejection() clears credentials and calls
onUnpaired, but only ProvisioningActivity ever assigned that callback — and it is long
gone by the time playback is running. So the screen sat on "Connecting to server" and
the player eventually blamed the URL, sending the operator off checking their network
while the server had already said exactly what was wrong. MainActivity now handles it.

(This half was mine: clearing those leaked callbacks to stop the relaunch loop removed
the only thing that surfaced a rejection. It was a broken path — it fired into a
destroyed Activity — but it was the only one, and MainActivity should have owned it.)

3. The reason was thrown away. The server sends device:auth-error {error: "Device
blocked"} and the client discarded it. It is kept now, and a blocked screen says so
instead of implying a network fault. Localised in all six languages, matching the other
on-screen status strings.

Also ran on prod: one stale saved block cleared (fingerprint ef6540376599, the reporter's
tablet), DB backed up first. It was the only such row.

Tests pin both directions, because the two are easy to confuse: unblock must clear the
saved copy, AND a genuine block must still survive a delete + re-pair.
2026-07-29 18:44:11 -05:00

144 lines
7.6 KiB
JavaScript

'use strict';
// #150 — fingerprint-keyed device settings that survive device-row deletion.
//
// Delete + re-pair (Bold's MDM churn) mints a BRAND-NEW device row whose INSERT omits every
// per-device setting, so orientation/name/playlist/etc silently reset to defaults. This module
// snapshots a device's settings (keyed by its durable hardware/canvas fingerprint) at DELETE
// time, and re-applies them on the next re-pair for the SAME fingerprint — automatically and
// silently. The same apply path also backs the operator "re-adopt" action for the case where
// the fingerprint changed (factory reset / new hardware), see routes/devices.js.
//
// The table has NO FK to devices, so device deletion can't cascade it away. On workspace/user/
// org deletion the rows ARE purged (purgeWorkspaces) so settings can never bleed across tenants.
const { db } = require('../db/database');
const ORIENTATIONS = new Set(['landscape', 'portrait', 'landscape-flipped', 'portrait-flipped']);
const validOrientation = (o) => (ORIENTATIONS.has(o) ? o : 'landscape');
// The devices-row columns we preserve/restore (approved scope: orientation, name, timezone,
// notes, default_content_id, layout_id, playlist_id, blocked, team_id). sort_order is out of
// scope by decision; wall membership (video_wall_devices grid geometry) is a deferred follow-up.
const _selDevice = db.prepare(
`SELECT name, orientation, timezone, notes, default_content_id, layout_id, playlist_id,
blocked, team_id, workspace_id, last_heartbeat
FROM devices WHERE id = ?`
);
const _fpForDevice = db.prepare(
'SELECT fingerprint FROM device_fingerprints WHERE device_id = ? ORDER BY last_seen DESC LIMIT 1'
);
const _upsert = db.prepare(`
INSERT INTO device_settings
(fingerprint, workspace_id, device_name, orientation, timezone, notes, default_content_id,
layout_id, playlist_id, blocked, team_id, last_seen, removed_at)
VALUES
(@fingerprint, @workspace_id, @device_name, @orientation, @timezone, @notes, @default_content_id,
@layout_id, @playlist_id, @blocked, @team_id, @last_seen, @removed_at)
ON CONFLICT(fingerprint) DO UPDATE SET
workspace_id=excluded.workspace_id, device_name=excluded.device_name, orientation=excluded.orientation,
timezone=excluded.timezone, notes=excluded.notes, default_content_id=excluded.default_content_id,
layout_id=excluded.layout_id, playlist_id=excluded.playlist_id, blocked=excluded.blocked,
team_id=excluded.team_id, last_seen=excluded.last_seen, removed_at=excluded.removed_at
`);
// Snapshot a device's current settings keyed by its fingerprint, called BEFORE the row is
// deleted. No-op (returns null) if the device has no fingerprint link yet — a never-fully-
// provisioned device has no durable key and no user settings worth preserving. UPSERT keyed on
// fingerprint => repeated delete/re-pair cycles update one row, never duplicate.
function snapshot(deviceId, now = Math.floor(Date.now() / 1000)) {
const d = _selDevice.get(deviceId);
if (!d) return null;
const fpRow = _fpForDevice.get(deviceId);
if (!fpRow || !fpRow.fingerprint) return null;
_upsert.run({
fingerprint: fpRow.fingerprint,
workspace_id: d.workspace_id || null,
device_name: d.name || null,
orientation: validOrientation(d.orientation),
timezone: d.timezone || null,
notes: d.notes || null,
default_content_id: d.default_content_id || null,
layout_id: d.layout_id || null,
playlist_id: d.playlist_id || null,
blocked: d.blocked ? 1 : 0,
team_id: d.team_id || null,
last_seen: d.last_heartbeat || now,
removed_at: now,
});
return fpRow.fingerprint;
}
// Apply saved settings for `fingerprint` onto `deviceId`. Backs BOTH the automatic re-pair
// restore and the operator re-adopt. Orientation is enum-validated (invalid stored value ->
// landscape). FK settings (playlist/layout/default_content) are existence-guarded so a
// since-deleted target is skipped rather than written as a dangling id. Returns the applied
// snapshot row, or null if there was nothing to apply.
function applyToDevice(deviceId, fingerprint) {
const s = db.prepare('SELECT * FROM device_settings WHERE fingerprint = ?').get(fingerprint);
if (!s) return null;
const sets = [], vals = [];
const put = (col, val) => { sets.push(`${col} = ?`); vals.push(val); };
put('orientation', validOrientation(s.orientation));
if (s.device_name != null) put('name', s.device_name);
if (s.timezone != null) put('timezone', s.timezone);
if (s.notes != null) put('notes', s.notes);
put('blocked', s.blocked ? 1 : 0); // security: a blocked device stays blocked across re-pair
if (s.team_id != null) put('team_id', s.team_id);
// FK-existence guards — only restore if the referenced row still exists.
if (s.playlist_id && db.prepare('SELECT 1 FROM playlists WHERE id = ?').get(s.playlist_id)) put('playlist_id', s.playlist_id);
if (s.layout_id && db.prepare('SELECT 1 FROM layouts WHERE id = ?').get(s.layout_id)) put('layout_id', s.layout_id);
if (s.default_content_id && db.prepare('SELECT 1 FROM content WHERE id = ?').get(s.default_content_id)) put('default_content_id', s.default_content_id);
// TODO #150 follow-up: wall membership (video_wall_devices grid geometry) is NOT restored —
// it lives in a separate CASCADE-deleted table with grid positions. Deferred; note in release.
vals.push(deviceId);
db.prepare(`UPDATE devices SET ${sets.join(', ')}, updated_at = strftime('%s','now') WHERE id = ?`).run(...vals);
return s;
}
// The "previously removed devices" browser — snapshots for the given workspace(s).
function listRemoved(workspaceIds) {
const ids = (Array.isArray(workspaceIds) ? workspaceIds : [workspaceIds]).filter(Boolean);
if (!ids.length) return [];
const ph = ids.map(() => '?').join(',');
return db.prepare(
`SELECT fingerprint, workspace_id, device_name, orientation, playlist_id, layout_id,
timezone, blocked, last_seen, removed_at
FROM device_settings WHERE workspace_id IN (${ph}) ORDER BY removed_at DESC`
).all(...ids);
}
function getByFingerprint(fingerprint) {
return db.prepare('SELECT * FROM device_settings WHERE fingerprint = ?').get(fingerprint);
}
// Purge snapshots for whole workspaces (workspace/user/org deletion). Runs on the caller's
// db handle (user-deletion runs inside a transaction). Prevents cross-tenant settings bleed.
function purgeWorkspaces(dbConn, workspaceIds) {
const ids = (workspaceIds || []).filter(Boolean);
if (!ids.length) return 0;
const ph = ids.map(() => '?').join(',');
return (dbConn || db).prepare(`DELETE FROM device_settings WHERE workspace_id IN (${ph})`).run(...ids).changes;
}
/**
* Mirror a device's blocked flag onto its SAVED settings.
*
* applyToDevice deliberately restores `blocked` across a re-pair, so a block cannot be shrugged off
* by deleting the device. That is right — but it also means the saved copy is the real authority for
* anything that outlives the device row, and unblocking used to touch only `devices`. The saved copy
* stayed 1, so the very next delete-and-re-pair restored the block: from the operator's side, unblock
* simply did not take, and there was no way out of it from the dashboard at all.
*
* No-ops when the device has no fingerprint yet (nothing to key the saved row on).
*/
function setBlockedByDevice(deviceId, blocked) {
const fp = _fpForDevice.get(deviceId)?.fingerprint;
if (!fp) return false;
const r = db.prepare("UPDATE device_settings SET blocked = ?, last_seen = strftime('%s','now') WHERE fingerprint = ?")
.run(blocked ? 1 : 0, fp);
return r.changes > 0;
}
module.exports = { snapshot, applyToDevice, listRemoved, getByFingerprint, purgeWorkspaces, setBlockedByDevice, validOrientation, ORIENTATIONS };