/** * Documenso webhook URL auto-updater. Called by `./scripts/tunnel-url.sh` * when the env flag `DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1` is set so a * freshly-restarted cloudflared quick-tunnel (which gets a NEW hostname * on every restart) doesn't leave Documenso pointing at a dead URL. * * Gated by env flag so production ports — which may have a stable * webhook URL — can never have their config rotated by a stale dev * script. Reads Documenso credentials from env (DOCUMENSO_API_URL + * DOCUMENSO_API_KEY + optional DOCUMENSO_API_VERSION). * * Usage (manual invocation): * DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1 pnpm tsx scripts/update-documenso-webhook.ts https://foo.trycloudflare.com * * Behaviour: * - Lists every webhook currently configured on the Documenso * instance. * - Identifies webhooks whose `webhookUrl` looks like a * trycloudflare.com domain OR matches our `/api/webhooks/documenso` * path suffix. These are the ones to rotate. * - PATCHes each matching webhook to point at the new tunnel URL. * - Leaves all other webhooks alone (in case the instance also * services another tenant or a stable production URL). * * Tries Documenso v2 first, falls back to v1 if the v2 endpoint * returns 404. Both versions support GET /webhook(s) + PATCH on the * webhook resource — the shape differs slightly between them but the * fields we touch (`id`, `webhookUrl`) are stable across versions. */ import 'dotenv/config'; const ENABLE_FLAG = process.env.DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK; const TUNNEL_BASE = process.argv[2]; if (ENABLE_FLAG !== '1') { console.log( 'DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK is not set to 1 — skipping Documenso webhook update.', ); process.exit(0); } if (!TUNNEL_BASE) { console.error('Usage: pnpm tsx scripts/update-documenso-webhook.ts '); console.error( 'Example: pnpm tsx scripts/update-documenso-webhook.ts https://foo.trycloudflare.com', ); process.exit(1); } const API_URL = process.env.DOCUMENSO_API_URL; const API_KEY = process.env.DOCUMENSO_API_KEY; const API_VERSION = (process.env.DOCUMENSO_API_VERSION ?? 'v2').toLowerCase(); if (!API_URL || !API_KEY) { console.error('DOCUMENSO_API_URL and DOCUMENSO_API_KEY must be set in env to update webhooks.'); process.exit(1); } // Trim trailing slash so we can compose paths cleanly. const BASE = API_URL.replace(/\/+$/, ''); const NEW_WEBHOOK_URL = `${TUNNEL_BASE.replace(/\/+$/, '')}/api/webhooks/documenso`; async function documensoRequest(path: string, init?: RequestInit): Promise { return fetch(`${BASE}${path}`, { ...init, headers: { 'Content-Type': 'application/json', Authorization: API_KEY!, ...(init?.headers ?? {}), }, }); } interface DocumensoWebhook { id: string | number; webhookUrl: string; } /** * Pluck the array of webhooks out of whatever shape the Documenso * version returned. v1 historically returned an array directly; v2 * tends to wrap in `{ data: [...] }` or similar. Be tolerant. */ function extractWebhooks(raw: unknown): DocumensoWebhook[] { if (Array.isArray(raw)) return raw as DocumensoWebhook[]; if (raw && typeof raw === 'object') { const r = raw as Record; if (Array.isArray(r.data)) return r.data as DocumensoWebhook[]; if (Array.isArray(r.webhooks)) return r.webhooks as DocumensoWebhook[]; } return []; } async function listWebhooks(): Promise<{ webhooks: DocumensoWebhook[]; version: 'v1' | 'v2' }> { if (API_VERSION === 'v2' || API_VERSION === 'v2.0' || API_VERSION === 'v2.x') { const res = await documensoRequest('/api/v2/webhook'); if (res.ok) { const body = (await res.json()) as unknown; return { webhooks: extractWebhooks(body), version: 'v2' }; } if (res.status !== 404) { console.error(`v2 webhook list returned ${res.status}: ${await res.text()}`); } // Fall through to v1. } const res = await documensoRequest('/api/v1/webhooks'); if (!res.ok) { console.error(`v1 webhook list returned ${res.status}: ${await res.text()}`); process.exit(1); } const body = (await res.json()) as unknown; return { webhooks: extractWebhooks(body), version: 'v1' }; } async function patchWebhook( version: 'v1' | 'v2', webhook: DocumensoWebhook, newUrl: string, ): Promise { const path = version === 'v2' ? '/api/v2/webhook' : `/api/v1/webhooks/${encodeURIComponent(String(webhook.id))}`; const body = version === 'v2' ? { id: webhook.id, webhookUrl: newUrl } : { webhookUrl: newUrl }; const res = await documensoRequest(path, { method: 'PATCH', body: JSON.stringify(body), }); if (!res.ok) { console.error(`PATCH ${path} (id=${webhook.id}) returned ${res.status}: ${await res.text()}`); return false; } return true; } /** * Decide whether a given existing webhook is "ours" (i.e. matches the * pattern we want to rotate). Two signals: * 1. Path tail matches `/api/webhooks/documenso` — the CRM-side * handler we own. * 2. Host matches `*.trycloudflare.com` — almost certainly a stale * quick-tunnel URL. Rotating these is always safe. */ function isRotatableWebhook(w: DocumensoWebhook): boolean { if (!w.webhookUrl) return false; if (w.webhookUrl.endsWith('/api/webhooks/documenso')) return true; try { const host = new URL(w.webhookUrl).hostname; if (host.endsWith('.trycloudflare.com')) return true; } catch { /* malformed — leave alone */ } return false; } async function main(): Promise { console.log(`Listing webhooks via Documenso ${API_VERSION.toUpperCase()} (base: ${BASE})…`); const { webhooks, version } = await listWebhooks(); console.log(`Found ${webhooks.length} webhook(s).`); const rotatable = webhooks.filter(isRotatableWebhook); if (rotatable.length === 0) { console.log( `No rotatable webhooks found (looking for paths ending /api/webhooks/documenso or *.trycloudflare.com hosts).`, ); console.log(`If your dev webhook is configured differently, point it at: ${NEW_WEBHOOK_URL}`); return; } console.log(`Updating ${rotatable.length} webhook(s) to ${NEW_WEBHOOK_URL}…`); let ok = 0; let fail = 0; for (const w of rotatable) { if (w.webhookUrl === NEW_WEBHOOK_URL) { console.log(` ${w.id}: already at the target URL, skipping.`); continue; } const succeeded = await patchWebhook(version, w, NEW_WEBHOOK_URL); if (succeeded) { ok++; console.log(` ${w.id}: ${w.webhookUrl} -> ${NEW_WEBHOOK_URL}`); } else { fail++; } } console.log(`Done. ${ok} updated, ${fail} failed.`); if (fail > 0) process.exit(1); } main().catch((err) => { console.error('Documenso webhook update failed:', err); process.exit(1); });