Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Fable 5 34a6def839 Shell 1.12.1: a catalog install keeps the tarball's file modes
parseTar threw away each entry's mode, so installModule wrote every
downloaded file 0644 — including the prebuilt helpers modules ship under
bin/. spawn() then failed with EACCES: on the 1.10.2 rollout (AB,
2026-10-04) that silently killed both LTC capture (Timers 1.7.0) and SPL
metering (0.5.0) until the binaries were chmod +x'd by hand.
carryOverExecutables (1.9.2) never applied: it only carries files the
download does NOT include, and these downloads include the helpers.

parseTar now keeps the header's mode field (permission bits only,
defaulting to 0644) and both writers pass it on: installModule's staging
and the archive-based shell update. The unbundled-shell test's mock repo
now ships an executable bin/ helper and asserts it still spawns-worthy
after a catalog install, alongside the existing carry-over check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-07 11:45:07 -04:00
8 changed files with 78 additions and 246 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "resi-cues", "id": "resi-cues",
"name": "Resi Cues", "name": "Resi Cues",
"version": "1.0.3", "version": "1.0.0",
"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"],
+13 -88
View File
@@ -39,9 +39,6 @@ function msToPosition(total) {
const WINDOW_MS = 120000; // how long a reading stays useful 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_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
@@ -64,8 +61,6 @@ function createPlayhead(options) {
let offsets = []; // { at, off } — off is mediaMs - wall time it was read let offsets = []; // { at, off } — off is mediaMs - wall time it was read
let spreads = []; // how far apart honest readings sit: this link's noise let spreads = []; // how far apart honest readings sit: this link's noise
let gaps = []; // how long between the playhead actually moving let gaps = []; // how long between the playhead actually moving
let changes = 0; // readings that differed from the one before
let rejected = false; // the last gap looked like a pause and was skipped
let base; // the trusted offset let base; // the trusted offset
let lastMedia; let lastMedia;
let lastNewAt; let lastNewAt;
@@ -95,25 +90,15 @@ function createPlayhead(options) {
spreads = spreads.concat(-delta).slice(-20); spreads = spreads.concat(-delta).slice(-20);
} }
} }
// The first reading lands at an arbitrary point in Resi's cycle, so the gap if (lastNewAt !== undefined) {
// from it to the next one is only a fraction of the real cadence. Learning
// from it locked in a 2s cadence that rejected every honest 30s gap.
if (lastNewAt !== undefined && changes > 0) {
const gap = t - lastNewAt; const gap = t - lastNewAt;
// The gap either side of a pause is not Resi's cadence — recording it // The gap either side of a pause is not Resi's cadence — recording it
// would inflate the estimate and make the next pause slow to notice. // would inflate the estimate and make the next pause slow to notice.
// But a pause is one gap; twice in a row is the cadence having changed.
const worst = gaps.length ? Math.max.apply(null, gaps) : 0; const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
if (gap > 0 && gap < 60000) { if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) {
if (!worst || gap <= worst * 3 || rejected) {
gaps = gaps.concat(gap).slice(-8); gaps = gaps.concat(gap).slice(-8);
rejected = false;
} else {
rejected = true;
} }
} }
}
if (lastNewAt !== undefined) changes++;
offsets = offsets.concat({ at: t, off }); offsets = offsets.concat({ at: t, off });
lastMedia = mediaMs; lastMedia = mediaMs;
lastNewAt = t; lastNewAt = t;
@@ -133,9 +118,7 @@ function createPlayhead(options) {
// Only call it paused once the playhead has been still for clearly longer than // Only call it paused once the playhead has been still for clearly longer than
// Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a // Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a
// countdown that freezes mid-service is the worse failure of the two. // countdown that freezes mid-service is the worse failure of the two.
// Until a full gap has been measured the cadence is unknown; assume the slowest const pauseAfter = Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000));
// Resi plausibly is, or a 30s publisher reads as paused 15s after every start.
const pauseAfter = cadenceMs ? Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000)) : 45000;
const playing = base !== undefined && t - lastNewAt < pauseAfter; const playing = base !== undefined && t - lastNewAt < pauseAfter;
if (!playing) { if (!playing) {
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs }; return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
@@ -178,43 +161,13 @@ function createResiClient(options) {
return h; return h;
} }
/* A failed login used to be retried on the very next poll -- once a second, for
ever. Resi answers that with 429s, and since the retries never let up, the
account never got out of them. So a failure now holds off further attempts,
doubling from 15s to 5min (or as long as Resi's Retry-After asks), and every
call in between fails at once with the same reason instead of signing in. */
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
let loginFailures = 0;
let loginBlockedUntil = 0;
let loginError = '';
async function login() { async function login() {
if (now() < loginBlockedUntil) { const res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
throw new Error(`${loginError} — retrying in ${wait}s`);
}
let res;
try {
res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ userName: opts.username, password: opts.password }), body: JSON.stringify({ userName: opts.username, password: opts.password }),
}); });
} catch (err) { if (!res.ok) throw new Error(`Resi login failed (${res.status})`);
holdOff(`Resi unreachable (${(err && err.message) || err})`, 0);
throw new Error(loginError);
}
if (!res.ok) {
const reason = res.status === 429 ? 'Resi is rate-limiting this login (429)'
: res.status === 400 || res.status === 401 ? `Resi rejected the login (${res.status}) — check the password`
: `Resi login failed (${res.status})`;
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
holdOff(reason, retryAfter * 1000);
throw new Error(reason);
}
loginFailures = 0;
loginBlockedUntil = 0;
loginError = '';
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie()) const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []); || (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
const jar = {}; const jar = {};
@@ -226,49 +179,24 @@ 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 = now(); loggedInAt = Date.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');
return me; return me;
} }
function holdOff(reason, askedMs) {
loginFailures++;
const backoff = Math.min(LOGIN_BACKOFF_MAX_MS, LOGIN_BACKOFF_MS * 2 ** (loginFailures - 1));
loginBlockedUntil = now() + Math.max(backoff, askedMs || 0);
loginError = reason;
}
async function ensureSession() { async function ensureSession() {
if (!token || now() - loggedInAt > 50 * 60 * 1000) await login(); if (!token || Date.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) {
if (now() < loginBlockedUntil) { const res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
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();
res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
}
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
const where = path.includes('/venues') ? 'its venues' await login();
: path.includes('/players') ? "this venue's decoders" : 'part of the account'; const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() });
holdOff(`This Resi login isn't allowed to see ${where} (${res.status}) — ask a Resi admin for access`, 0); if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`);
throw new Error(loginError); return retry.json();
}
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();
@@ -288,10 +216,7 @@ function createResiClient(options) {
try { try {
return (await get(`${path}?_=${Date.now()}`)) || []; return (await get(`${path}?_=${Date.now()}`)) || [];
} catch (err) { } catch (err) {
// Only an objection to the parameter itself; a refusal or a "slow down" if (!/\((4\d\d)\)/.test(String(err.message))) throw err;
// (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;
} }
} }
-26
View File
@@ -197,32 +197,6 @@ test('surroundingCues puts a cue you are exactly on behind you', () => {
assert.strictEqual(surroundingCues([], 1000).next, null); assert.strictEqual(surroundingCues([], 1000).next, null);
}); });
/* ── starting partway through Resi's cycle ─────────────────────────────── */
// Seen live at North Attleboro: Resi publishing every ~30s, and the board starting a
// few seconds before a publish. The first "gap" it measured was 2s (9s after a
// reboot), and once that was the cadence every honest 30s gap was thrown out as a
// pause gap -- so playback read as paused 15s into every update, the countdown froze,
// and the next cue (and its name) stopped moving on.
for (const [into, burst] of [[28000, false], [21000, false], [0, true]]) {
test(`30s Resi, started ${into / 1000}s into its cycle${burst ? ', 2s burst after a seek' : ''}: never falsely paused`, () => {
const w = makeWorld({ cadence: 30000, lag: 1000, jitter: 2000, net: 500 });
w.lastPublish = w.t - into;
const head = createPlayhead({ now: () => w.t });
const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); };
let paused = 0;
for (let i = 0; i < 100; i++) if (!second().playing) paused++;
if (burst) {
w.media += 15 * 60000; // a seek, published at once...
w.lastPublish = w.t - w.nextCadence;
second(); second();
w.lastPublish = w.t - w.nextCadence; // ...and again 2s later
}
for (let i = 0; i < 300; i++) if (!second().playing) paused++;
assert.strictEqual(paused, 0, `read as paused for ${paused}s while playing`);
});
}
/* ── the matrix ────────────────────────────────────────────────────────── */ /* ── the matrix ────────────────────────────────────────────────────────── */
const argv = process.argv.slice(2).map(Number); const argv = process.argv.slice(2).map(Number);
+4 -20
View File
@@ -70,7 +70,6 @@ function emptyTimers(now) {
} }
let current = null; // the live handle, shared with routes() let current = null; // the live handle, shared with routes()
let pickerClient = null; // { key, client } for the decoder picker when the module isn't running those credentials
module.exports = { module.exports = {
init({ config, log }) { init({ config, log }) {
@@ -147,9 +146,7 @@ module.exports = {
elapsedMs: null, elapsedMs: null,
remainingMs, remainingMs,
targetMs: Math.max(1000, state.next.ms - from), targetMs: Math.max(1000, state.next.ms - from),
// The label already is the cue's name, and it is what the card is titled status: { text: state.next.name || 'Next cue', tone: state.playing ? 'ok' : 'warn' },
// with; repeating it on the status line only crowded the card.
status: { text: state.playing ? 'Next cue' : 'Held', tone: state.playing ? 'ok' : 'warn' },
detail: state.event || '', detail: state.event || '',
}); });
} }
@@ -274,7 +271,6 @@ module.exports = {
stateStreams, stateStreams,
timerStreams, timerStreams,
resi, resi,
credentialsKey: `${username}\n${password}`,
configured: Boolean(username && password), configured: Boolean(username && password),
stop() { stop() {
stopped = true; stopped = true;
@@ -336,21 +332,9 @@ module.exports = {
return json(res, 200, { options: [], note: 'Enter the Resi login first' }); return json(res, 200, { options: [], note: 'Enter the Resi login first' });
} }
try { try {
// The running module's client when it has these credentials, else one kept const client = createResiClient({
// per login: a fresh client per request signed in on every admin page load, username: String(config.username), password: String(config.password),
// and its own hold-off never applied -- which kept Resi rate-limiting. });
const username = String(config.username).trim();
const key = `${username}\n${config.password}`;
let client;
if (current && current.resi && current.credentialsKey === key) client = current.resi;
else {
if (!pickerClient || pickerClient.key !== key) {
pickerClient = { key, client: createResiClient({
username, password: String(config.password),
}) };
}
client = pickerClient.client;
}
const options = []; const options = [];
for (const v of await client.venues()) { for (const v of await client.venues()) {
for (const p of await client.players(v.uuid)) { for (const p of await client.players(v.uuid)) {
+14 -93
View File
@@ -18,9 +18,8 @@
const assert = require('assert'); const assert = require('assert');
let passed = 0; let passed = 0;
// Awaited, so an async test finishes before the next one changes the world under it. function test(name, fn) {
async function test(name, fn) { fn();
await fn();
passed++; passed++;
console.log(` ok ${name}`); console.log(` ok ${name}`);
} }
@@ -102,7 +101,7 @@ function collect(route, req) {
const routes = server.routes({ config: {}, log: (m) => logged.push(m) }); const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
await test('signs in once and finds the configured decoder', () => { test('signs in once and finds the configured decoder', () => {
assert.strictEqual(world.logins, 1, 'should sign in exactly once'); assert.strictEqual(world.logins, 1, 'should sign in exactly once');
const state = handle.state; const state = handle.state;
assert.strictEqual(state.status, 'ok', state.message); assert.strictEqual(state.status, 'ok', state.message);
@@ -111,20 +110,20 @@ function collect(route, req) {
assert.strictEqual(state.cues.length, 3); assert.strictEqual(state.cues.length, 3);
}); });
await test('works out the cue either side of the playhead', () => { test('works out the cue either side of the playhead', () => {
const state = handle.state; const state = handle.state;
assert.strictEqual(state.next.name, 'Campus Host'); assert.strictEqual(state.next.name, 'Campus Host');
assert.strictEqual(state.prev.name, 'Welcome'); assert.strictEqual(state.prev.name, 'Welcome');
assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`); assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`);
}); });
await test('/state answers the same snapshot as the stream', () => { test('/state answers the same snapshot as the stream', () => {
const res = collect(routes['GET /state']); const res = collect(routes['GET /state']);
assert.strictEqual(res.code, 200); assert.strictEqual(res.code, 200);
assert.deepStrictEqual(JSON.parse(res.body), handle.state); assert.deepStrictEqual(JSON.parse(res.body), handle.state);
}); });
await test('/stream opens with an event so a new tile paints at once', () => { test('/stream opens with an event so a new tile paints at once', () => {
const res = collect(routes['GET /stream']); const res = collect(routes['GET /stream']);
assert.strictEqual(res.code, 200); assert.strictEqual(res.code, 200);
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8'); assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
@@ -132,7 +131,7 @@ function collect(route, req) {
assert.ok(res.body.includes('event: state'), 'should send an initial state event'); assert.ok(res.body.includes('event: state'), 'should send an initial state event');
}); });
await test('/timers offers the countdown and the playhead, shaped as the guide says', () => { test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
const snap = JSON.parse(collect(routes['GET /timers']).body); const snap = JSON.parse(collect(routes['GET /timers']).body);
assert.strictEqual(snap.source, 'resi-cues'); assert.strictEqual(snap.source, 'resi-cues');
assert.strictEqual(snap.label, 'Resi'); assert.strictEqual(snap.label, 'Resi');
@@ -146,7 +145,7 @@ function collect(route, req) {
assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`); assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`);
// the countdown runs from the cue before it, so a consumer can colour progress // the countdown runs from the cue before it, so a consumer can colour progress
assert.strictEqual(next.targetMs, 450000); assert.strictEqual(next.targetMs, 450000);
assert.strictEqual(next.status.text, 'Next cue'); // the name is the label; not repeated assert.strictEqual(next.status.text, 'Campus Host');
assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past'); assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
assert.strictEqual(playhead.id, 'playhead'); assert.strictEqual(playhead.id, 'playhead');
@@ -155,7 +154,7 @@ function collect(route, req) {
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000); assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
}); });
await test('a cue fired at the host moves the countdown on the next poll', async () => { test('a cue fired at the host moves the countdown on the next poll', async () => {
world.position = '00:12:40.000'; // jumped past Campus Host world.position = '00:12:40.000'; // jumped past Campus Host
tickFn(); tickFn();
await settle(); await settle();
@@ -163,10 +162,10 @@ function collect(route, req) {
assert.strictEqual(state.next.name, 'Message'); assert.strictEqual(state.next.name, 'Message');
assert.strictEqual(state.prev.name, 'Campus Host'); assert.strictEqual(state.prev.name, 'Campus Host');
const snap = JSON.parse(collect(routes['GET /timers']).body); const snap = JSON.parse(collect(routes['GET /timers']).body);
assert.strictEqual(snap.timers[0].label, 'Message'); // the card's name follows the cue assert.strictEqual(snap.timers[0].label, 'Message');
}); });
await test('a decoder with no event reports nothing playing rather than stale cues', async () => { test('a decoder with no event reports nothing playing rather than stale cues', async () => {
world.eventId = ''; world.eventId = '';
tickFn(); tickFn();
await settle(); await settle();
@@ -175,13 +174,13 @@ function collect(route, req) {
assert.strictEqual(handle.state.event, ''); assert.strictEqual(handle.state.event, '');
}); });
await test('health speaks about the upstream, not about itself', () => { test('health speaks about the upstream, not about itself', () => {
const h = handle.health(); const h = handle.health();
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status); assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
assert.ok(h.message.length > 0, 'health should say something useful'); assert.ok(h.message.length > 0, 'health should say something useful');
}); });
await test('an unconfigured module idles instead of hammering Resi', () => { test('an unconfigured module idles instead of hammering Resi', () => {
const before = world.logins; const before = world.logins;
const idle = server.init({ config: {}, log: () => {} }); const idle = server.init({ config: {}, log: () => {} });
assert.strictEqual(world.logins, before, 'must not sign in with no credentials'); assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
@@ -190,90 +189,12 @@ function collect(route, req) {
idle.stop(); idle.stop();
}); });
await test('stop() leaves nothing running', () => { test('stop() leaves nothing running', () => {
handle.stop(); handle.stop();
const res = collect(routes['GET /state']); const res = collect(routes['GET /state']);
assert.strictEqual(JSON.parse(res.body).status, 'error'); assert.strictEqual(JSON.parse(res.body).status, 'error');
}); });
// Seen live at North Attleboro: a failed login was retried on every 1s poll, so
// Resi answered 429 and kept answering it, and the decoder picker stayed empty.
await test('a failed login holds off instead of retrying every poll', async () => {
const { createResiClient } = require('./resi');
let t = 1700000000000;
let logins = 0;
let limited = true;
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++;
if (limited) return reply({}, { ok: false, status: 429 });
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') return reply([{ uuid: 'V1' }]);
return reply({}, { ok: false, status: 404 });
},
});
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
assert.match(await poll(), /rate-limiting/);
for (let i = 0; i < 60; i++) { t += 1000; await poll(); } // a minute of 1s polls
assert.ok(logins <= 3, `signed in ${logins} times in a minute while rate-limited`);
assert.match(await poll(), /retrying in \d+s/, 'the wait is shown, not hidden');
limited = false;
t += 5 * 60000; // past the longest hold-off
assert.strictEqual(await poll(), 'ok', 'recovers once Resi lets it in');
const after = logins;
for (let i = 0; i < 30; i++) { t += 1000; await poll(); }
assert.strictEqual(logins, after, 'a good session is not signed in again');
});
await test('a rejected password says so', async () => {
const { createResiClient } = require('./resi');
const client = createResiClient({
username: 'a@b.c', password: 'wrong', now: () => 0,
fetch: async () => reply({}, { ok: false, status: 400 }),
});
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`);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.12.0", "version": "1.12.1",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
+18 -11
View File
@@ -1121,6 +1121,11 @@ function parseTar(buf) {
while (off + 512 <= buf.length) { while (off + 512 <= buf.length) {
const h = buf.subarray(off, off + 512); const h = buf.subarray(off, off + 512);
if (h.every((b) => b === 0)) break; // end-of-archive blocks if (h.every((b) => b === 0)) break; // end-of-archive blocks
// The mode matters: modules ship prebuilt helpers under bin/, and writing
// one out without its execute bits leaves spawn() with EACCES and the
// feature silently dead. Permission bits only — setuid/sticky from an
// archive are not wanted here.
const mode = (parseInt(cstr(h, 100, 8).trim() || '0', 8) & 0o777) || 0o644;
const size = parseInt(cstr(h, 124, 12).trim() || '0', 8) || 0; const size = parseInt(cstr(h, 124, 12).trim() || '0', 8) || 0;
const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]); const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]);
const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : ''; const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : '';
@@ -1136,7 +1141,7 @@ function parseTar(buf) {
} }
const name = pendingName || shortName; const name = pendingName || shortName;
pendingName = null; pendingName = null;
if (type === '0') files.set(name, Buffer.from(data)); if (type === '0') files.set(name, { data: Buffer.from(data), mode });
} }
return { files, comment }; return { files, comment };
} }
@@ -1165,9 +1170,9 @@ async function fetchArchive() {
} }
const { files, comment } = parseTar(zlib.gunzipSync(gz)); const { files, comment } = parseTar(zlib.gunzipSync(gz));
const stripped = new Map(); // drop the archive's single top-level folder const stripped = new Map(); // drop the archive's single top-level folder
for (const [p, data] of files) { for (const [p, entry] of files) {
const i = p.indexOf('/'); const i = p.indexOf('/');
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), data); if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), entry);
} }
return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' }; return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' };
} }
@@ -1182,17 +1187,17 @@ async function refreshRemote(force = false) {
const job = (async () => { const job = (async () => {
try { try {
const { files, sha } = await fetchArchive(); const { files, sha } = await fetchArchive();
const pkg = safeJson(files.get('package.json')); const pkg = safeJson(files.get('package.json')?.data);
const modules = new Map(); const modules = new Map();
for (const [p, data] of files) { for (const [p, file] of files) {
const m = p.match(/^modules\/([^/]+)\/(.+)$/); const m = p.match(/^modules\/([^/]+)\/(.+)$/);
if (!m) continue; if (!m) continue;
const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() }; const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() };
entry.files.set(m[2], data); entry.files.set(m[2], file);
modules.set(m[1], entry); modules.set(m[1], entry);
} }
for (const [id, entry] of modules) { for (const [id, entry] of modules) {
const man = safeJson(entry.files.get('module.json')); const man = safeJson(entry.files.get('module.json')?.data);
if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) { if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) {
modules.delete(id); modules.delete(id);
continue; continue;
@@ -1322,12 +1327,14 @@ async function installModule(id) {
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`); const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
fs.rmSync(staging, { recursive: true, force: true }); fs.rmSync(staging, { recursive: true, force: true });
const shipped = new Set(); const shipped = new Set();
for (const [rel, data] of remoteEntry.files) { for (const [rel, { data, mode }] of remoteEntry.files) {
if (!safeRelPath(rel)) continue; if (!safeRelPath(rel)) continue;
shipped.add(rel); shipped.add(rel);
const target = path.join(staging, rel); const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true }); fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data); // The tarball's mode, or a shipped bin/ helper lands 0644 and spawns
// EACCES (what broke LTC and SPL on the 1.10.2 rollout, 2026-10-04).
fs.writeFileSync(target, data, { mode });
} }
const dest = path.join(INSTALLED_MODULES_DIR, id); const dest = path.join(INSTALLED_MODULES_DIR, id);
// Per-machine executables — a capture tool compiled here, or copied in // Per-machine executables — a capture tool compiled here, or copied in
@@ -1439,11 +1446,11 @@ async function updateShell() {
} else { } else {
const staging = path.join(ROOT, '.proddash-update'); const staging = path.join(ROOT, '.proddash-update');
fs.rmSync(staging, { recursive: true, force: true }); fs.rmSync(staging, { recursive: true, force: true });
for (const [rel, data] of remote.files) { for (const [rel, { data, mode }] of remote.files) {
if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue; if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
const target = path.join(staging, rel); const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true }); fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data); fs.writeFileSync(target, data, { mode });
} }
const newServer = path.join(staging, 'server.js'); const newServer = path.join(staging, 'server.js');
if (!fs.existsSync(newServer)) { if (!fs.existsSync(newServer)) {
+22 -1
View File
@@ -12,7 +12,12 @@
* 2. a shell update from an archive that carries modules/ never writes * 2. a shell update from an archive that carries modules/ never writes
* them into the app folder — they show up in the catalog instead; * them into the app folder — they show up in the catalog instead;
* 3. a fresh install lists everything the repo offers, and installing * 3. a fresh install lists everything the repo offers, and installing
* several at once lands them in the data directory and mounts them. * several at once lands them in the data directory and mounts them;
* 4. a module downloaded from the catalog keeps the tarball's modes — a
* shipped bin/ helper is still executable (0644 means spawn EACCES,
* which is what broke LTC and SPL on the 1.10.2 rollout) — while
* per-machine executables the download does not include are still
* carried over from the copy being replaced.
* *
* node tools/unbundled.test.js * node tools/unbundled.test.js
* *
@@ -70,6 +75,11 @@ function buildArchive(dir) {
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js')); fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js'));
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' })); fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0'); writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0');
// The repo ships this one prebuilt (the way Timers and SPL ship their
// capture helpers): it must come out of the download still executable.
fs.mkdirSync(path.join(top, 'modules', 'demo', 'bin'));
fs.writeFileSync(path.join(top, 'modules', 'demo', 'bin', 'demo-capture'), '#!/bin/sh\necho capture\n');
fs.chmodSync(path.join(top, 'modules', 'demo', 'bin', 'demo-capture'), 0o755);
writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0'); writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0');
writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0'); writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0');
const out = path.join(dir, 'main.tar.gz'); const out = path.join(dir, 'main.tar.gz');
@@ -188,6 +198,17 @@ function buildPackagedApp(app) {
const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' }); const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' });
assert.equal(empty.status, 400, 'nothing to install is said'); assert.equal(empty.status, 400, 'nothing to install is said');
// ── 4. a download keeps executable modes, and carry-over still works ──
const upd = await api('/api/admin/modules/demo/install', { method: 'POST', body: '{}' });
assert.equal(upd.status, 200, 'demo update: ' + JSON.stringify(upd.body));
assert.equal(upd.body.version, '2.0.0', 'demo was updated from the repo');
const shipped = path.join(dataDir, 'modules', 'demo', 'bin', 'demo-capture');
assert.ok(fs.existsSync(shipped), 'the downloaded helper is there');
assert.ok(fs.statSync(shipped).mode & 0o111, 'and the tarball\'s execute bit survived the install');
const carried = path.join(dataDir, 'modules', 'demo', 'bin', 'demo-helper');
assert.ok(fs.existsSync(carried), 'the per-machine executable the download lacks was carried over');
assert.ok(fs.statSync(carried).mode & 0o111, 'still executable');
console.log('unbundled shell: every check passed'); console.log('unbundled shell: every check passed');
} catch (err) { } catch (err) {
failed = true; failed = true;