@open-mercato/core 0.9.1-develop.7515.1.e2712c8b00 → 0.9.1-develop.7516.1.176184247d

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 (31) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/communication_channels/lib/send-as-user.js.map +2 -2
  3. package/dist/modules/customers/api/people/[id]/emails/route.js +44 -1
  4. package/dist/modules/customers/api/people/[id]/emails/route.js.map +2 -2
  5. package/dist/modules/customers/lib/personEmailThreads.js +36 -1
  6. package/dist/modules/customers/lib/personEmailThreads.js.map +2 -2
  7. package/dist/modules/messages/api/[id]/route.js +3 -9
  8. package/dist/modules/messages/api/[id]/route.js.map +2 -2
  9. package/dist/modules/messages/api/route.js +33 -16
  10. package/dist/modules/messages/api/route.js.map +2 -2
  11. package/dist/modules/messages/commands/messages.js.map +2 -2
  12. package/dist/modules/messages/lib/routeHelpers.js +40 -1
  13. package/dist/modules/messages/lib/routeHelpers.js.map +2 -2
  14. package/package.json +7 -7
  15. package/src/modules/communication_channels/lib/send-as-user.ts +4 -0
  16. package/src/modules/customers/api/people/[id]/emails/route.ts +67 -1
  17. package/src/modules/customers/i18n/de.json +1 -0
  18. package/src/modules/customers/i18n/en.json +1 -0
  19. package/src/modules/customers/i18n/es.json +1 -0
  20. package/src/modules/customers/i18n/ko.json +1 -0
  21. package/src/modules/customers/i18n/pl.json +1 -0
  22. package/src/modules/customers/lib/personEmailThreads.ts +61 -0
  23. package/src/modules/messages/api/[id]/route.ts +2 -9
  24. package/src/modules/messages/api/route.ts +42 -20
  25. package/src/modules/messages/commands/messages.ts +7 -0
  26. package/src/modules/messages/i18n/de.json +1 -0
  27. package/src/modules/messages/i18n/en.json +1 -0
  28. package/src/modules/messages/i18n/es.json +1 -0
  29. package/src/modules/messages/i18n/ko.json +1 -0
  30. package/src/modules/messages/i18n/pl.json +1 -0
  31. package/src/modules/messages/lib/routeHelpers.ts +84 -1
@@ -1691,6 +1691,7 @@
1691
1691
  "customers.email.conversationShare.sharedByTeammate": "Shared by a teammate",
1692
1692
  "customers.email.errors.deliveryFailed": "Delivery failed — not sent",
1693
1693
  "customers.email.errors.flipFailed": "Visibility update failed",
1694
+ "customers.email.errors.parentMessageNotFound": "The email you are replying to was not found",
1694
1695
  "customers.email.errors.sendFailed": "Send failed",
1695
1696
  "customers.email.sync.button": "Sync",
1696
1697
  "customers.email.sync.failed": "Failed to sync mailbox",
@@ -1691,6 +1691,7 @@
1691
1691
  "customers.email.conversationShare.sharedByTeammate": "Compartido por un compañero",
1692
1692
  "customers.email.errors.deliveryFailed": "Error de entrega: no enviado",
1693
1693
  "customers.email.errors.flipFailed": "Error al actualizar la visibilidad",
1694
+ "customers.email.errors.parentMessageNotFound": "No se encontró el correo al que está respondiendo",
1694
1695
  "customers.email.errors.sendFailed": "Error al enviar",
1695
1696
  "customers.email.sync.button": "Sincronizar",
1696
1697
  "customers.email.sync.failed": "No se pudo sincronizar el buzón",
@@ -1691,6 +1691,7 @@
1691
1691
  "customers.email.conversationShare.sharedByTeammate": "동료가 공유",
1692
1692
  "customers.email.errors.deliveryFailed": "전송 실패 — 발송되지 않음",
1693
1693
  "customers.email.errors.flipFailed": "공개 범위 업데이트 실패",
1694
+ "customers.email.errors.parentMessageNotFound": "답장하려는 이메일을 찾을 수 없습니다",
1694
1695
  "customers.email.errors.sendFailed": "전송 실패",
1695
1696
  "customers.email.sync.button": "동기화",
1696
1697
  "customers.email.sync.failed": "메일함 동기화에 실패했습니다",
@@ -1691,6 +1691,7 @@
1691
1691
  "customers.email.conversationShare.sharedByTeammate": "Udostępnione przez współpracownika",
1692
1692
  "customers.email.errors.deliveryFailed": "Dostarczanie nie powiodło się – nie wysłano",
1693
1693
  "customers.email.errors.flipFailed": "Aktualizacja widoczności nieudana",
1694
+ "customers.email.errors.parentMessageNotFound": "Nie znaleziono e-maila, na który odpowiadasz",
1694
1695
  "customers.email.errors.sendFailed": "Wysyłanie nieudane",
1695
1696
  "customers.email.sync.button": "Synchronizuj",
1696
1697
  "customers.email.sync.failed": "Nie udało się zsynchronizować skrzynki",
@@ -378,3 +378,64 @@ export async function buildPersonEmailThreads(
378
378
  threads.sort((a, b) => b.lastMessageAt.localeCompare(a.lastMessageAt))
379
379
  return threads.slice(0, maxThreads)
380
380
  }
381
+
382
+ export type PersonEmailMessageAccessOptions = Pick<
383
+ BuildPersonEmailThreadsOptions,
384
+ 'personId' | 'tenantId' | 'organizationId' | 'viewerUserId' | 'userFeatures' | 'sharedConversations' | 'sharedChannelIds'
385
+ > & {
386
+ /** Open Mercato `messages.message` id the caller wants to reply to. */
387
+ messageId: string
388
+ }
389
+
390
+ /**
391
+ * Whether `messageId` is on this Person's email history as the viewer sees it —
392
+ * i.e. whether {@link buildPersonEmailThreads} would show it to them.
393
+ *
394
+ * Walks the same anchor (email `CustomerInteraction` → `MessageChannelLink` →
395
+ * message) with the same scope and the same visibility predicate, so a reply
396
+ * from the Person page can only name a parent the Emails tab could have shown:
397
+ * the viewer's own mail, shared or legacy mail, a conversation shared for THIS
398
+ * Person, or a shared team mailbox. Callers remain responsible for resolving the
399
+ * Person in scope first.
400
+ */
401
+ export async function isMessageOnPersonEmailHistory(
402
+ em: EntityManager,
403
+ opts: PersonEmailMessageAccessOptions,
404
+ ): Promise<boolean> {
405
+ const { personId, tenantId, organizationId, messageId } = opts
406
+ const dscope = { tenantId, organizationId: organizationId ?? null }
407
+
408
+ const linkWhere: JsonRecord = { messageId, tenantId }
409
+ if (organizationId) linkWhere.organizationId = organizationId
410
+ const links = (await findWithDecryption(
411
+ em,
412
+ 'MessageChannelLink' as never,
413
+ linkWhere as never,
414
+ undefined,
415
+ dscope,
416
+ )) as JsonRecord[]
417
+ const linkIds = links
418
+ .map((link) => (typeof link.id === 'string' ? (link.id as string) : null))
419
+ .filter((value): value is string => !!value)
420
+ if (linkIds.length === 0) return false
421
+
422
+ const interactionWhere: JsonRecord = {
423
+ entity: personId,
424
+ interactionType: 'email',
425
+ deletedAt: null,
426
+ tenantId,
427
+ externalMessageId: { $in: linkIds },
428
+ }
429
+ if (organizationId) interactionWhere.organizationId = organizationId
430
+ Object.assign(
431
+ interactionWhere,
432
+ buildEmailVisibilityMikroFilter({
433
+ currentUserId: opts.viewerUserId,
434
+ userFeatures: opts.userFeatures,
435
+ sharedConversations: opts.sharedConversations,
436
+ sharedChannelIds: opts.sharedChannelIds,
437
+ }),
438
+ )
439
+ const visible = await em.count(CustomerInteraction, interactionWhere as never)
440
+ return visible > 0
441
+ }
@@ -16,9 +16,9 @@ import { getMessageTypeOrDefault } from '../../lib/message-types-registry'
16
16
  import { attachOperationMetadataHeader } from '../../lib/operationMetadata'
17
17
  import {
18
18
  canPostToChannelThread,
19
- hasChannelThreadReadAccess,
20
19
  hasOrganizationAccess,
21
20
  resolveMessageContext,
21
+ resolveMessageReadAccess,
22
22
  } from '../../lib/routeHelpers'
23
23
  import { resolveUserFeatures, runMessageMutationGuardAfterSuccess, runMessageMutationGuards } from '../guards'
24
24
  import {
@@ -103,13 +103,7 @@ export async function GET(req: Request, { params }: { params: { id: string } })
103
103
  return Response.json({ error: 'Access denied' }, { status: 403 })
104
104
  }
105
105
 
106
- const recipient = await em.findOne(MessageRecipient, {
107
- messageId: params.id,
108
- recipientUserId: scope.userId,
109
- deletedAt: null,
110
- })
111
-
112
- const isSender = message.senderUserId === scope.userId
106
+ const { recipient, isSender, hasChannelThreadAccess } = await resolveMessageReadAccess(ctx, scope, message)
113
107
  const isRecipient = Boolean(recipient)
114
108
 
115
109
  // #5535: a message that arrived over a communication channel has the channel
@@ -126,7 +120,6 @@ export async function GET(req: Request, { params }: { params: { id: string } })
126
120
  // `requireAuth` only so that a participant can always read their own message.
127
121
  // The gate goes through RBAC, not through `ctx.auth.features` — the session JWT
128
122
  // carries no `features` claim at all, so reading it would deny everyone.
129
- const hasChannelThreadAccess = await hasChannelThreadReadAccess(ctx, scope, message)
130
123
 
131
124
  if (!isSender && !isRecipient && !hasChannelThreadAccess) {
132
125
  return Response.json({ error: 'Access denied' }, { status: 403 })
@@ -12,7 +12,7 @@ import {
12
12
  resolveCrudCache,
13
13
  } from '@open-mercato/shared/lib/crud/cache'
14
14
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'
15
- import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
15
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
16
16
  import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
17
17
  import { User } from '../../auth/data/entities'
18
18
  import { Message, MessageObject } from '../data/entities'
@@ -36,6 +36,7 @@ import { attachOperationMetadataHeader } from '../lib/operationMetadata'
36
36
  import {
37
37
  canPostToChannelThread,
38
38
  canUseChannelThreadFallback,
39
+ resolveReplyParentMessage,
39
40
  canUseMessageEmailFeature,
40
41
  resolveMessageContext,
41
42
  } from '../lib/routeHelpers'
@@ -46,6 +47,7 @@ import { MessageCommandExecuteResult } from '../commands/shared'
46
47
  import {
47
48
  composeMessageRequestSchema as composeSchema,
48
49
  composeResponseSchema,
50
+ errorResponseSchema,
49
51
  listMessagesSchema as listSchema,
50
52
  messageListItemSchema,
51
53
  } from './openapi'
@@ -510,6 +512,35 @@ export async function POST(req: Request) {
510
512
  }
511
513
  }
512
514
 
515
+ // A parent decides which conversation the new message joins, so it is held to
516
+ // the same rule as reading it: knowing a message id is not enough to thread
517
+ // onto someone else's conversation. Checked on the parent the client sent, for
518
+ // drafts and every visibility, before any guard, delegation or write runs.
519
+ let replyParent: Message | null = null
520
+ if (input.parentMessageId) {
521
+ const resolution = await resolveReplyParentMessage(
522
+ ctx,
523
+ { tenantId: scope.tenantId, organizationId: scope.organizationId ?? null, userId: scope.userId },
524
+ input.parentMessageId,
525
+ )
526
+ if (resolution.status === 'not_found') {
527
+ const { translate } = await resolveTranslations()
528
+ return Response.json(
529
+ {
530
+ error: translate(
531
+ 'messages.errors.parentMessageNotFound',
532
+ 'The message you are replying to was not found',
533
+ ),
534
+ },
535
+ { status: 404 },
536
+ )
537
+ }
538
+ if (resolution.status === 'forbidden') {
539
+ return Response.json({ error: 'Access denied' }, { status: 403 })
540
+ }
541
+ replyParent = resolution.message
542
+ }
543
+
513
544
  // #5535: composing on a channel conversation without naming a parent message
514
545
  // used to open a brand-new thread. A fresh thread has no `ChannelThreadMapping`,
515
546
  // so the outbound bridge had nothing to route on and the operator got a 201 for
@@ -555,27 +586,14 @@ export async function POST(req: Request) {
555
586
  // derives the thread from that parent alone — so a public, non-draft message
556
587
  // could be threaded onto a channel-linked conversation and delivered to the
557
588
  // external correspondent without any channel-access check. A tenant-wide
558
- // channel accepts delivery from any sender, so `messages.compose` plus a known
559
- // message id was enough. Apply the same check to the thread the command will
560
- // actually derive. An internal thread, a thread outside the caller's scope,
561
- // and an absent hub all resolve to `null` and keep the pre-existing rule.
589
+ // channel accepts delivery from any sender, so reading a message is not
590
+ // enough to post onto its thread. Apply the same check to the thread the
591
+ // command will actually derive. An internal thread and an absent hub resolve
592
+ // to `null` and keep the pre-existing rule.
562
593
  // Both channel branches also require `messages.view`, like reply and forward:
563
594
  // the hub grants every shared channel regardless of features (#6432).
564
- if (isPublicVisibility && !input.isDraft && input.parentMessageId) {
565
- const em = ctx.container.resolve('em') as EntityManager
566
- const parentMessage = await findOneWithDecryption(
567
- em,
568
- Message,
569
- {
570
- id: input.parentMessageId,
571
- tenantId: scope.tenantId,
572
- organizationId: scope.organizationId,
573
- deletedAt: null,
574
- },
575
- undefined,
576
- { tenantId: scope.tenantId, organizationId: scope.organizationId },
577
- )
578
- const parentThreadId = parentMessage?.threadId ?? input.parentMessageId
595
+ if (isPublicVisibility && !input.isDraft && replyParent) {
596
+ const parentThreadId = replyParent.threadId ?? replyParent.id
579
597
  if (!(await canPostToChannelThread(ctx, scope, parentThreadId))) {
580
598
  return Response.json({ error: 'Access denied' }, { status: 403 })
581
599
  }
@@ -711,6 +729,10 @@ export const openApi: OpenApiRouteDoc = {
711
729
  schema: composeResponseSchema,
712
730
  },
713
731
  ],
732
+ errors: [
733
+ { status: 403, description: 'Access denied, including a parentMessageId the caller cannot read', schema: errorResponseSchema },
734
+ { status: 404, description: 'parentMessageId is unknown, deleted, or outside the caller organization', schema: errorResponseSchema },
735
+ ],
714
736
  },
715
737
  },
716
738
  }
@@ -266,6 +266,13 @@ async function buildComposeResultFromExisting(
266
266
  }
267
267
  }
268
268
 
269
+ /**
270
+ * `parentMessageId` is trusted: the thread is derived from it without a read
271
+ * check, because the acting `userId` is itself an input and server-side callers
272
+ * (channel ingest) pass parents the actor is not a participant of. Every surface
273
+ * that accepts a parent from a caller MUST resolve it against its own read rule
274
+ * first (`resolveReplyParentMessage` for the messages API).
275
+ */
269
276
  const composeMessageCommand: CommandHandler<unknown, { id: string; threadId: string | null; externalEmail: string | null; isDraft: boolean; recipientUserIds: string[]; deduplicated?: boolean }> = {
270
277
  id: 'messages.messages.compose',
271
278
  async execute(rawInput, ctx) {
@@ -130,6 +130,7 @@
130
130
  "messages.errors.noExternalEmail": "Bitte gib eine gültige externe E-Mail ein.",
131
131
  "messages.errors.noRecipients": "Füge mindestens einen Empfänger hinzu.",
132
132
  "messages.errors.noSubject": "Bitte Betreff eingeben.",
133
+ "messages.errors.parentMessageNotFound": "Die Nachricht, auf die Sie antworten, wurde nicht gefunden",
133
134
  "messages.errors.sendFailed": "Nachricht konnte nicht gesendet werden.",
134
135
  "messages.errors.senderDraftSendUnsupported": "Das Senden eines gespeicherten Entwurfs über ein verbundenes Postfach wird noch nicht unterstützt.",
135
136
  "messages.errors.senderPayloadUnsupported": "Der Versand aus dem eigenen Postfach unterstützt noch keine Anhänge oder verknüpften Datensätze.",
@@ -130,6 +130,7 @@
130
130
  "messages.errors.noExternalEmail": "Please enter a valid external email.",
131
131
  "messages.errors.noRecipients": "Please add at least one recipient.",
132
132
  "messages.errors.noSubject": "Please enter a subject.",
133
+ "messages.errors.parentMessageNotFound": "The message you are replying to was not found",
133
134
  "messages.errors.sendFailed": "Failed to send message.",
134
135
  "messages.errors.senderDraftSendUnsupported": "Sending a saved draft through a connected mailbox is not supported yet.",
135
136
  "messages.errors.senderPayloadUnsupported": "Sending from your own mailbox does not support attachments or linked records yet.",
@@ -130,6 +130,7 @@
130
130
  "messages.errors.noExternalEmail": "Introduce un correo externo válido.",
131
131
  "messages.errors.noRecipients": "Añade al menos un destinatario.",
132
132
  "messages.errors.noSubject": "Introduce un asunto.",
133
+ "messages.errors.parentMessageNotFound": "No se encontró el mensaje al que está respondiendo",
133
134
  "messages.errors.sendFailed": "No se pudo enviar el mensaje.",
134
135
  "messages.errors.senderDraftSendUnsupported": "Enviar un borrador guardado desde un buzón conectado todavía no es compatible.",
135
136
  "messages.errors.senderPayloadUnsupported": "El envío desde tu propio buzón todavía no admite adjuntos ni registros vinculados.",
@@ -130,6 +130,7 @@
130
130
  "messages.errors.noExternalEmail": "유효한 외부 이메일을 입력하세요.",
131
131
  "messages.errors.noRecipients": "수신자를 하나 이상 추가하세요.",
132
132
  "messages.errors.noSubject": "제목을 입력하세요.",
133
+ "messages.errors.parentMessageNotFound": "답장하려는 메시지를 찾을 수 없습니다",
133
134
  "messages.errors.sendFailed": "메시지 전송에 실패했습니다.",
134
135
  "messages.errors.senderDraftSendUnsupported": "저장된 초안을 연결된 메일함으로 보내는 기능은 아직 지원되지 않습니다.",
135
136
  "messages.errors.senderPayloadUnsupported": "본인 메일함에서 보내기는 아직 첨부 파일이나 연결된 레코드를 지원하지 않습니다.",
@@ -130,6 +130,7 @@
130
130
  "messages.errors.noExternalEmail": "Wpisz poprawny zewnętrzny adres e-mail.",
131
131
  "messages.errors.noRecipients": "Dodaj przynajmniej jednego odbiorcę.",
132
132
  "messages.errors.noSubject": "Wpisz temat.",
133
+ "messages.errors.parentMessageNotFound": "Nie znaleziono wiadomości, na którą odpowiadasz",
133
134
  "messages.errors.sendFailed": "Nie udało się wysłać wiadomości.",
134
135
  "messages.errors.senderDraftSendUnsupported": "Wysyłka zapisanego szkicu przez połączoną skrzynkę nie jest jeszcze obsługiwana.",
135
136
  "messages.errors.senderPayloadUnsupported": "Wysyłka z własnej skrzynki nie obsługuje jeszcze załączników ani powiązanych rekordów.",
@@ -1,4 +1,7 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
1
2
  import { resolveRequestContext } from '@open-mercato/shared/lib/api/context'
3
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import { Message, MessageRecipient } from '../data/entities'
2
5
  import {
3
6
  CHANNEL_THREAD_FALLBACK_FEATURE,
4
7
  EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE,
@@ -16,6 +19,11 @@ export function hasOrganizationAccess(
16
19
  return messageOrganizationId == null
17
20
  }
18
21
 
22
+ export type ChannelThreadCallerContext = {
23
+ container: { resolve: <T = unknown>(name: string) => T }
24
+ auth?: unknown
25
+ }
26
+
19
27
  export type MessageScope = {
20
28
  tenantId: string
21
29
  organizationId: string | null
@@ -101,7 +109,7 @@ export async function canUseChannelThreadFallback(
101
109
  * message that is not explicitly public.
102
110
  */
103
111
  export async function hasChannelThreadReadAccess(
104
- ctx: Awaited<ReturnType<typeof resolveRequestContext>>['ctx'],
112
+ ctx: ChannelThreadCallerContext,
105
113
  scope: MessageScope,
106
114
  message: { id: string; threadId?: string | null; sourceEntityType?: string | null },
107
115
  ): Promise<boolean> {
@@ -116,6 +124,81 @@ export async function hasChannelThreadReadAccess(
116
124
  return channelThread?.canAccess === true
117
125
  }
118
126
 
127
+ export type MessageReadAccess = {
128
+ recipient: MessageRecipient | null
129
+ isSender: boolean
130
+ hasChannelThreadAccess: boolean
131
+ canRead: boolean
132
+ }
133
+
134
+ /**
135
+ * The read rule of `GET /api/messages/[id]`, for a message already loaded in the
136
+ * caller's scope: the sender, a recipient whose row is not deleted, or — for an
137
+ * explicitly public message only — a caller the channels hub lets work the
138
+ * channel thread. Channel access never opens an internal note.
139
+ *
140
+ * Shared by the detail read and by every write that names another message as its
141
+ * parent, so "may reference" can never be wider than "may read".
142
+ */
143
+ export async function resolveMessageReadAccess(
144
+ ctx: ChannelThreadCallerContext,
145
+ scope: MessageScope,
146
+ message: Pick<Message, 'id' | 'senderUserId' | 'visibility' | 'threadId' | 'sourceEntityType'>,
147
+ ): Promise<MessageReadAccess> {
148
+ const em = ctx.container.resolve('em') as EntityManager
149
+ const recipient = await em.findOne(MessageRecipient, {
150
+ messageId: message.id,
151
+ recipientUserId: scope.userId,
152
+ deletedAt: null,
153
+ })
154
+ const isSender = message.senderUserId === scope.userId
155
+ const hasChannelThreadAccess = await hasChannelThreadReadAccess(ctx, scope, message)
156
+ const isParticipant = isSender || Boolean(recipient)
157
+ return {
158
+ recipient,
159
+ isSender,
160
+ hasChannelThreadAccess,
161
+ canRead: isParticipant || (hasChannelThreadAccess && message.visibility === 'public'),
162
+ }
163
+ }
164
+
165
+ export type ReplyParentResolution =
166
+ | { status: 'readable'; message: Message }
167
+ | { status: 'not_found' }
168
+ | { status: 'forbidden' }
169
+
170
+ /**
171
+ * Resolve a caller-supplied parent message before a write threads onto it.
172
+ *
173
+ * The parent is looked up in exactly the scope the new message is written to —
174
+ * tenant, organization (null only matches null) and not deleted — so a parent
175
+ * that is unknown, deleted, or in another organization or tenant all answer
176
+ * `not_found`. A parent in scope that the caller may not read answers
177
+ * `forbidden`, the same distinction the detail read already makes.
178
+ */
179
+ export async function resolveReplyParentMessage(
180
+ ctx: ChannelThreadCallerContext,
181
+ scope: MessageScope,
182
+ parentMessageId: string,
183
+ ): Promise<ReplyParentResolution> {
184
+ const em = ctx.container.resolve('em') as EntityManager
185
+ const parent = await findOneWithDecryption(
186
+ em,
187
+ Message,
188
+ {
189
+ id: parentMessageId,
190
+ tenantId: scope.tenantId,
191
+ organizationId: scope.organizationId,
192
+ deletedAt: null,
193
+ },
194
+ undefined,
195
+ { tenantId: scope.tenantId, organizationId: scope.organizationId },
196
+ )
197
+ if (!parent) return { status: 'not_found' }
198
+ const access = await resolveMessageReadAccess(ctx, scope, parent)
199
+ return access.canRead ? { status: 'readable', message: parent } : { status: 'forbidden' }
200
+ }
201
+
119
202
  /**
120
203
  * Whether the caller may post a message meant to leave the platform onto
121
204
  * `messageThreadId` (#5645 review, #6432).