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>
350 lines
18 KiB
JavaScript
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,
|
|
};
|