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>
This commit is contained in:
Waters Church
2026-10-11 10:25:02 -04:00
co-authored by Claude Fable 5.1
parent 6b51044be6
commit 459d8b9c38
9 changed files with 738 additions and 622 deletions
+182 -110
View File
@@ -1,46 +1,55 @@
/**
* Mock ProPresenter slide status + mock Overflow Tap API, for developing the
* overflow-tap module without a live ProPresenter or an Overflow account.
* 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]
* [--key=<apiKey>] [--read-only] [--slow]
* [--login=<email>:<password>] [--expire=<seconds>]
* [--fail-switch] [--slow] [--no-announcements]
*
* ProPresenter side (127.0.0.1:1602 by default) — the one route 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" in whole-word mode), a slide whose keyword
* maps to nothing on the account, and a stretch with
* nothing live.
* 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 documented reads with
* paging, and the switch the module sends:
* GET /api/v3/tap/destinations four destinations
* GET /api/v3/tap/groups three groups, each pointing somewhere
* GET /api/v3/tap/groups/{id}
* PATCH /api/v3/tap/groups/{id} { destinationId } — moves the group.
* --read-only answers 404 instead, which
* is what Overflow's public API does
* today, to exercise the failure path.
* Every request must carry x-client-id and x-api-key (any non-empty values;
* --key=<apiKey> demands that exact key, answering 401 otherwise). --slow adds
* a two-second delay to every Overflow answer.
*
* Suggested admin Keywords for this mock:
* give = Give Online
* connect = Connect Card @ Lobby
* prayer = Prayer Request
* baptism = Baptism Signup
* 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) => {
@@ -50,9 +59,16 @@ const opt = (name, 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 KEY = opt('key', '');
const READ_ONLY = args.includes('--read-only');
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 ────────────────────────────────────────────────── */
@@ -68,116 +84,172 @@ const SLIDES = [
{ uuid: 'S8', text: 'Give online (again)', notes: 'give' },
{ uuid: 'S9', text: 'See you next week', notes: '' },
];
let slideIdx = 0;
setInterval(() => { slideIdx = (slideIdx + 1) % SLIDES.length; }, STEP_MS).unref();
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];
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,
};
}
function sendJson(res, code, body, extra = {}) {
res.writeHead(code, { 'Content-Type': 'application/json', ...extra });
res.end(JSON.stringify(body));
}
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: false });
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)`);
console.log(`[pp] mock ProPresenter on http://127.0.0.1:${PP_PORT} (slide every ${STEP_MS} ms${NO_ANN ? ', no announcement layer' : ''})`);
});
/* ── Overflow ────────────────────────────────────────────────────── */
const stamp = (d) => new Date(d).toISOString();
const DESTINATIONS = [
{ id: '6710f34fd5061afeec3eab01', name: 'Give Online', type: 'web', url: 'https://example.org/give', textNumber: null, textContent: null },
{ id: '6710f34fd5061afeec3eab02', name: 'Connect Card', type: 'web', url: 'https://example.org/connect', textNumber: null, textContent: null },
{ id: '6710f34fd5061afeec3eab03', name: 'Prayer Request', type: 'web', url: 'https://example.org/pray', textNumber: null, textContent: null },
{ id: '6710f34fd5061afeec3eab04', name: 'Events', type: 'sms', url: null, textNumber: '+15550100', textContent: 'EVENTS' },
].map((d) => ({ ...d, createdAt: stamp('2026-05-01T17:22:11Z'), updatedAt: stamp('2026-05-04T14:09:32Z'), archivedAt: null }));
/* ── 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', name: 'Auditorium', destinationId: DESTINATIONS[3].id, deviceIds: ['6710f34fd5061afeec3eab21', '6710f34fd5061afeec3eab22'] },
{ id: '6710f34fd5061afeec3eab12', name: 'Lobby', destinationId: DESTINATIONS[3].id, deviceIds: ['6710f34fd5061afeec3eab23'] },
{ id: '6710f34fd5061afeec3eab13', name: 'Kids Wing', destinationId: DESTINATIONS[1].id, deviceIds: [] },
].map((g) => ({ ...g, createdAt: stamp('2026-05-01T17:22:11Z'), updatedAt: stamp('2026-05-04T14:09:32Z'), archivedAt: null }));
{ 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() }));
function destView(id) {
const d = DESTINATIONS.find((x) => x.id === id);
return d ? { id: d.id, name: d.name, type: d.type, url: d.url, textNumber: d.textNumber, textContent: d.textContent } : null;
}
function groupView(g) {
return { ...g, destination: destView(g.destinationId) };
}
function page(list, url) {
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit')) || 25));
const pg = Math.max(1, Number(url.searchParams.get('page')) || 1);
const start = (pg - 1) * limit;
return { data: list.slice(start, start + limit), totalCount: list.length };
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;
}
let served = 0;
http.createServer((req, res) => {
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;
served += 1;
const rl = { 'x-ratelimit-limit': '120', 'x-ratelimit-remaining': String(Math.max(0, 120 - (served % 120))), 'x-ratelimit-reset': '59' };
const answer = (code, body) => {
const go = () => sendJson(res, code, body, rl);
const go = () => sendJson(res, code, body);
if (SLOW) setTimeout(go, 2000);
else go();
};
const cid = String(req.headers['x-client-id'] || '');
const key = String(req.headers['x-api-key'] || '');
console.log(`[overflow] ${req.method} ${p}${url.search}${cid ? '' : ' (no client id)'}${key ? '' : ' (no api key)'}`);
if (!cid || !key || (KEY && key !== KEY)) return answer(401, { statusCode: 401, message: 'Unauthorized' });
console.log(`[overflow] ${req.method} ${p}${url.search}`);
if (req.method === 'GET' && p === '/api/v3/tap/destinations') return answer(200, page(DESTINATIONS, url));
const dOne = /^\/api\/v3\/tap\/destinations\/([^/]+)$/.exec(p);
if (req.method === 'GET' && dOne) {
const d = DESTINATIONS.find((x) => x.id === dOne[1]);
return d ? answer(200, { data: d }) : answer(404, { statusCode: 404, message: 'Destination not found' });
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) });
}
if (req.method === 'GET' && p === '/api/v3/tap/groups') return answer(200, page(GROUPS.map(groupView), url));
const gOne = /^\/api\/v3\/tap\/groups\/([^/]+)$/.exec(p);
if (gOne) {
const g = GROUPS.find((x) => x.id === gOne[1]);
if (!g) return answer(404, { statusCode: 404, message: 'Group not found' });
if (req.method === 'GET') return answer(200, { data: groupView(g) });
if (req.method === 'PATCH' || req.method === 'PUT') {
if (READ_ONLY) return answer(404, { statusCode: 404, message: `Cannot ${req.method} /api/v3/tap/groups/${g.id}` });
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
let parsed = {};
try { parsed = JSON.parse(body || '{}'); } catch { return answer(400, { statusCode: 400, message: 'Invalid JSON' }); }
const dest = DESTINATIONS.find((x) => x.id === parsed.destinationId);
if (!dest) return answer(400, { statusCode: 400, message: 'destinationId must be an existing destination' });
g.destinationId = dest.id;
g.updatedAt = stamp(Date.now());
console.log(`[overflow] ${g.name} → ${dest.name}`);
answer(200, { data: groupView(g) });
});
return;
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());
}
}
if (req.method === 'GET' && p === '/api/v3/tap/devices') {
const devices = GROUPS.flatMap((g) => g.deviceIds.map((id) => ({ id, serialNumber: `TAP-${id.slice(-4)}`, groupId: g.id })));
return answer(200, page(devices, url));
}
if (req.method === 'GET' && p === '/api/v3/tap/events') return answer(200, { data: [], totalCount: 0 });
answer(404, { statusCode: 404, message: `Cannot ${req.method} ${p}` });
}).listen(OF_PORT, '127.0.0.1', () => {
console.log(`[overflow] mock Overflow API on http://127.0.0.1:${OF_PORT}${READ_ONLY ? ' (read-only: PATCH answers 404)' : ''}${KEY ? ' (api key required)' : ''}`);
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)` : ''}`);
});