@open-mercato/webhooks 0.6.7-develop.6584.1.bc9d39be70 → 0.6.7-develop.6586.1.015fe64dfb

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.
@@ -5,7 +5,7 @@ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
5
5
  import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_ERROR_KEY } from "@open-mercato/shared/lib/ratelimit/helpers";
6
6
  import { emitWebhooksEvent } from "../../../events.js";
7
7
  import { getWebhookEndpointAdapter } from "../../../lib/adapter-registry.js";
8
- import { isWebhookIntegrationEnabled } from "../../../lib/integration-state.js";
8
+ import { isWebhookIntegrationEnabled, WEBHOOK_INTEGRATION_DISABLED_MESSAGE } from "../../../lib/integration-state.js";
9
9
  import { json } from "../../helpers.js";
10
10
  import { WebhookInboundReceiptEntity } from "../../../data/entities.js";
11
11
  const metadata = {
@@ -47,14 +47,19 @@ async function POST(request, context) {
47
47
  } catch {
48
48
  return json({ error: "Verification failed" }, { status: 400 });
49
49
  }
50
- if (verified.tenantId && verified.organizationId) {
51
- const integrationEnabled = await isWebhookIntegrationEnabled(em, {
52
- tenantId: verified.tenantId,
53
- organizationId: verified.organizationId
54
- });
50
+ const hasTenantId = Boolean(verified.tenantId);
51
+ const hasOrganizationId = Boolean(verified.organizationId);
52
+ if (hasTenantId !== hasOrganizationId) {
53
+ return json({ error: WEBHOOK_INTEGRATION_DISABLED_MESSAGE }, { status: 503 });
54
+ }
55
+ const integrationScope = hasTenantId && hasOrganizationId ? { tenantId: verified.tenantId, organizationId: verified.organizationId } : null;
56
+ if (integrationScope) {
57
+ const integrationEnabled = await isWebhookIntegrationEnabled(em, integrationScope);
55
58
  if (!integrationEnabled) {
56
- return json({ error: "Custom Webhooks integration is disabled" }, { status: 503 });
59
+ return json({ error: WEBHOOK_INTEGRATION_DISABLED_MESSAGE }, { status: 503 });
57
60
  }
61
+ } else if (!adapter.allowUnscopedInbound) {
62
+ return json({ error: WEBHOOK_INTEGRATION_DISABLED_MESSAGE }, { status: 503 });
58
63
  }
59
64
  const messageId = resolveInboundReceiptMessageId({
60
65
  endpointId: params.endpointId,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/webhooks/api/inbound/%5BendpointId%5D/route.ts"],
4
- "sourcesContent": ["import { z } from 'zod'\nimport { createHash } from 'node:crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_ERROR_KEY } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'\nimport { emitWebhooksEvent } from '../../../events'\nimport { getWebhookEndpointAdapter } from '../../../lib/adapter-registry'\nimport { isWebhookIntegrationEnabled } from '../../../lib/integration-state'\nimport { json } from '../../helpers'\nimport { WebhookInboundReceiptEntity } from '../../../data/entities'\n\nexport const metadata = {\n POST: { requireAuth: false },\n}\n\ninterface RouteContext {\n params: Promise<{ endpointId: string }>\n}\n\nconst inboundResponseSchema = z.object({\n ok: z.boolean(),\n duplicate: z.boolean().optional(),\n})\n\nconst errorSchema = z.object({ error: z.string() })\n\nexport async function POST(request: Request, context: RouteContext): Promise<Response> {\n const params = await context.params\n const adapter = getWebhookEndpointAdapter(params.endpointId)\n const { translate } = await resolveTranslations()\n\n if (!adapter) {\n return json({ error: 'Webhook endpoint not found' }, { status: 404 })\n }\n\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager).fork()\n const rateLimiterService = tryResolve<RateLimiterService>(container, 'rateLimiterService')\n\n if (rateLimiterService) {\n const rateLimitResponse = await checkRateLimit(\n rateLimiterService,\n { points: 60, duration: 60, keyPrefix: `webhooks:inbound:${params.endpointId}` },\n buildInboundRateLimitKey(params.endpointId, request, rateLimiterService.trustProxyDepth),\n translate(RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK),\n )\n\n if (rateLimitResponse) return rateLimitResponse\n }\n\n const body = await request.text()\n const headers = Object.fromEntries(request.headers.entries())\n let verified: Awaited<ReturnType<typeof adapter.verifyWebhook>>\n try {\n verified = await adapter.verifyWebhook({\n headers,\n body,\n method: request.method,\n })\n } catch {\n return json({ error: 'Verification failed' }, { status: 400 })\n }\n\n if (verified.tenantId && verified.organizationId) {\n const integrationEnabled = await isWebhookIntegrationEnabled(em, {\n tenantId: verified.tenantId,\n organizationId: verified.organizationId,\n })\n\n if (!integrationEnabled) {\n return json({ error: 'Custom Webhooks integration is disabled' }, { status: 503 })\n }\n }\n\n const messageId = resolveInboundReceiptMessageId({\n endpointId: params.endpointId,\n providerKey: adapter.providerKey,\n headers,\n body,\n })\n try {\n em.persist(em.create(WebhookInboundReceiptEntity, {\n endpointId: params.endpointId,\n messageId,\n providerKey: adapter.providerKey,\n eventType: verified.eventType,\n tenantId: verified.tenantId ?? null,\n organizationId: verified.organizationId ?? null,\n createdAt: new Date(),\n }))\n await em.flush()\n } catch (error) {\n if (isUniqueViolation(error)) {\n return json({ ok: true, duplicate: true })\n }\n throw error\n }\n\n await emitWebhooksEvent('webhooks.inbound.received', {\n providerKey: adapter.providerKey,\n endpointId: params.endpointId,\n messageId,\n eventType: verified.eventType,\n payload: verified.payload,\n tenantId: verified.tenantId ?? null,\n organizationId: verified.organizationId ?? null,\n }, { persistent: true })\n\n return json({ ok: true })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Receive inbound webhook',\n description: 'Verifies, rate limits, deduplicates, and processes an inbound webhook using the registered endpoint adapter.',\n methods: {\n POST: {\n summary: 'Receive inbound webhook',\n description: 'Endpoint ids currently resolve to registered adapter provider keys.',\n pathParams: z.object({ endpointId: z.string().min(1) }),\n responses: [{ status: 200, description: 'Inbound webhook accepted', schema: inboundResponseSchema }],\n errors: [\n { status: 400, description: 'Verification failed', schema: errorSchema },\n { status: 404, description: 'Endpoint not found', schema: errorSchema },\n { status: 429, description: 'Rate limit exceeded', schema: errorSchema },\n { status: 503, description: 'Webhook integration disabled', schema: errorSchema },\n ],\n },\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\nfunction isUniqueViolation(error: unknown): boolean {\n if (!error || typeof error !== 'object') return false\n const maybeError = error as { code?: string; cause?: unknown }\n if (maybeError.code === '23505') return true\n if (!maybeError.cause || typeof maybeError.cause !== 'object') return false\n return (maybeError.cause as { code?: string }).code === '23505'\n}\n\ntype ResolveInboundReceiptMessageIdInput = {\n endpointId: string\n providerKey: string\n headers: Record<string, string>\n body: string\n}\n\nexport function buildInboundRateLimitKey(endpointId: string, request: Request, trustProxyDepth: number): string {\n const clientIp = getClientIp(request, trustProxyDepth)\n return clientIp ? `${endpointId}:ip:${clientIp}` : `${endpointId}:global`\n}\n\nexport function resolveInboundReceiptMessageId(\n input: ResolveInboundReceiptMessageIdInput\n): string {\n const explicitMessageId = input.headers['webhook-id'] ?? input.headers['svix-id'] ?? null\n if (typeof explicitMessageId === 'string' && explicitMessageId.trim().length > 0) {\n return explicitMessageId.trim()\n }\n\n const timestamp =\n input.headers['webhook-timestamp'] ??\n input.headers['svix-timestamp'] ??\n null\n\n if (typeof timestamp === 'string' && timestamp.trim().length > 0) {\n const digest = createHash('sha256')\n .update(input.providerKey)\n .update(':')\n .update(input.endpointId)\n .update(':')\n .update(timestamp.trim())\n .update(':')\n .update(input.body)\n .digest('hex')\n\n return `derived:${timestamp.trim()}:${digest}`\n }\n\n const digest = createHash('sha256')\n .update(input.providerKey)\n .update(':')\n .update(input.endpointId)\n .update(':')\n .update(input.body)\n .digest('hex')\n\n return `derived:no-timestamp:${digest}`\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { z } from 'zod'\nimport { createHash } from 'node:crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_ERROR_KEY } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'\nimport { emitWebhooksEvent } from '../../../events'\nimport { getWebhookEndpointAdapter } from '../../../lib/adapter-registry'\nimport { isWebhookIntegrationEnabled, WEBHOOK_INTEGRATION_DISABLED_MESSAGE } from '../../../lib/integration-state'\nimport { json } from '../../helpers'\nimport { WebhookInboundReceiptEntity } from '../../../data/entities'\n\nexport const metadata = {\n POST: { requireAuth: false },\n}\n\ninterface RouteContext {\n params: Promise<{ endpointId: string }>\n}\n\nconst inboundResponseSchema = z.object({\n ok: z.boolean(),\n duplicate: z.boolean().optional(),\n})\n\nconst errorSchema = z.object({ error: z.string() })\n\nexport async function POST(request: Request, context: RouteContext): Promise<Response> {\n const params = await context.params\n const adapter = getWebhookEndpointAdapter(params.endpointId)\n const { translate } = await resolveTranslations()\n\n if (!adapter) {\n return json({ error: 'Webhook endpoint not found' }, { status: 404 })\n }\n\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager).fork()\n const rateLimiterService = tryResolve<RateLimiterService>(container, 'rateLimiterService')\n\n if (rateLimiterService) {\n const rateLimitResponse = await checkRateLimit(\n rateLimiterService,\n { points: 60, duration: 60, keyPrefix: `webhooks:inbound:${params.endpointId}` },\n buildInboundRateLimitKey(params.endpointId, request, rateLimiterService.trustProxyDepth),\n translate(RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK),\n )\n\n if (rateLimitResponse) return rateLimitResponse\n }\n\n const body = await request.text()\n const headers = Object.fromEntries(request.headers.entries())\n let verified: Awaited<ReturnType<typeof adapter.verifyWebhook>>\n try {\n verified = await adapter.verifyWebhook({\n headers,\n body,\n method: request.method,\n })\n } catch {\n return json({ error: 'Verification failed' }, { status: 400 })\n }\n\n const hasTenantId = Boolean(verified.tenantId)\n const hasOrganizationId = Boolean(verified.organizationId)\n if (hasTenantId !== hasOrganizationId) {\n return json({ error: WEBHOOK_INTEGRATION_DISABLED_MESSAGE }, { status: 503 })\n }\n\n const integrationScope = hasTenantId && hasOrganizationId\n ? { tenantId: verified.tenantId as string, organizationId: verified.organizationId as string }\n : null\n\n if (integrationScope) {\n const integrationEnabled = await isWebhookIntegrationEnabled(em, integrationScope)\n if (!integrationEnabled) {\n return json({ error: WEBHOOK_INTEGRATION_DISABLED_MESSAGE }, { status: 503 })\n }\n } else if (!adapter.allowUnscopedInbound) {\n return json({ error: WEBHOOK_INTEGRATION_DISABLED_MESSAGE }, { status: 503 })\n }\n\n const messageId = resolveInboundReceiptMessageId({\n endpointId: params.endpointId,\n providerKey: adapter.providerKey,\n headers,\n body,\n })\n try {\n em.persist(em.create(WebhookInboundReceiptEntity, {\n endpointId: params.endpointId,\n messageId,\n providerKey: adapter.providerKey,\n eventType: verified.eventType,\n tenantId: verified.tenantId ?? null,\n organizationId: verified.organizationId ?? null,\n createdAt: new Date(),\n }))\n await em.flush()\n } catch (error) {\n if (isUniqueViolation(error)) {\n return json({ ok: true, duplicate: true })\n }\n throw error\n }\n\n await emitWebhooksEvent('webhooks.inbound.received', {\n providerKey: adapter.providerKey,\n endpointId: params.endpointId,\n messageId,\n eventType: verified.eventType,\n payload: verified.payload,\n tenantId: verified.tenantId ?? null,\n organizationId: verified.organizationId ?? null,\n }, { persistent: true })\n\n return json({ ok: true })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Receive inbound webhook',\n description: 'Verifies, rate limits, deduplicates, and processes an inbound webhook using the registered endpoint adapter.',\n methods: {\n POST: {\n summary: 'Receive inbound webhook',\n description: 'Endpoint ids currently resolve to registered adapter provider keys.',\n pathParams: z.object({ endpointId: z.string().min(1) }),\n responses: [{ status: 200, description: 'Inbound webhook accepted', schema: inboundResponseSchema }],\n errors: [\n { status: 400, description: 'Verification failed', schema: errorSchema },\n { status: 404, description: 'Endpoint not found', schema: errorSchema },\n { status: 429, description: 'Rate limit exceeded', schema: errorSchema },\n { status: 503, description: 'Webhook integration disabled', schema: errorSchema },\n ],\n },\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\nfunction isUniqueViolation(error: unknown): boolean {\n if (!error || typeof error !== 'object') return false\n const maybeError = error as { code?: string; cause?: unknown }\n if (maybeError.code === '23505') return true\n if (!maybeError.cause || typeof maybeError.cause !== 'object') return false\n return (maybeError.cause as { code?: string }).code === '23505'\n}\n\ntype ResolveInboundReceiptMessageIdInput = {\n endpointId: string\n providerKey: string\n headers: Record<string, string>\n body: string\n}\n\nexport function buildInboundRateLimitKey(endpointId: string, request: Request, trustProxyDepth: number): string {\n const clientIp = getClientIp(request, trustProxyDepth)\n return clientIp ? `${endpointId}:ip:${clientIp}` : `${endpointId}:global`\n}\n\nexport function resolveInboundReceiptMessageId(\n input: ResolveInboundReceiptMessageIdInput\n): string {\n const explicitMessageId = input.headers['webhook-id'] ?? input.headers['svix-id'] ?? null\n if (typeof explicitMessageId === 'string' && explicitMessageId.trim().length > 0) {\n return explicitMessageId.trim()\n }\n\n const timestamp =\n input.headers['webhook-timestamp'] ??\n input.headers['svix-timestamp'] ??\n null\n\n if (typeof timestamp === 'string' && timestamp.trim().length > 0) {\n const digest = createHash('sha256')\n .update(input.providerKey)\n .update(':')\n .update(input.endpointId)\n .update(':')\n .update(timestamp.trim())\n .update(':')\n .update(input.body)\n .digest('hex')\n\n return `derived:${timestamp.trim()}:${digest}`\n }\n\n const digest = createHash('sha256')\n .update(input.providerKey)\n .update(':')\n .update(input.endpointId)\n .update(':')\n .update(input.body)\n .digest('hex')\n\n return `derived:no-timestamp:${digest}`\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["digest"]
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/webhooks/lib/adapter-registry.ts"],
4
- "sourcesContent": ["const WEBHOOK_ENDPOINT_ADAPTERS_KEY = '__openMercatoWebhookEndpointAdapters__'\n\nexport interface WebhookEndpointAdapter {\n readonly providerKey: string\n readonly subscribedEvents: string[]\n\n formatPayload?(event: { type: string; data: unknown }): Promise<{\n url: string\n headers: Record<string, string>\n body: Record<string, unknown>\n method: 'POST' | 'PUT' | 'PATCH'\n }>\n\n verifyWebhook(input: {\n headers: Record<string, string>\n body: string\n method: string\n }): Promise<{\n eventType: string\n payload: Record<string, unknown>\n tenantId?: string\n organizationId?: string\n }>\n\n processInbound(event: {\n eventType: string\n payload: Record<string, unknown>\n tenantId?: string\n organizationId?: string\n providerKey: string\n }): Promise<void>\n}\n\nfunction getRegistry(): Map<string, WebhookEndpointAdapter> {\n const globalState = globalThis as typeof globalThis & {\n [WEBHOOK_ENDPOINT_ADAPTERS_KEY]?: Map<string, WebhookEndpointAdapter>\n }\n\n if (!globalState[WEBHOOK_ENDPOINT_ADAPTERS_KEY]) {\n globalState[WEBHOOK_ENDPOINT_ADAPTERS_KEY] = new Map<string, WebhookEndpointAdapter>()\n }\n\n return globalState[WEBHOOK_ENDPOINT_ADAPTERS_KEY]\n}\n\nexport function registerWebhookEndpointAdapter(adapter: WebhookEndpointAdapter): () => void {\n const registry = getRegistry()\n registry.set(adapter.providerKey, adapter)\n return () => {\n registry.delete(adapter.providerKey)\n }\n}\n\nexport function getWebhookEndpointAdapter(providerKey: string): WebhookEndpointAdapter | undefined {\n return getRegistry().get(providerKey)\n}\n\nexport function listWebhookEndpointAdapters(): WebhookEndpointAdapter[] {\n return Array.from(getRegistry().values())\n}\n\nexport function clearWebhookEndpointAdapters(): void {\n getRegistry().clear()\n}\n"],
5
- "mappings": "AAAA,MAAM,gCAAgC;AAiCtC,SAAS,cAAmD;AAC1D,QAAM,cAAc;AAIpB,MAAI,CAAC,YAAY,6BAA6B,GAAG;AAC/C,gBAAY,6BAA6B,IAAI,oBAAI,IAAoC;AAAA,EACvF;AAEA,SAAO,YAAY,6BAA6B;AAClD;AAEO,SAAS,+BAA+B,SAA6C;AAC1F,QAAM,WAAW,YAAY;AAC7B,WAAS,IAAI,QAAQ,aAAa,OAAO;AACzC,SAAO,MAAM;AACX,aAAS,OAAO,QAAQ,WAAW;AAAA,EACrC;AACF;AAEO,SAAS,0BAA0B,aAAyD;AACjG,SAAO,YAAY,EAAE,IAAI,WAAW;AACtC;AAEO,SAAS,8BAAwD;AACtE,SAAO,MAAM,KAAK,YAAY,EAAE,OAAO,CAAC;AAC1C;AAEO,SAAS,+BAAqC;AACnD,cAAY,EAAE,MAAM;AACtB;",
4
+ "sourcesContent": ["const WEBHOOK_ENDPOINT_ADAPTERS_KEY = '__openMercatoWebhookEndpointAdapters__'\n\nexport interface WebhookEndpointAdapter {\n readonly providerKey: string\n readonly subscribedEvents: string[]\n /**\n * Opt-in for adapters whose verified events intentionally cannot be mapped to\n * tenant/organization scope. Unscoped events cannot be checked against the\n * Custom Webhooks integration enabled state, so the inbound route rejects them\n * by default unless an adapter explicitly declares this behavior.\n */\n readonly allowUnscopedInbound?: boolean\n\n formatPayload?(event: { type: string; data: unknown }): Promise<{\n url: string\n headers: Record<string, string>\n body: Record<string, unknown>\n method: 'POST' | 'PUT' | 'PATCH'\n }>\n\n verifyWebhook(input: {\n headers: Record<string, string>\n body: string\n method: string\n }): Promise<{\n eventType: string\n payload: Record<string, unknown>\n tenantId?: string\n organizationId?: string\n }>\n\n processInbound(event: {\n eventType: string\n payload: Record<string, unknown>\n tenantId?: string\n organizationId?: string\n providerKey: string\n }): Promise<void>\n}\n\nfunction getRegistry(): Map<string, WebhookEndpointAdapter> {\n const globalState = globalThis as typeof globalThis & {\n [WEBHOOK_ENDPOINT_ADAPTERS_KEY]?: Map<string, WebhookEndpointAdapter>\n }\n\n if (!globalState[WEBHOOK_ENDPOINT_ADAPTERS_KEY]) {\n globalState[WEBHOOK_ENDPOINT_ADAPTERS_KEY] = new Map<string, WebhookEndpointAdapter>()\n }\n\n return globalState[WEBHOOK_ENDPOINT_ADAPTERS_KEY]\n}\n\nexport function registerWebhookEndpointAdapter(adapter: WebhookEndpointAdapter): () => void {\n const registry = getRegistry()\n registry.set(adapter.providerKey, adapter)\n return () => {\n registry.delete(adapter.providerKey)\n }\n}\n\nexport function getWebhookEndpointAdapter(providerKey: string): WebhookEndpointAdapter | undefined {\n return getRegistry().get(providerKey)\n}\n\nexport function listWebhookEndpointAdapters(): WebhookEndpointAdapter[] {\n return Array.from(getRegistry().values())\n}\n\nexport function clearWebhookEndpointAdapters(): void {\n getRegistry().clear()\n}\n"],
5
+ "mappings": "AAAA,MAAM,gCAAgC;AAwCtC,SAAS,cAAmD;AAC1D,QAAM,cAAc;AAIpB,MAAI,CAAC,YAAY,6BAA6B,GAAG;AAC/C,gBAAY,6BAA6B,IAAI,oBAAI,IAAoC;AAAA,EACvF;AAEA,SAAO,YAAY,6BAA6B;AAClD;AAEO,SAAS,+BAA+B,SAA6C;AAC1F,QAAM,WAAW,YAAY;AAC7B,WAAS,IAAI,QAAQ,aAAa,OAAO;AACzC,SAAO,MAAM;AACX,aAAS,OAAO,QAAQ,WAAW;AAAA,EACrC;AACF;AAEO,SAAS,0BAA0B,aAAyD;AACjG,SAAO,YAAY,EAAE,IAAI,WAAW;AACtC;AAEO,SAAS,8BAAwD;AACtE,SAAO,MAAM,KAAK,YAAY,EAAE,OAAO,CAAC;AAC1C;AAEO,SAAS,+BAAqC;AACnD,cAAY,EAAE,MAAM;AACtB;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/webhooks",
3
- "version": "0.6.7-develop.6584.1.bc9d39be70",
3
+ "version": "0.6.7-develop.6586.1.015fe64dfb",
4
4
  "license": "MIT",
5
5
  "description": "Webhooks module for Open Mercato — Standard Webhooks compliant outbound/inbound delivery",
6
6
  "type": "module",
@@ -70,19 +70,19 @@
70
70
  }
71
71
  },
72
72
  "dependencies": {
73
- "@open-mercato/core": "0.6.7-develop.6584.1.bc9d39be70",
74
- "@open-mercato/queue": "0.6.7-develop.6584.1.bc9d39be70",
75
- "@open-mercato/ui": "0.6.7-develop.6584.1.bc9d39be70",
73
+ "@open-mercato/core": "0.6.7-develop.6586.1.015fe64dfb",
74
+ "@open-mercato/queue": "0.6.7-develop.6586.1.015fe64dfb",
75
+ "@open-mercato/ui": "0.6.7-develop.6586.1.015fe64dfb",
76
76
  "svix": "^1.96.1"
77
77
  },
78
78
  "peerDependencies": {
79
79
  "@mikro-orm/postgresql": "^7.0.14",
80
- "@open-mercato/shared": "0.6.7-develop.6584.1.bc9d39be70",
80
+ "@open-mercato/shared": "0.6.7-develop.6586.1.015fe64dfb",
81
81
  "react": "^19.0.0",
82
82
  "react-dom": "^19.0.0"
83
83
  },
84
84
  "devDependencies": {
85
- "@open-mercato/shared": "0.6.7-develop.6584.1.bc9d39be70",
85
+ "@open-mercato/shared": "0.6.7-develop.6586.1.015fe64dfb",
86
86
  "@types/jest": "^30.0.0",
87
87
  "@types/react": "^19.2.17",
88
88
  "@types/react-dom": "^19.2.3",
@@ -1,4 +1,244 @@
1
- import { buildInboundRateLimitKey, resolveInboundReceiptMessageId } from '../route'
1
+ /** @jest-environment node */
2
+
3
+ const mockCreateRequestContainer = jest.fn()
4
+ const mockResolveTranslations = jest.fn()
5
+ const mockEmitWebhooksEvent = jest.fn()
6
+ const mockGetWebhookEndpointAdapter = jest.fn()
7
+ const mockIsWebhookIntegrationEnabled = jest.fn()
8
+
9
+ const mockPersistedEntities: unknown[] = []
10
+
11
+ const mockRouteEm = {
12
+ create: jest.fn((_entity: unknown, data: Record<string, unknown>) => ({ ...data })),
13
+ persist: jest.fn((entity: unknown) => {
14
+ mockPersistedEntities.push(entity)
15
+ return mockRouteEm
16
+ }),
17
+ flush: jest.fn(async () => undefined),
18
+ }
19
+
20
+ const mockRootEm = {
21
+ fork: jest.fn(() => mockRouteEm),
22
+ }
23
+
24
+ const mockContainer = {
25
+ resolve: jest.fn((name: string) => {
26
+ if (name === 'em') return mockRootEm
27
+ throw new Error(`Unexpected dependency: ${name}`)
28
+ }),
29
+ }
30
+
31
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
32
+ createRequestContainer: () => mockCreateRequestContainer(),
33
+ }))
34
+
35
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
36
+ resolveTranslations: () => mockResolveTranslations(),
37
+ }))
38
+
39
+ jest.mock('../../../../events', () => ({
40
+ emitWebhooksEvent: (...args: unknown[]) => mockEmitWebhooksEvent(...args),
41
+ }))
42
+
43
+ jest.mock('../../../../lib/adapter-registry', () => ({
44
+ getWebhookEndpointAdapter: (...args: unknown[]) => mockGetWebhookEndpointAdapter(...args),
45
+ }))
46
+
47
+ jest.mock('../../../../lib/integration-state', () => ({
48
+ isWebhookIntegrationEnabled: (...args: unknown[]) => mockIsWebhookIntegrationEnabled(...args),
49
+ WEBHOOK_INTEGRATION_DISABLED_MESSAGE: 'Custom Webhooks integration is disabled',
50
+ }))
51
+
52
+ jest.mock('../../../helpers', () => ({
53
+ json: (payload: unknown, init: ResponseInit = { status: 200 }) =>
54
+ new Response(JSON.stringify(payload), {
55
+ ...init,
56
+ headers: { 'content-type': 'application/json' },
57
+ }),
58
+ }))
59
+
60
+ import type { WebhookEndpointAdapter } from '../../../../lib/adapter-registry'
61
+ import { POST, buildInboundRateLimitKey, resolveInboundReceiptMessageId } from '../route'
62
+
63
+ const routeContext = { params: Promise.resolve({ endpointId: 'mock_inbound' }) }
64
+
65
+ function createRequest(body: string = '{"ok":true}') {
66
+ return new Request('http://localhost/api/webhooks/inbound/mock_inbound', {
67
+ method: 'POST',
68
+ headers: {
69
+ 'content-type': 'application/json',
70
+ 'webhook-id': 'msg-1',
71
+ },
72
+ body,
73
+ })
74
+ }
75
+
76
+ function createAdapter(
77
+ verified: Awaited<ReturnType<WebhookEndpointAdapter['verifyWebhook']>>,
78
+ overrides: Partial<WebhookEndpointAdapter> = {},
79
+ ): WebhookEndpointAdapter {
80
+ return {
81
+ providerKey: 'mock_inbound',
82
+ subscribedEvents: ['*'],
83
+ verifyWebhook: jest.fn(async () => verified),
84
+ processInbound: jest.fn(async () => undefined),
85
+ ...overrides,
86
+ }
87
+ }
88
+
89
+ describe('POST', () => {
90
+ beforeEach(() => {
91
+ jest.clearAllMocks()
92
+ mockPersistedEntities.length = 0
93
+ mockCreateRequestContainer.mockResolvedValue(mockContainer)
94
+ mockResolveTranslations.mockResolvedValue({
95
+ translate: (_key: string, fallback?: string) => fallback ?? '',
96
+ })
97
+ mockIsWebhookIntegrationEnabled.mockResolvedValue(true)
98
+ })
99
+
100
+ it('rejects verified inbound webhooks without tenant and organization scope before side effects', async () => {
101
+ mockGetWebhookEndpointAdapter.mockReturnValue(createAdapter({
102
+ eventType: 'mock.inbound.received',
103
+ payload: { ok: true },
104
+ }))
105
+
106
+ const response = await POST(createRequest(), routeContext)
107
+
108
+ expect(response.status).toBe(503)
109
+ await expect(response.json()).resolves.toEqual({
110
+ error: 'Custom Webhooks integration is disabled',
111
+ })
112
+ expect(mockIsWebhookIntegrationEnabled).not.toHaveBeenCalled()
113
+ expect(mockRouteEm.persist).not.toHaveBeenCalled()
114
+ expect(mockRouteEm.flush).not.toHaveBeenCalled()
115
+ expect(mockEmitWebhooksEvent).not.toHaveBeenCalled()
116
+ })
117
+
118
+ it.each([
119
+ ['tenant only', { tenantId: 'tenant-1' }],
120
+ ['organization only', { organizationId: 'org-1' }],
121
+ ] as const)('rejects verified inbound webhooks with partial scope before side effects: %s', async (_label, partialScope) => {
122
+ mockGetWebhookEndpointAdapter.mockReturnValue(createAdapter({
123
+ eventType: 'mock.inbound.received',
124
+ payload: { ok: true },
125
+ ...partialScope,
126
+ }))
127
+
128
+ const response = await POST(createRequest(), routeContext)
129
+
130
+ expect(response.status).toBe(503)
131
+ await expect(response.json()).resolves.toEqual({
132
+ error: 'Custom Webhooks integration is disabled',
133
+ })
134
+ expect(mockIsWebhookIntegrationEnabled).not.toHaveBeenCalled()
135
+ expect(mockRouteEm.persist).not.toHaveBeenCalled()
136
+ expect(mockRouteEm.flush).not.toHaveBeenCalled()
137
+ expect(mockEmitWebhooksEvent).not.toHaveBeenCalled()
138
+ })
139
+
140
+ it('allows adapters that explicitly opt in to unscoped inbound events', async () => {
141
+ mockGetWebhookEndpointAdapter.mockReturnValue(createAdapter(
142
+ {
143
+ eventType: 'mock.inbound.received',
144
+ payload: { ok: true },
145
+ },
146
+ { allowUnscopedInbound: true },
147
+ ))
148
+
149
+ const response = await POST(createRequest(), routeContext)
150
+
151
+ expect(response.status).toBe(200)
152
+ await expect(response.json()).resolves.toEqual({ ok: true })
153
+ expect(mockIsWebhookIntegrationEnabled).not.toHaveBeenCalled()
154
+ expect(mockPersistedEntities).toEqual([
155
+ expect.objectContaining({
156
+ endpointId: 'mock_inbound',
157
+ messageId: 'msg-1',
158
+ providerKey: 'mock_inbound',
159
+ eventType: 'mock.inbound.received',
160
+ tenantId: null,
161
+ organizationId: null,
162
+ }),
163
+ ])
164
+ expect(mockEmitWebhooksEvent).toHaveBeenCalledWith(
165
+ 'webhooks.inbound.received',
166
+ expect.objectContaining({
167
+ providerKey: 'mock_inbound',
168
+ endpointId: 'mock_inbound',
169
+ messageId: 'msg-1',
170
+ eventType: 'mock.inbound.received',
171
+ tenantId: null,
172
+ organizationId: null,
173
+ }),
174
+ { persistent: true },
175
+ )
176
+ })
177
+
178
+ it('rejects scoped inbound webhooks before side effects when the integration is disabled', async () => {
179
+ mockIsWebhookIntegrationEnabled.mockResolvedValue(false)
180
+ mockGetWebhookEndpointAdapter.mockReturnValue(createAdapter({
181
+ eventType: 'mock.inbound.received',
182
+ payload: { ok: true },
183
+ tenantId: 'tenant-1',
184
+ organizationId: 'org-1',
185
+ }))
186
+
187
+ const response = await POST(createRequest(), routeContext)
188
+
189
+ expect(response.status).toBe(503)
190
+ await expect(response.json()).resolves.toEqual({
191
+ error: 'Custom Webhooks integration is disabled',
192
+ })
193
+ expect(mockIsWebhookIntegrationEnabled).toHaveBeenCalledWith(mockRouteEm, {
194
+ tenantId: 'tenant-1',
195
+ organizationId: 'org-1',
196
+ })
197
+ expect(mockRouteEm.persist).not.toHaveBeenCalled()
198
+ expect(mockRouteEm.flush).not.toHaveBeenCalled()
199
+ expect(mockEmitWebhooksEvent).not.toHaveBeenCalled()
200
+ })
201
+
202
+ it('persists and emits scoped inbound webhooks when the integration is enabled', async () => {
203
+ mockGetWebhookEndpointAdapter.mockReturnValue(createAdapter({
204
+ eventType: 'mock.inbound.received',
205
+ payload: { ok: true },
206
+ tenantId: 'tenant-1',
207
+ organizationId: 'org-1',
208
+ }))
209
+
210
+ const response = await POST(createRequest(), routeContext)
211
+
212
+ expect(response.status).toBe(200)
213
+ await expect(response.json()).resolves.toEqual({ ok: true })
214
+ expect(mockIsWebhookIntegrationEnabled).toHaveBeenCalledWith(mockRouteEm, {
215
+ tenantId: 'tenant-1',
216
+ organizationId: 'org-1',
217
+ })
218
+ expect(mockPersistedEntities).toEqual([
219
+ expect.objectContaining({
220
+ endpointId: 'mock_inbound',
221
+ messageId: 'msg-1',
222
+ providerKey: 'mock_inbound',
223
+ eventType: 'mock.inbound.received',
224
+ tenantId: 'tenant-1',
225
+ organizationId: 'org-1',
226
+ }),
227
+ ])
228
+ expect(mockEmitWebhooksEvent).toHaveBeenCalledWith(
229
+ 'webhooks.inbound.received',
230
+ expect.objectContaining({
231
+ providerKey: 'mock_inbound',
232
+ endpointId: 'mock_inbound',
233
+ messageId: 'msg-1',
234
+ eventType: 'mock.inbound.received',
235
+ tenantId: 'tenant-1',
236
+ organizationId: 'org-1',
237
+ }),
238
+ { persistent: true },
239
+ )
240
+ })
241
+ })
2
242
 
3
243
  describe('buildInboundRateLimitKey', () => {
4
244
  it('uses an endpoint-global bucket when proxy headers are untrusted', () => {
@@ -8,7 +8,7 @@ import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_ERRO
8
8
  import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
9
9
  import { emitWebhooksEvent } from '../../../events'
10
10
  import { getWebhookEndpointAdapter } from '../../../lib/adapter-registry'
11
- import { isWebhookIntegrationEnabled } from '../../../lib/integration-state'
11
+ import { isWebhookIntegrationEnabled, WEBHOOK_INTEGRATION_DISABLED_MESSAGE } from '../../../lib/integration-state'
12
12
  import { json } from '../../helpers'
13
13
  import { WebhookInboundReceiptEntity } from '../../../data/entities'
14
14
 
@@ -64,15 +64,23 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
64
64
  return json({ error: 'Verification failed' }, { status: 400 })
65
65
  }
66
66
 
67
- if (verified.tenantId && verified.organizationId) {
68
- const integrationEnabled = await isWebhookIntegrationEnabled(em, {
69
- tenantId: verified.tenantId,
70
- organizationId: verified.organizationId,
71
- })
67
+ const hasTenantId = Boolean(verified.tenantId)
68
+ const hasOrganizationId = Boolean(verified.organizationId)
69
+ if (hasTenantId !== hasOrganizationId) {
70
+ return json({ error: WEBHOOK_INTEGRATION_DISABLED_MESSAGE }, { status: 503 })
71
+ }
72
+
73
+ const integrationScope = hasTenantId && hasOrganizationId
74
+ ? { tenantId: verified.tenantId as string, organizationId: verified.organizationId as string }
75
+ : null
72
76
 
77
+ if (integrationScope) {
78
+ const integrationEnabled = await isWebhookIntegrationEnabled(em, integrationScope)
73
79
  if (!integrationEnabled) {
74
- return json({ error: 'Custom Webhooks integration is disabled' }, { status: 503 })
80
+ return json({ error: WEBHOOK_INTEGRATION_DISABLED_MESSAGE }, { status: 503 })
75
81
  }
82
+ } else if (!adapter.allowUnscopedInbound) {
83
+ return json({ error: WEBHOOK_INTEGRATION_DISABLED_MESSAGE }, { status: 503 })
76
84
  }
77
85
 
78
86
  const messageId = resolveInboundReceiptMessageId({
@@ -3,6 +3,13 @@ const WEBHOOK_ENDPOINT_ADAPTERS_KEY = '__openMercatoWebhookEndpointAdapters__'
3
3
  export interface WebhookEndpointAdapter {
4
4
  readonly providerKey: string
5
5
  readonly subscribedEvents: string[]
6
+ /**
7
+ * Opt-in for adapters whose verified events intentionally cannot be mapped to
8
+ * tenant/organization scope. Unscoped events cannot be checked against the
9
+ * Custom Webhooks integration enabled state, so the inbound route rejects them
10
+ * by default unless an adapter explicitly declares this behavior.
11
+ */
12
+ readonly allowUnscopedInbound?: boolean
6
13
 
7
14
  formatPayload?(event: { type: string; data: unknown }): Promise<{
8
15
  url: string