@open-mercato/core 0.6.8-develop.7051.1.2e20648fd5 → 0.6.8-develop.7056.1.1a563a270d

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 (57) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/communicationChannelsFixtures.js +19 -0
  3. package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
  4. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +9 -1
  5. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
  6. package/dist/modules/communication_channels/api/post/test-seed/route.js +95 -10
  7. package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
  8. package/dist/modules/communication_channels/commands/ingest-inbound-message.js +5 -0
  9. package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
  10. package/dist/modules/communication_channels/di.js +7 -1
  11. package/dist/modules/communication_channels/di.js.map +2 -2
  12. package/dist/modules/communication_channels/lib/email-capabilities.js +4 -1
  13. package/dist/modules/communication_channels/lib/email-capabilities.js.map +2 -2
  14. package/dist/modules/communication_channels/lib/outbound-recipient.js +33 -0
  15. package/dist/modules/communication_channels/lib/outbound-recipient.js.map +7 -0
  16. package/dist/modules/communication_channels/lib/resolve-channel-type.js +51 -0
  17. package/dist/modules/communication_channels/lib/resolve-channel-type.js.map +7 -0
  18. package/dist/modules/communication_channels/lib/test-seed.js +41 -2
  19. package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
  20. package/dist/modules/customers/api/people/check-phone/route.js +51 -9
  21. package/dist/modules/customers/api/people/check-phone/route.js.map +2 -2
  22. package/dist/modules/customers/lib/findPeopleByAddresses.js +1 -0
  23. package/dist/modules/customers/lib/findPeopleByAddresses.js.map +2 -2
  24. package/dist/modules/messages/api/openapi.js +2 -0
  25. package/dist/modules/messages/api/openapi.js.map +2 -2
  26. package/dist/modules/messages/api/route.js +17 -2
  27. package/dist/modules/messages/api/route.js.map +2 -2
  28. package/dist/modules/messages/data/validators.js +18 -4
  29. package/dist/modules/messages/data/validators.js.map +2 -2
  30. package/dist/modules/messages/lib/channel-sender-identity.js +26 -0
  31. package/dist/modules/messages/lib/channel-sender-identity.js.map +7 -0
  32. package/dist/modules/messages/lib/composeSourceChannelType.js +41 -0
  33. package/dist/modules/messages/lib/composeSourceChannelType.js.map +7 -0
  34. package/dist/modules/warranty_claims/api/assignees/route.js +3 -3
  35. package/dist/modules/warranty_claims/api/assignees/route.js.map +2 -2
  36. package/dist/modules/warranty_claims/api/portal/claims/route.js +12 -9
  37. package/dist/modules/warranty_claims/api/portal/claims/route.js.map +2 -2
  38. package/package.json +7 -7
  39. package/src/helpers/integration/communicationChannelsFixtures.ts +69 -1
  40. package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +17 -1
  41. package/src/modules/communication_channels/api/post/test-seed/route.ts +155 -16
  42. package/src/modules/communication_channels/commands/ingest-inbound-message.ts +5 -0
  43. package/src/modules/communication_channels/di.ts +7 -0
  44. package/src/modules/communication_channels/lib/adapter.ts +14 -0
  45. package/src/modules/communication_channels/lib/email-capabilities.ts +4 -0
  46. package/src/modules/communication_channels/lib/outbound-recipient.ts +71 -0
  47. package/src/modules/communication_channels/lib/resolve-channel-type.ts +99 -0
  48. package/src/modules/communication_channels/lib/test-seed.ts +64 -4
  49. package/src/modules/customers/api/people/check-phone/route.ts +75 -10
  50. package/src/modules/customers/lib/findPeopleByAddresses.ts +8 -6
  51. package/src/modules/messages/api/openapi.ts +2 -0
  52. package/src/modules/messages/api/route.ts +31 -2
  53. package/src/modules/messages/data/validators.ts +43 -4
  54. package/src/modules/messages/lib/channel-sender-identity.ts +55 -0
  55. package/src/modules/messages/lib/composeSourceChannelType.ts +95 -0
  56. package/src/modules/warranty_claims/api/assignees/route.ts +3 -3
  57. package/src/modules/warranty_claims/api/portal/claims/route.ts +12 -9
@@ -20,10 +20,16 @@ import {
20
20
  } from '../../../commands/connect-credential-channel'
21
21
  import { emitCommunicationChannelsEvent } from '../../../events'
22
22
  import {
23
+ TEST_SEED_CHAT_PROVIDER_KEY,
23
24
  TEST_SEED_PROVIDER_KEY,
24
25
  ensureTestSeedAdapterRegistered,
25
26
  isTestChannelSeedingEnabled,
26
27
  } from '../../../lib/test-seed'
28
+ import {
29
+ COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID,
30
+ type IngestInboundMessageInput,
31
+ type IngestInboundMessageResult,
32
+ } from '../../../commands/ingest-inbound-message'
27
33
 
28
34
  /**
29
35
  * TEST-ONLY channel seeding endpoint.
@@ -32,15 +38,22 @@ import {
32
38
  * production default) every request returns 404, so this route is invisible and
33
39
  * inert in production. See `lib/test-seed.ts` for the full rationale.
34
40
  *
35
- * Two actions, both scoped to the caller's tenant/org:
36
- * - `connect-channel`: connect a network-free `__test_seed__` channel owned by
37
- * the caller (delegates to the real connect-credential command so the channel
38
- * persists credentials + lands in `status='connected'`). Enables the outbound
39
- * compose → deliver → `.sent` chain to complete in CI.
41
+ * Three actions, all scoped to the caller's tenant/org:
42
+ * - `connect-channel`: connect a network-free stub channel owned by the caller
43
+ * (delegates to the real connect-credential command so the channel persists
44
+ * credentials + lands in `status='connected'`). Enables the outbound
45
+ * compose → deliver → `.sent` chain to complete in CI. `providerFlavor: 'chat'`
46
+ * connects the non-email stub instead, for tests about sender identity.
47
+ * - `ingest-inbound`: run the REAL `ingest_inbound_message` command over an
48
+ * adapter-normalized chat frame, so the platform compose path — and every
49
+ * validation rule on it — actually executes. Use this for anything that
50
+ * claims inbound works.
40
51
  * - `emit-inbound`: insert an inbound `MessageChannelLink` (+ a `messages.message`
41
52
  * row for threading) and emit `communication_channels.message.received` so the
42
53
  * customers link-channel-message subscriber runs against real Postgres. Enables
43
- * the inbound auto-link tests (TC-CRM-EMAIL-002..005).
54
+ * the inbound auto-link tests (TC-CRM-EMAIL-002..005). NOTE: this action
55
+ * deliberately bypasses `messages.messages.compose` — it can never prove that
56
+ * the hub accepts a message, only that downstream subscribers fire.
44
57
  */
45
58
  type RbacServiceLike = {
46
59
  loadAcl: (
@@ -68,6 +81,35 @@ const connectChannelSchema = z.object({
68
81
  action: z.literal('connect-channel'),
69
82
  displayName: z.string().min(1).max(255).optional(),
70
83
  externalIdentifier: z.string().min(1).max(255).optional(),
84
+ /**
85
+ * Which stub provider to connect. `email` (default) keeps the historical
86
+ * email-shaped channel; `chat` connects a channel whose senders have no
87
+ * address, so a test can exercise the hub's non-email identity contract
88
+ * without inventing one (#4975).
89
+ */
90
+ providerFlavor: z.enum(['email', 'chat']).optional(),
91
+ })
92
+
93
+ /**
94
+ * Drive the REAL `ingest_inbound_message` command with a chat-shaped frame.
95
+ *
96
+ * `emit-inbound` below deliberately bypasses the platform compose path (it
97
+ * inserts the `messages` row with raw SQL) because it only ever needed a
98
+ * landing zone for the CRM-link subscribers. That bypass is why an inbound test
99
+ * could pass while `composeMessageSchema` rejected every real message (#4975).
100
+ * This action takes the opposite approach: it hands the frame to the adapter and
101
+ * the ingest command and asserts nothing on the way, so whatever the hub's
102
+ * contract really is, the test feels it.
103
+ */
104
+ const ingestInboundSchema = z.object({
105
+ action: z.literal('ingest-inbound'),
106
+ channelId: z.string().uuid(),
107
+ /** Opaque sender handle — a Discord snowflake, a Slack member id, etc. */
108
+ senderIdentifier: z.string().min(1).max(255),
109
+ senderDisplayName: z.string().max(255).optional(),
110
+ body: z.string().max(50_000).optional(),
111
+ externalMessageId: z.string().min(1).max(255),
112
+ externalConversationId: z.string().min(1).max(255),
71
113
  })
72
114
 
73
115
  const emitInboundSchema = z.object({
@@ -103,7 +145,11 @@ const emitInboundSchema = z.object({
103
145
  createThreadMapping: z.boolean().optional(),
104
146
  })
105
147
 
106
- const bodySchema = z.discriminatedUnion('action', [connectChannelSchema, emitInboundSchema])
148
+ const bodySchema = z.discriminatedUnion('action', [
149
+ connectChannelSchema,
150
+ ingestInboundSchema,
151
+ emitInboundSchema,
152
+ ])
107
153
 
108
154
  export async function POST(req: Request): Promise<Response> {
109
155
  // Fail-closed: invisible in production. Mirrors an unknown route (404) rather
@@ -139,13 +185,23 @@ export async function POST(req: Request): Promise<Response> {
139
185
  if (body.action === 'connect-channel') {
140
186
  const stamp = Date.now()
141
187
  const commandBus = container.resolve('commandBus') as CommandBus
188
+ const isChatFlavor = body.providerFlavor === 'chat'
189
+ // A chat channel is deliberately connected WITHOUT an email-ish credential
190
+ // key, so `connect-credential-channel` derives no `externalIdentifier` and
191
+ // the row is shaped exactly like a real Discord channel (identifier NULL —
192
+ // the condition #4977 describes). Inventing one here would recreate the
193
+ // fixture dishonesty that hid #4975.
194
+ const credentials = isChatFlavor
195
+ ? { handle: body.externalIdentifier ?? `test-seed-chat-${stamp}` }
196
+ : {
197
+ username: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`,
198
+ fromAddress: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`,
199
+ }
142
200
  const input: ConnectCredentialChannelInput = {
143
- providerKey: TEST_SEED_PROVIDER_KEY,
144
- displayName: body.displayName ?? `Test Seed Channel ${stamp}`,
145
- credentials: {
146
- username: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`,
147
- fromAddress: body.externalIdentifier ?? `test-seed-${stamp}@test-seed.local`,
148
- },
201
+ providerKey: isChatFlavor ? TEST_SEED_CHAT_PROVIDER_KEY : TEST_SEED_PROVIDER_KEY,
202
+ displayName:
203
+ body.displayName ?? `Test Seed ${isChatFlavor ? 'Chat ' : ''}Channel ${stamp}`,
204
+ credentials,
149
205
  userId,
150
206
  scope: { tenantId, organizationId },
151
207
  }
@@ -174,9 +230,12 @@ export async function POST(req: Request): Promise<Response> {
174
230
  )
175
231
  }
176
232
 
177
- // action === 'emit-inbound'
233
+ // action === 'ingest-inbound' | 'emit-inbound' — both address an existing channel.
178
234
  const em = (container.resolve('em') as EntityManager).fork()
179
- const providerKey = body.providerKey ?? TEST_SEED_PROVIDER_KEY
235
+ // Only the `emit-inbound` branch stamps a caller-chosen provider key onto the
236
+ // rows it seeds; `ingest-inbound` takes the channel's own (see below).
237
+ const providerKey =
238
+ body.action === 'emit-inbound' ? body.providerKey ?? TEST_SEED_PROVIDER_KEY : TEST_SEED_PROVIDER_KEY
180
239
 
181
240
  // `channelId` is caller-supplied, so confirm it names a channel this tenant/org
182
241
  // actually owns before seeding rows that reference it. Mirrors the ownership
@@ -227,6 +286,85 @@ export async function POST(req: Request): Promise<Response> {
227
286
  )
228
287
  }
229
288
 
289
+ if (body.action === 'ingest-inbound') {
290
+ // Everything below this point is the real path: the adapter normalizes the
291
+ // frame and `ingest_inbound_message` composes the platform message through
292
+ // `messages.messages.compose`. Nothing is short-circuited, so a hub contract
293
+ // the provider cannot satisfy surfaces here as a failure instead of hiding
294
+ // behind seeded rows (#4975).
295
+ // The channel's own provider key, never a hardcoded one: ingest does not
296
+ // check that `providerKey` matches the channel it names, so passing a
297
+ // different one would silently stamp the wrong provider onto the link.
298
+ const channelProviderKey = ownedChannel.providerKey
299
+ if (channelProviderKey !== TEST_SEED_CHAT_PROVIDER_KEY) {
300
+ return NextResponse.json(
301
+ {
302
+ error:
303
+ 'ingest-inbound requires a channel connected with providerFlavor: "chat"; ' +
304
+ `channel ${body.channelId} is '${channelProviderKey}'`,
305
+ },
306
+ { status: 422 },
307
+ )
308
+ }
309
+
310
+ const commandBus = container.resolve('commandBus') as CommandBus
311
+ const adapterRegistry = container.resolve('channelAdapterRegistry') as {
312
+ get: (key: string) => { normalizeInbound: (raw: unknown) => Promise<unknown> } | undefined
313
+ }
314
+ const adapter = adapterRegistry.get(channelProviderKey)
315
+ if (!adapter) {
316
+ return NextResponse.json(
317
+ { error: '[internal] test-seed chat adapter is not registered' },
318
+ { status: 500 },
319
+ )
320
+ }
321
+
322
+ const normalized = await adapter.normalizeInbound({
323
+ raw: {
324
+ externalMessageId: body.externalMessageId,
325
+ externalConversationId: body.externalConversationId,
326
+ senderIdentifier: body.senderIdentifier,
327
+ senderDisplayName: body.senderDisplayName,
328
+ body: body.body ?? '',
329
+ },
330
+ eventType: 'message',
331
+ metadata: {},
332
+ })
333
+
334
+ const ingestInput = {
335
+ channelId: body.channelId,
336
+ providerKey: channelProviderKey,
337
+ channelType: ownedChannel.channelType,
338
+ scope: { tenantId, organizationId },
339
+ message: normalized,
340
+ } as IngestInboundMessageInput
341
+
342
+ const { result } = await commandBus.execute<
343
+ IngestInboundMessageInput,
344
+ IngestInboundMessageResult
345
+ >(COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID, {
346
+ input: ingestInput,
347
+ ctx: {
348
+ container,
349
+ auth: auth as never,
350
+ organizationScope: null,
351
+ selectedOrganizationId: organizationId,
352
+ organizationIds: organizationId ? [organizationId] : null,
353
+ },
354
+ })
355
+
356
+ return NextResponse.json(
357
+ {
358
+ status: result.status,
359
+ messageId: result.messageId ?? null,
360
+ conversationId: result.externalConversationId ?? null,
361
+ channelLinkId: result.channelLinkId ?? null,
362
+ channelType: ownedChannel.channelType,
363
+ },
364
+ { status: 201 },
365
+ )
366
+ }
367
+
230
368
  // A MessageChannelLink requires a non-null external_conversation_id (FK) and
231
369
  // message_id. Create a synthetic conversation + (optionally threaded) message
232
370
  // so the link is shaped like a real inbound row the subscriber can consume.
@@ -341,7 +479,8 @@ export const openApi = {
341
479
  tags: ['CommunicationChannels'],
342
480
  methods: {
343
481
  POST: {
344
- summary: 'Test-only: seed a connected channel or emit an inbound message (env-gated)',
482
+ summary:
483
+ 'Test-only: seed a connected channel, ingest a real inbound message, or emit a seeded inbound link (env-gated)',
345
484
  tags: ['CommunicationChannels'],
346
485
  responses: [
347
486
  { status: 201, description: 'Channel seeded / inbound message emitted' },
@@ -385,6 +385,11 @@ const ingestInboundMessageCommand: CommandHandler<IngestInboundMessageInput, Ing
385
385
  sourceEntityId: conversation.id,
386
386
  externalEmail: contactHint?.email ?? undefined,
387
387
  externalName: contactHint?.displayName ?? m.senderDisplayName,
388
+ // #4975: tells the hub which identity contract applies to this sender.
389
+ // Email-typed channels keep the mandatory `externalEmail`; providers whose
390
+ // senders have no address (Discord, Slack, SMS…) are validated without it.
391
+ // The messages validator fails closed on any type it does not recognize.
392
+ sourceChannelType: input.channelType,
388
393
  recipients: mapping?.assignedUserId
389
394
  ? [{ userId: mapping.assignedUserId, type: 'to' as const }]
390
395
  : [],
@@ -11,6 +11,7 @@ import {
11
11
  import { getChannelAdapterRegistry } from './lib/adapter-registry-singleton'
12
12
  import { ensureTestSeedAdapterRegistered } from './lib/test-seed'
13
13
  import { sendAsUser } from './lib/send-as-user'
14
+ import { resolveChannelTypeSafely } from './lib/resolve-channel-type'
14
15
 
15
16
  export function register(container: AppContainer) {
16
17
  // Test-only: register the network-free stub channel adapter when
@@ -36,5 +37,11 @@ export function register(container: AppContainer) {
36
37
  // In-process send-as-user facade. Cross-module callers (e.g. the customers
37
38
  // compose route) resolve this instead of making an HTTP self-call.
38
39
  communicationChannelsSendAsUser: asValue(sendAsUser),
40
+
41
+ // Cross-module channel-type lookup. The messages compose route resolves this
42
+ // to decide whether an external correspondent must carry an email address
43
+ // (#4975); absent module or failed lookup reads as "unknown", which the
44
+ // messages validator handles fail-closed.
45
+ communicationChannelsResolveChannelType: asValue(resolveChannelTypeSafely),
39
46
  })
40
47
  }
@@ -48,6 +48,20 @@ export interface ChannelCapabilities {
48
48
  * Optional; existing chat providers (Slack, WhatsApp) omit and are treated as `true`.
49
49
  */
50
50
  realtimePush?: boolean
51
+
52
+ /**
53
+ * Shape of the outbound recipient this provider's `sendMessage` accepts as
54
+ * `metadata.to`. Optional; when absent the hub validates recipients as email
55
+ * addresses, so every provider that predates this field keeps its exact
56
+ * behavior.
57
+ *
58
+ * Declare `'provider-native'` when recipients are provider-issued identifiers
59
+ * rather than email addresses (e.g. a Discord channel snowflake). The hub then
60
+ * applies transport-safety checks only (see `validateOutboundRecipient`) and
61
+ * the adapter owns the provider-specific format — it MUST treat the value as
62
+ * untrusted input.
63
+ */
64
+ recipientFormat?: 'email' | 'provider-native'
51
65
  }
52
66
 
53
67
  // ── Send / status / sender listing ────────────────────────────
@@ -57,4 +57,8 @@ export const baseEmailCapabilities: ChannelCapabilities = {
57
57
 
58
58
  // Polling (real-time push deferred to v2 for all email providers)
59
59
  realtimePush: false,
60
+
61
+ // Stated rather than left to the default so the email baseline reads as an
62
+ // explicit choice: outbound recipients here are addresses, not provider ids.
63
+ recipientFormat: 'email',
60
64
  }
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod'
2
+ import type { ChannelCapabilities } from './adapter'
3
+
4
+ /**
5
+ * Upper bound shared by both recipient shapes. 320 is the RFC 5321 maximum for
6
+ * an email address (64 local + `@` + 255 domain); provider-native identifiers
7
+ * are far shorter, so one ceiling covers both.
8
+ */
9
+ export const MAX_OUTBOUND_RECIPIENT_LENGTH = 320
10
+
11
+ const emailRecipientSchema = z.string().email()
12
+
13
+ /**
14
+ * Characters a provider-native recipient may contain. This is an allowlist, not a
15
+ * denylist, because the recipient reaches adapters that interpolate it into a REST
16
+ * path (Discord posts to `/channels/{recipient}/messages`), and a denylist fails
17
+ * open on every character nobody thought of — percent-encoded separators
18
+ * (`%2F`, `%2e%2e%2f`) and control bytes (NUL, DEL) all walked through the
19
+ * previous `[\r\n\s/\\?#]` class. An allowlist fails closed instead: the worst
20
+ * case is a provider whose identifier alphabet needs widening, which is a
21
+ * reviewable one-line change rather than a silent path-steering primitive.
22
+ *
23
+ * The set covers every provider-native identifier shape the hub has to carry
24
+ * today — Discord snowflakes (digits), Slack-style ids (alphanumerics), and email
25
+ * addresses, since `'provider-native'` must never narrow what `'email'` accepts.
26
+ */
27
+ const PROVIDER_NATIVE_ALLOWED = /^[A-Za-z0-9._:@+-]+$/
28
+
29
+ export type OutboundRecipientCheck = { ok: true } | { ok: false; error: string }
30
+
31
+ /**
32
+ * Validate an outbound recipient against the provider's declared recipient
33
+ * shape.
34
+ *
35
+ * The hub used to hard-wire `z.string().email()` on every outbound endpoint,
36
+ * which left providers whose recipients are not email addresses with no product
37
+ * path to send at all (#4976). Validation now follows the adapter's
38
+ * `capabilities.recipientFormat`, defaulting to `'email'` so every existing
39
+ * provider keeps byte-identical behavior.
40
+ */
41
+ export function validateOutboundRecipient(
42
+ recipient: unknown,
43
+ capabilities: Pick<ChannelCapabilities, 'recipientFormat'> | null | undefined,
44
+ ): OutboundRecipientCheck {
45
+ if (typeof recipient !== 'string' || recipient.length === 0) {
46
+ return { ok: false, error: 'Recipient is required' }
47
+ }
48
+ if (recipient.length > MAX_OUTBOUND_RECIPIENT_LENGTH) {
49
+ return {
50
+ ok: false,
51
+ error: `Recipient must be at most ${MAX_OUTBOUND_RECIPIENT_LENGTH} characters`,
52
+ }
53
+ }
54
+ if (capabilities?.recipientFormat !== 'provider-native') {
55
+ return emailRecipientSchema.safeParse(recipient).success
56
+ ? { ok: true }
57
+ : { ok: false, error: 'Recipient must be a valid email address' }
58
+ }
59
+ if (!PROVIDER_NATIVE_ALLOWED.test(recipient)) {
60
+ return {
61
+ ok: false,
62
+ error: 'Recipient may only contain letters, digits, and the characters . _ : @ + -',
63
+ }
64
+ }
65
+ // `.` is allowed (email addresses need it), so traversal still has to be
66
+ // rejected explicitly even though the separators it would need are not.
67
+ if (recipient.includes('..')) {
68
+ return { ok: false, error: 'Recipient must not contain ".."' }
69
+ }
70
+ return { ok: true }
71
+ }
@@ -0,0 +1,99 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import { createLogger } from '@open-mercato/shared/lib/logger'
5
+ import { CommunicationChannel, ExternalConversation, MessageChannelLink } from '../data/entities'
6
+
7
+ const logger = createLogger('communication_channels').child({ component: 'resolve-channel-type' })
8
+
9
+ export type ChannelTypeScope = {
10
+ tenantId: string
11
+ organizationId: string | null
12
+ }
13
+
14
+ export type ChannelTypeReference = {
15
+ /** `ExternalConversation.id`, as carried by a message's `sourceEntityId`. */
16
+ externalConversationId?: string | null
17
+ /** A platform `messages.message` id that may be linked to a channel. */
18
+ messageId?: string | null
19
+ }
20
+
21
+ /**
22
+ * Resolve the channel type behind a platform message or conversation (#4975).
23
+ *
24
+ * Cross-module callers (the `messages` compose route) need to know whether an
25
+ * external correspondent is reachable by email before validating a compose
26
+ * payload, but `messages` may not reach into this module's entities. They
27
+ * resolve this facade from DI instead — mirroring `communicationChannelsSendAsUser`
28
+ * — and treat a `null` result as "unknown", which the messages validator
29
+ * handles fail-closed.
30
+ *
31
+ * Always tenant/organization scoped: a reference that does not resolve inside
32
+ * the caller's scope is reported as unknown rather than looked up globally.
33
+ */
34
+ export async function resolveChannelType(
35
+ container: AppContainer,
36
+ scope: ChannelTypeScope,
37
+ reference: ChannelTypeReference,
38
+ ): Promise<string | null> {
39
+ const em = (container.resolve('em') as EntityManager).fork()
40
+ const dscope = { tenantId: scope.tenantId, organizationId: scope.organizationId ?? null }
41
+ const baseFilter = { tenantId: scope.tenantId, organizationId: scope.organizationId ?? null }
42
+
43
+ let externalConversationId = reference.externalConversationId ?? null
44
+
45
+ if (!externalConversationId && reference.messageId) {
46
+ // The message is linked to a channel directly — `MessageChannelLink` carries
47
+ // a denormalized channel type, so this is the cheapest hop when it exists.
48
+ const link = await findOneWithDecryption(
49
+ em,
50
+ MessageChannelLink,
51
+ { messageId: reference.messageId, ...baseFilter },
52
+ undefined,
53
+ dscope,
54
+ )
55
+ if (link?.channelType) return link.channelType
56
+ externalConversationId = link?.externalConversationId ?? null
57
+ }
58
+
59
+ if (!externalConversationId) return null
60
+
61
+ const conversation = await findOneWithDecryption(
62
+ em,
63
+ ExternalConversation,
64
+ { id: externalConversationId, ...baseFilter },
65
+ undefined,
66
+ dscope,
67
+ )
68
+ if (!conversation?.channelId) return null
69
+
70
+ const channel = await findOneWithDecryption(
71
+ em,
72
+ CommunicationChannel,
73
+ { id: conversation.channelId, ...baseFilter, deletedAt: null },
74
+ undefined,
75
+ dscope,
76
+ )
77
+ return channel?.channelType ?? null
78
+ }
79
+
80
+ /**
81
+ * Same contract, but never throws: a lookup failure degrades to "unknown" so a
82
+ * transient database error cannot turn into a 500 on an otherwise valid compose.
83
+ * Unknown is the safe answer — the messages validator fails closed on it.
84
+ */
85
+ export async function resolveChannelTypeSafely(
86
+ container: AppContainer,
87
+ scope: ChannelTypeScope,
88
+ reference: ChannelTypeReference,
89
+ ): Promise<string | null> {
90
+ try {
91
+ return await resolveChannelType(container, scope, reference)
92
+ } catch (err) {
93
+ logger.warn('channel type resolution failed, treating the channel as unknown', { err })
94
+ return null
95
+ }
96
+ }
97
+
98
+ /** DI service type for cross-module callers (resolve `communicationChannelsResolveChannelType`). */
99
+ export type ResolveChannelTypeService = typeof resolveChannelTypeSafely
@@ -41,6 +41,19 @@ import { hasChannelAdapter, registerChannelAdapter } from './adapter-registry-si
41
41
  /** Provider key for the network-free test stub adapter. */
42
42
  export const TEST_SEED_PROVIDER_KEY = '__test_seed__'
43
43
 
44
+ /**
45
+ * Provider key for the network-free stub adapter that stands in for a CHAT
46
+ * provider — one whose senders are identified by an opaque handle and have no
47
+ * email address at all (Discord, Slack, Telegram…).
48
+ *
49
+ * It exists because the email-shaped stub above can only ever prove the hub
50
+ * accepts email-shaped data. That is precisely how CI stayed green while every
51
+ * real inbound Discord message was rejected (#4975): the fixture invented an
52
+ * address the provider can never produce. Tests that need to prove the hub's
53
+ * non-email identity contract MUST drive this provider instead.
54
+ */
55
+ export const TEST_SEED_CHAT_PROVIDER_KEY = '__test_seed_chat__'
56
+
44
57
  /** Env flag that unlocks test-only channel seeding. Off in production. */
45
58
  export const TEST_CHANNEL_SEEDING_ENV = 'OM_ENABLE_TEST_CHANNEL_SEEDING'
46
59
 
@@ -72,8 +85,10 @@ const testSeedCapabilities: ChannelCapabilities = {
72
85
  * delivery worker reach its success path and emit `communication_channels.message.sent`.
73
86
  */
74
87
  class TestSeedChannelAdapter implements ChannelAdapter {
75
- readonly providerKey = TEST_SEED_PROVIDER_KEY
76
- readonly channelType = 'email'
88
+ // Widened to `string` rather than inferred as a literal so the chat-flavoured
89
+ // subclass below can override both with its own provider key / channel type.
90
+ readonly providerKey: string = TEST_SEED_PROVIDER_KEY
91
+ readonly channelType: string = 'email'
77
92
  readonly capabilities = testSeedCapabilities
78
93
 
79
94
  async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {
@@ -121,13 +136,54 @@ class TestSeedChannelAdapter implements ChannelAdapter {
121
136
  }
122
137
  }
123
138
 
139
+ /**
140
+ * Chat-flavoured twin of {@link TestSeedChannelAdapter}: same network-free
141
+ * behaviour, but it declares a non-email `channelType`, so a channel connected
142
+ * through it is shaped like a real chat channel — including an
143
+ * `externalIdentifier` of NULL when no email-ish credential key is supplied.
144
+ */
145
+ class TestSeedChatChannelAdapter extends TestSeedChannelAdapter {
146
+ readonly providerKey: string = TEST_SEED_CHAT_PROVIDER_KEY
147
+ readonly channelType: string = 'discord'
148
+
149
+ async normalizeInbound(raw: InboundMessage): Promise<NormalizedInboundMessage> {
150
+ // Unlike the email stub, this one is reachable: the test-seed ingest action
151
+ // feeds it a chat-shaped frame so the message travels the real ingest path
152
+ // (and therefore the real compose validation) rather than a SQL shortcut.
153
+ const frame = (raw.raw ?? {}) as Record<string, unknown>
154
+ const senderIdentifier = String(frame.senderIdentifier ?? '')
155
+ if (!senderIdentifier) {
156
+ throw new Error('[internal] TestSeedChatChannelAdapter requires a senderIdentifier')
157
+ }
158
+ return {
159
+ externalMessageId: String(frame.externalMessageId ?? ''),
160
+ externalConversationId: String(frame.externalConversationId ?? ''),
161
+ senderIdentifier,
162
+ senderDisplayName:
163
+ typeof frame.senderDisplayName === 'string' ? frame.senderDisplayName : undefined,
164
+ body: typeof frame.body === 'string' ? frame.body : '',
165
+ bodyFormat: 'text',
166
+ timestamp: new Date(),
167
+ channelPayload: {},
168
+ channelContentType: 'text/plain',
169
+ channelMetadata: {},
170
+ }
171
+ }
172
+ }
173
+
124
174
  let cachedTestSeedAdapter: TestSeedChannelAdapter | null = null
175
+ let cachedTestSeedChatAdapter: TestSeedChatChannelAdapter | null = null
125
176
 
126
177
  function getTestSeedChannelAdapter(): TestSeedChannelAdapter {
127
178
  if (!cachedTestSeedAdapter) cachedTestSeedAdapter = new TestSeedChannelAdapter()
128
179
  return cachedTestSeedAdapter
129
180
  }
130
181
 
182
+ function getTestSeedChatChannelAdapter(): TestSeedChatChannelAdapter {
183
+ if (!cachedTestSeedChatAdapter) cachedTestSeedChatAdapter = new TestSeedChatChannelAdapter()
184
+ return cachedTestSeedChatAdapter
185
+ }
186
+
131
187
  /**
132
188
  * Register the test-seed adapter exactly once, but ONLY when the env flag is set.
133
189
  * Idempotent and safe to call from every container creation (`di.register`) — a
@@ -135,6 +191,10 @@ function getTestSeedChannelAdapter(): TestSeedChannelAdapter {
135
191
  */
136
192
  export function ensureTestSeedAdapterRegistered(): void {
137
193
  if (!isTestChannelSeedingEnabled()) return
138
- if (hasChannelAdapter(TEST_SEED_PROVIDER_KEY)) return
139
- registerChannelAdapter(getTestSeedChannelAdapter())
194
+ if (!hasChannelAdapter(TEST_SEED_PROVIDER_KEY)) {
195
+ registerChannelAdapter(getTestSeedChannelAdapter())
196
+ }
197
+ if (!hasChannelAdapter(TEST_SEED_CHAT_PROVIDER_KEY)) {
198
+ registerChannelAdapter(getTestSeedChatChannelAdapter())
199
+ }
140
200
  }