Files
hope-events/backend/src/utils/webhooks.js
T
joshuaandClaude Opus 5.5 c0d01ce993 Outbound webhooks (1.48.0)
Site settings -> Webhooks: admins add https endpoints and choose event
types (registration created/paid/cancelled, payment received/refunded,
ticket checked in, account created, event published). Events are
noticed by a Prisma client extension so every write path is covered,
re-checked before sending, and delivered as signed JSON
(X-CrossEvents-Signature, HMAC-SHA256) with retries, a 30-day delivery
log, Send test and Resend. Public https addresses only; each endpoint
is added to the operator register; kids and volunteer tickets are never
sent. New `webhooks` job. Migration 20261028160000_webhooks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:00:59 +02:00

350 lines
18 KiB
JavaScript

/**
* Outbound webhooks (1.48.0): admins add endpoints (Site settings → Webhooks) and choose event
* types; what happens here is POSTed to them as JSON, signed with the endpoint's secret.
*
* - Events are noticed in one place, a Prisma client extension (config/db.js), so every path that
* creates a registration, marks it paid, records a payment, checks a ticket in… is covered.
* - `notice` only queues (in memory); about a second later `flush` reads the record again — a
* transaction that rolled back, or a status that changed back, sends nothing — builds the
* payload and stores one WebhookDelivery per endpoint that wants the type, then sends them.
* - Sending: `X-CrossEvents-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">`
* (like Stripe), 10 s timeout, redirects not followed, 2xx = delivered. Failures are retried
* after 1 min, 5 min, 30 min, 2 h and 12 h, then marked failed (Resend on the delivery log).
* - Only https URLs on public addresses (no SSRF into the server's own network), unless
* WEBHOOKS_ALLOW_PRIVATE=true (local testing).
* - Never sent: kids check-in and volunteer tickets (children's data stays here).
* - Deliveries hold personal information, so they're kept 30 days and removed with an account.
*/
const crypto = require('crypto');
const dns = require('dns').promises;
const net = require('net');
const prisma = require('../config/db');
const { encrypt, decrypt } = require('./encryption');
const EVENT_TYPES = [
{ type: 'registration.created', label: 'Registration made', description: 'Someone registers (online, at the door, by staff or with a season pass).' },
{ type: 'registration.paid', label: 'Registration paid', description: 'A registration is paid in full — or made free.' },
{ type: 'registration.cancelled', label: 'Registration cancelled', description: 'A registration is cancelled.' },
{ type: 'payment.received', label: 'Payment received', description: 'Any payment or donation is recorded (online or by staff).' },
{ type: 'payment.refunded', label: 'Payment refunded', description: 'A refund is recorded.' },
{ type: 'ticket.checked_in', label: 'Ticket checked in', description: 'A ticket is scanned or ticked present.' },
{ type: 'user.created', label: 'Account created', description: 'A new account is made.' },
{ type: 'event.published', label: 'Event published', description: 'An event becomes public (made active and not hidden).' },
];
const TYPES = new Set(EVENT_TYPES.map(t => t.type));
const RETRY_AFTER_MS = [60e3, 5 * 60e3, 30 * 60e3, 2 * 3600e3, 12 * 3600e3];
const MAX_ATTEMPTS = RETRY_AFTER_MS.length + 1;
const KEEP_DAYS = 30;
const TIMEOUT_MS = 10000;
function webhookError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
const allowPrivate = () => String(process.env.WEBHOOKS_ALLOW_PRIVATE || '').toLowerCase() === 'true';
// ─── Endpoint addresses ──────────────────────────────────────────────────────
/** Whether an IP address is private, loopback, link-local or otherwise not on the internet. */
function isPrivateAddress(ip) {
if (net.isIPv4(ip)) {
const [a, b] = ip.split('.').map(Number);
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224;
}
if (net.isIPv6(ip)) {
const v = ip.toLowerCase();
if (v === '::1' || v === '::') return true;
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v);
if (mapped) return isPrivateAddress(mapped[1]);
return /^f[cd]/.test(v) || /^fe[89ab]/.test(v);
}
return true;
}
/** A cleaned endpoint URL, or an error: https (http only for local testing), no user:password. */
function cleanUrl(raw) {
let u;
try { u = new URL(String(raw || '').trim()); } catch { throw webhookError('Enter the full address, starting with https://'); }
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && allowPrivate())) throw webhookError('The address must start with https://');
if (u.username || u.password) throw webhookError('Leave the user name and password out of the address');
if (!allowPrivate() && (u.hostname === 'localhost' || (net.isIP(u.hostname) && isPrivateAddress(u.hostname)))) {
throw webhookError('That address is on a private network — webhooks only go to public addresses');
}
return u.toString();
}
/** Refuses a host that resolves to a private address (checked at every send: DNS can change). */
async function assertPublicHost(hostname) {
if (allowPrivate()) return;
const addresses = net.isIP(hostname) ? [{ address: hostname }] : await dns.lookup(hostname, { all: true });
if (!addresses.length || addresses.some(a => isPrivateAddress(a.address))) {
throw webhookError(`${hostname} points to a private network address`);
}
}
// ─── Signing ─────────────────────────────────────────────────────────────────
const newSecret = () => `whsec_${crypto.randomBytes(24).toString('base64url')}`;
/** The signature header for a body: t=<seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">. */
function signatureHeader(secret, body, at = Date.now()) {
const t = Math.floor(at / 1000);
const v1 = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
return `t=${t},v1=${v1}`;
}
// ─── Endpoints (cached for `notice`) ─────────────────────────────────────────
let endpointCache = null;
let endpointCacheAt = 0;
async function activeEndpoints() {
if (!endpointCache || Date.now() - endpointCacheAt > 60e3) {
endpointCache = await prisma.webhookEndpoint.findMany({ where: { isActive: true }, select: { id: true, events: true } });
endpointCacheAt = Date.now();
}
return endpointCache;
}
function forgetEndpoints() { endpointCache = null; }
// ─── Noticing events ─────────────────────────────────────────────────────────
const queue = [];
let flushTimer = null;
const FLUSH_DELAY_MS = 1500;
/**
* Something happened (from the Prisma extension). Queued and checked again shortly; never throws.
* `ref` is the record's id.
*/
function notice(type, ref) {
if (!TYPES.has(type) || !ref) return;
if (String(process.env.WEBHOOKS_ENABLED || 'true').toLowerCase() === 'false') return;
queue.push({ type, ref, at: new Date() });
if (!flushTimer) {
flushTimer = setTimeout(() => { flushTimer = null; flush().catch(e => console.warn('[webhooks] flush:', e?.message || e)); }, FLUSH_DELAY_MS);
flushTimer.unref?.();
}
}
/** Builds and stores the deliveries for what was noticed, then sends them. */
async function flush() {
const items = queue.splice(0, queue.length);
if (!items.length) return [];
const endpoints = await activeEndpoints();
const made = [];
for (const item of items) {
const targets = endpoints.filter(e => e.events.includes(item.type));
if (!targets.length) continue;
let built;
try { built = await buildPayload(item.type, item.ref); } catch (e) { console.warn(`[webhooks] ${item.type} ${item.ref}:`, e?.message || e); continue; }
if (!built) continue;
const eventId = `evt_${crypto.randomUUID()}`;
const payload = { id: eventId, type: item.type, createdAt: item.at.toISOString(), data: built.data };
for (const ep of targets) {
made.push(await prisma.webhookDelivery.create({ data: { endpointId: ep.id, eventId, type: item.type, payload, userId: built.userId || null } }));
}
}
for (const d of made) await send(d.id).catch(e => console.warn('[webhooks] send:', e?.message || e));
return made;
}
// ─── Payloads ────────────────────────────────────────────────────────────────
const frontendUrl = () => String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const person = u => (u ? { id: u.id, name: u.name, email: u.email, phone: u.phoneNumber || null } : null);
const eventRef = e => (e ? { id: e.id, title: e.title, startDate: e.startDate, endDate: e.endDate } : null);
const reservedIds = e => new Set([e?.kidsOptionId, e?.volunteerOptionId].filter(Boolean));
const EVENT_SELECT = { id: true, title: true, startDate: true, endDate: true, kidsOptionId: true, volunteerOptionId: true };
const USER_SELECT = { id: true, name: true, email: true, phoneNumber: true };
async function registrationData(id, status) {
const r = await prisma.registration.findUnique({
where: { id },
include: {
event: { select: EVENT_SELECT }, user: { select: USER_SELECT }, payments: true,
registrationOptions: { include: { eventOption: { select: { id: true, name: true } }, variant: { select: { name: true } }, tranches: true } },
},
});
if (!r || (status && r.status !== status)) return null;
// Kids check-in and volunteer places are internal (and can be about children): never sent.
const reserved = reservedIds(r.event);
if (r.registrationOptions.length && r.registrationOptions.every(o => reserved.has(o.eventOptionId))) return null;
const { computeRegistrationTotalDue } = require('./pricing');
const totalDue = Math.round(computeRegistrationTotalDue(r, new Date()) * 100) / 100;
const paid = Math.round(r.payments.reduce((s, p) => s + p.amount, 0) * 100) / 100;
return {
userId: r.userId,
data: {
registration: {
id: r.id, status: r.status, createdAt: r.createdAt, totalDue, amountPaid: paid,
seasonPassPurchaseId: r.seasonPassPurchaseId || null,
items: r.registrationOptions.filter(o => !reserved.has(o.eventOptionId)).map(o => ({ ticket: o.eventOption?.name, variant: o.variant?.name || null, quantity: o.quantity, unitPrice: o.priceSnapshot })),
},
event: eventRef(r.event),
person: person(r.user),
},
};
}
async function buildPayload(type, ref) {
switch (type) {
case 'registration.created': return registrationData(ref);
case 'registration.paid': return registrationData(ref, 'paid');
case 'registration.cancelled': return registrationData(ref, 'cancelled');
case 'payment.received':
case 'payment.refunded': {
const p = await prisma.payment.findUnique({ where: { id: ref }, include: { user: { select: USER_SELECT }, event: { select: EVENT_SELECT } } });
if (!p) return null;
return {
userId: p.userId,
data: {
payment: { id: p.id, amount: p.amount, method: p.method, provider: p.provider || null, isDonation: p.isDonation, registrationId: p.registrationId, createdAt: p.createdAt },
event: eventRef(p.event),
person: person(p.user),
},
};
}
case 'ticket.checked_in': {
const u = await prisma.ticketUsage.findUnique({
where: { id: ref },
include: {
session: { select: { id: true, label: true, startsAt: true } },
ticket: { include: { event: { select: EVENT_SELECT }, user: { select: USER_SELECT }, holder: { select: USER_SELECT }, attendee: { select: { name: true } }, registrationOption: { select: { eventOptionId: true } } } },
},
});
if (!u) return null;
if (reservedIds(u.ticket.event).has(u.ticket.registrationOption?.eventOptionId)) return null;
// A transferred ticket belongs to its holder now.
const who = u.ticket.holder || u.ticket.user;
return {
userId: who?.id || u.ticket.userId,
data: {
checkIn: { id: u.id, scannedAt: u.scannedAt, people: u.quantityRedeemed, manual: u.manual, offline: u.offline, session: u.session || null },
ticket: { id: u.ticket.id, registrationOptionId: u.ticket.registrationOptionId, holderName: u.ticket.attendee?.name || who?.name || null },
event: eventRef(u.ticket.event),
person: person(who),
},
};
}
case 'user.created': {
const user = await prisma.user.findUnique({ where: { id: ref }, select: { ...USER_SELECT, role: true, createdAt: true } });
if (!user) return null;
return { userId: user.id, data: { person: { ...person(user), role: user.role, createdAt: user.createdAt } } };
}
case 'event.published': {
const e = await prisma.event.findUnique({
where: { id: ref },
select: { ...EVENT_SELECT, description: true, location: true, price: true, isActive: true, isHidden: true, goLiveAt: true, campus: { select: { name: true, slug: true } }, series: { select: { name: true, slug: true } } },
});
if (!e || !e.isActive || e.isHidden) return null;
return {
userId: null,
data: {
event: {
id: e.id, title: e.title, description: e.description, startDate: e.startDate, endDate: e.endDate, location: e.location,
price: e.price, goLiveAt: e.goLiveAt, url: `${frontendUrl()}/events/${e.id}`, campus: e.campus || null, series: e.series || null,
},
},
};
}
default: return null;
}
}
// ─── Sending ─────────────────────────────────────────────────────────────────
/** The wait before the next try after `attempts` failed tries, or null when it's given up. Pure. */
function retryDelay(attempts) {
return attempts < MAX_ATTEMPTS ? RETRY_AFTER_MS[attempts - 1] : null;
}
/** POSTs one delivery (claimed first, so two workers never send it twice). */
async function send(deliveryId) {
const claimed = await prisma.webhookDelivery.updateMany({ where: { id: deliveryId, status: { in: ['pending', 'retrying'] } }, data: { status: 'sending', nextAttemptAt: new Date() } });
if (!claimed.count) return null;
const d = await prisma.webhookDelivery.findUnique({ where: { id: deliveryId }, include: { endpoint: true } });
const attempts = d.attempts + 1;
const body = JSON.stringify(d.payload);
let statusCode = null;
let error = null;
try {
if (!d.endpoint.isActive) throw webhookError('The endpoint is switched off');
const url = new URL(cleanUrl(d.endpoint.url));
await assertPublicHost(url.hostname);
const res = await fetch(url, {
method: 'POST',
redirect: 'manual',
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: {
'Content-Type': 'application/json',
'User-Agent': 'CrossEvents-Webhooks/1',
'X-CrossEvents-Event': d.type,
'X-CrossEvents-Delivery': d.id,
'X-CrossEvents-Signature': signatureHeader(decrypt(d.endpoint.secret), body),
},
body,
});
statusCode = res.status;
if (res.status < 200 || res.status >= 300) error = `Answered ${res.status}${res.status >= 300 && res.status < 400 ? ' (redirects are not followed)' : ''}`;
} catch (e) {
error = e?.name === 'TimeoutError' ? `No answer within ${TIMEOUT_MS / 1000} seconds` : (e?.message || String(e));
}
const now = new Date();
const ok = !error;
const wait = ok ? null : retryDelay(attempts);
const updated = await prisma.webhookDelivery.update({
where: { id: d.id },
data: {
attempts, lastStatusCode: statusCode, lastError: error ? String(error).slice(0, 500) : null,
status: ok ? 'succeeded' : wait ? 'retrying' : 'failed',
deliveredAt: ok ? now : null,
nextAttemptAt: wait ? new Date(now.getTime() + wait) : now,
},
});
await prisma.webhookEndpoint.update({ where: { id: d.endpointId }, data: ok ? { lastSuccessAt: now } : { lastFailureAt: now } }).catch(() => {});
return updated;
}
/** The `webhooks` job: sends what's due, picks up sends cut off by a restart, prunes old deliveries. */
async function runJob(now = new Date()) {
// A send is claimed with nextAttemptAt = now; one still "sending" 5 minutes later was cut off.
await prisma.webhookDelivery.updateMany({ where: { status: 'sending', nextAttemptAt: { lt: new Date(now.getTime() - 5 * 60e3) } }, data: { status: 'retrying' } });
const due = await prisma.webhookDelivery.findMany({ where: { status: { in: ['pending', 'retrying'] }, nextAttemptAt: { lte: now } }, orderBy: { nextAttemptAt: 'asc' }, take: 100, select: { id: true } });
let sent = 0;
let failed = 0;
for (const { id } of due) {
const r = await send(id).catch(() => null);
if (r?.status === 'succeeded') sent++;
else if (r) failed++;
}
const pruned = await prisma.webhookDelivery.deleteMany({ where: { createdAt: { lt: new Date(now.getTime() - KEEP_DAYS * 864e5) } } });
return { due: due.length, sent, failed, pruned: pruned.count };
}
/** Sends a delivery again now (a fresh try, whatever happened before). */
async function resend(deliveryId) {
const d = await prisma.webhookDelivery.findUnique({ where: { id: deliveryId }, select: { id: true } });
if (!d) throw webhookError('Delivery not found', 404);
await prisma.webhookDelivery.update({ where: { id: d.id }, data: { status: 'pending', attempts: 0, nextAttemptAt: new Date() } });
return send(d.id);
}
/** A "webhook.test" delivery to one endpoint, sent at once. */
async function sendTest(endpointId) {
const eventId = `evt_${crypto.randomUUID()}`;
const payload = { id: eventId, type: 'webhook.test', createdAt: new Date().toISOString(), data: { message: 'A test from Cross Events. If you can read this, the webhook works.' } };
const d = await prisma.webhookDelivery.create({ data: { endpointId, eventId, type: 'webhook.test', payload } });
return send(d.id);
}
module.exports = {
EVENT_TYPES, TYPES, MAX_ATTEMPTS, KEEP_DAYS,
isPrivateAddress, cleanUrl, assertPublicHost, newSecret, signatureHeader, encrypt,
notice, flush, buildPayload, retryDelay, send, runJob, resend, sendTest, forgetEndpoints, webhookError,
};