@open-mercato/core 0.6.5-develop.5226.1.2cf979f8a3 → 0.6.5-develop.5240.2.bbd9d30275

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.
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
2
2
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
3
3
  import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
4
4
  import { readJsonSafe } from "@open-mercato/shared/lib/http/readJsonSafe";
5
+ import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK } from "@open-mercato/shared/lib/ratelimit/helpers";
5
6
  import { getWebhookHandler } from "@open-mercato/shared/modules/payment_gateways/types";
6
7
  import { GatewayTransaction } from "../../../data/entities.js";
7
8
  import { getPaymentGatewayQueue } from "../../../lib/queue.js";
@@ -11,6 +12,12 @@ const metadata = {
11
12
  path: "/payment_gateways/webhook/[provider]",
12
13
  POST: { requireAuth: false }
13
14
  };
15
+ const WEBHOOK_VERIFICATION_FAILED = "Webhook verification failed";
16
+ const paymentGatewayWebhookRateLimitConfig = {
17
+ points: 60,
18
+ duration: 60,
19
+ keyPrefix: "payment_gateways:webhook"
20
+ };
14
21
  async function POST(req, { params }) {
15
22
  const resolvedParams = await params;
16
23
  const providerKey = resolvedParams.provider;
@@ -19,6 +26,8 @@ async function POST(req, { params }) {
19
26
  if (!registration) {
20
27
  return NextResponse.json({ error: `No webhook handler for provider: ${providerKey}` }, { status: 404 });
21
28
  }
29
+ const rateLimitResponse = await checkProviderWebhookRateLimit(container, req, providerKey);
30
+ if (rateLimitResponse) return rateLimitResponse;
22
31
  const rawBody = await req.text();
23
32
  const headers = {};
24
33
  req.headers.forEach((value, key) => {
@@ -84,8 +93,25 @@ async function POST(req, { params }) {
84
93
  }
85
94
  return NextResponse.json({ received: true, queued: true }, { status: 202 });
86
95
  } catch (err) {
87
- const message = err instanceof Error ? err.message : "Webhook verification failed";
88
- return NextResponse.json({ error: message }, { status: 401 });
96
+ console.warn(`[payment_gateways] Webhook verification failed for provider "${providerKey}"`, err);
97
+ return NextResponse.json({ error: WEBHOOK_VERIFICATION_FAILED }, { status: 401 });
98
+ }
99
+ }
100
+ async function checkProviderWebhookRateLimit(container, req, providerKey) {
101
+ const rateLimiterService = tryResolve(container, "rateLimiterService");
102
+ if (!rateLimiterService) return null;
103
+ return checkRateLimit(
104
+ rateLimiterService,
105
+ paymentGatewayWebhookRateLimitConfig,
106
+ `${providerKey}:${getClientIp(req, rateLimiterService.trustProxyDepth) ?? "unknown"}`,
107
+ RATE_LIMIT_ERROR_FALLBACK
108
+ );
109
+ }
110
+ function tryResolve(container, name) {
111
+ try {
112
+ return container.resolve(name);
113
+ } catch {
114
+ return null;
89
115
  }
90
116
  }
91
117
  const openApi = {
@@ -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 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'\n\nexport const metadata = {\n path: '/payment_gateways/webhook/[provider]',\n POST: { requireAuth: false },\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 rawBody = await req.text()\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({\n name: 'payment-gateway-webhook',\n payload: jobPayload,\n })\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 const message = err instanceof Error ? err.message : 'Webhook verification failed'\n return NextResponse.json({ error: message }, { status: 401 })\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: 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 { 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'\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 const rawBody = await req.text()\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({\n name: 'payment-gateway-webhook',\n payload: jobPayload,\n })\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 console.warn(`[payment_gateways] Webhook verification failed for provider \"${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: 404, description: 'Unknown provider' },\n ],\n },\n },\n}\n\nexport default POST\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
2
2
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
3
3
  import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
4
4
  import { readJsonSafe } from "@open-mercato/shared/lib/http/readJsonSafe";
5
+ import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK } from "@open-mercato/shared/lib/ratelimit/helpers";
5
6
  import { CarrierShipment } from "../../../data/entities.js";
6
7
  import { getShippingAdapter } from "../../../lib/adapter-registry.js";
7
8
  import { getShippingCarrierQueue } from "../../../lib/queue.js";
@@ -10,6 +11,12 @@ const metadata = {
10
11
  path: "/shipping-carriers/webhook/[provider]",
11
12
  POST: { requireAuth: false }
12
13
  };
14
+ const WEBHOOK_VERIFICATION_FAILED = "Webhook verification failed";
15
+ const shippingCarrierWebhookRateLimitConfig = {
16
+ points: 60,
17
+ duration: 60,
18
+ keyPrefix: "shipping_carriers:webhook"
19
+ };
13
20
  function readCarrierShipmentId(payload) {
14
21
  if (!payload) return null;
15
22
  const shipmentId = payload.shipmentId;
@@ -28,12 +35,14 @@ async function POST(req, { params }) {
28
35
  if (!adapter) {
29
36
  return NextResponse.json({ error: `No shipping adapter for provider: ${providerKey}` }, { status: 404 });
30
37
  }
38
+ const container = await createRequestContainer();
39
+ const rateLimitResponse = await checkProviderWebhookRateLimit(container, req, providerKey);
40
+ if (rateLimitResponse) return rateLimitResponse;
31
41
  const rawBody = await req.text();
32
42
  const headers = {};
33
43
  req.headers.forEach((value, key) => {
34
44
  headers[key] = value;
35
45
  });
36
- const container = await createRequestContainer();
37
46
  const em = container.resolve("em");
38
47
  const integrationCredentialsService = container.resolve("integrationCredentialsService");
39
48
  const queue = getShippingCarrierQueue("shipping-carriers-webhook");
@@ -80,8 +89,25 @@ async function POST(req, { params }) {
80
89
  });
81
90
  return NextResponse.json({ received: true, queued: true }, { status: 202 });
82
91
  } catch (error) {
83
- const message = error instanceof Error ? error.message : "Webhook verification failed";
84
- return NextResponse.json({ error: message }, { status: 401 });
92
+ console.warn(`[shipping_carriers] Webhook verification failed for provider "${providerKey}"`, error);
93
+ return NextResponse.json({ error: WEBHOOK_VERIFICATION_FAILED }, { status: 401 });
94
+ }
95
+ }
96
+ async function checkProviderWebhookRateLimit(container, req, providerKey) {
97
+ const rateLimiterService = tryResolve(container, "rateLimiterService");
98
+ if (!rateLimiterService) return null;
99
+ return checkRateLimit(
100
+ rateLimiterService,
101
+ shippingCarrierWebhookRateLimitConfig,
102
+ `${providerKey}:${getClientIp(req, rateLimiterService.trustProxyDepth) ?? "unknown"}`,
103
+ RATE_LIMIT_ERROR_FALLBACK
104
+ );
105
+ }
106
+ function tryResolve(container, name) {
107
+ try {
108
+ return container.resolve(name);
109
+ } catch {
110
+ return null;
85
111
  }
86
112
  }
87
113
  const openApi = {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/shipping_carriers/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 type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CredentialsService } from '../../../../integrations/lib/credentials-service'\nimport { CarrierShipment } from '../../../data/entities'\nimport { getShippingAdapter } from '../../../lib/adapter-registry'\nimport { getShippingCarrierQueue } from '../../../lib/queue'\nimport { shippingCarriersTag } from '../../openapi'\n\nexport const metadata = {\n path: '/shipping-carriers/webhook/[provider]',\n POST: { requireAuth: false },\n}\n\nfunction readCarrierShipmentId(payload: Record<string, unknown> | null): string | null {\n if (!payload) return null\n const shipmentId = payload.shipmentId\n if (typeof shipmentId === 'string' && shipmentId.trim().length > 0) return shipmentId.trim()\n const data = payload.data\n if (data && typeof data === 'object') {\n const nested = (data as Record<string, unknown>).shipmentId\n if (typeof nested === 'string' && nested.trim().length > 0) return nested.trim()\n }\n return null\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 adapter = getShippingAdapter(providerKey)\n if (!adapter) {\n return NextResponse.json({ error: `No shipping adapter for provider: ${providerKey}` }, { status: 404 })\n }\n\n const rawBody = await req.text()\n const headers: Record<string, string> = {}\n req.headers.forEach((value, key) => {\n headers[key] = value\n })\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const integrationCredentialsService = container.resolve('integrationCredentialsService') as CredentialsService\n const queue = getShippingCarrierQueue('shipping-carriers-webhook')\n const payload = await readJsonSafe<Record<string, unknown>>(rawBody)\n const carrierShipmentId = readCarrierShipmentId(payload)\n\n try {\n // The webhook endpoint is unauthenticated. Tenant/organization scope MUST come from a\n // CarrierShipment whose per-tenant credentials successfully verify the inbound\n // signature \u2014 NEVER from attacker-controlled payload metadata or an unsigned retry.\n // If no candidate shipment can be located by the provider-reported carrierShipmentId,\n // or no candidate's credentials can verify the signature, we fail closed with 401.\n // This mirrors the fix landed for payment_gateways in PR #1311.\n const candidates = carrierShipmentId\n ? await findWithDecryption(\n em,\n CarrierShipment,\n {\n providerKey,\n carrierShipmentId,\n deletedAt: null,\n },\n { limit: 10, orderBy: { createdAt: 'desc' } },\n )\n : []\n\n let shipment: CarrierShipment | null = null\n let matchedScope: { organizationId: string; tenantId: string } | null = null\n let event: Awaited<ReturnType<typeof adapter.verifyWebhook>> | 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(`carrier_${providerKey}`, candidateScope) ?? {}\n try {\n event = await adapter.verifyWebhook({ rawBody, headers, credentials })\n shipment = candidate\n matchedScope = candidateScope\n break\n } catch (error: unknown) {\n lastVerificationError = error\n }\n }\n\n if (!event || !shipment || !matchedScope) {\n throw lastVerificationError ?? new Error('Webhook verification failed: no matching shipment')\n }\n\n await queue.enqueue({\n name: 'shipping-carrier-webhook',\n payload: {\n providerKey,\n event,\n shipmentId: shipment.id,\n scope: matchedScope,\n },\n })\n\n return NextResponse.json({ received: true, queued: true }, { status: 202 })\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : 'Webhook verification failed'\n return NextResponse.json({ error: message }, { status: 401 })\n }\n}\n\nexport const openApi = {\n tags: [shippingCarriersTag],\n summary: 'Receive shipping carrier webhook',\n methods: {\n POST: {\n summary: 'Process inbound carrier webhook',\n tags: [shippingCarriersTag],\n responses: [\n { status: 202, description: 'Webhook accepted for async processing' },\n { status: 401, description: 'Signature verification failed' },\n { status: 404, description: 'Unknown provider' },\n ],\n },\n },\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;AAG7B,SAAS,uBAAuB;AAChC,SAAS,0BAA0B;AACnC,SAAS,+BAA+B;AACxC,SAAS,2BAA2B;AAE7B,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,MAAM,EAAE,aAAa,MAAM;AAC7B;AAEA,SAAS,sBAAsB,SAAwD;AACrF,MAAI,CAAC,QAAS,QAAO;AACrB,QAAM,aAAa,QAAQ;AAC3B,MAAI,OAAO,eAAe,YAAY,WAAW,KAAK,EAAE,SAAS,EAAG,QAAO,WAAW,KAAK;AAC3F,QAAM,OAAO,QAAQ;AACrB,MAAI,QAAQ,OAAO,SAAS,UAAU;AACpC,UAAM,SAAU,KAAiC;AACjD,QAAI,OAAO,WAAW,YAAY,OAAO,KAAK,EAAE,SAAS,EAAG,QAAO,OAAO,KAAK;AAAA,EACjF;AACA,SAAO;AACT;AAEA,eAAsB,KAAK,KAAc,EAAE,OAAO,GAAqE;AACrH,QAAM,iBAAiB,MAAM;AAC7B,QAAM,cAAc,eAAe;AACnC,QAAM,UAAU,mBAAmB,WAAW;AAC9C,MAAI,CAAC,SAAS;AACZ,WAAO,aAAa,KAAK,EAAE,OAAO,qCAAqC,WAAW,GAAG,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACzG;AAEA,QAAM,UAAU,MAAM,IAAI,KAAK;AAC/B,QAAM,UAAkC,CAAC;AACzC,MAAI,QAAQ,QAAQ,CAAC,OAAO,QAAQ;AAClC,YAAQ,GAAG,IAAI;AAAA,EACjB,CAAC;AAED,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,KAAK,UAAU,QAAQ,IAAI;AACjC,QAAM,gCAAgC,UAAU,QAAQ,+BAA+B;AACvF,QAAM,QAAQ,wBAAwB,2BAA2B;AACjE,QAAM,UAAU,MAAM,aAAsC,OAAO;AACnE,QAAM,oBAAoB,sBAAsB,OAAO;AAEvD,MAAI;AAOF,UAAM,aAAa,oBACf,MAAM;AAAA,MACN;AAAA,MACA;AAAA,MACA;AAAA,QACE;AAAA,QACA;AAAA,QACA,WAAW;AAAA,MACb;AAAA,MACA,EAAE,OAAO,IAAI,SAAS,EAAE,WAAW,OAAO,EAAE;AAAA,IAC9C,IACE,CAAC;AAEL,QAAI,WAAmC;AACvC,QAAI,eAAoE;AACxE,QAAI,QAAkE;AACtE,QAAI,wBAAiC;AAErC,eAAW,aAAa,YAAY;AAClC,YAAM,iBAAiB,EAAE,gBAAgB,UAAU,gBAAgB,UAAU,UAAU,SAAS;AAChG,YAAM,cAAc,MAAM,8BAA8B,QAAQ,WAAW,WAAW,IAAI,cAAc,KAAK,CAAC;AAC9G,UAAI;AACF,gBAAQ,MAAM,QAAQ,cAAc,EAAE,SAAS,SAAS,YAAY,CAAC;AACrE,mBAAW;AACX,uBAAe;AACf;AAAA,MACF,SAAS,OAAgB;AACvB,gCAAwB;AAAA,MAC1B;AAAA,IACF;AAEA,QAAI,CAAC,SAAS,CAAC,YAAY,CAAC,cAAc;AACxC,YAAM,yBAAyB,IAAI,MAAM,mDAAmD;AAAA,IAC9F;AAEA,UAAM,MAAM,QAAQ;AAAA,MAClB,MAAM;AAAA,MACN,SAAS;AAAA,QACP;AAAA,QACA;AAAA,QACA,YAAY,SAAS;AAAA,QACrB,OAAO;AAAA,MACT;AAAA,IACF,CAAC;AAED,WAAO,aAAa,KAAK,EAAE,UAAU,MAAM,QAAQ,KAAK,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC5E,SAAS,OAAgB;AACvB,UAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU;AACzD,WAAO,aAAa,KAAK,EAAE,OAAO,QAAQ,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC9D;AACF;AAEO,MAAM,UAAU;AAAA,EACrB,MAAM,CAAC,mBAAmB;AAAA,EAC1B,SAAS;AAAA,EACT,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,MAAM,CAAC,mBAAmB;AAAA,MAC1B,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,wCAAwC;AAAA,QACpE,EAAE,QAAQ,KAAK,aAAa,gCAAgC;AAAA,QAC5D,EAAE,QAAQ,KAAK,aAAa,mBAAmB;AAAA,MACjD;AAAA,IACF;AAAA,EACF;AACF;",
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 type { CredentialsService } from '../../../../integrations/lib/credentials-service'\nimport { CarrierShipment } from '../../../data/entities'\nimport { getShippingAdapter } from '../../../lib/adapter-registry'\nimport { getShippingCarrierQueue } from '../../../lib/queue'\nimport { shippingCarriersTag } from '../../openapi'\n\nexport const metadata = {\n path: '/shipping-carriers/webhook/[provider]',\n POST: { requireAuth: false },\n}\n\nconst WEBHOOK_VERIFICATION_FAILED = 'Webhook verification failed'\n\nconst shippingCarrierWebhookRateLimitConfig = {\n points: 60,\n duration: 60,\n keyPrefix: 'shipping_carriers:webhook',\n}\n\nfunction readCarrierShipmentId(payload: Record<string, unknown> | null): string | null {\n if (!payload) return null\n const shipmentId = payload.shipmentId\n if (typeof shipmentId === 'string' && shipmentId.trim().length > 0) return shipmentId.trim()\n const data = payload.data\n if (data && typeof data === 'object') {\n const nested = (data as Record<string, unknown>).shipmentId\n if (typeof nested === 'string' && nested.trim().length > 0) return nested.trim()\n }\n return null\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 adapter = getShippingAdapter(providerKey)\n if (!adapter) {\n return NextResponse.json({ error: `No shipping adapter for provider: ${providerKey}` }, { status: 404 })\n }\n\n const container = await createRequestContainer()\n const rateLimitResponse = await checkProviderWebhookRateLimit(container, req, providerKey)\n if (rateLimitResponse) return rateLimitResponse\n\n const rawBody = await req.text()\n const headers: Record<string, string> = {}\n req.headers.forEach((value, key) => {\n headers[key] = value\n })\n\n const em = container.resolve('em') as EntityManager\n const integrationCredentialsService = container.resolve('integrationCredentialsService') as CredentialsService\n const queue = getShippingCarrierQueue('shipping-carriers-webhook')\n const payload = await readJsonSafe<Record<string, unknown>>(rawBody)\n const carrierShipmentId = readCarrierShipmentId(payload)\n\n try {\n // The webhook endpoint is unauthenticated. Tenant/organization scope MUST come from a\n // CarrierShipment whose per-tenant credentials successfully verify the inbound\n // signature \u2014 NEVER from attacker-controlled payload metadata or an unsigned retry.\n // If no candidate shipment can be located by the provider-reported carrierShipmentId,\n // or no candidate's credentials can verify the signature, we fail closed with 401.\n // This mirrors the fix landed for payment_gateways in PR #1311.\n const candidates = carrierShipmentId\n ? await findWithDecryption(\n em,\n CarrierShipment,\n {\n providerKey,\n carrierShipmentId,\n deletedAt: null,\n },\n { limit: 10, orderBy: { createdAt: 'desc' } },\n )\n : []\n\n let shipment: CarrierShipment | null = null\n let matchedScope: { organizationId: string; tenantId: string } | null = null\n let event: Awaited<ReturnType<typeof adapter.verifyWebhook>> | 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(`carrier_${providerKey}`, candidateScope) ?? {}\n try {\n event = await adapter.verifyWebhook({ rawBody, headers, credentials })\n shipment = candidate\n matchedScope = candidateScope\n break\n } catch (error: unknown) {\n lastVerificationError = error\n }\n }\n\n if (!event || !shipment || !matchedScope) {\n throw lastVerificationError ?? new Error('Webhook verification failed: no matching shipment')\n }\n\n await queue.enqueue({\n name: 'shipping-carrier-webhook',\n payload: {\n providerKey,\n event,\n shipmentId: shipment.id,\n scope: matchedScope,\n },\n })\n\n return NextResponse.json({ received: true, queued: true }, { status: 202 })\n } catch (error: unknown) {\n console.warn(`[shipping_carriers] Webhook verification failed for provider \"${providerKey}\"`, error)\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 shippingCarrierWebhookRateLimitConfig,\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: [shippingCarriersTag],\n summary: 'Receive shipping carrier webhook',\n methods: {\n POST: {\n summary: 'Process inbound carrier webhook',\n tags: [shippingCarriersTag],\n responses: [\n { status: 202, description: 'Webhook accepted for async processing' },\n { status: 401, description: 'Signature verification failed' },\n { status: 404, description: 'Unknown provider' },\n ],\n },\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.5-develop.5226.1.2cf979f8a3",
3
+ "version": "0.6.5-develop.5240.2.bbd9d30275",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -245,16 +245,16 @@
245
245
  "zod": "^4.4.3"
246
246
  },
247
247
  "peerDependencies": {
248
- "@open-mercato/ai-assistant": "0.6.5-develop.5226.1.2cf979f8a3",
249
- "@open-mercato/shared": "0.6.5-develop.5226.1.2cf979f8a3",
250
- "@open-mercato/ui": "0.6.5-develop.5226.1.2cf979f8a3",
248
+ "@open-mercato/ai-assistant": "0.6.5-develop.5240.2.bbd9d30275",
249
+ "@open-mercato/shared": "0.6.5-develop.5240.2.bbd9d30275",
250
+ "@open-mercato/ui": "0.6.5-develop.5240.2.bbd9d30275",
251
251
  "react": "^19.0.0",
252
252
  "react-dom": "^19.0.0"
253
253
  },
254
254
  "devDependencies": {
255
- "@open-mercato/ai-assistant": "0.6.5-develop.5226.1.2cf979f8a3",
256
- "@open-mercato/shared": "0.6.5-develop.5226.1.2cf979f8a3",
257
- "@open-mercato/ui": "0.6.5-develop.5226.1.2cf979f8a3",
255
+ "@open-mercato/ai-assistant": "0.6.5-develop.5240.2.bbd9d30275",
256
+ "@open-mercato/shared": "0.6.5-develop.5240.2.bbd9d30275",
257
+ "@open-mercato/ui": "0.6.5-develop.5240.2.bbd9d30275",
258
258
  "@testing-library/dom": "^10.4.1",
259
259
  "@testing-library/jest-dom": "^6.9.1",
260
260
  "@testing-library/react": "^16.3.1",
@@ -2,6 +2,8 @@ import { NextResponse } from 'next/server'
2
2
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
3
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
4
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
5
+ import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK } from '@open-mercato/shared/lib/ratelimit/helpers'
6
+ import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
5
7
  import type { EntityManager } from '@mikro-orm/postgresql'
6
8
  import { getWebhookHandler } from '@open-mercato/shared/modules/payment_gateways/types'
7
9
  import type { IntegrationLogService } from '../../../../integrations/lib/log-service'
@@ -17,6 +19,14 @@ export const metadata = {
17
19
  POST: { requireAuth: false },
18
20
  }
19
21
 
22
+ const WEBHOOK_VERIFICATION_FAILED = 'Webhook verification failed'
23
+
24
+ const paymentGatewayWebhookRateLimitConfig = {
25
+ points: 60,
26
+ duration: 60,
27
+ keyPrefix: 'payment_gateways:webhook',
28
+ }
29
+
20
30
  export async function POST(req: Request, { params }: { params: Promise<{ provider: string }> | { provider: string } }) {
21
31
  const resolvedParams = await params
22
32
  const providerKey = resolvedParams.provider
@@ -26,6 +36,9 @@ export async function POST(req: Request, { params }: { params: Promise<{ provide
26
36
  return NextResponse.json({ error: `No webhook handler for provider: ${providerKey}` }, { status: 404 })
27
37
  }
28
38
 
39
+ const rateLimitResponse = await checkProviderWebhookRateLimit(container, req, providerKey)
40
+ if (rateLimitResponse) return rateLimitResponse
41
+
29
42
  const rawBody = await req.text()
30
43
  const headers: Record<string, string> = {}
31
44
  req.headers.forEach((value, key) => {
@@ -109,8 +122,32 @@ export async function POST(req: Request, { params }: { params: Promise<{ provide
109
122
 
110
123
  return NextResponse.json({ received: true, queued: true }, { status: 202 })
111
124
  } catch (err: unknown) {
112
- const message = err instanceof Error ? err.message : 'Webhook verification failed'
113
- return NextResponse.json({ error: message }, { status: 401 })
125
+ console.warn(`[payment_gateways] Webhook verification failed for provider "${providerKey}"`, err)
126
+ return NextResponse.json({ error: WEBHOOK_VERIFICATION_FAILED }, { status: 401 })
127
+ }
128
+ }
129
+
130
+ async function checkProviderWebhookRateLimit(
131
+ container: { resolve: (name: string) => unknown },
132
+ req: Request,
133
+ providerKey: string,
134
+ ): Promise<NextResponse | null> {
135
+ const rateLimiterService = tryResolve<RateLimiterService>(container, 'rateLimiterService')
136
+ if (!rateLimiterService) return null
137
+
138
+ return checkRateLimit(
139
+ rateLimiterService,
140
+ paymentGatewayWebhookRateLimitConfig,
141
+ `${providerKey}:${getClientIp(req, rateLimiterService.trustProxyDepth) ?? 'unknown'}`,
142
+ RATE_LIMIT_ERROR_FALLBACK,
143
+ )
144
+ }
145
+
146
+ function tryResolve<T>(container: { resolve: (name: string) => unknown }, name: string): T | null {
147
+ try {
148
+ return container.resolve(name) as T
149
+ } catch {
150
+ return null
114
151
  }
115
152
  }
116
153
 
@@ -2,6 +2,8 @@ import { NextResponse } from 'next/server'
2
2
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
3
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
4
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
5
+ import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK } from '@open-mercato/shared/lib/ratelimit/helpers'
6
+ import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
5
7
  import type { EntityManager } from '@mikro-orm/postgresql'
6
8
  import type { CredentialsService } from '../../../../integrations/lib/credentials-service'
7
9
  import { CarrierShipment } from '../../../data/entities'
@@ -14,6 +16,14 @@ export const metadata = {
14
16
  POST: { requireAuth: false },
15
17
  }
16
18
 
19
+ const WEBHOOK_VERIFICATION_FAILED = 'Webhook verification failed'
20
+
21
+ const shippingCarrierWebhookRateLimitConfig = {
22
+ points: 60,
23
+ duration: 60,
24
+ keyPrefix: 'shipping_carriers:webhook',
25
+ }
26
+
17
27
  function readCarrierShipmentId(payload: Record<string, unknown> | null): string | null {
18
28
  if (!payload) return null
19
29
  const shipmentId = payload.shipmentId
@@ -34,13 +44,16 @@ export async function POST(req: Request, { params }: { params: Promise<{ provide
34
44
  return NextResponse.json({ error: `No shipping adapter for provider: ${providerKey}` }, { status: 404 })
35
45
  }
36
46
 
47
+ const container = await createRequestContainer()
48
+ const rateLimitResponse = await checkProviderWebhookRateLimit(container, req, providerKey)
49
+ if (rateLimitResponse) return rateLimitResponse
50
+
37
51
  const rawBody = await req.text()
38
52
  const headers: Record<string, string> = {}
39
53
  req.headers.forEach((value, key) => {
40
54
  headers[key] = value
41
55
  })
42
56
 
43
- const container = await createRequestContainer()
44
57
  const em = container.resolve('em') as EntityManager
45
58
  const integrationCredentialsService = container.resolve('integrationCredentialsService') as CredentialsService
46
59
  const queue = getShippingCarrierQueue('shipping-carriers-webhook')
@@ -101,8 +114,32 @@ export async function POST(req: Request, { params }: { params: Promise<{ provide
101
114
 
102
115
  return NextResponse.json({ received: true, queued: true }, { status: 202 })
103
116
  } catch (error: unknown) {
104
- const message = error instanceof Error ? error.message : 'Webhook verification failed'
105
- return NextResponse.json({ error: message }, { status: 401 })
117
+ console.warn(`[shipping_carriers] Webhook verification failed for provider "${providerKey}"`, error)
118
+ return NextResponse.json({ error: WEBHOOK_VERIFICATION_FAILED }, { status: 401 })
119
+ }
120
+ }
121
+
122
+ async function checkProviderWebhookRateLimit(
123
+ container: { resolve: (name: string) => unknown },
124
+ req: Request,
125
+ providerKey: string,
126
+ ): Promise<NextResponse | null> {
127
+ const rateLimiterService = tryResolve<RateLimiterService>(container, 'rateLimiterService')
128
+ if (!rateLimiterService) return null
129
+
130
+ return checkRateLimit(
131
+ rateLimiterService,
132
+ shippingCarrierWebhookRateLimitConfig,
133
+ `${providerKey}:${getClientIp(req, rateLimiterService.trustProxyDepth) ?? 'unknown'}`,
134
+ RATE_LIMIT_ERROR_FALLBACK,
135
+ )
136
+ }
137
+
138
+ function tryResolve<T>(container: { resolve: (name: string) => unknown }, name: string): T | null {
139
+ try {
140
+ return container.resolve(name) as T
141
+ } catch {
142
+ return null
106
143
  }
107
144
  }
108
145