From 58641e7bbe927ba3a6880e934be4f41c7a960a6f Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 4 Aug 2026 23:57:04 -0500 Subject: [PATCH] Persist the device token, not just the device id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge stored device_id in the registry and the display still came back as a NEW device on the next boot. The id is not an identity on its own: the server authenticates a claim to an existing display with the token, so an id presented without one reads as a brand-new player and gets a fresh row. device_token now sits alongside device_id in the registry, getConfig adopts both, and clearIdentity forgets both — a stale token must not outlive the identity it belongs to. Found on an XT245, not in a test, which is why the three new cases name the symptom rather than the mechanism. 951 pass. Also worth recording from the same session: the duplicate rows had a second cause. The widget's storage_path was pointing nowhere useful, so localStorage had no persistent home and the per-install fingerprint salt was regenerated on every boot. With storage_path set correctly the cache directory now exists on the player and the fingerprint is stable, which is what stopped the churn; the registry identity is the belt to that pair of braces. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- brightsign/st-bridge.js | 15 +++++++++++--- server/player/index.html | 15 +++++++++++--- server/test/brightsign-bridge.test.js | 30 +++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/brightsign/st-bridge.js b/brightsign/st-bridge.js index 55f7496..d2704fd 100644 --- a/brightsign/st-bridge.js +++ b/brightsign/st-bridge.js @@ -102,7 +102,11 @@ * which would register a "[object Promise]" display. */ var SECTION = 'screentinker'; - var CACHED_KEYS = ['device_id', 'server_url', 'sync_backend']; + // device_token belongs here as much as device_id: the server authenticates the claim to an + // existing display with the token, so an id presented without one reads as a NEW display and + // gets a fresh row. Persisting the id alone looked correct and still spawned a duplicate on + // every boot — found on hardware, not in a test. + var CACHED_KEYS = ['device_id', 'device_token', 'server_url', 'sync_backend']; var cache = {}; var ready = false; var readyWaiters = []; @@ -238,11 +242,16 @@ try { return global.localStorage.getItem('st_device_id'); } catch (e) { return null; } }, + /* The credential that proves this player IS that display. Useless without deviceId, and + deviceId is useless without it. */ + deviceToken: function () { return regGet('device_token', null); }, + /* Called once pairing completes, so a reboot comes back as the same display. */ - setIdentity: function (deviceId, serverUrl) { + setIdentity: function (deviceId, serverUrl, deviceToken) { var values = {}; if (deviceId) values.device_id = deviceId; if (serverUrl) values.server_url = serverUrl; + if (deviceToken) values.device_token = deviceToken; regSet(values); post({ type: 'identity', device_id: deviceId || null, server_url: serverUrl || null }); }, @@ -253,7 +262,7 @@ * the same identity on its next boot — a reset that resets nothing. */ clearIdentity: function () { - regSet({ device_id: '' }); + regSet({ device_id: '', device_token: '' }); return post({ type: 'identity', clear: true }); }, diff --git a/server/player/index.html b/server/player/index.html index caba8d5..06e426f 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -371,7 +371,14 @@ if (BS && !cfg.deviceId) { try { const known = BS.deviceId(); - if (known) { cfg.deviceId = known; cfg.paired = true; } + if (known) { + cfg.deviceId = known; + // Without the token the server cannot verify the claim and issues a NEW display, + // so the id alone is not an identity. + const tok = BS.deviceToken(); + if (tok) cfg.deviceToken = tok; + cfg.paired = true; + } } catch (e) { /* registry unavailable — carry on unpaired */ } } return cfg; @@ -381,7 +388,7 @@ // Mirror identity into the registry so it survives a reboot, a content update or a storage // wipe. Best-effort by design: failing to persist here must never break pairing itself. if (BS && cfg && cfg.deviceId) { - try { BS.setIdentity(cfg.deviceId, cfg.serverUrl || null); } catch (e) { /* ignore */ } + try { BS.setIdentity(cfg.deviceId, cfg.serverUrl || null, cfg.deviceToken || null); } catch (e) { /* ignore */ } } } const PLAYLIST_CACHE_KEY = 'rd_playlist_cache' + SCREEN_SUFFIX; @@ -1165,8 +1172,10 @@ const known = BS.deviceId(); if (known && !config.deviceId) { config.deviceId = known; + const tok = BS.deviceToken(); + if (tok) config.deviceToken = tok; config.paired = true; - console.log('[bs] adopted identity from registry:', known); + console.log('[bs] adopted identity from registry:', known, tok ? '(with token)' : '(NO TOKEN — will re-pair)'); } } catch (e) { /* carry on unpaired rather than not at all */ } connect(serverUrl); diff --git a/server/test/brightsign-bridge.test.js b/server/test/brightsign-bridge.test.js index ac281e0..d69ac84 100644 --- a/server/test/brightsign-bridge.test.js +++ b/server/test/brightsign-bridge.test.js @@ -215,3 +215,33 @@ test('a rejected registry read still lets the player boot', async () => { await ready; assert.doesNotThrow(() => api.deviceId()); }); + +test('THE DUPLICATE-ROW BUG: the token is persisted alongside the id', async () => { + // Persisting device_id alone looked correct and still spawned a new device row on every boot: + // the server authenticates a claim to an existing display with the TOKEN, so an id presented + // without one reads as a brand-new display. Found on an XT245, not in a test — hence this one. + const { api, ready } = load({ mods: true }); + await ready; + api.setIdentity('dev-9', 'https://alpha.screentinker.com', 'tok-abc123'); + assert.equal(api.deviceId(), 'dev-9'); + assert.equal(api.deviceToken(), 'tok-abc123', 'without this the display re-pairs every boot'); +}); + +test('an id with no token is still stored — it is better than nothing', async () => { + // An unpaired display has no token yet; the server issues one at pairing. Storing the id alone + // must not throw or wipe anything. + const { api, ready } = load({ mods: true }); + await ready; + api.setIdentity('dev-10', null, null); + assert.equal(api.deviceId(), 'dev-10'); + assert.equal(api.deviceToken(), null); +}); + +test('clearIdentity forgets the token too, or the reset leaks a credential', async () => { + const { api, ready } = load({ mods: true }); + await ready; + api.setIdentity('dev-11', null, 'tok-xyz'); + api.clearIdentity(); + assert.equal(api.deviceId(), null); + assert.equal(api.deviceToken(), null, 'a stale token must not outlive the identity it belongs to'); +});