@usebridger/transport-web 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,709 @@
1
+ import { a as CLOSE_TOO_BIG, c as DEFAULT_CONVERSATION, i as CLOSE_INVALID_AUTH, l as MAX_ATTACHMENTS, n as CLOSE_FORBIDDEN, o as CLOSE_UNAUTHORIZED, r as CLOSE_GOING_AWAY, s as CONVERSATION_PATTERN, t as AUTH_TIMEOUT_MS, u as MAX_FRAME_BYTES } from "./protocol-a9D3CajG.mjs";
2
+ import { createRemoteJWKSet, jwtVerify } from "jose";
3
+ import { randomBytes, randomUUID } from "node:crypto";
4
+ import { UnsupportedError } from "@usebridger/core";
5
+ import { TypedEmitter, assertCanSend, createMessage, groupActionCapability } from "@usebridger/core/adapter";
6
+ //#region src/jwt.ts
7
+ /** Lançado para qualquer token recusado; a mensagem é do `jose` ou do transport. */
8
+ var InvalidTokenError = class extends Error {
9
+ name = "InvalidTokenError";
10
+ };
11
+ const SECRET_ALGORITHMS = ["HS256"];
12
+ const JWKS_ALGORITHMS = ["RS256", "ES256"];
13
+ /** Monta o verificador. Valida a config na hora: a fábrica a chama, e o erro vira `BotConfigError`. */
14
+ function createVerifier(auth) {
15
+ let key;
16
+ let algorithms;
17
+ if (typeof auth.secret === "string") {
18
+ if (Buffer.byteLength(auth.secret) < 32) throw new TypeError("web: auth.secret precisa de pelo menos 32 bytes");
19
+ key = new TextEncoder().encode(auth.secret);
20
+ algorithms = SECRET_ALGORITHMS;
21
+ } else if (typeof auth.jwksUrl === "string") {
22
+ key = createRemoteJWKSet(new URL(auth.jwksUrl));
23
+ algorithms = JWKS_ALGORITHMS;
24
+ } else throw new TypeError("web: auth precisa de `secret` ou de `jwksUrl`");
25
+ const options = {
26
+ algorithms,
27
+ requiredClaims: ["exp", "sub"],
28
+ ...auth.issuer === void 0 ? null : { issuer: auth.issuer },
29
+ ...auth.audience === void 0 ? null : { audience: auth.audience }
30
+ };
31
+ return async (token) => {
32
+ let payload;
33
+ try {
34
+ ({payload} = key instanceof Uint8Array ? await jwtVerify(token, key, options) : await jwtVerify(token, key, options));
35
+ } catch (error) {
36
+ throw new InvalidTokenError(error instanceof Error ? error.message : String(error), { cause: error });
37
+ }
38
+ if (typeof payload.sub !== "string" || payload.sub === "" || typeof payload.exp !== "number") throw new InvalidTokenError("token sem `sub` em texto ou sem `exp` numérico");
39
+ return {
40
+ sub: payload.sub,
41
+ expiresAt: payload.exp * 1e3,
42
+ claims: payload
43
+ };
44
+ };
45
+ }
46
+ /** Token do header `Authorization: Bearer <jwt>`, ou `null`. */
47
+ function bearerToken(request) {
48
+ const header = request.headers.get("authorization");
49
+ return (header === null ? null : /^Bearer\s+(\S+)$/i.exec(header))?.[1] ?? null;
50
+ }
51
+ //#endregion
52
+ //#region src/media-store.ts
53
+ const MEDIA_TTL_MS = 36e5;
54
+ var MediaStore = class {
55
+ #entries = /* @__PURE__ */ new Map();
56
+ #bytes = 0;
57
+ #now;
58
+ #maxBytes;
59
+ constructor(options = {}) {
60
+ this.#now = options.now ?? Date.now;
61
+ this.#maxBytes = options.maxBytes ?? 268435456;
62
+ }
63
+ /** Guarda e devolve um ID aleatório de 128 bits, que serve de URL de download. */
64
+ put(media) {
65
+ this.#prune();
66
+ const id = randomBytes(16).toString("base64url");
67
+ this.#entries.set(id, {
68
+ ...media,
69
+ expiresAt: this.#now() + MEDIA_TTL_MS
70
+ });
71
+ this.#bytes += media.bytes.length;
72
+ for (const [oldest, entry] of this.#entries) {
73
+ if (this.#bytes <= this.#maxBytes || oldest === id) break;
74
+ this.#delete(oldest, entry);
75
+ }
76
+ return id;
77
+ }
78
+ /** O arquivo, se ainda vale. */
79
+ get(id) {
80
+ const entry = this.#entries.get(id);
81
+ if (entry === void 0) return void 0;
82
+ if (entry.expiresAt <= this.#now()) {
83
+ this.#delete(id, entry);
84
+ return;
85
+ }
86
+ return entry;
87
+ }
88
+ /**
89
+ * Tira os uploads de `owner` do mapa, todos ou nenhum: a mensagem passa a segurar os bytes, e o
90
+ * mesmo upload não entra em duas mensagens. `undefined` se algum não existe, venceu ou é de
91
+ * outro contato.
92
+ */
93
+ takeAll(ids, owner) {
94
+ const now = this.#now();
95
+ const found = [];
96
+ for (const id of ids) {
97
+ const entry = this.#entries.get(id);
98
+ if (entry === void 0 || entry.owner !== owner || entry.expiresAt <= now) return void 0;
99
+ found.push(entry);
100
+ }
101
+ for (const [index, id] of ids.entries()) if (this.#entries.delete(id)) this.#bytes -= found[index].bytes.length;
102
+ return found;
103
+ }
104
+ clear() {
105
+ this.#entries.clear();
106
+ this.#bytes = 0;
107
+ }
108
+ get size() {
109
+ return this.#entries.size;
110
+ }
111
+ #prune() {
112
+ const now = this.#now();
113
+ for (const [id, entry] of this.#entries) {
114
+ if (entry.expiresAt > now) break;
115
+ this.#delete(id, entry);
116
+ }
117
+ }
118
+ #delete(id, entry) {
119
+ this.#entries.delete(id);
120
+ this.#bytes -= entry.bytes.length;
121
+ }
122
+ };
123
+ const OUTBOX_TTL_MS = 36e5;
124
+ var Outbox = class {
125
+ #chats = /* @__PURE__ */ new Map();
126
+ #now;
127
+ constructor(now = Date.now) {
128
+ this.#now = now;
129
+ }
130
+ push(chatId, frame) {
131
+ const now = this.#now();
132
+ this.#prune(now);
133
+ const pending = this.#chats.get(chatId) ?? [];
134
+ this.#chats.delete(chatId);
135
+ pending.push({
136
+ frame,
137
+ expiresAt: now + OUTBOX_TTL_MS
138
+ });
139
+ if (pending.length > 100) pending.shift();
140
+ this.#chats.set(chatId, pending);
141
+ }
142
+ /** Tira e devolve o que ainda vale para a conversa, em ordem. */
143
+ drain(chatId) {
144
+ const pending = this.#chats.get(chatId);
145
+ if (pending === void 0) return [];
146
+ this.#chats.delete(chatId);
147
+ const now = this.#now();
148
+ return pending.filter((item) => item.expiresAt > now).map((item) => item.frame);
149
+ }
150
+ clear() {
151
+ this.#chats.clear();
152
+ }
153
+ get size() {
154
+ return this.#chats.size;
155
+ }
156
+ /** Solta as conversas cujo frame mais novo já venceu: todos os outros venceram antes. */
157
+ #prune(now) {
158
+ for (const [chatId, pending] of this.#chats) {
159
+ const newest = pending.at(-1);
160
+ if (newest !== void 0 && newest.expiresAt > now) break;
161
+ this.#chats.delete(chatId);
162
+ }
163
+ }
164
+ };
165
+ //#endregion
166
+ //#region src/transport.ts
167
+ const DEFAULT_MEDIA_MAX_BYTES = 10485760;
168
+ const CAPABILITIES = [
169
+ "send.text",
170
+ "send.image",
171
+ "send.video",
172
+ "send.audio",
173
+ "send.document",
174
+ "media.download",
175
+ "actions",
176
+ "quoted",
177
+ "typing",
178
+ "message.edit",
179
+ "message.delete"
180
+ ];
181
+ const SELF = {
182
+ id: "bot",
183
+ name: null,
184
+ phone: null,
185
+ isBot: true
186
+ };
187
+ /** Maior atraso que o `setTimeout` aceita; acima dele, o timer dispara na hora. */
188
+ const MAX_TIMER_MS = 2 ** 31 - 1;
189
+ /** Token recusado por falta do claim de tenant: válido, mas sem lugar neste transport. */
190
+ var ForbiddenError = class extends Error {
191
+ name = "ForbiddenError";
192
+ };
193
+ var WebTransport = class {
194
+ name = "web";
195
+ capabilities = new Set(CAPABILITIES);
196
+ native = null;
197
+ #options;
198
+ #verify;
199
+ #routes;
200
+ #log;
201
+ #events;
202
+ #maxBytes;
203
+ #connections = /* @__PURE__ */ new Set();
204
+ /** Conexões autenticadas por conversa; várias abas na mesma conversa recebem os mesmos frames. */
205
+ #chats = /* @__PURE__ */ new Map();
206
+ #outbox = new Outbox();
207
+ #media = new MediaStore();
208
+ #connected = false;
209
+ #self = null;
210
+ constructor(options, verify, routes, log) {
211
+ this.#options = options;
212
+ this.#verify = verify;
213
+ this.#routes = routes;
214
+ this.#log = log;
215
+ this.#maxBytes = options.media?.maxBytes ?? 10485760;
216
+ this.#events = new TypedEmitter((error, event) => this.#log.error("handler de evento do transport falhou", {
217
+ err: error,
218
+ event
219
+ }));
220
+ routes.ws("/chat", (request) => this.#accept(request));
221
+ routes.route("POST", "/media", (request) => this.#upload(request));
222
+ routes.route("OPTIONS", "/media", (request) => this.#preflight(request));
223
+ routes.route("GET", "/media/:id", (request, { params }) => this.#download(request, params["id"] ?? ""));
224
+ }
225
+ get self() {
226
+ return this.#self;
227
+ }
228
+ async connect() {
229
+ this.#connected = true;
230
+ this.#self = SELF;
231
+ this.#events.emit("connection.status", { status: "open" });
232
+ }
233
+ async disconnect() {
234
+ this.#connected = false;
235
+ for (const connection of this.#connections) {
236
+ clearTimeout(connection.timer);
237
+ connection.closed = true;
238
+ connection.socket?.close(CLOSE_GOING_AWAY, "transport desconectado");
239
+ }
240
+ this.#connections.clear();
241
+ this.#chats.clear();
242
+ this.#outbox.clear();
243
+ this.#media.clear();
244
+ }
245
+ on(event, handler) {
246
+ return this.#events.on(event, handler);
247
+ }
248
+ async send(chatId, content, options) {
249
+ this.#assertConnected();
250
+ assertCanSend(this, content, options);
251
+ const frame = this.#messageFrame(content, options);
252
+ this.#deliver(chatId, frame);
253
+ return {
254
+ chatId,
255
+ id: frame.id,
256
+ fromMe: true,
257
+ senderId: null
258
+ };
259
+ }
260
+ async react() {
261
+ throw new UnsupportedError("reactions", this.name);
262
+ }
263
+ async edit(key, text, formatted) {
264
+ this.#assertConnected();
265
+ this.#deliver(key.chatId, {
266
+ type: "edit",
267
+ id: key.id,
268
+ text,
269
+ ...formatted === void 0 ? null : { formatted }
270
+ });
271
+ }
272
+ async delete(key) {
273
+ this.#assertConnected();
274
+ this.#deliver(key.chatId, {
275
+ type: "delete",
276
+ id: key.id
277
+ });
278
+ }
279
+ async sendTyping(chatId, kind) {
280
+ this.#assertConnected();
281
+ for (const connection of this.#chats.get(chatId) ?? []) this.#write(connection, {
282
+ type: "typing",
283
+ kind
284
+ });
285
+ }
286
+ async getGroupMetadata() {
287
+ throw new UnsupportedError("groups", this.name);
288
+ }
289
+ async updateGroupParticipants(_groupId, _participantIds, action) {
290
+ throw new UnsupportedError(groupActionCapability(action), this.name);
291
+ }
292
+ #accept(request) {
293
+ if (!this.#connected) return new Response("transport desconectado", { status: 503 });
294
+ if (!this.#originAllowed(request)) return new Response("origem recusada", { status: 403 });
295
+ const connection = {
296
+ socket: null,
297
+ chat: null,
298
+ sender: null,
299
+ authenticating: false,
300
+ timer: void 0,
301
+ closed: false
302
+ };
303
+ return {
304
+ onOpen: (socket) => {
305
+ connection.socket = socket;
306
+ this.#connections.add(connection);
307
+ connection.timer = setTimeout(() => this.#close(connection, CLOSE_UNAUTHORIZED, "auth não chegou"), AUTH_TIMEOUT_MS);
308
+ },
309
+ onMessage: (_socket, data) => this.#onFrame(connection, data),
310
+ onClose: () => this.#forget(connection)
311
+ };
312
+ }
313
+ async #onFrame(connection, data) {
314
+ if (connection.closed) return;
315
+ if ((typeof data === "string" ? Buffer.byteLength(data) : data.byteLength) > 65536) {
316
+ this.#close(connection, CLOSE_TOO_BIG, "frame grande demais");
317
+ return;
318
+ }
319
+ const frame = typeof data === "string" ? parseJson(data) : void 0;
320
+ if (connection.chat === null) {
321
+ await this.#authenticate(connection, frame);
322
+ return;
323
+ }
324
+ if (!isRecord(frame)) {
325
+ this.#reject(connection, "invalid-frame", "frame não é um objeto JSON");
326
+ return;
327
+ }
328
+ if (frame["type"] === "message") this.#onMessage(connection, frame);
329
+ else if (frame["type"] === "action") this.#onAction(connection, frame);
330
+ else this.#reject(connection, "invalid-frame", "tipo de frame desconhecido");
331
+ }
332
+ async #authenticate(connection, frame) {
333
+ if (connection.authenticating || !isRecord(frame) || frame["type"] !== "auth") {
334
+ this.#close(connection, CLOSE_INVALID_AUTH, "o primeiro frame deve ser `auth`");
335
+ return;
336
+ }
337
+ const { token, conversation = DEFAULT_CONVERSATION } = frame;
338
+ if (typeof token !== "string" || typeof conversation !== "string" || !CONVERSATION_PATTERN.test(conversation)) {
339
+ this.#close(connection, CLOSE_INVALID_AUTH, "`auth` com token ou conversa inválidos");
340
+ return;
341
+ }
342
+ connection.authenticating = true;
343
+ let identity;
344
+ try {
345
+ identity = await this.#identify(token);
346
+ } catch (error) {
347
+ this.#refuse(connection, error);
348
+ return;
349
+ }
350
+ if (connection.closed) return;
351
+ const chatId = `${identity.contact.id}/${conversation}`;
352
+ connection.chat = {
353
+ id: chatId,
354
+ isGroup: false,
355
+ kind: "dm",
356
+ ...identity.tenantId === void 0 ? null : { tenantId: identity.tenantId }
357
+ };
358
+ connection.sender = identity.contact;
359
+ connection.authenticating = false;
360
+ clearTimeout(connection.timer);
361
+ this.#expireAt(connection, identity.expiresAt);
362
+ let peers = this.#chats.get(chatId);
363
+ if (peers === void 0) {
364
+ peers = /* @__PURE__ */ new Set();
365
+ this.#chats.set(chatId, peers);
366
+ }
367
+ peers.add(connection);
368
+ this.#write(connection, {
369
+ type: "ready",
370
+ chatId,
371
+ userId: identity.contact.id
372
+ });
373
+ for (const pending of this.#outbox.drain(chatId)) this.#write(connection, pending);
374
+ }
375
+ #refuse(connection, error) {
376
+ if (error instanceof ForbiddenError) {
377
+ this.#log.debug("conexão recusada sem o claim de tenant", { reason: error.message });
378
+ this.#close(connection, CLOSE_FORBIDDEN, error.message);
379
+ return;
380
+ }
381
+ if (error instanceof InvalidTokenError) {
382
+ this.#log.debug("token recusado", { reason: error.message });
383
+ this.#close(connection, CLOSE_UNAUTHORIZED, "token inválido");
384
+ return;
385
+ }
386
+ this.#log.warn("não deu para verificar o token", { err: error });
387
+ this.#close(connection, CLOSE_UNAUTHORIZED, "não deu para verificar o token");
388
+ }
389
+ /** Fecha a conexão no `exp`; o cliente reconecta com um token novo (ADR 0077). */
390
+ #expireAt(connection, expiresAt) {
391
+ const remaining = expiresAt - Date.now();
392
+ if (remaining <= 0) {
393
+ this.#close(connection, CLOSE_UNAUTHORIZED, "token venceu");
394
+ return;
395
+ }
396
+ connection.timer = setTimeout(() => this.#expireAt(connection, expiresAt), Math.min(remaining, MAX_TIMER_MS));
397
+ }
398
+ #onMessage(connection, frame) {
399
+ const { ref, text, attachments = [] } = frame;
400
+ const chat = connection.chat;
401
+ const sender = connection.sender;
402
+ if (ref !== void 0 && (typeof ref !== "string" || ref.length > 128)) {
403
+ this.#reject(connection, "invalid-frame", "`ref` deve ser texto de até 128 caracteres");
404
+ return;
405
+ }
406
+ if (text !== void 0 && typeof text !== "string" || !Array.isArray(attachments) || attachments.length > 10 || !attachments.every((id) => typeof id === "string") || (text ?? "") === "" && attachments.length === 0) {
407
+ this.#reject(connection, "invalid-frame", `mensagem precisa de \`text\` ou de até 10 \`attachments\``, ref);
408
+ return;
409
+ }
410
+ const media = this.#media.takeAll(attachments, sender.id);
411
+ if (media === void 0) {
412
+ this.#reject(connection, "media-not-found", "anexo inexistente, vencido ou de outro usuário", ref);
413
+ return;
414
+ }
415
+ const id = randomUUID();
416
+ this.#write(connection, {
417
+ type: "ack",
418
+ ref: ref ?? null,
419
+ id
420
+ });
421
+ const base = {
422
+ id,
423
+ chat,
424
+ sender,
425
+ timestamp: Date.now(),
426
+ fromMe: false
427
+ };
428
+ const sources = media.map(toSource);
429
+ const first = sources[0];
430
+ const message = first === void 0 ? createMessage({
431
+ ...base,
432
+ type: "text",
433
+ text
434
+ }) : createMessage({
435
+ ...base,
436
+ ...mediaType(first.mimetype, first.fileName),
437
+ text: text || null,
438
+ media: first,
439
+ attachments: sources
440
+ });
441
+ this.#events.emit("message", message);
442
+ }
443
+ #onAction(connection, frame) {
444
+ const { actionId } = frame;
445
+ if (typeof actionId !== "string" || actionId === "" || actionId.length > 64) {
446
+ this.#reject(connection, "invalid-frame", "`actionId` deve ser texto de até 64 caracteres");
447
+ return;
448
+ }
449
+ this.#events.emit("interaction", {
450
+ id: randomUUID(),
451
+ chat: connection.chat,
452
+ sender: connection.sender,
453
+ timestamp: Date.now(),
454
+ actionId
455
+ });
456
+ }
457
+ #forget(connection) {
458
+ clearTimeout(connection.timer);
459
+ connection.closed = true;
460
+ this.#connections.delete(connection);
461
+ const chatId = connection.chat?.id;
462
+ if (chatId === void 0) return;
463
+ const peers = this.#chats.get(chatId);
464
+ peers?.delete(connection);
465
+ if (peers?.size === 0) this.#chats.delete(chatId);
466
+ }
467
+ #close(connection, code, reason) {
468
+ if (connection.closed) return;
469
+ this.#forget(connection);
470
+ connection.socket?.close(code, reason);
471
+ }
472
+ #reject(connection, code, message, ref) {
473
+ this.#write(connection, {
474
+ type: "error",
475
+ code,
476
+ message,
477
+ ...typeof ref === "string" ? { ref } : null
478
+ });
479
+ }
480
+ #write(connection, frame) {
481
+ connection.socket?.send(JSON.stringify(frame));
482
+ }
483
+ /** Para quem está na conversa, ou para o buffer se ninguém estiver. */
484
+ #deliver(chatId, frame) {
485
+ const peers = this.#chats.get(chatId);
486
+ if (peers === void 0) {
487
+ this.#outbox.push(chatId, frame);
488
+ return;
489
+ }
490
+ for (const connection of peers) this.#write(connection, frame);
491
+ }
492
+ #messageFrame(content, options) {
493
+ const common = {
494
+ type: "message",
495
+ id: randomUUID(),
496
+ timestamp: Date.now(),
497
+ ...options?.quoted === void 0 ? null : { quotedId: options.quoted.id }
498
+ };
499
+ switch (content.type) {
500
+ case "text": return {
501
+ ...common,
502
+ kind: "text",
503
+ text: content.text,
504
+ ...content.formatted === void 0 ? null : { formatted: content.formatted },
505
+ ...options?.actions === void 0 || options.actions.length === 0 ? null : { actions: options.actions.map(({ id, label }) => ({
506
+ id,
507
+ label
508
+ })) }
509
+ };
510
+ case "image":
511
+ case "video":
512
+ case "audio":
513
+ case "document": {
514
+ const caption = "caption" in content ? content.caption : void 0;
515
+ const formatted = "formattedCaption" in content ? content.formattedCaption : void 0;
516
+ return {
517
+ ...common,
518
+ kind: content.type,
519
+ media: this.#mediaRef(content),
520
+ ...caption === void 0 ? null : { text: caption },
521
+ ...formatted === void 0 ? null : { formatted }
522
+ };
523
+ }
524
+ default: throw new UnsupportedError("send.album", this.name);
525
+ }
526
+ }
527
+ #mediaRef(content) {
528
+ const named = content.fileName === void 0 ? null : { fileName: content.fileName };
529
+ const mimetype = content.mimetype ?? "application/octet-stream";
530
+ if (!Buffer.isBuffer(content.media)) return {
531
+ url: content.media.url,
532
+ mimetype,
533
+ ...named
534
+ };
535
+ const id = this.#media.put({
536
+ bytes: content.media,
537
+ mimetype,
538
+ ...named
539
+ });
540
+ return {
541
+ url: `${this.#routes.basePath}/media/${id}`,
542
+ mimetype,
543
+ ...named
544
+ };
545
+ }
546
+ async #upload(request) {
547
+ if (!this.#connected) return new Response("transport desconectado", { status: 503 });
548
+ const cors = this.#cors(request);
549
+ if (cors === null) return new Response("origem recusada", { status: 403 });
550
+ const token = bearerToken(request);
551
+ if (token === null) return text("falta o header Authorization: Bearer", 401, cors);
552
+ let identity;
553
+ try {
554
+ identity = await this.#identify(token);
555
+ } catch (error) {
556
+ if (error instanceof ForbiddenError) return text(error.message, 403, cors);
557
+ if (error instanceof InvalidTokenError) return text("token inválido", 401, cors);
558
+ this.#log.warn("não deu para verificar o token do upload", { err: error });
559
+ return text("não deu para verificar o token", 503, cors);
560
+ }
561
+ const mimetype = request.headers.get("content-type")?.split(";")[0]?.trim();
562
+ if (!mimetype) return text("falta o Content-Type", 415, cors);
563
+ if (Number(request.headers.get("content-length") ?? 0) > this.#maxBytes) return text("arquivo grande demais", 413, cors);
564
+ const bytes = await readLimited(request, this.#maxBytes);
565
+ if (bytes === null) return text("arquivo grande demais", 413, cors);
566
+ if (bytes.length === 0) return text("arquivo vazio", 400, cors);
567
+ const name = new URL(request.url).searchParams.get("name");
568
+ const id = this.#media.put({
569
+ bytes,
570
+ mimetype,
571
+ owner: identity.contact.id,
572
+ ...name ? { fileName: name.slice(0, 255) } : null
573
+ });
574
+ return Response.json({ id }, {
575
+ status: 201,
576
+ headers: cors
577
+ });
578
+ }
579
+ #preflight(request) {
580
+ const cors = this.#cors(request);
581
+ if (cors === null) return new Response("origem recusada", { status: 403 });
582
+ return new Response(null, {
583
+ status: 204,
584
+ headers: {
585
+ ...cors,
586
+ "access-control-allow-methods": "POST",
587
+ "access-control-allow-headers": "authorization, content-type",
588
+ "access-control-max-age": "600"
589
+ }
590
+ });
591
+ }
592
+ #download(request, id) {
593
+ const cors = this.#cors(request) ?? {};
594
+ const media = this.#media.get(id);
595
+ if (media === void 0 || media.owner !== void 0) return text("não encontrado", 404, cors);
596
+ const inline = /^(image|video|audio)\//.test(media.mimetype);
597
+ const name = media.fileName === void 0 ? "" : `; filename*=UTF-8''${encodeURIComponent(media.fileName)}`;
598
+ return new Response(media.bytes, { headers: {
599
+ ...cors,
600
+ "content-type": media.mimetype,
601
+ "content-disposition": `${inline ? "inline" : "attachment"}${name}`,
602
+ "x-content-type-options": "nosniff",
603
+ "content-security-policy": "sandbox",
604
+ "cache-control": "private, max-age=3600"
605
+ } });
606
+ }
607
+ async #identify(token) {
608
+ const verified = await this.#verify(token);
609
+ const claim = this.#options.tenantClaim;
610
+ let tenantId;
611
+ if (claim !== void 0) {
612
+ const value = verified.claims[claim];
613
+ if (typeof value !== "string" && typeof value !== "number" || value === "") throw new ForbiddenError(`token sem o claim de tenant "${claim}"`);
614
+ tenantId = String(value);
615
+ }
616
+ const sub = encodeURIComponent(verified.sub);
617
+ const id = tenantId === void 0 ? sub : `${encodeURIComponent(tenantId)}:${sub}`;
618
+ const name = verified.claims["name"];
619
+ return {
620
+ contact: {
621
+ id,
622
+ name: typeof name === "string" ? name : null,
623
+ phone: null,
624
+ claims: verified.claims
625
+ },
626
+ ...tenantId === void 0 ? null : { tenantId },
627
+ expiresAt: verified.expiresAt
628
+ };
629
+ }
630
+ #originAllowed(request) {
631
+ const origin = request.headers.get("origin");
632
+ return origin === null || this.#options.origins === void 0 || this.#options.origins.includes(origin);
633
+ }
634
+ /** Headers de CORS para a origem do pedido; `null` se ela não é aceita. */
635
+ #cors(request) {
636
+ if (!this.#originAllowed(request)) return null;
637
+ const origin = request.headers.get("origin");
638
+ if (origin === null) return {};
639
+ return this.#options.origins === void 0 ? { "access-control-allow-origin": "*" } : {
640
+ "access-control-allow-origin": origin,
641
+ vary: "Origin"
642
+ };
643
+ }
644
+ #assertConnected() {
645
+ if (!this.#connected) throw new Error("web: transport desconectado");
646
+ }
647
+ };
648
+ function parseJson(data) {
649
+ try {
650
+ return JSON.parse(data);
651
+ } catch {
652
+ return;
653
+ }
654
+ }
655
+ function isRecord(value) {
656
+ return typeof value === "object" && value !== null && !Array.isArray(value);
657
+ }
658
+ function text(body, status, headers) {
659
+ return new Response(body, {
660
+ status,
661
+ headers
662
+ });
663
+ }
664
+ /** Corpo inteiro, ou `null` se passar de `max` (o `Content-Length` pode mentir ou faltar). */
665
+ async function readLimited(request, max) {
666
+ if (request.body === null) return Buffer.alloc(0);
667
+ const chunks = [];
668
+ let total = 0;
669
+ for await (const chunk of request.body) {
670
+ total += chunk.byteLength;
671
+ if (total > max) return null;
672
+ chunks.push(chunk);
673
+ }
674
+ return Buffer.concat(chunks);
675
+ }
676
+ function toSource(media) {
677
+ return {
678
+ mimetype: media.mimetype,
679
+ size: media.bytes.length,
680
+ ...media.fileName === void 0 ? null : { fileName: media.fileName },
681
+ download: async () => media.bytes
682
+ };
683
+ }
684
+ /** Tipo da mensagem pelo mimetype do primeiro anexo (ADR 0065). */
685
+ function mediaType(mimetype, fileName) {
686
+ if (mimetype.startsWith("image/")) return { type: "image" };
687
+ if (mimetype.startsWith("video/")) return { type: "video" };
688
+ if (mimetype.startsWith("audio/")) return { type: "audio" };
689
+ return {
690
+ type: "document",
691
+ fileName: fileName ?? null
692
+ };
693
+ }
694
+ //#endregion
695
+ //#region src/index.ts
696
+ function web(options) {
697
+ const verify = createVerifier(options.auth);
698
+ const maxBytes = options.media?.maxBytes;
699
+ if (maxBytes !== void 0 && (!Number.isInteger(maxBytes) || maxBytes <= 0)) throw new RangeError(`web: media.maxBytes deve ser inteiro positivo; recebido ${maxBytes}`);
700
+ if (options.tenantClaim === "") throw new TypeError("web: tenantClaim vazio");
701
+ return (deps) => {
702
+ if (deps.http === void 0) throw new TypeError("web: o transport precisa de `http` no createBot (ver createHttp)");
703
+ return new WebTransport(options, verify, deps.http, deps.log);
704
+ };
705
+ }
706
+ //#endregion
707
+ export { AUTH_TIMEOUT_MS, CLOSE_FORBIDDEN, CLOSE_GOING_AWAY, CLOSE_INVALID_AUTH, CLOSE_TOO_BIG, CLOSE_UNAUTHORIZED, CONVERSATION_PATTERN, DEFAULT_CONVERSATION, DEFAULT_MEDIA_MAX_BYTES, MAX_ATTACHMENTS, MAX_FRAME_BYTES, web };
708
+
709
+ //# sourceMappingURL=index.mjs.map