@adatechnology/conversations-ui 0.1.0-rc.17 → 0.1.0-rc.18

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.
@@ -120,9 +120,14 @@ declare const PREVIEW_DOCUMENTS: Readonly<Record<string, readonly ConversationDo
120
120
  * Assina com WebCrypto porque `node:crypto` não existe no navegador. Os builders vêm dos contratos
121
121
  * (isomórficos) justamente para que o mesmo payload seja montado nos dois runtimes.
122
122
  *
123
- * ⚠️ Isto carrega o app secret de DESENVOLVIMENTO no bundle. existe para o docker local, e
124
- * `assertPreviewEnvironment` recusa rodar em produção um segredo de dev vazado é irrelevante,
125
- * mas o hábito de embarcar segredo em frontend não é.
123
+ * ⚠️ SOMENTE EXECUÇÃO LOCAL. Isto carrega o app secret no bundle, e bundle é público onde quer que
124
+ * seja servido em qualquer ambiente com URL acessível (homologação inclusive) usar esta fábrica
125
+ * equivale a publicar o segredo, e quem o tiver forja webhooks válidos daquele app da Meta: injeta
126
+ * mensagem de qualquer número e dispara os fluxos. `assertPreviewEnvironment` barra produção, mas
127
+ * homologação passaria, então a barreira não basta.
128
+ *
129
+ * Para qualquer ambiente publicado use `createPreviewBridgeClient`: o navegador manda a intenção e
130
+ * o servidor assina com o segredo que ele já tem.
126
131
  */
127
132
 
128
133
  type PreviewWebhookClient = {
@@ -206,6 +211,67 @@ type PreviewUploadedMedia = {
206
211
  declare function mediaTypeOf(mimeType: string): SendPreviewMediaParams['mediaType'];
207
212
  declare function ConversationPreview({ client, sse, conversationId, loadMessages, placeholder, pollIntervalMs, uploadMedia, }: ConversationPreviewProps): react.JSX.Element;
208
213
 
214
+ /**
215
+ * Cliente do preview que NÃO carrega segredo: em vez de montar e assinar o payload da Meta no
216
+ * navegador, manda um comando semântico (`{ kind: 'text', text }`) para uma rota do próprio host,
217
+ * autenticada pela sessão que o painel já tem. Quem monta o payload e assina é o servidor, com o
218
+ * app secret que nunca sai de lá.
219
+ *
220
+ * Por que esta fábrica existe ao lado de `createPreviewWebhookClient`: assinar no navegador exige o
221
+ * app secret dentro do bundle, e bundle é público por definição — em qualquer ambiente com URL
222
+ * acessível isso é o mesmo que publicar o segredo. Com o segredo vazado, qualquer um forja webhooks
223
+ * válidos daquele app: injeta mensagens de qualquer número e dispara os fluxos. `createPreviewWebhook
224
+ * Client` continua servindo para execução puramente local (docker de dev, onde o bundle não é
225
+ * servido para ninguém); para qualquer ambiente publicado, a ponte é o caminho.
226
+ *
227
+ * O pacote não decide autenticação: o host injeta `sendCommand` (ou `headers` + `fetchImplementation`),
228
+ * porque token, cookie e cabeçalho de sessão são do produto, não da biblioteca.
229
+ */
230
+
231
+ /**
232
+ * Comando semântico entregue ao host. É deliberadamente o QUE o cliente fez, não o payload da Meta:
233
+ * se o navegador mandasse o payload pronto, a rota viraria um injetor de webhook arbitrário para
234
+ * quem tivesse sessão. Mandando a intenção, o servidor é quem escolhe a forma.
235
+ */
236
+ type PreviewInboundCommand = {
237
+ readonly kind: 'text';
238
+ readonly from: string;
239
+ readonly text: string;
240
+ } | {
241
+ readonly kind: 'buttonReply';
242
+ readonly from: string;
243
+ readonly reply: InteractiveReplyOption;
244
+ } | {
245
+ readonly kind: 'listReply';
246
+ readonly from: string;
247
+ readonly reply: InteractiveReplyOption;
248
+ } | {
249
+ readonly kind: 'audio';
250
+ readonly from: string;
251
+ readonly mediaId: string;
252
+ } | ({
253
+ readonly kind: 'media';
254
+ readonly from: string;
255
+ } & SendPreviewMediaParams);
256
+ type SendPreviewInboundCommand = (command: PreviewInboundCommand) => Promise<void>;
257
+ declare class PreviewBridgeRejectedError extends Error {
258
+ readonly status: number;
259
+ constructor(status: number);
260
+ }
261
+ type CreatePreviewBridgeClientParams = {
262
+ readonly from: string;
263
+ /**
264
+ * Entrega o comando. Use quando o host já tem um cliente HTTP com sessão, interceptors e refresh
265
+ * de token — reimplementar isso aqui só duplicaria a autenticação do produto.
266
+ */
267
+ readonly sendCommand?: SendPreviewInboundCommand;
268
+ /** Alternativa a `sendCommand` para hosts sem cliente HTTP próprio. */
269
+ readonly endpointUrl?: string;
270
+ readonly headers?: Readonly<Record<string, string>>;
271
+ readonly fetchImplementation?: typeof fetch;
272
+ };
273
+ declare function createPreviewBridgeClient(params: CreatePreviewBridgeClientParams): PreviewWebhookClient;
274
+
209
275
  /**
210
276
  * Roteiro que mantém o preview vivo: sem tráfego chegando, a inbox é uma tela estática e as
211
277
  * transições que o atendente precisa testar (fila de espera enchendo, handoff, devolução ao bot)
@@ -304,4 +370,4 @@ type MediaTypesPreviewProps = {
304
370
  };
305
371
  declare function MediaTypesPreview({ conversationId, className, }: MediaTypesPreviewProps): react.JSX.Element;
306
372
 
307
- export { type AppendMessageParams, ConversationPreview, type ConversationPreviewProps, type CreateMockConversationsApiParams, type CreateMockSSEProviderParams, type CreatePreviewStoreParams, type CreatePreviewWebhookClientParams, DEFAULT_PREVIEW_SCRIPT, GLOBAL_CHANNEL, type ListConversationsFilters, MEDIA_TYPES_CONVERSATION_ID, MediaTypesPreview, type MediaTypesPreviewProps, type MockEventSource, PREVIEW_CONVERSATIONS, PREVIEW_DOCUMENTS, PREVIEW_FILE_SAMPLES, PREVIEW_MESSAGES, type PreviewEmission, PreviewInProductionError, type PreviewScriptStep, type PreviewStore, type PreviewStoreListener, type PreviewUploadedMedia, type PreviewWebhookClient, PreviewWebhookRejectedError, type SendPreviewMediaParams, type SetModeParams, type StartPreviewScriptParams, assertPreviewEnvironment, conversationChannel, createMockConversationsApi, createMockEventSource, createMockSSEProvider, createPreviewMediaResolver, createPreviewStore, createPreviewWebhookClient, mediaTypeOf, previewFileBase64, previewFileUrl, resolvePreviewFileSample, signPreviewPayload, startPreviewScript };
373
+ export { type AppendMessageParams, ConversationPreview, type ConversationPreviewProps, type CreateMockConversationsApiParams, type CreateMockSSEProviderParams, type CreatePreviewBridgeClientParams, type CreatePreviewStoreParams, type CreatePreviewWebhookClientParams, DEFAULT_PREVIEW_SCRIPT, GLOBAL_CHANNEL, type ListConversationsFilters, MEDIA_TYPES_CONVERSATION_ID, MediaTypesPreview, type MediaTypesPreviewProps, type MockEventSource, PREVIEW_CONVERSATIONS, PREVIEW_DOCUMENTS, PREVIEW_FILE_SAMPLES, PREVIEW_MESSAGES, PreviewBridgeRejectedError, type PreviewEmission, PreviewInProductionError, type PreviewInboundCommand, type PreviewScriptStep, type PreviewStore, type PreviewStoreListener, type PreviewUploadedMedia, type PreviewWebhookClient, PreviewWebhookRejectedError, type SendPreviewInboundCommand, type SendPreviewMediaParams, type SetModeParams, type StartPreviewScriptParams, assertPreviewEnvironment, conversationChannel, createMockConversationsApi, createMockEventSource, createMockSSEProvider, createPreviewBridgeClient, createPreviewMediaResolver, createPreviewStore, createPreviewWebhookClient, mediaTypeOf, previewFileBase64, previewFileUrl, resolvePreviewFileSample, signPreviewPayload, startPreviewScript };
@@ -1092,6 +1092,43 @@ function createPreviewWebhookClient(params) {
1092
1092
  };
1093
1093
  }
1094
1094
 
1095
+ // src/preview/createPreviewBridgeClient.ts
1096
+ var PreviewBridgeRejectedError = class extends Error {
1097
+ constructor(status) {
1098
+ super(`A rota de preview do host recusou a entrega (HTTP ${status}).`);
1099
+ this.status = status;
1100
+ this.name = "PreviewBridgeRejectedError";
1101
+ }
1102
+ };
1103
+ function buildFetchSender(params) {
1104
+ const endpointUrl = params.endpointUrl;
1105
+ if (!endpointUrl) {
1106
+ throw new Error("createPreviewBridgeClient exige `sendCommand` ou `endpointUrl`.");
1107
+ }
1108
+ return async (command) => {
1109
+ const performRequest = params.fetchImplementation ?? fetch;
1110
+ const response = await performRequest(endpointUrl, {
1111
+ method: "POST",
1112
+ // `credentials` fica com o host via `headers`/`fetchImplementation`: sessão por cookie e por
1113
+ // bearer não convivem numa escolha default sem quebrar um dos dois.
1114
+ headers: { "content-type": "application/json", ...params.headers },
1115
+ body: JSON.stringify(command)
1116
+ });
1117
+ if (!response.ok) throw new PreviewBridgeRejectedError(response.status);
1118
+ };
1119
+ }
1120
+ function createPreviewBridgeClient(params) {
1121
+ const send = params.sendCommand ?? buildFetchSender(params);
1122
+ const from = params.from;
1123
+ return {
1124
+ sendText: (text) => send({ kind: "text", from, text }),
1125
+ sendButtonReply: (reply) => send({ kind: "buttonReply", from, reply }),
1126
+ sendListReply: (reply) => send({ kind: "listReply", from, reply }),
1127
+ sendAudio: (mediaId) => send({ kind: "audio", from, mediaId }),
1128
+ sendMedia: (media) => send({ kind: "media", from, ...media })
1129
+ };
1130
+ }
1131
+
1095
1132
  // src/preview/startPreviewScript.ts
1096
1133
  var DEFAULT_PREVIEW_SCRIPT = [
1097
1134
  (store) => store.appendMessage({
@@ -1189,6 +1226,7 @@ export {
1189
1226
  PREVIEW_DOCUMENTS,
1190
1227
  PREVIEW_FILE_SAMPLES,
1191
1228
  PREVIEW_MESSAGES,
1229
+ PreviewBridgeRejectedError,
1192
1230
  PreviewInProductionError,
1193
1231
  PreviewWebhookRejectedError,
1194
1232
  assertPreviewEnvironment,
@@ -1196,6 +1234,7 @@ export {
1196
1234
  createMockConversationsApi,
1197
1235
  createMockEventSource,
1198
1236
  createMockSSEProvider,
1237
+ createPreviewBridgeClient,
1199
1238
  createPreviewMediaResolver,
1200
1239
  createPreviewStore,
1201
1240
  createPreviewWebhookClient,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.17",
3
+ "version": "0.1.0-rc.18",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,92 @@
1
+ /**
2
+ * O que estes testes protegem é a propriedade de segurança da ponte: nenhum caminho pode voltar a
3
+ * exigir segredo no navegador, e o corpo enviado tem que ser a INTENÇÃO — se um refactor passar a
4
+ * mandar payload da Meta montado no cliente, a rota do host vira injetor de webhook arbitrário.
5
+ */
6
+
7
+ import { describe, expect, it } from 'bun:test'
8
+
9
+ import { createPreviewBridgeClient, PreviewBridgeRejectedError } from './createPreviewBridgeClient'
10
+ import type { PreviewInboundCommand } from './createPreviewBridgeClient'
11
+
12
+ const FROM = '5511999999999'
13
+
14
+ function createRecordingClient() {
15
+ const commands: PreviewInboundCommand[] = []
16
+ const client = createPreviewBridgeClient({
17
+ from: FROM,
18
+ sendCommand: async (command) => {
19
+ commands.push(command)
20
+ },
21
+ })
22
+ return { client, commands }
23
+ }
24
+
25
+ describe('createPreviewBridgeClient', () => {
26
+ it('entrega a intenção do cliente, carimbando o remetente em cada comando', async () => {
27
+ const { client, commands } = createRecordingClient()
28
+
29
+ await client.sendText('quero simular')
30
+ await client.sendButtonReply({ id: 'hab_pronto', title: 'Imóvel pronto' })
31
+ await client.sendListReply({ id: 'faixa_2', title: 'Faixa 2' })
32
+ await client.sendAudio('media-1')
33
+ await client.sendMedia({ mediaType: 'document', mediaId: 'media-2', filename: 'rg.pdf' })
34
+
35
+ expect(commands).toEqual([
36
+ { kind: 'text', from: FROM, text: 'quero simular' },
37
+ { kind: 'buttonReply', from: FROM, reply: { id: 'hab_pronto', title: 'Imóvel pronto' } },
38
+ { kind: 'listReply', from: FROM, reply: { id: 'faixa_2', title: 'Faixa 2' } },
39
+ { kind: 'audio', from: FROM, mediaId: 'media-1' },
40
+ { kind: 'media', from: FROM, mediaType: 'document', mediaId: 'media-2', filename: 'rg.pdf' },
41
+ ])
42
+ })
43
+
44
+ it('nunca embute assinatura nem segredo no que sai do navegador', async () => {
45
+ const { client, commands } = createRecordingClient()
46
+
47
+ await client.sendText('oi')
48
+
49
+ const serialized = JSON.stringify(commands[0])
50
+ expect(serialized).not.toMatch(/sha256=/)
51
+ expect(serialized).not.toMatch(/secret/i)
52
+ expect(commands[0]).not.toHaveProperty('entry')
53
+ })
54
+
55
+ it('posta no endpoint do host com os headers de sessão que o host injeta', async () => {
56
+ const calls: Array<{ url: string; init: RequestInit }> = []
57
+ const client = createPreviewBridgeClient({
58
+ from: FROM,
59
+ endpointUrl: 'https://host.test/api/conversations/preview/inbound',
60
+ headers: { authorization: 'Bearer token-do-painel' },
61
+ fetchImplementation: (async (url: string, init: RequestInit) => {
62
+ calls.push({ url, init })
63
+ return { ok: true } as Response
64
+ }) as unknown as typeof fetch,
65
+ })
66
+
67
+ await client.sendText('oi')
68
+
69
+ expect(calls[0]?.url).toBe('https://host.test/api/conversations/preview/inbound')
70
+ expect(calls[0]?.init.method).toBe('POST')
71
+ expect(calls[0]?.init.headers).toMatchObject({
72
+ 'content-type': 'application/json',
73
+ authorization: 'Bearer token-do-painel',
74
+ })
75
+ expect(JSON.parse(String(calls[0]?.init.body))).toEqual({ kind: 'text', from: FROM, text: 'oi' })
76
+ })
77
+
78
+ it('converte recusa do host em erro tipado, para o painel poder mostrar o motivo', async () => {
79
+ const client = createPreviewBridgeClient({
80
+ from: FROM,
81
+ endpointUrl: 'https://host.test/preview',
82
+ fetchImplementation: (async () => ({ ok: false, status: 403 }) as Response) as unknown as typeof fetch,
83
+ })
84
+
85
+ await expect(client.sendText('oi')).rejects.toBeInstanceOf(PreviewBridgeRejectedError)
86
+ await expect(client.sendText('oi')).rejects.toThrow(/403/)
87
+ })
88
+
89
+ it('recusa configuração sem forma de entregar, em vez de falhar só no primeiro envio', () => {
90
+ expect(() => createPreviewBridgeClient({ from: FROM })).toThrow(/sendCommand.*endpointUrl/)
91
+ })
92
+ })
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Cliente do preview que NÃO carrega segredo: em vez de montar e assinar o payload da Meta no
3
+ * navegador, manda um comando semântico (`{ kind: 'text', text }`) para uma rota do próprio host,
4
+ * autenticada pela sessão que o painel já tem. Quem monta o payload e assina é o servidor, com o
5
+ * app secret que nunca sai de lá.
6
+ *
7
+ * Por que esta fábrica existe ao lado de `createPreviewWebhookClient`: assinar no navegador exige o
8
+ * app secret dentro do bundle, e bundle é público por definição — em qualquer ambiente com URL
9
+ * acessível isso é o mesmo que publicar o segredo. Com o segredo vazado, qualquer um forja webhooks
10
+ * válidos daquele app: injeta mensagens de qualquer número e dispara os fluxos. `createPreviewWebhook
11
+ * Client` continua servindo para execução puramente local (docker de dev, onde o bundle não é
12
+ * servido para ninguém); para qualquer ambiente publicado, a ponte é o caminho.
13
+ *
14
+ * O pacote não decide autenticação: o host injeta `sendCommand` (ou `headers` + `fetchImplementation`),
15
+ * porque token, cookie e cabeçalho de sessão são do produto, não da biblioteca.
16
+ */
17
+
18
+ import type { InboundMediaType, InteractiveReplyOption } from '@adatechnology/meta-whatsapp-contracts/testing'
19
+ import type { PreviewWebhookClient, SendPreviewMediaParams } from './createPreviewWebhookClient'
20
+
21
+ /**
22
+ * Comando semântico entregue ao host. É deliberadamente o QUE o cliente fez, não o payload da Meta:
23
+ * se o navegador mandasse o payload pronto, a rota viraria um injetor de webhook arbitrário para
24
+ * quem tivesse sessão. Mandando a intenção, o servidor é quem escolhe a forma.
25
+ */
26
+ export type PreviewInboundCommand =
27
+ | { readonly kind: 'text'; readonly from: string; readonly text: string }
28
+ | { readonly kind: 'buttonReply'; readonly from: string; readonly reply: InteractiveReplyOption }
29
+ | { readonly kind: 'listReply'; readonly from: string; readonly reply: InteractiveReplyOption }
30
+ | { readonly kind: 'audio'; readonly from: string; readonly mediaId: string }
31
+ | ({ readonly kind: 'media'; readonly from: string } & SendPreviewMediaParams)
32
+
33
+ export type SendPreviewInboundCommand = (command: PreviewInboundCommand) => Promise<void>
34
+
35
+ export class PreviewBridgeRejectedError extends Error {
36
+ constructor(readonly status: number) {
37
+ super(`A rota de preview do host recusou a entrega (HTTP ${status}).`)
38
+ this.name = 'PreviewBridgeRejectedError'
39
+ }
40
+ }
41
+
42
+ export type CreatePreviewBridgeClientParams = {
43
+ readonly from: string
44
+ /**
45
+ * Entrega o comando. Use quando o host já tem um cliente HTTP com sessão, interceptors e refresh
46
+ * de token — reimplementar isso aqui só duplicaria a autenticação do produto.
47
+ */
48
+ readonly sendCommand?: SendPreviewInboundCommand
49
+ /** Alternativa a `sendCommand` para hosts sem cliente HTTP próprio. */
50
+ readonly endpointUrl?: string
51
+ readonly headers?: Readonly<Record<string, string>>
52
+ readonly fetchImplementation?: typeof fetch
53
+ }
54
+
55
+ function buildFetchSender(params: CreatePreviewBridgeClientParams): SendPreviewInboundCommand {
56
+ const endpointUrl = params.endpointUrl
57
+ if (!endpointUrl) {
58
+ throw new Error('createPreviewBridgeClient exige `sendCommand` ou `endpointUrl`.')
59
+ }
60
+
61
+ return async (command) => {
62
+ const performRequest = params.fetchImplementation ?? fetch
63
+ const response = await performRequest(endpointUrl, {
64
+ method: 'POST',
65
+ // `credentials` fica com o host via `headers`/`fetchImplementation`: sessão por cookie e por
66
+ // bearer não convivem numa escolha default sem quebrar um dos dois.
67
+ headers: { 'content-type': 'application/json', ...params.headers },
68
+ body: JSON.stringify(command),
69
+ })
70
+
71
+ if (!response.ok) throw new PreviewBridgeRejectedError(response.status)
72
+ }
73
+ }
74
+
75
+ export function createPreviewBridgeClient(params: CreatePreviewBridgeClientParams): PreviewWebhookClient {
76
+ const send = params.sendCommand ?? buildFetchSender(params)
77
+ const from = params.from
78
+
79
+ return {
80
+ sendText: (text) => send({ kind: 'text', from, text }),
81
+ sendButtonReply: (reply) => send({ kind: 'buttonReply', from, reply }),
82
+ sendListReply: (reply) => send({ kind: 'listReply', from, reply }),
83
+ sendAudio: (mediaId) => send({ kind: 'audio', from, mediaId }),
84
+ sendMedia: (media) => send({ kind: 'media', from, ...media }),
85
+ }
86
+ }
87
+
88
+ export type { InboundMediaType }
@@ -6,9 +6,14 @@
6
6
  * Assina com WebCrypto porque `node:crypto` não existe no navegador. Os builders vêm dos contratos
7
7
  * (isomórficos) justamente para que o mesmo payload seja montado nos dois runtimes.
8
8
  *
9
- * ⚠️ Isto carrega o app secret de DESENVOLVIMENTO no bundle. existe para o docker local, e
10
- * `assertPreviewEnvironment` recusa rodar em produção um segredo de dev vazado é irrelevante,
11
- * mas o hábito de embarcar segredo em frontend não é.
9
+ * ⚠️ SOMENTE EXECUÇÃO LOCAL. Isto carrega o app secret no bundle, e bundle é público onde quer que
10
+ * seja servido em qualquer ambiente com URL acessível (homologação inclusive) usar esta fábrica
11
+ * equivale a publicar o segredo, e quem o tiver forja webhooks válidos daquele app da Meta: injeta
12
+ * mensagem de qualquer número e dispara os fluxos. `assertPreviewEnvironment` barra produção, mas
13
+ * homologação passaria, então a barreira não basta.
14
+ *
15
+ * Para qualquer ambiente publicado use `createPreviewBridgeClient`: o navegador manda a intenção e
16
+ * o servidor assina com o segredo que ele já tem.
12
17
  */
13
18
 
14
19
  import {
@@ -45,6 +45,13 @@ export type {
45
45
  SendPreviewMediaParams,
46
46
  } from './createPreviewWebhookClient'
47
47
 
48
+ export { createPreviewBridgeClient, PreviewBridgeRejectedError } from './createPreviewBridgeClient'
49
+ export type {
50
+ CreatePreviewBridgeClientParams,
51
+ PreviewInboundCommand,
52
+ SendPreviewInboundCommand,
53
+ } from './createPreviewBridgeClient'
54
+
48
55
  export { startPreviewScript, DEFAULT_PREVIEW_SCRIPT } from './startPreviewScript'
49
56
  export type { PreviewScriptStep, StartPreviewScriptParams } from './startPreviewScript'
50
57
  export { PREVIEW_FILE_SAMPLES, resolvePreviewFileSample } from './previewFileSamples'