@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.
- package/dist/modules/payment_gateways/api/webhook/[provider]/route.js +28 -2
- package/dist/modules/payment_gateways/api/webhook/[provider]/route.js.map +2 -2
- package/dist/modules/shipping_carriers/api/webhook/[provider]/route.js +29 -3
- package/dist/modules/shipping_carriers/api/webhook/[provider]/route.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/payment_gateways/api/webhook/[provider]/route.ts +39 -2
- package/src/modules/shipping_carriers/api/webhook/[provider]/route.ts +40 -3
|
@@ -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
|
-
|
|
88
|
-
return NextResponse.json({ error:
|
|
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
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;
|
|
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
|
-
|
|
84
|
-
return NextResponse.json({ error:
|
|
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
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;
|
|
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.
|
|
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.
|
|
249
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
250
|
-
"@open-mercato/ui": "0.6.5-develop.
|
|
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.
|
|
256
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
257
|
-
"@open-mercato/ui": "0.6.5-develop.
|
|
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
|
-
|
|
113
|
-
return NextResponse.json({ error:
|
|
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
|
-
|
|
105
|
-
return NextResponse.json({ error:
|
|
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
|
|