@open-mercato/core 0.8.1-develop.7441.1.b42958b98c → 0.8.1-develop.7447.1.ac22e0f0bc

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,22 +1,33 @@
1
1
  import { NextResponse } from "next/server";
2
+ import { z } from "zod";
2
3
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
3
4
  import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
4
5
  import { readJsonSafe } from "@open-mercato/shared/lib/http/readJsonSafe";
5
6
  import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK } from "@open-mercato/shared/lib/ratelimit/helpers";
6
- import { getWebhookHandler } from "@open-mercato/shared/modules/payment_gateways/types";
7
+ import {
8
+ getWebhookHandler,
9
+ WebhookVerificationUnavailableError
10
+ } from "@open-mercato/shared/modules/payment_gateways/types";
7
11
  import { markQueueJobOrigin } from "@open-mercato/shared/lib/queue/dispatchOrigin";
8
12
  import { GatewayTransaction } from "../../../data/entities.js";
9
13
  import { getPaymentGatewayQueue } from "../../../lib/queue.js";
10
14
  import { processPaymentGatewayWebhookJob } from "../../../lib/webhook-processor.js";
11
15
  import { paymentGatewaysTag } from "../../openapi.js";
12
16
  import { createLogger } from "@open-mercato/shared/lib/logger";
13
- import { readBoundedRequestBody, WebhookBodyTooLargeError } from "@open-mercato/shared/lib/webhooks";
17
+ import { readBoundedRequestBody, readBoundedRequestBytes, WebhookBodyTooLargeError } from "@open-mercato/shared/lib/webhooks";
18
+ import { getTelemetryRuntime } from "@open-mercato/shared/lib/telemetry/runtime";
14
19
  const logger = createLogger("payment_gateways").child({ component: "webhook" });
15
20
  const metadata = {
16
21
  path: "/payment_gateways/webhook/[provider]",
17
22
  POST: { requireAuth: false }
18
23
  };
19
24
  const WEBHOOK_VERIFICATION_FAILED = "Webhook verification failed";
25
+ const WEBHOOK_PAYLOAD_TOO_LARGE = "Webhook payload too large";
26
+ const INTERNAL_SERVER_ERROR = "Internal server error";
27
+ const DEFAULT_TEXT_CONTENT_TYPE = "text/plain; charset=utf-8";
28
+ const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]/;
29
+ const JSON_CONTENT_TYPE = /^application\/([\w.+-]+\+)?json(\s*;|$)/;
30
+ const paymentIdHintSchema = z.guid();
20
31
  const paymentGatewayWebhookRateLimitConfig = {
21
32
  points: 60,
22
33
  duration: 60,
@@ -31,14 +42,27 @@ async function POST(req, { params }) {
31
42
  return NextResponse.json({ error: `No webhook handler for provider: ${providerKey}` }, { status: 404 });
32
43
  }
33
44
  const rateLimitResponse = await checkProviderWebhookRateLimit(container, req, providerKey);
34
- if (rateLimitResponse) return rateLimitResponse;
45
+ const outcome = rateLimitResponse ? "rate_limited" : await receiveWebhook(req, providerKey, registration, container);
46
+ const response = respond(registration, outcome, rateLimitResponse);
47
+ const logFields = { providerKey, outcome, status: response.status };
48
+ if (outcome === "accepted") logger.info("Webhook handled", logFields);
49
+ else logger.warn("Webhook handled", logFields);
50
+ return response;
51
+ }
52
+ async function receiveWebhook(req, providerKey, registration, container) {
35
53
  let rawBody;
54
+ let bodyText;
36
55
  try {
37
- rawBody = registration.maxBodyBytes === void 0 ? await req.text() : await readBoundedRequestBody(req, { maxBytes: registration.maxBodyBytes });
38
- } catch (error) {
39
- if (error instanceof WebhookBodyTooLargeError) {
40
- return NextResponse.json({ error: "Webhook payload too large" }, { status: 413 });
56
+ if (registration.rawBody === "bytes") {
57
+ const bytes = registration.maxBodyBytes === void 0 ? new Uint8Array(await req.arrayBuffer()) : await readBoundedRequestBytes(req, { maxBytes: registration.maxBodyBytes });
58
+ rawBody = Buffer.from(bytes);
59
+ bodyText = new TextDecoder().decode(rawBody);
60
+ } else {
61
+ bodyText = registration.maxBodyBytes === void 0 ? await req.text() : await readBoundedRequestBody(req, { maxBytes: registration.maxBodyBytes });
62
+ rawBody = bodyText;
41
63
  }
64
+ } catch (error) {
65
+ if (error instanceof WebhookBodyTooLargeError) return "payload_too_large";
42
66
  throw error;
43
67
  }
44
68
  const headers = {};
@@ -49,62 +73,181 @@ async function POST(req, { params }) {
49
73
  const em = container.resolve("em");
50
74
  const integrationCredentialsService = container.resolve("integrationCredentialsService");
51
75
  const queue = getPaymentGatewayQueue(registration.queue ?? "payment-gateways-webhook");
52
- const payload = await readJsonSafe(rawBody);
53
- const sessionIdHint = registration.readSessionIdHint?.(payload) ?? null;
76
+ const payload = await readJsonSafe(bodyText);
77
+ const locatorContext = { rawBody, headers };
78
+ let sessionIdHint;
79
+ let paymentIdHint;
54
80
  try {
55
- const candidates = sessionIdHint ? await findWithDecryption(
56
- em,
57
- GatewayTransaction,
58
- {
59
- providerKey,
60
- providerSessionId: sessionIdHint,
61
- deletedAt: null
62
- },
63
- { limit: 10, orderBy: { createdAt: "desc" } }
64
- ) : [];
65
- let transaction = null;
66
- let matchedScope = null;
67
- let event = null;
68
- let lastVerificationError = null;
81
+ sessionIdHint = normalizeSessionIdHint(registration.readSessionIdHint?.(payload, locatorContext));
82
+ paymentIdHint = normalizePaymentIdHint(registration.readPaymentIdHint?.(payload, locatorContext));
83
+ } catch (error) {
84
+ reportWebhookError(error, "payment_gateways.webhook_locator_failed");
85
+ return "no_candidate";
86
+ }
87
+ try {
88
+ let candidates;
89
+ try {
90
+ candidates = sessionIdHint || paymentIdHint ? await findWithDecryption(
91
+ em,
92
+ GatewayTransaction,
93
+ {
94
+ providerKey,
95
+ ...sessionIdHint ? { providerSessionId: sessionIdHint } : {},
96
+ ...paymentIdHint ? { paymentId: paymentIdHint } : {},
97
+ deletedAt: null
98
+ },
99
+ { limit: 10, orderBy: { createdAt: "desc" } }
100
+ ) : [];
101
+ } catch (error) {
102
+ reportWebhookError(error, "payment_gateways.webhook_lookup_failed");
103
+ return "processing_failed";
104
+ }
105
+ if (candidates.length === 0) return "no_candidate";
106
+ const verifiedMatches = [];
107
+ let verificationUnavailable = false;
69
108
  for (const candidate of candidates) {
70
109
  const candidateScope = { organizationId: candidate.organizationId, tenantId: candidate.tenantId };
71
- const credentials = await integrationCredentialsService.resolve(`gateway_${providerKey}`, candidateScope) ?? {};
110
+ let credentials;
72
111
  try {
73
- event = await registration.handler({ rawBody, headers, credentials });
74
- transaction = candidate;
75
- matchedScope = candidateScope;
76
- break;
112
+ credentials = await integrationCredentialsService.resolve(`gateway_${providerKey}`, candidateScope) ?? {};
77
113
  } catch (error) {
78
- lastVerificationError = error;
114
+ reportWebhookError(error, "payment_gateways.webhook_credentials_failed");
115
+ verificationUnavailable = true;
116
+ continue;
79
117
  }
118
+ try {
119
+ const candidateEvent = await registration.handler({
120
+ rawBody,
121
+ headers,
122
+ credentials,
123
+ candidate: {
124
+ transactionId: candidate.id,
125
+ paymentId: candidate.paymentId,
126
+ providerSessionId: candidate.providerSessionId ?? null,
127
+ amount: candidate.amount,
128
+ currencyCode: candidate.currencyCode
129
+ }
130
+ });
131
+ verifiedMatches.push({ transaction: candidate, scope: candidateScope, event: candidateEvent });
132
+ } catch (error) {
133
+ if (error instanceof WebhookVerificationUnavailableError) verificationUnavailable = true;
134
+ }
135
+ }
136
+ if (verifiedMatches.length > 1) {
137
+ logger.error("Webhook matched multiple verified candidates", {
138
+ providerKey,
139
+ candidateCount: verifiedMatches.length
140
+ });
141
+ getTelemetryRuntime()?.reportError(
142
+ new Error("[internal] Webhook matched multiple verified candidates"),
143
+ { module: "payment_gateways", code: "payment_gateways.webhook_ambiguous_candidates" }
144
+ );
145
+ return "verification_failed";
80
146
  }
81
- if (!event || !transaction || !matchedScope) {
82
- throw lastVerificationError ?? new Error("Webhook verification failed: no matching transaction");
147
+ if (verificationUnavailable && verifiedMatches.length === 1) {
148
+ logger.warn("Webhook candidate left unverified; ambiguity cannot be ruled out", {
149
+ providerKey,
150
+ candidateCount: candidates.length
151
+ });
152
+ return "verification_unavailable";
83
153
  }
84
- const scope = matchedScope;
154
+ const [match] = verifiedMatches;
155
+ if (!match) return verificationUnavailable ? "verification_unavailable" : "verification_failed";
85
156
  const jobPayload = markQueueJobOrigin({
86
157
  providerKey,
87
- event,
88
- transactionId: transaction.id,
89
- scope
158
+ event: match.event,
159
+ transactionId: match.transaction.id,
160
+ scope: match.scope
90
161
  }, "inbound-webhook");
91
- if (process.env.QUEUE_STRATEGY === "async") {
92
- await queue.enqueue(jobPayload);
93
- } else {
94
- await processPaymentGatewayWebhookJob(
95
- {
96
- em: container.resolve("em"),
97
- paymentGatewayService: service,
98
- integrationLogService: container.resolve("integrationLogService")
99
- },
100
- jobPayload
101
- );
162
+ try {
163
+ if (process.env.QUEUE_STRATEGY === "async") {
164
+ await queue.enqueue(jobPayload);
165
+ } else {
166
+ await processPaymentGatewayWebhookJob(
167
+ {
168
+ em: container.resolve("em"),
169
+ paymentGatewayService: service,
170
+ integrationLogService: container.resolve("integrationLogService")
171
+ },
172
+ jobPayload
173
+ );
174
+ }
175
+ } catch (error) {
176
+ getTelemetryRuntime()?.reportError(error, {
177
+ module: "payment_gateways",
178
+ code: "payment_gateways.webhook_processing_failed"
179
+ });
180
+ return "processing_failed";
181
+ }
182
+ return "accepted";
183
+ } catch (error) {
184
+ reportWebhookError(error, "payment_gateways.webhook_unexpected_failure");
185
+ return "verification_failed";
186
+ }
187
+ }
188
+ function respond(registration, outcome, rateLimitResponse) {
189
+ if (!registration.formatResponse) return legacyResponse(outcome, rateLimitResponse);
190
+ try {
191
+ const response = toValidatedResponse(registration.formatResponse(outcome));
192
+ if (outcome === "rate_limited" && rateLimitResponse) {
193
+ rateLimitResponse.headers.forEach((value, key) => {
194
+ if (key !== "content-type" && key !== "content-length") response.headers.set(key, value);
195
+ });
196
+ }
197
+ return response;
198
+ } catch (error) {
199
+ getTelemetryRuntime()?.reportError(error, {
200
+ module: "payment_gateways",
201
+ code: "payment_gateways.webhook_formatter_invalid"
202
+ });
203
+ return NextResponse.json({ error: INTERNAL_SERVER_ERROR }, { status: 500 });
204
+ }
205
+ }
206
+ function legacyResponse(outcome, rateLimitResponse) {
207
+ if (outcome === "rate_limited" && rateLimitResponse) return rateLimitResponse;
208
+ if (outcome === "accepted") return NextResponse.json({ received: true, queued: true }, { status: 202 });
209
+ if (outcome === "payload_too_large") return NextResponse.json({ error: WEBHOOK_PAYLOAD_TOO_LARGE }, { status: 413 });
210
+ return NextResponse.json({ error: WEBHOOK_VERIFICATION_FAILED }, { status: 401 });
211
+ }
212
+ function toValidatedResponse(formatted) {
213
+ const { status, body, contentType } = formatted;
214
+ if (!Number.isInteger(status) || status < 200 || status > 599) {
215
+ throw new Error("[internal] Webhook formatter returned an invalid status");
216
+ }
217
+ if (contentType !== void 0 && (typeof contentType !== "string" || CONTROL_CHARACTERS.test(contentType))) {
218
+ throw new Error("[internal] Webhook formatter returned an invalid content type");
219
+ }
220
+ const normalizedContentType = contentType?.toLowerCase();
221
+ if (typeof body === "string") {
222
+ if (normalizedContentType !== void 0 && !normalizedContentType.startsWith("text/")) {
223
+ throw new Error("[internal] Webhook formatter returned a non-text content type for a text body");
102
224
  }
103
- return NextResponse.json({ received: true, queued: true }, { status: 202 });
104
- } catch (err) {
105
- logger.warn("Webhook verification failed", { providerKey, err });
106
- return NextResponse.json({ error: WEBHOOK_VERIFICATION_FAILED }, { status: 401 });
225
+ return new NextResponse(body, { status, headers: { "content-type": contentType ?? DEFAULT_TEXT_CONTENT_TYPE } });
226
+ }
227
+ if (!isPlainObject(body)) {
228
+ throw new Error("[internal] Webhook formatter returned an invalid body");
107
229
  }
230
+ if (normalizedContentType !== void 0 && !JSON_CONTENT_TYPE.test(normalizedContentType)) {
231
+ throw new Error("[internal] Webhook formatter returned a non-JSON content type for an object body");
232
+ }
233
+ const response = NextResponse.json(body, { status });
234
+ if (contentType !== void 0) response.headers.set("content-type", contentType);
235
+ return response;
236
+ }
237
+ function isPlainObject(value) {
238
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
239
+ const prototype = Object.getPrototypeOf(value);
240
+ return prototype === Object.prototype || prototype === null;
241
+ }
242
+ function reportWebhookError(error, code) {
243
+ getTelemetryRuntime()?.reportError(error, { module: "payment_gateways", code });
244
+ }
245
+ function normalizeSessionIdHint(value) {
246
+ return typeof value === "string" && value.length > 0 ? value : null;
247
+ }
248
+ function normalizePaymentIdHint(value) {
249
+ const parsed = paymentIdHintSchema.safeParse(value);
250
+ return parsed.success ? parsed.data : null;
108
251
  }
109
252
  async function checkProviderWebhookRateLimit(container, req, providerKey) {
110
253
  const rateLimiterService = tryResolve(container, "rateLimiterService");
@@ -131,10 +274,15 @@ const openApi = {
131
274
  summary: "Process inbound webhook from payment provider",
132
275
  tags: [paymentGatewaysTag],
133
276
  responses: [
134
- { status: 202, description: "Webhook accepted for async processing" },
135
- { status: 401, description: "Signature verification failed" },
277
+ { status: 200, description: "Webhook accepted (provider-defined acknowledgement may apply)" },
278
+ { status: 202, description: "Webhook accepted for processing (default acknowledgement)" },
279
+ { status: 400, description: "Webhook rejected (provider-defined response may apply)" },
280
+ { status: 401, description: "Webhook could not be verified or processed (default failure response)" },
281
+ { status: 404, description: "Unknown provider" },
136
282
  { status: 413, description: "Webhook payload too large" },
137
- { status: 404, description: "Unknown provider" }
283
+ { status: 429, description: "Too many webhook requests" },
284
+ { status: 500, description: "Internal server error (provider-defined response may apply)" },
285
+ { status: 503, description: "Webhook temporarily unavailable (provider-defined response may apply)" }
138
286
  ]
139
287
  }
140
288
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/payment_gateways/api/webhook/%5Bprovider%5D/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getWebhookHandler } from '@open-mercato/shared/modules/payment_gateways/types'\nimport { markQueueJobOrigin } from '@open-mercato/shared/lib/queue/dispatchOrigin'\nimport type { IntegrationLogService } from '../../../../integrations/lib/log-service'\nimport type { PaymentGatewayService } from '../../../lib/gateway-service'\nimport type { CredentialsService } from '../../../../integrations/lib/credentials-service'\nimport { GatewayTransaction } from '../../../data/entities'\nimport { getPaymentGatewayQueue } from '../../../lib/queue'\nimport { processPaymentGatewayWebhookJob } from '../../../lib/webhook-processor'\nimport { paymentGatewaysTag } from '../../openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { readBoundedRequestBody, WebhookBodyTooLargeError } from '@open-mercato/shared/lib/webhooks'\n\nconst logger = createLogger('payment_gateways').child({ component: 'webhook' })\n\nexport const metadata = {\n path: '/payment_gateways/webhook/[provider]',\n POST: { requireAuth: false },\n}\n\nconst WEBHOOK_VERIFICATION_FAILED = 'Webhook verification failed'\n\nconst paymentGatewayWebhookRateLimitConfig = {\n points: 60,\n duration: 60,\n keyPrefix: 'payment_gateways:webhook',\n}\n\nexport async function POST(req: Request, { params }: { params: Promise<{ provider: string }> | { provider: string } }) {\n const resolvedParams = await params\n const providerKey = resolvedParams.provider\n const container = await createRequestContainer()\n const registration = getWebhookHandler(providerKey)\n if (!registration) {\n return NextResponse.json({ error: `No webhook handler for provider: ${providerKey}` }, { status: 404 })\n }\n\n const rateLimitResponse = await checkProviderWebhookRateLimit(container, req, providerKey)\n if (rateLimitResponse) return rateLimitResponse\n\n let rawBody: string\n try {\n rawBody = registration.maxBodyBytes === undefined\n ? await req.text()\n : await readBoundedRequestBody(req, { maxBytes: registration.maxBodyBytes })\n } catch (error) {\n if (error instanceof WebhookBodyTooLargeError) {\n return NextResponse.json({ error: 'Webhook payload too large' }, { status: 413 })\n }\n throw error\n }\n const headers: Record<string, string> = {}\n req.headers.forEach((value, key) => {\n headers[key] = value\n })\n\n const service = container.resolve('paymentGatewayService') as PaymentGatewayService\n const em = container.resolve('em') as EntityManager\n const integrationCredentialsService = container.resolve('integrationCredentialsService') as CredentialsService\n const queue = getPaymentGatewayQueue(registration.queue ?? 'payment-gateways-webhook')\n const payload = await readJsonSafe<Record<string, unknown>>(rawBody)\n const sessionIdHint = registration.readSessionIdHint?.(payload) ?? null\n\n try {\n // The webhook endpoint is unauthenticated. Tenant/organization scope MUST come from a\n // GatewayTransaction whose per-tenant credentials successfully verify the inbound\n // signature \u2014 NEVER from attacker-controlled payload metadata. If no candidate\n // transaction can be located by the provider-reported session id, or no candidate's\n // credentials can verify the signature, we fail closed with 401. This prevents\n // forged webhooks (e.g. mock gateway PoC) from mutating another tenant's payment\n // state via `event.data.metadata.{organizationId,tenantId}`.\n const candidates = sessionIdHint\n ? await findWithDecryption(\n em,\n GatewayTransaction,\n {\n providerKey,\n providerSessionId: sessionIdHint,\n deletedAt: null,\n },\n { limit: 10, orderBy: { createdAt: 'desc' } },\n )\n : []\n\n let transaction: GatewayTransaction | null = null\n let matchedScope: { organizationId: string; tenantId: string } | null = null\n let event: Awaited<ReturnType<typeof registration.handler>> | null = null\n let lastVerificationError: unknown = null\n\n for (const candidate of candidates) {\n const candidateScope = { organizationId: candidate.organizationId, tenantId: candidate.tenantId }\n const credentials = await integrationCredentialsService.resolve(`gateway_${providerKey}`, candidateScope) ?? {}\n try {\n event = await registration.handler({ rawBody, headers, credentials })\n transaction = candidate\n matchedScope = candidateScope\n break\n } catch (error: unknown) {\n lastVerificationError = error\n }\n }\n\n if (!event || !transaction || !matchedScope) {\n throw lastVerificationError ?? new Error('Webhook verification failed: no matching transaction')\n }\n\n const scope = matchedScope\n\n const jobPayload = markQueueJobOrigin({\n providerKey,\n event,\n transactionId: transaction.id,\n scope,\n }, 'inbound-webhook')\n\n if (process.env.QUEUE_STRATEGY === 'async') {\n await queue.enqueue(jobPayload)\n } else {\n await processPaymentGatewayWebhookJob(\n {\n em: container.resolve('em') as EntityManager,\n paymentGatewayService: service,\n integrationLogService: container.resolve('integrationLogService') as IntegrationLogService,\n },\n jobPayload,\n )\n }\n\n return NextResponse.json({ received: true, queued: true }, { status: 202 })\n } catch (err: unknown) {\n logger.warn('Webhook verification failed', { providerKey, err })\n return NextResponse.json({ error: WEBHOOK_VERIFICATION_FAILED }, { status: 401 })\n }\n}\n\nasync function checkProviderWebhookRateLimit(\n container: { resolve: (name: string) => unknown },\n req: Request,\n providerKey: string,\n): Promise<NextResponse | null> {\n const rateLimiterService = tryResolve<RateLimiterService>(container, 'rateLimiterService')\n if (!rateLimiterService) return null\n\n return checkRateLimit(\n rateLimiterService,\n paymentGatewayWebhookRateLimitConfig,\n `${providerKey}:${getClientIp(req, rateLimiterService.trustProxyDepth) ?? 'unknown'}`,\n RATE_LIMIT_ERROR_FALLBACK,\n )\n}\n\nfunction tryResolve<T>(container: { resolve: (name: string) => unknown }, name: string): T | null {\n try {\n return container.resolve(name) as T\n } catch {\n return null\n }\n}\n\nexport const openApi = {\n tags: [paymentGatewaysTag],\n summary: 'Receive payment gateway webhook',\n methods: {\n POST: {\n summary: 'Process inbound webhook from payment provider',\n tags: [paymentGatewaysTag],\n responses: [\n { status: 202, description: 'Webhook accepted for async processing' },\n { status: 401, description: 'Signature verification failed' },\n { status: 413, description: 'Webhook payload too large' },\n { status: 404, description: 'Unknown provider' },\n ],\n },\n },\n}\n\nexport default POST\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n getWebhookHandler,\n WebhookVerificationUnavailableError,\n type WebhookHandlerRegistration,\n type WebhookHttpResponse,\n type WebhookResponseOutcome,\n} from '@open-mercato/shared/modules/payment_gateways/types'\nimport { markQueueJobOrigin } from '@open-mercato/shared/lib/queue/dispatchOrigin'\nimport type { IntegrationLogService } from '../../../../integrations/lib/log-service'\nimport type { PaymentGatewayService } from '../../../lib/gateway-service'\nimport type { CredentialsService } from '../../../../integrations/lib/credentials-service'\nimport { GatewayTransaction } from '../../../data/entities'\nimport { getPaymentGatewayQueue } from '../../../lib/queue'\nimport { processPaymentGatewayWebhookJob } from '../../../lib/webhook-processor'\nimport { paymentGatewaysTag } from '../../openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { readBoundedRequestBody, readBoundedRequestBytes, WebhookBodyTooLargeError } from '@open-mercato/shared/lib/webhooks'\nimport { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'\n\nconst logger = createLogger('payment_gateways').child({ component: 'webhook' })\n\nexport const metadata = {\n path: '/payment_gateways/webhook/[provider]',\n POST: { requireAuth: false },\n}\n\nconst WEBHOOK_VERIFICATION_FAILED = 'Webhook verification failed'\nconst WEBHOOK_PAYLOAD_TOO_LARGE = 'Webhook payload too large'\nconst INTERNAL_SERVER_ERROR = 'Internal server error'\nconst DEFAULT_TEXT_CONTENT_TYPE = 'text/plain; charset=utf-8'\nconst CONTROL_CHARACTERS = /[\\u0000-\\u001F\\u007F]/\nconst JSON_CONTENT_TYPE = /^application\\/([\\w.+-]+\\+)?json(\\s*;|$)/\n\nconst paymentIdHintSchema = z.guid()\n\nconst paymentGatewayWebhookRateLimitConfig = {\n points: 60,\n duration: 60,\n keyPrefix: 'payment_gateways:webhook',\n}\n\ntype RequestContainer = Awaited<ReturnType<typeof createRequestContainer>>\n\nexport async function POST(req: Request, { params }: { params: Promise<{ provider: string }> | { provider: string } }) {\n const resolvedParams = await params\n const providerKey = resolvedParams.provider\n const container = await createRequestContainer()\n const registration = getWebhookHandler(providerKey)\n if (!registration) {\n return NextResponse.json({ error: `No webhook handler for provider: ${providerKey}` }, { status: 404 })\n }\n\n const rateLimitResponse = await checkProviderWebhookRateLimit(container, req, providerKey)\n const outcome: WebhookResponseOutcome = rateLimitResponse\n ? 'rate_limited'\n : await receiveWebhook(req, providerKey, registration, container)\n const response = respond(registration, outcome, rateLimitResponse)\n const logFields = { providerKey, outcome, status: response.status }\n if (outcome === 'accepted') logger.info('Webhook handled', logFields)\n else logger.warn('Webhook handled', logFields)\n return response\n}\n\nasync function receiveWebhook(\n req: Request,\n providerKey: string,\n registration: WebhookHandlerRegistration,\n container: RequestContainer,\n): Promise<WebhookResponseOutcome> {\n let rawBody: string | Buffer\n let bodyText: string\n try {\n if (registration.rawBody === 'bytes') {\n const bytes = registration.maxBodyBytes === undefined\n ? new Uint8Array(await req.arrayBuffer())\n : await readBoundedRequestBytes(req, { maxBytes: registration.maxBodyBytes })\n rawBody = Buffer.from(bytes)\n bodyText = new TextDecoder().decode(rawBody)\n } else {\n bodyText = registration.maxBodyBytes === undefined\n ? await req.text()\n : await readBoundedRequestBody(req, { maxBytes: registration.maxBodyBytes })\n rawBody = bodyText\n }\n } catch (error) {\n if (error instanceof WebhookBodyTooLargeError) return 'payload_too_large'\n throw error\n }\n const headers: Record<string, string> = {}\n req.headers.forEach((value, key) => {\n headers[key] = value\n })\n\n const service = container.resolve('paymentGatewayService') as PaymentGatewayService\n const em = container.resolve('em') as EntityManager\n const integrationCredentialsService = container.resolve('integrationCredentialsService') as CredentialsService\n const queue = getPaymentGatewayQueue(registration.queue ?? 'payment-gateways-webhook')\n const payload = await readJsonSafe<Record<string, unknown>>(bodyText)\n const locatorContext = { rawBody, headers }\n let sessionIdHint: string | null\n let paymentIdHint: string | null\n try {\n sessionIdHint = normalizeSessionIdHint(registration.readSessionIdHint?.(payload, locatorContext))\n paymentIdHint = normalizePaymentIdHint(registration.readPaymentIdHint?.(payload, locatorContext))\n } catch (error: unknown) {\n reportWebhookError(error, 'payment_gateways.webhook_locator_failed')\n return 'no_candidate'\n }\n\n try {\n // The webhook endpoint is unauthenticated. Tenant/organization scope MUST come from a\n // GatewayTransaction whose per-tenant credentials successfully verify the inbound\n // signature \u2014 NEVER from attacker-controlled payload metadata. If no candidate\n // transaction can be located by the provider-reported session or payment id, or no\n // candidate's credentials can verify the signature, we fail closed. This\n // prevents forged webhooks (e.g. mock gateway PoC) from mutating another tenant's\n // payment state via `event.data.metadata.{organizationId,tenantId}`. When more than one\n // candidate verifies, the scope is ambiguous and we also fail closed.\n let candidates: GatewayTransaction[]\n try {\n candidates = sessionIdHint || paymentIdHint\n ? await findWithDecryption(\n em,\n GatewayTransaction,\n {\n providerKey,\n ...(sessionIdHint ? { providerSessionId: sessionIdHint } : {}),\n ...(paymentIdHint ? { paymentId: paymentIdHint } : {}),\n deletedAt: null,\n },\n { limit: 10, orderBy: { createdAt: 'desc' } },\n )\n : []\n } catch (error: unknown) {\n reportWebhookError(error, 'payment_gateways.webhook_lookup_failed')\n return 'processing_failed'\n }\n\n if (candidates.length === 0) return 'no_candidate'\n\n const verifiedMatches: Array<{\n transaction: GatewayTransaction\n scope: { organizationId: string; tenantId: string }\n event: Awaited<ReturnType<typeof registration.handler>>\n }> = []\n let verificationUnavailable = false\n\n for (const candidate of candidates) {\n const candidateScope = { organizationId: candidate.organizationId, tenantId: candidate.tenantId }\n let credentials: Record<string, unknown>\n try {\n credentials = await integrationCredentialsService.resolve(`gateway_${providerKey}`, candidateScope) ?? {}\n } catch (error: unknown) {\n reportWebhookError(error, 'payment_gateways.webhook_credentials_failed')\n verificationUnavailable = true\n continue\n }\n try {\n const candidateEvent = await registration.handler({\n rawBody,\n headers,\n credentials,\n candidate: {\n transactionId: candidate.id,\n paymentId: candidate.paymentId,\n providerSessionId: candidate.providerSessionId ?? null,\n amount: candidate.amount,\n currencyCode: candidate.currencyCode,\n },\n })\n verifiedMatches.push({ transaction: candidate, scope: candidateScope, event: candidateEvent })\n } catch (error: unknown) {\n if (error instanceof WebhookVerificationUnavailableError) verificationUnavailable = true\n }\n }\n\n if (verifiedMatches.length > 1) {\n logger.error('Webhook matched multiple verified candidates', {\n providerKey,\n candidateCount: verifiedMatches.length,\n })\n getTelemetryRuntime()?.reportError(\n new Error('[internal] Webhook matched multiple verified candidates'),\n { module: 'payment_gateways', code: 'payment_gateways.webhook_ambiguous_candidates' },\n )\n return 'verification_failed'\n }\n\n if (verificationUnavailable && verifiedMatches.length === 1) {\n logger.warn('Webhook candidate left unverified; ambiguity cannot be ruled out', {\n providerKey,\n candidateCount: candidates.length,\n })\n return 'verification_unavailable'\n }\n\n const [match] = verifiedMatches\n if (!match) return verificationUnavailable ? 'verification_unavailable' : 'verification_failed'\n\n const jobPayload = markQueueJobOrigin({\n providerKey,\n event: match.event,\n transactionId: match.transaction.id,\n scope: match.scope,\n }, 'inbound-webhook')\n\n try {\n if (process.env.QUEUE_STRATEGY === 'async') {\n await queue.enqueue(jobPayload)\n } else {\n await processPaymentGatewayWebhookJob(\n {\n em: container.resolve('em') as EntityManager,\n paymentGatewayService: service,\n integrationLogService: container.resolve('integrationLogService') as IntegrationLogService,\n },\n jobPayload,\n )\n }\n } catch (error: unknown) {\n getTelemetryRuntime()?.reportError(error, {\n module: 'payment_gateways',\n code: 'payment_gateways.webhook_processing_failed',\n })\n return 'processing_failed'\n }\n\n return 'accepted'\n } catch (error: unknown) {\n reportWebhookError(error, 'payment_gateways.webhook_unexpected_failure')\n return 'verification_failed'\n }\n}\n\nfunction respond(\n registration: WebhookHandlerRegistration,\n outcome: WebhookResponseOutcome,\n rateLimitResponse: NextResponse | null,\n): NextResponse {\n if (!registration.formatResponse) return legacyResponse(outcome, rateLimitResponse)\n try {\n const response = toValidatedResponse(registration.formatResponse(outcome))\n if (outcome === 'rate_limited' && rateLimitResponse) {\n rateLimitResponse.headers.forEach((value, key) => {\n if (key !== 'content-type' && key !== 'content-length') response.headers.set(key, value)\n })\n }\n return response\n } catch (error: unknown) {\n getTelemetryRuntime()?.reportError(error, {\n module: 'payment_gateways',\n code: 'payment_gateways.webhook_formatter_invalid',\n })\n return NextResponse.json({ error: INTERNAL_SERVER_ERROR }, { status: 500 })\n }\n}\n\nfunction legacyResponse(outcome: WebhookResponseOutcome, rateLimitResponse: NextResponse | null): NextResponse {\n if (outcome === 'rate_limited' && rateLimitResponse) return rateLimitResponse\n if (outcome === 'accepted') return NextResponse.json({ received: true, queued: true }, { status: 202 })\n if (outcome === 'payload_too_large') return NextResponse.json({ error: WEBHOOK_PAYLOAD_TOO_LARGE }, { status: 413 })\n return NextResponse.json({ error: WEBHOOK_VERIFICATION_FAILED }, { status: 401 })\n}\n\nfunction toValidatedResponse(formatted: WebhookHttpResponse): NextResponse {\n const { status, body, contentType } = formatted\n if (!Number.isInteger(status) || status < 200 || status > 599) {\n throw new Error('[internal] Webhook formatter returned an invalid status')\n }\n if (contentType !== undefined && (typeof contentType !== 'string' || CONTROL_CHARACTERS.test(contentType))) {\n throw new Error('[internal] Webhook formatter returned an invalid content type')\n }\n const normalizedContentType = contentType?.toLowerCase()\n if (typeof body === 'string') {\n if (normalizedContentType !== undefined && !normalizedContentType.startsWith('text/')) {\n throw new Error('[internal] Webhook formatter returned a non-text content type for a text body')\n }\n return new NextResponse(body, { status, headers: { 'content-type': contentType ?? DEFAULT_TEXT_CONTENT_TYPE } })\n }\n if (!isPlainObject(body)) {\n throw new Error('[internal] Webhook formatter returned an invalid body')\n }\n if (normalizedContentType !== undefined && !JSON_CONTENT_TYPE.test(normalizedContentType)) {\n throw new Error('[internal] Webhook formatter returned a non-JSON content type for an object body')\n }\n const response = NextResponse.json(body, { status })\n if (contentType !== undefined) response.headers.set('content-type', contentType)\n return response\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false\n const prototype = Object.getPrototypeOf(value)\n return prototype === Object.prototype || prototype === null\n}\n\nfunction reportWebhookError(error: unknown, code: string): void {\n getTelemetryRuntime()?.reportError(error, { module: 'payment_gateways', code })\n}\n\nfunction normalizeSessionIdHint(value: unknown): string | null {\n return typeof value === 'string' && value.length > 0 ? value : null\n}\n\nfunction normalizePaymentIdHint(value: unknown): string | null {\n const parsed = paymentIdHintSchema.safeParse(value)\n return parsed.success ? parsed.data : null\n}\n\nasync function checkProviderWebhookRateLimit(\n container: { resolve: (name: string) => unknown },\n req: Request,\n providerKey: string,\n): Promise<NextResponse | null> {\n const rateLimiterService = tryResolve<RateLimiterService>(container, 'rateLimiterService')\n if (!rateLimiterService) return null\n\n return checkRateLimit(\n rateLimiterService,\n paymentGatewayWebhookRateLimitConfig,\n `${providerKey}:${getClientIp(req, rateLimiterService.trustProxyDepth) ?? 'unknown'}`,\n RATE_LIMIT_ERROR_FALLBACK,\n )\n}\n\nfunction tryResolve<T>(container: { resolve: (name: string) => unknown }, name: string): T | null {\n try {\n return container.resolve(name) as T\n } catch {\n return null\n }\n}\n\nexport const openApi = {\n tags: [paymentGatewaysTag],\n summary: 'Receive payment gateway webhook',\n methods: {\n POST: {\n summary: 'Process inbound webhook from payment provider',\n tags: [paymentGatewaysTag],\n responses: [\n { status: 200, description: 'Webhook accepted (provider-defined acknowledgement may apply)' },\n { status: 202, description: 'Webhook accepted for processing (default acknowledgement)' },\n { status: 400, description: 'Webhook rejected (provider-defined response may apply)' },\n { status: 401, description: 'Webhook could not be verified or processed (default failure response)' },\n { status: 404, description: 'Unknown provider' },\n { status: 413, description: 'Webhook payload too large' },\n { status: 429, description: 'Too many webhook requests' },\n { status: 500, description: 'Internal server error (provider-defined response may apply)' },\n { status: 503, description: 'Webhook temporarily unavailable (provider-defined response may apply)' },\n ],\n },\n },\n}\n\nexport default POST\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { QueryOrder } from "@mikro-orm/core";
2
3
  import { findOneWithDecryption, findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
3
4
  import { createLogger } from "@open-mercato/shared/lib/logger";
4
5
  import {
@@ -109,6 +110,13 @@ function createPaymentGatewayService(deps) {
109
110
  payload: payload ?? null
110
111
  }, scope);
111
112
  }
113
+ async function stampLastPolledAt(transaction, polledAt) {
114
+ await em.nativeUpdate(
115
+ GatewayTransaction,
116
+ { id: transaction.id, organizationId: transaction.organizationId, tenantId: transaction.tenantId },
117
+ { lastPolledAt: polledAt }
118
+ );
119
+ }
112
120
  async function resolveAdapterAndCredentials(providerKey, scope) {
113
121
  const integrationId = `gateway_${providerKey}`;
114
122
  const selectedVersion = deps.integrationStateService ? await deps.integrationStateService.resolveApiVersion(integrationId, scope) : void 0;
@@ -520,21 +528,37 @@ function createPaymentGatewayService(deps) {
520
528
  },
521
529
  async getPaymentStatus(transactionId, scope) {
522
530
  const transaction = await findTransactionOrThrow(transactionId, scope);
523
- const { adapter, credentials } = await resolveAdapterAndCredentials(
524
- transaction.providerKey,
525
- { organizationId: transaction.organizationId, tenantId: transaction.tenantId }
526
- );
527
- const status = await adapter.getStatus({
528
- sessionId: readProviderSessionId(transaction),
529
- credentials
530
- });
531
- if (status.status !== transaction.unifiedStatus && isValidTransition(transaction.unifiedStatus, status.status)) {
531
+ let status;
532
+ try {
533
+ const { adapter, credentials } = await resolveAdapterAndCredentials(
534
+ transaction.providerKey,
535
+ { organizationId: transaction.organizationId, tenantId: transaction.tenantId }
536
+ );
537
+ status = await adapter.getStatus({
538
+ sessionId: readProviderSessionId(transaction),
539
+ credentials
540
+ });
541
+ } catch (error) {
542
+ try {
543
+ await stampLastPolledAt(transaction, /* @__PURE__ */ new Date());
544
+ } catch (stampError) {
545
+ logger.warn("Failed to record last poll time after a failed status read", {
546
+ transactionId: transaction.id,
547
+ providerKey: transaction.providerKey,
548
+ err: stampError
549
+ });
550
+ }
551
+ throw error;
552
+ }
553
+ const polledAt = /* @__PURE__ */ new Date();
554
+ const shouldApplyStatus = status.status !== transaction.unifiedStatus && isValidTransition(transaction.unifiedStatus, status.status);
555
+ if (shouldApplyStatus) {
532
556
  const previousStatus = transaction.unifiedStatus;
533
557
  transaction.unifiedStatus = status.status;
534
558
  alignCapturedAmountWithStatus(transaction, status.status);
535
559
  transaction.gatewayStatus = status.status;
536
560
  transaction.gatewayMetadata = { ...readGatewayMetadata(transaction.gatewayMetadata), statusResult: status.providerData ?? null };
537
- transaction.lastPolledAt = /* @__PURE__ */ new Date();
561
+ transaction.lastPolledAt = polledAt;
538
562
  await em.flush();
539
563
  await emitStatusEvent(status.status, {
540
564
  transactionId: transaction.id,
@@ -555,6 +579,8 @@ function createPaymentGatewayService(deps) {
555
579
  nextStatus: status.status
556
580
  }
557
581
  );
582
+ } else {
583
+ await stampLastPolledAt(transaction, polledAt);
558
584
  }
559
585
  return status;
560
586
  },
@@ -654,7 +680,7 @@ function createPaymentGatewayService(deps) {
654
680
  GatewayTransaction,
655
681
  where,
656
682
  {
657
- orderBy: { updatedAt: "asc" },
683
+ orderBy: { lastPolledAt: QueryOrder.ASC_NULLS_FIRST, createdAt: QueryOrder.ASC },
658
684
  limit: scope?.limit ?? 100
659
685
  },
660
686
  scope