@open-mercato/channel-discord 0.6.8-develop.7090.1.4dbca7b350

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.
Files changed (220) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/build.mjs +7 -0
  3. package/dist/index.js +5 -0
  4. package/dist/index.js.map +7 -0
  5. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js +86 -0
  6. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js.map +7 -0
  7. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js +39 -0
  8. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js.map +7 -0
  9. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js +97 -0
  10. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js.map +7 -0
  11. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js +65 -0
  12. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js.map +7 -0
  13. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js +110 -0
  14. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js.map +7 -0
  15. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js +103 -0
  16. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js.map +7 -0
  17. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js +93 -0
  18. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js.map +7 -0
  19. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js +52 -0
  20. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js.map +7 -0
  21. package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js +46 -0
  22. package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js.map +7 -0
  23. package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js +16 -0
  24. package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js.map +7 -0
  25. package/dist/modules/channel_discord/__integration__/meta.js +10 -0
  26. package/dist/modules/channel_discord/__integration__/meta.js.map +7 -0
  27. package/dist/modules/channel_discord/acl.js +15 -0
  28. package/dist/modules/channel_discord/acl.js.map +7 -0
  29. package/dist/modules/channel_discord/ai-agents.js +97 -0
  30. package/dist/modules/channel_discord/ai-agents.js.map +7 -0
  31. package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js +73 -0
  32. package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js.map +7 -0
  33. package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js +222 -0
  34. package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js.map +7 -0
  35. package/dist/modules/channel_discord/api/interactions/route.js +173 -0
  36. package/dist/modules/channel_discord/api/interactions/route.js.map +7 -0
  37. package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js +196 -0
  38. package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js.map +7 -0
  39. package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js +14 -0
  40. package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js.map +7 -0
  41. package/dist/modules/channel_discord/cli.js +142 -0
  42. package/dist/modules/channel_discord/cli.js.map +7 -0
  43. package/dist/modules/channel_discord/commands/ai-reply-proposal.js +116 -0
  44. package/dist/modules/channel_discord/commands/ai-reply-proposal.js.map +7 -0
  45. package/dist/modules/channel_discord/commands/update-ai-auto-reply.js +104 -0
  46. package/dist/modules/channel_discord/commands/update-ai-auto-reply.js.map +7 -0
  47. package/dist/modules/channel_discord/di.js +23 -0
  48. package/dist/modules/channel_discord/di.js.map +7 -0
  49. package/dist/modules/channel_discord/index.js +9 -0
  50. package/dist/modules/channel_discord/index.js.map +7 -0
  51. package/dist/modules/channel_discord/integration.js +96 -0
  52. package/dist/modules/channel_discord/integration.js.map +7 -0
  53. package/dist/modules/channel_discord/lib/adapter.js +248 -0
  54. package/dist/modules/channel_discord/lib/adapter.js.map +7 -0
  55. package/dist/modules/channel_discord/lib/ai-agent-directory.js +45 -0
  56. package/dist/modules/channel_discord/lib/ai-agent-directory.js.map +7 -0
  57. package/dist/modules/channel_discord/lib/ai-features.js +9 -0
  58. package/dist/modules/channel_discord/lib/ai-features.js.map +7 -0
  59. package/dist/modules/channel_discord/lib/ai-proposal-contract.js +9 -0
  60. package/dist/modules/channel_discord/lib/ai-proposal-contract.js.map +7 -0
  61. package/dist/modules/channel_discord/lib/ai-proposal.js +109 -0
  62. package/dist/modules/channel_discord/lib/ai-proposal.js.map +7 -0
  63. package/dist/modules/channel_discord/lib/ai-reply.js +96 -0
  64. package/dist/modules/channel_discord/lib/ai-reply.js.map +7 -0
  65. package/dist/modules/channel_discord/lib/ai-service-principal.js +70 -0
  66. package/dist/modules/channel_discord/lib/ai-service-principal.js.map +7 -0
  67. package/dist/modules/channel_discord/lib/capabilities.js +42 -0
  68. package/dist/modules/channel_discord/lib/capabilities.js.map +7 -0
  69. package/dist/modules/channel_discord/lib/channel-access.js +62 -0
  70. package/dist/modules/channel_discord/lib/channel-access.js.map +7 -0
  71. package/dist/modules/channel_discord/lib/channel-state-store.js +103 -0
  72. package/dist/modules/channel_discord/lib/channel-state-store.js.map +7 -0
  73. package/dist/modules/channel_discord/lib/convert-outbound.js +38 -0
  74. package/dist/modules/channel_discord/lib/convert-outbound.js.map +7 -0
  75. package/dist/modules/channel_discord/lib/credentials.js +61 -0
  76. package/dist/modules/channel_discord/lib/credentials.js.map +7 -0
  77. package/dist/modules/channel_discord/lib/discord-gateway-client.js +289 -0
  78. package/dist/modules/channel_discord/lib/discord-gateway-client.js.map +7 -0
  79. package/dist/modules/channel_discord/lib/discord-rest.js +203 -0
  80. package/dist/modules/channel_discord/lib/discord-rest.js.map +7 -0
  81. package/dist/modules/channel_discord/lib/failure-reason.js +30 -0
  82. package/dist/modules/channel_discord/lib/failure-reason.js.map +7 -0
  83. package/dist/modules/channel_discord/lib/gateway-bridge.js +42 -0
  84. package/dist/modules/channel_discord/lib/gateway-bridge.js.map +7 -0
  85. package/dist/modules/channel_discord/lib/health.js +37 -0
  86. package/dist/modules/channel_discord/lib/health.js.map +7 -0
  87. package/dist/modules/channel_discord/lib/interactions-dispatch.js +164 -0
  88. package/dist/modules/channel_discord/lib/interactions-dispatch.js.map +7 -0
  89. package/dist/modules/channel_discord/lib/interactions-handler.js +115 -0
  90. package/dist/modules/channel_discord/lib/interactions-handler.js.map +7 -0
  91. package/dist/modules/channel_discord/lib/interactions-queue.js +17 -0
  92. package/dist/modules/channel_discord/lib/interactions-queue.js.map +7 -0
  93. package/dist/modules/channel_discord/lib/interactions-verify.js +84 -0
  94. package/dist/modules/channel_discord/lib/interactions-verify.js.map +7 -0
  95. package/dist/modules/channel_discord/lib/normalize-inbound.js +59 -0
  96. package/dist/modules/channel_discord/lib/normalize-inbound.js.map +7 -0
  97. package/dist/modules/channel_discord/lib/preset.js +63 -0
  98. package/dist/modules/channel_discord/lib/preset.js.map +7 -0
  99. package/dist/modules/channel_discord/lib/queue-strategy.js +12 -0
  100. package/dist/modules/channel_discord/lib/queue-strategy.js.map +7 -0
  101. package/dist/modules/channel_discord/lib/slash-commands.js +42 -0
  102. package/dist/modules/channel_discord/lib/slash-commands.js.map +7 -0
  103. package/dist/modules/channel_discord/message-types.js +64 -0
  104. package/dist/modules/channel_discord/message-types.js.map +7 -0
  105. package/dist/modules/channel_discord/setup.js +62 -0
  106. package/dist/modules/channel_discord/setup.js.map +7 -0
  107. package/dist/modules/channel_discord/subscribers/ai-auto-reply.js +185 -0
  108. package/dist/modules/channel_discord/subscribers/ai-auto-reply.js.map +7 -0
  109. package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js +79 -0
  110. package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js.map +7 -0
  111. package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js +20 -0
  112. package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js.map +7 -0
  113. package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js +220 -0
  114. package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js.map +7 -0
  115. package/dist/modules/channel_discord/widgets/injection/connect/widget.js +17 -0
  116. package/dist/modules/channel_discord/widgets/injection/connect/widget.js.map +7 -0
  117. package/dist/modules/channel_discord/widgets/injection-table.js +26 -0
  118. package/dist/modules/channel_discord/widgets/injection-table.js.map +7 -0
  119. package/dist/modules/channel_discord/workers/discord-gateway.js +270 -0
  120. package/dist/modules/channel_discord/workers/discord-gateway.js.map +7 -0
  121. package/dist/modules/channel_discord/workers/discord-interactions.js +113 -0
  122. package/dist/modules/channel_discord/workers/discord-interactions.js.map +7 -0
  123. package/jest.config.cjs +38 -0
  124. package/package.json +104 -0
  125. package/src/index.ts +1 -0
  126. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.ts +109 -0
  127. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.ts +58 -0
  128. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.ts +175 -0
  129. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.ts +83 -0
  130. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.ts +156 -0
  131. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.ts +144 -0
  132. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.ts +119 -0
  133. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.ts +75 -0
  134. package/src/modules/channel_discord/__integration__/helpers/discordSignature.ts +77 -0
  135. package/src/modules/channel_discord/__integration__/helpers/eventsQueue.ts +26 -0
  136. package/src/modules/channel_discord/__integration__/meta.ts +6 -0
  137. package/src/modules/channel_discord/__tests__/ai-auto-reply.policy.integration.test.ts +286 -0
  138. package/src/modules/channel_discord/acl.ts +19 -0
  139. package/src/modules/channel_discord/ai-agents.ts +157 -0
  140. package/src/modules/channel_discord/api/ai-auto-reply/channels/__tests__/route.test.ts +175 -0
  141. package/src/modules/channel_discord/api/ai-auto-reply/channels/route.ts +99 -0
  142. package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/__tests__/route.test.ts +311 -0
  143. package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.ts +299 -0
  144. package/src/modules/channel_discord/api/interactions/route.ts +242 -0
  145. package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.ts +18 -0
  146. package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.tsx +288 -0
  147. package/src/modules/channel_discord/cli.ts +234 -0
  148. package/src/modules/channel_discord/commands/__tests__/ai-reply-proposal.test.ts +155 -0
  149. package/src/modules/channel_discord/commands/__tests__/update-ai-auto-reply.test.ts +185 -0
  150. package/src/modules/channel_discord/commands/ai-reply-proposal.ts +162 -0
  151. package/src/modules/channel_discord/commands/update-ai-auto-reply.ts +157 -0
  152. package/src/modules/channel_discord/di.ts +26 -0
  153. package/src/modules/channel_discord/i18n/de.json +57 -0
  154. package/src/modules/channel_discord/i18n/en.json +57 -0
  155. package/src/modules/channel_discord/i18n/es.json +57 -0
  156. package/src/modules/channel_discord/i18n/ko.json +57 -0
  157. package/src/modules/channel_discord/i18n/pl.json +57 -0
  158. package/src/modules/channel_discord/index.ts +6 -0
  159. package/src/modules/channel_discord/integration.ts +98 -0
  160. package/src/modules/channel_discord/lib/__tests__/adapter.test.ts +206 -0
  161. package/src/modules/channel_discord/lib/__tests__/ai-agent-directory.test.ts +61 -0
  162. package/src/modules/channel_discord/lib/__tests__/ai-reply.test.ts +115 -0
  163. package/src/modules/channel_discord/lib/__tests__/capabilities.test.ts +170 -0
  164. package/src/modules/channel_discord/lib/__tests__/channel-quarantine.test.ts +89 -0
  165. package/src/modules/channel_discord/lib/__tests__/channel-state-store.auto-reply-marker.test.ts +145 -0
  166. package/src/modules/channel_discord/lib/__tests__/channel-state-store.test.ts +78 -0
  167. package/src/modules/channel_discord/lib/__tests__/convert-outbound.test.ts +39 -0
  168. package/src/modules/channel_discord/lib/__tests__/discord-gateway-client.test.ts +243 -0
  169. package/src/modules/channel_discord/lib/__tests__/failure-reason.test.ts +68 -0
  170. package/src/modules/channel_discord/lib/__tests__/gateway-bridge.test.ts +62 -0
  171. package/src/modules/channel_discord/lib/__tests__/interactions-dispatch.test.ts +223 -0
  172. package/src/modules/channel_discord/lib/__tests__/interactions-handler.test.ts +469 -0
  173. package/src/modules/channel_discord/lib/__tests__/interactions-verify.test.ts +126 -0
  174. package/src/modules/channel_discord/lib/__tests__/normalize-inbound.identity.test.ts +64 -0
  175. package/src/modules/channel_discord/lib/__tests__/normalize-inbound.test.ts +67 -0
  176. package/src/modules/channel_discord/lib/__tests__/preset.test.ts +110 -0
  177. package/src/modules/channel_discord/lib/__tests__/queue-strategy.test.ts +33 -0
  178. package/src/modules/channel_discord/lib/adapter.ts +359 -0
  179. package/src/modules/channel_discord/lib/ai-agent-directory.ts +124 -0
  180. package/src/modules/channel_discord/lib/ai-features.ts +23 -0
  181. package/src/modules/channel_discord/lib/ai-proposal-contract.ts +24 -0
  182. package/src/modules/channel_discord/lib/ai-proposal.ts +179 -0
  183. package/src/modules/channel_discord/lib/ai-reply.ts +179 -0
  184. package/src/modules/channel_discord/lib/ai-service-principal.ts +143 -0
  185. package/src/modules/channel_discord/lib/capabilities.ts +104 -0
  186. package/src/modules/channel_discord/lib/channel-access.ts +110 -0
  187. package/src/modules/channel_discord/lib/channel-state-store.ts +217 -0
  188. package/src/modules/channel_discord/lib/convert-outbound.ts +88 -0
  189. package/src/modules/channel_discord/lib/credentials.ts +88 -0
  190. package/src/modules/channel_discord/lib/discord-gateway-client.ts +432 -0
  191. package/src/modules/channel_discord/lib/discord-rest.ts +393 -0
  192. package/src/modules/channel_discord/lib/failure-reason.ts +49 -0
  193. package/src/modules/channel_discord/lib/gateway-bridge.ts +79 -0
  194. package/src/modules/channel_discord/lib/health.ts +55 -0
  195. package/src/modules/channel_discord/lib/interactions-dispatch.ts +288 -0
  196. package/src/modules/channel_discord/lib/interactions-handler.ts +267 -0
  197. package/src/modules/channel_discord/lib/interactions-queue.ts +52 -0
  198. package/src/modules/channel_discord/lib/interactions-verify.ts +174 -0
  199. package/src/modules/channel_discord/lib/normalize-inbound.ts +89 -0
  200. package/src/modules/channel_discord/lib/preset.ts +109 -0
  201. package/src/modules/channel_discord/lib/queue-strategy.ts +29 -0
  202. package/src/modules/channel_discord/lib/slash-commands.ts +57 -0
  203. package/src/modules/channel_discord/message-types.ts +75 -0
  204. package/src/modules/channel_discord/setup.ts +78 -0
  205. package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.armed.test.ts +149 -0
  206. package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.failure-marker.test.ts +133 -0
  207. package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.test.ts +273 -0
  208. package/src/modules/channel_discord/subscribers/ai-auto-reply.ts +325 -0
  209. package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.tsx +143 -0
  210. package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.ts +19 -0
  211. package/src/modules/channel_discord/widgets/injection/connect/widget.client.tsx +237 -0
  212. package/src/modules/channel_discord/widgets/injection/connect/widget.ts +16 -0
  213. package/src/modules/channel_discord/widgets/injection-table.ts +24 -0
  214. package/src/modules/channel_discord/workers/__tests__/discord-gateway.single-identify.test.ts +88 -0
  215. package/src/modules/channel_discord/workers/__tests__/discord-gateway.test.ts +179 -0
  216. package/src/modules/channel_discord/workers/__tests__/discord-interactions.test.ts +152 -0
  217. package/src/modules/channel_discord/workers/discord-gateway.ts +458 -0
  218. package/src/modules/channel_discord/workers/discord-interactions.ts +162 -0
  219. package/tsconfig.json +9 -0
  220. package/watch.mjs +7 -0
@@ -0,0 +1,469 @@
1
+ import { generateKeyPairSync, sign as cryptoSign } from 'node:crypto'
2
+ import {
3
+ DEFAULT_INTERACTION_MESSAGES,
4
+ handleDiscordInteraction,
5
+ resolveDiscordInteraction,
6
+ screenInteractionRequest,
7
+ type InteractionCandidate,
8
+ } from '../interactions-handler'
9
+ import { DiscordInteractionResponseType, DiscordInteractionType } from '../interactions-verify'
10
+
11
+ function makeSigner() {
12
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519')
13
+ const spki = publicKey.export({ type: 'spki', format: 'der' }) as Buffer
14
+ const publicKeyHex = spki.subarray(spki.length - 32).toString('hex')
15
+ return {
16
+ publicKeyHex,
17
+ sign: (message: string) => cryptoSign(null, Buffer.from(message, 'utf-8'), privateKey).toString('hex'),
18
+ }
19
+ }
20
+
21
+ const timestamp = '1700000000'
22
+ const freshness = { nowEpochSeconds: Number(timestamp) }
23
+
24
+ describe('handleDiscordInteraction', () => {
25
+ it('answers PING with a synchronous PONG for a verified request', () => {
26
+ const signer = makeSigner()
27
+ const rawBody = JSON.stringify({ type: DiscordInteractionType.PING })
28
+ const candidate: InteractionCandidate = {
29
+ channelId: 'ch-1',
30
+ channelType: 'discord',
31
+ tenantId: 't-1',
32
+ organizationId: 'o-1',
33
+ publicKey: signer.publicKeyHex,
34
+ applicationId: 'app-1',
35
+ credentialScope: { tenantId: 't-1', organizationId: 't-1', userId: null },
36
+ }
37
+ const result = handleDiscordInteraction({
38
+ rawBody,
39
+ signatureHex: signer.sign(timestamp + rawBody),
40
+ timestamp,
41
+ freshness,
42
+ candidates: [candidate],
43
+ })
44
+ expect(result.status).toBe(200)
45
+ expect(result.body).toEqual({ type: DiscordInteractionResponseType.PONG })
46
+ expect(result.matchedChannel?.channelId).toBe('ch-1')
47
+ })
48
+
49
+ it('rejects a tampered signature with 401 and no matched channel (fail-closed)', () => {
50
+ const signer = makeSigner()
51
+ const rawBody = JSON.stringify({ type: DiscordInteractionType.PING })
52
+ const candidate: InteractionCandidate = {
53
+ channelId: 'ch-1',
54
+ channelType: 'discord',
55
+ tenantId: 't-1',
56
+ organizationId: null,
57
+ publicKey: signer.publicKeyHex,
58
+ applicationId: 'app-1',
59
+ credentialScope: { tenantId: 't-1', organizationId: 't-1', userId: null },
60
+ }
61
+ const result = handleDiscordInteraction({
62
+ rawBody,
63
+ signatureHex: signer.sign(timestamp + '{"type":999}'),
64
+ timestamp,
65
+ freshness,
66
+ candidates: [candidate],
67
+ })
68
+ expect(result.status).toBe(401)
69
+ expect(result.matchedChannel).toBeNull()
70
+ })
71
+
72
+ it('pins to the channel whose public key verifies (tenant isolation)', () => {
73
+ const tenantA = makeSigner()
74
+ const tenantB = makeSigner()
75
+ const rawBody = JSON.stringify({
76
+ type: DiscordInteractionType.APPLICATION_COMMAND,
77
+ id: 'interaction-1',
78
+ token: 'interaction-token',
79
+ application_id: 'app-b',
80
+ channel_id: 'discord-channel-1',
81
+ member: { user: { id: 'user-1', username: 'ada' } },
82
+ data: { name: 'mercato' },
83
+ })
84
+ // Signed with tenant B's key only.
85
+ const signatureHex = tenantB.sign(timestamp + rawBody)
86
+ const candidates: InteractionCandidate[] = [
87
+ {
88
+ channelId: 'ch-a',
89
+ channelType: 'discord',
90
+ tenantId: 't-a',
91
+ organizationId: null,
92
+ publicKey: tenantA.publicKeyHex,
93
+ applicationId: 'app-a',
94
+ credentialScope: { tenantId: 't-a', organizationId: 't-a', userId: null },
95
+ },
96
+ {
97
+ channelId: 'ch-b',
98
+ channelType: 'discord',
99
+ tenantId: 't-b',
100
+ organizationId: null,
101
+ publicKey: tenantB.publicKeyHex,
102
+ applicationId: 'app-b',
103
+ credentialScope: { tenantId: 't-b', organizationId: 't-b', userId: null },
104
+ },
105
+ ]
106
+ const result = handleDiscordInteraction({ rawBody, signatureHex, timestamp, candidates, freshness })
107
+ expect(result.status).toBe(200)
108
+ expect(result.matchedChannel?.channelId).toBe('ch-b')
109
+ expect(result.matchedChannel?.tenantId).toBe('t-b')
110
+ expect(result.body).toEqual({ type: DiscordInteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE })
111
+ // The dispatch travels with the ack, so the follow-up is sent under the
112
+ // channel that verified rather than the first candidate offered.
113
+ expect(result.dispatch?.id).toBe('interaction-1')
114
+ })
115
+
116
+ it('rejects a replayed capture (valid signature, stale timestamp) BEFORE any verify work', () => {
117
+ const signer = makeSigner()
118
+ const rawBody = JSON.stringify({ type: DiscordInteractionType.PING })
119
+ const candidate: InteractionCandidate = {
120
+ channelId: 'ch-1',
121
+ channelType: 'discord',
122
+ tenantId: 't-1',
123
+ organizationId: null,
124
+ publicKey: signer.publicKeyHex,
125
+ applicationId: 'app-1',
126
+ credentialScope: { tenantId: 't-1', organizationId: 't-1', userId: null },
127
+ }
128
+ const result = handleDiscordInteraction({
129
+ rawBody,
130
+ signatureHex: signer.sign(timestamp + rawBody),
131
+ timestamp,
132
+ candidates: [candidate],
133
+ freshness: { nowEpochSeconds: Number(timestamp) + 3600 },
134
+ })
135
+ expect(result.status).toBe(401)
136
+ expect(result.body).toEqual({ error: 'stale_timestamp' })
137
+ expect(result.matchedChannel).toBeNull()
138
+ })
139
+
140
+ it('rejects when no candidate verifies', () => {
141
+ const signer = makeSigner()
142
+ const other = makeSigner()
143
+ const rawBody = JSON.stringify({ type: DiscordInteractionType.PING })
144
+ const result = handleDiscordInteraction({
145
+ rawBody,
146
+ signatureHex: signer.sign(timestamp + rawBody),
147
+ timestamp,
148
+ freshness,
149
+ candidates: [
150
+ {
151
+ channelId: 'ch',
152
+ channelType: 'discord',
153
+ tenantId: 't',
154
+ organizationId: null,
155
+ publicKey: other.publicKeyHex,
156
+ applicationId: 'app',
157
+ credentialScope: { tenantId: 't', organizationId: 't', userId: null },
158
+ },
159
+ ],
160
+ })
161
+ expect(result.status).toBe(401)
162
+ })
163
+ })
164
+
165
+ describe('screenInteractionRequest', () => {
166
+ it('rejects a request carrying no signature headers at all', () => {
167
+ expect(screenInteractionRequest({ signatureHex: null, timestamp: null, freshness })).toEqual({
168
+ status: 401,
169
+ body: { error: 'stale_timestamp' },
170
+ matchedChannel: null,
171
+ })
172
+ })
173
+
174
+ it('rejects a signature that is not 64 hex-encoded bytes', () => {
175
+ expect(screenInteractionRequest({ signatureHex: 'not-hex', timestamp, freshness })).toEqual({
176
+ status: 401,
177
+ body: { error: 'invalid_signature' },
178
+ matchedChannel: null,
179
+ })
180
+ expect(screenInteractionRequest({ signatureHex: 'ab'.repeat(10), timestamp, freshness })?.status).toBe(401)
181
+ })
182
+
183
+ it('passes a well-formed, fresh request through to the candidate stage', () => {
184
+ expect(screenInteractionRequest({ signatureHex: 'ab'.repeat(64), timestamp, freshness })).toBeNull()
185
+ })
186
+ })
187
+
188
+ /**
189
+ * Resolving candidates reads every active Discord channel in the installation
190
+ * and decrypts its credentials, so WHEN that happens is an availability property
191
+ * of an endpoint any caller can POST to, not an implementation detail. These
192
+ * tests assert on the loader's CALL COUNT rather than on the response alone, so
193
+ * a refactor that quietly moves the load back above the guards fails here
194
+ * instead of in production.
195
+ */
196
+ describe('resolveDiscordInteraction candidate-loading discipline', () => {
197
+ const rawBody = JSON.stringify({ type: DiscordInteractionType.PING, application_id: 'app-1' })
198
+
199
+ it('loads no candidates for an unsigned request', async () => {
200
+ const loadCandidates = jest.fn(async () => [] as InteractionCandidate[])
201
+ const result = await resolveDiscordInteraction({
202
+ rawBody: '',
203
+ signatureHex: null,
204
+ timestamp: null,
205
+ loadCandidates,
206
+ freshness,
207
+ })
208
+ expect(result.status).toBe(401)
209
+ expect(loadCandidates).toHaveBeenCalledTimes(0)
210
+ })
211
+
212
+ it('loads no candidates for a malformed signature header', async () => {
213
+ const loadCandidates = jest.fn(async () => [] as InteractionCandidate[])
214
+ const result = await resolveDiscordInteraction({
215
+ rawBody,
216
+ signatureHex: 'zzzz',
217
+ timestamp,
218
+ loadCandidates,
219
+ freshness,
220
+ })
221
+ expect(result.status).toBe(401)
222
+ expect(result.body).toEqual({ error: 'invalid_signature' })
223
+ expect(loadCandidates).toHaveBeenCalledTimes(0)
224
+ })
225
+
226
+ it('loads no candidates for a replayed request, even with a cryptographically valid signature', async () => {
227
+ const signer = makeSigner()
228
+ const loadCandidates = jest.fn(async () => [] as InteractionCandidate[])
229
+ const result = await resolveDiscordInteraction({
230
+ rawBody,
231
+ signatureHex: signer.sign(timestamp + rawBody),
232
+ timestamp,
233
+ loadCandidates,
234
+ freshness: { nowEpochSeconds: Number(timestamp) + 3600 },
235
+ })
236
+ expect(result.status).toBe(401)
237
+ expect(result.body).toEqual({ error: 'stale_timestamp' })
238
+ expect(loadCandidates).toHaveBeenCalledTimes(0)
239
+ })
240
+
241
+ it('narrows the candidate load by the application id the body claims', async () => {
242
+ const signer = makeSigner()
243
+ const loadCandidates = jest.fn(async () => [
244
+ {
245
+ channelId: 'ch-1',
246
+ channelType: 'discord',
247
+ tenantId: 't-1',
248
+ organizationId: null,
249
+ publicKey: signer.publicKeyHex,
250
+ applicationId: 'app-1',
251
+ credentialScope: { tenantId: 't-1', organizationId: 't-1', userId: null },
252
+ },
253
+ ])
254
+ const result = await resolveDiscordInteraction({
255
+ rawBody,
256
+ signatureHex: signer.sign(timestamp + rawBody),
257
+ timestamp,
258
+ loadCandidates,
259
+ freshness,
260
+ })
261
+ expect(result.status).toBe(200)
262
+ expect(loadCandidates).toHaveBeenCalledTimes(1)
263
+ expect(loadCandidates).toHaveBeenCalledWith({ applicationId: 'app-1' })
264
+ })
265
+
266
+ it('falls back to the full candidate set when the body claims no application id', async () => {
267
+ const signer = makeSigner()
268
+ const bodyWithoutApplication = JSON.stringify({ type: DiscordInteractionType.PING })
269
+ const loadCandidates = jest.fn(async () => [
270
+ {
271
+ channelId: 'ch-1',
272
+ channelType: 'discord',
273
+ tenantId: 't-1',
274
+ organizationId: null,
275
+ publicKey: signer.publicKeyHex,
276
+ applicationId: 'app-1',
277
+ credentialScope: { tenantId: 't-1', organizationId: 't-1', userId: null },
278
+ },
279
+ ])
280
+ const result = await resolveDiscordInteraction({
281
+ rawBody: bodyWithoutApplication,
282
+ signatureHex: signer.sign(timestamp + bodyWithoutApplication),
283
+ timestamp,
284
+ loadCandidates,
285
+ freshness,
286
+ })
287
+ expect(result.status).toBe(200)
288
+ expect(loadCandidates).toHaveBeenCalledWith({ applicationId: null })
289
+ })
290
+
291
+ it('still rejects a forged application id — narrowing is never authorization', async () => {
292
+ const victim = makeSigner()
293
+ const attacker = makeSigner()
294
+ const forgedBody = JSON.stringify({
295
+ type: DiscordInteractionType.APPLICATION_COMMAND,
296
+ application_id: 'app-victim',
297
+ })
298
+ // Claiming the victim's application makes the narrowing hand the attacker
299
+ // exactly the victim's channel — and the signature gate rejects them anyway.
300
+ const loadCandidates = jest.fn(async (filter: { applicationId: string | null }) => {
301
+ expect(filter.applicationId).toBe('app-victim')
302
+ return [
303
+ {
304
+ channelId: 'ch-victim',
305
+ channelType: 'discord',
306
+ tenantId: 't-victim',
307
+ organizationId: null,
308
+ publicKey: victim.publicKeyHex,
309
+ applicationId: 'app-victim',
310
+ credentialScope: { tenantId: 't-victim', organizationId: 't-victim', userId: null },
311
+ },
312
+ ]
313
+ })
314
+ const result = await resolveDiscordInteraction({
315
+ rawBody: forgedBody,
316
+ signatureHex: attacker.sign(timestamp + forgedBody),
317
+ timestamp,
318
+ loadCandidates,
319
+ freshness,
320
+ })
321
+ expect(result.status).toBe(401)
322
+ expect(result.body).toEqual({ error: 'invalid_signature' })
323
+ expect(result.matchedChannel).toBeNull()
324
+ })
325
+ })
326
+
327
+ /**
328
+ * A deferred acknowledgement is a promise: Discord shows "thinking…" until
329
+ * something replaces it. These tests assert that the handler never makes that
330
+ * promise without also handing the caller the `dispatch` that keeps it — the
331
+ * defect #4663 was filed for was exactly a deferred ack with no follow-up.
332
+ */
333
+ describe('handleDiscordInteraction dispatch', () => {
334
+ function signedCandidate(signer: ReturnType<typeof makeSigner>): InteractionCandidate {
335
+ return {
336
+ channelId: 'ch-1',
337
+ channelType: 'discord',
338
+ tenantId: 't-1',
339
+ organizationId: 'o-1',
340
+ publicKey: signer.publicKeyHex,
341
+ applicationId: 'app-1',
342
+ credentialScope: { tenantId: 't-1', organizationId: 'o-1', userId: null },
343
+ }
344
+ }
345
+
346
+ function verified(body: Record<string, unknown>) {
347
+ const signer = makeSigner()
348
+ const rawBody = JSON.stringify(body)
349
+ return handleDiscordInteraction({
350
+ rawBody,
351
+ signatureHex: signer.sign(timestamp + rawBody),
352
+ timestamp,
353
+ freshness,
354
+ candidates: [signedCandidate(signer)],
355
+ })
356
+ }
357
+
358
+ const invoker = { user: { id: 'user-1', username: 'ada' } }
359
+
360
+ it('defers a slash command AND returns the dispatch that will answer it', () => {
361
+ const result = verified({
362
+ type: DiscordInteractionType.APPLICATION_COMMAND,
363
+ id: 'interaction-1',
364
+ token: 'interaction-token',
365
+ application_id: 'app-1',
366
+ channel_id: 'discord-channel-1',
367
+ guild_id: 'guild-1',
368
+ member: invoker,
369
+ data: { name: 'mercato', options: [{ name: 'message', value: 'the printer is jammed' }] },
370
+ })
371
+
372
+ expect(result.body).toEqual({ type: DiscordInteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE })
373
+ expect(result.dispatch).toMatchObject({
374
+ id: 'interaction-1',
375
+ token: 'interaction-token',
376
+ applicationId: 'app-1',
377
+ discordChannelId: 'discord-channel-1',
378
+ commandName: 'mercato',
379
+ content: '/mercato message:the printer is jammed',
380
+ })
381
+ // Tenant pinning is the same guarantee the PING path asserts.
382
+ expect(result.matchedChannel?.tenantId).toBe('t-1')
383
+ })
384
+
385
+ it('defers a component press AND returns its dispatch', () => {
386
+ const result = verified({
387
+ type: DiscordInteractionType.MESSAGE_COMPONENT,
388
+ id: 'interaction-2',
389
+ token: 'component-token',
390
+ application_id: 'app-1',
391
+ channel_id: 'discord-channel-1',
392
+ member: invoker,
393
+ data: { custom_id: 'escalate', values: ['tier-2'] },
394
+ })
395
+
396
+ expect(result.body).toEqual({ type: DiscordInteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE })
397
+ expect(result.dispatch?.customId).toBe('escalate')
398
+ expect(result.dispatch?.content).toBe('[component] escalate tier-2')
399
+ })
400
+
401
+ it('answers visibly, never with a deferred ack, when the payload cannot be dispatched', () => {
402
+ // No token and no channel: there would be nothing to send a follow-up to.
403
+ const result = verified({
404
+ type: DiscordInteractionType.APPLICATION_COMMAND,
405
+ id: 'interaction-3',
406
+ application_id: 'app-1',
407
+ member: invoker,
408
+ data: { name: 'mercato' },
409
+ })
410
+
411
+ expect(result.dispatch).toBeNull()
412
+ expect(result.body).toEqual({
413
+ type: DiscordInteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
414
+ data: { content: DEFAULT_INTERACTION_MESSAGES.notDispatchable, flags: 64 },
415
+ })
416
+ })
417
+
418
+ it('answers autocomplete with an empty choice list — Discord rejects a deferred ack there', () => {
419
+ const result = verified({
420
+ type: DiscordInteractionType.APPLICATION_COMMAND_AUTOCOMPLETE,
421
+ id: 'interaction-4',
422
+ token: 'autocomplete-token',
423
+ application_id: 'app-1',
424
+ channel_id: 'discord-channel-1',
425
+ member: invoker,
426
+ data: { name: 'mercato' },
427
+ })
428
+
429
+ expect(result.body).toEqual({
430
+ type: DiscordInteractionResponseType.APPLICATION_COMMAND_AUTOCOMPLETE_RESULT,
431
+ data: { choices: [] },
432
+ })
433
+ expect(result.dispatch).toBeNull()
434
+ })
435
+
436
+ it('tells the user an unknown interaction type is not handled instead of deferring forever', () => {
437
+ const result = verified({
438
+ type: 99,
439
+ id: 'interaction-5',
440
+ token: 'unknown-token',
441
+ application_id: 'app-1',
442
+ channel_id: 'discord-channel-1',
443
+ member: invoker,
444
+ })
445
+
446
+ expect(result.dispatch).toBeNull()
447
+ expect(result.body).toEqual({
448
+ type: DiscordInteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
449
+ data: { content: DEFAULT_INTERACTION_MESSAGES.unsupported, flags: 64 },
450
+ })
451
+ })
452
+
453
+ it('uses the caller-supplied localized copy when it is given one', () => {
454
+ const signer = makeSigner()
455
+ const rawBody = JSON.stringify({ type: 99, id: 'i', token: 't', application_id: 'app-1' })
456
+ const result = handleDiscordInteraction({
457
+ rawBody,
458
+ signatureHex: signer.sign(timestamp + rawBody),
459
+ timestamp,
460
+ freshness,
461
+ candidates: [signedCandidate(signer)],
462
+ messages: { notDispatchable: 'nie zapisano', unsupported: 'nieobsługiwane' },
463
+ })
464
+ expect(result.body).toEqual({
465
+ type: DiscordInteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
466
+ data: { content: 'nieobsługiwane', flags: 64 },
467
+ })
468
+ })
469
+ })
@@ -0,0 +1,126 @@
1
+ import { generateKeyPairSync, sign as cryptoSign } from 'node:crypto'
2
+ import {
3
+ verifyDiscordSignature,
4
+ parseInteractionBody,
5
+ isSignatureTimestampFresh,
6
+ DiscordInteractionType,
7
+ DISCORD_SIGNATURE_MAX_SKEW_SECONDS,
8
+ } from '../interactions-verify'
9
+
10
+ function makeSigner() {
11
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519')
12
+ // Raw 32-byte public key is the last 32 bytes of the SPKI DER document.
13
+ const spki = publicKey.export({ type: 'spki', format: 'der' }) as Buffer
14
+ const publicKeyHex = spki.subarray(spki.length - 32).toString('hex')
15
+ return {
16
+ publicKeyHex,
17
+ sign(message: string): string {
18
+ return cryptoSign(null, Buffer.from(message, 'utf-8'), privateKey).toString('hex')
19
+ },
20
+ }
21
+ }
22
+
23
+ describe('verifyDiscordSignature (Ed25519, fail-closed)', () => {
24
+ const signer = makeSigner()
25
+ const timestamp = '1700000000'
26
+ const rawBody = JSON.stringify({ type: 1 })
27
+ const signatureHex = signer.sign(timestamp + rawBody)
28
+
29
+ it('accepts a correctly signed request', () => {
30
+ expect(
31
+ verifyDiscordSignature({ publicKeyHex: signer.publicKeyHex, signatureHex, timestamp, rawBody }),
32
+ ).toBe(true)
33
+ })
34
+
35
+ it('rejects a tampered body', () => {
36
+ expect(
37
+ verifyDiscordSignature({
38
+ publicKeyHex: signer.publicKeyHex,
39
+ signatureHex,
40
+ timestamp,
41
+ rawBody: JSON.stringify({ type: 2 }),
42
+ }),
43
+ ).toBe(false)
44
+ })
45
+
46
+ it('rejects a tampered timestamp', () => {
47
+ expect(
48
+ verifyDiscordSignature({ publicKeyHex: signer.publicKeyHex, signatureHex, timestamp: '1700000001', rawBody }),
49
+ ).toBe(false)
50
+ })
51
+
52
+ it('rejects a missing signature (fail-closed)', () => {
53
+ expect(
54
+ verifyDiscordSignature({ publicKeyHex: signer.publicKeyHex, signatureHex: undefined, timestamp, rawBody }),
55
+ ).toBe(false)
56
+ })
57
+
58
+ it('rejects a missing timestamp (fail-closed)', () => {
59
+ expect(
60
+ verifyDiscordSignature({ publicKeyHex: signer.publicKeyHex, signatureHex, timestamp: null, rawBody }),
61
+ ).toBe(false)
62
+ })
63
+
64
+ it('rejects a malformed public key without throwing', () => {
65
+ expect(
66
+ verifyDiscordSignature({ publicKeyHex: 'not-hex', signatureHex, timestamp, rawBody }),
67
+ ).toBe(false)
68
+ })
69
+
70
+ it('rejects a signature that verifies under a different key', () => {
71
+ const otherSigner = makeSigner()
72
+ expect(
73
+ verifyDiscordSignature({ publicKeyHex: otherSigner.publicKeyHex, signatureHex, timestamp, rawBody }),
74
+ ).toBe(false)
75
+ })
76
+ })
77
+
78
+ describe('isSignatureTimestampFresh (replay guard, fail-closed)', () => {
79
+ const now = 1_700_000_000
80
+
81
+ it('accepts a timestamp at the current clock', () => {
82
+ expect(isSignatureTimestampFresh(String(now), { nowEpochSeconds: now })).toBe(true)
83
+ })
84
+
85
+ it('accepts skew up to the window boundary, in both directions', () => {
86
+ expect(isSignatureTimestampFresh(String(now - DISCORD_SIGNATURE_MAX_SKEW_SECONDS), { nowEpochSeconds: now })).toBe(true)
87
+ expect(isSignatureTimestampFresh(String(now + DISCORD_SIGNATURE_MAX_SKEW_SECONDS), { nowEpochSeconds: now })).toBe(true)
88
+ })
89
+
90
+ it('rejects a timestamp just past the window (replayed capture)', () => {
91
+ expect(isSignatureTimestampFresh(String(now - DISCORD_SIGNATURE_MAX_SKEW_SECONDS - 1), { nowEpochSeconds: now })).toBe(false)
92
+ expect(isSignatureTimestampFresh(String(now + DISCORD_SIGNATURE_MAX_SKEW_SECONDS + 1), { nowEpochSeconds: now })).toBe(false)
93
+ })
94
+
95
+ it('honors a custom maxSkewSeconds', () => {
96
+ expect(isSignatureTimestampFresh(String(now - 10), { nowEpochSeconds: now, maxSkewSeconds: 5 })).toBe(false)
97
+ expect(isSignatureTimestampFresh(String(now - 10), { nowEpochSeconds: now, maxSkewSeconds: 15 })).toBe(true)
98
+ })
99
+
100
+ it('rejects missing or non-numeric timestamps (fail-closed)', () => {
101
+ expect(isSignatureTimestampFresh(null, { nowEpochSeconds: now })).toBe(false)
102
+ expect(isSignatureTimestampFresh(undefined, { nowEpochSeconds: now })).toBe(false)
103
+ expect(isSignatureTimestampFresh('', { nowEpochSeconds: now })).toBe(false)
104
+ expect(isSignatureTimestampFresh('not-a-number', { nowEpochSeconds: now })).toBe(false)
105
+ expect(isSignatureTimestampFresh('1700000000.5', { nowEpochSeconds: now })).toBe(false)
106
+ expect(isSignatureTimestampFresh('-1700000000', { nowEpochSeconds: now })).toBe(false)
107
+ expect(isSignatureTimestampFresh('1e9', { nowEpochSeconds: now })).toBe(false)
108
+ })
109
+
110
+ it('uses the real clock by default', () => {
111
+ expect(isSignatureTimestampFresh(String(Math.floor(Date.now() / 1000)))).toBe(true)
112
+ expect(isSignatureTimestampFresh('1700000000')).toBe(false)
113
+ })
114
+ })
115
+
116
+ describe('parseInteractionBody', () => {
117
+ it('parses a JSON interaction with a numeric type', () => {
118
+ const parsed = parseInteractionBody(JSON.stringify({ type: DiscordInteractionType.PING }))
119
+ expect(parsed?.type).toBe(DiscordInteractionType.PING)
120
+ })
121
+
122
+ it('returns null for a non-interaction body', () => {
123
+ expect(parseInteractionBody(JSON.stringify({ hello: 'world' }))).toBeNull()
124
+ expect(parseInteractionBody('not json')).toBeNull()
125
+ })
126
+ })
@@ -0,0 +1,64 @@
1
+ import { normalizeInboundDiscordMessage } from '../normalize-inbound'
2
+ import type { DiscordMessageObject } from '../discord-rest'
3
+
4
+ /**
5
+ * The sender-identity half of the inbound contract, pinned against a real
6
+ * `MESSAGE_CREATE` frame captured from a live guild during QA of #4391.
7
+ *
8
+ * TC-CHANNEL-DISCORD-003 claimed to cover "an inbound Discord message lands in
9
+ * the hub", but drove the generic email-shaped test-seed fixture with an invented
10
+ * `@test-seed.local` address, so this function never executed on that path and
11
+ * the test passed while real inbound was rejected (#4975). These assertions are
12
+ * the ones that were actually missing: they run the mapper on the real payload
13
+ * shape and pin the fact that a Discord sender carries a snowflake and nothing
14
+ * email-like, which is precisely why the hub's `externalEmail` requirement makes
15
+ * the two contracts incompatible.
16
+ */
17
+ const GATEWAY_MESSAGE: DiscordMessageObject = {
18
+ id: '1534333813851816108',
19
+ channel_id: '1534331920463433771',
20
+ guild_id: '1534331919796273152',
21
+ content: 'Kolejna wiadomość testowa!@',
22
+ timestamp: '2026-08-04T21:14:07.000000+00:00',
23
+ author: {
24
+ id: '1499156851487539260',
25
+ username: 'lorakaspak',
26
+ global_name: 'Karol Kapsa',
27
+ bot: false,
28
+ },
29
+ } as unknown as DiscordMessageObject
30
+
31
+ describe('normalizeInboundDiscordMessage — sender identity', () => {
32
+ it('identifies the sender by Discord snowflake, not by an address', () => {
33
+ const normalized = normalizeInboundDiscordMessage(GATEWAY_MESSAGE)
34
+
35
+ expect(normalized.senderIdentifier).toBe('1499156851487539260')
36
+ expect(normalized.senderDisplayName).toBe('Karol Kapsa')
37
+ expect(normalized.externalMessageId).toBe('1534333813851816108')
38
+ expect(normalized.externalConversationId).toBe('discord-channel:1534331920463433771')
39
+ })
40
+
41
+ it('produces no email-shaped sender value anywhere in the normalized message', () => {
42
+ const normalized = normalizeInboundDiscordMessage(GATEWAY_MESSAGE)
43
+
44
+ // The guard that matters: nothing the mapper emits can satisfy a
45
+ // `z.string().email()` requirement, so any hub path demanding one rejects
46
+ // every Discord message deterministically. Kept as an explicit assertion so
47
+ // a future "just synthesise <id>@discord.invalid" workaround — explicitly
48
+ // rejected in #4975 because it pollutes CRM contact resolution — fails here.
49
+ expect(normalized.senderIdentifier).not.toMatch(/@/)
50
+ expect(normalized.senderDisplayName ?? '').not.toMatch(/@/)
51
+ })
52
+
53
+ it('carries the Discord identity through channelMetadata for contact resolution', () => {
54
+ const normalized = normalizeInboundDiscordMessage(GATEWAY_MESSAGE)
55
+
56
+ expect(normalized.channelMetadata).toMatchObject({
57
+ discordChannelId: '1534331920463433771',
58
+ discordGuildId: '1534331919796273152',
59
+ discordAuthorId: '1499156851487539260',
60
+ discordAuthorUsername: 'lorakaspak',
61
+ discordAuthorIsBot: false,
62
+ })
63
+ })
64
+ })