@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/communicationChannelsFixtures.js +19 -0
- package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
- package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +9 -1
- package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
- package/dist/modules/communication_channels/api/post/test-seed/route.js +95 -10
- package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js +5 -0
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
- package/dist/modules/communication_channels/di.js +7 -1
- package/dist/modules/communication_channels/di.js.map +2 -2
- package/dist/modules/communication_channels/lib/email-capabilities.js +4 -1
- package/dist/modules/communication_channels/lib/email-capabilities.js.map +2 -2
- package/dist/modules/communication_channels/lib/outbound-recipient.js +33 -0
- package/dist/modules/communication_channels/lib/outbound-recipient.js.map +7 -0
- package/dist/modules/communication_channels/lib/resolve-channel-type.js +51 -0
- package/dist/modules/communication_channels/lib/resolve-channel-type.js.map +7 -0
- package/dist/modules/communication_channels/lib/test-seed.js +41 -2
- package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
- package/dist/modules/customers/api/people/check-phone/route.js +51 -9
- package/dist/modules/customers/api/people/check-phone/route.js.map +2 -2
- package/dist/modules/customers/lib/findPeopleByAddresses.js +1 -0
- package/dist/modules/customers/lib/findPeopleByAddresses.js.map +2 -2
- package/dist/modules/messages/api/openapi.js +2 -0
- package/dist/modules/messages/api/openapi.js.map +2 -2
- package/dist/modules/messages/api/route.js +17 -2
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/data/validators.js +18 -4
- package/dist/modules/messages/data/validators.js.map +2 -2
- package/dist/modules/messages/lib/channel-sender-identity.js +26 -0
- package/dist/modules/messages/lib/channel-sender-identity.js.map +7 -0
- package/dist/modules/messages/lib/composeSourceChannelType.js +41 -0
- package/dist/modules/messages/lib/composeSourceChannelType.js.map +7 -0
- package/dist/modules/warranty_claims/api/assignees/route.js +3 -3
- package/dist/modules/warranty_claims/api/assignees/route.js.map +2 -2
- package/dist/modules/warranty_claims/api/portal/claims/route.js +12 -9
- package/dist/modules/warranty_claims/api/portal/claims/route.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/communicationChannelsFixtures.ts +69 -1
- package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +17 -1
- package/src/modules/communication_channels/api/post/test-seed/route.ts +155 -16
- package/src/modules/communication_channels/commands/ingest-inbound-message.ts +5 -0
- package/src/modules/communication_channels/di.ts +7 -0
- package/src/modules/communication_channels/lib/adapter.ts +14 -0
- package/src/modules/communication_channels/lib/email-capabilities.ts +4 -0
- package/src/modules/communication_channels/lib/outbound-recipient.ts +71 -0
- package/src/modules/communication_channels/lib/resolve-channel-type.ts +99 -0
- package/src/modules/communication_channels/lib/test-seed.ts +64 -4
- package/src/modules/customers/api/people/check-phone/route.ts +75 -10
- package/src/modules/customers/lib/findPeopleByAddresses.ts +8 -6
- package/src/modules/messages/api/openapi.ts +2 -0
- package/src/modules/messages/api/route.ts +31 -2
- package/src/modules/messages/data/validators.ts +43 -4
- package/src/modules/messages/lib/channel-sender-identity.ts +55 -0
- package/src/modules/messages/lib/composeSourceChannelType.ts +95 -0
- package/src/modules/warranty_claims/api/assignees/route.ts +3 -3
- 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
|
-
*
|
|
36
|
-
* - `connect-channel`: connect a network-free
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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', [
|
|
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:
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
76
|
-
|
|
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))
|
|
139
|
-
|
|
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
|
}
|