From 79d32373284b1418e5fe13dda73c22f52b871ad4 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 6 Oct 2026 16:27:38 -0400 Subject: [PATCH] Resi Cues 1.0.3: a login without access to the venue stops signing in every poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every 401/403 was answered by signing in again on the spot. For a login that works but may not list the account's venues (media@waters.church at North Attleboro got 403 on /venues), that was a successful sign-in on every 1s poll -- the pattern that had just got another Resi account locked. - A refusal straight after signing in is a permission, not an expired session: it is held off like a failed login, with the message "This Resi login isn't allowed to see its venues (403) — ask a Resi admin for access". Only a session over a minute old is renewed. - 429 on any request holds off too, not only on the login. - The players cache-bust fallback no longer drops the parameter for good on a 401/403/429, which are about the login rather than the parameter. Co-Authored-By: Claude Opus 5.5 --- modules/resi-cues/module.json | 2 +- modules/resi-cues/resi.js | 38 +++++++++++++++++++++++++------- modules/resi-cues/server.test.js | 31 ++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/modules/resi-cues/module.json b/modules/resi-cues/module.json index 6b507ab..e930cc0 100644 --- a/modules/resi-cues/module.json +++ b/modules/resi-cues/module.json @@ -1,7 +1,7 @@ { "id": "resi-cues", "name": "Resi Cues", - "version": "1.0.2", + "version": "1.0.3", "proddash": ">=1.4.0", "description": "Time until the next Resi cue point, on the board and as a timer", "provides": ["timers"], diff --git a/modules/resi-cues/resi.js b/modules/resi-cues/resi.js index 4b5e861..8692b37 100644 --- a/modules/resi-cues/resi.js +++ b/modules/resi-cues/resi.js @@ -41,6 +41,7 @@ const WINDOW_MS = 120000; // how long a reading stays useful const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek const LOGIN_BACKOFF_MS = 15000; // first wait after a failed Resi login const LOGIN_BACKOFF_MAX_MS = 300000; // longest wait between login attempts +const SESSION_FRESH_MS = 60000; // a refusal younger than this is a permission, not an expired session /** * Resi republishes a decoder's `position` only every few seconds, on an irregular @@ -225,7 +226,7 @@ function createResiClient(options) { token = jar.livingas1 || ''; if (!token) throw new Error('Resi login returned no session cookie'); cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; '); - loggedInAt = Date.now(); + loggedInAt = now(); const me = await get('/api_v2.svc/users/me'); customerId = (me && me.customerId) || ''; if (!customerId) throw new Error('This Resi account has no customerId'); @@ -240,16 +241,34 @@ function createResiClient(options) { } async function ensureSession() { - if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login(); + if (!token || now() - loggedInAt > 50 * 60 * 1000) await login(); } + /* The same hold-off covers every request, not just the login. A 401/403 used to + sign in again on the spot, every time: for a login that simply isn't allowed to + see a venue, that was a fresh sign-in on every poll. Now a refusal straight + after signing in is taken as what it is -- a permission -- and held off like a + failed login; only an older session is renewed. A 429 holds off too. */ async function get(path) { - const res = await doFetch(RESI + path, { method: 'GET', headers: headers() }); - if (res.status === 401 || res.status === 403) { + if (now() < loginBlockedUntil) { + const wait = Math.ceil((loginBlockedUntil - now()) / 1000); + throw new Error(`${loginError} — retrying in ${wait}s`); + } + let res = await doFetch(RESI + path, { method: 'GET', headers: headers() }); + if ((res.status === 401 || res.status === 403) && now() - loggedInAt > SESSION_FRESH_MS) { await login(); - const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() }); - if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`); - return retry.json(); + res = await doFetch(RESI + path, { method: 'GET', headers: headers() }); + } + if (res.status === 401 || res.status === 403) { + const where = path.includes('/venues') ? 'its venues' + : path.includes('/players') ? "this venue's decoders" : 'part of the account'; + holdOff(`This Resi login isn't allowed to see ${where} (${res.status}) — ask a Resi admin for access`, 0); + throw new Error(loginError); + } + if (res.status === 429) { + const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0; + holdOff('Resi is rate-limiting requests (429)', retryAfter * 1000); + throw new Error(loginError); } if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`); return res.json(); @@ -269,7 +288,10 @@ function createResiClient(options) { try { return (await get(`${path}?_=${Date.now()}`)) || []; } catch (err) { - if (!/\((4\d\d)\)/.test(String(err.message))) throw err; + // Only an objection to the parameter itself; a refusal or a "slow down" + // (401/403/429) is about the login, and dropping it then lost fresh readings. + const code = (String(err.message).match(/\((4\d\d)\)/) || [])[1]; + if (!code || ['401', '403', '429'].includes(code)) throw err; cacheBust = false; } } diff --git a/modules/resi-cues/server.test.js b/modules/resi-cues/server.test.js index 9d50d6c..a9a83ba 100644 --- a/modules/resi-cues/server.test.js +++ b/modules/resi-cues/server.test.js @@ -243,6 +243,37 @@ function collect(route, req) { await assert.rejects(client.venues(), /check the password/); }); + // Seen live: media@waters.church signs in fine but may not list venues (403). The + // client answered every 403 by signing in again -- a fresh login on every poll. + await test('a login that may not see venues is held off, not signed in every poll', async () => { + const { createResiClient } = require('./resi'); + let t = 1700000000000; + let logins = 0; + let venueCalls = 0; + const client = createResiClient({ + username: 'a@b.c', password: 'secret', now: () => t, + fetch: async (url) => { + const path = String(url).replace('https://central.resi.io', '').split('?')[0]; + if (path === '/api/v3/login') { + logins++; + return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } }); + } + if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' }); + if (path === '/api/v3/customers/CUST/venues') { venueCalls++; return reply({}, { ok: false, status: 403 }); } + return reply({}, { ok: false, status: 404 }); + }, + }); + const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } }; + + assert.match(await poll(), /isn't allowed to see its venues \(403\)/); + for (let i = 0; i < 120; i++) { t += 1000; await poll(); } // two minutes of 1s polls + // At most one renewal once the session is over a minute old (in case it really + // had expired), then the hold-off keeps growing -- not one sign-in per poll. + assert.ok(logins <= 2, `signed in ${logins} times in two minutes for a permission problem`); + assert.ok(venueCalls <= 8, `asked for venues ${venueCalls} times in two minutes (a poll each would be 121)`); + assert.match(await poll(), /ask a Resi admin/); + }); + globalThis.setInterval = realSetInterval; globalThis.clearInterval = realClearInterval; console.log(`\n${passed} tests passed`);