@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.
- package/.turbo/turbo-build.log +2 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js +86 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js +39 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js +97 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js +65 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js +110 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js +103 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js +93 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js +52 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js +46 -0
- package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js +16 -0
- package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/meta.js +10 -0
- package/dist/modules/channel_discord/__integration__/meta.js.map +7 -0
- package/dist/modules/channel_discord/acl.js +15 -0
- package/dist/modules/channel_discord/acl.js.map +7 -0
- package/dist/modules/channel_discord/ai-agents.js +97 -0
- package/dist/modules/channel_discord/ai-agents.js.map +7 -0
- package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js +73 -0
- package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js.map +7 -0
- package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js +222 -0
- package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js.map +7 -0
- package/dist/modules/channel_discord/api/interactions/route.js +173 -0
- package/dist/modules/channel_discord/api/interactions/route.js.map +7 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js +196 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js.map +7 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js +14 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js.map +7 -0
- package/dist/modules/channel_discord/cli.js +142 -0
- package/dist/modules/channel_discord/cli.js.map +7 -0
- package/dist/modules/channel_discord/commands/ai-reply-proposal.js +116 -0
- package/dist/modules/channel_discord/commands/ai-reply-proposal.js.map +7 -0
- package/dist/modules/channel_discord/commands/update-ai-auto-reply.js +104 -0
- package/dist/modules/channel_discord/commands/update-ai-auto-reply.js.map +7 -0
- package/dist/modules/channel_discord/di.js +23 -0
- package/dist/modules/channel_discord/di.js.map +7 -0
- package/dist/modules/channel_discord/index.js +9 -0
- package/dist/modules/channel_discord/index.js.map +7 -0
- package/dist/modules/channel_discord/integration.js +96 -0
- package/dist/modules/channel_discord/integration.js.map +7 -0
- package/dist/modules/channel_discord/lib/adapter.js +248 -0
- package/dist/modules/channel_discord/lib/adapter.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-agent-directory.js +45 -0
- package/dist/modules/channel_discord/lib/ai-agent-directory.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-features.js +9 -0
- package/dist/modules/channel_discord/lib/ai-features.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-proposal-contract.js +9 -0
- package/dist/modules/channel_discord/lib/ai-proposal-contract.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-proposal.js +109 -0
- package/dist/modules/channel_discord/lib/ai-proposal.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-reply.js +96 -0
- package/dist/modules/channel_discord/lib/ai-reply.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-service-principal.js +70 -0
- package/dist/modules/channel_discord/lib/ai-service-principal.js.map +7 -0
- package/dist/modules/channel_discord/lib/capabilities.js +42 -0
- package/dist/modules/channel_discord/lib/capabilities.js.map +7 -0
- package/dist/modules/channel_discord/lib/channel-access.js +62 -0
- package/dist/modules/channel_discord/lib/channel-access.js.map +7 -0
- package/dist/modules/channel_discord/lib/channel-state-store.js +103 -0
- package/dist/modules/channel_discord/lib/channel-state-store.js.map +7 -0
- package/dist/modules/channel_discord/lib/convert-outbound.js +38 -0
- package/dist/modules/channel_discord/lib/convert-outbound.js.map +7 -0
- package/dist/modules/channel_discord/lib/credentials.js +61 -0
- package/dist/modules/channel_discord/lib/credentials.js.map +7 -0
- package/dist/modules/channel_discord/lib/discord-gateway-client.js +289 -0
- package/dist/modules/channel_discord/lib/discord-gateway-client.js.map +7 -0
- package/dist/modules/channel_discord/lib/discord-rest.js +203 -0
- package/dist/modules/channel_discord/lib/discord-rest.js.map +7 -0
- package/dist/modules/channel_discord/lib/failure-reason.js +30 -0
- package/dist/modules/channel_discord/lib/failure-reason.js.map +7 -0
- package/dist/modules/channel_discord/lib/gateway-bridge.js +42 -0
- package/dist/modules/channel_discord/lib/gateway-bridge.js.map +7 -0
- package/dist/modules/channel_discord/lib/health.js +37 -0
- package/dist/modules/channel_discord/lib/health.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-dispatch.js +164 -0
- package/dist/modules/channel_discord/lib/interactions-dispatch.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-handler.js +115 -0
- package/dist/modules/channel_discord/lib/interactions-handler.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-queue.js +17 -0
- package/dist/modules/channel_discord/lib/interactions-queue.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-verify.js +84 -0
- package/dist/modules/channel_discord/lib/interactions-verify.js.map +7 -0
- package/dist/modules/channel_discord/lib/normalize-inbound.js +59 -0
- package/dist/modules/channel_discord/lib/normalize-inbound.js.map +7 -0
- package/dist/modules/channel_discord/lib/preset.js +63 -0
- package/dist/modules/channel_discord/lib/preset.js.map +7 -0
- package/dist/modules/channel_discord/lib/queue-strategy.js +12 -0
- package/dist/modules/channel_discord/lib/queue-strategy.js.map +7 -0
- package/dist/modules/channel_discord/lib/slash-commands.js +42 -0
- package/dist/modules/channel_discord/lib/slash-commands.js.map +7 -0
- package/dist/modules/channel_discord/message-types.js +64 -0
- package/dist/modules/channel_discord/message-types.js.map +7 -0
- package/dist/modules/channel_discord/setup.js +62 -0
- package/dist/modules/channel_discord/setup.js.map +7 -0
- package/dist/modules/channel_discord/subscribers/ai-auto-reply.js +185 -0
- package/dist/modules/channel_discord/subscribers/ai-auto-reply.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js +79 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js +20 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js +220 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection-table.js +26 -0
- package/dist/modules/channel_discord/widgets/injection-table.js.map +7 -0
- package/dist/modules/channel_discord/workers/discord-gateway.js +270 -0
- package/dist/modules/channel_discord/workers/discord-gateway.js.map +7 -0
- package/dist/modules/channel_discord/workers/discord-interactions.js +113 -0
- package/dist/modules/channel_discord/workers/discord-interactions.js.map +7 -0
- package/jest.config.cjs +38 -0
- package/package.json +104 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.ts +109 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.ts +58 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.ts +175 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.ts +83 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.ts +156 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.ts +144 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.ts +119 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.ts +75 -0
- package/src/modules/channel_discord/__integration__/helpers/discordSignature.ts +77 -0
- package/src/modules/channel_discord/__integration__/helpers/eventsQueue.ts +26 -0
- package/src/modules/channel_discord/__integration__/meta.ts +6 -0
- package/src/modules/channel_discord/__tests__/ai-auto-reply.policy.integration.test.ts +286 -0
- package/src/modules/channel_discord/acl.ts +19 -0
- package/src/modules/channel_discord/ai-agents.ts +157 -0
- package/src/modules/channel_discord/api/ai-auto-reply/channels/__tests__/route.test.ts +175 -0
- package/src/modules/channel_discord/api/ai-auto-reply/channels/route.ts +99 -0
- package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/__tests__/route.test.ts +311 -0
- package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.ts +299 -0
- package/src/modules/channel_discord/api/interactions/route.ts +242 -0
- package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.ts +18 -0
- package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.tsx +288 -0
- package/src/modules/channel_discord/cli.ts +234 -0
- package/src/modules/channel_discord/commands/__tests__/ai-reply-proposal.test.ts +155 -0
- package/src/modules/channel_discord/commands/__tests__/update-ai-auto-reply.test.ts +185 -0
- package/src/modules/channel_discord/commands/ai-reply-proposal.ts +162 -0
- package/src/modules/channel_discord/commands/update-ai-auto-reply.ts +157 -0
- package/src/modules/channel_discord/di.ts +26 -0
- package/src/modules/channel_discord/i18n/de.json +57 -0
- package/src/modules/channel_discord/i18n/en.json +57 -0
- package/src/modules/channel_discord/i18n/es.json +57 -0
- package/src/modules/channel_discord/i18n/ko.json +57 -0
- package/src/modules/channel_discord/i18n/pl.json +57 -0
- package/src/modules/channel_discord/index.ts +6 -0
- package/src/modules/channel_discord/integration.ts +98 -0
- package/src/modules/channel_discord/lib/__tests__/adapter.test.ts +206 -0
- package/src/modules/channel_discord/lib/__tests__/ai-agent-directory.test.ts +61 -0
- package/src/modules/channel_discord/lib/__tests__/ai-reply.test.ts +115 -0
- package/src/modules/channel_discord/lib/__tests__/capabilities.test.ts +170 -0
- package/src/modules/channel_discord/lib/__tests__/channel-quarantine.test.ts +89 -0
- package/src/modules/channel_discord/lib/__tests__/channel-state-store.auto-reply-marker.test.ts +145 -0
- package/src/modules/channel_discord/lib/__tests__/channel-state-store.test.ts +78 -0
- package/src/modules/channel_discord/lib/__tests__/convert-outbound.test.ts +39 -0
- package/src/modules/channel_discord/lib/__tests__/discord-gateway-client.test.ts +243 -0
- package/src/modules/channel_discord/lib/__tests__/failure-reason.test.ts +68 -0
- package/src/modules/channel_discord/lib/__tests__/gateway-bridge.test.ts +62 -0
- package/src/modules/channel_discord/lib/__tests__/interactions-dispatch.test.ts +223 -0
- package/src/modules/channel_discord/lib/__tests__/interactions-handler.test.ts +469 -0
- package/src/modules/channel_discord/lib/__tests__/interactions-verify.test.ts +126 -0
- package/src/modules/channel_discord/lib/__tests__/normalize-inbound.identity.test.ts +64 -0
- package/src/modules/channel_discord/lib/__tests__/normalize-inbound.test.ts +67 -0
- package/src/modules/channel_discord/lib/__tests__/preset.test.ts +110 -0
- package/src/modules/channel_discord/lib/__tests__/queue-strategy.test.ts +33 -0
- package/src/modules/channel_discord/lib/adapter.ts +359 -0
- package/src/modules/channel_discord/lib/ai-agent-directory.ts +124 -0
- package/src/modules/channel_discord/lib/ai-features.ts +23 -0
- package/src/modules/channel_discord/lib/ai-proposal-contract.ts +24 -0
- package/src/modules/channel_discord/lib/ai-proposal.ts +179 -0
- package/src/modules/channel_discord/lib/ai-reply.ts +179 -0
- package/src/modules/channel_discord/lib/ai-service-principal.ts +143 -0
- package/src/modules/channel_discord/lib/capabilities.ts +104 -0
- package/src/modules/channel_discord/lib/channel-access.ts +110 -0
- package/src/modules/channel_discord/lib/channel-state-store.ts +217 -0
- package/src/modules/channel_discord/lib/convert-outbound.ts +88 -0
- package/src/modules/channel_discord/lib/credentials.ts +88 -0
- package/src/modules/channel_discord/lib/discord-gateway-client.ts +432 -0
- package/src/modules/channel_discord/lib/discord-rest.ts +393 -0
- package/src/modules/channel_discord/lib/failure-reason.ts +49 -0
- package/src/modules/channel_discord/lib/gateway-bridge.ts +79 -0
- package/src/modules/channel_discord/lib/health.ts +55 -0
- package/src/modules/channel_discord/lib/interactions-dispatch.ts +288 -0
- package/src/modules/channel_discord/lib/interactions-handler.ts +267 -0
- package/src/modules/channel_discord/lib/interactions-queue.ts +52 -0
- package/src/modules/channel_discord/lib/interactions-verify.ts +174 -0
- package/src/modules/channel_discord/lib/normalize-inbound.ts +89 -0
- package/src/modules/channel_discord/lib/preset.ts +109 -0
- package/src/modules/channel_discord/lib/queue-strategy.ts +29 -0
- package/src/modules/channel_discord/lib/slash-commands.ts +57 -0
- package/src/modules/channel_discord/message-types.ts +75 -0
- package/src/modules/channel_discord/setup.ts +78 -0
- package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.armed.test.ts +149 -0
- package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.failure-marker.test.ts +133 -0
- package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.test.ts +273 -0
- package/src/modules/channel_discord/subscribers/ai-auto-reply.ts +325 -0
- package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.tsx +143 -0
- package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.ts +19 -0
- package/src/modules/channel_discord/widgets/injection/connect/widget.client.tsx +237 -0
- package/src/modules/channel_discord/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_discord/widgets/injection-table.ts +24 -0
- package/src/modules/channel_discord/workers/__tests__/discord-gateway.single-identify.test.ts +88 -0
- package/src/modules/channel_discord/workers/__tests__/discord-gateway.test.ts +179 -0
- package/src/modules/channel_discord/workers/__tests__/discord-interactions.test.ts +152 -0
- package/src/modules/channel_discord/workers/discord-gateway.ts +458 -0
- package/src/modules/channel_discord/workers/discord-interactions.ts +162 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +7 -0
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
|
|
11
|
+
/**
|
|
12
|
+
* TC-CHANNEL-DISCORD-004 — reactions on a Discord-provider message.
|
|
13
|
+
* Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md
|
|
14
|
+
*
|
|
15
|
+
* `MESSAGE_REACTION_ADD` / `MESSAGE_REACTION_REMOVE` are bridged into the hub's
|
|
16
|
+
* existing reaction routes, which resolve the owning channel through the
|
|
17
|
+
* `ChannelThreadMapping` an inbound message leaves behind. This spec proves that
|
|
18
|
+
* resolution works when the inbound traffic carries `providerKey: 'discord'` —
|
|
19
|
+
* i.e. the provider needs no reaction storage of its own — by adding and then
|
|
20
|
+
* removing a reaction on a seeded Discord message.
|
|
21
|
+
*
|
|
22
|
+
* Driven via the env-gated test-seed fixture (`OM_ENABLE_TEST_CHANNEL_SEEDING`);
|
|
23
|
+
* skips when the gate is off.
|
|
24
|
+
*/
|
|
25
|
+
test.describe('TC-CHANNEL-DISCORD-004: reactions round-trip on a discord message', () => {
|
|
26
|
+
test('a reaction can be added to and removed from a discord-provider message', async ({
|
|
27
|
+
request,
|
|
28
|
+
}) => {
|
|
29
|
+
test.slow()
|
|
30
|
+
let token: string | null = null
|
|
31
|
+
let channelId: string | null = null
|
|
32
|
+
try {
|
|
33
|
+
token = await getAuthToken(request, 'admin')
|
|
34
|
+
const seedingAvailable = await isChannelSeedingAvailable(request, token)
|
|
35
|
+
test.skip(
|
|
36
|
+
!seedingAvailable,
|
|
37
|
+
'OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled in this environment; cannot emit inbound messages.',
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const stamp = Date.now()
|
|
41
|
+
channelId = await seedConnectedChannel(request, token, {
|
|
42
|
+
displayName: `TC-CHANNEL-DISCORD-004 ${stamp}`,
|
|
43
|
+
externalIdentifier: `discord-004-${stamp}@test-seed.local`,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const seeded = await seedInboundMessage(request, token, {
|
|
47
|
+
channelId,
|
|
48
|
+
providerKey: 'discord',
|
|
49
|
+
from: `discord-user-${stamp}`,
|
|
50
|
+
subject: `Discord reaction target ${stamp}`,
|
|
51
|
+
bodyText: 'react to me',
|
|
52
|
+
messageId: `discord-message-${stamp}`,
|
|
53
|
+
// The reaction route resolves the owning channel through this mapping.
|
|
54
|
+
createThreadMapping: true,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const added = await apiRequest(
|
|
58
|
+
request,
|
|
59
|
+
'POST',
|
|
60
|
+
`/api/communication_channels/messages/${seeded.messageId}/reactions`,
|
|
61
|
+
{ token, data: { emoji: '👍' } },
|
|
62
|
+
)
|
|
63
|
+
expect(added.status(), 'adding a reaction to a discord message should return 201').toBe(201)
|
|
64
|
+
const addedBody = await readJsonSafe<{ id?: string; emoji?: string; messageId?: string }>(added)
|
|
65
|
+
expect(addedBody?.emoji, 'the reaction echoes the emoji').toBe('👍')
|
|
66
|
+
expect(addedBody?.messageId, 'the reaction is bound to the seeded discord message').toBe(
|
|
67
|
+
seeded.messageId,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const reactionId = addedBody?.id
|
|
71
|
+
expect(reactionId, 'the reaction response must carry an id to remove it by').toBeTruthy()
|
|
72
|
+
const removed = await apiRequest(
|
|
73
|
+
request,
|
|
74
|
+
'DELETE',
|
|
75
|
+
`/api/communication_channels/messages/${seeded.messageId}/reactions/${reactionId}`,
|
|
76
|
+
{ token },
|
|
77
|
+
)
|
|
78
|
+
expect(removed.status(), 'removing the reaction should return 204').toBe(204)
|
|
79
|
+
} finally {
|
|
80
|
+
await deleteChannelIfExists(request, token, channelId)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
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'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* TC-CHANNEL-DISCORD-005 — Interactions endpoint security (fail-closed).
|
|
13
|
+
* Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md
|
|
14
|
+
*
|
|
15
|
+
* `POST /api/channel_discord/interactions` is deliberately unauthenticated at
|
|
16
|
+
* the platform layer — the Ed25519 signature IS the auth. This spec drives the
|
|
17
|
+
* REAL route (DB fan-out over candidate channels included) and asserts it is
|
|
18
|
+
* fail-closed on every rejection path:
|
|
19
|
+
*
|
|
20
|
+
* - no signature headers at all → 401
|
|
21
|
+
* - a syntactically valid but wrong signature → 401
|
|
22
|
+
* - a body altered after signing → 401
|
|
23
|
+
* - a signed timestamp outside the replay window → 401 `stale_timestamp`,
|
|
24
|
+
* which also proves the freshness guard runs BEFORE the per-candidate
|
|
25
|
+
* verify fan-out (constant cost for a replayed capture)
|
|
26
|
+
* - a fully-formed slash command signed by an unknown key → 401, never a
|
|
27
|
+
* deferred ack: since #4663 a deferred ack promises a hub write and a
|
|
28
|
+
* follow-up, so it must stay strictly behind the signature gate
|
|
29
|
+
*
|
|
30
|
+
* A rejection must never leak which tenants have Discord channels, so the
|
|
31
|
+
* response body is asserted to carry nothing but the error code.
|
|
32
|
+
*
|
|
33
|
+
* The success paths — PONG (`{ type: 1 }`) and the dispatched slash command /
|
|
34
|
+
* component (`{ type: 5 }` plus the hub job and the Discord follow-up) — need a
|
|
35
|
+
* channel whose stored public key matches the signer; connecting one requires a
|
|
36
|
+
* live bot token. That half stays unit-tested, in
|
|
37
|
+
* `lib/__tests__/interactions-handler.test.ts`,
|
|
38
|
+
* `lib/__tests__/interactions-dispatch.test.ts`,
|
|
39
|
+
* `lib/__tests__/capabilities.test.ts` (the capability parity check) and
|
|
40
|
+
* `workers/__tests__/discord-interactions.test.ts`.
|
|
41
|
+
*/
|
|
42
|
+
test.describe('TC-CHANNEL-DISCORD-005: interactions endpoint is fail-closed', () => {
|
|
43
|
+
test('an unsigned interaction is rejected', async ({ request }) => {
|
|
44
|
+
const bare = await request.post(interactionsUrl(), {
|
|
45
|
+
headers: { 'content-type': 'application/json' },
|
|
46
|
+
data: pingInteractionBody(),
|
|
47
|
+
})
|
|
48
|
+
expect(bare.status(), 'a request with no signature headers must be rejected').toBe(401)
|
|
49
|
+
expect(
|
|
50
|
+
await bare.json(),
|
|
51
|
+
'with no signed timestamp the freshness guard answers first — it runs before the signature fan-out',
|
|
52
|
+
).toEqual({ error: 'stale_timestamp' })
|
|
53
|
+
|
|
54
|
+
const timestamped = await request.post(interactionsUrl(), {
|
|
55
|
+
headers: { 'content-type': 'application/json', 'x-signature-timestamp': freshTimestamp() },
|
|
56
|
+
data: pingInteractionBody(),
|
|
57
|
+
})
|
|
58
|
+
expect(timestamped.status(), 'a fresh timestamp with no signature must still be rejected').toBe(401)
|
|
59
|
+
expect(await timestamped.json(), 'the rejection must not leak candidate channels').toEqual({
|
|
60
|
+
error: 'invalid_signature',
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('a signature from an unknown key is rejected', async ({ request }) => {
|
|
65
|
+
const signer = createDiscordSigner()
|
|
66
|
+
const timestamp = freshTimestamp()
|
|
67
|
+
const body = pingInteractionBody()
|
|
68
|
+
|
|
69
|
+
const response = await request.post(interactionsUrl(), {
|
|
70
|
+
headers: {
|
|
71
|
+
'content-type': 'application/json',
|
|
72
|
+
'x-signature-ed25519': signer.sign(timestamp, body),
|
|
73
|
+
'x-signature-timestamp': timestamp,
|
|
74
|
+
},
|
|
75
|
+
data: body,
|
|
76
|
+
})
|
|
77
|
+
expect(
|
|
78
|
+
response.status(),
|
|
79
|
+
'a cryptographically valid signature from a key no channel stores must still be rejected',
|
|
80
|
+
).toBe(401)
|
|
81
|
+
expect(await response.json()).toEqual({ error: 'invalid_signature' })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('a body altered after signing is rejected', async ({ request }) => {
|
|
85
|
+
const signer = createDiscordSigner()
|
|
86
|
+
const timestamp = freshTimestamp()
|
|
87
|
+
const signed = pingInteractionBody()
|
|
88
|
+
const tampered = JSON.stringify({ ...JSON.parse(signed), type: 2 })
|
|
89
|
+
|
|
90
|
+
const response = await request.post(interactionsUrl(), {
|
|
91
|
+
headers: {
|
|
92
|
+
'content-type': 'application/json',
|
|
93
|
+
'x-signature-ed25519': signer.sign(timestamp, signed),
|
|
94
|
+
'x-signature-timestamp': timestamp,
|
|
95
|
+
},
|
|
96
|
+
data: tampered,
|
|
97
|
+
})
|
|
98
|
+
expect(response.status(), 'a tampered body must be rejected').toBe(401)
|
|
99
|
+
expect(await response.json()).toEqual({ error: 'invalid_signature' })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('a replayed (stale) timestamp is rejected before the candidate fan-out', async ({
|
|
103
|
+
request,
|
|
104
|
+
}) => {
|
|
105
|
+
const signer = createDiscordSigner()
|
|
106
|
+
const timestamp = staleTimestamp()
|
|
107
|
+
const body = pingInteractionBody()
|
|
108
|
+
|
|
109
|
+
const response = await request.post(interactionsUrl(), {
|
|
110
|
+
headers: {
|
|
111
|
+
'content-type': 'application/json',
|
|
112
|
+
'x-signature-ed25519': signer.sign(timestamp, body),
|
|
113
|
+
'x-signature-timestamp': timestamp,
|
|
114
|
+
},
|
|
115
|
+
data: body,
|
|
116
|
+
})
|
|
117
|
+
expect(response.status(), 'a stale signed timestamp must be rejected').toBe(401)
|
|
118
|
+
expect(
|
|
119
|
+
await response.json(),
|
|
120
|
+
'the replay guard must answer before any per-candidate verification',
|
|
121
|
+
).toEqual({ error: 'stale_timestamp' })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('an unverified slash command gets no deferred ack and no dispatch', async ({ request }) => {
|
|
125
|
+
// The endpoint now DISPATCHES verified commands (issue #4663), which means a
|
|
126
|
+
// deferred ack is a promise to write into a tenant's hub and post back to
|
|
127
|
+
// Discord. That promise must stay behind the signature gate: a command
|
|
128
|
+
// signed by a key no channel stores is rejected exactly like a PING, never
|
|
129
|
+
// acknowledged with `{ type: 5 }`.
|
|
130
|
+
const signer = createDiscordSigner()
|
|
131
|
+
const timestamp = freshTimestamp()
|
|
132
|
+
const body = slashCommandInteractionBody()
|
|
133
|
+
|
|
134
|
+
const response = await request.post(interactionsUrl(), {
|
|
135
|
+
headers: {
|
|
136
|
+
'content-type': 'application/json',
|
|
137
|
+
'x-signature-ed25519': signer.sign(timestamp, body),
|
|
138
|
+
'x-signature-timestamp': timestamp,
|
|
139
|
+
},
|
|
140
|
+
data: body,
|
|
141
|
+
})
|
|
142
|
+
expect(response.status(), 'an unverified slash command must be rejected, not deferred').toBe(401)
|
|
143
|
+
expect(await response.json()).toEqual({ error: 'invalid_signature' })
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('a non-JSON body cannot crash the endpoint', async ({ request }) => {
|
|
147
|
+
const response = await request.post(interactionsUrl(), {
|
|
148
|
+
headers: { 'content-type': 'application/json' },
|
|
149
|
+
data: 'not json at all',
|
|
150
|
+
})
|
|
151
|
+
expect(
|
|
152
|
+
response.status(),
|
|
153
|
+
'an unsigned garbage body must be refused by the signature gate, never 5xx',
|
|
154
|
+
).toBe(401)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
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'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* TC-CHANNEL-DISCORD-006 — contact resolution for an inbound Discord message.
|
|
15
|
+
* Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md
|
|
16
|
+
*
|
|
17
|
+
* The provider deliberately ships NO contact-resolution logic: an inbound
|
|
18
|
+
* Discord message reaches the CRM through the same hub event and the same
|
|
19
|
+
* persistent `customers:link-channel-message-received` subscriber as e-mail.
|
|
20
|
+
* This spec proves that claim end to end — a Discord-provider inbound message
|
|
21
|
+
* whose sender matches a Person's stored address lands exactly one interaction
|
|
22
|
+
* on that Person's timeline, attributed to the `discord` provider.
|
|
23
|
+
*
|
|
24
|
+
* That "exactly one" matters: a provider that duplicated the hub's linking would
|
|
25
|
+
* show up here as two interactions.
|
|
26
|
+
*
|
|
27
|
+
* Driven via the env-gated test-seed fixture (`OM_ENABLE_TEST_CHANNEL_SEEDING`);
|
|
28
|
+
* skips when the gate is off.
|
|
29
|
+
*/
|
|
30
|
+
type Interaction = {
|
|
31
|
+
id: string
|
|
32
|
+
interactionType: string
|
|
33
|
+
title: string | null
|
|
34
|
+
body: string | null
|
|
35
|
+
visibility: string | null
|
|
36
|
+
authorUserId: string | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function drainAndListInteractions(
|
|
40
|
+
request: Parameters<typeof apiRequest>[0],
|
|
41
|
+
token: string,
|
|
42
|
+
personId: string,
|
|
43
|
+
timeoutMs = 20_000,
|
|
44
|
+
): Promise<Interaction[]> {
|
|
45
|
+
const deadline = Date.now() + timeoutMs
|
|
46
|
+
let last: Interaction[] = []
|
|
47
|
+
while (Date.now() < deadline) {
|
|
48
|
+
await drainEventsQueue()
|
|
49
|
+
const response = await apiRequest(
|
|
50
|
+
request,
|
|
51
|
+
'GET',
|
|
52
|
+
`/api/customers/interactions?entityId=${encodeURIComponent(personId)}&interactionType=email`,
|
|
53
|
+
{ token },
|
|
54
|
+
)
|
|
55
|
+
if (response.ok()) {
|
|
56
|
+
const body = await readJsonSafe<{ items?: Interaction[] }>(response)
|
|
57
|
+
last = Array.isArray(body?.items) ? body!.items : []
|
|
58
|
+
if (last.length > 0) return last
|
|
59
|
+
}
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
61
|
+
}
|
|
62
|
+
return last
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test.describe('TC-CHANNEL-DISCORD-006: inbound discord message resolves to a contact', () => {
|
|
66
|
+
test('an inbound discord message from a known contact creates exactly one interaction', async ({
|
|
67
|
+
request,
|
|
68
|
+
}) => {
|
|
69
|
+
test.slow()
|
|
70
|
+
let token: string | null = null
|
|
71
|
+
let personId: string | null = null
|
|
72
|
+
let channelId: string | null = null
|
|
73
|
+
let interactionId: string | null = null
|
|
74
|
+
try {
|
|
75
|
+
token = await getAuthToken(request, 'admin')
|
|
76
|
+
const seedingAvailable = await isChannelSeedingAvailable(request, token)
|
|
77
|
+
test.skip(
|
|
78
|
+
!seedingAvailable,
|
|
79
|
+
'OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled in this environment; cannot emit inbound messages.',
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const stamp = Date.now()
|
|
83
|
+
const senderAddress = `discord-006-sender-${stamp}@example.com`
|
|
84
|
+
|
|
85
|
+
const person = await apiRequest(request, 'POST', '/api/customers/people', {
|
|
86
|
+
token,
|
|
87
|
+
data: {
|
|
88
|
+
firstName: 'Discord006',
|
|
89
|
+
lastName: `Contact${stamp}`,
|
|
90
|
+
displayName: `Discord006 Contact ${stamp}`,
|
|
91
|
+
primaryEmail: senderAddress,
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
expect(person.ok(), 'creating the contact fixture should succeed').toBeTruthy()
|
|
95
|
+
const personBody = await readJsonSafe<{ id?: string }>(person)
|
|
96
|
+
personId = personBody?.id ?? null
|
|
97
|
+
expect(personId, 'the person fixture must return an id').toBeTruthy()
|
|
98
|
+
|
|
99
|
+
channelId = await seedConnectedChannel(request, token, {
|
|
100
|
+
displayName: `TC-CHANNEL-DISCORD-006 ${stamp}`,
|
|
101
|
+
externalIdentifier: `discord-006-${stamp}@test-seed.local`,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const subject = `Discord inbound from a known contact ${stamp}`
|
|
105
|
+
const bodyText = 'this should land on the CRM timeline'
|
|
106
|
+
await seedInboundMessage(request, token, {
|
|
107
|
+
channelId,
|
|
108
|
+
providerKey: 'discord',
|
|
109
|
+
from: senderAddress,
|
|
110
|
+
subject,
|
|
111
|
+
bodyText,
|
|
112
|
+
messageId: `discord-message-006-${stamp}`,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const items = await drainAndListInteractions(request, token, personId as string)
|
|
116
|
+
expect(items.length, 'exactly one interaction must be linked to the contact').toBe(1)
|
|
117
|
+
const interaction = items[0]
|
|
118
|
+
interactionId = interaction.id
|
|
119
|
+
expect(interaction.interactionType, 'channel messages land as email interactions').toBe('email')
|
|
120
|
+
// Title + body identify THIS Discord message. The interactions list
|
|
121
|
+
// projection does not expose `externalMessageId` / `channelProviderKey`,
|
|
122
|
+
// so link identity and provider attribution are asserted where they are
|
|
123
|
+
// written, in the hub handler's own unit tests.
|
|
124
|
+
expect(interaction.title, 'the interaction must carry the discord message subject').toBe(subject)
|
|
125
|
+
expect(interaction.body, 'the interaction must carry the discord message body').toBe(bodyText)
|
|
126
|
+
expect(
|
|
127
|
+
interaction.visibility,
|
|
128
|
+
'a user-owned channel produces a private inbound interaction',
|
|
129
|
+
).toBe('private')
|
|
130
|
+
expect(
|
|
131
|
+
interaction.authorUserId,
|
|
132
|
+
'the interaction must be authored by the channel owner, not left unattributed',
|
|
133
|
+
).toBeTruthy()
|
|
134
|
+
} finally {
|
|
135
|
+
if (token) {
|
|
136
|
+
await deleteEntityIfExists(request, token, '/api/customers/interactions', interactionId)
|
|
137
|
+
}
|
|
138
|
+
await deleteChannelIfExists(request, token, channelId)
|
|
139
|
+
if (token) {
|
|
140
|
+
await deleteEntityIfExists(request, token, '/api/customers/people', personId)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
|
|
11
|
+
/**
|
|
12
|
+
* TC-CHANNEL-DISCORD-007 — health surface for a channel carrying Discord traffic.
|
|
13
|
+
* Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md
|
|
14
|
+
*
|
|
15
|
+
* Operators diagnose a Discord channel through the hub's health route, so this
|
|
16
|
+
* spec pins that surface: it is tenant-scoped (401 unauthenticated, 400 on a
|
|
17
|
+
* malformed id, 404 for a channel the caller does not own) and it reports a
|
|
18
|
+
* fixed numeric shape that includes Discord-provider links.
|
|
19
|
+
*
|
|
20
|
+
* The provider's own probe (`channelDiscordHealthCheck`: healthy on a valid bot
|
|
21
|
+
* token, unhealthy on Discord's 401) calls the Discord API and is unit-tested
|
|
22
|
+
* against a stubbed REST client in `lib/__tests__/adapter.test.ts`.
|
|
23
|
+
*/
|
|
24
|
+
const UNKNOWN_CHANNEL_ID = '00000000-0000-4000-8000-0000000d1c07'
|
|
25
|
+
|
|
26
|
+
test.describe('TC-CHANNEL-DISCORD-007: channel health surface', () => {
|
|
27
|
+
test('requires authentication', async ({ request }) => {
|
|
28
|
+
const response = await apiRequest(
|
|
29
|
+
request,
|
|
30
|
+
'GET',
|
|
31
|
+
`/api/communication_channels/channels/${UNKNOWN_CHANNEL_ID}/health`,
|
|
32
|
+
// Intentionally empty token — this assertion is the 401 unauth path.
|
|
33
|
+
{ token: '' },
|
|
34
|
+
)
|
|
35
|
+
expect(response.status(), 'health must reject an unauthenticated caller').toBe(401)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('rejects a malformed channel id', async ({ request }) => {
|
|
39
|
+
const token = await getAuthToken(request, 'admin')
|
|
40
|
+
const response = await apiRequest(
|
|
41
|
+
request,
|
|
42
|
+
'GET',
|
|
43
|
+
'/api/communication_channels/channels/not-a-uuid/health',
|
|
44
|
+
{ token },
|
|
45
|
+
)
|
|
46
|
+
expect(response.status(), 'a non-uuid channel id must be a 400, not a 500').toBe(400)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('rejects a channel the caller does not own', async ({ request }) => {
|
|
50
|
+
const token = await getAuthToken(request, 'admin')
|
|
51
|
+
const response = await apiRequest(
|
|
52
|
+
request,
|
|
53
|
+
'GET',
|
|
54
|
+
`/api/communication_channels/channels/${UNKNOWN_CHANNEL_ID}/health`,
|
|
55
|
+
{ token },
|
|
56
|
+
)
|
|
57
|
+
expect(response.status(), 'an unknown/foreign channel must be 404').toBe(404)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('reports a numeric delivery snapshot that includes discord traffic', async ({ request }) => {
|
|
61
|
+
test.slow()
|
|
62
|
+
let token: string | null = null
|
|
63
|
+
let channelId: string | null = null
|
|
64
|
+
try {
|
|
65
|
+
token = await getAuthToken(request, 'admin')
|
|
66
|
+
const seedingAvailable = await isChannelSeedingAvailable(request, token)
|
|
67
|
+
test.skip(
|
|
68
|
+
!seedingAvailable,
|
|
69
|
+
'OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled in this environment; cannot seed a channel.',
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const stamp = Date.now()
|
|
73
|
+
channelId = await seedConnectedChannel(request, token, {
|
|
74
|
+
displayName: `TC-CHANNEL-DISCORD-007 ${stamp}`,
|
|
75
|
+
externalIdentifier: `discord-007-${stamp}@test-seed.local`,
|
|
76
|
+
})
|
|
77
|
+
await seedInboundMessage(request, token, {
|
|
78
|
+
channelId,
|
|
79
|
+
providerKey: 'discord',
|
|
80
|
+
from: `discord-user-${stamp}`,
|
|
81
|
+
subject: `Discord health seed ${stamp}`,
|
|
82
|
+
bodyText: 'seed message for the health window',
|
|
83
|
+
messageId: `discord-message-007-${stamp}`,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const response = await apiRequest(
|
|
87
|
+
request,
|
|
88
|
+
'GET',
|
|
89
|
+
`/api/communication_channels/channels/${channelId}/health`,
|
|
90
|
+
{ token },
|
|
91
|
+
)
|
|
92
|
+
expect(response.status(), 'GET /channels/[id]/health should return 200').toBe(200)
|
|
93
|
+
const body = await readJsonSafe<{
|
|
94
|
+
channelId?: string
|
|
95
|
+
windowHours?: number
|
|
96
|
+
counts?: Record<string, number>
|
|
97
|
+
totalsLast24h?: number
|
|
98
|
+
recentFailures?: unknown[]
|
|
99
|
+
}>(response)
|
|
100
|
+
|
|
101
|
+
expect(body?.channelId, 'health must be scoped to the requested channel').toBe(channelId)
|
|
102
|
+
expect(typeof body?.windowHours, 'the health window must be reported').toBe('number')
|
|
103
|
+
expect(
|
|
104
|
+
Object.keys(body?.counts ?? {}),
|
|
105
|
+
'the delivery-status breakdown must keep its fixed shape',
|
|
106
|
+
).toEqual(expect.arrayContaining(['sent', 'delivered', 'failed', 'pending', 'queued']))
|
|
107
|
+
for (const [status, value] of Object.entries(body?.counts ?? {})) {
|
|
108
|
+
expect(typeof value, `counts.${status} must be numeric`).toBe('number')
|
|
109
|
+
}
|
|
110
|
+
expect(
|
|
111
|
+
body?.totalsLast24h ?? 0,
|
|
112
|
+
'the seeded discord message must be inside the 24h window',
|
|
113
|
+
).toBeGreaterThanOrEqual(1)
|
|
114
|
+
expect(Array.isArray(body?.recentFailures), 'recentFailures must always be an array').toBe(true)
|
|
115
|
+
} finally {
|
|
116
|
+
await deleteChannelIfExists(request, token, channelId)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { createDiscordSigner, freshTimestamp, interactionsUrl, pingInteractionBody } from './helpers/discordSignature'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TC-CHANNEL-DISCORD-008 — tenant isolation on the shared interactions endpoint.
|
|
6
|
+
* Source: .ai/specs/2026-06-19-discord-communication-channel-integration.md
|
|
7
|
+
*
|
|
8
|
+
* One unauthenticated URL serves every tenant's Discord application, so the
|
|
9
|
+
* candidate fan-out is the isolation boundary: a request is pinned to the single
|
|
10
|
+
* channel whose stored public key verifies it, and a request that verifies
|
|
11
|
+
* against none must be indistinguishable from any other rejection.
|
|
12
|
+
*
|
|
13
|
+
* This spec drives the real route with two independent applications' keys and
|
|
14
|
+
* asserts the endpoint is a black box: identical status, identical body, no
|
|
15
|
+
* channel id, tenant id, organization id or count anywhere in the response, and
|
|
16
|
+
* no timing-visible difference in shape between them. That is the observable
|
|
17
|
+
* half of the isolation contract on an instance with no connected Discord
|
|
18
|
+
* channel — the positive half (a signature pinning exactly its own tenant, and
|
|
19
|
+
* never another's) requires two live bot tokens and is unit-tested as
|
|
20
|
+
* "per-tenant public-key pinning" in `lib/__tests__/interactions-handler.test.ts`.
|
|
21
|
+
*/
|
|
22
|
+
const LEAKY_KEYS = ['channelId', 'tenantId', 'organizationId', 'candidates', 'channels']
|
|
23
|
+
|
|
24
|
+
test.describe('TC-CHANNEL-DISCORD-008: interactions endpoint leaks no tenant state', () => {
|
|
25
|
+
test('two different applications get byte-identical rejections', async ({ request }) => {
|
|
26
|
+
const bodies: unknown[] = []
|
|
27
|
+
const statuses: number[] = []
|
|
28
|
+
|
|
29
|
+
for (const label of ['tenant-a', 'tenant-b']) {
|
|
30
|
+
const signer = createDiscordSigner()
|
|
31
|
+
const timestamp = freshTimestamp()
|
|
32
|
+
const body = JSON.stringify({ ...JSON.parse(pingInteractionBody()), application_id: label })
|
|
33
|
+
const response = await request.post(interactionsUrl(), {
|
|
34
|
+
headers: {
|
|
35
|
+
'content-type': 'application/json',
|
|
36
|
+
'x-signature-ed25519': signer.sign(timestamp, body),
|
|
37
|
+
'x-signature-timestamp': timestamp,
|
|
38
|
+
},
|
|
39
|
+
data: body,
|
|
40
|
+
})
|
|
41
|
+
statuses.push(response.status())
|
|
42
|
+
bodies.push(await response.json())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
expect(statuses, 'both applications must be rejected the same way').toEqual([401, 401])
|
|
46
|
+
expect(
|
|
47
|
+
bodies[0],
|
|
48
|
+
'the rejection body must not vary with the caller — that variance would be a tenant oracle',
|
|
49
|
+
).toEqual(bodies[1])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('a rejection body carries no tenant-scoped identifiers', async ({ request }) => {
|
|
53
|
+
const signer = createDiscordSigner()
|
|
54
|
+
const timestamp = freshTimestamp()
|
|
55
|
+
const body = pingInteractionBody()
|
|
56
|
+
const response = await request.post(interactionsUrl(), {
|
|
57
|
+
headers: {
|
|
58
|
+
'content-type': 'application/json',
|
|
59
|
+
'x-signature-ed25519': signer.sign(timestamp, body),
|
|
60
|
+
'x-signature-timestamp': timestamp,
|
|
61
|
+
},
|
|
62
|
+
data: body,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
expect(response.status()).toBe(401)
|
|
66
|
+
const raw = await response.text()
|
|
67
|
+
for (const key of LEAKY_KEYS) {
|
|
68
|
+
expect(raw, `an unverified caller must not learn "${key}"`).not.toContain(key)
|
|
69
|
+
}
|
|
70
|
+
expect(
|
|
71
|
+
Object.keys((await response.json()) as Record<string, unknown>),
|
|
72
|
+
'the rejection body is exactly one error code',
|
|
73
|
+
).toEqual(['error'])
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createPrivateKey, generateKeyPairSync, sign as signEd25519 } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ed25519 request-signing helpers for the Discord Interactions integration
|
|
5
|
+
* tests. Discord signs `timestamp + rawBody` with the application's Ed25519 key
|
|
6
|
+
* and sends the signature in `X-Signature-Ed25519`; the provider route verifies
|
|
7
|
+
* it against every candidate channel's stored public key and is fail-closed.
|
|
8
|
+
*
|
|
9
|
+
* The tests generate their own keypair so they never need a real Discord
|
|
10
|
+
* application, and so a signature that is cryptographically perfect but belongs
|
|
11
|
+
* to no connected channel is still rejected — which is the property under test.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type DiscordSigner = {
|
|
15
|
+
/** Raw 32-byte Ed25519 public key, hex encoded — the shape Discord shows in the portal. */
|
|
16
|
+
publicKeyHex: string
|
|
17
|
+
/** Sign `timestamp + body` exactly the way Discord does. */
|
|
18
|
+
sign: (timestamp: string, body: string) => string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createDiscordSigner(): DiscordSigner {
|
|
22
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
|
|
23
|
+
const spki = publicKey.export({ format: 'der', type: 'spki' }) as Buffer
|
|
24
|
+
// SPKI for Ed25519 is a fixed 12-byte header followed by the raw 32-byte key.
|
|
25
|
+
const rawPublicKey = spki.subarray(spki.length - 32)
|
|
26
|
+
const key = createPrivateKey(privateKey.export({ format: 'pem', type: 'pkcs8' }) as string)
|
|
27
|
+
return {
|
|
28
|
+
publicKeyHex: rawPublicKey.toString('hex'),
|
|
29
|
+
sign: (timestamp: string, body: string) =>
|
|
30
|
+
signEd25519(null, Buffer.from(`${timestamp}${body}`, 'utf8'), key).toString('hex'),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A PING interaction body — the handshake Discord sends when an endpoint URL is saved. */
|
|
35
|
+
export function pingInteractionBody(): string {
|
|
36
|
+
return JSON.stringify({ type: 1, id: 'integration-ping', application_id: 'integration' })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A fully-formed `APPLICATION_COMMAND` body — everything the dispatch path needs
|
|
41
|
+
* (id, follow-up token, application, channel and invoking user), so a rejection
|
|
42
|
+
* can only come from the signature gate and never from an incomplete payload.
|
|
43
|
+
*/
|
|
44
|
+
export function slashCommandInteractionBody(): string {
|
|
45
|
+
return JSON.stringify({
|
|
46
|
+
type: 2,
|
|
47
|
+
id: 'integration-command',
|
|
48
|
+
token: 'integration-follow-up-token',
|
|
49
|
+
application_id: 'integration',
|
|
50
|
+
channel_id: 'integration-channel',
|
|
51
|
+
member: { user: { id: 'integration-user', username: 'integration' } },
|
|
52
|
+
data: { name: 'mercato', options: [{ name: 'message', value: 'integration probe' }] },
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Current unix seconds as the signed timestamp header value. */
|
|
57
|
+
export function freshTimestamp(): string {
|
|
58
|
+
return String(Math.floor(Date.now() / 1000))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A timestamp far outside the provider's replay window (DISCORD_SIGNATURE_MAX_SKEW_SECONDS = 300). */
|
|
62
|
+
export function staleTimestamp(): string {
|
|
63
|
+
return String(Math.floor(Date.now() / 1000) - 3600)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const INTERACTIONS_PATH = '/api/channel_discord/interactions'
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The interactions endpoint is posted with a RAW body (the signature covers the
|
|
70
|
+
* exact bytes), so these specs bypass `apiRequest` and call Playwright's request
|
|
71
|
+
* context directly — which means they must resolve `BASE_URL` the same way the
|
|
72
|
+
* shared helper does.
|
|
73
|
+
*/
|
|
74
|
+
export function interactionsUrl(): string {
|
|
75
|
+
const base = process.env.BASE_URL?.trim()
|
|
76
|
+
return base ? `${base}${INTERACTIONS_PATH}` : INTERACTIONS_PATH
|
|
77
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { config as loadEnv } from 'dotenv'
|
|
3
|
+
import { drainIntegrationQueue } from '@open-mercato/core/helpers/integration/queue'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The hub emits `communication_channels.message.received` through the `events`
|
|
7
|
+
* queue, where the persistent `customers:link-channel-message-received`
|
|
8
|
+
* subscriber consumes it. Integration specs that assert on the CRM side of an
|
|
9
|
+
* inbound Discord message must drain that queue first.
|
|
10
|
+
*
|
|
11
|
+
* Mirrors the customers module's own inbound-email helper: the Playwright
|
|
12
|
+
* process is the monorepo while the job was created by the app under test, so
|
|
13
|
+
* the drain runs from the app root (`OM_TEST_APP_ROOT` when set).
|
|
14
|
+
*/
|
|
15
|
+
export const APP_ROOT = process.env.OM_TEST_APP_ROOT?.trim()
|
|
16
|
+
? path.resolve(process.env.OM_TEST_APP_ROOT as string)
|
|
17
|
+
: path.resolve(process.cwd(), 'apps/mercato')
|
|
18
|
+
|
|
19
|
+
if (!process.env.OM_TEST_APP_ROOT?.trim()) {
|
|
20
|
+
loadEnv({ path: path.resolve(APP_ROOT, '.env') })
|
|
21
|
+
process.env.QUEUE_BASE_DIR = path.resolve(APP_ROOT, '.mercato/queue')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function drainEventsQueue(): Promise<void> {
|
|
25
|
+
await drainIntegrationQueue('events', { appRoot: APP_ROOT })
|
|
26
|
+
}
|