SPB Git

Repository Public

webhook.ts 2.9 KB 35 lines typescript

1// Optional outgoing webhook (off by default): one HTTPS POST per alert to a URL the owner configured, signed with a shared secret.2//   X-SPW-Timestamp: <unix seconds>      X-SPW-Signature: v1=<hex HMAC-SHA256(secret, "<timestamp>.<body>")>3// The receiver recomputes the HMAC over the raw body and refuses a timestamp older than a few minutes (replay).4// What is sent is exactly `webhookBody()`: without "details" there is no amount, price, address or reason text.5import { createHmac, timingSafeEqual } from 'node:crypto';6import { renderAlert, safeParams, type AlertParams, type AlertSeverity } from '@spw/core';78export interface WebhookAlert { id: string; ts: Date; kind: string; severity: AlertSeverity; params: AlertParams; count: number }910export function webhookBody(a: WebhookAlert, details: boolean): string {11  const text = renderAlert(a.kind, { ...a.params, count: a.params.count ?? a.count }, 'en', details);12  return JSON.stringify({ app: 'sp-wallet', id: Number(a.id), ts: a.ts.toISOString(), kind: a.kind, severity: a.severity, title: text.title, body: text.body,13    params: details ? a.params : safeParams(a.params) });14}15export const webhookSignature = (secret: string, timestamp: number, body: string): string => `v1=${createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')}`;16/** what a receiver does (used by tests and documented in docs/v6/ALERTS.md) */17export function verifyWebhook(secret: string, timestamp: string, signature: string, body: string, now = Date.now(), toleranceSec = 300): boolean {18  const t = Number(timestamp);19  if (!Number.isInteger(t) || Math.abs(now / 1000 - t) > toleranceSec) return false;20  const want = Buffer.from(webhookSignature(secret, t, body)), got = Buffer.from(signature);21  return want.length === got.length && timingSafeEqual(want, got);22}23export function webhookUrlOk(url: string): boolean {24  try { const u = new URL(url); return u.protocol === 'https:' && !u.username && !u.password; } catch { return false; }25}26export async function sendWebhook(url: string, secret: string, a: WebhookAlert, details: boolean, o: { now?: number; fetch?: typeof fetch; allowHttp?: boolean } = {}): Promise<{ ok: boolean; status: number; error?: string }> {27  if (!o.allowHttp && !webhookUrlOk(url)) return { ok: false, status: 0, error: 'the webhook URL must be https' };28  const body = webhookBody(a, details), ts = Math.floor((o.now ?? Date.now()) / 1000);29  try {30    const r = await (o.fetch ?? fetch)(url, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(8000), body,31      headers: { 'content-type': 'application/json', 'user-agent': 'sp-wallet-alerts/1', 'x-spw-timestamp': String(ts), 'x-spw-signature': webhookSignature(secret, ts, body) } });32    return r.ok ? { ok: true, status: r.status } : { ok: false, status: r.status, error: `receiver answered ${r.status}` };33  } catch (e) { return { ok: false, status: 0, error: (e as Error).message.slice(0, 160) }; }34}35
Files main
Menu

Theme