@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,2 @@
1
+ [build:channel-discord] found 60 entry points
2
+ [build:channel-discord] built successfully
package/build.mjs ADDED
@@ -0,0 +1,7 @@
1
+ import { dirname } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { buildPackage } from '../../scripts/build-package.mjs'
4
+
5
+ const packageDir = dirname(fileURLToPath(import.meta.url))
6
+
7
+ await buildPackage(packageDir, { name: 'channel-discord' })
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ import { metadata } from "./modules/channel_discord/index.js";
2
+ export {
3
+ metadata
4
+ };
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.ts"],
4
+ "sourcesContent": ["export { metadata } from './modules/channel_discord/index'\n"],
5
+ "mappings": "AAAA,SAAS,gBAAgB;",
6
+ "names": []
7
+ }
@@ -0,0 +1,86 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
+ import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
4
+ test.describe("TC-CHANNEL-DISCORD-001: connect channel via credentials", () => {
5
+ test("requires authentication", async ({ request }) => {
6
+ const response = await apiRequest(
7
+ request,
8
+ "POST",
9
+ "/api/communication_channels/channels/connect/credentials",
10
+ // Intentionally empty token — this assertion is the 401 unauth path.
11
+ { token: "", data: { providerKey: "discord", displayName: "unauth", credentials: {} } }
12
+ );
13
+ expect(response.status(), "connect must reject an unauthenticated caller").toBe(401);
14
+ });
15
+ test("the discord adapter is registered and rejects malformed credentials with field errors", async ({
16
+ request
17
+ }) => {
18
+ const token = await getAuthToken(request, "admin");
19
+ const stamp = Date.now();
20
+ const unknownProvider = await apiRequest(
21
+ request,
22
+ "POST",
23
+ "/api/communication_channels/channels/connect/credentials",
24
+ {
25
+ token,
26
+ data: {
27
+ providerKey: `not-a-provider-${stamp}`,
28
+ displayName: `TC-CHANNEL-DISCORD-001 unknown ${stamp}`,
29
+ credentials: {}
30
+ }
31
+ }
32
+ );
33
+ expect(
34
+ unknownProvider.status(),
35
+ "an unregistered provider key must resolve to no adapter (404)"
36
+ ).toBe(404);
37
+ const discord = await apiRequest(
38
+ request,
39
+ "POST",
40
+ "/api/communication_channels/channels/connect/credentials",
41
+ {
42
+ token,
43
+ data: {
44
+ providerKey: "discord",
45
+ displayName: `TC-CHANNEL-DISCORD-001 ${stamp}`,
46
+ // Missing botToken/applicationId, and a publicKey that is neither hex
47
+ // nor 32 bytes — all three must come back as field errors.
48
+ credentials: { publicKey: "not-a-hex-key" }
49
+ }
50
+ }
51
+ );
52
+ expect(
53
+ discord.status(),
54
+ "discord IS registered, so the failure must be credential validation (422), not 404"
55
+ ).toBe(422);
56
+ const body = await readJsonSafe(discord);
57
+ expect(body?.error, "the 422 must report credential validation").toBe("Credential validation failed");
58
+ const fieldErrors = body?.fieldErrors ?? {};
59
+ expect(
60
+ Object.keys(fieldErrors),
61
+ "every missing/invalid credential field must be reported back to the operator"
62
+ ).toEqual(expect.arrayContaining(["botToken", "applicationId", "publicKey"]));
63
+ });
64
+ test("a rejected connect attempt creates no channel", async ({ request }) => {
65
+ const token = await getAuthToken(request, "admin");
66
+ const stamp = Date.now();
67
+ const displayName = `TC-CHANNEL-DISCORD-001 orphan ${stamp}`;
68
+ const rejected = await apiRequest(
69
+ request,
70
+ "POST",
71
+ "/api/communication_channels/channels/connect/credentials",
72
+ { token, data: { providerKey: "discord", displayName, credentials: {} } }
73
+ );
74
+ expect(rejected.status(), "malformed credentials must be rejected").toBe(422);
75
+ const listed = await apiRequest(request, "GET", "/api/communication_channels/me/channels", {
76
+ token
77
+ });
78
+ expect(listed.status(), "GET /me/channels should return 200").toBe(200);
79
+ const body = await readJsonSafe(listed);
80
+ const names = (body?.items ?? []).map((item) => item.displayName);
81
+ expect(names, "a failed validation must not leave a half-connected channel behind").not.toContain(
82
+ displayName
83
+ );
84
+ });
85
+ });
86
+ //# sourceMappingURL=TC-CHANNEL-DISCORD-001.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.ts"],
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\n\n/**\n * TC-CHANNEL-DISCORD-001 \u2014 connect a Discord channel via credentials.\n * Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md\n *\n * Asserts the two halves of the connect contract through the hub's real route:\n *\n * 1. The `discord` adapter IS registered with the hub \u2014 an unknown provider key\n * resolves to no adapter and returns 404, `discord` does not.\n * 2. `validateCredentials` is fail-closed and offline \u2014 a malformed credential\n * blob is rejected with per-field errors (422) by the zod schema BEFORE any\n * Discord REST call, so this test needs no bot token and no network.\n *\n * The happy path (a real token accepted by `GET /users/@me`) needs live Discord\n * and stays unit-tested with a stubbed REST client in\n * `lib/__tests__/adapter.test.ts`.\n */\ntest.describe('TC-CHANNEL-DISCORD-001: connect channel via credentials', () => {\n test('requires authentication', async ({ request }) => {\n const response = await apiRequest(\n request,\n 'POST',\n '/api/communication_channels/channels/connect/credentials',\n // Intentionally empty token \u2014 this assertion is the 401 unauth path.\n { token: '', data: { providerKey: 'discord', displayName: 'unauth', credentials: {} } },\n )\n expect(response.status(), 'connect must reject an unauthenticated caller').toBe(401)\n })\n\n test('the discord adapter is registered and rejects malformed credentials with field errors', async ({\n request,\n }) => {\n const token = await getAuthToken(request, 'admin')\n const stamp = Date.now()\n\n const unknownProvider = await apiRequest(\n request,\n 'POST',\n '/api/communication_channels/channels/connect/credentials',\n {\n token,\n data: {\n providerKey: `not-a-provider-${stamp}`,\n displayName: `TC-CHANNEL-DISCORD-001 unknown ${stamp}`,\n credentials: {},\n },\n },\n )\n expect(\n unknownProvider.status(),\n 'an unregistered provider key must resolve to no adapter (404)',\n ).toBe(404)\n\n const discord = await apiRequest(\n request,\n 'POST',\n '/api/communication_channels/channels/connect/credentials',\n {\n token,\n data: {\n providerKey: 'discord',\n displayName: `TC-CHANNEL-DISCORD-001 ${stamp}`,\n // Missing botToken/applicationId, and a publicKey that is neither hex\n // nor 32 bytes \u2014 all three must come back as field errors.\n credentials: { publicKey: 'not-a-hex-key' },\n },\n },\n )\n expect(\n discord.status(),\n 'discord IS registered, so the failure must be credential validation (422), not 404',\n ).toBe(422)\n\n const body = await readJsonSafe<{ error?: string; fieldErrors?: Record<string, string> }>(discord)\n expect(body?.error, 'the 422 must report credential validation').toBe('Credential validation failed')\n const fieldErrors = body?.fieldErrors ?? {}\n expect(\n Object.keys(fieldErrors),\n 'every missing/invalid credential field must be reported back to the operator',\n ).toEqual(expect.arrayContaining(['botToken', 'applicationId', 'publicKey']))\n })\n\n test('a rejected connect attempt creates no channel', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n const stamp = Date.now()\n const displayName = `TC-CHANNEL-DISCORD-001 orphan ${stamp}`\n\n const rejected = await apiRequest(\n request,\n 'POST',\n '/api/communication_channels/channels/connect/credentials',\n { token, data: { providerKey: 'discord', displayName, credentials: {} } },\n )\n expect(rejected.status(), 'malformed credentials must be rejected').toBe(422)\n\n const listed = await apiRequest(request, 'GET', '/api/communication_channels/me/channels', {\n token,\n })\n expect(listed.status(), 'GET /me/channels should return 200').toBe(200)\n const body = await readJsonSafe<{ items?: Array<{ displayName?: string }> }>(listed)\n const names = (body?.items ?? []).map((item) => item.displayName)\n expect(names, 'a failed validation must not leave a half-connected channel behind').not.toContain(\n displayName,\n )\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAkB7B,KAAK,SAAS,2DAA2D,MAAM;AAC7E,OAAK,2BAA2B,OAAO,EAAE,QAAQ,MAAM;AACrD,UAAM,WAAW,MAAM;AAAA,MACrB;AAAA,MACA;AAAA,MACA;AAAA;AAAA,MAEA,EAAE,OAAO,IAAI,MAAM,EAAE,aAAa,WAAW,aAAa,UAAU,aAAa,CAAC,EAAE,EAAE;AAAA,IACxF;AACA,WAAO,SAAS,OAAO,GAAG,+CAA+C,EAAE,KAAK,GAAG;AAAA,EACrF,CAAC;AAED,OAAK,yFAAyF,OAAO;AAAA,IACnG;AAAA,EACF,MAAM;AACJ,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,UAAM,QAAQ,KAAK,IAAI;AAEvB,UAAM,kBAAkB,MAAM;AAAA,MAC5B;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,QACE;AAAA,QACA,MAAM;AAAA,UACJ,aAAa,kBAAkB,KAAK;AAAA,UACpC,aAAa,kCAAkC,KAAK;AAAA,UACpD,aAAa,CAAC;AAAA,QAChB;AAAA,MACF;AAAA,IACF;AACA;AAAA,MACE,gBAAgB,OAAO;AAAA,MACvB;AAAA,IACF,EAAE,KAAK,GAAG;AAEV,UAAM,UAAU,MAAM;AAAA,MACpB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,QACE;AAAA,QACA,MAAM;AAAA,UACJ,aAAa;AAAA,UACb,aAAa,0BAA0B,KAAK;AAAA;AAAA;AAAA,UAG5C,aAAa,EAAE,WAAW,gBAAgB;AAAA,QAC5C;AAAA,MACF;AAAA,IACF;AACA;AAAA,MACE,QAAQ,OAAO;AAAA,MACf;AAAA,IACF,EAAE,KAAK,GAAG;AAEV,UAAM,OAAO,MAAM,aAAuE,OAAO;AACjG,WAAO,MAAM,OAAO,2CAA2C,EAAE,KAAK,8BAA8B;AACpG,UAAM,cAAc,MAAM,eAAe,CAAC;AAC1C;AAAA,MACE,OAAO,KAAK,WAAW;AAAA,MACvB;AAAA,IACF,EAAE,QAAQ,OAAO,gBAAgB,CAAC,YAAY,iBAAiB,WAAW,CAAC,CAAC;AAAA,EAC9E,CAAC;AAED,OAAK,iDAAiD,OAAO,EAAE,QAAQ,MAAM;AAC3E,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,UAAM,QAAQ,KAAK,IAAI;AACvB,UAAM,cAAc,iCAAiC,KAAK;AAE1D,UAAM,WAAW,MAAM;AAAA,MACrB;AAAA,MACA;AAAA,MACA;AAAA,MACA,EAAE,OAAO,MAAM,EAAE,aAAa,WAAW,aAAa,aAAa,CAAC,EAAE,EAAE;AAAA,IAC1E;AACA,WAAO,SAAS,OAAO,GAAG,wCAAwC,EAAE,KAAK,GAAG;AAE5E,UAAM,SAAS,MAAM,WAAW,SAAS,OAAO,2CAA2C;AAAA,MACzF;AAAA,IACF,CAAC;AACD,WAAO,OAAO,OAAO,GAAG,oCAAoC,EAAE,KAAK,GAAG;AACtE,UAAM,OAAO,MAAM,aAA0D,MAAM;AACnF,UAAM,SAAS,MAAM,SAAS,CAAC,GAAG,IAAI,CAAC,SAAS,KAAK,WAAW;AAChE,WAAO,OAAO,oEAAoE,EAAE,IAAI;AAAA,MACtF;AAAA,IACF;AAAA,EACF,CAAC;AACH,CAAC;",
6
+ "names": []
7
+ }
@@ -0,0 +1,39 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
+ const UNKNOWN_CHANNEL_ID = "00000000-0000-4000-8000-0000000d1c02";
4
+ test.describe("TC-CHANNEL-DISCORD-002: outbound test-send guards", () => {
5
+ test("requires authentication", async ({ request }) => {
6
+ const response = await apiRequest(
7
+ request,
8
+ "POST",
9
+ `/api/communication_channels/channels/${UNKNOWN_CHANNEL_ID}/test-send`,
10
+ // Intentionally empty token — this assertion is the 401 unauth path.
11
+ { token: "", data: { to: "integration@example.com" } }
12
+ );
13
+ expect(response.status(), "test-send must reject an unauthenticated caller").toBe(401);
14
+ });
15
+ test("rejects a malformed channel id before touching any adapter", async ({ request }) => {
16
+ const token = await getAuthToken(request, "admin");
17
+ const response = await apiRequest(
18
+ request,
19
+ "POST",
20
+ "/api/communication_channels/channels/not-a-uuid/test-send",
21
+ { token, data: { to: "integration@example.com" } }
22
+ );
23
+ expect(response.status(), "a non-uuid channel id must be a 400, not a 500").toBe(400);
24
+ });
25
+ test("rejects a channel the caller does not own", async ({ request }) => {
26
+ const token = await getAuthToken(request, "admin");
27
+ const response = await apiRequest(
28
+ request,
29
+ "POST",
30
+ `/api/communication_channels/channels/${UNKNOWN_CHANNEL_ID}/test-send`,
31
+ { token, data: { to: "integration@example.com" } }
32
+ );
33
+ expect(
34
+ response.status(),
35
+ "an unknown/foreign channel id must be 404 \u2014 never a send attempt"
36
+ ).toBe(404);
37
+ });
38
+ });
39
+ //# sourceMappingURL=TC-CHANNEL-DISCORD-002.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.ts"],
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\n\n/**\n * TC-CHANNEL-DISCORD-002 \u2014 outbound test-send routing for a Discord channel.\n * Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md\n *\n * The hub owns the outbound entry point (`POST /channels/[id]/test-send`); the\n * provider only supplies `convertOutbound` + `sendMessage`. This spec pins the\n * route's tenant-safety contract, which is what a provider PR can break: an\n * unauthenticated caller, a malformed id, and an id the caller does not own are\n * all refused BEFORE any adapter is resolved \u2014 so no Discord REST call can be\n * driven by an unauthorized request.\n *\n * The actual Discord payload conversion and REST post are unit-tested against a\n * stubbed client in `lib/__tests__/convert-outbound.test.ts` and\n * `lib/__tests__/adapter.test.ts`; they need a live bot token and are therefore\n * out of scope for CI.\n */\nconst UNKNOWN_CHANNEL_ID = '00000000-0000-4000-8000-0000000d1c02'\n\ntest.describe('TC-CHANNEL-DISCORD-002: outbound test-send guards', () => {\n test('requires authentication', async ({ request }) => {\n const response = await apiRequest(\n request,\n 'POST',\n `/api/communication_channels/channels/${UNKNOWN_CHANNEL_ID}/test-send`,\n // Intentionally empty token \u2014 this assertion is the 401 unauth path.\n { token: '', data: { to: 'integration@example.com' } },\n )\n expect(response.status(), 'test-send must reject an unauthenticated caller').toBe(401)\n })\n\n test('rejects a malformed channel id before touching any adapter', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n const response = await apiRequest(\n request,\n 'POST',\n '/api/communication_channels/channels/not-a-uuid/test-send',\n { token, data: { to: 'integration@example.com' } },\n )\n expect(response.status(), 'a non-uuid channel id must be a 400, not a 500').toBe(400)\n })\n\n test('rejects a channel the caller does not own', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n const response = await apiRequest(\n request,\n 'POST',\n `/api/communication_channels/channels/${UNKNOWN_CHANNEL_ID}/test-send`,\n { token, data: { to: 'integration@example.com' } },\n )\n expect(\n response.status(),\n 'an unknown/foreign channel id must be 404 \u2014 never a send attempt',\n ).toBe(404)\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AAkBzC,MAAM,qBAAqB;AAE3B,KAAK,SAAS,qDAAqD,MAAM;AACvE,OAAK,2BAA2B,OAAO,EAAE,QAAQ,MAAM;AACrD,UAAM,WAAW,MAAM;AAAA,MACrB;AAAA,MACA;AAAA,MACA,wCAAwC,kBAAkB;AAAA;AAAA,MAE1D,EAAE,OAAO,IAAI,MAAM,EAAE,IAAI,0BAA0B,EAAE;AAAA,IACvD;AACA,WAAO,SAAS,OAAO,GAAG,iDAAiD,EAAE,KAAK,GAAG;AAAA,EACvF,CAAC;AAED,OAAK,8DAA8D,OAAO,EAAE,QAAQ,MAAM;AACxF,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,UAAM,WAAW,MAAM;AAAA,MACrB;AAAA,MACA;AAAA,MACA;AAAA,MACA,EAAE,OAAO,MAAM,EAAE,IAAI,0BAA0B,EAAE;AAAA,IACnD;AACA,WAAO,SAAS,OAAO,GAAG,gDAAgD,EAAE,KAAK,GAAG;AAAA,EACtF,CAAC;AAED,OAAK,6CAA6C,OAAO,EAAE,QAAQ,MAAM;AACvE,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,UAAM,WAAW,MAAM;AAAA,MACrB;AAAA,MACA;AAAA,MACA,wCAAwC,kBAAkB;AAAA,MAC1D,EAAE,OAAO,MAAM,EAAE,IAAI,0BAA0B,EAAE;AAAA,IACnD;AACA;AAAA,MACE,SAAS,OAAO;AAAA,MAChB;AAAA,IACF,EAAE,KAAK,GAAG;AAAA,EACZ,CAAC;AACH,CAAC;",
6
+ "names": []
7
+ }
@@ -0,0 +1,97 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
+ import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
4
+ import {
5
+ deleteChannelIfExists,
6
+ ingestInboundChatMessage,
7
+ isChannelSeedingAvailable,
8
+ seedConnectedChannel
9
+ } from "@open-mercato/core/helpers/integration/communicationChannelsFixtures";
10
+ import { normalizeInboundDiscordMessage } from "../lib/normalize-inbound.js";
11
+ async function readHealth(request, token, channelId) {
12
+ const response = await apiRequest(
13
+ request,
14
+ "GET",
15
+ `/api/communication_channels/channels/${channelId}/health`,
16
+ { token }
17
+ );
18
+ expect(response.status(), "GET /channels/[id]/health should return 200").toBe(200);
19
+ return await readJsonSafe(response) ?? {};
20
+ }
21
+ function discordMessageCreateFrame(stamp) {
22
+ return {
23
+ id: `1534333813851816${String(stamp).slice(-3)}`,
24
+ channel_id: "1534331920463433771",
25
+ guild_id: "1534331919796273152",
26
+ content: "Kolejna wiadomo\u015B\u0107 testowa!@",
27
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
28
+ author: {
29
+ id: "1499156851487539260",
30
+ username: "lorakaspak",
31
+ global_name: "Karol Kapsa",
32
+ bot: false
33
+ },
34
+ attachments: []
35
+ };
36
+ }
37
+ test.describe("TC-CHANNEL-DISCORD-003: inbound discord message ingest", () => {
38
+ test("a message shaped the way the Discord gateway actually produces it is ingested", async ({
39
+ request
40
+ }) => {
41
+ test.slow();
42
+ let token = null;
43
+ let channelId = null;
44
+ try {
45
+ token = await getAuthToken(request, "admin");
46
+ const seedingAvailable = await isChannelSeedingAvailable(request, token);
47
+ test.skip(
48
+ !seedingAvailable,
49
+ "OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled in this environment; cannot ingest."
50
+ );
51
+ const stamp = Date.now();
52
+ channelId = await seedConnectedChannel(request, token, {
53
+ displayName: `TC-CHANNEL-DISCORD-003 ${stamp}`,
54
+ providerFlavor: "chat"
55
+ });
56
+ const before = await readHealth(request, token, channelId);
57
+ const totalBefore = before.totalsLast24h ?? 0;
58
+ const frame = discordMessageCreateFrame(stamp);
59
+ const normalized = normalizeInboundDiscordMessage(frame);
60
+ expect(
61
+ normalized.senderIdentifier,
62
+ "the sender must stay the Discord snowflake \u2014 synthesising an address here is what #4975 rejects"
63
+ ).toBe("1499156851487539260");
64
+ expect(normalized.externalConversationId).toBe("discord-channel:1534331920463433771");
65
+ expect(
66
+ normalized.externalEmail,
67
+ "the normalizer must not invent an address"
68
+ ).toBeUndefined();
69
+ const ingested = await ingestInboundChatMessage(request, token, {
70
+ channelId,
71
+ senderIdentifier: normalized.senderIdentifier,
72
+ senderDisplayName: normalized.senderDisplayName,
73
+ body: normalized.body,
74
+ externalMessageId: normalized.externalMessageId,
75
+ externalConversationId: normalized.externalConversationId
76
+ });
77
+ expect(
78
+ ingested.status,
79
+ "the hub must accept an inbound message whose sender has no email address"
80
+ ).toBe("created");
81
+ expect(ingested.messageId, "a platform message must exist").toBeTruthy();
82
+ expect(ingested.channelLinkId, "the hub must persist a MessageChannelLink").toBeTruthy();
83
+ expect(
84
+ ingested.channelType,
85
+ "the compose must have been told the originating channel type"
86
+ ).not.toBe("email");
87
+ const after = await readHealth(request, token, channelId);
88
+ expect(
89
+ after.totalsLast24h ?? 0,
90
+ "the channel health snapshot must count the ingested message"
91
+ ).toBeGreaterThan(totalBefore);
92
+ } finally {
93
+ if (token && channelId) await deleteChannelIfExists(request, token, channelId);
94
+ }
95
+ });
96
+ });
97
+ //# sourceMappingURL=TC-CHANNEL-DISCORD-003.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.ts"],
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n deleteChannelIfExists,\n ingestInboundChatMessage,\n isChannelSeedingAvailable,\n seedConnectedChannel,\n} from '@open-mercato/core/helpers/integration/communicationChannelsFixtures'\nimport { normalizeInboundDiscordMessage } from '../lib/normalize-inbound'\n\n/**\n * TC-CHANNEL-DISCORD-003 \u2014 a message shaped the way the Discord gateway\n * actually produces it is ingested end to end.\n * Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md\n *\n * \u26A0\uFE0F HISTORY, because this spec earned the scepticism (QA of #4391, @Kapsik89\n * 2026-08-04 \u2192 #4975).\n *\n * The first version of this spec claimed to prove that \"an inbound Discord\n * message lands in the hub\" and stayed green through three live defects. It fed\n * the hub an invented `\u2026@test-seed.local` address for both the channel\n * identifier and the recipient, because the hub's `composeMessageSchema`\n * demanded `externalEmail` for every public message. A real Discord channel\n * carries `external_identifier = NULL` and a real Discord sender has no address\n * at all, so `normalizeInboundDiscordMessage` never ran on that path \u2014 the spec\n * asserted a landing zone the real provider could never reach.\n *\n * Both halves of that are fixed now:\n * - #4975 (merged as #5252) made the `externalEmail` requirement conditional\n * on the originating channel being email-typed, fail-closed.\n * - the test-seed `ingest-inbound` action drives the real\n * `ingest_inbound_message` command, which composes through\n * `messages.messages.compose` \u2014 no SQL shortcut, no seeded rows.\n *\n * So this spec now does what its name always claimed:\n * 1. it starts from a verbatim Discord `MESSAGE_CREATE` frame,\n * 2. runs it through the **real** `normalizeInboundDiscordMessage` \u2014 the same\n * function the gateway worker calls, not a re-implementation,\n * 3. hands the normalized result to the hub with **no address anywhere**, and\n * 4. asserts a platform message exists at the other end.\n *\n * Ceiling, stated rather than implied: the transport is the chat-flavoured\n * test-seed adapter, because connecting a real Discord channel requires a live\n * bot token and a live credential probe, neither of which exists in CI. What is\n * NOT stubbed is the part that was broken \u2014 the Discord frame, the Discord\n * normalizer, and the hub's compose validation. The socket state machine\n * (identify/resume/backoff, bot-self filtering, replay dedup) is a pure state\n * machine covered by `lib/__tests__/discord-gateway-client.test.ts` and\n * `lib/__tests__/gateway-bridge.test.ts`.\n *\n * Driven via the env-gated test-seed fixture (`OM_ENABLE_TEST_CHANNEL_SEEDING`);\n * skips when the gate is off.\n */\ntype HealthSnapshot = {\n channelId?: string\n counts?: Record<string, number>\n totalsLast24h?: number\n}\n\nasync function readHealth(\n request: Parameters<typeof apiRequest>[0],\n token: string,\n channelId: string,\n): Promise<HealthSnapshot> {\n const response = await apiRequest(\n request,\n 'GET',\n `/api/communication_channels/channels/${channelId}/health`,\n { token },\n )\n expect(response.status(), 'GET /channels/[id]/health should return 200').toBe(200)\n return (await readJsonSafe<HealthSnapshot>(response)) ?? {}\n}\n\n/**\n * A `MESSAGE_CREATE` frame in the exact shape Discord delivers it. The values\n * are the ones QA captured from a real guild while filing #4975 \u2014 in particular\n * the author id is a genuine snowflake, which is the whole point: there is no\n * address on it, and none is invented anywhere below.\n */\nfunction discordMessageCreateFrame(stamp: number) {\n return {\n id: `1534333813851816${String(stamp).slice(-3)}`,\n channel_id: '1534331920463433771',\n guild_id: '1534331919796273152',\n content: 'Kolejna wiadomo\u015B\u0107 testowa!@',\n timestamp: new Date().toISOString(),\n author: {\n id: '1499156851487539260',\n username: 'lorakaspak',\n global_name: 'Karol Kapsa',\n bot: false,\n },\n attachments: [],\n }\n}\n\ntest.describe('TC-CHANNEL-DISCORD-003: inbound discord message ingest', () => {\n test('a message shaped the way the Discord gateway actually produces it is ingested', async ({\n request,\n }) => {\n test.slow()\n let token: string | null = null\n let channelId: string | null = null\n try {\n token = await getAuthToken(request, 'admin')\n const seedingAvailable = await isChannelSeedingAvailable(request, token)\n test.skip(\n !seedingAvailable,\n 'OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled in this environment; cannot ingest.',\n )\n\n const stamp = Date.now()\n // `providerFlavor: 'chat'` connects the non-email stub, so the channel row\n // carries a non-email `channelType` and a NULL `externalIdentifier` \u2014 the\n // shape a real Discord channel has. No `externalIdentifier` is passed.\n channelId = await seedConnectedChannel(request, token, {\n displayName: `TC-CHANNEL-DISCORD-003 ${stamp}`,\n providerFlavor: 'chat',\n })\n\n const before = await readHealth(request, token, channelId)\n const totalBefore = before.totalsLast24h ?? 0\n\n // The real normalizer, on a real frame. If Discord's payload shape and the\n // hub's expectations ever drift apart, this line is where it shows.\n const frame = discordMessageCreateFrame(stamp)\n const normalized = normalizeInboundDiscordMessage(frame as never)\n\n expect(\n normalized.senderIdentifier,\n 'the sender must stay the Discord snowflake \u2014 synthesising an address here is what #4975 rejects',\n ).toBe('1499156851487539260')\n expect(normalized.externalConversationId).toBe('discord-channel:1534331920463433771')\n expect(\n (normalized as { externalEmail?: unknown }).externalEmail,\n 'the normalizer must not invent an address',\n ).toBeUndefined()\n\n const ingested = await ingestInboundChatMessage(request, token, {\n channelId,\n senderIdentifier: normalized.senderIdentifier,\n senderDisplayName: normalized.senderDisplayName,\n body: normalized.body,\n externalMessageId: normalized.externalMessageId,\n externalConversationId: normalized.externalConversationId,\n })\n\n // The assertion the old version could not make: the hub composed a real\n // platform message from a sender with no address. Before #4975 this step\n // failed `externalEmail is required when visibility is public`, retried\n // three times and died in the queue while the channel still read\n // `Connected`.\n expect(\n ingested.status,\n 'the hub must accept an inbound message whose sender has no email address',\n ).toBe('created')\n expect(ingested.messageId, 'a platform message must exist').toBeTruthy()\n expect(ingested.channelLinkId, 'the hub must persist a MessageChannelLink').toBeTruthy()\n expect(\n ingested.channelType,\n 'the compose must have been told the originating channel type',\n ).not.toBe('email')\n\n const after = await readHealth(request, token, channelId)\n expect(\n after.totalsLast24h ?? 0,\n 'the channel health snapshot must count the ingested message',\n ).toBeGreaterThan(totalBefore)\n } finally {\n if (token && channelId) await deleteChannelIfExists(request, token, channelId)\n }\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,sCAAsC;AAmD/C,eAAe,WACb,SACA,OACA,WACyB;AACzB,QAAM,WAAW,MAAM;AAAA,IACrB;AAAA,IACA;AAAA,IACA,wCAAwC,SAAS;AAAA,IACjD,EAAE,MAAM;AAAA,EACV;AACA,SAAO,SAAS,OAAO,GAAG,6CAA6C,EAAE,KAAK,GAAG;AACjF,SAAQ,MAAM,aAA6B,QAAQ,KAAM,CAAC;AAC5D;AAQA,SAAS,0BAA0B,OAAe;AAChD,SAAO;AAAA,IACL,IAAI,mBAAmB,OAAO,KAAK,EAAE,MAAM,EAAE,CAAC;AAAA,IAC9C,YAAY;AAAA,IACZ,UAAU;AAAA,IACV,SAAS;AAAA,IACT,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,IAClC,QAAQ;AAAA,MACN,IAAI;AAAA,MACJ,UAAU;AAAA,MACV,aAAa;AAAA,MACb,KAAK;AAAA,IACP;AAAA,IACA,aAAa,CAAC;AAAA,EAChB;AACF;AAEA,KAAK,SAAS,0DAA0D,MAAM;AAC5E,OAAK,iFAAiF,OAAO;AAAA,IAC3F;AAAA,EACF,MAAM;AACJ,SAAK,KAAK;AACV,QAAI,QAAuB;AAC3B,QAAI,YAA2B;AAC/B,QAAI;AACF,cAAQ,MAAM,aAAa,SAAS,OAAO;AAC3C,YAAM,mBAAmB,MAAM,0BAA0B,SAAS,KAAK;AACvE,WAAK;AAAA,QACH,CAAC;AAAA,QACD;AAAA,MACF;AAEA,YAAM,QAAQ,KAAK,IAAI;AAIvB,kBAAY,MAAM,qBAAqB,SAAS,OAAO;AAAA,QACrD,aAAa,0BAA0B,KAAK;AAAA,QAC5C,gBAAgB;AAAA,MAClB,CAAC;AAED,YAAM,SAAS,MAAM,WAAW,SAAS,OAAO,SAAS;AACzD,YAAM,cAAc,OAAO,iBAAiB;AAI5C,YAAM,QAAQ,0BAA0B,KAAK;AAC7C,YAAM,aAAa,+BAA+B,KAAc;AAEhE;AAAA,QACE,WAAW;AAAA,QACX;AAAA,MACF,EAAE,KAAK,qBAAqB;AAC5B,aAAO,WAAW,sBAAsB,EAAE,KAAK,qCAAqC;AACpF;AAAA,QACG,WAA2C;AAAA,QAC5C;AAAA,MACF,EAAE,cAAc;AAEhB,YAAM,WAAW,MAAM,yBAAyB,SAAS,OAAO;AAAA,QAC9D;AAAA,QACA,kBAAkB,WAAW;AAAA,QAC7B,mBAAmB,WAAW;AAAA,QAC9B,MAAM,WAAW;AAAA,QACjB,mBAAmB,WAAW;AAAA,QAC9B,wBAAwB,WAAW;AAAA,MACrC,CAAC;AAOD;AAAA,QACE,SAAS;AAAA,QACT;AAAA,MACF,EAAE,KAAK,SAAS;AAChB,aAAO,SAAS,WAAW,+BAA+B,EAAE,WAAW;AACvE,aAAO,SAAS,eAAe,2CAA2C,EAAE,WAAW;AACvF;AAAA,QACE,SAAS;AAAA,QACT;AAAA,MACF,EAAE,IAAI,KAAK,OAAO;AAElB,YAAM,QAAQ,MAAM,WAAW,SAAS,OAAO,SAAS;AACxD;AAAA,QACE,MAAM,iBAAiB;AAAA,QACvB;AAAA,MACF,EAAE,gBAAgB,WAAW;AAAA,IAC/B,UAAE;AACA,UAAI,SAAS,UAAW,OAAM,sBAAsB,SAAS,OAAO,SAAS;AAAA,IAC/E;AAAA,EACF,CAAC;AACH,CAAC;",
6
+ "names": []
7
+ }
@@ -0,0 +1,65 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
+ import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
4
+ import {
5
+ deleteChannelIfExists,
6
+ isChannelSeedingAvailable,
7
+ seedConnectedChannel,
8
+ seedInboundMessage
9
+ } from "@open-mercato/core/helpers/integration/communicationChannelsFixtures";
10
+ test.describe("TC-CHANNEL-DISCORD-004: reactions round-trip on a discord message", () => {
11
+ test("a reaction can be added to and removed from a discord-provider message", async ({
12
+ request
13
+ }) => {
14
+ test.slow();
15
+ let token = null;
16
+ let channelId = null;
17
+ try {
18
+ token = await getAuthToken(request, "admin");
19
+ const seedingAvailable = await isChannelSeedingAvailable(request, token);
20
+ test.skip(
21
+ !seedingAvailable,
22
+ "OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled in this environment; cannot emit inbound messages."
23
+ );
24
+ const stamp = Date.now();
25
+ channelId = await seedConnectedChannel(request, token, {
26
+ displayName: `TC-CHANNEL-DISCORD-004 ${stamp}`,
27
+ externalIdentifier: `discord-004-${stamp}@test-seed.local`
28
+ });
29
+ const seeded = await seedInboundMessage(request, token, {
30
+ channelId,
31
+ providerKey: "discord",
32
+ from: `discord-user-${stamp}`,
33
+ subject: `Discord reaction target ${stamp}`,
34
+ bodyText: "react to me",
35
+ messageId: `discord-message-${stamp}`,
36
+ // The reaction route resolves the owning channel through this mapping.
37
+ createThreadMapping: true
38
+ });
39
+ const added = await apiRequest(
40
+ request,
41
+ "POST",
42
+ `/api/communication_channels/messages/${seeded.messageId}/reactions`,
43
+ { token, data: { emoji: "\u{1F44D}" } }
44
+ );
45
+ expect(added.status(), "adding a reaction to a discord message should return 201").toBe(201);
46
+ const addedBody = await readJsonSafe(added);
47
+ expect(addedBody?.emoji, "the reaction echoes the emoji").toBe("\u{1F44D}");
48
+ expect(addedBody?.messageId, "the reaction is bound to the seeded discord message").toBe(
49
+ seeded.messageId
50
+ );
51
+ const reactionId = addedBody?.id;
52
+ expect(reactionId, "the reaction response must carry an id to remove it by").toBeTruthy();
53
+ const removed = await apiRequest(
54
+ request,
55
+ "DELETE",
56
+ `/api/communication_channels/messages/${seeded.messageId}/reactions/${reactionId}`,
57
+ { token }
58
+ );
59
+ expect(removed.status(), "removing the reaction should return 204").toBe(204);
60
+ } finally {
61
+ await deleteChannelIfExists(request, token, channelId);
62
+ }
63
+ });
64
+ });
65
+ //# sourceMappingURL=TC-CHANNEL-DISCORD-004.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.ts"],
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n deleteChannelIfExists,\n isChannelSeedingAvailable,\n seedConnectedChannel,\n seedInboundMessage,\n} from '@open-mercato/core/helpers/integration/communicationChannelsFixtures'\n\n/**\n * TC-CHANNEL-DISCORD-004 \u2014 reactions on a Discord-provider message.\n * Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md\n *\n * `MESSAGE_REACTION_ADD` / `MESSAGE_REACTION_REMOVE` are bridged into the hub's\n * existing reaction routes, which resolve the owning channel through the\n * `ChannelThreadMapping` an inbound message leaves behind. This spec proves that\n * resolution works when the inbound traffic carries `providerKey: 'discord'` \u2014\n * i.e. the provider needs no reaction storage of its own \u2014 by adding and then\n * removing a reaction on a seeded Discord message.\n *\n * Driven via the env-gated test-seed fixture (`OM_ENABLE_TEST_CHANNEL_SEEDING`);\n * skips when the gate is off.\n */\ntest.describe('TC-CHANNEL-DISCORD-004: reactions round-trip on a discord message', () => {\n test('a reaction can be added to and removed from a discord-provider message', async ({\n request,\n }) => {\n test.slow()\n let token: string | null = null\n let channelId: string | null = null\n try {\n token = await getAuthToken(request, 'admin')\n const seedingAvailable = await isChannelSeedingAvailable(request, token)\n test.skip(\n !seedingAvailable,\n 'OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled in this environment; cannot emit inbound messages.',\n )\n\n const stamp = Date.now()\n channelId = await seedConnectedChannel(request, token, {\n displayName: `TC-CHANNEL-DISCORD-004 ${stamp}`,\n externalIdentifier: `discord-004-${stamp}@test-seed.local`,\n })\n\n const seeded = await seedInboundMessage(request, token, {\n channelId,\n providerKey: 'discord',\n from: `discord-user-${stamp}`,\n subject: `Discord reaction target ${stamp}`,\n bodyText: 'react to me',\n messageId: `discord-message-${stamp}`,\n // The reaction route resolves the owning channel through this mapping.\n createThreadMapping: true,\n })\n\n const added = await apiRequest(\n request,\n 'POST',\n `/api/communication_channels/messages/${seeded.messageId}/reactions`,\n { token, data: { emoji: '\uD83D\uDC4D' } },\n )\n expect(added.status(), 'adding a reaction to a discord message should return 201').toBe(201)\n const addedBody = await readJsonSafe<{ id?: string; emoji?: string; messageId?: string }>(added)\n expect(addedBody?.emoji, 'the reaction echoes the emoji').toBe('\uD83D\uDC4D')\n expect(addedBody?.messageId, 'the reaction is bound to the seeded discord message').toBe(\n seeded.messageId,\n )\n\n const reactionId = addedBody?.id\n expect(reactionId, 'the reaction response must carry an id to remove it by').toBeTruthy()\n const removed = await apiRequest(\n request,\n 'DELETE',\n `/api/communication_channels/messages/${seeded.messageId}/reactions/${reactionId}`,\n { token },\n )\n expect(removed.status(), 'removing the reaction should return 204').toBe(204)\n } finally {\n await deleteChannelIfExists(request, token, channelId)\n }\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAgBP,KAAK,SAAS,qEAAqE,MAAM;AACvF,OAAK,0EAA0E,OAAO;AAAA,IACpF;AAAA,EACF,MAAM;AACJ,SAAK,KAAK;AACV,QAAI,QAAuB;AAC3B,QAAI,YAA2B;AAC/B,QAAI;AACF,cAAQ,MAAM,aAAa,SAAS,OAAO;AAC3C,YAAM,mBAAmB,MAAM,0BAA0B,SAAS,KAAK;AACvE,WAAK;AAAA,QACH,CAAC;AAAA,QACD;AAAA,MACF;AAEA,YAAM,QAAQ,KAAK,IAAI;AACvB,kBAAY,MAAM,qBAAqB,SAAS,OAAO;AAAA,QACrD,aAAa,0BAA0B,KAAK;AAAA,QAC5C,oBAAoB,eAAe,KAAK;AAAA,MAC1C,CAAC;AAED,YAAM,SAAS,MAAM,mBAAmB,SAAS,OAAO;AAAA,QACtD;AAAA,QACA,aAAa;AAAA,QACb,MAAM,gBAAgB,KAAK;AAAA,QAC3B,SAAS,2BAA2B,KAAK;AAAA,QACzC,UAAU;AAAA,QACV,WAAW,mBAAmB,KAAK;AAAA;AAAA,QAEnC,qBAAqB;AAAA,MACvB,CAAC;AAED,YAAM,QAAQ,MAAM;AAAA,QAClB;AAAA,QACA;AAAA,QACA,wCAAwC,OAAO,SAAS;AAAA,QACxD,EAAE,OAAO,MAAM,EAAE,OAAO,YAAK,EAAE;AAAA,MACjC;AACA,aAAO,MAAM,OAAO,GAAG,0DAA0D,EAAE,KAAK,GAAG;AAC3F,YAAM,YAAY,MAAM,aAAkE,KAAK;AAC/F,aAAO,WAAW,OAAO,+BAA+B,EAAE,KAAK,WAAI;AACnE,aAAO,WAAW,WAAW,qDAAqD,EAAE;AAAA,QAClF,OAAO;AAAA,MACT;AAEA,YAAM,aAAa,WAAW;AAC9B,aAAO,YAAY,wDAAwD,EAAE,WAAW;AACxF,YAAM,UAAU,MAAM;AAAA,QACpB;AAAA,QACA;AAAA,QACA,wCAAwC,OAAO,SAAS,cAAc,UAAU;AAAA,QAChF,EAAE,MAAM;AAAA,MACV;AACA,aAAO,QAAQ,OAAO,GAAG,yCAAyC,EAAE,KAAK,GAAG;AAAA,IAC9E,UAAE;AACA,YAAM,sBAAsB,SAAS,OAAO,SAAS;AAAA,IACvD;AAAA,EACF,CAAC;AACH,CAAC;",
6
+ "names": []
7
+ }
@@ -0,0 +1,110 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import {
3
+ createDiscordSigner,
4
+ freshTimestamp,
5
+ interactionsUrl,
6
+ pingInteractionBody,
7
+ slashCommandInteractionBody,
8
+ staleTimestamp
9
+ } from "./helpers/discordSignature.js";
10
+ test.describe("TC-CHANNEL-DISCORD-005: interactions endpoint is fail-closed", () => {
11
+ test("an unsigned interaction is rejected", async ({ request }) => {
12
+ const bare = await request.post(interactionsUrl(), {
13
+ headers: { "content-type": "application/json" },
14
+ data: pingInteractionBody()
15
+ });
16
+ expect(bare.status(), "a request with no signature headers must be rejected").toBe(401);
17
+ expect(
18
+ await bare.json(),
19
+ "with no signed timestamp the freshness guard answers first \u2014 it runs before the signature fan-out"
20
+ ).toEqual({ error: "stale_timestamp" });
21
+ const timestamped = await request.post(interactionsUrl(), {
22
+ headers: { "content-type": "application/json", "x-signature-timestamp": freshTimestamp() },
23
+ data: pingInteractionBody()
24
+ });
25
+ expect(timestamped.status(), "a fresh timestamp with no signature must still be rejected").toBe(401);
26
+ expect(await timestamped.json(), "the rejection must not leak candidate channels").toEqual({
27
+ error: "invalid_signature"
28
+ });
29
+ });
30
+ test("a signature from an unknown key is rejected", async ({ request }) => {
31
+ const signer = createDiscordSigner();
32
+ const timestamp = freshTimestamp();
33
+ const body = pingInteractionBody();
34
+ const response = await request.post(interactionsUrl(), {
35
+ headers: {
36
+ "content-type": "application/json",
37
+ "x-signature-ed25519": signer.sign(timestamp, body),
38
+ "x-signature-timestamp": timestamp
39
+ },
40
+ data: body
41
+ });
42
+ expect(
43
+ response.status(),
44
+ "a cryptographically valid signature from a key no channel stores must still be rejected"
45
+ ).toBe(401);
46
+ expect(await response.json()).toEqual({ error: "invalid_signature" });
47
+ });
48
+ test("a body altered after signing is rejected", async ({ request }) => {
49
+ const signer = createDiscordSigner();
50
+ const timestamp = freshTimestamp();
51
+ const signed = pingInteractionBody();
52
+ const tampered = JSON.stringify({ ...JSON.parse(signed), type: 2 });
53
+ const response = await request.post(interactionsUrl(), {
54
+ headers: {
55
+ "content-type": "application/json",
56
+ "x-signature-ed25519": signer.sign(timestamp, signed),
57
+ "x-signature-timestamp": timestamp
58
+ },
59
+ data: tampered
60
+ });
61
+ expect(response.status(), "a tampered body must be rejected").toBe(401);
62
+ expect(await response.json()).toEqual({ error: "invalid_signature" });
63
+ });
64
+ test("a replayed (stale) timestamp is rejected before the candidate fan-out", async ({
65
+ request
66
+ }) => {
67
+ const signer = createDiscordSigner();
68
+ const timestamp = staleTimestamp();
69
+ const body = pingInteractionBody();
70
+ const response = await request.post(interactionsUrl(), {
71
+ headers: {
72
+ "content-type": "application/json",
73
+ "x-signature-ed25519": signer.sign(timestamp, body),
74
+ "x-signature-timestamp": timestamp
75
+ },
76
+ data: body
77
+ });
78
+ expect(response.status(), "a stale signed timestamp must be rejected").toBe(401);
79
+ expect(
80
+ await response.json(),
81
+ "the replay guard must answer before any per-candidate verification"
82
+ ).toEqual({ error: "stale_timestamp" });
83
+ });
84
+ test("an unverified slash command gets no deferred ack and no dispatch", async ({ request }) => {
85
+ const signer = createDiscordSigner();
86
+ const timestamp = freshTimestamp();
87
+ const body = slashCommandInteractionBody();
88
+ const response = await request.post(interactionsUrl(), {
89
+ headers: {
90
+ "content-type": "application/json",
91
+ "x-signature-ed25519": signer.sign(timestamp, body),
92
+ "x-signature-timestamp": timestamp
93
+ },
94
+ data: body
95
+ });
96
+ expect(response.status(), "an unverified slash command must be rejected, not deferred").toBe(401);
97
+ expect(await response.json()).toEqual({ error: "invalid_signature" });
98
+ });
99
+ test("a non-JSON body cannot crash the endpoint", async ({ request }) => {
100
+ const response = await request.post(interactionsUrl(), {
101
+ headers: { "content-type": "application/json" },
102
+ data: "not json at all"
103
+ });
104
+ expect(
105
+ response.status(),
106
+ "an unsigned garbage body must be refused by the signature gate, never 5xx"
107
+ ).toBe(401);
108
+ });
109
+ });
110
+ //# sourceMappingURL=TC-CHANNEL-DISCORD-005.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.ts"],
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport {\n createDiscordSigner,\n freshTimestamp,\n interactionsUrl,\n pingInteractionBody,\n slashCommandInteractionBody,\n staleTimestamp,\n} from './helpers/discordSignature'\n\n/**\n * TC-CHANNEL-DISCORD-005 \u2014 Interactions endpoint security (fail-closed).\n * Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md\n *\n * `POST /api/channel_discord/interactions` is deliberately unauthenticated at\n * the platform layer \u2014 the Ed25519 signature IS the auth. This spec drives the\n * REAL route (DB fan-out over candidate channels included) and asserts it is\n * fail-closed on every rejection path:\n *\n * - no signature headers at all \u2192 401\n * - a syntactically valid but wrong signature \u2192 401\n * - a body altered after signing \u2192 401\n * - a signed timestamp outside the replay window \u2192 401 `stale_timestamp`,\n * which also proves the freshness guard runs BEFORE the per-candidate\n * verify fan-out (constant cost for a replayed capture)\n * - a fully-formed slash command signed by an unknown key \u2192 401, never a\n * deferred ack: since #4663 a deferred ack promises a hub write and a\n * follow-up, so it must stay strictly behind the signature gate\n *\n * A rejection must never leak which tenants have Discord channels, so the\n * response body is asserted to carry nothing but the error code.\n *\n * The success paths \u2014 PONG (`{ type: 1 }`) and the dispatched slash command /\n * component (`{ type: 5 }` plus the hub job and the Discord follow-up) \u2014 need a\n * channel whose stored public key matches the signer; connecting one requires a\n * live bot token. That half stays unit-tested, in\n * `lib/__tests__/interactions-handler.test.ts`,\n * `lib/__tests__/interactions-dispatch.test.ts`,\n * `lib/__tests__/capabilities.test.ts` (the capability parity check) and\n * `workers/__tests__/discord-interactions.test.ts`.\n */\ntest.describe('TC-CHANNEL-DISCORD-005: interactions endpoint is fail-closed', () => {\n test('an unsigned interaction is rejected', async ({ request }) => {\n const bare = await request.post(interactionsUrl(), {\n headers: { 'content-type': 'application/json' },\n data: pingInteractionBody(),\n })\n expect(bare.status(), 'a request with no signature headers must be rejected').toBe(401)\n expect(\n await bare.json(),\n 'with no signed timestamp the freshness guard answers first \u2014 it runs before the signature fan-out',\n ).toEqual({ error: 'stale_timestamp' })\n\n const timestamped = await request.post(interactionsUrl(), {\n headers: { 'content-type': 'application/json', 'x-signature-timestamp': freshTimestamp() },\n data: pingInteractionBody(),\n })\n expect(timestamped.status(), 'a fresh timestamp with no signature must still be rejected').toBe(401)\n expect(await timestamped.json(), 'the rejection must not leak candidate channels').toEqual({\n error: 'invalid_signature',\n })\n })\n\n test('a signature from an unknown key is rejected', async ({ request }) => {\n const signer = createDiscordSigner()\n const timestamp = freshTimestamp()\n const body = pingInteractionBody()\n\n const response = await request.post(interactionsUrl(), {\n headers: {\n 'content-type': 'application/json',\n 'x-signature-ed25519': signer.sign(timestamp, body),\n 'x-signature-timestamp': timestamp,\n },\n data: body,\n })\n expect(\n response.status(),\n 'a cryptographically valid signature from a key no channel stores must still be rejected',\n ).toBe(401)\n expect(await response.json()).toEqual({ error: 'invalid_signature' })\n })\n\n test('a body altered after signing is rejected', async ({ request }) => {\n const signer = createDiscordSigner()\n const timestamp = freshTimestamp()\n const signed = pingInteractionBody()\n const tampered = JSON.stringify({ ...JSON.parse(signed), type: 2 })\n\n const response = await request.post(interactionsUrl(), {\n headers: {\n 'content-type': 'application/json',\n 'x-signature-ed25519': signer.sign(timestamp, signed),\n 'x-signature-timestamp': timestamp,\n },\n data: tampered,\n })\n expect(response.status(), 'a tampered body must be rejected').toBe(401)\n expect(await response.json()).toEqual({ error: 'invalid_signature' })\n })\n\n test('a replayed (stale) timestamp is rejected before the candidate fan-out', async ({\n request,\n }) => {\n const signer = createDiscordSigner()\n const timestamp = staleTimestamp()\n const body = pingInteractionBody()\n\n const response = await request.post(interactionsUrl(), {\n headers: {\n 'content-type': 'application/json',\n 'x-signature-ed25519': signer.sign(timestamp, body),\n 'x-signature-timestamp': timestamp,\n },\n data: body,\n })\n expect(response.status(), 'a stale signed timestamp must be rejected').toBe(401)\n expect(\n await response.json(),\n 'the replay guard must answer before any per-candidate verification',\n ).toEqual({ error: 'stale_timestamp' })\n })\n\n test('an unverified slash command gets no deferred ack and no dispatch', async ({ request }) => {\n // The endpoint now DISPATCHES verified commands (issue #4663), which means a\n // deferred ack is a promise to write into a tenant's hub and post back to\n // Discord. That promise must stay behind the signature gate: a command\n // signed by a key no channel stores is rejected exactly like a PING, never\n // acknowledged with `{ type: 5 }`.\n const signer = createDiscordSigner()\n const timestamp = freshTimestamp()\n const body = slashCommandInteractionBody()\n\n const response = await request.post(interactionsUrl(), {\n headers: {\n 'content-type': 'application/json',\n 'x-signature-ed25519': signer.sign(timestamp, body),\n 'x-signature-timestamp': timestamp,\n },\n data: body,\n })\n expect(response.status(), 'an unverified slash command must be rejected, not deferred').toBe(401)\n expect(await response.json()).toEqual({ error: 'invalid_signature' })\n })\n\n test('a non-JSON body cannot crash the endpoint', async ({ request }) => {\n const response = await request.post(interactionsUrl(), {\n headers: { 'content-type': 'application/json' },\n data: 'not json at all',\n })\n expect(\n response.status(),\n 'an unsigned garbage body must be refused by the signature gate, never 5xx',\n ).toBe(401)\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAiCP,KAAK,SAAS,gEAAgE,MAAM;AAClF,OAAK,uCAAuC,OAAO,EAAE,QAAQ,MAAM;AACjE,UAAM,OAAO,MAAM,QAAQ,KAAK,gBAAgB,GAAG;AAAA,MACjD,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAC9C,MAAM,oBAAoB;AAAA,IAC5B,CAAC;AACD,WAAO,KAAK,OAAO,GAAG,sDAAsD,EAAE,KAAK,GAAG;AACtF;AAAA,MACE,MAAM,KAAK,KAAK;AAAA,MAChB;AAAA,IACF,EAAE,QAAQ,EAAE,OAAO,kBAAkB,CAAC;AAEtC,UAAM,cAAc,MAAM,QAAQ,KAAK,gBAAgB,GAAG;AAAA,MACxD,SAAS,EAAE,gBAAgB,oBAAoB,yBAAyB,eAAe,EAAE;AAAA,MACzF,MAAM,oBAAoB;AAAA,IAC5B,CAAC;AACD,WAAO,YAAY,OAAO,GAAG,4DAA4D,EAAE,KAAK,GAAG;AACnG,WAAO,MAAM,YAAY,KAAK,GAAG,gDAAgD,EAAE,QAAQ;AAAA,MACzF,OAAO;AAAA,IACT,CAAC;AAAA,EACH,CAAC;AAED,OAAK,+CAA+C,OAAO,EAAE,QAAQ,MAAM;AACzE,UAAM,SAAS,oBAAoB;AACnC,UAAM,YAAY,eAAe;AACjC,UAAM,OAAO,oBAAoB;AAEjC,UAAM,WAAW,MAAM,QAAQ,KAAK,gBAAgB,GAAG;AAAA,MACrD,SAAS;AAAA,QACP,gBAAgB;AAAA,QAChB,uBAAuB,OAAO,KAAK,WAAW,IAAI;AAAA,QAClD,yBAAyB;AAAA,MAC3B;AAAA,MACA,MAAM;AAAA,IACR,CAAC;AACD;AAAA,MACE,SAAS,OAAO;AAAA,MAChB;AAAA,IACF,EAAE,KAAK,GAAG;AACV,WAAO,MAAM,SAAS,KAAK,CAAC,EAAE,QAAQ,EAAE,OAAO,oBAAoB,CAAC;AAAA,EACtE,CAAC;AAED,OAAK,4CAA4C,OAAO,EAAE,QAAQ,MAAM;AACtE,UAAM,SAAS,oBAAoB;AACnC,UAAM,YAAY,eAAe;AACjC,UAAM,SAAS,oBAAoB;AACnC,UAAM,WAAW,KAAK,UAAU,EAAE,GAAG,KAAK,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC;AAElE,UAAM,WAAW,MAAM,QAAQ,KAAK,gBAAgB,GAAG;AAAA,MACrD,SAAS;AAAA,QACP,gBAAgB;AAAA,QAChB,uBAAuB,OAAO,KAAK,WAAW,MAAM;AAAA,QACpD,yBAAyB;AAAA,MAC3B;AAAA,MACA,MAAM;AAAA,IACR,CAAC;AACD,WAAO,SAAS,OAAO,GAAG,kCAAkC,EAAE,KAAK,GAAG;AACtE,WAAO,MAAM,SAAS,KAAK,CAAC,EAAE,QAAQ,EAAE,OAAO,oBAAoB,CAAC;AAAA,EACtE,CAAC;AAED,OAAK,yEAAyE,OAAO;AAAA,IACnF;AAAA,EACF,MAAM;AACJ,UAAM,SAAS,oBAAoB;AACnC,UAAM,YAAY,eAAe;AACjC,UAAM,OAAO,oBAAoB;AAEjC,UAAM,WAAW,MAAM,QAAQ,KAAK,gBAAgB,GAAG;AAAA,MACrD,SAAS;AAAA,QACP,gBAAgB;AAAA,QAChB,uBAAuB,OAAO,KAAK,WAAW,IAAI;AAAA,QAClD,yBAAyB;AAAA,MAC3B;AAAA,MACA,MAAM;AAAA,IACR,CAAC;AACD,WAAO,SAAS,OAAO,GAAG,2CAA2C,EAAE,KAAK,GAAG;AAC/E;AAAA,MACE,MAAM,SAAS,KAAK;AAAA,MACpB;AAAA,IACF,EAAE,QAAQ,EAAE,OAAO,kBAAkB,CAAC;AAAA,EACxC,CAAC;AAED,OAAK,oEAAoE,OAAO,EAAE,QAAQ,MAAM;AAM9F,UAAM,SAAS,oBAAoB;AACnC,UAAM,YAAY,eAAe;AACjC,UAAM,OAAO,4BAA4B;AAEzC,UAAM,WAAW,MAAM,QAAQ,KAAK,gBAAgB,GAAG;AAAA,MACrD,SAAS;AAAA,QACP,gBAAgB;AAAA,QAChB,uBAAuB,OAAO,KAAK,WAAW,IAAI;AAAA,QAClD,yBAAyB;AAAA,MAC3B;AAAA,MACA,MAAM;AAAA,IACR,CAAC;AACD,WAAO,SAAS,OAAO,GAAG,4DAA4D,EAAE,KAAK,GAAG;AAChG,WAAO,MAAM,SAAS,KAAK,CAAC,EAAE,QAAQ,EAAE,OAAO,oBAAoB,CAAC;AAAA,EACtE,CAAC;AAED,OAAK,6CAA6C,OAAO,EAAE,QAAQ,MAAM;AACvE,UAAM,WAAW,MAAM,QAAQ,KAAK,gBAAgB,GAAG;AAAA,MACrD,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAC9C,MAAM;AAAA,IACR,CAAC;AACD;AAAA,MACE,SAAS,OAAO;AAAA,MAChB;AAAA,IACF,EAAE,KAAK,GAAG;AAAA,EACZ,CAAC;AACH,CAAC;",
6
+ "names": []
7
+ }
@@ -0,0 +1,103 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
+ import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
4
+ import { deleteEntityIfExists } from "@open-mercato/core/helpers/integration/crmFixtures";
5
+ import {
6
+ deleteChannelIfExists,
7
+ isChannelSeedingAvailable,
8
+ seedConnectedChannel,
9
+ seedInboundMessage
10
+ } from "@open-mercato/core/helpers/integration/communicationChannelsFixtures";
11
+ import { drainEventsQueue } from "./helpers/eventsQueue.js";
12
+ async function drainAndListInteractions(request, token, personId, timeoutMs = 2e4) {
13
+ const deadline = Date.now() + timeoutMs;
14
+ let last = [];
15
+ while (Date.now() < deadline) {
16
+ await drainEventsQueue();
17
+ const response = await apiRequest(
18
+ request,
19
+ "GET",
20
+ `/api/customers/interactions?entityId=${encodeURIComponent(personId)}&interactionType=email`,
21
+ { token }
22
+ );
23
+ if (response.ok()) {
24
+ const body = await readJsonSafe(response);
25
+ last = Array.isArray(body?.items) ? body.items : [];
26
+ if (last.length > 0) return last;
27
+ }
28
+ await new Promise((resolve) => setTimeout(resolve, 250));
29
+ }
30
+ return last;
31
+ }
32
+ test.describe("TC-CHANNEL-DISCORD-006: inbound discord message resolves to a contact", () => {
33
+ test("an inbound discord message from a known contact creates exactly one interaction", async ({
34
+ request
35
+ }) => {
36
+ test.slow();
37
+ let token = null;
38
+ let personId = null;
39
+ let channelId = null;
40
+ let interactionId = null;
41
+ try {
42
+ token = await getAuthToken(request, "admin");
43
+ const seedingAvailable = await isChannelSeedingAvailable(request, token);
44
+ test.skip(
45
+ !seedingAvailable,
46
+ "OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled in this environment; cannot emit inbound messages."
47
+ );
48
+ const stamp = Date.now();
49
+ const senderAddress = `discord-006-sender-${stamp}@example.com`;
50
+ const person = await apiRequest(request, "POST", "/api/customers/people", {
51
+ token,
52
+ data: {
53
+ firstName: "Discord006",
54
+ lastName: `Contact${stamp}`,
55
+ displayName: `Discord006 Contact ${stamp}`,
56
+ primaryEmail: senderAddress
57
+ }
58
+ });
59
+ expect(person.ok(), "creating the contact fixture should succeed").toBeTruthy();
60
+ const personBody = await readJsonSafe(person);
61
+ personId = personBody?.id ?? null;
62
+ expect(personId, "the person fixture must return an id").toBeTruthy();
63
+ channelId = await seedConnectedChannel(request, token, {
64
+ displayName: `TC-CHANNEL-DISCORD-006 ${stamp}`,
65
+ externalIdentifier: `discord-006-${stamp}@test-seed.local`
66
+ });
67
+ const subject = `Discord inbound from a known contact ${stamp}`;
68
+ const bodyText = "this should land on the CRM timeline";
69
+ await seedInboundMessage(request, token, {
70
+ channelId,
71
+ providerKey: "discord",
72
+ from: senderAddress,
73
+ subject,
74
+ bodyText,
75
+ messageId: `discord-message-006-${stamp}`
76
+ });
77
+ const items = await drainAndListInteractions(request, token, personId);
78
+ expect(items.length, "exactly one interaction must be linked to the contact").toBe(1);
79
+ const interaction = items[0];
80
+ interactionId = interaction.id;
81
+ expect(interaction.interactionType, "channel messages land as email interactions").toBe("email");
82
+ expect(interaction.title, "the interaction must carry the discord message subject").toBe(subject);
83
+ expect(interaction.body, "the interaction must carry the discord message body").toBe(bodyText);
84
+ expect(
85
+ interaction.visibility,
86
+ "a user-owned channel produces a private inbound interaction"
87
+ ).toBe("private");
88
+ expect(
89
+ interaction.authorUserId,
90
+ "the interaction must be authored by the channel owner, not left unattributed"
91
+ ).toBeTruthy();
92
+ } finally {
93
+ if (token) {
94
+ await deleteEntityIfExists(request, token, "/api/customers/interactions", interactionId);
95
+ }
96
+ await deleteChannelIfExists(request, token, channelId);
97
+ if (token) {
98
+ await deleteEntityIfExists(request, token, "/api/customers/people", personId);
99
+ }
100
+ }
101
+ });
102
+ });
103
+ //# sourceMappingURL=TC-CHANNEL-DISCORD-006.spec.js.map