@open-mercato/core 0.9.0 → 0.9.1-develop.7459.1.99e3accd30

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 (243) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/customer_dictionary_entry/index.js +2 -0
  3. package/dist/generated/entities/customer_dictionary_entry/index.js.map +2 -2
  4. package/dist/generated/entities/customer_interaction/index.js +2 -0
  5. package/dist/generated/entities/customer_interaction/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +2 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/catalog/commands/categories.js +1 -1
  9. package/dist/modules/catalog/commands/categories.js.map +2 -2
  10. package/dist/modules/catalog/commands/productDeleteChildren.js +389 -0
  11. package/dist/modules/catalog/commands/productDeleteChildren.js.map +7 -0
  12. package/dist/modules/catalog/commands/products.js +52 -17
  13. package/dist/modules/catalog/commands/products.js.map +2 -2
  14. package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js +4 -6
  15. package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js.map +2 -2
  16. package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js +4 -6
  17. package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js.map +2 -2
  18. package/dist/modules/communication_channels/api/post/oauth/[provider]/initiate/route.js +47 -11
  19. package/dist/modules/communication_channels/api/post/oauth/[provider]/initiate/route.js.map +3 -3
  20. package/dist/modules/communication_channels/commands/deliver-outbound-message.js +5 -1
  21. package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
  22. package/dist/modules/communication_channels/lib/inbound-message-origin.js +1 -1
  23. package/dist/modules/communication_channels/lib/inbound-message-origin.js.map +2 -2
  24. package/dist/modules/communication_channels/lib/oauth-state.js +4 -1
  25. package/dist/modules/communication_channels/lib/oauth-state.js.map +2 -2
  26. package/dist/modules/communication_channels/lib/use-connect-channel.js +10 -0
  27. package/dist/modules/communication_channels/lib/use-connect-channel.js.map +2 -2
  28. package/dist/modules/customers/api/activity-types/route.js +151 -0
  29. package/dist/modules/customers/api/activity-types/route.js.map +7 -0
  30. package/dist/modules/customers/api/deals/[id]/people/route.js +58 -5
  31. package/dist/modules/customers/api/deals/[id]/people/route.js.map +2 -2
  32. package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js +13 -5
  33. package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js.map +2 -2
  34. package/dist/modules/customers/api/dictionaries/[kind]/route.js +20 -5
  35. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  36. package/dist/modules/customers/api/interactions/route.js +3 -0
  37. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  38. package/dist/modules/customers/api/interactions/tasks/route.js +1 -0
  39. package/dist/modules/customers/api/interactions/tasks/route.js.map +2 -2
  40. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js +18 -0
  41. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js.map +2 -2
  42. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js +0 -23
  43. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js.map +2 -2
  44. package/dist/modules/customers/backend/customers/deals/[id]/page.js +15 -17
  45. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  46. package/dist/modules/customers/calendar-event-types.js +548 -0
  47. package/dist/modules/customers/calendar-event-types.js.map +7 -0
  48. package/dist/modules/customers/commands/dictionaries.js +70 -10
  49. package/dist/modules/customers/commands/dictionaries.js.map +2 -2
  50. package/dist/modules/customers/commands/interactions.js +187 -22
  51. package/dist/modules/customers/commands/interactions.js.map +3 -3
  52. package/dist/modules/customers/components/ActivityTypeEditor.js +288 -0
  53. package/dist/modules/customers/components/ActivityTypeEditor.js.map +7 -0
  54. package/dist/modules/customers/components/DictionarySettings.js +3 -1
  55. package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
  56. package/dist/modules/customers/components/calendar/CalendarEventEditor.js +391 -297
  57. package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +3 -3
  58. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +9 -74
  59. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
  60. package/dist/modules/customers/components/calendar/TimeGrid.js +20 -16
  61. package/dist/modules/customers/components/calendar/TimeGrid.js.map +2 -2
  62. package/dist/modules/customers/components/calendar/editor/EventTypePanel.js +218 -0
  63. package/dist/modules/customers/components/calendar/editor/EventTypePanel.js.map +7 -0
  64. package/dist/modules/customers/components/calendar/editor/LocationField.js +7 -3
  65. package/dist/modules/customers/components/calendar/editor/LocationField.js.map +2 -2
  66. package/dist/modules/customers/components/calendar/editor/PeopleField.js +5 -2
  67. package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
  68. package/dist/modules/customers/components/calendar/editor/PriorityField.js +3 -1
  69. package/dist/modules/customers/components/calendar/editor/PriorityField.js.map +2 -2
  70. package/dist/modules/customers/components/calendar/editor/RepeatField.js +9 -1
  71. package/dist/modules/customers/components/calendar/editor/RepeatField.js.map +2 -2
  72. package/dist/modules/customers/components/calendar/editor/ResourcesField.js +4 -1
  73. package/dist/modules/customers/components/calendar/editor/ResourcesField.js.map +2 -2
  74. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js +15 -7
  75. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js.map +2 -2
  76. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js +3 -1
  77. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js.map +2 -2
  78. package/dist/modules/customers/components/calendar/editor/hooks.js +28 -13
  79. package/dist/modules/customers/components/calendar/editor/hooks.js.map +3 -3
  80. package/dist/modules/customers/components/calendar/editor/inputs.js +14 -6
  81. package/dist/modules/customers/components/calendar/editor/inputs.js.map +2 -2
  82. package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js +54 -0
  83. package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js.map +7 -0
  84. package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js +82 -0
  85. package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js.map +7 -0
  86. package/dist/modules/customers/components/calendar/types.js +1 -0
  87. package/dist/modules/customers/components/calendar/types.js.map +2 -2
  88. package/dist/modules/customers/components/detail/CompanyPeopleSection.js +78 -343
  89. package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
  90. package/dist/modules/customers/components/detail/CreatePersonDialog.js +12 -11
  91. package/dist/modules/customers/components/detail/CreatePersonDialog.js.map +2 -2
  92. package/dist/modules/customers/components/detail/DealForm.js +7 -3
  93. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  94. package/dist/modules/customers/components/detail/DealPeopleSection.js +206 -0
  95. package/dist/modules/customers/components/detail/DealPeopleSection.js.map +7 -0
  96. package/dist/modules/customers/components/detail/LinkedPeopleSection.js +342 -0
  97. package/dist/modules/customers/components/detail/LinkedPeopleSection.js.map +7 -0
  98. package/dist/modules/customers/components/detail/PersonCard.js +5 -2
  99. package/dist/modules/customers/components/detail/PersonCard.js.map +2 -2
  100. package/dist/modules/customers/components/linking/adapters/personAdapter.js +3 -0
  101. package/dist/modules/customers/components/linking/adapters/personAdapter.js.map +2 -2
  102. package/dist/modules/customers/data/entities.js +6 -0
  103. package/dist/modules/customers/data/entities.js.map +2 -2
  104. package/dist/modules/customers/data/validators.js +39 -10
  105. package/dist/modules/customers/data/validators.js.map +2 -2
  106. package/dist/modules/customers/di.js +20 -1
  107. package/dist/modules/customers/di.js.map +2 -2
  108. package/dist/modules/customers/extension-points.js +13 -0
  109. package/dist/modules/customers/extension-points.js.map +2 -2
  110. package/dist/modules/customers/lib/calendar/conflicts.js +59 -16
  111. package/dist/modules/customers/lib/calendar/conflicts.js.map +2 -2
  112. package/dist/modules/customers/lib/calendar/editorPayload.js +34 -16
  113. package/dist/modules/customers/lib/calendar/editorPayload.js.map +2 -2
  114. package/dist/modules/customers/lib/calendar/eventTypeResolver.js +197 -0
  115. package/dist/modules/customers/lib/calendar/eventTypeResolver.js.map +7 -0
  116. package/dist/modules/customers/lib/calendar/interactionApplicability.js +67 -0
  117. package/dist/modules/customers/lib/calendar/interactionApplicability.js.map +7 -0
  118. package/dist/modules/customers/lib/calendar/mapItem.js +5 -2
  119. package/dist/modules/customers/lib/calendar/mapItem.js.map +2 -2
  120. package/dist/modules/customers/lib/calendar/recurrence.js +62 -1
  121. package/dist/modules/customers/lib/calendar/recurrence.js.map +2 -2
  122. package/dist/modules/customers/lib/calendar/timezone.js +111 -0
  123. package/dist/modules/customers/lib/calendar/timezone.js.map +7 -0
  124. package/dist/modules/customers/lib/interactionReadModel.js +1 -0
  125. package/dist/modules/customers/lib/interactionReadModel.js.map +2 -2
  126. package/dist/modules/customers/lib/todoCompatibility.js +1 -0
  127. package/dist/modules/customers/lib/todoCompatibility.js.map +2 -2
  128. package/dist/modules/customers/migrations/Migration20260928175058_customers.js +17 -0
  129. package/dist/modules/customers/migrations/Migration20260928175058_customers.js.map +7 -0
  130. package/dist/modules/customers/migrations/Migration20260930110235_customers.js +17 -0
  131. package/dist/modules/customers/migrations/Migration20260930110235_customers.js.map +7 -0
  132. package/dist/modules/dictionaries/components/DictionaryTable.js +60 -51
  133. package/dist/modules/dictionaries/components/DictionaryTable.js.map +2 -2
  134. package/dist/modules/messages/subscribers/message-notification.js +13 -1
  135. package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
  136. package/dist/modules/payment_gateways/api/webhook/[provider]/route.js +201 -53
  137. package/dist/modules/payment_gateways/api/webhook/[provider]/route.js.map +2 -2
  138. package/dist/modules/payment_gateways/lib/gateway-service.js +37 -11
  139. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  140. package/dist/modules/planner/lib/availabilityMerge.js +101 -38
  141. package/dist/modules/planner/lib/availabilityMerge.js.map +3 -3
  142. package/dist/modules/planner/services/plannerAvailabilityService.js.map +2 -2
  143. package/dist/modules/sales/components/documents/LineItemDialog.js +159 -81
  144. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  145. package/dist/modules/sales/components/documents/lineItemUtils.js +7 -0
  146. package/dist/modules/sales/components/documents/lineItemUtils.js.map +2 -2
  147. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +12 -7
  148. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  149. package/dist/modules/sync_excel/lib/adapters/customers.js +28 -25
  150. package/dist/modules/sync_excel/lib/adapters/customers.js.map +2 -2
  151. package/generated/entities/customer_dictionary_entry/index.ts +1 -0
  152. package/generated/entities/customer_interaction/index.ts +1 -0
  153. package/generated/entity-fields-registry.ts +2 -0
  154. package/package.json +9 -8
  155. package/src/modules/auth/i18n/de.json +3 -0
  156. package/src/modules/auth/i18n/en.json +3 -0
  157. package/src/modules/auth/i18n/es.json +3 -0
  158. package/src/modules/auth/i18n/ko.json +3 -0
  159. package/src/modules/auth/i18n/pl.json +3 -0
  160. package/src/modules/catalog/commands/categories.ts +1 -1
  161. package/src/modules/catalog/commands/productDeleteChildren.ts +557 -0
  162. package/src/modules/catalog/commands/products.ts +58 -17
  163. package/src/modules/communication_channels/api/post/channels/connect/credentials/route.ts +4 -6
  164. package/src/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.ts +4 -6
  165. package/src/modules/communication_channels/api/post/oauth/[provider]/initiate/route.ts +46 -10
  166. package/src/modules/communication_channels/commands/deliver-outbound-message.ts +6 -1
  167. package/src/modules/communication_channels/i18n/de.json +4 -0
  168. package/src/modules/communication_channels/i18n/en.json +4 -0
  169. package/src/modules/communication_channels/i18n/es.json +4 -0
  170. package/src/modules/communication_channels/i18n/ko.json +4 -0
  171. package/src/modules/communication_channels/i18n/pl.json +4 -0
  172. package/src/modules/communication_channels/lib/inbound-message-origin.ts +2 -6
  173. package/src/modules/communication_channels/lib/oauth-state.ts +4 -1
  174. package/src/modules/communication_channels/lib/use-connect-channel.ts +10 -0
  175. package/src/modules/customers/api/activity-types/route.ts +154 -0
  176. package/src/modules/customers/api/deals/[id]/people/route.ts +82 -3
  177. package/src/modules/customers/api/dictionaries/[kind]/[id]/route.ts +9 -1
  178. package/src/modules/customers/api/dictionaries/[kind]/route.ts +18 -2
  179. package/src/modules/customers/api/interactions/route.ts +4 -0
  180. package/src/modules/customers/api/interactions/tasks/route.ts +1 -0
  181. package/src/modules/customers/backend/config/customers/pipeline-stages/page.meta.ts +20 -0
  182. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.ts +0 -29
  183. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +15 -17
  184. package/src/modules/customers/calendar-event-types.ts +674 -0
  185. package/src/modules/customers/commands/dictionaries.ts +84 -8
  186. package/src/modules/customers/commands/interactions.ts +244 -21
  187. package/src/modules/customers/components/ActivityTypeEditor.tsx +239 -0
  188. package/src/modules/customers/components/DictionarySettings.tsx +5 -3
  189. package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +347 -218
  190. package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +18 -96
  191. package/src/modules/customers/components/calendar/TimeGrid.tsx +20 -18
  192. package/src/modules/customers/components/calendar/editor/EventTypePanel.tsx +230 -0
  193. package/src/modules/customers/components/calendar/editor/LocationField.tsx +7 -1
  194. package/src/modules/customers/components/calendar/editor/PeopleField.tsx +4 -0
  195. package/src/modules/customers/components/calendar/editor/PriorityField.tsx +3 -0
  196. package/src/modules/customers/components/calendar/editor/RepeatField.tsx +10 -0
  197. package/src/modules/customers/components/calendar/editor/ResourcesField.tsx +4 -0
  198. package/src/modules/customers/components/calendar/editor/ScheduleSection.tsx +16 -4
  199. package/src/modules/customers/components/calendar/editor/SegmentGroup.tsx +3 -0
  200. package/src/modules/customers/components/calendar/editor/hooks.ts +29 -13
  201. package/src/modules/customers/components/calendar/editor/inputs.tsx +17 -5
  202. package/src/modules/customers/components/calendar/editor/useCalendarCustomFields.ts +49 -0
  203. package/src/modules/customers/components/calendar/editor/useEventTypeCatalog.ts +100 -0
  204. package/src/modules/customers/components/calendar/types.ts +1 -0
  205. package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +94 -388
  206. package/src/modules/customers/components/detail/CreatePersonDialog.tsx +39 -19
  207. package/src/modules/customers/components/detail/DealForm.tsx +7 -3
  208. package/src/modules/customers/components/detail/DealPeopleSection.tsx +256 -0
  209. package/src/modules/customers/components/detail/LinkedPeopleSection.tsx +474 -0
  210. package/src/modules/customers/components/detail/PersonCard.tsx +12 -4
  211. package/src/modules/customers/components/detail/types.ts +1 -0
  212. package/src/modules/customers/components/linking/adapters/personAdapter.tsx +4 -0
  213. package/src/modules/customers/data/entities.ts +8 -1
  214. package/src/modules/customers/data/validators.ts +42 -7
  215. package/src/modules/customers/di.ts +21 -0
  216. package/src/modules/customers/extension-points.ts +13 -0
  217. package/src/modules/customers/i18n/de.json +104 -8
  218. package/src/modules/customers/i18n/en.json +104 -8
  219. package/src/modules/customers/i18n/es.json +104 -8
  220. package/src/modules/customers/i18n/ko.json +104 -8
  221. package/src/modules/customers/i18n/pl.json +104 -8
  222. package/src/modules/customers/lib/calendar/conflicts.ts +106 -27
  223. package/src/modules/customers/lib/calendar/editorPayload.ts +39 -19
  224. package/src/modules/customers/lib/calendar/eventTypeResolver.ts +258 -0
  225. package/src/modules/customers/lib/calendar/interactionApplicability.ts +99 -0
  226. package/src/modules/customers/lib/calendar/mapItem.ts +6 -2
  227. package/src/modules/customers/lib/calendar/recurrence.ts +65 -1
  228. package/src/modules/customers/lib/calendar/timezone.ts +117 -0
  229. package/src/modules/customers/lib/interactionReadModel.ts +1 -0
  230. package/src/modules/customers/lib/todoCompatibility.ts +2 -0
  231. package/src/modules/customers/migrations/.snapshot-open-mercato.json +34 -0
  232. package/src/modules/customers/migrations/Migration20260928175058_customers.ts +15 -0
  233. package/src/modules/customers/migrations/Migration20260930110235_customers.ts +15 -0
  234. package/src/modules/dictionaries/components/DictionaryTable.tsx +63 -56
  235. package/src/modules/messages/subscribers/message-notification.ts +13 -3
  236. package/src/modules/payment_gateways/api/webhook/[provider]/route.ts +243 -62
  237. package/src/modules/payment_gateways/lib/gateway-service.ts +39 -12
  238. package/src/modules/planner/lib/availabilityMerge.ts +106 -41
  239. package/src/modules/planner/services/plannerAvailabilityService.ts +4 -0
  240. package/src/modules/sales/components/documents/LineItemDialog.tsx +194 -111
  241. package/src/modules/sales/components/documents/lineItemUtils.ts +18 -0
  242. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +25 -13
  243. package/src/modules/sync_excel/lib/adapters/customers.ts +33 -27
@@ -25,16 +25,14 @@ async function POST(req) {
25
25
  if (!auth?.sub || !auth?.tenantId) {
26
26
  return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
27
27
  }
28
- let body;
29
- try {
30
- const json = await readJsonSafe(req, null);
31
- body = bodySchema.parse(json);
32
- } catch (err) {
28
+ const parsed = bodySchema.safeParse(await readJsonSafe(req, null));
29
+ if (!parsed.success) {
33
30
  return NextResponse.json(
34
- { error: err instanceof Error ? err.message : "Invalid request body" },
31
+ { error: "Invalid request body", details: parsed.error.issues },
35
32
  { status: 422 }
36
33
  );
37
34
  }
35
+ const body = parsed.data;
38
36
  const container = await createRequestContainer();
39
37
  const adapterRegistry = container.resolve("channelAdapterRegistry");
40
38
  const earlyAdapter = adapterRegistry.get(body.providerKey);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../../src/modules/communication_channels/api/post/channels/connect/credentials/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport {\n COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID,\n type ConnectCredentialChannelInput,\n type ConnectCredentialChannelResult,\n} from '../../../../../commands/connect-credential-channel'\nimport type { ChannelAdapterRegistry } from '../../../../../lib/registry'\nimport { validateRouteMutationGuard } from '../../../../../lib/route-mutation-guard'\n\nexport const metadata = {\n path: '/communication_channels/channels/connect/credentials',\n POST: {\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\nconst bodySchema = z.object({\n providerKey: z.string().min(1).max(64),\n displayName: z.string().min(1).max(255),\n credentials: z.record(z.string(), z.unknown()),\n pollIntervalSeconds: z.number().int().positive().max(86_400).optional(),\n})\n\nexport async function POST(req: Request): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: z.infer<typeof bodySchema>\n try {\n const json = await readJsonSafe(req, null)\n body = bodySchema.parse(json)\n } catch (err) {\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Invalid request body' },\n { status: 422 },\n )\n }\n\n const container = await createRequestContainer()\n\n // Tenant-scoped providers (push: FCM/APNs/Expo) must go through the admin-gated\n // tenant route, not this per-user one (granted to manager/employee via\n // `connect_user_channel`). Reject them here so a non-admin can't mint a\n // privileged tenant-wide channel. The command enforces the same invariant\n // (`wrong_scope_for_route`) as defense-in-depth.\n const adapterRegistry = container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const earlyAdapter = adapterRegistry.get(body.providerKey)\n if (earlyAdapter && earlyAdapter.channelScope === 'tenant') {\n return NextResponse.json(\n {\n error: 'This provider is connected tenant-wide by an administrator, not per-user.',\n code: 'provider_is_tenant_scoped',\n },\n { status: 403 },\n )\n }\n\n const guard = await validateRouteMutationGuard({\n container,\n req,\n auth,\n input: {\n resourceKind: 'communication_channels.channel',\n resourceId: `new:${body.providerKey}`,\n operation: 'create',\n mutationPayload: {\n providerKey: body.providerKey,\n displayName: body.displayName,\n pollIntervalSeconds: body.pollIntervalSeconds,\n },\n },\n })\n if ('response' in guard) return guard.response\n\n const commandBus = container.resolve('commandBus') as CommandBus\n\n const input: ConnectCredentialChannelInput = {\n providerKey: body.providerKey,\n displayName: body.displayName,\n credentials: body.credentials,\n pollIntervalSeconds: body.pollIntervalSeconds,\n userId: auth.sub as string,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId: (auth as { orgId?: string | null }).orgId ?? null,\n },\n }\n const { result } = await commandBus.execute<\n ConnectCredentialChannelInput,\n ConnectCredentialChannelResult\n >(COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID, {\n input,\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: (auth as { orgId?: string | null }).orgId ?? null,\n organizationIds: (auth as { orgId?: string | null }).orgId\n ? [(auth as { orgId?: string | null }).orgId!]\n : null,\n },\n })\n\n if (result.status === 'no_adapter') {\n return NextResponse.json({ error: result.reason }, { status: 404 })\n }\n if (result.status === 'wrong_scope_for_route') {\n return NextResponse.json(\n {\n error: 'This provider is connected tenant-wide by an administrator, not per-user.',\n code: 'provider_is_tenant_scoped',\n },\n { status: 403 },\n )\n }\n if (result.status === 'validation_failed') {\n return NextResponse.json(\n {\n error: 'Credential validation failed',\n fieldErrors: result.errors,\n ...(result.errorCodes ? { fieldErrorCodes: result.errorCodes } : {}),\n },\n { status: 422 },\n )\n }\n if (result.status === 'duplicate_mailbox') {\n return NextResponse.json(\n {\n error: `This mailbox is already connected via \"${result.existingProviderKey}\". Disconnect it there before connecting it again with a different provider.`,\n code: 'mailbox_already_connected',\n },\n { status: 409 },\n )\n }\n await guard.afterSuccess()\n return NextResponse.json(\n {\n channelId: result.channelId,\n externalIdentifier: result.externalIdentifier,\n },\n { status: 201 },\n )\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Connect a credential-based per-user channel (IMAP/SMTP)',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 201, description: 'Channel connected' },\n { status: 401, description: 'Unauthorized' },\n { status: 403, description: 'Provider is tenant-scoped \u2014 use the tenant connect route' },\n { status: 404, description: 'Unknown provider' },\n { status: 409, description: 'Mailbox already connected via another provider' },\n { status: 422, description: 'Invalid body or credential validation failed' },\n ],\n },\n },\n}\nexport default POST\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport {\n COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID,\n type ConnectCredentialChannelInput,\n type ConnectCredentialChannelResult,\n} from '../../../../../commands/connect-credential-channel'\nimport type { ChannelAdapterRegistry } from '../../../../../lib/registry'\nimport { validateRouteMutationGuard } from '../../../../../lib/route-mutation-guard'\n\nexport const metadata = {\n path: '/communication_channels/channels/connect/credentials',\n POST: {\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\nconst bodySchema = z.object({\n providerKey: z.string().min(1).max(64),\n displayName: z.string().min(1).max(255),\n credentials: z.record(z.string(), z.unknown()),\n pollIntervalSeconds: z.number().int().positive().max(86_400).optional(),\n})\n\nexport async function POST(req: Request): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const parsed = bodySchema.safeParse(await readJsonSafe(req, null))\n if (!parsed.success) {\n return NextResponse.json(\n { error: 'Invalid request body', details: parsed.error.issues },\n { status: 422 },\n )\n }\n const body = parsed.data\n\n const container = await createRequestContainer()\n\n // Tenant-scoped providers (push: FCM/APNs/Expo) must go through the admin-gated\n // tenant route, not this per-user one (granted to manager/employee via\n // `connect_user_channel`). Reject them here so a non-admin can't mint a\n // privileged tenant-wide channel. The command enforces the same invariant\n // (`wrong_scope_for_route`) as defense-in-depth.\n const adapterRegistry = container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const earlyAdapter = adapterRegistry.get(body.providerKey)\n if (earlyAdapter && earlyAdapter.channelScope === 'tenant') {\n return NextResponse.json(\n {\n error: 'This provider is connected tenant-wide by an administrator, not per-user.',\n code: 'provider_is_tenant_scoped',\n },\n { status: 403 },\n )\n }\n\n const guard = await validateRouteMutationGuard({\n container,\n req,\n auth,\n input: {\n resourceKind: 'communication_channels.channel',\n resourceId: `new:${body.providerKey}`,\n operation: 'create',\n mutationPayload: {\n providerKey: body.providerKey,\n displayName: body.displayName,\n pollIntervalSeconds: body.pollIntervalSeconds,\n },\n },\n })\n if ('response' in guard) return guard.response\n\n const commandBus = container.resolve('commandBus') as CommandBus\n\n const input: ConnectCredentialChannelInput = {\n providerKey: body.providerKey,\n displayName: body.displayName,\n credentials: body.credentials,\n pollIntervalSeconds: body.pollIntervalSeconds,\n userId: auth.sub as string,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId: (auth as { orgId?: string | null }).orgId ?? null,\n },\n }\n const { result } = await commandBus.execute<\n ConnectCredentialChannelInput,\n ConnectCredentialChannelResult\n >(COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID, {\n input,\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: (auth as { orgId?: string | null }).orgId ?? null,\n organizationIds: (auth as { orgId?: string | null }).orgId\n ? [(auth as { orgId?: string | null }).orgId!]\n : null,\n },\n })\n\n if (result.status === 'no_adapter') {\n return NextResponse.json({ error: result.reason }, { status: 404 })\n }\n if (result.status === 'wrong_scope_for_route') {\n return NextResponse.json(\n {\n error: 'This provider is connected tenant-wide by an administrator, not per-user.',\n code: 'provider_is_tenant_scoped',\n },\n { status: 403 },\n )\n }\n if (result.status === 'validation_failed') {\n return NextResponse.json(\n {\n error: 'Credential validation failed',\n fieldErrors: result.errors,\n ...(result.errorCodes ? { fieldErrorCodes: result.errorCodes } : {}),\n },\n { status: 422 },\n )\n }\n if (result.status === 'duplicate_mailbox') {\n return NextResponse.json(\n {\n error: `This mailbox is already connected via \"${result.existingProviderKey}\". Disconnect it there before connecting it again with a different provider.`,\n code: 'mailbox_already_connected',\n },\n { status: 409 },\n )\n }\n await guard.afterSuccess()\n return NextResponse.json(\n {\n channelId: result.channelId,\n externalIdentifier: result.externalIdentifier,\n },\n { status: 201 },\n )\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Connect a credential-based per-user channel (IMAP/SMTP)',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 201, description: 'Channel connected' },\n { status: 401, description: 'Unauthorized' },\n { status: 403, description: 'Provider is tenant-scoped \u2014 use the tenant connect route' },\n { status: 404, description: 'Unknown provider' },\n { status: 409, description: 'Mailbox already connected via another provider' },\n { status: 422, description: 'Invalid body or credential validation failed' },\n ],\n },\n },\n}\nexport default POST\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -25,16 +25,14 @@ async function POST(req) {
25
25
  if (!auth?.sub || !auth?.tenantId) {
26
26
  return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
27
27
  }
28
- let body;
29
- try {
30
- const json = await readJsonSafe(req, null);
31
- body = bodySchema.parse(json);
32
- } catch (err) {
28
+ const parsed = bodySchema.safeParse(await readJsonSafe(req, null));
29
+ if (!parsed.success) {
33
30
  return NextResponse.json(
34
- { error: err instanceof Error ? err.message : "Invalid request body" },
31
+ { error: "Invalid request body", details: parsed.error.issues },
35
32
  { status: 422 }
36
33
  );
37
34
  }
35
+ const body = parsed.data;
38
36
  const container = await createRequestContainer();
39
37
  const adapterRegistry = container.resolve("channelAdapterRegistry");
40
38
  const adapter = adapterRegistry.get(body.providerKey);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../../src/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport {\n COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID,\n type ConnectCredentialChannelInput,\n type ConnectCredentialChannelResult,\n} from '../../../../../commands/connect-credential-channel'\nimport type { ChannelAdapterRegistry } from '../../../../../lib/registry'\nimport { validateRouteMutationGuard } from '../../../../../lib/route-mutation-guard'\n\nexport const metadata = {\n path: '/communication_channels/channels/connect/tenant-credentials',\n POST: {\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_tenant_channel'],\n },\n}\n\nconst bodySchema = z.object({\n providerKey: z.string().min(1).max(64),\n displayName: z.string().min(1).max(255),\n credentials: z.record(z.string(), z.unknown()),\n pollIntervalSeconds: z.number().int().positive().max(86_400).optional(),\n})\n\nexport async function POST(req: Request): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: z.infer<typeof bodySchema>\n try {\n const json = await readJsonSafe(req, null)\n body = bodySchema.parse(json)\n } catch (err) {\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Invalid request body' },\n { status: 422 },\n )\n }\n\n const container = await createRequestContainer()\n\n // Reject providers that are NOT tenant-scoped so an admin can't force a\n // per-user provider (Gmail/IMAP) into a shared tenant-wide channel. Push\n // providers (FCM/APNs/Expo) declare `channelScope: 'tenant'` on their adapter.\n const adapterRegistry = container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const adapter = adapterRegistry.get(body.providerKey)\n if (!adapter) {\n return NextResponse.json(\n { error: `No adapter registered for provider '${body.providerKey}'` },\n { status: 404 },\n )\n }\n if (adapter.channelScope !== 'tenant') {\n return NextResponse.json(\n {\n error: 'This provider connects per-user channels; use the personal channel connect flow instead.',\n code: 'provider_not_tenant_scoped',\n },\n { status: 400 },\n )\n }\n\n const guard = await validateRouteMutationGuard({\n container,\n req,\n auth,\n input: {\n resourceKind: 'communication_channels.channel',\n resourceId: `new:${body.providerKey}`,\n operation: 'create',\n mutationPayload: {\n providerKey: body.providerKey,\n displayName: body.displayName,\n pollIntervalSeconds: body.pollIntervalSeconds,\n },\n },\n })\n if ('response' in guard) return guard.response\n\n const commandBus = container.resolve('commandBus') as CommandBus\n const orgId = (auth as { orgId?: string | null }).orgId ?? null\n\n const input: ConnectCredentialChannelInput = {\n providerKey: body.providerKey,\n displayName: body.displayName,\n credentials: body.credentials,\n pollIntervalSeconds: body.pollIntervalSeconds,\n // Tenant-wide: no owning user. The command re-derives the effective scope\n // from the adapter, so the channel + credentials are stored with user_id NULL.\n userId: null,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId: orgId,\n },\n }\n const { result } = await commandBus.execute<\n ConnectCredentialChannelInput,\n ConnectCredentialChannelResult\n >(COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID, {\n input,\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: orgId,\n organizationIds: orgId ? [orgId] : null,\n },\n })\n\n if (result.status === 'no_adapter') {\n return NextResponse.json({ error: result.reason }, { status: 404 })\n }\n if (result.status === 'wrong_scope_for_route') {\n // Unreachable in practice \u2014 this route always dispatches with userId: null \u2014\n // but handle it so the result union narrows and the invariant is explicit.\n return NextResponse.json(\n { error: 'Unexpected per-user scope on the tenant connect route.', code: 'wrong_scope_for_route' },\n { status: 500 },\n )\n }\n if (result.status === 'validation_failed') {\n // `fieldErrors` stays English for API consumers and logs; `fieldErrorCodes`\n // lets the connect widget render a localized message per field.\n return NextResponse.json(\n {\n error: 'Credential validation failed',\n fieldErrors: result.errors,\n ...(result.errorCodes ? { fieldErrorCodes: result.errorCodes } : {}),\n },\n { status: 422 },\n )\n }\n if (result.status === 'duplicate_mailbox') {\n return NextResponse.json(\n {\n error: `This mailbox is already connected via \"${result.existingProviderKey}\". Disconnect it there before connecting it again with a different provider.`,\n code: 'mailbox_already_connected',\n },\n { status: 409 },\n )\n }\n await guard.afterSuccess()\n return NextResponse.json(\n {\n channelId: result.channelId,\n externalIdentifier: result.externalIdentifier,\n },\n { status: 201 },\n )\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Connect a tenant-wide credential-based channel (push: FCM/APNs/Expo)',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 201, description: 'Channel connected' },\n { status: 400, description: 'Provider is not tenant-scoped' },\n { status: 401, description: 'Unauthorized' },\n { status: 404, description: 'Unknown provider' },\n { status: 422, description: 'Invalid body or credential validation failed' },\n ],\n },\n },\n}\nexport default POST\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport {\n COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID,\n type ConnectCredentialChannelInput,\n type ConnectCredentialChannelResult,\n} from '../../../../../commands/connect-credential-channel'\nimport type { ChannelAdapterRegistry } from '../../../../../lib/registry'\nimport { validateRouteMutationGuard } from '../../../../../lib/route-mutation-guard'\n\nexport const metadata = {\n path: '/communication_channels/channels/connect/tenant-credentials',\n POST: {\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_tenant_channel'],\n },\n}\n\nconst bodySchema = z.object({\n providerKey: z.string().min(1).max(64),\n displayName: z.string().min(1).max(255),\n credentials: z.record(z.string(), z.unknown()),\n pollIntervalSeconds: z.number().int().positive().max(86_400).optional(),\n})\n\nexport async function POST(req: Request): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const parsed = bodySchema.safeParse(await readJsonSafe(req, null))\n if (!parsed.success) {\n return NextResponse.json(\n { error: 'Invalid request body', details: parsed.error.issues },\n { status: 422 },\n )\n }\n const body = parsed.data\n\n const container = await createRequestContainer()\n\n // Reject providers that are NOT tenant-scoped so an admin can't force a\n // per-user provider (Gmail/IMAP) into a shared tenant-wide channel. Push\n // providers (FCM/APNs/Expo) declare `channelScope: 'tenant'` on their adapter.\n const adapterRegistry = container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const adapter = adapterRegistry.get(body.providerKey)\n if (!adapter) {\n return NextResponse.json(\n { error: `No adapter registered for provider '${body.providerKey}'` },\n { status: 404 },\n )\n }\n if (adapter.channelScope !== 'tenant') {\n return NextResponse.json(\n {\n error: 'This provider connects per-user channels; use the personal channel connect flow instead.',\n code: 'provider_not_tenant_scoped',\n },\n { status: 400 },\n )\n }\n\n const guard = await validateRouteMutationGuard({\n container,\n req,\n auth,\n input: {\n resourceKind: 'communication_channels.channel',\n resourceId: `new:${body.providerKey}`,\n operation: 'create',\n mutationPayload: {\n providerKey: body.providerKey,\n displayName: body.displayName,\n pollIntervalSeconds: body.pollIntervalSeconds,\n },\n },\n })\n if ('response' in guard) return guard.response\n\n const commandBus = container.resolve('commandBus') as CommandBus\n const orgId = (auth as { orgId?: string | null }).orgId ?? null\n\n const input: ConnectCredentialChannelInput = {\n providerKey: body.providerKey,\n displayName: body.displayName,\n credentials: body.credentials,\n pollIntervalSeconds: body.pollIntervalSeconds,\n // Tenant-wide: no owning user. The command re-derives the effective scope\n // from the adapter, so the channel + credentials are stored with user_id NULL.\n userId: null,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId: orgId,\n },\n }\n const { result } = await commandBus.execute<\n ConnectCredentialChannelInput,\n ConnectCredentialChannelResult\n >(COMMUNICATION_CHANNELS_CONNECT_CREDENTIAL_CHANNEL_COMMAND_ID, {\n input,\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: orgId,\n organizationIds: orgId ? [orgId] : null,\n },\n })\n\n if (result.status === 'no_adapter') {\n return NextResponse.json({ error: result.reason }, { status: 404 })\n }\n if (result.status === 'wrong_scope_for_route') {\n // Unreachable in practice \u2014 this route always dispatches with userId: null \u2014\n // but handle it so the result union narrows and the invariant is explicit.\n return NextResponse.json(\n { error: 'Unexpected per-user scope on the tenant connect route.', code: 'wrong_scope_for_route' },\n { status: 500 },\n )\n }\n if (result.status === 'validation_failed') {\n // `fieldErrors` stays English for API consumers and logs; `fieldErrorCodes`\n // lets the connect widget render a localized message per field.\n return NextResponse.json(\n {\n error: 'Credential validation failed',\n fieldErrors: result.errors,\n ...(result.errorCodes ? { fieldErrorCodes: result.errorCodes } : {}),\n },\n { status: 422 },\n )\n }\n if (result.status === 'duplicate_mailbox') {\n return NextResponse.json(\n {\n error: `This mailbox is already connected via \"${result.existingProviderKey}\". Disconnect it there before connecting it again with a different provider.`,\n code: 'mailbox_already_connected',\n },\n { status: 409 },\n )\n }\n await guard.afterSuccess()\n return NextResponse.json(\n {\n channelId: result.channelId,\n externalIdentifier: result.externalIdentifier,\n },\n { status: 201 },\n )\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Connect a tenant-wide credential-based channel (push: FCM/APNs/Expo)',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 201, description: 'Channel connected' },\n { status: 400, description: 'Provider is not tenant-scoped' },\n { status: 401, description: 'Unauthorized' },\n { status: 404, description: 'Unknown provider' },\n { status: 422, description: 'Invalid body or credential validation failed' },\n ],\n },\n },\n}\nexport default POST\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -4,6 +4,8 @@ import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
4
4
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
5
5
  import { readJsonSafe } from "@open-mercato/shared/lib/http/readJsonSafe";
6
6
  import { toAbsoluteUrl } from "@open-mercato/shared/lib/url";
7
+ import { createLogger } from "@open-mercato/shared/lib/logger";
8
+ import { getTelemetryRuntime } from "@open-mercato/shared/lib/telemetry/runtime";
7
9
  import { getChannelAdapter } from "../../../../../lib/adapter-registry-singleton.js";
8
10
  import { resolveOAuthClientCredentials } from "../../../../../lib/oauth-client-config.js";
9
11
  import {
@@ -15,6 +17,7 @@ import {
15
17
  normalizeOAuthReturnUrl,
16
18
  OAuthStateError
17
19
  } from "../../../../../lib/oauth-state.js";
20
+ const logger = createLogger("communication_channels").child({ component: "oauth-initiate" });
18
21
  const metadata = {
19
22
  path: "/communication_channels/oauth/[provider]/initiate",
20
23
  POST: {
@@ -34,6 +37,20 @@ const initiateBodySchema = z.object({
34
37
  function defaultRedirectUri(req, providerKey) {
35
38
  return toAbsoluteUrl(req, `/api/communication_channels/oauth/${providerKey}/callback`);
36
39
  }
40
+ function oauthStateErrorResponse(err) {
41
+ if (err instanceof OAuthStateError) {
42
+ if (err.code === "missing_secret") {
43
+ logger.error("OAuth state secret is not configured", { err });
44
+ getTelemetryRuntime()?.reportError(err, {
45
+ module: "communication_channels",
46
+ code: "communication_channels.missing_secret"
47
+ });
48
+ return NextResponse.json({ error: "OAuth is not available", code: err.code }, { status: 500 });
49
+ }
50
+ return NextResponse.json({ error: err.message, code: err.code }, { status: 500 });
51
+ }
52
+ return null;
53
+ }
37
54
  async function POST(req, context) {
38
55
  const { provider } = await context.params;
39
56
  if (!/^[a-z0-9_-]+$/i.test(provider)) {
@@ -88,13 +105,20 @@ async function POST(req, context) {
88
105
  );
89
106
  }
90
107
  const redirectUri = defaultRedirectUri(req, provider);
91
- const stateEnvelope = createOAuthState({
92
- userId: auth.sub,
93
- tenantId: auth.tenantId,
94
- organizationId: auth.orgId ?? null,
95
- providerKey: provider,
96
- returnUrl: normalizeOAuthReturnUrl(body.returnUrl, DEFAULT_OAUTH_RETURN_URL)
97
- });
108
+ let stateEnvelope;
109
+ try {
110
+ stateEnvelope = createOAuthState({
111
+ userId: auth.sub,
112
+ tenantId: auth.tenantId,
113
+ organizationId: auth.orgId ?? null,
114
+ providerKey: provider,
115
+ returnUrl: normalizeOAuthReturnUrl(body.returnUrl, DEFAULT_OAUTH_RETURN_URL)
116
+ });
117
+ } catch (err) {
118
+ const response2 = oauthStateErrorResponse(err);
119
+ if (response2) return response2;
120
+ throw err;
121
+ }
98
122
  let result;
99
123
  try {
100
124
  result = await adapter.buildOAuthAuthorizeUrl({
@@ -115,10 +139,21 @@ async function POST(req, context) {
115
139
  const message = err instanceof Error ? err.message : "Failed to build OAuth authorize URL";
116
140
  return NextResponse.json({ error: message }, { status: 502 });
117
141
  }
118
- const finalCookie = result.extra ? (await import("../../../../../lib/oauth-state.js")).encryptOAuthState({
119
- ...stateEnvelope.payload,
120
- extra: { ...stateEnvelope.payload.extra ?? {}, ...result.extra }
121
- }) : stateEnvelope.cookie;
142
+ let finalCookie;
143
+ if (result.extra) {
144
+ try {
145
+ finalCookie = (await import("../../../../../lib/oauth-state.js")).encryptOAuthState({
146
+ ...stateEnvelope.payload,
147
+ extra: { ...stateEnvelope.payload.extra ?? {}, ...result.extra }
148
+ });
149
+ } catch (err) {
150
+ const response2 = oauthStateErrorResponse(err);
151
+ if (response2) return response2;
152
+ throw err;
153
+ }
154
+ } else {
155
+ finalCookie = stateEnvelope.cookie;
156
+ }
122
157
  const response = NextResponse.json({ authorizeUrl: result.authorizeUrl });
123
158
  response.cookies.set({
124
159
  name: COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME,
@@ -142,6 +177,7 @@ const openApi = {
142
177
  { status: 400, description: "Invalid provider or unsupported (no OAuth)" },
143
178
  { status: 401, description: "Unauthorized" },
144
179
  { status: 422, description: "Invalid request body" },
180
+ { status: 500, description: "OAuth state secret missing or state encryption failed" },
145
181
  { status: 502, description: "Adapter failed to build authorize URL" }
146
182
  ]
147
183
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../../src/modules/communication_channels/api/post/oauth/%5Bprovider%5D/initiate/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { toAbsoluteUrl } from '@open-mercato/shared/lib/url'\nimport { getChannelAdapter } from '../../../../../lib/adapter-registry-singleton'\nimport { resolveOAuthClientCredentials } from '../../../../../lib/oauth-client-config'\nimport {\n COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME,\n COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS,\n createOAuthState,\n DEFAULT_OAUTH_RETURN_URL,\n isSafeOAuthReturnUrl,\n normalizeOAuthReturnUrl,\n OAuthStateError,\n} from '../../../../../lib/oauth-state'\n\nexport const metadata = {\n path: '/communication_channels/oauth/[provider]/initiate',\n POST: {\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\nconst initiateBodySchema = z.object({\n channelType: z.literal('email').optional(),\n /** Where to send the user after the callback succeeds. Defaults to the profile page. */\n returnUrl: z.string().min(1).max(2048).refine(isSafeOAuthReturnUrl, {\n message: 'returnUrl must be a same-origin path',\n }).optional(),\n /** Optional pre-filled email \u2014 Google `login_hint`. */\n loginHint: z.string().email().optional(),\n})\n\ntype RouteContext = {\n params: Promise<{ provider: string }> | { provider: string }\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string },\n ) => Promise<Record<string, unknown> | null>\n}\n\n/**\n * Default profile redirect \u2014 matches the per-user channels page registered by\n * slice 3d (the `/backend/profile/communication-channels` route).\n */\nfunction defaultRedirectUri(req: Request, providerKey: string): string {\n // Derive the origin from the configured app URL / forwarded headers rather\n // than the raw request origin, so the redirect_uri matches the value the\n // provider has registered even when the app sits behind a reverse proxy.\n return toAbsoluteUrl(req, `/api/communication_channels/oauth/${providerKey}/callback`)\n}\n\nexport async function POST(req: Request, context: RouteContext): Promise<Response> {\n const { provider } = await context.params\n if (!/^[a-z0-9_-]+$/i.test(provider)) {\n return NextResponse.json({ error: 'Invalid provider' }, { status: 400 })\n }\n\n const adapter = getChannelAdapter(provider)\n if (!adapter) {\n return NextResponse.json(\n { error: `No ChannelAdapter for provider: ${provider}` },\n { status: 404 },\n )\n }\n if (typeof adapter.buildOAuthAuthorizeUrl !== 'function') {\n return NextResponse.json(\n { error: `Provider '${provider}' does not support OAuth (no buildOAuthAuthorizeUrl)` },\n { status: 400 },\n )\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: z.infer<typeof initiateBodySchema>\n try {\n const json = await readJsonSafe(req, {})\n body = initiateBodySchema.parse(json ?? {})\n } catch (err) {\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Invalid request body' },\n { status: 422 },\n )\n }\n\n const container = await createRequestContainer()\n const credentialsService = (() => {\n try {\n return container.resolve('integrationCredentialsService') as CredentialsServiceLike\n } catch {\n return null\n }\n })()\n // OAuth client credentials are tenant-level \u2014 the admin configures them under\n // the `channel_<provider>` integration in the Integrations UI (stored at\n // userId = null). A missing row means the provider has not been set up yet;\n // we surface that as an actionable error instead of handing an empty object to\n // the adapter (which would throw a cryptic Zod \"expected string\" message).\n const credentials = await resolveOAuthClientCredentials(credentialsService, provider, {\n tenantId: auth.tenantId as string,\n organizationId: (auth as { orgId?: string | null }).orgId ?? null,\n })\n if (!credentials) {\n return NextResponse.json(\n {\n error:\n `${provider} is not configured for this workspace yet. An administrator must add the OAuth Client ID and Secret under Integrations before mailboxes can be connected.`,\n code: 'oauth_client_not_configured',\n },\n { status: 409 },\n )\n }\n\n const redirectUri = defaultRedirectUri(req, provider)\n const stateEnvelope = createOAuthState({\n userId: auth.sub as string,\n tenantId: auth.tenantId as string,\n organizationId: (auth as { orgId?: string | null }).orgId ?? null,\n providerKey: provider,\n returnUrl: normalizeOAuthReturnUrl(body.returnUrl, DEFAULT_OAUTH_RETURN_URL),\n })\n\n let result\n try {\n result = await adapter.buildOAuthAuthorizeUrl({\n state: stateEnvelope.stateParam,\n nonce: stateEnvelope.payload.nonce,\n redirectUri,\n credentials,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId:\n (auth as { orgId?: string | null }).orgId ?? (auth.tenantId as string),\n },\n loginHint: body.loginHint,\n })\n } catch (err) {\n if (err instanceof OAuthStateError) {\n return NextResponse.json({ error: err.message, code: err.code }, { status: 500 })\n }\n const message = err instanceof Error ? err.message : 'Failed to build OAuth authorize URL'\n return NextResponse.json({ error: message }, { status: 502 })\n }\n\n // If the adapter packed extras (PKCE verifier, scopes), bake them into the\n // state cookie now so the callback handler can pass them to exchangeOAuthCode.\n const finalCookie = result.extra\n ? (await import('../../../../../lib/oauth-state')).encryptOAuthState({\n ...stateEnvelope.payload,\n extra: { ...(stateEnvelope.payload.extra ?? {}), ...result.extra },\n })\n : stateEnvelope.cookie\n\n const response = NextResponse.json({ authorizeUrl: result.authorizeUrl })\n response.cookies.set({\n name: COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME,\n value: finalCookie,\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n path: '/',\n maxAge: Math.floor(COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS / 1000),\n })\n return response\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Start a per-user channel OAuth flow',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Authorize URL + state cookie set' },\n { status: 400, description: 'Invalid provider or unsupported (no OAuth)' },\n { status: 401, description: 'Unauthorized' },\n { status: 422, description: 'Invalid request body' },\n { status: 502, description: 'Adapter failed to build authorize URL' },\n ],\n },\n },\n}\nexport default POST\n"],
5
- "mappings": "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",
6
- "names": []
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { toAbsoluteUrl } from '@open-mercato/shared/lib/url'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'\nimport { getChannelAdapter } from '../../../../../lib/adapter-registry-singleton'\nimport { resolveOAuthClientCredentials } from '../../../../../lib/oauth-client-config'\nimport {\n COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME,\n COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS,\n createOAuthState,\n DEFAULT_OAUTH_RETURN_URL,\n isSafeOAuthReturnUrl,\n normalizeOAuthReturnUrl,\n OAuthStateError,\n} from '../../../../../lib/oauth-state'\n\nconst logger = createLogger('communication_channels').child({ component: 'oauth-initiate' })\n\nexport const metadata = {\n path: '/communication_channels/oauth/[provider]/initiate',\n POST: {\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\nconst initiateBodySchema = z.object({\n channelType: z.literal('email').optional(),\n /** Where to send the user after the callback succeeds. Defaults to the profile page. */\n returnUrl: z.string().min(1).max(2048).refine(isSafeOAuthReturnUrl, {\n message: 'returnUrl must be a same-origin path',\n }).optional(),\n /** Optional pre-filled email \u2014 Google `login_hint`. */\n loginHint: z.string().email().optional(),\n})\n\ntype RouteContext = {\n params: Promise<{ provider: string }> | { provider: string }\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string },\n ) => Promise<Record<string, unknown> | null>\n}\n\n/**\n * Default profile redirect \u2014 matches the per-user channels page registered by\n * slice 3d (the `/backend/profile/communication-channels` route).\n */\nfunction defaultRedirectUri(req: Request, providerKey: string): string {\n // Derive the origin from the configured app URL / forwarded headers rather\n // than the raw request origin, so the redirect_uri matches the value the\n // provider has registered even when the app sits behind a reverse proxy.\n return toAbsoluteUrl(req, `/api/communication_channels/oauth/${providerKey}/callback`)\n}\n\nfunction oauthStateErrorResponse(err: unknown): Response | null {\n if (err instanceof OAuthStateError) {\n if (err.code === 'missing_secret') {\n logger.error('OAuth state secret is not configured', { err })\n getTelemetryRuntime()?.reportError(err, {\n module: 'communication_channels',\n code: 'communication_channels.missing_secret',\n })\n return NextResponse.json({ error: 'OAuth is not available', code: err.code }, { status: 500 })\n }\n return NextResponse.json({ error: err.message, code: err.code }, { status: 500 })\n }\n return null\n}\n\nexport async function POST(req: Request, context: RouteContext): Promise<Response> {\n const { provider } = await context.params\n if (!/^[a-z0-9_-]+$/i.test(provider)) {\n return NextResponse.json({ error: 'Invalid provider' }, { status: 400 })\n }\n\n const adapter = getChannelAdapter(provider)\n if (!adapter) {\n return NextResponse.json(\n { error: `No ChannelAdapter for provider: ${provider}` },\n { status: 404 },\n )\n }\n if (typeof adapter.buildOAuthAuthorizeUrl !== 'function') {\n return NextResponse.json(\n { error: `Provider '${provider}' does not support OAuth (no buildOAuthAuthorizeUrl)` },\n { status: 400 },\n )\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: z.infer<typeof initiateBodySchema>\n try {\n const json = await readJsonSafe(req, {})\n body = initiateBodySchema.parse(json ?? {})\n } catch (err) {\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Invalid request body' },\n { status: 422 },\n )\n }\n\n const container = await createRequestContainer()\n const credentialsService = (() => {\n try {\n return container.resolve('integrationCredentialsService') as CredentialsServiceLike\n } catch {\n return null\n }\n })()\n // OAuth client credentials are tenant-level \u2014 the admin configures them under\n // the `channel_<provider>` integration in the Integrations UI (stored at\n // userId = null). A missing row means the provider has not been set up yet;\n // we surface that as an actionable error instead of handing an empty object to\n // the adapter (which would throw a cryptic Zod \"expected string\" message).\n const credentials = await resolveOAuthClientCredentials(credentialsService, provider, {\n tenantId: auth.tenantId as string,\n organizationId: (auth as { orgId?: string | null }).orgId ?? null,\n })\n if (!credentials) {\n return NextResponse.json(\n {\n error:\n `${provider} is not configured for this workspace yet. An administrator must add the OAuth Client ID and Secret under Integrations before mailboxes can be connected.`,\n code: 'oauth_client_not_configured',\n },\n { status: 409 },\n )\n }\n\n const redirectUri = defaultRedirectUri(req, provider)\n let stateEnvelope: ReturnType<typeof createOAuthState>\n try {\n stateEnvelope = createOAuthState({\n userId: auth.sub as string,\n tenantId: auth.tenantId as string,\n organizationId: (auth as { orgId?: string | null }).orgId ?? null,\n providerKey: provider,\n returnUrl: normalizeOAuthReturnUrl(body.returnUrl, DEFAULT_OAUTH_RETURN_URL),\n })\n } catch (err) {\n const response = oauthStateErrorResponse(err)\n if (response) return response\n throw err\n }\n\n let result\n try {\n result = await adapter.buildOAuthAuthorizeUrl({\n state: stateEnvelope.stateParam,\n nonce: stateEnvelope.payload.nonce,\n redirectUri,\n credentials,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId:\n (auth as { orgId?: string | null }).orgId ?? (auth.tenantId as string),\n },\n loginHint: body.loginHint,\n })\n } catch (err) {\n if (err instanceof OAuthStateError) {\n return NextResponse.json({ error: err.message, code: err.code }, { status: 500 })\n }\n const message = err instanceof Error ? err.message : 'Failed to build OAuth authorize URL'\n return NextResponse.json({ error: message }, { status: 502 })\n }\n\n // If the adapter packed extras (PKCE verifier, scopes), bake them into the\n // state cookie now so the callback handler can pass them to exchangeOAuthCode.\n let finalCookie: string\n if (result.extra) {\n try {\n finalCookie = (await import('../../../../../lib/oauth-state')).encryptOAuthState({\n ...stateEnvelope.payload,\n extra: { ...(stateEnvelope.payload.extra ?? {}), ...result.extra },\n })\n } catch (err) {\n const response = oauthStateErrorResponse(err)\n if (response) return response\n throw err\n }\n } else {\n finalCookie = stateEnvelope.cookie\n }\n\n const response = NextResponse.json({ authorizeUrl: result.authorizeUrl })\n response.cookies.set({\n name: COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME,\n value: finalCookie,\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n path: '/',\n maxAge: Math.floor(COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS / 1000),\n })\n return response\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Start a per-user channel OAuth flow',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Authorize URL + state cookie set' },\n { status: 400, description: 'Invalid provider or unsupported (no OAuth)' },\n { status: 401, description: 'Unauthorized' },\n { status: 422, description: 'Invalid request body' },\n { status: 500, description: 'OAuth state secret missing or state encryption failed' },\n { status: 502, description: 'Adapter failed to build authorize URL' },\n ],\n },\n },\n}\nexport default POST\n"],
5
+ "mappings": "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",
6
+ "names": ["response"]
7
7
  }
@@ -36,6 +36,10 @@ const deliverInputSchema = z.object({
36
36
  forceCredentialRefresh: z.boolean().optional()
37
37
  });
38
38
  const COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID = "communication_channels.message.deliver_outbound";
39
+ function omitRawMessage(metadata) {
40
+ if (!metadata) return {};
41
+ return Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== "rawMessage"));
42
+ }
39
43
  const deliverOutboundMessageCommand = {
40
44
  id: COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID,
41
45
  async execute(rawInput, ctx) {
@@ -311,7 +315,7 @@ const deliverOutboundMessageCommand = {
311
315
  link.externalMessageId = externalMessageRowId;
312
316
  link.channelMetadata = {
313
317
  ...link.channelMetadata ?? {},
314
- ...converted.metadata ?? {},
318
+ ...omitRawMessage(converted.metadata),
315
319
  externalMessageId: sendResult.externalMessageId,
316
320
  // Always persist the RFC2822 Message-ID so inbound reply matching (JWZ
317
321
  // strategy in lib/thread-matcher) and sent-folder dedup can resolve this
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/commands/deliver-outbound-message.ts"],
4
- "sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { emitCommunicationChannelsEvent } from '../events'\nimport { refreshCredentialsIfNeeded } from '../lib/credential-refresh'\nimport { classifyOutboundError, isReauthError } from '../lib/error-classification'\nimport {\n buildBodyFooter,\n buildReferencesId,\n getOrCreateThreadToken,\n} from '../lib/thread-token'\nimport { stringOrUndefined, stripBrackets } from '../lib/email-mime'\nimport { resolveOutboundReplyExternalId, stripCallerReplyTargeting } from '../lib/outbound-reply-ref'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport { isUniqueViolation } from '../lib/pg-errors'\nimport { Message } from '../../messages/data/entities'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalMessage,\n MessageChannelLink,\n} from '../data/entities'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst moduleLogger = createLogger('communication_channels').child({ component: 'deliver-outbound-message' })\n\n/**\n * Sentinel \u2014 `Message.threadId` of an internal-only (no channel link) message\n * has no matching `ChannelThreadMapping`. In that case outbound delivery is a no-op.\n */\nconst NO_THREAD_MAPPING_RESULT = { status: 'no_channel_link' as const }\n\nconst deliverInputSchema = z.object({\n messageId: z.string().uuid(),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n /**\n * If true, force a credential refresh before sending \u2014 used by retry attempts\n * after a 401 from the provider.\n */\n forceCredentialRefresh: z.boolean().optional(),\n})\n\nexport type DeliverOutboundMessageInput = z.infer<typeof deliverInputSchema>\n\nexport type DeliverOutboundMessageResult =\n | { status: 'no_channel_link' }\n | { status: 'already_delivered'; messageId: string; channelLinkId: string }\n | {\n status: 'delivered'\n messageId: string\n channelLinkId: string\n externalMessageId: string\n providerKey: string\n }\n | {\n status: 'failed'\n messageId: string\n channelLinkId: string\n providerKey: string\n error: string\n transient: boolean\n /**\n * True when the failure was a 401 / invalid_grant \u2014 the channel was\n * flipped to `requires_reauth`. The worker uses this to attempt one\n * forced credential refresh before giving up.\n */\n requiresReauth: boolean\n }\n\nexport const COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID =\n 'communication_channels.message.deliver_outbound'\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\ntype IntegrationLogLike = {\n log?: (entry: Record<string, unknown>) => Promise<void> | void\n warn?: (entry: Record<string, unknown>) => Promise<void> | void\n error?: (entry: Record<string, unknown>) => Promise<void> | void\n}\n\n/**\n * Outbound delivery command. Called from the outbound worker.\n *\n * Steps (SPEC-045d \u00A77):\n * 1. Re-fetch the Message by ID. Bail if internal-only (no ChannelThreadMapping).\n * 2. Resolve channel + adapter + credentials.\n * 3. Idempotently upsert a 'pending' MessageChannelLink (unique on `messageId`).\n * Skip if a 'sent'/'delivered' link already exists.\n * 4. Refresh credentials when OAuth + near expiry (or when caller forces it).\n * 5. Call `adapter.convertOutbound(...)` \u2192 channel-native content.\n * 6. Call `adapter.sendMessage(...)`.\n * 7. On success: persist ExternalMessage + flip link to 'sent', emit `.message.sent`.\n * 8. On failure: flip link to 'failed' + classify error, log to integrationLog,\n * emit `.delivery_failed`. The worker decides whether to retry based on\n * `result.transient`.\n *\n * Idempotency: the unique constraint on `message_channel_links.message_id`\n * prevents the same Message being sent twice through the channel even if the\n * subscriber fires repeatedly. Combined with the link's lifecycle state\n * (pending \u2192 sent | failed), we get safe retries.\n */\nconst deliverOutboundMessageCommand: CommandHandler<\n DeliverOutboundMessageInput,\n DeliverOutboundMessageResult\n> = {\n id: COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID,\n async execute(rawInput, ctx) {\n const input = deliverInputSchema.parse(rawInput) as DeliverOutboundMessageInput\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n }\n\n // (1) Re-fetch Message by ID \u2014 never trust the event payload shape.\n const message = await findOneWithDecryption(\n em,\n Message,\n {\n id: input.messageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!message) {\n // Message was deleted before we got to deliver. Treat as no-op.\n return NO_THREAD_MAPPING_RESULT\n }\n if (!message.threadId) {\n // Message has no thread \u2192 no channel routing.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (1 cont.) Look up the channel link via ChannelThreadMapping.threadId.\n const mapping = await findOneWithDecryption(\n em,\n ChannelThreadMapping,\n {\n messageThreadId: message.threadId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (!mapping) {\n // Internal-only message \u2014 no channel delivery needed.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (2) Channel + adapter.\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: mapping.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n throw new Error(\n `[internal] Channel ${mapping.channelId} not found for tenant ${input.scope.tenantId} (or has been deleted)`,\n )\n }\n if (!channel.isActive) {\n throw new Error(`[internal] Channel ${mapping.channelId} is inactive; refusing to deliver outbound`)\n }\n\n const adapterRegistry = ctx.container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const adapter = adapterRegistry.get(channel.providerKey)\n if (!adapter) {\n throw new Error(\n `[internal] No ChannelAdapter registered for providerKey '${channel.providerKey}'. ` +\n 'Check that the provider package is enabled in modules.ts.',\n )\n }\n\n // (3) Idempotently upsert a 'pending' MessageChannelLink.\n let link = await findOneWithDecryption(\n em,\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (\n link &&\n (link.deliveryStatus === 'queued' ||\n link.deliveryStatus === 'sent' ||\n link.deliveryStatus === 'delivered' ||\n link.deliveryStatus === 'read')\n ) {\n // Already sent (or accepted by the provider as 'queued') \u2014 short-circuit\n // so a retried job does not re-invoke the adapter and double-send.\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: link.id,\n }\n }\n if (!link) {\n link = em.create(MessageChannelLink, {\n messageId: message.id,\n externalConversationId: mapping.externalConversationId,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n deliveryStatus: 'pending',\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(link)\n try {\n await em.flush()\n } catch (flushErr) {\n // Concurrency guard: the link lookup above is not atomic with this\n // insert, so two deliveries of the same message (a replayed\n // `messages.message.sent`, or an overlapping worker retry) can both\n // reach here. The `message_channel_links_message_uq` index rejects the\n // loser with a 23505. Defer to the winning job \u2014 re-read its link on a\n // fresh fork and report `already_delivered` \u2014 instead of re-invoking the\n // adapter (double send) or letting the raw error dead-letter the job.\n if (isUniqueViolation(flushErr)) {\n const winner = await findOneWithDecryption(\n em.fork(),\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (winner) {\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: winner.id,\n }\n }\n }\n throw flushErr\n }\n }\n\n // (2 cont.) Decrypted credentials via the integrations module (if available).\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.container.resolve(\n 'integrationCredentialsService',\n ) as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n // Per-user credentials scope: pass `channel.userId` so the credentials\n // service returns this user's row, not whoever connected last. See\n // review R2-C1 / N1 (2026-05-26).\n //\n // Key the org on the CHANNEL's own org, not the message's org. Tenant-wide\n // channels store `organization_id = NULL` and their credentials live at\n // `organization_id = tenantId` (see connect-credential-channel.ts), so\n // `channel.organizationId ?? tenantId` matches the write key for both\n // org-scoped and tenant-wide channels. Keying on the message org would\n // resolve `{}` the moment a tenant-wide provider delivers from a message\n // that carries a non-null org.\n const credentialsScope = {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n userId: channel.userId ?? null,\n }\n let credentials: Record<string, unknown> = {}\n if (channel.credentialsRef && credentialsService) {\n try {\n credentials =\n (await credentialsService.resolve(`channel_${channel.providerKey}`, credentialsScope)) ?? {}\n } catch {\n credentials = {}\n }\n }\n\n // (4) Credential refresh if OAuth + near expiry, or forced by retry.\n let integrationLog: IntegrationLogLike | null = null\n try {\n integrationLog = ctx.container.resolve('integrationLogService') as IntegrationLogLike\n } catch {\n integrationLog = null\n }\n const refreshResult = await refreshCredentialsIfNeeded(\n {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialsScope,\n force: Boolean(input.forceCredentialRefresh),\n },\n {\n credentialsService,\n logger: (...args) => moduleLogger.warn('credential refresh diagnostic', { details: args }),\n },\n )\n credentials = refreshResult.credentials\n\n // (4b) Spec B \u2014 get-or-create the per-thread crypto token and inject it\n // into the outbound payload BEFORE the adapter converts it. The token\n // travels as both a `References` header (via channelMetadata.references)\n // and a hidden body marker so inbound replies can be threaded back to\n // this conversation even when the recipient's MUA strips RFC 5322\n // headers. Idempotent on retry \u2014 same token reused per thread.\n let threadToken: string | null = null\n try {\n const { token } = await getOrCreateThreadToken(em, {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n messageThreadId: mapping.messageThreadId,\n })\n threadToken = token\n } catch (tokenErr) {\n // Token creation should never block a send \u2014 if it fails, the message\n // still goes out, just without our thread-token attachment point.\n // Threading falls back to the JWZ strategy via Message-Id headers.\n moduleLogger.warn('thread token unavailable, proceeding without it', { err: tokenErr })\n }\n\n // (4c) Chat-provider reply threading \u2014 resolve the provider-native id of the\n // message this one answers so a threading adapter can attach it (Discord's\n // `message_reference`). Email adapters ignore this and keep threading on the\n // `inReplyTo` / `references` headers built above. Best-effort like the thread\n // token: an unresolvable parent sends an unthreaded reply rather than failing\n // a delivery.\n let replyToExternalId: string | null = null\n try {\n replyToExternalId = await resolveOutboundReplyExternalId(em, {\n parentMessageId: message.parentMessageId ?? null,\n externalConversationId: mapping.externalConversationId,\n capabilities: adapter.capabilities,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n })\n } catch (replyRefErr) {\n moduleLogger.warn('outbound reply reference unavailable, sending unthreaded', {\n err: replyRefErr,\n })\n }\n if (!replyToExternalId && message.parentMessageId && adapter.capabilities?.threading === true) {\n // The provider threads and the caller asked for a reply, yet nothing\n // resolved \u2014 the parent never reached this channel, or it belongs to\n // another conversation. Delivery is unaffected, but say so: an unthreaded\n // reply is otherwise indistinguishable from a non-reply, which is exactly\n // the silent-unreachability shape #5541 was filed about.\n moduleLogger.debug('reply parent has no external id on this channel, sending unthreaded', {\n messageId: message.id,\n parentMessageId: message.parentMessageId,\n conversationId: mapping.externalConversationId,\n })\n }\n\n // (5) + (6) Convert + send.\n try {\n const outboundPayload = (link.channelPayload as Record<string, unknown> | null) ?? {}\n const outboundHtml = typeof outboundPayload.html === 'string' ? outboundPayload.html : null\n const outboundText = typeof outboundPayload.text === 'string' ? outboundPayload.text : null\n let outboundBody = outboundHtml ?? outboundText ?? message.body ?? ''\n const outboundBodyFormat = outboundHtml\n ? 'html'\n : ((message.bodyFormat as 'text' | 'markdown' | 'html') ?? 'text')\n\n // Pre-existing channelMetadata.references (string[]) so we can extend\n // it with the synthetic thread-token id without disturbing other refs\n // (e.g. the recipient's own reply chain).\n const baseMetadata = (link.channelMetadata as Record<string, unknown> | undefined) ?? {}\n const existingRefs = Array.isArray(baseMetadata.references)\n ? (baseMetadata.references as unknown[]).filter(\n (value): value is string => typeof value === 'string',\n )\n : []\n let mergedReferences = existingRefs\n if (threadToken && !outboundBody.includes(`[OM:${threadToken}]`)) {\n // Append the body footer for the corresponding format. The hidden\n // HTML span is `display:none`; the plain-text trailer is a small\n // bracketed marker on its own line. Both survive most reply clients.\n const footer = buildBodyFooter(threadToken)\n if (outboundBodyFormat === 'html') {\n const closingBody = outboundBody.lastIndexOf('</body>')\n outboundBody =\n closingBody >= 0\n ? `${outboundBody.slice(0, closingBody)}${footer.html}${outboundBody.slice(closingBody)}`\n : `${outboundBody}${footer.html}`\n } else {\n outboundBody = `${outboundBody}${footer.plain}`\n }\n const refId = buildReferencesId(threadToken)\n if (!mergedReferences.includes(refId)) {\n mergedReferences = [...mergedReferences, refId]\n }\n }\n const converted = await adapter.convertOutbound({\n body: outboundBody,\n bodyFormat: outboundBodyFormat,\n channelMetadata: {\n thread_id: mapping.externalThreadRef,\n // Reply targeting is hub-resolved only. `send-as-user` merges\n // caller-supplied metadata onto the link, so every reply-targeting key\n // is stripped off the stored metadata here rather than merely\n // out-ranked below: merge order settles the contest only when the hub\n // resolved a parent, and the uncontested case \u2014 no `parentMessageId`,\n // or a parent that does not resolve \u2014 is exactly the one a caller\n // controls.\n ...stripCallerReplyTargeting(baseMetadata),\n references: mergedReferences,\n ...(threadToken ? { omThreadToken: threadToken } : {}),\n ...(replyToExternalId ? { replyToExternalId } : {}),\n },\n })\n\n // NOTE \u2014 at-least-once delivery (accepted v1 semantics). This provider\n // send is a non-transactional external side effect. The terminal-status\n // short-circuit above and the `message_channel_links_message_uq` index\n // prevent duplicate link records and re-sends after a *completed*\n // delivery, but if the process crashes in the narrow window between this\n // call returning and the success flush below, the link stays `pending`\n // and a worker retry re-invokes the adapter \u2014 the recipient may receive a\n // duplicate. This is deliberate: email providers (Gmail/SMTP) expose no\n // idempotent-send key nor a reliable \"did message X send?\" query, so\n // re-sending is preferred over risking a dropped message.\n const sendResult = await adapter.sendMessage({\n conversationId: mapping.externalThreadRef,\n content: converted.content,\n credentials,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n },\n metadata: converted.metadata,\n })\n\n if (sendResult.status === 'failed') {\n throw new Error(sendResult.error ?? `Adapter '${adapter.providerKey}' reported send failure`)\n }\n\n // (7) Persist success records.\n //\n // Pre-generate the ExternalMessage PK client-side. The PK uses\n // `defaultRaw: 'gen_random_uuid()'`, so `externalMessage.id` is undefined\n // until after the INSERT returns \u2014 writing `link.externalMessageId =\n // externalMessage.id` before the flush would silently persist NULL on the\n // link's FK. Mirrors the inbound ingest path (ingest-inbound-message.ts).\n const externalMessageRowId = randomUUID()\n const externalMessage = em.create(ExternalMessage, {\n id: externalMessageRowId,\n channelId: channel.id,\n conversationId: mapping.externalConversationId,\n externalMessageId: sendResult.externalMessageId,\n direction: 'outbound',\n senderIdentifier: null,\n senderDisplayName: null,\n providerTimestamp: new Date(),\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(externalMessage)\n\n // A successful send proves the credentials are valid \u2014 clear any prior\n // `requires_reauth` / `error` state so a recovered channel doesn't keep\n // showing a stale reconnect banner (e.g. after a forced-refresh retry).\n if (channel.status === 'requires_reauth' || channel.status === 'error') {\n channel.status = 'connected'\n }\n\n link.deliveryStatus = sendResult.status === 'sent' ? 'sent' : 'queued'\n link.externalMessageId = externalMessageRowId\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n ...(converted.metadata ?? {}),\n externalMessageId: sendResult.externalMessageId,\n // Always persist the RFC2822 Message-ID so inbound reply matching (JWZ\n // strategy in lib/thread-matcher) and sent-folder dedup can resolve this\n // outbound message. Adapters that build the message themselves (Gmail)\n // return it in `converted.metadata.messageId`; IMAP/SMTP lets\n // the transport mint it, surfacing it only as `sendResult.externalMessageId`\n // (the RFC2822 id the recipient replies to) \u2014 fall back to that.\n // Store it bracket-stripped to match the inbound convention\n // (`normalizeMimeInbound` strips), so the JWZ matcher and sent-folder dedup \u2014\n // which compare against stripped ids \u2014 resolve it. `assembleRfc2822`\n // re-applies brackets when this id is later used to build reply headers.\n messageId: stripBrackets(\n stringOrUndefined((converted.metadata as Record<string, unknown> | undefined)?.messageId) ??\n sendResult.externalMessageId,\n ),\n }\n await em.flush()\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.sent',\n {\n messageId: message.id,\n externalMessageId: externalMessage.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n // #6095: same contract as the inbound path \u2014 the send time travels on\n // the event so consumers (the customers timeline) date their rows\n // from it instead of reading the ExternalMessage row across the\n // module boundary. ISO string because this event is persistent.\n providerTimestamp:\n externalMessage.providerTimestamp instanceof Date\n ? externalMessage.providerTimestamp.toISOString()\n : null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'delivered',\n messageId: message.id,\n channelLinkId: link.id,\n externalMessageId: externalMessage.id,\n providerKey: channel.providerKey,\n }\n } catch (sendErr) {\n // (8) Failure path \u2014 classify, persist, emit, return.\n const classification = classifyOutboundError(sendErr)\n const requiresReauth = isReauthError(classification)\n link.deliveryStatus = 'failed'\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n lastError: classification.message,\n lastErrorAt: new Date().toISOString(),\n transient: classification.transient,\n requiresReauth,\n }\n // A 401 / invalid_grant means the stored credentials are dead and no\n // retry will help \u2014 flip the channel to `requires_reauth` (mirrors the\n // inbound poll path) so the operator gets a reconnect signal instead of\n // silently-failing sends. A later successful send self-heals back to\n // `connected` (see the success path above).\n if (requiresReauth) {\n channel.status = 'requires_reauth'\n }\n await em.flush()\n\n try {\n await integrationLog?.error?.({\n integrationId: `channel_${channel.providerKey}`,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n channelId: channel.id,\n messageId: message.id,\n status: classification.status ?? null,\n transient: classification.transient,\n message: classification.message,\n })\n } catch {\n // best-effort logging\n }\n\n if (requiresReauth) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.channel.requires_reauth',\n {\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n reason: classification.message,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.delivery_failed',\n {\n messageId: message.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n transient: classification.transient,\n error: classification.message,\n status: classification.status ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'failed',\n messageId: message.id,\n channelLinkId: link.id,\n providerKey: channel.providerKey,\n error: classification.message,\n transient: classification.transient,\n requiresReauth,\n }\n }\n },\n}\n\nregisterCommand(deliverOutboundMessageCommand)\n\nexport default deliverOutboundMessageCommand\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { emitCommunicationChannelsEvent } from '../events'\nimport { refreshCredentialsIfNeeded } from '../lib/credential-refresh'\nimport { classifyOutboundError, isReauthError } from '../lib/error-classification'\nimport {\n buildBodyFooter,\n buildReferencesId,\n getOrCreateThreadToken,\n} from '../lib/thread-token'\nimport { stringOrUndefined, stripBrackets } from '../lib/email-mime'\nimport { resolveOutboundReplyExternalId, stripCallerReplyTargeting } from '../lib/outbound-reply-ref'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport { isUniqueViolation } from '../lib/pg-errors'\nimport { Message } from '../../messages/data/entities'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalMessage,\n MessageChannelLink,\n} from '../data/entities'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst moduleLogger = createLogger('communication_channels').child({ component: 'deliver-outbound-message' })\n\n/**\n * Sentinel \u2014 `Message.threadId` of an internal-only (no channel link) message\n * has no matching `ChannelThreadMapping`. In that case outbound delivery is a no-op.\n */\nconst NO_THREAD_MAPPING_RESULT = { status: 'no_channel_link' as const }\n\nconst deliverInputSchema = z.object({\n messageId: z.string().uuid(),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n /**\n * If true, force a credential refresh before sending \u2014 used by retry attempts\n * after a 401 from the provider.\n */\n forceCredentialRefresh: z.boolean().optional(),\n})\n\nexport type DeliverOutboundMessageInput = z.infer<typeof deliverInputSchema>\n\nexport type DeliverOutboundMessageResult =\n | { status: 'no_channel_link' }\n | { status: 'already_delivered'; messageId: string; channelLinkId: string }\n | {\n status: 'delivered'\n messageId: string\n channelLinkId: string\n externalMessageId: string\n providerKey: string\n }\n | {\n status: 'failed'\n messageId: string\n channelLinkId: string\n providerKey: string\n error: string\n transient: boolean\n /**\n * True when the failure was a 401 / invalid_grant \u2014 the channel was\n * flipped to `requires_reauth`. The worker uses this to attempt one\n * forced credential refresh before giving up.\n */\n requiresReauth: boolean\n }\n\nexport const COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID =\n 'communication_channels.message.deliver_outbound'\n\nfunction omitRawMessage(metadata: Record<string, unknown> | undefined): Record<string, unknown> {\n if (!metadata) return {}\n return Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== 'rawMessage'))\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\ntype IntegrationLogLike = {\n log?: (entry: Record<string, unknown>) => Promise<void> | void\n warn?: (entry: Record<string, unknown>) => Promise<void> | void\n error?: (entry: Record<string, unknown>) => Promise<void> | void\n}\n\n/**\n * Outbound delivery command. Called from the outbound worker.\n *\n * Steps (SPEC-045d \u00A77):\n * 1. Re-fetch the Message by ID. Bail if internal-only (no ChannelThreadMapping).\n * 2. Resolve channel + adapter + credentials.\n * 3. Idempotently upsert a 'pending' MessageChannelLink (unique on `messageId`).\n * Skip if a 'sent'/'delivered' link already exists.\n * 4. Refresh credentials when OAuth + near expiry (or when caller forces it).\n * 5. Call `adapter.convertOutbound(...)` \u2192 channel-native content.\n * 6. Call `adapter.sendMessage(...)`.\n * 7. On success: persist ExternalMessage + flip link to 'sent', emit `.message.sent`.\n * 8. On failure: flip link to 'failed' + classify error, log to integrationLog,\n * emit `.delivery_failed`. The worker decides whether to retry based on\n * `result.transient`.\n *\n * Idempotency: the unique constraint on `message_channel_links.message_id`\n * prevents the same Message being sent twice through the channel even if the\n * subscriber fires repeatedly. Combined with the link's lifecycle state\n * (pending \u2192 sent | failed), we get safe retries.\n */\nconst deliverOutboundMessageCommand: CommandHandler<\n DeliverOutboundMessageInput,\n DeliverOutboundMessageResult\n> = {\n id: COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID,\n async execute(rawInput, ctx) {\n const input = deliverInputSchema.parse(rawInput) as DeliverOutboundMessageInput\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n }\n\n // (1) Re-fetch Message by ID \u2014 never trust the event payload shape.\n const message = await findOneWithDecryption(\n em,\n Message,\n {\n id: input.messageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!message) {\n // Message was deleted before we got to deliver. Treat as no-op.\n return NO_THREAD_MAPPING_RESULT\n }\n if (!message.threadId) {\n // Message has no thread \u2192 no channel routing.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (1 cont.) Look up the channel link via ChannelThreadMapping.threadId.\n const mapping = await findOneWithDecryption(\n em,\n ChannelThreadMapping,\n {\n messageThreadId: message.threadId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (!mapping) {\n // Internal-only message \u2014 no channel delivery needed.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (2) Channel + adapter.\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: mapping.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n throw new Error(\n `[internal] Channel ${mapping.channelId} not found for tenant ${input.scope.tenantId} (or has been deleted)`,\n )\n }\n if (!channel.isActive) {\n throw new Error(`[internal] Channel ${mapping.channelId} is inactive; refusing to deliver outbound`)\n }\n\n const adapterRegistry = ctx.container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const adapter = adapterRegistry.get(channel.providerKey)\n if (!adapter) {\n throw new Error(\n `[internal] No ChannelAdapter registered for providerKey '${channel.providerKey}'. ` +\n 'Check that the provider package is enabled in modules.ts.',\n )\n }\n\n // (3) Idempotently upsert a 'pending' MessageChannelLink.\n let link = await findOneWithDecryption(\n em,\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (\n link &&\n (link.deliveryStatus === 'queued' ||\n link.deliveryStatus === 'sent' ||\n link.deliveryStatus === 'delivered' ||\n link.deliveryStatus === 'read')\n ) {\n // Already sent (or accepted by the provider as 'queued') \u2014 short-circuit\n // so a retried job does not re-invoke the adapter and double-send.\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: link.id,\n }\n }\n if (!link) {\n link = em.create(MessageChannelLink, {\n messageId: message.id,\n externalConversationId: mapping.externalConversationId,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n deliveryStatus: 'pending',\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(link)\n try {\n await em.flush()\n } catch (flushErr) {\n // Concurrency guard: the link lookup above is not atomic with this\n // insert, so two deliveries of the same message (a replayed\n // `messages.message.sent`, or an overlapping worker retry) can both\n // reach here. The `message_channel_links_message_uq` index rejects the\n // loser with a 23505. Defer to the winning job \u2014 re-read its link on a\n // fresh fork and report `already_delivered` \u2014 instead of re-invoking the\n // adapter (double send) or letting the raw error dead-letter the job.\n if (isUniqueViolation(flushErr)) {\n const winner = await findOneWithDecryption(\n em.fork(),\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (winner) {\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: winner.id,\n }\n }\n }\n throw flushErr\n }\n }\n\n // (2 cont.) Decrypted credentials via the integrations module (if available).\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.container.resolve(\n 'integrationCredentialsService',\n ) as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n // Per-user credentials scope: pass `channel.userId` so the credentials\n // service returns this user's row, not whoever connected last. See\n // review R2-C1 / N1 (2026-05-26).\n //\n // Key the org on the CHANNEL's own org, not the message's org. Tenant-wide\n // channels store `organization_id = NULL` and their credentials live at\n // `organization_id = tenantId` (see connect-credential-channel.ts), so\n // `channel.organizationId ?? tenantId` matches the write key for both\n // org-scoped and tenant-wide channels. Keying on the message org would\n // resolve `{}` the moment a tenant-wide provider delivers from a message\n // that carries a non-null org.\n const credentialsScope = {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n userId: channel.userId ?? null,\n }\n let credentials: Record<string, unknown> = {}\n if (channel.credentialsRef && credentialsService) {\n try {\n credentials =\n (await credentialsService.resolve(`channel_${channel.providerKey}`, credentialsScope)) ?? {}\n } catch {\n credentials = {}\n }\n }\n\n // (4) Credential refresh if OAuth + near expiry, or forced by retry.\n let integrationLog: IntegrationLogLike | null = null\n try {\n integrationLog = ctx.container.resolve('integrationLogService') as IntegrationLogLike\n } catch {\n integrationLog = null\n }\n const refreshResult = await refreshCredentialsIfNeeded(\n {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialsScope,\n force: Boolean(input.forceCredentialRefresh),\n },\n {\n credentialsService,\n logger: (...args) => moduleLogger.warn('credential refresh diagnostic', { details: args }),\n },\n )\n credentials = refreshResult.credentials\n\n // (4b) Spec B \u2014 get-or-create the per-thread crypto token and inject it\n // into the outbound payload BEFORE the adapter converts it. The token\n // travels as both a `References` header (via channelMetadata.references)\n // and a hidden body marker so inbound replies can be threaded back to\n // this conversation even when the recipient's MUA strips RFC 5322\n // headers. Idempotent on retry \u2014 same token reused per thread.\n let threadToken: string | null = null\n try {\n const { token } = await getOrCreateThreadToken(em, {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n messageThreadId: mapping.messageThreadId,\n })\n threadToken = token\n } catch (tokenErr) {\n // Token creation should never block a send \u2014 if it fails, the message\n // still goes out, just without our thread-token attachment point.\n // Threading falls back to the JWZ strategy via Message-Id headers.\n moduleLogger.warn('thread token unavailable, proceeding without it', { err: tokenErr })\n }\n\n // (4c) Chat-provider reply threading \u2014 resolve the provider-native id of the\n // message this one answers so a threading adapter can attach it (Discord's\n // `message_reference`). Email adapters ignore this and keep threading on the\n // `inReplyTo` / `references` headers built above. Best-effort like the thread\n // token: an unresolvable parent sends an unthreaded reply rather than failing\n // a delivery.\n let replyToExternalId: string | null = null\n try {\n replyToExternalId = await resolveOutboundReplyExternalId(em, {\n parentMessageId: message.parentMessageId ?? null,\n externalConversationId: mapping.externalConversationId,\n capabilities: adapter.capabilities,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n })\n } catch (replyRefErr) {\n moduleLogger.warn('outbound reply reference unavailable, sending unthreaded', {\n err: replyRefErr,\n })\n }\n if (!replyToExternalId && message.parentMessageId && adapter.capabilities?.threading === true) {\n // The provider threads and the caller asked for a reply, yet nothing\n // resolved \u2014 the parent never reached this channel, or it belongs to\n // another conversation. Delivery is unaffected, but say so: an unthreaded\n // reply is otherwise indistinguishable from a non-reply, which is exactly\n // the silent-unreachability shape #5541 was filed about.\n moduleLogger.debug('reply parent has no external id on this channel, sending unthreaded', {\n messageId: message.id,\n parentMessageId: message.parentMessageId,\n conversationId: mapping.externalConversationId,\n })\n }\n\n // (5) + (6) Convert + send.\n try {\n const outboundPayload = (link.channelPayload as Record<string, unknown> | null) ?? {}\n const outboundHtml = typeof outboundPayload.html === 'string' ? outboundPayload.html : null\n const outboundText = typeof outboundPayload.text === 'string' ? outboundPayload.text : null\n let outboundBody = outboundHtml ?? outboundText ?? message.body ?? ''\n const outboundBodyFormat = outboundHtml\n ? 'html'\n : ((message.bodyFormat as 'text' | 'markdown' | 'html') ?? 'text')\n\n // Pre-existing channelMetadata.references (string[]) so we can extend\n // it with the synthetic thread-token id without disturbing other refs\n // (e.g. the recipient's own reply chain).\n const baseMetadata = (link.channelMetadata as Record<string, unknown> | undefined) ?? {}\n const existingRefs = Array.isArray(baseMetadata.references)\n ? (baseMetadata.references as unknown[]).filter(\n (value): value is string => typeof value === 'string',\n )\n : []\n let mergedReferences = existingRefs\n if (threadToken && !outboundBody.includes(`[OM:${threadToken}]`)) {\n // Append the body footer for the corresponding format. The hidden\n // HTML span is `display:none`; the plain-text trailer is a small\n // bracketed marker on its own line. Both survive most reply clients.\n const footer = buildBodyFooter(threadToken)\n if (outboundBodyFormat === 'html') {\n const closingBody = outboundBody.lastIndexOf('</body>')\n outboundBody =\n closingBody >= 0\n ? `${outboundBody.slice(0, closingBody)}${footer.html}${outboundBody.slice(closingBody)}`\n : `${outboundBody}${footer.html}`\n } else {\n outboundBody = `${outboundBody}${footer.plain}`\n }\n const refId = buildReferencesId(threadToken)\n if (!mergedReferences.includes(refId)) {\n mergedReferences = [...mergedReferences, refId]\n }\n }\n const converted = await adapter.convertOutbound({\n body: outboundBody,\n bodyFormat: outboundBodyFormat,\n channelMetadata: {\n thread_id: mapping.externalThreadRef,\n // Reply targeting is hub-resolved only. `send-as-user` merges\n // caller-supplied metadata onto the link, so every reply-targeting key\n // is stripped off the stored metadata here rather than merely\n // out-ranked below: merge order settles the contest only when the hub\n // resolved a parent, and the uncontested case \u2014 no `parentMessageId`,\n // or a parent that does not resolve \u2014 is exactly the one a caller\n // controls.\n ...stripCallerReplyTargeting(baseMetadata),\n references: mergedReferences,\n ...(threadToken ? { omThreadToken: threadToken } : {}),\n ...(replyToExternalId ? { replyToExternalId } : {}),\n },\n })\n\n // NOTE \u2014 at-least-once delivery (accepted v1 semantics). This provider\n // send is a non-transactional external side effect. The terminal-status\n // short-circuit above and the `message_channel_links_message_uq` index\n // prevent duplicate link records and re-sends after a *completed*\n // delivery, but if the process crashes in the narrow window between this\n // call returning and the success flush below, the link stays `pending`\n // and a worker retry re-invokes the adapter \u2014 the recipient may receive a\n // duplicate. This is deliberate: email providers (Gmail/SMTP) expose no\n // idempotent-send key nor a reliable \"did message X send?\" query, so\n // re-sending is preferred over risking a dropped message.\n const sendResult = await adapter.sendMessage({\n conversationId: mapping.externalThreadRef,\n content: converted.content,\n credentials,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n },\n metadata: converted.metadata,\n })\n\n if (sendResult.status === 'failed') {\n throw new Error(sendResult.error ?? `Adapter '${adapter.providerKey}' reported send failure`)\n }\n\n // (7) Persist success records.\n //\n // Pre-generate the ExternalMessage PK client-side. The PK uses\n // `defaultRaw: 'gen_random_uuid()'`, so `externalMessage.id` is undefined\n // until after the INSERT returns \u2014 writing `link.externalMessageId =\n // externalMessage.id` before the flush would silently persist NULL on the\n // link's FK. Mirrors the inbound ingest path (ingest-inbound-message.ts).\n const externalMessageRowId = randomUUID()\n const externalMessage = em.create(ExternalMessage, {\n id: externalMessageRowId,\n channelId: channel.id,\n conversationId: mapping.externalConversationId,\n externalMessageId: sendResult.externalMessageId,\n direction: 'outbound',\n senderIdentifier: null,\n senderDisplayName: null,\n providerTimestamp: new Date(),\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(externalMessage)\n\n // A successful send proves the credentials are valid \u2014 clear any prior\n // `requires_reauth` / `error` state so a recovered channel doesn't keep\n // showing a stale reconnect banner (e.g. after a forced-refresh retry).\n if (channel.status === 'requires_reauth' || channel.status === 'error') {\n channel.status = 'connected'\n }\n\n link.deliveryStatus = sendResult.status === 'sent' ? 'sent' : 'queued'\n link.externalMessageId = externalMessageRowId\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n ...omitRawMessage(converted.metadata),\n externalMessageId: sendResult.externalMessageId,\n // Always persist the RFC2822 Message-ID so inbound reply matching (JWZ\n // strategy in lib/thread-matcher) and sent-folder dedup can resolve this\n // outbound message. Adapters that build the message themselves (Gmail)\n // return it in `converted.metadata.messageId`; IMAP/SMTP lets\n // the transport mint it, surfacing it only as `sendResult.externalMessageId`\n // (the RFC2822 id the recipient replies to) \u2014 fall back to that.\n // Store it bracket-stripped to match the inbound convention\n // (`normalizeMimeInbound` strips), so the JWZ matcher and sent-folder dedup \u2014\n // which compare against stripped ids \u2014 resolve it. `assembleRfc2822`\n // re-applies brackets when this id is later used to build reply headers.\n messageId: stripBrackets(\n stringOrUndefined((converted.metadata as Record<string, unknown> | undefined)?.messageId) ??\n sendResult.externalMessageId,\n ),\n }\n await em.flush()\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.sent',\n {\n messageId: message.id,\n externalMessageId: externalMessage.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n // #6095: same contract as the inbound path \u2014 the send time travels on\n // the event so consumers (the customers timeline) date their rows\n // from it instead of reading the ExternalMessage row across the\n // module boundary. ISO string because this event is persistent.\n providerTimestamp:\n externalMessage.providerTimestamp instanceof Date\n ? externalMessage.providerTimestamp.toISOString()\n : null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'delivered',\n messageId: message.id,\n channelLinkId: link.id,\n externalMessageId: externalMessage.id,\n providerKey: channel.providerKey,\n }\n } catch (sendErr) {\n // (8) Failure path \u2014 classify, persist, emit, return.\n const classification = classifyOutboundError(sendErr)\n const requiresReauth = isReauthError(classification)\n link.deliveryStatus = 'failed'\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n lastError: classification.message,\n lastErrorAt: new Date().toISOString(),\n transient: classification.transient,\n requiresReauth,\n }\n // A 401 / invalid_grant means the stored credentials are dead and no\n // retry will help \u2014 flip the channel to `requires_reauth` (mirrors the\n // inbound poll path) so the operator gets a reconnect signal instead of\n // silently-failing sends. A later successful send self-heals back to\n // `connected` (see the success path above).\n if (requiresReauth) {\n channel.status = 'requires_reauth'\n }\n await em.flush()\n\n try {\n await integrationLog?.error?.({\n integrationId: `channel_${channel.providerKey}`,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n channelId: channel.id,\n messageId: message.id,\n status: classification.status ?? null,\n transient: classification.transient,\n message: classification.message,\n })\n } catch {\n // best-effort logging\n }\n\n if (requiresReauth) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.channel.requires_reauth',\n {\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n reason: classification.message,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.delivery_failed',\n {\n messageId: message.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n transient: classification.transient,\n error: classification.message,\n status: classification.status ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'failed',\n messageId: message.id,\n channelLinkId: link.id,\n providerKey: channel.providerKey,\n error: classification.message,\n transient: classification.transient,\n requiresReauth,\n }\n }\n },\n}\n\nregisterCommand(deliverOutboundMessageCommand)\n\nexport default deliverOutboundMessageCommand\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,5 +1,5 @@
1
1
  import { resolveCommunicationChannelsSystemUserId } from "./system-user.js";
2
- const EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE = "communication_channels.external_conversation";
2
+ import { EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE } from "../../messages/lib/messageSourceEntityTypes.js";
3
3
  function buildInboundIdempotencyKey(channelId, externalMessageId) {
4
4
  return `cc:${channelId}:${externalMessageId}`;
5
5
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/lib/inbound-message-origin.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { resolveCommunicationChannelsSystemUserId } from './system-user'\n\n/**\n * Entity type a channel-backed conversation is exposed under on a message's\n * `sourceEntityType`. Written by `commands/ingest-inbound-message.ts` and\n * inherited by every operator message composed on the same conversation.\n */\nexport const EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE = 'communication_channels.external_conversation'\n\n/**\n * Dedup key `ingest-inbound-message` stamps on the platform message it composes,\n * so a retried ingest reuses the first attempt's message instead of duplicating\n * it. The `cc:` prefix also identifies the message as ingest-authored \u2014 see\n * {@link isIngestedInboundMessage}.\n */\nexport function buildInboundIdempotencyKey(channelId: string, externalMessageId: string): string {\n return `cc:${channelId}:${externalMessageId}`\n}\n\ntype InboundMessageOriginInput = {\n message: {\n senderUserId: string\n sourceEntityType?: string | null\n idempotencyKey?: string | null\n }\n /** The `MessageChannelLink` already loaded for this message, if any. */\n existingLink: { direction: 'inbound' | 'outbound' } | null | undefined\n tenantId: string\n}\n\n/**\n * Whether a platform message in a channel-linked thread is the one\n * `ingest-inbound-message` composed for an incoming message \u2014 as opposed to a\n * message an operator wrote in the same conversation.\n *\n * The distinction matters because the messages module emits\n * `messages.message.sent` for both, and only the operator's message may be\n * delivered outbound. Before #5535 the discriminator was `sourceEntityType`\n * alone, which every operator reply inherits from the message it answers \u2014 so\n * operator replies were silently dropped along with the ingested messages.\n *\n * Three signals, checked cheapest-first, because the ingested message's inbound\n * `MessageChannelLink` is written *after* the compose command emits its event\n * and is therefore not guaranteed to be visible yet:\n *\n * 1. an inbound `MessageChannelLink` for this message \u2014 authoritative;\n * 2. the `cc:` ingest dedup key ({@link buildInboundIdempotencyKey}) \u2014 set\n * whenever the provider supplied an external message id;\n * 3. the tenant's channel system user as the sender \u2014 the ingest attribution\n * when the conversation has no assigned user.\n */\nexport async function isIngestedInboundMessage(\n em: EntityManager,\n { message, existingLink, tenantId }: InboundMessageOriginInput,\n): Promise<boolean> {\n if (message.sourceEntityType !== EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE) return false\n if (existingLink?.direction === 'inbound') return true\n if (typeof message.idempotencyKey === 'string' && message.idempotencyKey.startsWith('cc:')) return true\n const systemUserId = await resolveCommunicationChannelsSystemUserId(em, tenantId)\n return message.senderUserId === systemUserId\n}\n"],
5
- "mappings": "AACA,SAAS,gDAAgD;AAOlD,MAAM,2CAA2C;AAQjD,SAAS,2BAA2B,WAAmB,mBAAmC;AAC/F,SAAO,MAAM,SAAS,IAAI,iBAAiB;AAC7C;AAkCA,eAAsB,yBACpB,IACA,EAAE,SAAS,cAAc,SAAS,GAChB;AAClB,MAAI,QAAQ,qBAAqB,yCAA0C,QAAO;AAClF,MAAI,cAAc,cAAc,UAAW,QAAO;AAClD,MAAI,OAAO,QAAQ,mBAAmB,YAAY,QAAQ,eAAe,WAAW,KAAK,EAAG,QAAO;AACnG,QAAM,eAAe,MAAM,yCAAyC,IAAI,QAAQ;AAChF,SAAO,QAAQ,iBAAiB;AAClC;",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { resolveCommunicationChannelsSystemUserId } from './system-user'\nimport { EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE } from '../../messages/lib/messageSourceEntityTypes'\n\nexport { EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE }\n\n/**\n * Dedup key `ingest-inbound-message` stamps on the platform message it composes,\n * so a retried ingest reuses the first attempt's message instead of duplicating\n * it. The `cc:` prefix also identifies the message as ingest-authored \u2014 see\n * {@link isIngestedInboundMessage}.\n */\nexport function buildInboundIdempotencyKey(channelId: string, externalMessageId: string): string {\n return `cc:${channelId}:${externalMessageId}`\n}\n\ntype InboundMessageOriginInput = {\n message: {\n senderUserId: string\n sourceEntityType?: string | null\n idempotencyKey?: string | null\n }\n /** The `MessageChannelLink` already loaded for this message, if any. */\n existingLink: { direction: 'inbound' | 'outbound' } | null | undefined\n tenantId: string\n}\n\n/**\n * Whether a platform message in a channel-linked thread is the one\n * `ingest-inbound-message` composed for an incoming message \u2014 as opposed to a\n * message an operator wrote in the same conversation.\n *\n * The distinction matters because the messages module emits\n * `messages.message.sent` for both, and only the operator's message may be\n * delivered outbound. Before #5535 the discriminator was `sourceEntityType`\n * alone, which every operator reply inherits from the message it answers \u2014 so\n * operator replies were silently dropped along with the ingested messages.\n *\n * Three signals, checked cheapest-first, because the ingested message's inbound\n * `MessageChannelLink` is written *after* the compose command emits its event\n * and is therefore not guaranteed to be visible yet:\n *\n * 1. an inbound `MessageChannelLink` for this message \u2014 authoritative;\n * 2. the `cc:` ingest dedup key ({@link buildInboundIdempotencyKey}) \u2014 set\n * whenever the provider supplied an external message id;\n * 3. the tenant's channel system user as the sender \u2014 the ingest attribution\n * when the conversation has no assigned user.\n */\nexport async function isIngestedInboundMessage(\n em: EntityManager,\n { message, existingLink, tenantId }: InboundMessageOriginInput,\n): Promise<boolean> {\n if (message.sourceEntityType !== EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE) return false\n if (existingLink?.direction === 'inbound') return true\n if (typeof message.idempotencyKey === 'string' && message.idempotencyKey.startsWith('cc:')) return true\n const systemUserId = await resolveCommunicationChannelsSystemUserId(em, tenantId)\n return message.senderUserId === systemUserId\n}\n"],
5
+ "mappings": "AACA,SAAS,gDAAgD;AACzD,SAAS,gDAAgD;AAUlD,SAAS,2BAA2B,WAAmB,mBAAmC;AAC/F,SAAO,MAAM,SAAS,IAAI,iBAAiB;AAC7C;AAkCA,eAAsB,yBACpB,IACA,EAAE,SAAS,cAAc,SAAS,GAChB;AAClB,MAAI,QAAQ,qBAAqB,yCAA0C,QAAO;AAClF,MAAI,cAAc,cAAc,UAAW,QAAO;AAClD,MAAI,OAAO,QAAQ,mBAAmB,YAAY,QAAQ,eAAe,WAAW,KAAK,EAAG,QAAO;AACnG,QAAM,eAAe,MAAM,yCAAyC,IAAI,QAAQ;AAChF,SAAO,QAAQ,iBAAiB;AAClC;",
6
6
  "names": []
7
7
  }
@@ -40,7 +40,10 @@ function getSecret() {
40
40
  const dedicated = process.env.OM_HUB_OAUTH_STATE_KEY ?? process.env.KMS_MASTER_KEY;
41
41
  if (dedicated) return dedicated;
42
42
  if (process.env.NODE_ENV === "production") {
43
- throw new Error("[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production");
43
+ throw new OAuthStateError(
44
+ "[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production",
45
+ "missing_secret"
46
+ );
44
47
  }
45
48
  const fallback = process.env.JWT_SECRET;
46
49
  if (!fallback) {