/** * 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=:] [--expire=] * [--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=: 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)` : ''}`); });