Resi Cues 1.0.3: the next cue's name keeps moving, and Resi logins stop hammering the account #43
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "resi-cues",
|
"id": "resi-cues",
|
||||||
"name": "Resi Cues",
|
"name": "Resi Cues",
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Time until the next Resi cue point, on the board and as a timer",
|
"description": "Time until the next Resi cue point, on the board and as a timer",
|
||||||
"provides": ["timers"],
|
"provides": ["timers"],
|
||||||
|
|||||||
@@ -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 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_MS = 15000; // first wait after a failed Resi login
|
||||||
const LOGIN_BACKOFF_MAX_MS = 300000; // longest wait between login attempts
|
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
|
* Resi republishes a decoder's `position` only every few seconds, on an irregular
|
||||||
@@ -225,7 +226,7 @@ function createResiClient(options) {
|
|||||||
token = jar.livingas1 || '';
|
token = jar.livingas1 || '';
|
||||||
if (!token) throw new Error('Resi login returned no session cookie');
|
if (!token) throw new Error('Resi login returned no session cookie');
|
||||||
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
|
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
|
||||||
loggedInAt = Date.now();
|
loggedInAt = now();
|
||||||
const me = await get('/api_v2.svc/users/me');
|
const me = await get('/api_v2.svc/users/me');
|
||||||
customerId = (me && me.customerId) || '';
|
customerId = (me && me.customerId) || '';
|
||||||
if (!customerId) throw new Error('This Resi account has no customerId');
|
if (!customerId) throw new Error('This Resi account has no customerId');
|
||||||
@@ -240,16 +241,34 @@ function createResiClient(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureSession() {
|
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) {
|
async function get(path) {
|
||||||
const res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
if (now() < loginBlockedUntil) {
|
||||||
if (res.status === 401 || res.status === 403) {
|
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();
|
await login();
|
||||||
const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||||
if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`);
|
}
|
||||||
return retry.json();
|
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})`);
|
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -269,7 +288,10 @@ function createResiClient(options) {
|
|||||||
try {
|
try {
|
||||||
return (await get(`${path}?_=${Date.now()}`)) || [];
|
return (await get(`${path}?_=${Date.now()}`)) || [];
|
||||||
} catch (err) {
|
} 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;
|
cacheBust = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,37 @@ function collect(route, req) {
|
|||||||
await assert.rejects(client.venues(), /check the password/);
|
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.setInterval = realSetInterval;
|
||||||
globalThis.clearInterval = realClearInterval;
|
globalThis.clearInterval = realClearInterval;
|
||||||
console.log(`\n${passed} tests passed`);
|
console.log(`\n${passed} tests passed`);
|
||||||
|
|||||||
Reference in New Issue
Block a user