@open-mercato/core 0.6.8-develop.7066.1.6bfa9ff69f → 0.6.8-develop.7067.1.82e9867ea1
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.
- package/dist/modules/payment_gateways/api/webhook/[provider]/route.js +4 -6
- package/dist/modules/payment_gateways/api/webhook/[provider]/route.js.map +2 -2
- package/dist/modules/payment_gateways/lib/webhook-processor.js +7 -0
- package/dist/modules/payment_gateways/lib/webhook-processor.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/payment_gateways/api/webhook/[provider]/route.ts +4 -6
- package/src/modules/payment_gateways/lib/webhook-processor.ts +11 -0
|
@@ -4,6 +4,7 @@ import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
|
4
4
|
import { readJsonSafe } from "@open-mercato/shared/lib/http/readJsonSafe";
|
|
5
5
|
import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK } from "@open-mercato/shared/lib/ratelimit/helpers";
|
|
6
6
|
import { getWebhookHandler } from "@open-mercato/shared/modules/payment_gateways/types";
|
|
7
|
+
import { markQueueJobOrigin } from "@open-mercato/shared/lib/queue/dispatchOrigin";
|
|
7
8
|
import { GatewayTransaction } from "../../../data/entities.js";
|
|
8
9
|
import { getPaymentGatewayQueue } from "../../../lib/queue.js";
|
|
9
10
|
import { processPaymentGatewayWebhookJob } from "../../../lib/webhook-processor.js";
|
|
@@ -81,17 +82,14 @@ async function POST(req, { params }) {
|
|
|
81
82
|
throw lastVerificationError ?? new Error("Webhook verification failed: no matching transaction");
|
|
82
83
|
}
|
|
83
84
|
const scope = matchedScope;
|
|
84
|
-
const jobPayload = {
|
|
85
|
+
const jobPayload = markQueueJobOrigin({
|
|
85
86
|
providerKey,
|
|
86
87
|
event,
|
|
87
88
|
transactionId: transaction.id,
|
|
88
89
|
scope
|
|
89
|
-
};
|
|
90
|
+
}, "inbound-webhook");
|
|
90
91
|
if (process.env.QUEUE_STRATEGY === "async") {
|
|
91
|
-
await queue.enqueue(
|
|
92
|
-
name: "payment-gateway-webhook",
|
|
93
|
-
payload: jobPayload
|
|
94
|
-
});
|
|
92
|
+
await queue.enqueue(jobPayload);
|
|
95
93
|
} else {
|
|
96
94
|
await processPaymentGatewayWebhookJob(
|
|
97
95
|
{
|
|
@@ -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 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 = {\n providerKey,\n event,\n transactionId: transaction.id,\n scope,\n }\n\n if (process.env.QUEUE_STRATEGY === 'async') {\n await queue.enqueue(
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;AAC7B,SAAS,gBAAgB,aAAa,iCAAiC;AAGvE,SAAS,yBAAyB;
|
|
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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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { getGatewayAdapter } from "@open-mercato/shared/modules/payment_gateways/types";
|
|
2
|
+
import { isTrustedWebhookDispatch } from "@open-mercato/shared/lib/queue/dispatchOrigin";
|
|
3
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
2
4
|
import { claimWebhookProcessing, releaseWebhookClaim } from "./webhook-utils.js";
|
|
5
|
+
const logger = createLogger("payment_gateways").child({ component: "webhook-processor" });
|
|
3
6
|
function readSessionIdFromEvent(event) {
|
|
4
7
|
const id = event.data.id;
|
|
5
8
|
if (typeof id === "string" && id.trim().length > 0) return id.trim();
|
|
@@ -20,6 +23,10 @@ async function writeTransactionLog(integrationLogService, providerKey, scope, tr
|
|
|
20
23
|
async function processPaymentGatewayWebhookJob(deps, payload) {
|
|
21
24
|
const { em, paymentGatewayService, integrationLogService } = deps;
|
|
22
25
|
const { providerKey, event } = payload;
|
|
26
|
+
if (!isTrustedWebhookDispatch(payload)) {
|
|
27
|
+
logger.error("Dropping webhook job with missing or untrusted dispatch origin", { providerKey, eventType: event?.eventType ?? null });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
23
30
|
const scopedPayload = payload.scope ?? null;
|
|
24
31
|
if (!scopedPayload) return;
|
|
25
32
|
let transaction = payload.transactionId ? await paymentGatewayService.findTransaction(payload.transactionId, scopedPayload) : null;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/payment_gateways/lib/webhook-processor.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { getGatewayAdapter, type WebhookEvent } from '@open-mercato/shared/modules/payment_gateways/types'\nimport type { IntegrationLogService } from '../../integrations/lib/log-service'\nimport type { PaymentGatewayService } from './gateway-service'\nimport { claimWebhookProcessing, releaseWebhookClaim } from './webhook-utils'\n\nexport type PaymentGatewayWebhookJobPayload = {\n providerKey: string\n event: WebhookEvent\n transactionId?: string | null\n scope?: {\n organizationId: string\n tenantId: string\n } | null\n}\n\ntype PaymentGatewayWebhookProcessorDeps = {\n em: EntityManager\n paymentGatewayService: PaymentGatewayService\n integrationLogService: IntegrationLogService\n}\n\nfunction readSessionIdFromEvent(event: WebhookEvent): string | null {\n const id = event.data.id\n if (typeof id === 'string' && id.trim().length > 0) return id.trim()\n const paymentIntent = event.data.payment_intent\n if (typeof paymentIntent === 'string' && paymentIntent.trim().length > 0) return paymentIntent.trim()\n return null\n}\n\nasync function writeTransactionLog(\n integrationLogService: IntegrationLogService,\n providerKey: string,\n scope: { organizationId: string; tenantId: string },\n transactionId: string,\n level: 'info' | 'warn' | 'error',\n message: string,\n payload?: Record<string, unknown>,\n) {\n await integrationLogService.write({\n integrationId: `gateway_${providerKey}`,\n scopeEntityType: 'payment_transaction',\n scopeEntityId: transactionId,\n level,\n message,\n payload: payload ?? null,\n }, scope)\n}\n\nexport async function processPaymentGatewayWebhookJob(\n deps: PaymentGatewayWebhookProcessorDeps,\n payload: PaymentGatewayWebhookJobPayload,\n): Promise<void> {\n const { em, paymentGatewayService, integrationLogService } = deps\n const { providerKey, event } = payload\n // Scope MUST come from the trusted route layer (derived from a verified GatewayTransaction).\n // Never fall back to attacker-controlled metadata on `event.data.metadata` \u2014 that was the\n // vector that allowed forged mock webhooks to mutate another tenant's payment state.\n const scopedPayload = payload.scope ?? null\n if (!scopedPayload) return\n\n let transaction = payload.transactionId\n ? await paymentGatewayService.findTransaction(payload.transactionId, scopedPayload)\n : null\n\n if (!transaction) {\n const sessionId = readSessionIdFromEvent(event)\n if (sessionId) {\n transaction = await paymentGatewayService.findTransactionBySessionId(sessionId, scopedPayload, providerKey)\n }\n }\n if (!transaction) return\n\n const scope = { organizationId: transaction.organizationId, tenantId: transaction.tenantId }\n const claimed = await claimWebhookProcessing(em, event.idempotencyKey, providerKey, scope, event.eventType)\n if (!claimed) {\n await writeTransactionLog(integrationLogService, providerKey, scope, transaction.id, 'info', 'Duplicate payment gateway webhook skipped', {\n eventType: event.eventType,\n idempotencyKey: event.idempotencyKey,\n })\n return\n }\n\n try {\n const adapter = getGatewayAdapter(providerKey)\n if (!adapter) {\n await writeTransactionLog(integrationLogService, providerKey, scope, transaction.id, 'warn', 'Missing payment gateway adapter for webhook event', {\n providerKey,\n eventType: event.eventType,\n })\n return\n }\n\n const providerStatus = typeof event.data.status === 'string' ? event.data.status : ''\n const unifiedStatus = adapter.mapStatus(providerStatus, event.eventType)\n await writeTransactionLog(integrationLogService, providerKey, scope, transaction.id, 'info', 'Payment gateway webhook received', {\n eventType: event.eventType,\n providerStatus,\n unifiedStatus,\n })\n\n await paymentGatewayService.syncTransactionStatus(transaction.id, {\n unifiedStatus,\n providerStatus: event.eventType,\n providerData: event.data,\n webhookEvent: {\n eventType: event.eventType,\n idempotencyKey: event.idempotencyKey,\n processed: true,\n },\n }, scope)\n\n await writeTransactionLog(integrationLogService, providerKey, scope, transaction.id, 'info', 'Payment gateway webhook processed', {\n eventType: event.eventType,\n unifiedStatus,\n })\n } catch (error: unknown) {\n await releaseWebhookClaim(em, event.idempotencyKey, providerKey, scope)\n throw error\n }\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,yBAA4C;
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { getGatewayAdapter, type WebhookEvent } from '@open-mercato/shared/modules/payment_gateways/types'\nimport { isTrustedWebhookDispatch } from '@open-mercato/shared/lib/queue/dispatchOrigin'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { IntegrationLogService } from '../../integrations/lib/log-service'\nimport type { PaymentGatewayService } from './gateway-service'\nimport { claimWebhookProcessing, releaseWebhookClaim } from './webhook-utils'\n\nconst logger = createLogger('payment_gateways').child({ component: 'webhook-processor' })\n\nexport type PaymentGatewayWebhookJobPayload = {\n providerKey: string\n event: WebhookEvent\n transactionId?: string | null\n scope?: {\n organizationId: string\n tenantId: string\n } | null\n}\n\ntype PaymentGatewayWebhookProcessorDeps = {\n em: EntityManager\n paymentGatewayService: PaymentGatewayService\n integrationLogService: IntegrationLogService\n}\n\nfunction readSessionIdFromEvent(event: WebhookEvent): string | null {\n const id = event.data.id\n if (typeof id === 'string' && id.trim().length > 0) return id.trim()\n const paymentIntent = event.data.payment_intent\n if (typeof paymentIntent === 'string' && paymentIntent.trim().length > 0) return paymentIntent.trim()\n return null\n}\n\nasync function writeTransactionLog(\n integrationLogService: IntegrationLogService,\n providerKey: string,\n scope: { organizationId: string; tenantId: string },\n transactionId: string,\n level: 'info' | 'warn' | 'error',\n message: string,\n payload?: Record<string, unknown>,\n) {\n await integrationLogService.write({\n integrationId: `gateway_${providerKey}`,\n scopeEntityType: 'payment_transaction',\n scopeEntityId: transactionId,\n level,\n message,\n payload: payload ?? null,\n }, scope)\n}\n\nexport async function processPaymentGatewayWebhookJob(\n deps: PaymentGatewayWebhookProcessorDeps,\n payload: PaymentGatewayWebhookJobPayload,\n): Promise<void> {\n const { em, paymentGatewayService, integrationLogService } = deps\n const { providerKey, event } = payload\n // Fail closed on untrusted dispatch origins (#5213): only jobs enqueued by the\n // inbound webhook route (signature verified) may drive payment state. Jobs that\n // arrive through any other path \u2014 e.g. a scheduled queue target \u2014 are dropped.\n if (!isTrustedWebhookDispatch(payload)) {\n logger.error('Dropping webhook job with missing or untrusted dispatch origin', { providerKey, eventType: event?.eventType ?? null })\n return\n }\n // Scope MUST come from the trusted route layer (derived from a verified GatewayTransaction).\n // Never fall back to attacker-controlled metadata on `event.data.metadata` \u2014 that was the\n // vector that allowed forged mock webhooks to mutate another tenant's payment state.\n const scopedPayload = payload.scope ?? null\n if (!scopedPayload) return\n\n let transaction = payload.transactionId\n ? await paymentGatewayService.findTransaction(payload.transactionId, scopedPayload)\n : null\n\n if (!transaction) {\n const sessionId = readSessionIdFromEvent(event)\n if (sessionId) {\n transaction = await paymentGatewayService.findTransactionBySessionId(sessionId, scopedPayload, providerKey)\n }\n }\n if (!transaction) return\n\n const scope = { organizationId: transaction.organizationId, tenantId: transaction.tenantId }\n const claimed = await claimWebhookProcessing(em, event.idempotencyKey, providerKey, scope, event.eventType)\n if (!claimed) {\n await writeTransactionLog(integrationLogService, providerKey, scope, transaction.id, 'info', 'Duplicate payment gateway webhook skipped', {\n eventType: event.eventType,\n idempotencyKey: event.idempotencyKey,\n })\n return\n }\n\n try {\n const adapter = getGatewayAdapter(providerKey)\n if (!adapter) {\n await writeTransactionLog(integrationLogService, providerKey, scope, transaction.id, 'warn', 'Missing payment gateway adapter for webhook event', {\n providerKey,\n eventType: event.eventType,\n })\n return\n }\n\n const providerStatus = typeof event.data.status === 'string' ? event.data.status : ''\n const unifiedStatus = adapter.mapStatus(providerStatus, event.eventType)\n await writeTransactionLog(integrationLogService, providerKey, scope, transaction.id, 'info', 'Payment gateway webhook received', {\n eventType: event.eventType,\n providerStatus,\n unifiedStatus,\n })\n\n await paymentGatewayService.syncTransactionStatus(transaction.id, {\n unifiedStatus,\n providerStatus: event.eventType,\n providerData: event.data,\n webhookEvent: {\n eventType: event.eventType,\n idempotencyKey: event.idempotencyKey,\n processed: true,\n },\n }, scope)\n\n await writeTransactionLog(integrationLogService, providerKey, scope, transaction.id, 'info', 'Payment gateway webhook processed', {\n eventType: event.eventType,\n unifiedStatus,\n })\n } catch (error: unknown) {\n await releaseWebhookClaim(em, event.idempotencyKey, providerKey, scope)\n throw error\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.7067.1.82e9867ea1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -252,16 +252,16 @@
|
|
|
252
252
|
"zod": "^4.4.3"
|
|
253
253
|
},
|
|
254
254
|
"peerDependencies": {
|
|
255
|
-
"@open-mercato/ai-assistant": "0.6.8-develop.
|
|
256
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
257
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
255
|
+
"@open-mercato/ai-assistant": "0.6.8-develop.7067.1.82e9867ea1",
|
|
256
|
+
"@open-mercato/shared": "0.6.8-develop.7067.1.82e9867ea1",
|
|
257
|
+
"@open-mercato/ui": "0.6.8-develop.7067.1.82e9867ea1",
|
|
258
258
|
"react": "^19.0.0",
|
|
259
259
|
"react-dom": "^19.0.0"
|
|
260
260
|
},
|
|
261
261
|
"devDependencies": {
|
|
262
|
-
"@open-mercato/ai-assistant": "0.6.8-develop.
|
|
263
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
264
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
262
|
+
"@open-mercato/ai-assistant": "0.6.8-develop.7067.1.82e9867ea1",
|
|
263
|
+
"@open-mercato/shared": "0.6.8-develop.7067.1.82e9867ea1",
|
|
264
|
+
"@open-mercato/ui": "0.6.8-develop.7067.1.82e9867ea1",
|
|
265
265
|
"@testing-library/dom": "^10.4.1",
|
|
266
266
|
"@testing-library/jest-dom": "^7.0.0",
|
|
267
267
|
"@testing-library/react": "^16.3.1",
|
|
@@ -6,6 +6,7 @@ import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK } from '@open-me
|
|
|
6
6
|
import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
|
|
7
7
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
8
8
|
import { getWebhookHandler } from '@open-mercato/shared/modules/payment_gateways/types'
|
|
9
|
+
import { markQueueJobOrigin } from '@open-mercato/shared/lib/queue/dispatchOrigin'
|
|
9
10
|
import type { IntegrationLogService } from '../../../../integrations/lib/log-service'
|
|
10
11
|
import type { PaymentGatewayService } from '../../../lib/gateway-service'
|
|
11
12
|
import type { CredentialsService } from '../../../../integrations/lib/credentials-service'
|
|
@@ -111,18 +112,15 @@ export async function POST(req: Request, { params }: { params: Promise<{ provide
|
|
|
111
112
|
|
|
112
113
|
const scope = matchedScope
|
|
113
114
|
|
|
114
|
-
const jobPayload = {
|
|
115
|
+
const jobPayload = markQueueJobOrigin({
|
|
115
116
|
providerKey,
|
|
116
117
|
event,
|
|
117
118
|
transactionId: transaction.id,
|
|
118
119
|
scope,
|
|
119
|
-
}
|
|
120
|
+
}, 'inbound-webhook')
|
|
120
121
|
|
|
121
122
|
if (process.env.QUEUE_STRATEGY === 'async') {
|
|
122
|
-
await queue.enqueue(
|
|
123
|
-
name: 'payment-gateway-webhook',
|
|
124
|
-
payload: jobPayload,
|
|
125
|
-
})
|
|
123
|
+
await queue.enqueue(jobPayload)
|
|
126
124
|
} else {
|
|
127
125
|
await processPaymentGatewayWebhookJob(
|
|
128
126
|
{
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
2
|
import { getGatewayAdapter, type WebhookEvent } from '@open-mercato/shared/modules/payment_gateways/types'
|
|
3
|
+
import { isTrustedWebhookDispatch } from '@open-mercato/shared/lib/queue/dispatchOrigin'
|
|
4
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
3
5
|
import type { IntegrationLogService } from '../../integrations/lib/log-service'
|
|
4
6
|
import type { PaymentGatewayService } from './gateway-service'
|
|
5
7
|
import { claimWebhookProcessing, releaseWebhookClaim } from './webhook-utils'
|
|
6
8
|
|
|
9
|
+
const logger = createLogger('payment_gateways').child({ component: 'webhook-processor' })
|
|
10
|
+
|
|
7
11
|
export type PaymentGatewayWebhookJobPayload = {
|
|
8
12
|
providerKey: string
|
|
9
13
|
event: WebhookEvent
|
|
@@ -53,6 +57,13 @@ export async function processPaymentGatewayWebhookJob(
|
|
|
53
57
|
): Promise<void> {
|
|
54
58
|
const { em, paymentGatewayService, integrationLogService } = deps
|
|
55
59
|
const { providerKey, event } = payload
|
|
60
|
+
// Fail closed on untrusted dispatch origins (#5213): only jobs enqueued by the
|
|
61
|
+
// inbound webhook route (signature verified) may drive payment state. Jobs that
|
|
62
|
+
// arrive through any other path — e.g. a scheduled queue target — are dropped.
|
|
63
|
+
if (!isTrustedWebhookDispatch(payload)) {
|
|
64
|
+
logger.error('Dropping webhook job with missing or untrusted dispatch origin', { providerKey, eventType: event?.eventType ?? null })
|
|
65
|
+
return
|
|
66
|
+
}
|
|
56
67
|
// Scope MUST come from the trusted route layer (derived from a verified GatewayTransaction).
|
|
57
68
|
// Never fall back to attacker-controlled metadata on `event.data.metadata` — that was the
|
|
58
69
|
// vector that allowed forged mock webhooks to mutate another tenant's payment state.
|