2026-10-07 14:00:37 -04:00
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-11 10:25:02 -04:00
|
|
|
|
* 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).
|
2026-10-07 14:00:37 -04:00
|
|
|
|
*
|
2026-10-11 10:25:02 -04:00
|
|
|
|
* 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
|
2026-10-07 14:00:37 -04:00
|
|
|
|
*
|
2026-10-11 10:25:02 -04:00
|
|
|
|
* 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).
|
2026-10-07 14:00:37 -04:00
|
|
|
|
*
|
2026-10-11 10:25:02 -04:00
|
|
|
|
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at
|
|
|
|
|
|
* tools/overflow-tap-mock.js.
|
2026-10-07 14:00:37 -04:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
const DEFAULT_API_BASE = 'https://server.overflow.co';
|
|
|
|
|
|
const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const DASH = '/nonprofit-dashboard';
|
2026-10-07 14:00:37 -04:00
|
|
|
|
const TIMEOUT_MS = 10000;
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const TRIGGER_TIMEOUT_MS = 15000;
|
2026-10-07 14:00:37 -04:00
|
|
|
|
|
|
|
|
|
|
class OverflowError extends Error {
|
2026-10-11 10:25:02 -04:00
|
|
|
|
constructor(message, { status = 0, retryable = false, auth = false } = {}) {
|
2026-10-07 14:00:37 -04:00
|
|
|
|
super(message);
|
|
|
|
|
|
this.name = 'OverflowError';
|
|
|
|
|
|
this.status = status;
|
|
|
|
|
|
this.retryable = retryable;
|
2026-10-11 10:25:02 -04:00
|
|
|
|
this.auth = auth; // the session was refused: sign in again before retrying
|
2026-10-07 14:00:37 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
/** 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;
|
|
|
|
|
|
}
|
2026-10-07 14:00:37 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
/** 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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-10-07 14:00:37 -04:00
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
function createOverflowClient({ fetchImpl = globalThis.fetch } = {}) {
|
|
|
|
|
|
async function request(method, path, { query, body, token, timeout = TIMEOUT_MS } = {}) {
|
2026-10-07 14:00:37 -04:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const headers = { Accept: 'application/json' };
|
|
|
|
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
2026-10-07 14:00:37 -04:00
|
|
|
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
|
|
|
|
const ctl = new AbortController();
|
2026-10-11 10:25:02 -04:00
|
|
|
|
const timer = setTimeout(() => ctl.abort(), timeout);
|
2026-10-07 14:00:37 -04:00
|
|
|
|
let res;
|
|
|
|
|
|
try {
|
2026-10-11 10:25:02 -04:00
|
|
|
|
res = await fetchImpl(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: ctl.signal });
|
2026-10-07 14:00:37 -04:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
const aborted = err && err.name === 'AbortError';
|
|
|
|
|
|
const cause = err && err.cause && (err.cause.code || err.cause.message);
|
2026-10-11 10:25:02 -04:00
|
|
|
|
throw new OverflowError(
|
|
|
|
|
|
aborted ? `Overflow did not answer within ${timeout / 1000} s` : `Overflow unreachable: ${err.message}${cause ? ` (${cause})` : ''}`,
|
|
|
|
|
|
{ retryable: true },
|
|
|
|
|
|
);
|
2026-10-07 14:00:37 -04:00
|
|
|
|
} 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);
|
2026-10-11 10:25:02 -04:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-10-07 14:00:37 -04:00
|
|
|
|
}
|
|
|
|
|
|
return data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
base: API_BASE,
|
2026-10-11 10:25:02 -04:00
|
|
|
|
|
|
|
|
|
|
/** 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;
|
|
|
|
|
|
}
|
2026-10-07 14:00:37 -04:00
|
|
|
|
},
|
2026-10-11 10:25:02 -04:00
|
|
|
|
|
|
|
|
|
|
/** 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: {} });
|
2026-10-07 14:00:37 -04:00
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
module.exports = { createOverflowClient, OverflowError, decodeJwt, describeSession, API_BASE, DEFAULT_API_BASE };
|