Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Fable 5.1 459d8b9c38 Overflow Tap: speak Overflow's Dashboard API, as its own helper app does
The first cut guessed a write route on Overflow's public API, which documents
only reads for Tap. With the helper app's bundle in hand (Overflow ProPresenter
Integration 1.2.3, read 2026-10-11) the module now makes exactly the calls the
helper makes, against the Dashboard's own backend:

  POST /nonprofit-dashboard/authentication/login/jwt      a Dashboard sign-in → JWT
  GET  /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords
                                                          the Keyword Mappings
  POST …/nonprofits/{id}/keywords?syncNonprofit=true      { keywords } = the switch

So the keyword → destination → Tap groups mapping stays where Overflow keeps
it (Dashboard › Integrations › ProPresenter › Keyword Mappings) and is read
from there: on start, every five minutes, on the tile's refresh button, and
from every trigger's answer. Admin config shrinks to the ProPresenter endpoint,
the Overflow Dashboard login (plus a Nonprofit ID only for an @overflow.co
staff login) and the alert switch; the API-key, Tap-groups, keyword-text and
match-mode fields are gone.

Behaviour follows the helper: both the presentation layer (/v1/status/slide)
and the announcement layer (/v1/announcement/slide_index + /active) are
watched; when a layer's set of matched keywords changes to a non-empty set it
is sent; matching is per word, case-insensitive, punctuation around the word
ignored. The session token is cached across re-inits for the same login so an
admin saving config repeatedly cannot trip Overflow's "too many attempts";
a 401 signs in again once; a refused sign-in is not retried within 20 s.

tools/overflow-tap-mock.js now plays the Dashboard API (any email/password
signs in; --fail-switch, --expire=<s>, --login=<email>:<password>) and gives
the mock ProPresenter an announcement layer (--no-announcements for an older
one). Verified against it: sign-in, keyword load, triggers on both layers,
token expiry → re-login, 500 → NOT SWITCHED + alert + retries, wrong login →
the reason on the tile and in the health line, and recovery. keywords.selftest.js
pins the matching rule (8 cases).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 10:25:02 -04:00
Waters ChurchandClaude Fable 5.1 6b51044be6 Overflow Tap 1.0.0: switch Overflow Tap destinations from keywords in ProPresenter slide notes
A new module that does what Overflow's "ProPresenter Integration" helper app
does, inside ProdDash: the live slide's notes are read from ProPresenter
(GET /v1/status/slide, twice a second) and when they carry one of the admin
page's keywords, the chosen Overflow Tap groups are pointed at that keyword's
destination. One tile: the keyword Tap is on (and since when), its
destination and the groups moved, a chip saying whether the slide on screen
carries a keyword, and the last four switches. A failed switch turns the tile
red with the reason, retries three times, and can flash an alert channel.

Admin config: ProPresenter endpoint + password; Overflow client ID + API key
(x-client-id / x-api-key, the public API's two headers); Tap groups to switch
(multiselect read from the account over /groups); keywords as text, one per
line (`give = Give Online`, optionally `@ Lobby, Kids Wing`); match mode
(whole word / anywhere / own line); alerts switch + channel.

The one unverified piece: Overflow's public API documents only reads for Tap
(OpenAPI checked 2026-10-07), while Overflow's helper uses the Dashboard's
private API. The switch is sent as PATCH /api/v3/tap/groups/{id} with the new
destinationId — the natural shape for this API — and every switch is confirmed
by reading the group back, so a refusal shows on the tile instead of passing
silently. overflow.js is the one place to change if Overflow names a
different route.

Also: tools/overflow-tap-mock.js (a ProPresenter whose slide notes carry
keywords on :1602 plus the Overflow Tap API on :24720, --read-only to fail
every switch), keywords.selftest.js (14 cases), README + CLAUDE.md entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:00:37 -04:00
13 changed files with 1821 additions and 43 deletions
+1
View File
@@ -52,6 +52,7 @@ title-bar controls). The rules that matter most:
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
`node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`).
`node tools/overflow-tap-mock.js` (ProPresenter slide notes on `127.0.0.1:1602` + the Overflow Dashboard API on `127.0.0.1:24720`; run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`, any email/password signs in; `--fail-switch`, `--expire=20`, `--login=<email>:<password>`).
## Reference code (read-only forks — don't develop these here)
+30 -2
View File
@@ -510,6 +510,27 @@ below. Nothing is installed without someone clicking.
read from the account), and a folded **Timing** group for the poll
intervals and a position trim.
Tile settings: cue name, previous cue, playhead, cue list.
- **Overflow Tap** — what Overflow's own "ProPresenter Integration" helper
app does, inside ProdDash, speaking the same Dashboard API it does: the
live slide's notes are read from ProPresenter (the presentation layer and
the announcement layer), and when they carry one of the keywords mapped in
the Overflow Dashboard, Overflow is told — and points that keyword's Tap
groups at its destination, so a tap on a disc follows what is on screen.
A slide with no keyword changes nothing. Keywords match as the helper
matches them: as a whole word of the notes, any case, punctuation around
it ignored (`give` fires on "Give!" and not on "forgiveness"). The tile is
one keyword, big: what Tap is on (and since when), its destination and the
groups moved, a chip saying whether the slide on screen carries a keyword,
and the last four switches with their times; a failed switch turns the
tile red with the reason, is retried, and — if switched on in admin —
flashes an alert channel. Admin config: the ProPresenter endpoint and an
**Overflow Dashboard login** (email and password; Google sign-in has no
password, so set one with "Forgot Your Password?"). The keywords, their
destinations and their Tap groups stay where Overflow keeps them —
Integrations › ProPresenter › Keyword Mappings in the Dashboard — and are
read from there on start, every few minutes, and on the tile's refresh
button. Tile settings are four switches: destination, groups, slide
match, history.
- **Band Lineup** — the backstage stage board: everyone on stage in their mic
slot with photo, name and mic/pack label, on one shared board that every
screen sees the same way — whoever edits it changes it everywhere, over SSE.
@@ -560,8 +581,15 @@ timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and
`node tools/pco-band-mock.js 24710` (Planning Center for the Band Lineup
board — a plan, its team members, birthdays and photos — run ProdDash with
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of
the [module guide](docs/MODULE-GUIDE.md).
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and
`node tools/overflow-tap-mock.js` (a ProPresenter whose slide notes carry
keywords on `127.0.0.1:1602`, announcement layer included, plus the Overflow
Dashboard API on `127.0.0.1:24720` — run ProdDash with
`OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720` and sign the module in with
any email and password; `--fail-switch` refuses every switch,
`--expire=20` makes sessions expire, `--login=<email>:<password>` accepts
one login only). See the testing section of the
[module guide](docs/MODULE-GUIDE.md).
## Install as a desktop web app
+233
View File
@@ -0,0 +1,233 @@
/* Overflow Tap — the tile.
*
* One fact matters across a booth: which keyword Tap is on right now, and
* whether the switch actually landed. That is the big line. Under it, the
* destination it maps to and the groups it moved; a small line says whether
* the slide on screen carries a keyword at all; and the last four switches
* sit at the bottom with their times, so a tech can see the service's trail.
*
* Everything comes from the server's `state` stream. The tile never talks to
* ProPresenter or Overflow itself.
*/
const HISTORY_SHOWN = 4;
const ICON_REFRESH = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"/><path d="M13.5 2.5v3h-3"/></svg>';
export default function create({ root, moduleApi }) {
let state = null;
let stream = null;
let refreshBtn = null;
let streamDown = false; // the shared ProdDash stream dropped; cleared by the next state
const settings = () => moduleApi.instanceSettings || {};
const on = (key) => settings()[key] !== false;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined && text !== null) node.textContent = text;
return node;
}
function clock(ms) {
if (!ms) return '';
try {
return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch {
return '';
}
}
/** The one-line reason the tile cannot do its job, or '' when it can. */
function blocker() {
if (!state) return 'Waiting for ProdDash';
const pp = state.propresenter || {};
const of = state.overflow || {};
if (!pp.configured) return 'No ProPresenter host — set one in /admin';
if (!of.configured) return 'No Overflow login — add it in /admin';
if (of.reachable && !(state.keywords || []).length) return 'No keyword mappings in the Overflow Dashboard yet';
return '';
}
function render() {
root.textContent = '';
const box = el('div', 'ot-root');
root.append(box);
const stop = blocker();
if (stop) {
box.append(el('div', 'ot-empty', stop));
return;
}
const pp = state.propresenter || {};
const of = state.overflow || {};
const cur = state.current;
const offline = streamDown || !pp.reachable || !of.reachable;
if (offline) {
box.classList.add('is-offline');
const why = streamDown ? 'ProdDash connection lost'
: !of.reachable ? (of.error || 'Overflow unreachable')
: (pp.error ? `ProPresenter unreachable — ${pp.error}` : 'ProPresenter unreachable');
box.append(el('div', 'ot-offline', why));
}
const main = el('div', 'ot-main');
if (cur) {
// The label carries the state and, when there is one, the moment it
// became true; the small lines under the destination are the groups
// moved — or, when the switch failed, what went wrong, in their place
// (a tile has room for one or the other).
let label = `TAP IS ON · since ${clock(cur.at)}`;
let tone = 'ok';
let note = '';
if (cur.pending) {
label = cur.attempts > 1 ? `SWITCHING · attempt ${cur.attempts}` : 'SWITCHING';
tone = 'muted';
} else if (!cur.ok) {
label = `NOT SWITCHED · ${clock(cur.at)}`;
tone = 'alert';
note = cur.error || '';
}
main.dataset.tone = tone;
main.append(el('div', 'ot-label', label));
main.append(el('div', 'ot-keyword', cur.keyword));
if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination || ''));
if (note) main.append(el('div', 'ot-error', note));
else if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · ')));
} else {
main.dataset.tone = 'muted';
main.append(el('div', 'ot-label', 'NO KEYWORD YET'));
main.append(el('div', 'ot-keyword is-none', '—'));
main.append(el('div', 'ot-dest', 'Tap stays where Overflow left it'));
}
box.append(main);
if (on('showSlide')) {
const slide = state.slide || {};
const ann = state.announcement || {};
const slideKeys = Array.isArray(slide.keywords) ? slide.keywords : [];
const annKeys = Array.isArray(ann.keywords) ? ann.keywords : [];
let text;
let cls = 'ot-slide';
if (!pp.reachable) { text = 'ProPresenter: not answering'; cls += ' is-muted'; }
else if (!slide.live && !ann.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; }
else if (slideKeys.length || annKeys.length) {
const parts = [];
if (slideKeys.length) parts.push(`This slide: ${slideKeys.join(' + ')}`);
if (annKeys.length) parts.push(`Announcement: ${annKeys.join(' + ')}`);
text = parts.join(' · ');
cls += ' is-hit';
} else if (slide.live) { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; }
else { text = 'Announcement: no keyword'; cls += ' is-muted'; }
box.append(el('div', cls, text));
}
if (on('showHistory')) {
const rows = (state.history || []).slice(0, HISTORY_SHOWN);
const list = el('ol', 'ot-history');
if (!rows.length) {
list.append(el('li', 'ot-h-empty', cur ? 'No earlier switches' : 'No switches yet'));
}
for (const h of rows) {
const row = el('li', `ot-h-row${h.ok ? '' : ' is-failed'}`);
row.title = h.ok
? `${h.keyword} → ${h.destination || ''}${h.groups && h.groups.length ? ` on ${h.groups.join(', ')}` : ''}`
: `${h.keyword}: ${h.error || 'failed'}`;
row.append(el('span', 'ot-h-time', clock(h.at)));
row.append(el('span', 'ot-h-kw', h.keyword));
row.append(el('span', 'ot-h-dest', h.destination || ''));
row.append(el('span', 'ot-h-mark', h.ok ? '✓' : '✕'));
list.append(row);
}
box.append(list);
}
}
function applyStatus() {
if (!state) return moduleApi.setStatus('connecting', 'Waiting for ProdDash');
const s = state.status === 'ok' || state.status === 'connecting' || state.status === 'error' ? state.status : 'connecting';
moduleApi.setStatus(s, state.message || '');
}
function handleState(e) {
try {
state = JSON.parse(e.data);
} catch {
return;
}
streamDown = false;
applyStatus();
render();
}
async function backfill() {
try {
const res = await moduleApi.fetch('/state');
if (!res.ok) return;
const body = await res.json();
if (!body || !body.state || !stream) return;
state = body.state;
streamDown = false;
applyStatus();
render();
} catch {
/* the stream will bring the next state */
}
}
async function refresh() {
if (!refreshBtn) return;
refreshBtn.classList.add('active');
try {
const res = await moduleApi.fetch('/refresh', { method: 'POST' });
if (!res.ok) {
let why = `Keyword refresh failed (${res.status})`;
try { why = (await res.json()).error || why; } catch { /* keep the status */ }
moduleApi.setStatus('error', why);
}
} catch (err) {
moduleApi.setStatus('error', `Keyword refresh failed: ${err && err.message ? err.message : err}`);
} finally {
refreshBtn.classList.remove('active');
}
}
return {
start() {
moduleApi.setStatus('connecting', 'Connecting');
refreshBtn = moduleApi.header.addButton({
icon: ICON_REFRESH,
title: 'Refresh keywords',
onClick: refresh,
});
stream = moduleApi.sse('/stream', {
// A fresh connection replays the state; a tile joining a stream the
// page already carries gets only `open`, and the server sends state
// on change alone — so backfill, or a second tile waits for the next
// switch to draw anything.
open() { backfill(); },
error() {
moduleApi.setStatus('error', 'Lost the connection to ProdDash');
streamDown = true;
render();
},
events: { state: handleState },
});
render();
},
stop() {
if (stream) stream.close();
stream = null;
refreshBtn = null;
root.textContent = '';
},
onConfigChange() {
render();
},
};
}
+66
View File
@@ -0,0 +1,66 @@
'use strict';
/**
* Keyword matching — which of Overflow's keywords a slide's notes carry.
*
* Pure functions, no I/O, so keywords.selftest.js can pin every rule down:
*
* cleanNotes(raw) → the notes with ProPresenter's invisible padding removed
* keywordMatches(notes, keyword) → boolean
* matchKeywords(notes, keywords) → every keyword (in the order given) the notes carry
* sameSet(a, b) → the two keyword lists hold the same keywords
*
* The rule is Overflow's own helper's: the notes are split into whitespace-
* separated words, each stripped of surrounding punctuation and lower-cased,
* and a keyword fires when it is one of those words. So `give` fires on
* "give", "Give!", "#give" and "(give)", and not on "forgiveness", "giveaway"
* or "give_now". Phrases work too, bounded the same way on both ends —
* something the helper cannot do, but nothing it would have matched is lost.
*/
const ZERO_WIDTH = /[​-‍⁠]/g;
function cleanNotes(raw) {
return String(raw ?? '').replace(ZERO_WIDTH, '').replace(/\r\n?/g, '\n');
}
function norm(s) {
return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
}
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function keywordMatches(notes, keyword) {
const n = cleanNotes(notes);
const k = norm(keyword);
if (!k || !n.trim()) return false;
const flat = n.toLowerCase().replace(/\s+/g, ' ');
const re = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${escapeRe(k)}(?=$|[^\\p{L}\\p{N}_])`, 'u');
return re.test(flat);
}
/** Every keyword the notes carry, in the order the list gives them, each once. Entries may be strings or objects with a `keyword`. */
function matchKeywords(notes, keywords) {
const out = [];
const seen = new Set();
for (const raw of keywords || []) {
const k = typeof raw === 'string' ? raw : raw && raw.keyword;
const key = norm(k);
if (!key || seen.has(key)) continue;
if (keywordMatches(notes, k)) {
seen.add(key);
out.push(String(k).trim());
}
}
return out;
}
function sameSet(a, b) {
const x = [...(a || [])].map(norm).sort();
const y = [...(b || [])].map(norm).sort();
return x.length === y.length && x.every((v, i) => v === y[i]);
}
module.exports = { cleanNotes, norm, keywordMatches, matchKeywords, sameSet };
+76
View File
@@ -0,0 +1,76 @@
'use strict';
/**
* Self-test for keywords.js — the matching rules, pinned.
*
* node modules/overflow-tap/keywords.selftest.js
*/
const assert = require('node:assert/strict');
const { keywordMatches, matchKeywords, sameSet, cleanNotes } = require('./keywords');
let n = 0;
function test(name, fn) {
n += 1;
try {
fn();
console.log(`ok ${name}`);
} catch (err) {
console.log(`FAIL ${name}\n ${err.message}`);
process.exitCode = 1;
}
}
test('a keyword fires as a word of the notes, whatever the case', () => {
assert.equal(keywordMatches('give', 'give'), true);
assert.equal(keywordMatches('Give', 'give'), true);
assert.equal(keywordMatches('please GIVE today', 'give'), true);
});
test('surrounding punctuation is ignored, as the helper strips it', () => {
assert.equal(keywordMatches('give!', 'give'), true);
assert.equal(keywordMatches('#give now', 'give'), true);
assert.equal(keywordMatches('go to (give) now', 'give'), true);
assert.equal(keywordMatches('"give"', 'give'), true);
assert.equal(keywordMatches('give.', 'give'), true);
});
test('a keyword inside another word does not fire', () => {
assert.equal(keywordMatches('a sermon on forgiveness', 'give'), false);
assert.equal(keywordMatches('giveaway', 'give'), false);
assert.equal(keywordMatches('give_now', 'give'), false);
});
test('a phrase keyword is bounded the same way', () => {
assert.equal(keywordMatches('Please fill out your Next Steps card', 'next steps'), true);
assert.equal(keywordMatches('nextsteps', 'next steps'), false);
});
test('regex characters in a keyword are literal', () => {
assert.equal(keywordMatches('give.now', 'give.now'), true);
assert.equal(keywordMatches('giveXnow', 'give.now'), false);
});
test('empty notes never match; zero-width padding is ignored', () => {
assert.equal(keywordMatches('', 'give'), false);
assert.equal(keywordMatches('​', 'give'), false);
assert.equal(cleanNotes('​give'), 'give');
assert.equal(keywordMatches('​give', 'give'), true);
});
test('matchKeywords returns every hit, in list order, once each', () => {
const list = ['connect', 'give', 'prayer', { keyword: 'Events' }];
assert.deepEqual(matchKeywords('give and connect, then events', list), ['connect', 'give', 'Events']);
assert.deepEqual(matchKeywords('just worship', list), []);
assert.deepEqual(matchKeywords('give', []), []);
assert.deepEqual(matchKeywords('give give', ['give', 'GIVE']), ['give']);
});
test('sameSet compares keyword lists regardless of order and case', () => {
assert.equal(sameSet(['give', 'connect'], ['Connect', 'give']), true);
assert.equal(sameSet(['give'], ['give', 'connect']), false);
assert.equal(sameSet([], []), true);
assert.equal(sameSet(['give'], []), false);
});
console.log(`${n} tests, exit ${process.exitCode || 0}`);
+112
View File
@@ -0,0 +1,112 @@
{
"id": "overflow-tap",
"name": "Overflow Tap",
"version": "1.0.0",
"proddash": ">=1.11.0",
"description": "Switches Overflow Tap destinations from keywords in ProPresenter slide notes",
"permissions": [
{
"kind": "local-network",
"reason": "Reaching ProPresenter on this network"
}
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": {
"w": 2,
"h": 2
},
"defaultSize": {
"w": 4,
"h": 3
},
"configGroups": {
"ProPresenter": {
"help": "The ProPresenter whose slide notes carry the keywords. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Only the live slide's notes (and the announcement layer's) are read — nothing on the stage screens changes."
},
"Overflow": {
"collapsed": "whenSet",
"help": "An Overflow Dashboard login with Tap access — what Overflow's own ProPresenter helper app signs in with. Google sign-in does not work here: the account needs a password (Forgot Your Password? on the Dashboard sets one). The keywords, their destinations and their Tap groups are managed in the Dashboard under Integrations › ProPresenter › Keyword Mappings and read from there; after changing them, press the tile's refresh button or wait a few minutes."
},
"Alerts": {
"toggle": "alertEnabled",
"collapsed": true,
"help": "Flash the dashboards listening on this channel when a switch fails — Overflow unreachable or refusing the change — so someone can fix Tap by hand before the moment passes."
}
},
"configSchema": {
"propresenter": {
"type": "endpoint",
"label": "ProPresenter",
"group": "ProPresenter",
"default": {
"host": "",
"port": 1025
}
},
"password": {
"type": "password",
"label": "API password",
"group": "ProPresenter",
"default": "",
"help": "Only if a password is set in ProPresenter's network preferences."
},
"overflowEmail": {
"type": "string",
"label": "Email",
"group": "Overflow",
"default": ""
},
"overflowPassword": {
"type": "password",
"label": "Password",
"group": "Overflow",
"default": ""
},
"nonprofitId": {
"type": "string",
"label": "Nonprofit ID",
"group": "Overflow",
"default": "",
"help": "Leave blank: a church login carries its own. Only an @overflow.co staff login needs one."
},
"alertEnabled": {
"type": "switch",
"label": "Alert when a switch fails",
"group": "Alerts",
"default": false
},
"alertChannel": {
"type": "number",
"label": "Channel",
"group": "Alerts",
"default": 1,
"min": 1,
"max": 6,
"help": "1–6; each dashboard chooses the channels it listens to in its Layouts menu."
}
},
"instanceSchema": {
"showDestination": {
"type": "switch",
"label": "Destination",
"default": true
},
"showGroups": {
"type": "switch",
"label": "Groups",
"default": true
},
"showSlide": {
"type": "switch",
"label": "Slide match",
"default": true
},
"showHistory": {
"type": "switch",
"label": "History",
"default": true
}
}
}
+174
View File
@@ -0,0 +1,174 @@
'use strict';
/**
* Overflow Dashboard API — the calls Overflow's own "ProPresenter Integration"
* helper app (1.2.3) makes, read from its bundle on 2026-10-11. None of this is
* in Overflow's public API reference (which documents only reads for Tap): it
* is the Dashboard's own backend, used with a Dashboard login — the same
* email and password the helper asks for. Google sign-in does not work here;
* the account needs a password (Overflow's article says the same).
*
* POST /nonprofit-dashboard/authentication/login/jwt
* { email, password } → { token } a JWT; its payload carries
* email, nonprofitId, permissions [{ action, subject }] (subject TAP
* or all is required), nonprofits [...], exp
* GET /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/keywords
* → [ { keyword, destinationName, tapGroups: [{ id, tapGroupName }], … } ]
* the Dashboard's Integrations › ProPresenter › Keyword Mappings
* POST /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/keywords?syncNonprofit=true
* { keywords: ["give"] } → the same list, updated THE SWITCH:
* Overflow points the keyword's groups at its destination
* GET /nonprofit-dashboard/propresenter/nonprofits?search=&limit=
* POST /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/select
* only for an @overflow.co staff login, which has no nonprofit of its
* own and must pick one first
*
* Bearer auth on everything after login. A 401 means the session is gone —
* sign in again. The helper's own wording for a refused login is kept here
* (401 invalid email or password, 429 too many attempts, 403 locked).
*
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at
* tools/overflow-tap-mock.js.
*/
const DEFAULT_API_BASE = 'https://server.overflow.co';
const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
const DASH = '/nonprofit-dashboard';
const TIMEOUT_MS = 10000;
const TRIGGER_TIMEOUT_MS = 15000;
class OverflowError extends Error {
constructor(message, { status = 0, retryable = false, auth = false } = {}) {
super(message);
this.name = 'OverflowError';
this.status = status;
this.retryable = retryable;
this.auth = auth; // the session was refused: sign in again before retrying
}
}
/** The JWT's payload, or null when the token is not one. No signature check — we only read our own session. */
function decodeJwt(token) {
const parts = String(token || '').split('.');
if (parts.length !== 3) return null;
try {
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
} catch {
return null;
}
}
/** What the module needs to know about a session, from its token. */
function describeSession(token) {
const p = decodeJwt(token) || {};
const email = String(p.email || '');
const perms = Array.isArray(p.permissions) ? p.permissions : [];
const nonprofits = Array.isArray(p.nonprofits)
? p.nonprofits.map((n) => ({ id: String((n && n.id) || ''), name: String((n && n.name) || '') })).filter((n) => n.id)
: [];
return {
email,
staff: email.toLowerCase().endsWith('@overflow.co'),
nonprofitId: p.nonprofitId ? String(p.nonprofitId) : '',
nonprofits,
hasTap: perms.some((x) => x && (x.subject === 'TAP' || x.subject === 'all')),
expiresAt: Number.isFinite(Number(p.exp)) ? Number(p.exp) * 1000 : 0,
};
}
function createOverflowClient({ fetchImpl = globalThis.fetch } = {}) {
async function request(method, path, { query, body, token, timeout = TIMEOUT_MS } = {}) {
const url = new URL(API_BASE + path);
for (const [k, v] of Object.entries(query || {})) {
if (v === undefined || v === null || v === '') continue;
url.searchParams.set(k, String(v));
}
const headers = { Accept: 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
if (body !== undefined) headers['Content-Type'] = 'application/json';
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeout);
let res;
try {
res = await fetchImpl(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: ctl.signal });
} catch (err) {
const aborted = err && err.name === 'AbortError';
const cause = err && err.cause && (err.cause.code || err.cause.message);
throw new OverflowError(
aborted ? `Overflow did not answer within ${timeout / 1000} s` : `Overflow unreachable: ${err.message}${cause ? ` (${cause})` : ''}`,
{ retryable: true },
);
} finally {
clearTimeout(timer);
}
const text = await res.text();
let data = null;
if (text) {
try { data = JSON.parse(text); } catch { data = null; }
}
if (!res.ok) {
const detail = data && (data.message || data.error || data.detail);
const what = Array.isArray(detail) ? detail.join('; ') : (detail ? String(detail) : '');
throw new OverflowError(what ? `Overflow answered ${res.status}: ${what}` : `Overflow answered ${res.status} ${res.statusText}`.trim(), {
status: res.status,
auth: res.status === 401,
retryable: res.status === 429 || res.status >= 500,
});
}
return data;
}
return {
base: API_BASE,
/** Sign in with a Dashboard login; resolves to the JWT. */
async login(email, password) {
try {
const data = await request('POST', `${DASH}/authentication/login/jwt`, { body: { email, password } });
const token = data && data.token;
if (!token || typeof token !== 'string') throw new OverflowError('Overflow sign-in answered without a token');
return token;
} catch (err) {
if (!(err instanceof OverflowError) || !err.status) throw err;
if (err.status === 401) throw new OverflowError('Overflow refused the sign-in: invalid email or password', { status: 401 });
if (err.status === 429) throw new OverflowError('Overflow refused the sign-in: too many attempts — wait a while', { status: 429, retryable: true });
if (err.status === 403) throw new OverflowError('Overflow refused the sign-in: account locked', { status: 403 });
throw err;
}
},
/** The Keyword Mappings for a nonprofit, as the Dashboard holds them. */
async listKeywords(token, nonprofitId) {
const data = await request('GET', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/keywords`, { token });
return Array.isArray(data) ? data : [];
},
/** Tell Overflow these keywords are on screen; it moves their groups. Resolves to the updated list when one comes back. */
async trigger(token, nonprofitId, keywords) {
const data = await request('POST', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/keywords`, {
query: { syncNonprofit: 'true' },
body: { keywords },
token,
timeout: TRIGGER_TIMEOUT_MS,
});
return Array.isArray(data) ? data : null;
},
/** Staff logins only: the nonprofits this login may act for. */
async searchNonprofits(token, search = '', limit = 10) {
const data = await request('GET', `${DASH}/propresenter/nonprofits`, { token, query: { search, limit } });
const list = (data && data.data && data.data.nonprofits) || (data && data.nonprofits) || (data && data.data) || data;
return Array.isArray(list)
? list.map((n) => ({ id: String((n && n.id) || ''), name: String((n && n.name) || ''), slug: String((n && n.slug) || '') }))
: [];
},
/** Staff logins only: bind the session to one nonprofit before reading its keywords. */
selectNonprofit(token, nonprofitId) {
return request('POST', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/select`, { token, body: {} });
},
};
}
module.exports = { createOverflowClient, OverflowError, decodeJwt, describeSession, API_BASE, DEFAULT_API_BASE };
+648
View File
@@ -0,0 +1,648 @@
'use strict';
/**
* Overflow Tap — server entry.
*
* Does what Overflow's "ProPresenter Integration" helper app does, inside
* ProdDash: watches the live slide in ProPresenter (the presentation layer
* and the announcement layer), and when its notes carry one of the keywords
* mapped in the Overflow Dashboard, tells Overflow — which points that
* keyword's Tap groups at its destination, so what people reach when they
* tap a disc follows what is on screen. A slide with no keyword changes
* nothing; the last destination stays until the next keyworded slide.
*
* GET /state → the view-model every tile draws
* GET /stream → SSE, `state` events on every change (heartbeated)
* POST /refresh → re-read the keyword mappings from the Dashboard now
* GET /keywords → the mappings as read, for a look while setting up
*
* ProPresenter is polled (GET /v1/status/slide for the presentation layer,
* GET /v1/announcement/slide_index for announcements, once a second each —
* small requests, nothing heavier) because its chunked update stream is the
* thing that goes quiet without saying so. Overflow is the Dashboard's own
* API, signed in with a Dashboard login — the calls, and where they come
* from, are at the top of overflow.js. Keyword mappings are read on start,
* every few minutes after that, on /refresh, and from every trigger's answer.
*
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the module at
* tools/overflow-tap-mock.js, which also plays a ProPresenter on :1602.
*/
const crypto = require('crypto');
const { createOverflowClient, OverflowError, describeSession } = require('./overflow');
const { matchKeywords, sameSet, cleanNotes, norm } = require('./keywords');
const POLL_MS = 1000; // ProPresenter, while it answers
const RETRY_MS = 3000; // ProPresenter, while it does not
const PP_TIMEOUT_MS = 4000;
const HEARTBEAT_MS = 15000;
const KEYWORDS_REFRESH_MS = 5 * 60 * 1000;
const OVERFLOW_RETRY_MS = 30 * 1000;
const LOGIN_MIN_GAP_MS = 20 * 1000; // a refused sign-in is not retried sooner (429 = "too many attempts")
const SESSION_MARGIN_MS = 60 * 1000; // sign in again this long before the token expires
const SWITCH_RETRY_MS = 10 * 1000;
const SWITCH_RETRIES = 3;
const HISTORY_MAX = 8; // the tile shows four; a few more for the hover
const ALERT_SECONDS = 30;
/** Set by init(), read by routes() — both are rebuilt together on a config save. */
let current = null;
/**
* The signed-in session, kept across re-inits for the same login: an admin
* saving config five times in a minute must not become five sign-ins
* (Overflow refuses that with 429). Keyed on a hash of the login, so a
* changed email or password starts fresh; the token itself never leaves the
* server process.
*/
let sessionCache = null; // { key, token, expiresAt, email, nonprofitId }
function json(res, code, body) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
}
/** One line for a log or a tile; a fetch failure names its cause (ECONNREFUSED, ENOTFOUND…). */
function describe(err) {
if (err instanceof OverflowError) return err.message;
if (err && err.name === 'AbortError') return 'timed out';
const msg = err && err.message ? err.message : String(err);
const cause = err && err.cause && (err.cause.code || err.cause.message);
return cause && !msg.includes(String(cause)) ? `${msg} (${cause})` : msg;
}
function unique(list) {
const seen = new Set();
const out = [];
for (const v of list) {
const k = norm(v);
if (!k || seen.has(k)) continue;
seen.add(k);
out.push(v);
}
return out;
}
/** The destination and group names Overflow attached to a keyword mapping, whatever the field names turn out to be. */
function destinationOf(entry) {
return String((entry && (entry.destinationName || (entry.destination && entry.destination.name))) || '');
}
function groupsOf(entry) {
const list = entry && Array.isArray(entry.tapGroups) ? entry.tapGroups : [];
return list.map((g) => String((g && (g.tapGroupName || g.name || g.id)) || '')).filter(Boolean);
}
module.exports = {
init({ config, log, shell }) {
/* ── config ────────────────────────────────────────────────────── */
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
const host = String(ep.host || '').trim();
const port = Number(ep.port) || 1025;
const ppPassword = String(config.password || '');
const email = String(config.overflowEmail || '').trim();
const ofPassword = String(config.overflowPassword || '');
const staffNonprofit = String(config.nonprofitId || '').trim();
const configured = Boolean(email && ofPassword);
const alertEnabled = Boolean(config.alertEnabled);
const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1)));
const sessionKey = crypto.createHash('sha256').update(`${email}\n${ofPassword}`).digest('hex');
const overflow = createOverflowClient();
/* ── state ─────────────────────────────────────────────────────── */
const streams = new Set();
const timers = new Set();
let stopped = false;
let lastFrame = '';
let keywordCache = new Map(); // norm(keyword) → the Dashboard's entry
let session = null; // { token, expiresAt, email, nonprofitId }
let lastLoginAt = 0;
let lastLoginError = null;
let wanted = null; // the keywords Tap should be on: { keywords, keyword, destination, groups }
let inFlight = false;
let retriesLeft = 0;
let retryTimer = null;
let alertRaised = false;
// Presentation layer: the slide /v1/status/slide reports.
let lastSlideKey = null;
let lastSlide = null;
let lastPresKeywords = [];
// Announcement layer: the slide /v1/announcement/slide_index points at, read from /v1/announcement/active.
let annSupported = true;
let lastAnnKey = null;
let annNotes = '';
let annLive = false;
let lastAnnKeywords = [];
const state = {
status: 'connecting',
message: '',
propresenter: { configured: Boolean(host), reachable: false, error: '', host, port },
overflow: { configured, reachable: false, error: '', keywords: 0, loadedAt: 0 },
keywords: [],
slide: { live: false, keywords: [], hasNotes: false },
announcement: { live: false, keywords: [] },
current: null,
history: [],
updatedAt: 0,
};
/* ── timers that die with the handle ───────────────────────────── */
function later(fn, ms) {
const t = setTimeout(() => {
timers.delete(t);
if (!stopped) fn();
}, ms);
t.unref?.();
timers.add(t);
return t;
}
/* ── health, shared by the admin line and the tiles ────────────── */
function assess(forAdmin = false) {
const pp = state.propresenter;
const of = state.overflow;
if (!pp.configured) return ['error', 'No ProPresenter host configured — set one in /admin'];
if (!of.configured) return ['error', 'Overflow login not set — add the email and password in /admin'];
if (!of.reachable) return ['error', of.error || 'Signing in to Overflow…'];
if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…'];
if (!state.keywords.length) return ['error', 'No keyword mappings in the Overflow Dashboard (Integrations › ProPresenter)'];
if (state.current && !state.current.pending && !state.current.ok) {
return ['error', `Tap not switched to ${state.current.keyword} — ${state.current.error}`];
}
if (state.current && state.current.pending) return ['connecting', `Switching Tap to ${state.current.keyword}…`];
const n = state.keywords.length;
const who = forAdmin && session && session.email ? `${session.email}, ` : '';
return ['ok', `Following ${host}:${port} · Overflow: ${who}${n} keyword${n === 1 ? '' : 's'}`];
}
function publish() {
if (stopped) return;
[state.status, state.message] = assess(false);
const { updatedAt, ...rest } = state;
let frame;
try {
frame = JSON.stringify(rest);
} catch {
return;
}
if (frame === lastFrame) return;
lastFrame = frame;
state.updatedAt = Date.now();
const payload = `event: state\ndata: ${JSON.stringify(state)}\n\n`;
for (const res of streams) {
try {
res.write(payload);
} catch {
streams.delete(res);
}
}
}
/* ── the Overflow session ──────────────────────────────────────── */
async function ensureSession() {
const now = Date.now();
if (session && now < session.expiresAt - SESSION_MARGIN_MS) return session;
if (sessionCache && sessionCache.key === sessionKey && now < sessionCache.expiresAt - SESSION_MARGIN_MS) {
session = sessionCache;
return session;
}
if (lastLoginError && now - lastLoginAt < LOGIN_MIN_GAP_MS) throw lastLoginError;
lastLoginAt = now;
let token;
try {
token = await overflow.login(email, ofPassword);
} catch (err) {
lastLoginError = err;
throw err;
}
lastLoginError = null;
const info = describeSession(token);
if (!info.hasTap) throw new OverflowError('This Overflow login has no Tap permission — use one with the Organization Admin or Tap Only role');
let nonprofitId = info.nonprofitId || (info.nonprofits.length === 1 ? info.nonprofits[0].id : '');
if (info.staff) {
if (!staffNonprofit) throw new OverflowError('An @overflow.co login has no nonprofit of its own — enter the Nonprofit ID in /admin');
await overflow.selectNonprofit(token, staffNonprofit);
nonprofitId = staffNonprofit;
} else if (!nonprofitId) {
nonprofitId = staffNonprofit;
}
if (!nonprofitId) throw new OverflowError('This Overflow login carries no nonprofit — enter the Nonprofit ID in /admin');
session = { token, expiresAt: info.expiresAt || now + 12 * 3600 * 1000, email: info.email || email, nonprofitId };
sessionCache = { key: sessionKey, ...session };
log(`signed in to Overflow as ${session.email} (nonprofit ${nonprofitId})`);
return session;
}
function dropSession() {
session = null;
if (sessionCache && sessionCache.key === sessionKey) sessionCache = null;
}
/** Run a signed-in call; when the session is refused, sign in again once and retry. */
async function withSession(fn) {
const s = await ensureSession();
try {
return await fn(s);
} catch (err) {
if (!(err instanceof OverflowError) || !err.auth) throw err;
dropSession();
const again = await ensureSession();
return fn(again);
}
}
/* ── the keyword mappings ──────────────────────────────────────── */
function rebuildKeywordView() {
state.keywords = [...keywordCache.values()].map((e) => ({
keyword: String(e.keyword).trim(),
destination: destinationOf(e),
groups: groupsOf(e),
}));
state.overflow.keywords = state.keywords.length;
}
/** Replace the cache with the Dashboard's list (`merge` folds a partial answer into it instead). */
function applyKeywordList(list, merge = false) {
const next = merge ? new Map(keywordCache) : new Map();
for (const entry of list || []) {
if (!entry || typeof entry !== 'object') continue;
const k = norm(entry.keyword);
if (!k) continue;
if (entry.active === false || entry.enabled === false || entry.isActive === false) {
next.delete(k);
continue;
}
next.set(k, entry);
}
keywordCache = next;
rebuildKeywordView();
}
let loading = null;
let refreshTimer = null;
async function loadKeywords() {
if (stopped || !configured) return;
if (loading) return loading;
loading = (async () => {
try {
const list = await withSession((s) => overflow.listKeywords(s.token, s.nonprofitId));
applyKeywordList(list);
state.overflow.reachable = true;
state.overflow.error = '';
state.overflow.loadedAt = Date.now();
reconsider(); // a slide already up when the mappings arrive takes effect now
} catch (err) {
state.overflow.reachable = false;
state.overflow.error = describe(err);
log(`Overflow: ${state.overflow.error}`);
}
publish();
})();
try {
await loading;
} finally {
loading = null;
}
if (stopped) return;
if (refreshTimer) {
clearTimeout(refreshTimer);
timers.delete(refreshTimer);
}
refreshTimer = later(loadKeywords, state.overflow.reachable ? KEYWORDS_REFRESH_MS : OVERFLOW_RETRY_MS);
}
/* ── the switch ────────────────────────────────────────────────── */
function describeTarget(keys) {
const entries = keys.map((k) => keywordCache.get(norm(k))).filter(Boolean);
return {
keywords: keys,
keyword: keys.join(' + '),
destination: unique(entries.map(destinationOf).filter(Boolean)).join(' + '),
groups: unique(entries.flatMap(groupsOf)),
};
}
function pushHistory(entry) {
const { pending, ...rest } = entry;
state.history.unshift(rest);
if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX;
}
function raiseAlert(keyword) {
if (!alertEnabled || !shell || typeof shell.alert !== 'function') return;
alertRaised = true;
Promise.resolve(shell.alert({ channel: alertChannel, level: 'medium', text: `Tap not switched: ${keyword}`, duration: ALERT_SECONDS }))
.catch((err) => log(`alert refused: ${describe(err)}`));
}
function clearAlert() {
if (!alertRaised || !shell || typeof shell.clearAlert !== 'function') return;
alertRaised = false;
Promise.resolve(shell.clearAlert(alertChannel)).catch(() => {});
}
function requestSwitch(target) {
wanted = target;
retriesLeft = SWITCH_RETRIES;
if (retryTimer) {
clearTimeout(retryTimer);
timers.delete(retryTimer);
retryTimer = null;
}
runSwitch();
}
async function runSwitch() {
if (stopped || inFlight || !wanted) return;
const target = wanted;
const cur = state.current;
const sameTarget = cur && sameSet(cur.keywords, target.keywords);
if (sameTarget && cur.ok) return; // already there
inFlight = true;
let entry;
if (sameTarget) {
entry = cur; // another go at the same switch
} else {
if (cur) pushHistory(cur);
entry = {
keyword: target.keyword,
keywords: target.keywords,
destination: target.destination,
groups: target.groups,
at: Date.now(),
ok: false,
pending: true,
error: '',
attempts: 0,
};
state.current = entry;
}
entry.pending = true;
entry.attempts += 1;
publish();
try {
const answer = await withSession((s) => overflow.trigger(s.token, s.nonprofitId, target.keywords));
if (answer) {
applyKeywordList(answer, true);
const fresh = describeTarget(target.keywords);
entry.destination = fresh.destination || entry.destination;
entry.groups = fresh.groups.length ? fresh.groups : entry.groups;
}
entry.ok = true;
entry.error = '';
entry.at = Date.now();
clearAlert();
log(`Tap → ${entry.destination || '?'} (${target.keyword})${entry.groups.length ? ` on ${entry.groups.join(', ')}` : ''}`);
} catch (err) {
if (stopped) return;
entry.ok = false;
entry.error = describe(err);
log(`Tap not switched to ${target.keyword}: ${entry.error}`);
if (retriesLeft > 0) {
retriesLeft -= 1;
retryTimer = later(() => {
retryTimer = null;
runSwitch();
}, SWITCH_RETRY_MS);
}
raiseAlert(target.keyword);
} finally {
entry.pending = false;
inFlight = false;
publish();
if (!stopped && wanted !== target) runSwitch(); // a newer keyword arrived meanwhile
}
}
/* ── what is on screen, and what to do about it ────────────────── */
/**
* The helper's rule, per layer: when the set of keywords the layer's
* slide carries changes to a non-empty set, send it. Returning to a
* keyword after a slide without one sends it again; staying on the same
* keyword across slides sends nothing.
*/
function reconsider() {
const list = [...keywordCache.values()];
const presNotes = lastSlide ? cleanNotes(lastSlide.notes) : '';
const presKeys = lastSlide ? matchKeywords(presNotes, list) : [];
state.slide = { live: Boolean(lastSlide), keywords: presKeys, hasNotes: Boolean(presNotes.trim()) };
const presChanged = !sameSet(presKeys, lastPresKeywords);
if (presChanged) lastPresKeywords = presKeys;
const annKeys = annLive ? matchKeywords(annNotes, list) : [];
state.announcement = { live: annLive, keywords: annKeys };
const annChanged = !sameSet(annKeys, lastAnnKeywords);
if (annChanged) lastAnnKeywords = annKeys;
const batch = [];
if (presChanged && presKeys.length) batch.push(...presKeys);
if (annChanged && annKeys.length) batch.push(...annKeys);
if (batch.length) requestSwitch(describeTarget(unique(batch)));
}
/* ── ProPresenter ──────────────────────────────────────────────── */
function ppUrl(target) {
const u = new URL(`http://${host}:${port}${target}`);
if (ppPassword) u.searchParams.set('password', ppPassword);
return u;
}
async function ppJson(target) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), PP_TIMEOUT_MS);
try {
const res = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, signal: ctl.signal });
if (!res.ok) {
const err = new Error(`ProPresenter ${res.status} ${res.statusText}`);
err.status = res.status;
throw err;
}
const text = await res.text();
return text ? JSON.parse(text) : null;
} finally {
clearTimeout(timer);
}
}
/** The presentation layer: /v1/status/slide names the live slide and carries its notes. */
async function pollSlide() {
const data = await ppJson('/v1/status/slide');
const cur = data && data.current && typeof data.current === 'object' ? data.current : null;
const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : '';
if (key === lastSlideKey) return false;
lastSlideKey = key;
lastSlide = cur;
return true;
}
/**
* The announcement layer: its slide index names the document and the
* slide; the document's slides (every group's, in order, disabled ones
* included — that is the index's space) carry the notes. An older
* ProPresenter without this API answers 404 once and is left alone.
*/
async function pollAnnouncement() {
if (!annSupported) return false;
let idx;
try {
idx = await ppJson('/v1/announcement/slide_index');
} catch (err) {
if (err && err.status === 404) {
annSupported = false;
log('this ProPresenter has no announcement layer API — following the presentation layer only');
return false;
}
throw err;
}
const node = idx && idx.announcement_index;
if (!node || !Number.isInteger(node.index)) {
if (lastAnnKey === '') return false;
lastAnnKey = '';
annLive = false;
annNotes = '';
return true;
}
const id = node.presentation_id || {};
const key = `${id.uuid || ''}\u0000${id.name || ''}\u0000${node.index}`;
if (key === lastAnnKey) return false;
const doc = await ppJson('/v1/announcement/active');
const ann = doc && doc.announcement;
const slides = [];
for (const g of (ann && Array.isArray(ann.groups) ? ann.groups : [])) {
for (const s of (g && Array.isArray(g.slides) ? g.slides : [])) slides.push(s);
}
const slide = slides[node.index];
annNotes = slide ? cleanNotes(slide.notes) : '';
annLive = true;
lastAnnKey = key;
return true;
}
async function pollTick() {
if (stopped) return;
let ok = false;
let changed = false;
try {
changed = await pollSlide();
state.propresenter.reachable = true;
state.propresenter.error = '';
ok = true;
try {
if (await pollAnnouncement()) changed = true;
} catch {
/* the presentation poll already said ProPresenter answers; a hiccup here waits for the next tick */
}
} catch (err) {
state.propresenter.reachable = false;
state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err);
}
if (changed) reconsider();
publish();
later(pollTick, ok ? POLL_MS : RETRY_MS);
}
/* ── go ────────────────────────────────────────────────────────── */
if (host) {
pollTick();
log(`following ProPresenter at ${host}:${port}`);
} else {
log('no ProPresenter host configured — set one in /admin');
}
if (configured) {
loadKeywords();
log(`Overflow at ${overflow.base}`);
} else {
log('Overflow login not set — add the email and password in /admin');
}
publish();
const heartbeat = setInterval(() => {
for (const res of streams) {
try {
res.write(': ping\n\n');
} catch {
streams.delete(res);
}
}
}, HEARTBEAT_MS);
heartbeat.unref?.();
const handle = {
streams,
configured,
getState: () => state,
refresh: () => loadKeywords(),
};
current = handle;
return {
stop() {
stopped = true;
clearInterval(heartbeat);
for (const t of timers) clearTimeout(t);
timers.clear();
clearAlert();
for (const res of streams) {
try { res.end(); } catch { /* already gone */ }
}
streams.clear();
if (current === handle) current = null;
},
health() {
const [status, message] = assess(true);
return { status, message };
},
};
},
routes() {
return {
'GET /state': (req, res) => {
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
json(res, 200, { state: current.getState() });
},
'GET /stream': (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
if (!current) return void res.end();
res.write(`event: state\ndata: ${JSON.stringify(current.getState())}\n\n`);
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
current.streams.add(res);
req.on('close', () => current && current.streams.delete(res));
},
'POST /refresh': async (req, res) => {
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
if (!current.configured) return json(res, 400, { error: 'Overflow login is not set.' });
await current.refresh();
const st = current.getState();
if (!st.overflow.reachable) return json(res, 502, { error: st.overflow.error || 'Overflow unreachable.' });
json(res, 200, { ok: true, keywords: st.overflow.keywords });
},
'GET /keywords': (req, res) => {
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
const st = current.getState();
json(res, 200, { keywords: st.keywords, loadedAt: st.overflow.loadedAt, error: st.overflow.error });
},
};
},
};
+213
View File
@@ -0,0 +1,213 @@
/* Overflow Tap — scoped under its tile via [data-module="overflow-tap"] and
styled with the shell's variables only. Sizes hang off the tile (container
units) so the keyword reads from across a booth on a big tile and still
fits a 2×2. */
[data-module="overflow-tap"] {
container-type: size;
}
[data-module="overflow-tap"] .ot-root {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: stretch;
padding: clamp(4px, 4cqmin, 18px);
gap: clamp(2px, 1.8cqmin, 10px);
overflow: hidden;
color: var(--text);
user-select: none;
-webkit-user-select: none;
}
[data-module="overflow-tap"] .ot-offline {
flex-shrink: 0;
font-size: clamp(0.6rem, 4.6cqmin, 0.9rem);
font-weight: 700;
color: var(--danger);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="overflow-tap"] .ot-root.is-offline .ot-main,
[data-module="overflow-tap"] .ot-root.is-offline .ot-slide,
[data-module="overflow-tap"] .ot-root.is-offline .ot-history {
opacity: 0.45;
filter: grayscale(0.6);
}
/* ── the live keyword ───────────────────────────────────────────── */
[data-module="overflow-tap"] .ot-main {
--tone: var(--accent);
flex: 1 1 auto;
min-height: 0;
container-type: size;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
text-align: center;
overflow: hidden;
}
[data-module="overflow-tap"] .ot-main[data-tone="ok"] { --tone: var(--accent); }
[data-module="overflow-tap"] .ot-main[data-tone="warn"] { --tone: var(--warn); }
[data-module="overflow-tap"] .ot-main[data-tone="alert"] { --tone: var(--danger); }
[data-module="overflow-tap"] .ot-main[data-tone="muted"] { --tone: var(--muted); }
[data-module="overflow-tap"] .ot-label {
color: var(--tone);
font-size: clamp(0.5rem, 9cqh, 0.95rem);
font-weight: 700;
letter-spacing: 0.14em;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
[data-module="overflow-tap"] .ot-keyword {
color: var(--tone);
font-size: min(clamp(1rem, 38cqh, 7rem), 18cqw);
font-weight: 750;
line-height: 1.15;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="overflow-tap"] .ot-keyword.is-none { color: var(--muted); }
[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-keyword {
animation: ot-pulse 1.2s ease-in-out infinite;
}
@keyframes ot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
[data-module="overflow-tap"] .ot-dest {
color: var(--text);
font-size: min(clamp(0.7rem, 16cqh, 1.9rem), 8cqw);
font-weight: 600;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-dest { color: var(--muted); font-weight: 500; }
[data-module="overflow-tap"] .ot-groups {
color: var(--muted);
font-size: clamp(0.5rem, 9cqh, 0.95rem);
line-height: 1.3;
flex-shrink: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="overflow-tap"] .ot-error {
color: var(--danger);
font-size: clamp(0.5rem, 9cqh, 0.95rem);
line-height: 1.3;
max-width: 100%;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
[data-module="overflow-tap"] .ot-error.is-warn { color: var(--warn); }
/* ── this slide ─────────────────────────────────────────────────── */
[data-module="overflow-tap"] .ot-slide {
flex-shrink: 0;
text-align: center;
font-size: clamp(0.55rem, 4.8cqmin, 0.95rem);
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0.25em 0.6em;
border-radius: 999px;
background: color-mix(in srgb, var(--text) 7%, transparent);
align-self: center;
max-width: 100%;
}
[data-module="overflow-tap"] .ot-slide.is-muted { color: var(--muted); }
[data-module="overflow-tap"] .ot-slide.is-hit {
color: var(--accent-text);
background: color-mix(in srgb, var(--accent) 80%, transparent);
font-weight: 650;
}
[data-module="overflow-tap"] .ot-slide.is-warn {
color: var(--warn);
background: color-mix(in srgb, var(--warn) 16%, transparent);
}
/* ── the trail ──────────────────────────────────────────────────── */
[data-module="overflow-tap"] .ot-history {
flex-shrink: 0;
list-style: none;
margin: 0;
padding: 0.3em 0 0;
border-top: 1px solid var(--border);
font-size: clamp(0.55rem, 4.6cqmin, 0.95rem);
display: flex;
flex-direction: column;
gap: 0.1em;
}
[data-module="overflow-tap"] .ot-h-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1.4fr) auto;
align-items: baseline;
gap: 0 0.7em;
color: var(--text);
}
[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-kw,
[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-mark { color: var(--danger); }
[data-module="overflow-tap"] .ot-h-time {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
[data-module="overflow-tap"] .ot-h-kw { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
[data-module="overflow-tap"] .ot-h-dest { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
[data-module="overflow-tap"] .ot-h-mark { color: var(--accent); font-weight: 700; }
[data-module="overflow-tap"] .ot-h-empty { color: var(--muted); text-align: center; }
/* On a short tile the trail would crowd the keyword out: keep the
keyword, drop the trail. Inside the keyword block the same rule: when
it is short, the small lines go before the keyword does. */
@container (max-height: 120px) {
[data-module="overflow-tap"] .ot-history { display: none; }
}
@container (max-height: 70px) {
[data-module="overflow-tap"] .ot-groups,
[data-module="overflow-tap"] .ot-error { display: none; }
}
@container (max-height: 46px) {
[data-module="overflow-tap"] .ot-dest,
[data-module="overflow-tap"] .ot-label { display: none; }
}
[data-module="overflow-tap"] .ot-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 6%;
color: var(--muted);
text-align: center;
font-size: clamp(0.65rem, 5.5cqmin, 1rem);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proddash",
"version": "1.12.1",
"version": "1.12.0",
"private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED",
+11 -18
View File
@@ -1121,11 +1121,6 @@ function parseTar(buf) {
while (off + 512 <= buf.length) {
const h = buf.subarray(off, off + 512);
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 type = h[156] === 0 ? '0' : String.fromCharCode(h[156]);
const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : '';
@@ -1141,7 +1136,7 @@ function parseTar(buf) {
}
const name = pendingName || shortName;
pendingName = null;
if (type === '0') files.set(name, { data: Buffer.from(data), mode });
if (type === '0') files.set(name, Buffer.from(data));
}
return { files, comment };
}
@@ -1170,9 +1165,9 @@ async function fetchArchive() {
}
const { files, comment } = parseTar(zlib.gunzipSync(gz));
const stripped = new Map(); // drop the archive's single top-level folder
for (const [p, entry] of files) {
for (const [p, data] of files) {
const i = p.indexOf('/');
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), entry);
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), data);
}
return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' };
}
@@ -1187,17 +1182,17 @@ async function refreshRemote(force = false) {
const job = (async () => {
try {
const { files, sha } = await fetchArchive();
const pkg = safeJson(files.get('package.json')?.data);
const pkg = safeJson(files.get('package.json'));
const modules = new Map();
for (const [p, file] of files) {
for (const [p, data] of files) {
const m = p.match(/^modules\/([^/]+)\/(.+)$/);
if (!m) continue;
const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() };
entry.files.set(m[2], file);
entry.files.set(m[2], data);
modules.set(m[1], entry);
}
for (const [id, entry] of modules) {
const man = safeJson(entry.files.get('module.json')?.data);
const man = safeJson(entry.files.get('module.json'));
if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) {
modules.delete(id);
continue;
@@ -1327,14 +1322,12 @@ async function installModule(id) {
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
fs.rmSync(staging, { recursive: true, force: true });
const shipped = new Set();
for (const [rel, { data, mode }] of remoteEntry.files) {
for (const [rel, data] of remoteEntry.files) {
if (!safeRelPath(rel)) continue;
shipped.add(rel);
const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true });
// 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 });
fs.writeFileSync(target, data);
}
const dest = path.join(INSTALLED_MODULES_DIR, id);
// Per-machine executables — a capture tool compiled here, or copied in
@@ -1446,11 +1439,11 @@ async function updateShell() {
} else {
const staging = path.join(ROOT, '.proddash-update');
fs.rmSync(staging, { recursive: true, force: true });
for (const [rel, { data, mode }] of remote.files) {
for (const [rel, data] of remote.files) {
if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data, { mode });
fs.writeFileSync(target, data);
}
const newServer = path.join(staging, 'server.js');
if (!fs.existsSync(newServer)) {
+255
View File
@@ -0,0 +1,255 @@
/**
* Mock ProPresenter slide status + mock Overflow Dashboard API, for developing
* the overflow-tap module without a live ProPresenter or an Overflow account.
*
* node tools/overflow-tap-mock.js [--pp=1602] [--overflow=24720] [--step=4000]
* [--login=<email>:<password>] [--expire=<seconds>]
* [--fail-switch] [--slow] [--no-announcements]
*
* ProPresenter side (127.0.0.1:1602 by default) — the routes the module uses,
* with a service that advances every --step ms:
* GET /version reachability probe
* GET /v1/status/slide { current: { uuid, text, notes }, next }, cycling
* through slides whose notes carry keywords
* ("give", "Next steps: connect", "prayer"),
* slides with other notes ("forgiveness" —
* must NOT fire "give"), a slide whose
* keyword is not mapped on the account, and
* a stretch with nothing live.
* GET /v1/announcement/slide_index the announcement layer: a three-slide
* document whose index advances every third
* step; its middle slide carries "events".
* GET /v1/announcement/active that document, groups → slides → notes.
* --no-announcements answers 404 to both,
* like an older ProPresenter.
*
* Overflow side (127.0.0.1:24720 by default; run ProdDash with
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720) — the Dashboard API the
* module speaks (see modules/overflow-tap/overflow.js):
* POST /nonprofit-dashboard/authentication/login/jwt any non-empty email +
* password signs in (--login=<email>:<password> demands that pair,
* answering 401 otherwise); the token expires after --expire seconds
* (3600 by default — set 20 to exercise the sign-in-again path). An
* @overflow.co email gets a staff token with no nonprofit of its own.
* GET /nonprofit-dashboard/propresenter/nonprofits staff: the nonprofits
* POST /nonprofit-dashboard/propresenter/nonprofits/{id}/select
* GET /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords four mappings:
* give → Give Online (every group), connect → Connect Card (Lobby),
* prayer → Prayer Request (every group), events → Events (Lobby, Kids
* Wing). "baptism" is deliberately unmapped.
* POST /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords?syncNonprofit=true
* { keywords } — moves the groups and answers the updated list.
* --fail-switch answers 500 instead, to exercise the failure path.
* Every signed-in call needs a Bearer token this mock issued and has not
* expired, or it answers 401. --slow adds a two-second delay to every Overflow
* answer.
*
* Every incoming request is logged so we can confirm what the module does.
*/
'use strict';
const http = require('http');
const crypto = require('crypto');
const args = process.argv.slice(2);
const opt = (name, dflt) => {
const a = args.find((x) => x.startsWith(`--${name}=`));
return a ? a.slice(name.length + 3) : dflt;
};
const PP_PORT = Number.parseInt(opt('pp', '1602'), 10);
const OF_PORT = Number.parseInt(opt('overflow', '24720'), 10);
const STEP_MS = Number.parseInt(opt('step', '4000'), 10);
const LOGIN = opt('login', '');
const EXPIRE_S = Number.parseInt(opt('expire', '3600'), 10);
const FAIL_SWITCH = args.includes('--fail-switch');
const SLOW = args.includes('--slow');
const NO_ANN = args.includes('--no-announcements');
function sendJson(res, code, body, extra = {}) {
res.writeHead(code, { 'Content-Type': 'application/json', ...extra });
res.end(body === undefined ? '' : JSON.stringify(body));
}
/* ── ProPresenter ────────────────────────────────────────────────── */
const SLIDES = [
{ uuid: 'S1', text: 'Welcome to Waters Church', notes: '' },
{ uuid: 'S2', text: 'Give online', notes: 'give' },
{ uuid: 'S3', text: 'Amazing grace, how sweet the sound', notes: 'Chorus x2' },
{ uuid: 'S4', text: 'Fill out a Connect Card', notes: 'Next steps: connect' },
{ uuid: 'S5', text: 'A sermon on forgiveness', notes: 'forgiveness — do not switch on this' },
{ uuid: 'S6', text: 'Pray with us', notes: 'prayer' },
{ uuid: 'S7', text: 'Baptism Sunday', notes: 'baptism' },
null, // nothing live
{ uuid: 'S8', text: 'Give online (again)', notes: 'give' },
{ uuid: 'S9', text: 'See you next week', notes: '' },
];
const ANNOUNCEMENT = {
id: { uuid: 'ANN-1', name: 'Announcements', index: 0 },
presentation_path: 'Announcements',
groups: [
{ name: 'Loop', slides: [
{ enabled: true, notes: '', text: 'Welcome', label: '' },
{ enabled: true, notes: 'events', text: 'Upcoming events', label: '' },
{ enabled: true, notes: '', text: 'Thanks for coming', label: '' },
] },
],
};
let step = 0;
setInterval(() => { step += 1; }, STEP_MS).unref();
const slideIdx = () => step % SLIDES.length;
const annIdx = () => Math.floor(step / 3) % ANNOUNCEMENT.groups[0].slides.length;
function slidePayload() {
const cur = SLIDES[slideIdx()];
const nxt = SLIDES[(slideIdx() + 1) % SLIDES.length];
return {
current: cur ? { uuid: cur.uuid, text: cur.text, notes: cur.notes } : null,
next: nxt ? { uuid: nxt.uuid, text: nxt.text, notes: nxt.notes } : null,
};
}
http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const p = url.pathname;
console.log(`[pp] ${req.method} ${p}`);
if (p === '/version') return sendJson(res, 200, { name: 'Overflow mock', platform: 'mac', host_description: 'ProPresenter 21 (mock)', api_version: 'v1' });
if (p === '/v1/status/slide') return sendJson(res, 200, slidePayload());
if (p === '/v1/status/layers') return sendJson(res, 200, { slide: Boolean(SLIDES[slideIdx()]), media: false, announcements: !NO_ANN });
if (p === '/v1/announcement/slide_index') {
if (NO_ANN) return sendJson(res, 404, { error: 'not found' });
return sendJson(res, 200, { announcement_index: { index: annIdx(), presentation_id: ANNOUNCEMENT.id } });
}
if (p === '/v1/announcement/active') {
if (NO_ANN) return sendJson(res, 404, { error: 'not found' });
return sendJson(res, 200, { announcement: ANNOUNCEMENT });
}
sendJson(res, 404, { error: 'not found' });
}).listen(PP_PORT, '127.0.0.1', () => {
console.log(`[pp] mock ProPresenter on http://127.0.0.1:${PP_PORT} (slide every ${STEP_MS} ms${NO_ANN ? ', no announcement layer' : ''})`);
});
/* ── Overflow Dashboard ──────────────────────────────────────────── */
const NONPROFIT = { id: '6710f34fd5061afeec3eab00', name: 'Waters Church (mock)', slug: 'waters-church' };
const DESTINATIONS = {
give: { id: '6710f34fd5061afeec3eab01', name: 'Give Online' },
connect: { id: '6710f34fd5061afeec3eab02', name: 'Connect Card' },
prayer: { id: '6710f34fd5061afeec3eab03', name: 'Prayer Request' },
events: { id: '6710f34fd5061afeec3eab04', name: 'Events' },
};
const GROUPS = [
{ id: '6710f34fd5061afeec3eab11', tapGroupName: 'Auditorium', destinationId: DESTINATIONS.events.id },
{ id: '6710f34fd5061afeec3eab12', tapGroupName: 'Lobby', destinationId: DESTINATIONS.events.id },
{ id: '6710f34fd5061afeec3eab13', tapGroupName: 'Kids Wing', destinationId: DESTINATIONS.connect.id },
];
const groupRef = (g) => ({ id: g.id, tapGroupName: g.tapGroupName });
const KEYWORDS = [
{ id: 'kw1', keyword: 'give', destinationId: DESTINATIONS.give.id, destinationName: DESTINATIONS.give.name, tapGroups: GROUPS.map(groupRef), active: true },
{ id: 'kw2', keyword: 'connect', destinationId: DESTINATIONS.connect.id, destinationName: DESTINATIONS.connect.name, tapGroups: [groupRef(GROUPS[1])], active: true },
{ id: 'kw3', keyword: 'prayer', destinationId: DESTINATIONS.prayer.id, destinationName: DESTINATIONS.prayer.name, tapGroups: GROUPS.map(groupRef), active: true },
{ id: 'kw4', keyword: 'events', destinationId: DESTINATIONS.events.id, destinationName: DESTINATIONS.events.name, tapGroups: [groupRef(GROUPS[1]), groupRef(GROUPS[2])], active: true },
];
const stampedKeywords = () => KEYWORDS.map((k) => ({ ...k, nonprofitId: NONPROFIT.id, updatedAt: new Date().toISOString() }));
const tokens = new Map(); // token → { email, exp, nonprofitId }
function issueToken(email) {
const staff = email.toLowerCase().endsWith('@overflow.co');
const now = Math.floor(Date.now() / 1000);
const payload = {
id: 'user-1',
email,
fullName: 'Mock User',
role: staff ? 'overflowSupport' : 'orgAdmin',
permissions: [{ action: 'manage', subject: 'TAP' }],
nonprofits: staff ? [] : [{ id: NONPROFIT.id, name: NONPROFIT.name, slug: NONPROFIT.slug }],
iat: now,
exp: now + EXPIRE_S,
};
if (!staff) payload.nonprofitId = NONPROFIT.id;
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const token = `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.${crypto.randomBytes(16).toString('base64url')}`;
tokens.set(token, { email, exp: payload.exp, nonprofitId: staff ? '' : NONPROFIT.id });
return token;
}
function bearer(req) {
const m = /^Bearer\s+(.+)$/.exec(String(req.headers.authorization || ''));
if (!m) return null;
const t = tokens.get(m[1]);
if (!t) return null;
if (Date.now() / 1000 >= t.exp) return null;
return t;
}
function readBody(req) {
return new Promise((resolve) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
try { resolve(JSON.parse(body || '{}')); } catch { resolve(null); }
});
});
}
http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const p = url.pathname;
const answer = (code, body) => {
const go = () => sendJson(res, code, body);
if (SLOW) setTimeout(go, 2000);
else go();
};
console.log(`[overflow] ${req.method} ${p}${url.search}`);
if (req.method === 'POST' && p === '/nonprofit-dashboard/authentication/login/jwt') {
const body = await readBody(req);
const email = String((body && body.email) || '').trim();
const password = String((body && body.password) || '');
if (!email || !password) return answer(400, { statusCode: 400, message: 'email and password are required' });
if (LOGIN && `${email}:${password}` !== LOGIN) return answer(401, { statusCode: 401, message: 'Invalid email or password' });
console.log(`[overflow] signed in ${email} (token expires in ${EXPIRE_S} s)`);
return answer(200, { token: issueToken(email) });
}
const session = bearer(req);
if (!session) return answer(401, { statusCode: 401, message: 'Unauthorized' });
if (req.method === 'GET' && p === '/nonprofit-dashboard/propresenter/nonprofits') {
return answer(200, { data: { nonprofits: [NONPROFIT] } });
}
const sel = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/select$/.exec(p);
if (req.method === 'POST' && sel) {
if (sel[1] !== NONPROFIT.id) return answer(404, { statusCode: 404, message: 'Nonprofit not found' });
session.nonprofitId = NONPROFIT.id;
return answer(204);
}
const kw = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/keywords$/.exec(p);
if (kw) {
if (kw[1] !== NONPROFIT.id || session.nonprofitId !== NONPROFIT.id) return answer(403, { statusCode: 403, message: 'Forbidden' });
if (req.method === 'GET') return answer(200, stampedKeywords());
if (req.method === 'POST') {
const body = await readBody(req);
const keys = body && Array.isArray(body.keywords) ? body.keywords.map((k) => String(k).toLowerCase().trim()) : [];
if (!keys.length) return answer(400, { statusCode: 400, message: 'keywords is required' });
if (FAIL_SWITCH) return answer(500, { statusCode: 500, message: 'Internal server error' });
for (const k of keys) {
const entry = KEYWORDS.find((x) => x.keyword === k);
if (!entry) {
console.log(`[overflow] "${k}" is not a mapped keyword — ignored`);
continue;
}
for (const g of entry.tapGroups) {
const group = GROUPS.find((x) => x.id === g.id);
if (group) group.destinationId = entry.destinationId;
}
console.log(`[overflow] ${entry.tapGroups.map((g) => g.tapGroupName).join(', ')} → ${entry.destinationName} (${k})`);
}
return answer(200, stampedKeywords());
}
}
answer(404, { statusCode: 404, message: `Cannot ${req.method} ${p}` });
}).listen(OF_PORT, '127.0.0.1', () => {
console.log(`[overflow] mock Overflow Dashboard API on http://127.0.0.1:${OF_PORT}${FAIL_SWITCH ? ' (every switch answers 500)' : ''}${LOGIN ? ' (one login accepted)' : ''}${EXPIRE_S < 3600 ? ` (tokens expire after ${EXPIRE_S} s)` : ''}`);
});
+1 -22
View File
@@ -12,12 +12,7 @@
* 2. a shell update from an archive that carries modules/ never writes
* them into the app folder — they show up in the catalog instead;
* 3. a fresh install lists everything the repo offers, and installing
* 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.
* several at once lands them in the data directory and mounts them.
*
* node tools/unbundled.test.js
*
@@ -75,11 +70,6 @@ function buildArchive(dir) {
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' }));
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', 'third'), 'third', '1.0.0');
const out = path.join(dir, 'main.tar.gz');
@@ -198,17 +188,6 @@ function buildPackagedApp(app) {
const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' });
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');
} catch (err) {
failed = true;