'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 };