diff --git a/frontend/js/api.js b/frontend/js/api.js
index 786516b..6ff0649 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -32,6 +32,10 @@ export const api = {
getDevice: (id) => request(`/devices/${id}`),
updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteDevice: (id) => request(`/devices/${id}`, { method: 'DELETE' }),
+ // #146 Item D: operator block/unblock — refuses the device at its next register with
+ // no restart. Server enforces via the SNAT-safe identity chain (deviceSocket).
+ blockDevice: (id) => request(`/devices/${id}/block`, { method: 'POST' }),
+ unblockDevice: (id) => request(`/devices/${id}/unblock`, { method: 'POST' }),
// #109 PiP overlay: push/clear a floating overlay on a device or group. `id` may be a
// device id OR a group id (the server resolves + expands). Needs full scope (no-op for JWT).
diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js
index 329c148..cc5ef1c 100644
--- a/frontend/js/views/device-detail.js
+++ b/frontend/js/views/device-detail.js
@@ -162,6 +162,7 @@ async function loadDevice(deviceId, activeTab = null) {
${t('device.screenshot_btn')}
+
@@ -793,6 +794,19 @@ async function setupActions(device) {
} catch (err) { showToast(err.message, 'error'); }
});
+ // #146 Item D: operator block/unblock — takes effect on the device's next register,
+ // no restart. Server enforces even a device_id-less reconnect via the identity chain.
+ const blockBtn = document.getElementById('blockDeviceBtn');
+ blockBtn?.addEventListener('click', async () => {
+ blockBtn.disabled = true;
+ try {
+ if (device.blocked) { await api.unblockDevice(device.id); device.blocked = 0; showToast('Device unblocked', 'success'); }
+ else { await api.blockDevice(device.id); device.blocked = 1; showToast('Device blocked — refused on next reconnect', 'success'); }
+ blockBtn.textContent = device.blocked ? 'Unblock' : 'Block';
+ } catch (err) { showToast(err.message, 'error'); }
+ finally { blockBtn.disabled = false; }
+ });
+
// Delete (double-click to confirm)
const deleteBtn = document.getElementById('deleteDeviceBtn');
let deleteConfirming = false;
diff --git a/server/routes/devices.js b/server/routes/devices.js
index 93d4fef..8945a15 100644
--- a/server/routes/devices.js
+++ b/server/routes/devices.js
@@ -231,6 +231,28 @@ router.put('/:id', (req, res) => {
res.json(stripDeviceSecrets(updated));
});
+// #146 Item D: operator BLOCK / UNBLOCK toggle. Writes devices.blocked; the device
+// socket re-reads `blocked` on every register, so the block takes effect on the
+// device's NEXT register with NO server restart (and, via the #146 identity chain, is
+// enforced even if that reconnect arrives without a device_id). Write-gated + workspace-
+// scoped by checkDeviceOwnership. OUTAGE PROCEDURE (dashboard down): set it by hand via
+// direct SQLite — `UPDATE devices SET blocked = 1 WHERE id = '';` (0 to
+// unblock) — same column, same next-register effect.
+router.post('/:id/block', (req, res) => {
+ const device = checkDeviceOwnership(req, res);
+ if (!device) return;
+ db.prepare("UPDATE devices SET blocked = 1, updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
+ console.warn(`[blocked] device ${req.params.id} blocked via dashboard (user ${req.user.id})`);
+ res.json({ success: true, id: req.params.id, blocked: true });
+});
+router.post('/:id/unblock', (req, res) => {
+ const device = checkDeviceOwnership(req, res);
+ if (!device) return;
+ db.prepare("UPDATE devices SET blocked = 0, updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
+ console.log(`[blocked] device ${req.params.id} unblocked via dashboard (user ${req.user.id})`);
+ res.json({ success: true, id: req.params.id, blocked: false });
+});
+
// Delete device
router.delete('/:id', (req, res) => {
const device = checkDeviceOwnership(req, res);
diff --git a/server/test/operator-block.test.js b/server/test/operator-block.test.js
new file mode 100644
index 0000000..bcfae08
--- /dev/null
+++ b/server/test/operator-block.test.js
@@ -0,0 +1,72 @@
+'use strict';
+
+// #146 hardening (Item D) — operator block kill switch. Enforced at the device:register
+// handshake BEFORE throttle/DB/playlist. #146 fix: resolve identity via the fallback
+// chain so a blocked device that reconnects WITHOUT a device_id (but with a mapped
+// fingerprint) is STILL caught (the old `if (device_id)` gate let it slip). Unblock takes
+// effect on the next register with NO restart. In-process.
+
+const os = require('node:os');
+const path = require('node:path');
+const crypto = require('node:crypto');
+process.env.DATA_DIR = path.join(os.tmpdir(), 'st-block-' + crypto.randomBytes(4).toString('hex'));
+process.env.SELF_HOSTED = 'true';
+process.env.NODE_ENV = 'test';
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const { Server } = require('socket.io');
+const ioClient = require('socket.io-client');
+const { db } = require('../db/database');
+const setupDeviceSocket = require('../ws/deviceSocket');
+
+let httpServer, io, base;
+before(async () => {
+ db.pragma('foreign_keys = OFF');
+ db.prepare("INSERT INTO devices (id, device_token, status, blocked) VALUES ('blk-dev', 'tok', 'offline', 1)").run();
+ db.prepare("INSERT OR REPLACE INTO device_fingerprints (fingerprint, device_id) VALUES ('fp-blk', 'blk-dev')").run();
+ db.pragma('foreign_keys = ON');
+ httpServer = http.createServer();
+ io = new Server(httpServer);
+ setupDeviceSocket(io);
+ await new Promise((r) => httpServer.listen(0, r));
+ base = `http://127.0.0.1:${httpServer.address().port}`;
+});
+after(() => { try { io.close(); } catch { /* */ } try { httpServer.close(); } catch { /* */ } });
+
+const connect = () => ioClient(`${base}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
+
+// register(payload) -> { authError, registered, gotPlaylist }
+function register(payload) {
+ return new Promise((resolve) => {
+ const s = connect();
+ let done = false, gotPlaylist = false;
+ const fin = (r) => { if (done) return; done = true; try { s.close(); } catch { /* */ } resolve({ ...r, gotPlaylist }); };
+ s.on('device:playlist-update', () => { gotPlaylist = true; });
+ s.on('connect', () => s.emit('device:register', payload));
+ s.on('device:auth-error', (e) => fin({ authError: e && e.error }));
+ s.on('device:registered', () => setTimeout(() => fin({ registered: true }), 100));
+ setTimeout(() => fin({ timeout: true }), 2000);
+ });
+}
+
+test('a blocked device is refused at handshake, cheaply (no playlist build)', async () => {
+ const r = await register({ device_id: 'blk-dev', device_token: 'tok' });
+ assert.equal(r.authError, 'Device blocked', 'refused with the block error');
+ assert.ok(!r.registered, 'never registered');
+ assert.ok(!r.gotPlaylist, 'no playlist was built — short-circuited before heavy work');
+});
+
+test('a blocked device reconnecting WITHOUT device_id but with a mapped fingerprint is still refused', async () => {
+ const r = await register({ fingerprint: 'fp-blk' }); // no device_id — resolves via device_fingerprints
+ assert.equal(r.authError, 'Device blocked', 'caught via the fingerprint->device_id identity chain');
+ assert.ok(!r.registered);
+});
+
+test('unblock takes effect on the NEXT register, no restart', async () => {
+ db.prepare("UPDATE devices SET blocked = 0 WHERE id = 'blk-dev'").run();
+ const r = await register({ device_id: 'blk-dev', device_token: 'tok' });
+ assert.ok(r.registered, 'registers once unblocked — same running server, no restart');
+ assert.ok(!r.authError);
+});
diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js
index c7a1a7f..e4fac2e 100644
--- a/server/ws/deviceSocket.js
+++ b/server/ws/deviceSocket.js
@@ -290,18 +290,25 @@ module.exports = function setupDeviceSocket(io) {
socket.on('device:register', (data) => {
const { pairing_code, device_id, device_token, device_info, fingerprint } = data;
- // #143 operator KILL SWITCH — the FIRST gate, before the fingerprint block,
- // the reconnect throttle, any DB writes, or playlist build. A device flagged
- // `blocked` is refused immediately. Settable by DIRECT SQLite during an outage
- // (dashboard down): UPDATE devices SET blocked = 1 WHERE id = '';
- // The row is re-read on every register, so a hand-edited UPDATE takes effect on
- // the device's NEXT reconnect with NO server restart. Unblock: blocked = 0.
+ // #146: resolve identity ONCE via the SNAT-safe chain (device_id -> fingerprint
+ // -> token -> global anon), used by BOTH the operator block and the flap limiter.
+ const ident = resolveIdentity({ device_id, fingerprint, device_token });
+
+ // #143 operator KILL SWITCH — the FIRST gate, before the fingerprint block, the
+ // throttle, any DB writes, or playlist build. #146: resolve the effective
+ // device_id via the identity chain (device_id directly, OR fingerprint->device_id)
+ // so a blocked device that reconnects WITHOUT a device_id is STILL caught — the
+ // old `if (device_id)` gate let a device_id-less reconnect slip past. Settable by
+ // DIRECT SQLite during an outage (dashboard down), takes effect on the device's
+ // NEXT register with NO restart (the row is re-read every register):
+ // UPDATE devices SET blocked = 1 WHERE id = ''; (0 to unblock)
// Unlike nulling the token (#143: that re-provisioned instead of locking out),
- // this is an explicit, enforceable block.
- if (device_id) {
- const blk = db.prepare('SELECT blocked FROM devices WHERE id = ?').get(device_id);
+ // `blocked` is an explicit, enforceable lever. Also settable via the dashboard
+ // (routes/devices.js POST /:id/block) — same DB write, same next-register effect.
+ if (ident.deviceId) {
+ const blk = db.prepare('SELECT blocked FROM devices WHERE id = ?').get(ident.deviceId);
if (blk && blk.blocked) {
- console.warn(`[blocked] refused device ${device_id} (operator block) from ${getClientIp(socket)}`);
+ console.warn(`[blocked] refused device ${ident.deviceId} (operator block, via ${ident.kind})`);
socket.emit('device:auth-error', { error: 'Device blocked' });
process.nextTick(() => { try { socket.disconnect(true); } catch (_) { /* */ } });
return;
@@ -311,11 +318,9 @@ module.exports = function setupDeviceSocket(io) {
// #146 Item B: SUSTAINED flap limiter — BEFORE fingerprint tracking, throttle,
// DB writes, or playlist build, so a refusal is cheap. Skips same-socket playlist
// refreshes (currentDeviceId===device_id — a periodic pull, not a new connection).
- // Keyed via the SNAT-safe identity chain (device_id -> fingerprint -> token ->
- // global anon), NEVER IP. Over the sustained rate -> refuse + disconnect.
+ // Keyed via the same SNAT-safe identity, NEVER IP.
const isRefreshConnect = device_id && currentDeviceId === device_id;
if (!isRefreshConnect) {
- const ident = resolveIdentity({ device_id, fingerprint, device_token });
const fv = flapLimiter.check(ident.key);
if (!fv.allow) {
console.warn(`[flap] refused ${ident.kind} ${ident.deviceId || ident.key} reason=${fv.reason} retry=${fv.retryAfterMs}ms trips=${fv.trips || 0}`);