'use strict'; /** * Overflow API client — the Tap part of Overflow's public REST API. * * https://docs.overflow.co/api-reference (OpenAPI: server.stage.overflow.co/api/docs/openapi.json) * * Authentication is two headers, `x-client-id` and `x-api-key`, from the * Overflow Dashboard's Developers › API Keys. 120 requests per minute per * client; a 429 is answered when the window is spent. * * Reads used here are documented: GET /api/v3/tap/destinations, * GET /api/v3/tap/groups and GET /api/v3/tap/groups/{id} (a group carries the * `destinationId` it currently points at). * * THE SWITCH IS NOT (as of 2026-10-07). The public reference lists reads only * for Tap; Overflow's own ProPresenter helper changes a group's destination * through the Dashboard's private API after signing in with a Dashboard * login. `setGroupDestination` below sends the natural REST shape for this * API — PATCH the group with the new `destinationId`, as PATCH /donors/{id} * updates a donor — and the server confirms every switch by reading the group * back, so an answer that changes nothing is reported, not believed. If * Overflow publishes (or confirms) a different route, this one function is * the only place to change. tools/overflow-tap-mock.js serves the same shape. * * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at that mock. */ const DEFAULT_API_BASE = 'https://server.overflow.co'; const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, ''); const TIMEOUT_MS = 10000; const PAGE_SIZE = 100; const MAX_PAGES = 20; const ID_RE = /^[0-9a-f]{24}$/i; class OverflowError extends Error { constructor(message, { status = 0, retryable = false } = {}) { super(message); this.name = 'OverflowError'; this.status = status; this.retryable = retryable; } } function looksLikeId(s) { return ID_RE.test(String(s || '').trim()); } function createOverflowClient({ clientId, apiKey, fetchImpl = globalThis.fetch }) { const id = String(clientId || '').trim(); const key = String(apiKey || '').trim(); async function request(method, path, { query, body } = {}) { if (!id || !key) throw new OverflowError('Overflow client ID and API key are not set', { status: 0 }); 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', 'x-client-id': id, 'x-api-key': key }; if (body !== undefined) headers['Content-Type'] = 'application/json'; const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS); 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_MS / 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) : res.statusText); let message; if (res.status === 401 || res.status === 403) message = `Overflow refused the credentials (${res.status}${what ? `: ${what}` : ''})`; else if (res.status === 404 || res.status === 405) message = `Overflow has no ${method} ${path} (${res.status})`; else if (res.status === 429) message = 'Overflow is rate-limiting (120 requests a minute)'; else message = `Overflow answered ${res.status}${what ? `: ${what}` : ''}`; throw new OverflowError(message, { status: res.status, retryable: res.status === 429 || res.status >= 500 }); } return data; } /** Every page of a collection, newest-first as the API sorts it. */ async function listAll(path, query = {}) { const all = []; for (let page = 1; page <= MAX_PAGES; page += 1) { const res = await request('GET', path, { query: { ...query, limit: PAGE_SIZE, page } }); const data = Array.isArray(res && res.data) ? res.data : []; all.push(...data); const total = Number(res && res.totalCount); if (data.length < PAGE_SIZE || (Number.isFinite(total) && all.length >= total)) break; } return all; } return { base: API_BASE, configured: Boolean(id && key), listDestinations: () => listAll('/api/v3/tap/destinations'), listGroups: () => listAll('/api/v3/tap/groups'), async getGroup(groupId) { const res = await request('GET', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`); return res && res.data ? res.data : res; }, /** See the note at the top of this file: the one unverified call. */ setGroupDestination(groupId, destinationId) { return request('PATCH', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`, { body: { destinationId } }); }, }; } module.exports = { createOverflowClient, OverflowError, looksLikeId, API_BASE, DEFAULT_API_BASE };