@open-mercato/channel-discord 0.10.1-develop.7801.1.52db3dc0dd → 0.10.1-develop.7802.1.2697b2d029

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.
@@ -3,6 +3,7 @@ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
3
3
  import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
4
4
  import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
5
5
  import { createLogger } from "@open-mercato/shared/lib/logger";
6
+ import { readBoundedRequestBody, WebhookBodyTooLargeError } from "@open-mercato/shared/lib/webhooks";
6
7
  import { CommunicationChannel } from "@open-mercato/core/modules/communication_channels/data/entities";
7
8
  import { discordCredentialsSchema } from "../../lib/credentials.js";
8
9
  import { DISCORD_MESSAGE_FLAG_EPHEMERAL } from "../../lib/discord-rest.js";
@@ -33,11 +34,19 @@ const metadata = {
33
34
  }
34
35
  };
35
36
  async function POST(req) {
36
- const rawBody = await req.text();
37
37
  const signatureHex = req.headers.get("x-signature-ed25519");
38
38
  const timestamp = req.headers.get("x-signature-timestamp");
39
39
  const screened = screenInteractionRequest({ signatureHex, timestamp });
40
40
  if (screened) return NextResponse.json(screened.body, { status: screened.status });
41
+ let rawBody;
42
+ try {
43
+ rawBody = await readBoundedRequestBody(req);
44
+ } catch (error) {
45
+ if (error instanceof WebhookBodyTooLargeError) {
46
+ return NextResponse.json({ error: "payload_too_large" }, { status: 413 });
47
+ }
48
+ throw error;
49
+ }
41
50
  const { t } = await resolveTranslations();
42
51
  const result = await resolveDiscordInteraction({
43
52
  rawBody,
@@ -160,7 +169,8 @@ const openApi = {
160
169
  description: "Verified interaction \u2014 PONG for the handshake, a deferred ack for a dispatched slash command / component / modal submission, an empty autocomplete result, or an ephemeral message when the interaction cannot be dispatched"
161
170
  },
162
171
  { status: 400, description: "Verified but malformed interaction body" },
163
- { status: 401, description: "Signature verification failed against every candidate channel, or the signed timestamp is outside the replay window" }
172
+ { status: 401, description: "Signature verification failed against every candidate channel, or the signed timestamp is outside the replay window" },
173
+ { status: 413, description: "Request body exceeds the configured byte limit (1 MiB by default; OM_WEBHOOK_MAX_BODY_BYTES override)" }
164
174
  ]
165
175
  }
166
176
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/channel_discord/api/interactions/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'\nimport { discordCredentialsSchema } from '../../lib/credentials'\nimport { DISCORD_MESSAGE_FLAG_EPHEMERAL } from '../../lib/discord-rest'\nimport type { DispatchableInteraction } from '../../lib/interactions-dispatch'\nimport {\n DEFAULT_INTERACTION_MESSAGES,\n resolveDiscordInteraction,\n screenInteractionRequest,\n type InteractionCandidate,\n type InteractionCandidateFilter,\n} from '../../lib/interactions-handler'\nimport {\n getInteractionDispatchQueue,\n type InteractionDispatchJobPayload,\n} from '../../lib/interactions-queue'\nimport { DiscordInteractionResponseType } from '../../lib/interactions-verify'\n\nconst logger = createLogger('channel_discord').child({ component: 'interactions-route' })\n\n/**\n * Discord Interactions endpoint (slash commands, buttons, PING handshake).\n *\n * This is a **provider-owned** signed route \u2014 the resolution to the spec's one\n * \"under negotiation\" hub touch-point. Discord requires a *synchronous* PONG\n * (`{ type: 1 }`) on the initial PING, which the hub's generic\n * `api/post/webhook/[provider]` route cannot return (it 202-acks + enqueues). By\n * shipping this route from the provider package we serve the handshake without\n * changing the hub contract. Operators set the Interactions Endpoint URL to\n * `/api/channel_discord/interactions`.\n *\n * Auth model: unauthenticated at the platform layer \u2014 Ed25519 signature\n * verification IS the auth, and it is fail-closed (a tampered/missing signature\n * verifies against no candidate channel \u2192 401).\n *\n * Timing model: Discord closes an interaction that is not answered within three\n * seconds, so a verified slash command / component press is answered with a\n * DEFERRED acknowledgement and handed to `workers/discord-interactions.ts`,\n * which does the two slow halves \u2014 writing the interaction into the hub's\n * inbound queue and replacing the \"thinking\u2026\" placeholder with a real message.\n * The hand-off happens BEFORE the ack is returned, so a queue that cannot accept\n * the job downgrades the response to a visible error instead of promising a\n * follow-up nothing will send.\n */\nexport const metadata = {\n // Pinned explicitly: this URL is operator-facing \u2014 it goes into the Discord\n // application's Interactions Endpoint field \u2014 so it is part of this route's\n // contract rather than a by-product of where the file happens to sit. It is\n // the same path the folder derives, so the pin documents the contract instead\n // of overriding it.\n path: '/channel_discord/interactions',\n POST: {\n requireAuth: false,\n // Unauthenticated by design. Unsigned traffic is now rejected before any\n // candidate is loaded, so this bounds the residual case: a caller who does\n // present well-formed, fresh headers and drives the narrowed candidate load\n // repeatedly before the signature gate rejects them.\n rateLimit: { points: 120, duration: 60, keyPrefix: 'discord_interactions' },\n },\n}\n\nexport async function POST(req: Request): Promise<Response> {\n const rawBody = await req.text()\n const signatureHex = req.headers.get('x-signature-ed25519')\n const timestamp = req.headers.get('x-signature-timestamp')\n\n // Screened here as well as inside `resolveDiscordInteraction` so that unsigned,\n // malformed or stale traffic returns before the dictionary load below \u2014 this\n // route is unauthenticated, so everything decidable from the request alone\n // stays ahead of every other cost.\n const screened = screenInteractionRequest({ signatureHex, timestamp })\n if (screened) return NextResponse.json(screened.body, { status: screened.status })\n\n const { t } = await resolveTranslations()\n\n const result = await resolveDiscordInteraction({\n rawBody,\n signatureHex,\n timestamp,\n loadCandidates: loadInteractionCandidates,\n messages: {\n notDispatchable: t(\n 'channel_discord.interactions.notDispatchable',\n DEFAULT_INTERACTION_MESSAGES.notDispatchable,\n ),\n unsupported: t('channel_discord.interactions.unsupported', DEFAULT_INTERACTION_MESSAGES.unsupported),\n },\n })\n\n // A deferred acknowledgement is a promise of a follow-up. Enqueue it BEFORE\n // answering: if the hand-off fails the user gets a visible error instead of a\n // \"thinking\u2026\" state nothing will ever replace.\n if (result.dispatch && result.matchedChannel) {\n const enqueued = await enqueueInteractionDispatch(result.dispatch, result.matchedChannel)\n if (!enqueued) {\n return NextResponse.json(\n {\n type: DiscordInteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,\n data: {\n content: t(\n 'channel_discord.interactions.dispatchFailed',\n 'Open Mercato could not record this interaction right now. Please try again in a moment.',\n ),\n flags: DISCORD_MESSAGE_FLAG_EPHEMERAL,\n },\n },\n { status: 200 },\n )\n }\n }\n\n return NextResponse.json(result.body, { status: result.status })\n}\n\n/**\n * Hand the verified interaction to the dispatch worker. Returns `false` when the\n * job could not be queued at all, which is the one case where the deferred ack\n * must be downgraded to a visible reply.\n */\nasync function enqueueInteractionDispatch(\n dispatch: DispatchableInteraction,\n channel: InteractionCandidate,\n): Promise<boolean> {\n const payload: InteractionDispatchJobPayload = {\n channelId: channel.channelId,\n channelType: channel.channelType,\n tenantId: channel.tenantId,\n organizationId: channel.organizationId,\n credentialScope: channel.credentialScope,\n interaction: dispatch,\n }\n try {\n await getInteractionDispatchQueue().enqueue(payload as unknown as Record<string, unknown>)\n return true\n } catch (err) {\n logger.error('failed to enqueue discord interaction dispatch', { err, channelId: channel.channelId })\n return false\n }\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n}\n\nasync function loadInteractionCandidates(\n filter: InteractionCandidateFilter,\n): Promise<InteractionCandidate[]> {\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager).fork()\n\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = container.resolve<CredentialsServiceLike>('integrationCredentialsService')\n } catch {\n credentialsService = null\n }\n if (!credentialsService) return []\n\n const candidates: InteractionCandidate[] = []\n try {\n const rows = (await findWithDecryption(em, CommunicationChannel, {\n providerKey: 'discord',\n isActive: true,\n deletedAt: null,\n })) as CommunicationChannel[]\n\n // Credentials are resolved per (tenant, organization, user) scope, and a\n // tenant's Discord channels usually share one \u2014 cache within this request so\n // N channel rows do not become N decrypts of the same credential bag.\n const resolvedByScope = new Map<string, Record<string, unknown> | null>()\n\n for (const channel of rows) {\n if (!channel.credentialsRef) continue\n const organizationId = channel.organizationId ?? channel.tenantId\n const userId = channel.userId ?? null\n const scopeKey = `${channel.tenantId}|${organizationId}|${userId ?? ''}`\n if (!resolvedByScope.has(scopeKey)) {\n try {\n resolvedByScope.set(\n scopeKey,\n await credentialsService.resolve('channel_discord', {\n tenantId: channel.tenantId,\n organizationId,\n userId,\n }),\n )\n } catch {\n resolvedByScope.set(scopeKey, null)\n }\n }\n const parsed = discordCredentialsSchema.safeParse(resolvedByScope.get(scopeKey) ?? {})\n if (!parsed.success) continue\n // Narrowing only \u2014 the signature still decides. A body claiming an\n // application nobody here owns simply verifies against nothing.\n if (filter.applicationId && parsed.data.applicationId !== filter.applicationId) continue\n candidates.push({\n channelId: channel.id,\n channelType: channel.channelType,\n tenantId: channel.tenantId,\n organizationId: channel.organizationId ?? null,\n publicKey: parsed.data.publicKey,\n applicationId: parsed.data.applicationId,\n // The exact scope this row's credentials resolved under, so the worker\n // re-resolves the same bag instead of guessing \u2014 and no token travels.\n credentialScope: { tenantId: channel.tenantId, organizationId, userId },\n })\n }\n } catch (err) {\n logger.warn('failed to load discord interaction candidates', { err })\n return []\n }\n\n return candidates\n}\n\nexport const openApi = {\n tags: ['ChannelDiscord'],\n summary: 'Discord Interactions endpoint (slash commands, buttons, PING handshake)',\n methods: {\n POST: {\n summary: 'Verify (Ed25519, fail-closed) and dispatch a Discord interaction',\n tags: ['ChannelDiscord'],\n responses: [\n {\n status: 200,\n description:\n 'Verified interaction \u2014 PONG for the handshake, a deferred ack for a dispatched slash command / component / modal submission, an empty autocomplete result, or an ephemeral message when the interaction cannot be dispatched',\n },\n { status: 400, description: 'Verified but malformed interaction body' },\n { status: 401, description: 'Signature verification failed against every candidate channel, or the signed timestamp is outside the replay window' },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { readBoundedRequestBody, WebhookBodyTooLargeError } from '@open-mercato/shared/lib/webhooks'\nimport { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'\nimport { discordCredentialsSchema } from '../../lib/credentials'\nimport { DISCORD_MESSAGE_FLAG_EPHEMERAL } from '../../lib/discord-rest'\nimport type { DispatchableInteraction } from '../../lib/interactions-dispatch'\nimport {\n DEFAULT_INTERACTION_MESSAGES,\n resolveDiscordInteraction,\n screenInteractionRequest,\n type InteractionCandidate,\n type InteractionCandidateFilter,\n} from '../../lib/interactions-handler'\nimport {\n getInteractionDispatchQueue,\n type InteractionDispatchJobPayload,\n} from '../../lib/interactions-queue'\nimport { DiscordInteractionResponseType } from '../../lib/interactions-verify'\n\nconst logger = createLogger('channel_discord').child({ component: 'interactions-route' })\n\n/**\n * Discord Interactions endpoint (slash commands, buttons, PING handshake).\n *\n * This is a **provider-owned** signed route \u2014 the resolution to the spec's one\n * \"under negotiation\" hub touch-point. Discord requires a *synchronous* PONG\n * (`{ type: 1 }`) on the initial PING, which the hub's generic\n * `api/post/webhook/[provider]` route cannot return (it 202-acks + enqueues). By\n * shipping this route from the provider package we serve the handshake without\n * changing the hub contract. Operators set the Interactions Endpoint URL to\n * `/api/channel_discord/interactions`.\n *\n * Auth model: unauthenticated at the platform layer \u2014 Ed25519 signature\n * verification IS the auth, and it is fail-closed (a tampered/missing signature\n * verifies against no candidate channel \u2192 401).\n *\n * Timing model: Discord closes an interaction that is not answered within three\n * seconds, so a verified slash command / component press is answered with a\n * DEFERRED acknowledgement and handed to `workers/discord-interactions.ts`,\n * which does the two slow halves \u2014 writing the interaction into the hub's\n * inbound queue and replacing the \"thinking\u2026\" placeholder with a real message.\n * The hand-off happens BEFORE the ack is returned, so a queue that cannot accept\n * the job downgrades the response to a visible error instead of promising a\n * follow-up nothing will send.\n */\nexport const metadata = {\n // Pinned explicitly: this URL is operator-facing \u2014 it goes into the Discord\n // application's Interactions Endpoint field \u2014 so it is part of this route's\n // contract rather than a by-product of where the file happens to sit. It is\n // the same path the folder derives, so the pin documents the contract instead\n // of overriding it.\n path: '/channel_discord/interactions',\n POST: {\n requireAuth: false,\n // Unauthenticated by design. Unsigned traffic is now rejected before any\n // candidate is loaded, so this bounds the residual case: a caller who does\n // present well-formed, fresh headers and drives the narrowed candidate load\n // repeatedly before the signature gate rejects them.\n rateLimit: { points: 120, duration: 60, keyPrefix: 'discord_interactions' },\n },\n}\n\nexport async function POST(req: Request): Promise<Response> {\n const signatureHex = req.headers.get('x-signature-ed25519')\n const timestamp = req.headers.get('x-signature-timestamp')\n\n // Screened here as well as inside `resolveDiscordInteraction` so that unsigned,\n // malformed or stale traffic returns before the dictionary load below \u2014 this\n // route is unauthenticated, so everything decidable from the request alone\n // stays ahead of every other cost.\n const screened = screenInteractionRequest({ signatureHex, timestamp })\n if (screened) return NextResponse.json(screened.body, { status: screened.status })\n\n let rawBody: string\n try {\n rawBody = await readBoundedRequestBody(req)\n } catch (error) {\n if (error instanceof WebhookBodyTooLargeError) {\n return NextResponse.json({ error: 'payload_too_large' }, { status: 413 })\n }\n throw error\n }\n\n const { t } = await resolveTranslations()\n\n const result = await resolveDiscordInteraction({\n rawBody,\n signatureHex,\n timestamp,\n loadCandidates: loadInteractionCandidates,\n messages: {\n notDispatchable: t(\n 'channel_discord.interactions.notDispatchable',\n DEFAULT_INTERACTION_MESSAGES.notDispatchable,\n ),\n unsupported: t('channel_discord.interactions.unsupported', DEFAULT_INTERACTION_MESSAGES.unsupported),\n },\n })\n\n // A deferred acknowledgement is a promise of a follow-up. Enqueue it BEFORE\n // answering: if the hand-off fails the user gets a visible error instead of a\n // \"thinking\u2026\" state nothing will ever replace.\n if (result.dispatch && result.matchedChannel) {\n const enqueued = await enqueueInteractionDispatch(result.dispatch, result.matchedChannel)\n if (!enqueued) {\n return NextResponse.json(\n {\n type: DiscordInteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,\n data: {\n content: t(\n 'channel_discord.interactions.dispatchFailed',\n 'Open Mercato could not record this interaction right now. Please try again in a moment.',\n ),\n flags: DISCORD_MESSAGE_FLAG_EPHEMERAL,\n },\n },\n { status: 200 },\n )\n }\n }\n\n return NextResponse.json(result.body, { status: result.status })\n}\n\n/**\n * Hand the verified interaction to the dispatch worker. Returns `false` when the\n * job could not be queued at all, which is the one case where the deferred ack\n * must be downgraded to a visible reply.\n */\nasync function enqueueInteractionDispatch(\n dispatch: DispatchableInteraction,\n channel: InteractionCandidate,\n): Promise<boolean> {\n const payload: InteractionDispatchJobPayload = {\n channelId: channel.channelId,\n channelType: channel.channelType,\n tenantId: channel.tenantId,\n organizationId: channel.organizationId,\n credentialScope: channel.credentialScope,\n interaction: dispatch,\n }\n try {\n await getInteractionDispatchQueue().enqueue(payload as unknown as Record<string, unknown>)\n return true\n } catch (err) {\n logger.error('failed to enqueue discord interaction dispatch', { err, channelId: channel.channelId })\n return false\n }\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n}\n\nasync function loadInteractionCandidates(\n filter: InteractionCandidateFilter,\n): Promise<InteractionCandidate[]> {\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager).fork()\n\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = container.resolve<CredentialsServiceLike>('integrationCredentialsService')\n } catch {\n credentialsService = null\n }\n if (!credentialsService) return []\n\n const candidates: InteractionCandidate[] = []\n try {\n const rows = (await findWithDecryption(em, CommunicationChannel, {\n providerKey: 'discord',\n isActive: true,\n deletedAt: null,\n })) as CommunicationChannel[]\n\n // Credentials are resolved per (tenant, organization, user) scope, and a\n // tenant's Discord channels usually share one \u2014 cache within this request so\n // N channel rows do not become N decrypts of the same credential bag.\n const resolvedByScope = new Map<string, Record<string, unknown> | null>()\n\n for (const channel of rows) {\n if (!channel.credentialsRef) continue\n const organizationId = channel.organizationId ?? channel.tenantId\n const userId = channel.userId ?? null\n const scopeKey = `${channel.tenantId}|${organizationId}|${userId ?? ''}`\n if (!resolvedByScope.has(scopeKey)) {\n try {\n resolvedByScope.set(\n scopeKey,\n await credentialsService.resolve('channel_discord', {\n tenantId: channel.tenantId,\n organizationId,\n userId,\n }),\n )\n } catch {\n resolvedByScope.set(scopeKey, null)\n }\n }\n const parsed = discordCredentialsSchema.safeParse(resolvedByScope.get(scopeKey) ?? {})\n if (!parsed.success) continue\n // Narrowing only \u2014 the signature still decides. A body claiming an\n // application nobody here owns simply verifies against nothing.\n if (filter.applicationId && parsed.data.applicationId !== filter.applicationId) continue\n candidates.push({\n channelId: channel.id,\n channelType: channel.channelType,\n tenantId: channel.tenantId,\n organizationId: channel.organizationId ?? null,\n publicKey: parsed.data.publicKey,\n applicationId: parsed.data.applicationId,\n // The exact scope this row's credentials resolved under, so the worker\n // re-resolves the same bag instead of guessing \u2014 and no token travels.\n credentialScope: { tenantId: channel.tenantId, organizationId, userId },\n })\n }\n } catch (err) {\n logger.warn('failed to load discord interaction candidates', { err })\n return []\n }\n\n return candidates\n}\n\nexport const openApi = {\n tags: ['ChannelDiscord'],\n summary: 'Discord Interactions endpoint (slash commands, buttons, PING handshake)',\n methods: {\n POST: {\n summary: 'Verify (Ed25519, fail-closed) and dispatch a Discord interaction',\n tags: ['ChannelDiscord'],\n responses: [\n {\n status: 200,\n description:\n 'Verified interaction \u2014 PONG for the handshake, a deferred ack for a dispatched slash command / component / modal submission, an empty autocomplete result, or an ephemeral message when the interaction cannot be dispatched',\n },\n { status: 400, description: 'Verified but malformed interaction body' },\n { status: 401, description: 'Signature verification failed against every candidate channel, or the signed timestamp is outside the replay window' },\n { status: 413, description: 'Request body exceeds the configured byte limit (1 MiB by default; OM_WEBHOOK_MAX_BODY_BYTES override)' },\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/channel-discord",
3
- "version": "0.10.1-develop.7801.1.52db3dc0dd",
3
+ "version": "0.10.1-develop.7802.1.2697b2d029",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -62,14 +62,14 @@
62
62
  }
63
63
  },
64
64
  "dependencies": {
65
- "@open-mercato/core": "0.10.1-develop.7801.1.52db3dc0dd",
66
- "@open-mercato/queue": "0.10.1-develop.7801.1.52db3dc0dd",
67
- "@open-mercato/ui": "0.10.1-develop.7801.1.52db3dc0dd"
65
+ "@open-mercato/core": "0.10.1-develop.7802.1.2697b2d029",
66
+ "@open-mercato/queue": "0.10.1-develop.7802.1.2697b2d029",
67
+ "@open-mercato/ui": "0.10.1-develop.7802.1.2697b2d029"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@mikro-orm/postgresql": "^7.0.14",
71
- "@open-mercato/ai-assistant": "0.10.1-develop.7801.1.52db3dc0dd",
72
- "@open-mercato/shared": "0.10.1-develop.7801.1.52db3dc0dd",
71
+ "@open-mercato/ai-assistant": "0.10.1-develop.7802.1.2697b2d029",
72
+ "@open-mercato/shared": "0.10.1-develop.7802.1.2697b2d029",
73
73
  "react": "^19.0.0",
74
74
  "react-dom": "^19.0.0"
75
75
  },
@@ -79,8 +79,8 @@
79
79
  }
80
80
  },
81
81
  "devDependencies": {
82
- "@open-mercato/ai-assistant": "0.10.1-develop.7801.1.52db3dc0dd",
83
- "@open-mercato/shared": "0.10.1-develop.7801.1.52db3dc0dd",
82
+ "@open-mercato/ai-assistant": "0.10.1-develop.7802.1.2697b2d029",
83
+ "@open-mercato/shared": "0.10.1-develop.7802.1.2697b2d029",
84
84
  "@types/jest": "^30.0.0",
85
85
  "@types/react": "^19.2.18",
86
86
  "@types/react-dom": "^19.2.5",
@@ -0,0 +1,172 @@
1
+ import { generateKeyPairSync, sign } from 'node:crypto'
2
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
+ import { DEFAULT_WEBHOOK_BODY_LIMIT_BYTES } from '@open-mercato/shared/lib/webhooks'
6
+ import { resolveDiscordInteraction } from '../../../lib/interactions-handler'
7
+ import { getInteractionDispatchQueue } from '../../../lib/interactions-queue'
8
+ import { POST } from '../route'
9
+
10
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({ createRequestContainer: jest.fn() }))
11
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({ findWithDecryption: jest.fn() }))
12
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({ resolveTranslations: jest.fn() }))
13
+ jest.mock('@open-mercato/core/modules/communication_channels/data/entities', () => ({
14
+ CommunicationChannel: class CommunicationChannel {},
15
+ }))
16
+ jest.mock('../../../lib/interactions-queue', () => ({ getInteractionDispatchQueue: jest.fn() }))
17
+ jest.mock('../../../lib/interactions-handler', () => {
18
+ const actual = jest.requireActual<typeof import('../../../lib/interactions-handler')>('../../../lib/interactions-handler')
19
+ return { ...actual, resolveDiscordInteraction: jest.fn(actual.resolveDiscordInteraction) }
20
+ })
21
+
22
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519')
23
+ const publicKeyDer = publicKey.export({ type: 'spki', format: 'der' })
24
+ const publicKeyHex = publicKeyDer.subarray(publicKeyDer.length - 32).toString('hex')
25
+ const credentialsResolve = jest.fn()
26
+ const entityManager = { fork: jest.fn() }
27
+ const containerResolve = jest.fn()
28
+ const encoder = new TextEncoder()
29
+
30
+ function signedHeaders(rawBody: string): Headers {
31
+ const timestamp = String(Math.floor(Date.now() / 1000))
32
+ return new Headers({
33
+ 'x-signature-timestamp': timestamp,
34
+ 'x-signature-ed25519': sign(null, Buffer.from(timestamp + rawBody), privateKey).toString('hex'),
35
+ })
36
+ }
37
+
38
+ function streamingRequest(chunks: Uint8Array[], headers: Headers) {
39
+ let nextChunk = 0
40
+ const cancel = jest.fn()
41
+ const pull = jest.fn((controller: ReadableStreamDefaultController<Uint8Array>) => {
42
+ const chunk = chunks[nextChunk++]
43
+ if (chunk) controller.enqueue(chunk)
44
+ else controller.close()
45
+ })
46
+ const body = new ReadableStream<Uint8Array>({ pull, cancel }, { highWaterMark: 0 })
47
+ const request = new Request('http://localhost/api/channel_discord/interactions', {
48
+ method: 'POST', headers, body, duplex: 'half',
49
+ } as RequestInit & { duplex: 'half' })
50
+ return { request, pull, cancel }
51
+ }
52
+
53
+ function expectNoDownstreamWork() {
54
+ expect(resolveDiscordInteraction).not.toHaveBeenCalled()
55
+ expect(resolveTranslations).not.toHaveBeenCalled()
56
+ expect(createRequestContainer).not.toHaveBeenCalled()
57
+ expect(findWithDecryption).not.toHaveBeenCalled()
58
+ expect(credentialsResolve).not.toHaveBeenCalled()
59
+ expect(getInteractionDispatchQueue).not.toHaveBeenCalled()
60
+ }
61
+
62
+ describe('Discord interaction request body limits', () => {
63
+ const previousLimit = process.env.OM_WEBHOOK_MAX_BODY_BYTES
64
+
65
+ beforeEach(() => {
66
+ jest.clearAllMocks()
67
+ delete process.env.OM_WEBHOOK_MAX_BODY_BYTES
68
+ entityManager.fork.mockReturnValue(entityManager)
69
+ containerResolve.mockImplementation((name: string) => {
70
+ if (name === 'em') return entityManager
71
+ if (name === 'integrationCredentialsService') return { resolve: credentialsResolve }
72
+ throw new Error(`[internal] Unexpected service ${name}`)
73
+ })
74
+ jest.mocked(createRequestContainer).mockResolvedValue({ resolve: containerResolve } as never)
75
+ jest.mocked(resolveTranslations).mockResolvedValue({
76
+ t: (key: string, fallback?: string) => fallback ?? key,
77
+ } as never)
78
+ jest.mocked(findWithDecryption).mockResolvedValue([{
79
+ id: 'channel-1', channelType: 'discord', tenantId: 'tenant-1', organizationId: null,
80
+ credentialsRef: 'discord', userId: null,
81
+ }] as never)
82
+ credentialsResolve.mockResolvedValue({ botToken: 'test-bot-token', applicationId: 'app-1', publicKey: publicKeyHex })
83
+ })
84
+
85
+ afterAll(() => {
86
+ if (previousLimit === undefined) delete process.env.OM_WEBHOOK_MAX_BODY_BYTES
87
+ else process.env.OM_WEBHOOK_MAX_BODY_BYTES = previousLimit
88
+ })
89
+
90
+ it.each(['missing', 'malformed', 'stale'])('rejects %s signature headers before consuming the body', async (kind) => {
91
+ const headers = signedHeaders('{}')
92
+ if (kind === 'missing') headers.delete('x-signature-ed25519')
93
+ if (kind === 'malformed') headers.set('x-signature-ed25519', 'not-a-signature')
94
+ if (kind === 'stale') headers.set('x-signature-timestamp', '1')
95
+ const { request, pull } = streamingRequest([encoder.encode('{}')], headers)
96
+
97
+ expect((await POST(request)).status).toBe(401)
98
+ expect(pull).not.toHaveBeenCalled()
99
+ expect(request.bodyUsed).toBe(false)
100
+ expectNoDownstreamWork()
101
+ })
102
+
103
+ it('rejects an oversized declared length before reading any body bytes', async () => {
104
+ const headers = signedHeaders('{}')
105
+ headers.set('content-length', String(DEFAULT_WEBHOOK_BODY_LIMIT_BYTES + 1))
106
+ const { request, pull } = streamingRequest([encoder.encode('{}')], headers)
107
+
108
+ const response = await POST(request)
109
+
110
+ expect(response.status).toBe(413)
111
+ expect(await response.json()).toEqual({ error: 'payload_too_large' })
112
+ expect(pull).not.toHaveBeenCalled()
113
+ expectNoDownstreamWork()
114
+ })
115
+
116
+ it.each([undefined, '2', 'invalid'])('bounds chunked input with Content-Length %s and cancels on overflow', async (declaredLength) => {
117
+ process.env.OM_WEBHOOK_MAX_BODY_BYTES = '64'
118
+ const headers = signedHeaders('{}')
119
+ if (declaredLength !== undefined) headers.set('content-length', declaredLength)
120
+ const { request, cancel, pull } = streamingRequest([
121
+ new Uint8Array(32), new Uint8Array(33), encoder.encode('never read'),
122
+ ], headers)
123
+
124
+ const response = await POST(request)
125
+
126
+ expect(response.status).toBe(413)
127
+ expect(cancel).toHaveBeenCalledTimes(1)
128
+ expect(pull).toHaveBeenCalledTimes(2)
129
+ expectNoDownstreamWork()
130
+ })
131
+
132
+ it.each([256, DEFAULT_WEBHOOK_BODY_LIMIT_BYTES])('accepts a signed PING at the exact %i-byte boundary', async (limit) => {
133
+ if (limit !== DEFAULT_WEBHOOK_BODY_LIMIT_BYTES) process.env.OM_WEBHOOK_MAX_BODY_BYTES = String(limit)
134
+ const payload = JSON.stringify({ type: 1, application_id: 'app-1', note: '€' })
135
+ const rawBody = payload + ' '.repeat(limit - Buffer.byteLength(payload))
136
+ const bytes = encoder.encode(rawBody)
137
+ const split = bytes.indexOf(0xe2) + 1
138
+ const headers = signedHeaders(rawBody)
139
+ headers.set('content-length', String(limit))
140
+ const { request } = streamingRequest([bytes.subarray(0, split), bytes.subarray(split)], headers)
141
+
142
+ const response = await POST(request)
143
+
144
+ expect(response.status).toBe(200)
145
+ expect(await response.json()).toEqual({ type: 1 })
146
+ expect(resolveDiscordInteraction).toHaveBeenCalledWith(expect.objectContaining({ rawBody }))
147
+ expect(credentialsResolve).toHaveBeenCalledTimes(1)
148
+ expect(getInteractionDispatchQueue).not.toHaveBeenCalled()
149
+ })
150
+
151
+ it('preserves the signed PING handshake below the limit without Content-Length', async () => {
152
+ const rawBody = JSON.stringify({ type: 1, application_id: 'app-1' })
153
+ const { request } = streamingRequest([encoder.encode(rawBody)], signedHeaders(rawBody))
154
+
155
+ const response = await POST(request)
156
+
157
+ expect(response.status).toBe(200)
158
+ expect(await response.json()).toEqual({ type: 1 })
159
+ expect(createRequestContainer).toHaveBeenCalledTimes(1)
160
+ })
161
+
162
+ it('propagates a stream failure without calling the interaction handler', async () => {
163
+ const failure = new Error('[internal] request stream failed')
164
+ const body = new ReadableStream<Uint8Array>({ start(controller) { controller.error(failure) } })
165
+ const request = new Request('http://localhost/api/channel_discord/interactions', {
166
+ method: 'POST', headers: signedHeaders('{}'), body, duplex: 'half',
167
+ } as RequestInit & { duplex: 'half' })
168
+
169
+ await expect(POST(request)).rejects.toBe(failure)
170
+ expectNoDownstreamWork()
171
+ })
172
+ })
@@ -4,6 +4,7 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
5
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
6
  import { createLogger } from '@open-mercato/shared/lib/logger'
7
+ import { readBoundedRequestBody, WebhookBodyTooLargeError } from '@open-mercato/shared/lib/webhooks'
7
8
  import { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'
8
9
  import { discordCredentialsSchema } from '../../lib/credentials'
9
10
  import { DISCORD_MESSAGE_FLAG_EPHEMERAL } from '../../lib/discord-rest'
@@ -65,7 +66,6 @@ export const metadata = {
65
66
  }
66
67
 
67
68
  export async function POST(req: Request): Promise<Response> {
68
- const rawBody = await req.text()
69
69
  const signatureHex = req.headers.get('x-signature-ed25519')
70
70
  const timestamp = req.headers.get('x-signature-timestamp')
71
71
 
@@ -76,6 +76,16 @@ export async function POST(req: Request): Promise<Response> {
76
76
  const screened = screenInteractionRequest({ signatureHex, timestamp })
77
77
  if (screened) return NextResponse.json(screened.body, { status: screened.status })
78
78
 
79
+ let rawBody: string
80
+ try {
81
+ rawBody = await readBoundedRequestBody(req)
82
+ } catch (error) {
83
+ if (error instanceof WebhookBodyTooLargeError) {
84
+ return NextResponse.json({ error: 'payload_too_large' }, { status: 413 })
85
+ }
86
+ throw error
87
+ }
88
+
79
89
  const { t } = await resolveTranslations()
80
90
 
81
91
  const result = await resolveDiscordInteraction({
@@ -236,6 +246,7 @@ export const openApi = {
236
246
  },
237
247
  { status: 400, description: 'Verified but malformed interaction body' },
238
248
  { status: 401, description: 'Signature verification failed against every candidate channel, or the signed timestamp is outside the replay window' },
249
+ { status: 413, description: 'Request body exceeds the configured byte limit (1 MiB by default; OM_WEBHOOK_MAX_BODY_BYTES override)' },
239
250
  ],
240
251
  },
241
252
  },