@dtelecom/secure-chat-client 0.1.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.cjs ADDED
@@ -0,0 +1,2189 @@
1
+ 'use strict';
2
+
3
+ var vodozemac = require('@dtelecom/vodozemac-wasm');
4
+
5
+ function _interopNamespace(e) {
6
+ if (e && e.__esModule) return e;
7
+ var n = Object.create(null);
8
+ if (e) {
9
+ Object.keys(e).forEach(function (k) {
10
+ if (k !== 'default') {
11
+ var d = Object.getOwnPropertyDescriptor(e, k);
12
+ Object.defineProperty(n, k, d.get ? d : {
13
+ enumerable: true,
14
+ get: function () { return e[k]; }
15
+ });
16
+ }
17
+ });
18
+ }
19
+ n.default = e;
20
+ return Object.freeze(n);
21
+ }
22
+
23
+ var vodozemac__namespace = /*#__PURE__*/_interopNamespace(vodozemac);
24
+
25
+ // src/content/protocol.ts
26
+ var CONTENT_PROTOCOL_VERSION = 1;
27
+ var enc = new TextEncoder();
28
+ var dec = new TextDecoder();
29
+ function encodeEvent(event) {
30
+ if (event.v !== CONTENT_PROTOCOL_VERSION) {
31
+ throw new Error(`encodeEvent: bad version ${event.v}`);
32
+ }
33
+ if (!event.id || !event.type) {
34
+ throw new Error("encodeEvent: missing id or type");
35
+ }
36
+ return JSON.stringify(event);
37
+ }
38
+ function decodeEvent(plaintext) {
39
+ let raw;
40
+ try {
41
+ raw = JSON.parse(plaintext);
42
+ } catch {
43
+ return null;
44
+ }
45
+ if (typeof raw !== "object" || raw === null) return null;
46
+ const obj = raw;
47
+ if (typeof obj.v !== "number" || obj.v > CONTENT_PROTOCOL_VERSION) return null;
48
+ if (typeof obj.id !== "string" || !obj.id) return null;
49
+ if (typeof obj.type !== "string") return null;
50
+ if (typeof obj.clientSentAt !== "number") return null;
51
+ switch (obj.type) {
52
+ case "text": {
53
+ if (typeof obj.text !== "string") return null;
54
+ const replyTo = typeof obj.replyTo === "string" ? obj.replyTo : void 0;
55
+ return {
56
+ v: 1,
57
+ id: obj.id,
58
+ type: "text",
59
+ clientSentAt: obj.clientSentAt,
60
+ text: obj.text,
61
+ ...replyTo !== void 0 ? { replyTo } : {}
62
+ };
63
+ }
64
+ case "edit": {
65
+ if (typeof obj.targetId !== "string" || typeof obj.text !== "string") return null;
66
+ return {
67
+ v: 1,
68
+ id: obj.id,
69
+ type: "edit",
70
+ clientSentAt: obj.clientSentAt,
71
+ targetId: obj.targetId,
72
+ text: obj.text
73
+ };
74
+ }
75
+ case "delete": {
76
+ if (typeof obj.targetId !== "string") return null;
77
+ return {
78
+ v: 1,
79
+ id: obj.id,
80
+ type: "delete",
81
+ clientSentAt: obj.clientSentAt,
82
+ targetId: obj.targetId
83
+ };
84
+ }
85
+ case "read": {
86
+ if (typeof obj.upToId !== "string") return null;
87
+ return {
88
+ v: 1,
89
+ id: obj.id,
90
+ type: "read",
91
+ clientSentAt: obj.clientSentAt,
92
+ upToId: obj.upToId
93
+ };
94
+ }
95
+ case "received": {
96
+ if (!Array.isArray(obj.ids) || !obj.ids.every((x) => typeof x === "string")) return null;
97
+ return {
98
+ v: 1,
99
+ id: obj.id,
100
+ type: "received",
101
+ clientSentAt: obj.clientSentAt,
102
+ ids: obj.ids
103
+ };
104
+ }
105
+ case "typing": {
106
+ if (obj.state !== "started" && obj.state !== "stopped") return null;
107
+ return {
108
+ v: 1,
109
+ id: obj.id,
110
+ type: "typing",
111
+ clientSentAt: obj.clientSentAt,
112
+ state: obj.state
113
+ };
114
+ }
115
+ case "selfEcho": {
116
+ if (typeof obj.originalPeer !== "string" || !obj.originalPeer) return null;
117
+ if (typeof obj.original !== "object" || obj.original === null) return null;
118
+ const inner = decodeEvent(JSON.stringify(obj.original));
119
+ if (inner === null) return null;
120
+ if (inner.type !== "text" && inner.type !== "edit" && inner.type !== "delete" && inner.type !== "read") {
121
+ return null;
122
+ }
123
+ return {
124
+ v: 1,
125
+ id: obj.id,
126
+ type: "selfEcho",
127
+ clientSentAt: obj.clientSentAt,
128
+ originalPeer: obj.originalPeer,
129
+ original: inner
130
+ };
131
+ }
132
+ default:
133
+ return null;
134
+ }
135
+ }
136
+ function encodeEventBytes(event) {
137
+ return enc.encode(encodeEvent(event));
138
+ }
139
+ function decodeEventBytes(bytes) {
140
+ return decodeEvent(dec.decode(bytes));
141
+ }
142
+ function newId() {
143
+ return globalThis.crypto.randomUUID();
144
+ }
145
+ function newText(text, replyTo) {
146
+ return {
147
+ v: 1,
148
+ id: newId(),
149
+ type: "text",
150
+ clientSentAt: Date.now(),
151
+ text,
152
+ ...replyTo !== void 0 ? { replyTo } : {}
153
+ };
154
+ }
155
+ function newEdit(targetId, text) {
156
+ return { v: 1, id: newId(), type: "edit", clientSentAt: Date.now(), targetId, text };
157
+ }
158
+ function newDelete(targetId) {
159
+ return { v: 1, id: newId(), type: "delete", clientSentAt: Date.now(), targetId };
160
+ }
161
+ function newRead(upToId) {
162
+ return { v: 1, id: newId(), type: "read", clientSentAt: Date.now(), upToId };
163
+ }
164
+ function newReceived(ids) {
165
+ return { v: 1, id: newId(), type: "received", clientSentAt: Date.now(), ids };
166
+ }
167
+ function newTyping(state) {
168
+ return { v: 1, id: newId(), type: "typing", clientSentAt: Date.now(), state };
169
+ }
170
+ function newSelfEcho(originalPeer, original) {
171
+ return {
172
+ v: 1,
173
+ id: newId(),
174
+ type: "selfEcho",
175
+ clientSentAt: Date.now(),
176
+ originalPeer,
177
+ original
178
+ };
179
+ }
180
+ var OLM_ACCOUNT_KEY = "olm/account";
181
+ var OLM_SESSION_PREFIX = "olm/session/";
182
+ var wasmInitialized = null;
183
+ async function ensureWasmReady() {
184
+ if (wasmInitialized) return wasmInitialized;
185
+ const mod = vodozemac__namespace;
186
+ if (typeof mod.default !== "function") {
187
+ wasmInitialized = Promise.resolve();
188
+ return wasmInitialized;
189
+ }
190
+ wasmInitialized = mod.default().then(() => void 0);
191
+ return wasmInitialized;
192
+ }
193
+ var OlmCryptoAdapter = class {
194
+ constructor(opts) {
195
+ this.opts = opts;
196
+ }
197
+ opts;
198
+ account = null;
199
+ sessions = /* @__PURE__ */ new Map();
200
+ async init() {
201
+ await ensureWasmReady();
202
+ if (this.account) return;
203
+ const pickled = await this.opts.store.getString(OLM_ACCOUNT_KEY);
204
+ if (pickled) {
205
+ this.account = vodozemac.Account.fromPickle(pickled);
206
+ }
207
+ }
208
+ async hasAccount() {
209
+ return this.account !== null;
210
+ }
211
+ async generateAccount(otkCount) {
212
+ if (this.account) throw new Error("account already exists");
213
+ const acc = new vodozemac.Account();
214
+ acc.generateOneTimeKeys(otkCount);
215
+ acc.generateFallbackKey();
216
+ this.account = acc;
217
+ const bundle = this.buildBundle(acc);
218
+ acc.markKeysAsPublished();
219
+ await this.persistAccount();
220
+ return bundle;
221
+ }
222
+ async getCurrentBundle() {
223
+ return this.buildBundle(this.requireAccount());
224
+ }
225
+ async generateOneTimeKeys(n) {
226
+ const acc = this.requireAccount();
227
+ acc.generateOneTimeKeys(n);
228
+ const otks = parseOneTimeKeys(acc.oneTimeKeys());
229
+ acc.markKeysAsPublished();
230
+ await this.persistAccount();
231
+ return otks;
232
+ }
233
+ async unusedOneTimeKeyCount() {
234
+ const acc = this.requireAccount();
235
+ return parseOneTimeKeys(acc.oneTimeKeys()).length;
236
+ }
237
+ async encryptForPeer(peerUserId, peerDeviceId, peerBundle, plaintext) {
238
+ const acc = this.requireAccount();
239
+ let session = await this.loadSession(peerUserId, peerDeviceId);
240
+ if (!session) {
241
+ const remoteOtk = peerBundle.oneTimeKey?.public ?? peerBundle.fallbackPrekey;
242
+ session = acc.createOutboundSession(peerBundle.identityKeyCurve, remoteOtk);
243
+ this.sessions.set(sessionKey(peerUserId, peerDeviceId), session);
244
+ }
245
+ const out = JSON.parse(session.encrypt(plaintext));
246
+ await this.persistSession(peerUserId, peerDeviceId, session);
247
+ return {
248
+ ciphertext: out.body,
249
+ msgType: out.type === 0 ? "prekey" : "normal"
250
+ };
251
+ }
252
+ async decryptFromPeer(peerUserId, peerDeviceId, ciphertext, msgType) {
253
+ const acc = this.requireAccount();
254
+ let session = await this.loadSession(peerUserId, peerDeviceId);
255
+ const isPrekey = msgType === "prekey";
256
+ if (!session) {
257
+ if (!isPrekey) {
258
+ throw new Error(`no session for normal-type ciphertext from ${peerUserId}/${peerDeviceId}`);
259
+ }
260
+ const inbound = acc.createInboundSession(ciphertext);
261
+ session = inbound.takeSession();
262
+ const plaintext2 = inbound.plaintext;
263
+ await this.persistAccount();
264
+ this.sessions.set(sessionKey(peerUserId, peerDeviceId), session);
265
+ await this.persistSession(peerUserId, peerDeviceId, session);
266
+ return plaintext2;
267
+ }
268
+ const olmType = isPrekey ? 0 : 1;
269
+ const plaintext = session.decrypt(olmType, ciphertext);
270
+ await this.persistSession(peerUserId, peerDeviceId, session);
271
+ return plaintext;
272
+ }
273
+ async forgetSession(peerUserId, peerDeviceId) {
274
+ const key = sessionKey(peerUserId, peerDeviceId);
275
+ this.sessions.delete(key);
276
+ await this.opts.store.delete(OLM_SESSION_PREFIX + key);
277
+ }
278
+ async hasSession(peerUserId, peerDeviceId) {
279
+ if (this.sessions.has(sessionKey(peerUserId, peerDeviceId))) return true;
280
+ const persisted = await this.opts.store.getString(
281
+ OLM_SESSION_PREFIX + sessionKey(peerUserId, peerDeviceId)
282
+ );
283
+ return persisted !== null;
284
+ }
285
+ // ── internal ───────────────────────────────────────────────────────────────
286
+ requireAccount() {
287
+ if (!this.account) throw new Error("no Olm account; call generateAccount() or init()");
288
+ return this.account;
289
+ }
290
+ buildBundle(acc) {
291
+ const idKeys = JSON.parse(acc.identityKeys());
292
+ const otks = parseOneTimeKeys(acc.oneTimeKeys());
293
+ const signedPrekey = idKeys.curve25519;
294
+ const signedPrekeySig = acc.sign(signedPrekey);
295
+ const fallbackParsed = parseOneTimeKeys(acc.fallbackKey());
296
+ const fallbackPrekey = fallbackParsed[0]?.public ?? signedPrekey;
297
+ const fallbackPrekeySig = acc.sign(fallbackPrekey);
298
+ return {
299
+ identityKeyCurve: idKeys.curve25519,
300
+ identityKeyEd: idKeys.ed25519,
301
+ signedPrekey,
302
+ signedPrekeySig,
303
+ fallbackPrekey,
304
+ fallbackPrekeySig,
305
+ fingerprint: formatFingerprint(idKeys.ed25519),
306
+ oneTimeKeys: otks
307
+ };
308
+ }
309
+ async persistAccount() {
310
+ const acc = this.requireAccount();
311
+ await this.opts.store.setString(OLM_ACCOUNT_KEY, acc.pickle());
312
+ }
313
+ async loadSession(peerUserId, peerDeviceId) {
314
+ const key = sessionKey(peerUserId, peerDeviceId);
315
+ const cached = this.sessions.get(key);
316
+ if (cached) return cached;
317
+ const pickled = await this.opts.store.getString(OLM_SESSION_PREFIX + key);
318
+ if (!pickled) return null;
319
+ const session = vodozemac.Session.fromPickle(pickled);
320
+ this.sessions.set(key, session);
321
+ return session;
322
+ }
323
+ async persistSession(peerUserId, peerDeviceId, session) {
324
+ await this.opts.store.setString(
325
+ OLM_SESSION_PREFIX + sessionKey(peerUserId, peerDeviceId),
326
+ session.pickle()
327
+ );
328
+ }
329
+ };
330
+ function sessionKey(peerUserId, peerDeviceId) {
331
+ return `${peerUserId}|${peerDeviceId}`;
332
+ }
333
+ function parseOneTimeKeys(json) {
334
+ const parsed = JSON.parse(json);
335
+ const out = [];
336
+ for (const [id, pub] of Object.entries(parsed.curve25519 ?? {})) {
337
+ out.push({ id, public: pub });
338
+ }
339
+ return out;
340
+ }
341
+ function formatFingerprint(ed25519PubB64) {
342
+ const groups = (ed25519PubB64.replace(/[+/=]/g, "").match(/.{1,4}/g) ?? []).slice(0, 12);
343
+ return groups.join("-");
344
+ }
345
+
346
+ // src/device.ts
347
+ var DEVICE_ID_KEY = "deviceId";
348
+ function generateUUID() {
349
+ if (typeof globalThis.crypto?.randomUUID === "function") {
350
+ return globalThis.crypto.randomUUID();
351
+ }
352
+ const bytes = new Uint8Array(16);
353
+ globalThis.crypto.getRandomValues(bytes);
354
+ bytes[6] = bytes[6] & 15 | 64;
355
+ bytes[8] = bytes[8] & 63 | 128;
356
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
357
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
358
+ }
359
+ async function loadOrCreateDeviceId(store) {
360
+ const existing = await store.getString(DEVICE_ID_KEY);
361
+ if (existing) return existing;
362
+ const id = generateUUID();
363
+ await store.setString(DEVICE_ID_KEY, id);
364
+ return id;
365
+ }
366
+
367
+ // src/device_discovery.ts
368
+ var DEFAULT_STALE_AFTER_MS = 5 * 60 * 1e3;
369
+ var PeerDeviceCache = class {
370
+ constructor(opts) {
371
+ this.opts = opts;
372
+ }
373
+ opts;
374
+ cache = /* @__PURE__ */ new Map();
375
+ /**
376
+ * Return peer's known devices, refreshing if cache is missing or older
377
+ * than `staleAfterMs`.
378
+ */
379
+ async getPeerDevices(peerUserId) {
380
+ const now = Date.now();
381
+ const stale = this.opts.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
382
+ const entry = this.cache.get(peerUserId);
383
+ if (entry && now - entry.fetchedAt < stale) {
384
+ return entry.devices;
385
+ }
386
+ return this.refresh(peerUserId);
387
+ }
388
+ /** Force a fresh fetch regardless of staleness. */
389
+ async refresh(peerUserId) {
390
+ const res = await this.opts.http.listDevices(this.opts.selfDeviceId, peerUserId);
391
+ const devices = res.devices.map((d) => ({
392
+ deviceId: d.deviceId,
393
+ fingerprint: d.fingerprint,
394
+ lastActiveAt: d.lastActiveAt
395
+ }));
396
+ this.cache.set(peerUserId, { devices, fetchedAt: Date.now() });
397
+ return devices;
398
+ }
399
+ /**
400
+ * Add a peer device discovered through other channels (e.g., an inbound
401
+ * prekey-message from a device the cache doesn't know about). Doesn't
402
+ * hit the network. Caller is responsible for the (deviceId, fingerprint).
403
+ */
404
+ noteNewPeerDevice(peerUserId, device) {
405
+ const entry = this.cache.get(peerUserId);
406
+ if (!entry) {
407
+ this.cache.set(peerUserId, { devices: [device], fetchedAt: Date.now() });
408
+ return;
409
+ }
410
+ if (entry.devices.some((d) => d.deviceId === device.deviceId)) return;
411
+ entry.devices.push(device);
412
+ }
413
+ /** Drop the cached entry; next getPeerDevices will refetch. */
414
+ invalidate(peerUserId) {
415
+ this.cache.delete(peerUserId);
416
+ }
417
+ /** Drop everything. */
418
+ clear() {
419
+ this.cache.clear();
420
+ }
421
+ };
422
+
423
+ // src/key_bundle.ts
424
+ var DEFAULT_OTK_COUNT = 100;
425
+ var OTK_TOPUP_WATERMARK = 20;
426
+ var OTK_TOPUP_TARGET = 100;
427
+ var KeyBundleManager = class {
428
+ constructor(opts) {
429
+ this.opts = opts;
430
+ }
431
+ opts;
432
+ /** In-flight topup promise — coalesces concurrent callers so the
433
+ * initial connect + onWsState("open") path doesn't double-upload. */
434
+ inflightTopup = null;
435
+ /**
436
+ * Idempotent. On first call generates a new Olm account and uploads
437
+ * the bundle; on subsequent calls no-ops if the adapter already has
438
+ * an account. Safe to call on every connect.
439
+ */
440
+ async ensureKeyBundle() {
441
+ await this.opts.crypto.init();
442
+ if (await this.opts.crypto.hasAccount()) {
443
+ return;
444
+ }
445
+ const bundle = await this.opts.crypto.generateAccount(
446
+ this.opts.initialOtkCount ?? DEFAULT_OTK_COUNT
447
+ );
448
+ await this.opts.http.uploadKeyBundle(this.opts.deviceId, {
449
+ deviceId: this.opts.deviceId,
450
+ identityKeyCurve: bundle.identityKeyCurve,
451
+ identityKeyEd: bundle.identityKeyEd,
452
+ signedPrekey: bundle.signedPrekey,
453
+ signedPrekeySig: bundle.signedPrekeySig,
454
+ fallbackPrekey: bundle.fallbackPrekey,
455
+ fallbackPrekeySig: bundle.fallbackPrekeySig,
456
+ fingerprint: bundle.fingerprint,
457
+ oneTimeKeys: bundle.oneTimeKeys
458
+ });
459
+ }
460
+ /**
461
+ * Re-upload the existing bundle without generating new keys. Used after
462
+ * backend wipes during testing, or when the SDK detects a registry
463
+ * mismatch.
464
+ */
465
+ async reuploadCurrentBundle() {
466
+ await this.opts.crypto.init();
467
+ if (!await this.opts.crypto.hasAccount()) {
468
+ throw new Error("reuploadCurrentBundle: no account; call ensureKeyBundle first");
469
+ }
470
+ const bundle = await this.opts.crypto.getCurrentBundle();
471
+ await this.opts.http.uploadKeyBundle(this.opts.deviceId, {
472
+ deviceId: this.opts.deviceId,
473
+ identityKeyCurve: bundle.identityKeyCurve,
474
+ identityKeyEd: bundle.identityKeyEd,
475
+ signedPrekey: bundle.signedPrekey,
476
+ signedPrekeySig: bundle.signedPrekeySig,
477
+ fallbackPrekey: bundle.fallbackPrekey,
478
+ fallbackPrekeySig: bundle.fallbackPrekeySig,
479
+ fingerprint: bundle.fingerprint,
480
+ oneTimeKeys: bundle.oneTimeKeys
481
+ });
482
+ }
483
+ /**
484
+ * Top up if the SERVER's OTK count for this device has dropped below
485
+ * the watermark. Generates fresh OTKs and uploads. Idempotent: a no-op
486
+ * if the count is healthy.
487
+ */
488
+ async topUpIfNeeded() {
489
+ if (this.inflightTopup) return this.inflightTopup;
490
+ const p = (async () => {
491
+ const { count } = await this.opts.http.otkCount(this.opts.deviceId);
492
+ if (count >= OTK_TOPUP_WATERMARK) {
493
+ return { topped: false };
494
+ }
495
+ const need = OTK_TOPUP_TARGET - count;
496
+ const fresh = await this.opts.crypto.generateOneTimeKeys(need);
497
+ const res = await this.opts.http.topupOtks(this.opts.deviceId, fresh);
498
+ return { topped: true, newCount: res.currentCount };
499
+ })();
500
+ this.inflightTopup = p;
501
+ try {
502
+ return await p;
503
+ } finally {
504
+ this.inflightTopup = null;
505
+ }
506
+ }
507
+ };
508
+
509
+ // src/message_store.ts
510
+ var MessageStore = class {
511
+ constructor(store) {
512
+ this.store = store;
513
+ }
514
+ store;
515
+ cache = /* @__PURE__ */ new Map();
516
+ /** Insert or overwrite. Used for both outbound (self-sent) and inbound. */
517
+ async put(msg) {
518
+ this.cache.set(msg.id, msg);
519
+ await this.store.setString(this.kvKey(msg.id), JSON.stringify(msg));
520
+ }
521
+ async get(messageId) {
522
+ const cached = this.cache.get(messageId);
523
+ if (cached) return cached;
524
+ const raw = await this.store.getString(this.kvKey(messageId));
525
+ if (!raw) return null;
526
+ try {
527
+ const parsed = JSON.parse(raw);
528
+ this.cache.set(parsed.id, parsed);
529
+ return parsed;
530
+ } catch {
531
+ return null;
532
+ }
533
+ }
534
+ /**
535
+ * Return messages for `peerUserId`, oldest→newest within the page.
536
+ *
537
+ * `beforeSentAt` filters to messages with `sentAt < beforeSentAt`, used by
538
+ * the chat UI to load older history above what's already rendered. `limit`
539
+ * caps the page size; when set, the most-recent matches are returned (i.e.
540
+ * the page sits at the boundary just before `beforeSentAt`).
541
+ *
542
+ * v1 implementation walks every persisted message; secondary indexing is
543
+ * a follow-up. For typical chat volumes (hundreds of messages per peer)
544
+ * this is fine; >10k per peer should add a per-peer KV index.
545
+ */
546
+ async listForPeer(peerUserId, opts = {}) {
547
+ const keys = await this.store.listKeys("messages/");
548
+ const out = [];
549
+ for (const key of keys) {
550
+ const id = key.slice("messages/".length);
551
+ const cached = this.cache.get(id);
552
+ let msg = cached ?? null;
553
+ if (!msg) {
554
+ const raw = await this.store.getString(key);
555
+ if (!raw) continue;
556
+ try {
557
+ msg = JSON.parse(raw);
558
+ this.cache.set(msg.id, msg);
559
+ } catch {
560
+ continue;
561
+ }
562
+ }
563
+ if (msg.peerUserId !== peerUserId) continue;
564
+ if (opts.beforeSentAt !== void 0 && msg.sentAt >= opts.beforeSentAt) continue;
565
+ out.push(msg);
566
+ }
567
+ out.sort((a, b) => a.sentAt - b.sentAt);
568
+ if (opts.limit !== void 0 && out.length > opts.limit) {
569
+ return out.slice(-opts.limit);
570
+ }
571
+ return out;
572
+ }
573
+ /**
574
+ * Apply an edit if authorized. Returns the resulting message on
575
+ * success, or null if the message doesn't exist, is already deleted,
576
+ * or the editor is not the original sender.
577
+ */
578
+ async applyEdit(opts) {
579
+ const target = await this.get(opts.targetId);
580
+ if (!target) return null;
581
+ if (target.deletedAt !== null) return null;
582
+ if (target.senderUserId !== opts.editorUserId) return null;
583
+ const updated = {
584
+ ...target,
585
+ text: opts.newText,
586
+ editedAt: opts.editedAt
587
+ };
588
+ await this.put(updated);
589
+ return updated;
590
+ }
591
+ /**
592
+ * Apply a delete (tombstone) if authorized. Same authorization rule as
593
+ * edit. Returns the tombstoned message or null.
594
+ */
595
+ async applyDelete(opts) {
596
+ const target = await this.get(opts.targetId);
597
+ if (!target) return null;
598
+ if (target.senderUserId !== opts.deleterUserId) return null;
599
+ const updated = {
600
+ ...target,
601
+ // Purge the original text — the contract is "tombstone, not preserve".
602
+ text: "",
603
+ deletedAt: opts.deletedAt
604
+ };
605
+ await this.put(updated);
606
+ return updated;
607
+ }
608
+ kvKey(messageId) {
609
+ return `messages/${messageId}`;
610
+ }
611
+ };
612
+
613
+ // src/outbox.ts
614
+ var DEFAULT_MAX_ATTEMPTS = 5;
615
+ var DEFAULT_BASE_BACKOFF = 500;
616
+ var Outbox = class {
617
+ queue = [];
618
+ inflightIds = /* @__PURE__ */ new Set();
619
+ opts;
620
+ constructor(opts = {}) {
621
+ this.opts = {
622
+ maxAttempts: opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
623
+ baseBackoffMs: opts.baseBackoffMs ?? DEFAULT_BASE_BACKOFF
624
+ };
625
+ }
626
+ /**
627
+ * Enqueue a send attempt. Idempotent on messageId — a second enqueue
628
+ * with the same id is a no-op (returns the existing entry's promise).
629
+ */
630
+ enqueue(entry) {
631
+ if (this.inflightIds.has(entry.messageId)) return;
632
+ this.inflightIds.add(entry.messageId);
633
+ this.queue.push({ ...entry, attempts: 0, nextRetryAt: 0 });
634
+ }
635
+ /**
636
+ * Process the queue once. Sends due-now entries; reschedules failures
637
+ * with backoff. Returns the number of entries that completed (success
638
+ * or terminal failure) on this tick.
639
+ */
640
+ async tick() {
641
+ const now = Date.now();
642
+ let completed = 0;
643
+ const stillPending = [];
644
+ for (const entry of this.queue) {
645
+ if (entry.nextRetryAt > now) {
646
+ stillPending.push(entry);
647
+ continue;
648
+ }
649
+ entry.attempts++;
650
+ let outcomes;
651
+ try {
652
+ outcomes = await entry.attempt();
653
+ } catch {
654
+ outcomes = /* @__PURE__ */ new Map();
655
+ }
656
+ const anyOk = Array.from(outcomes.values()).some((s) => s === "live" || s === "stored");
657
+ if (anyOk) {
658
+ this.inflightIds.delete(entry.messageId);
659
+ completed++;
660
+ continue;
661
+ }
662
+ if (entry.attempts >= this.opts.maxAttempts) {
663
+ this.inflightIds.delete(entry.messageId);
664
+ completed++;
665
+ continue;
666
+ }
667
+ entry.nextRetryAt = now + this.computeBackoff(entry.attempts);
668
+ stillPending.push(entry);
669
+ }
670
+ this.queue = stillPending;
671
+ return completed;
672
+ }
673
+ size() {
674
+ return this.queue.length;
675
+ }
676
+ /** Test helper. */
677
+ has(messageId) {
678
+ return this.inflightIds.has(messageId);
679
+ }
680
+ computeBackoff(attempt) {
681
+ const base = this.opts.baseBackoffMs * 2 ** (attempt - 1);
682
+ const jitter = base * (0.4 * Math.random() - 0.2);
683
+ return Math.max(100, Math.floor(base + jitter));
684
+ }
685
+ };
686
+
687
+ // src/sessions.ts
688
+ var SessionManager = class {
689
+ constructor(opts) {
690
+ this.opts = opts;
691
+ }
692
+ opts;
693
+ /**
694
+ * Per-peer-device locks to serialize outbound session creation. Without
695
+ * this, two parallel sends to the same fresh peer would each consume an
696
+ * OTK and create two competing sessions.
697
+ */
698
+ locks = /* @__PURE__ */ new Map();
699
+ // Cached peer-device bundles. Refreshed by device_discovery; consumed
700
+ // here on send. null = no claim attempted yet for this peer.
701
+ bundleCache = /* @__PURE__ */ new Map();
702
+ // In-flight claim_all promises keyed by peer user. Coalesces parallel
703
+ // first-time sends so they share one claim_all → one OTK consumed → one
704
+ // outbound session per peer device. Without this, two parallel sendText
705
+ // calls both miss the cache, both call claim_all, both consume an OTK,
706
+ // both create outbound sessions, the second persisted session overwrites
707
+ // the first — and subsequent messages encrypt with a session the peer's
708
+ // inbound side never saw.
709
+ inflightRefresh = /* @__PURE__ */ new Map();
710
+ /**
711
+ * Encrypt one plaintext for ALL of a peer's currently-known devices.
712
+ * On first contact OR when bundleCache is empty for this peer, calls
713
+ * claim_all to fetch fresh bundles (which atomically pop OTKs).
714
+ *
715
+ * Returns an empty array when the peer has no chat-registered devices
716
+ * (or has blocked the caller — same shape; see contract §2.5).
717
+ */
718
+ async encryptForPeer(peerUserId, plaintext) {
719
+ let devices = await this.ensurePeerBundles(peerUserId);
720
+ if (this.opts.selfUserId && peerUserId === this.opts.selfUserId) {
721
+ devices = devices.filter((d) => d.deviceId !== this.opts.selfDeviceId);
722
+ }
723
+ if (devices.length === 0) return [];
724
+ const results = [];
725
+ for (const dev of devices) {
726
+ const env = await this.encryptForOneDevice(peerUserId, dev, plaintext);
727
+ results.push({ peerDeviceId: dev.deviceId, ciphertext: env.ciphertext, msgType: env.msgType });
728
+ }
729
+ return results;
730
+ }
731
+ /**
732
+ * Decrypt an inbound ciphertext from (peerUserId, peerDeviceId). On
733
+ * msgType=="prekey" with no existing session, creates an inbound
734
+ * session from the prekey message itself — no claim needed.
735
+ */
736
+ async decrypt(peerUserId, peerDeviceId, ciphertext, msgType) {
737
+ return this.opts.crypto.decryptFromPeer(peerUserId, peerDeviceId, ciphertext, msgType);
738
+ }
739
+ /**
740
+ * Drop the cached bundle list and any session with this peer device.
741
+ * Used on decrypt-failure recovery, before a re-claim.
742
+ */
743
+ async forgetPeerDevice(peerUserId, peerDeviceId) {
744
+ const cached = this.bundleCache.get(peerUserId);
745
+ if (cached) {
746
+ this.bundleCache.set(
747
+ peerUserId,
748
+ cached.filter((d) => d.deviceId !== peerDeviceId)
749
+ );
750
+ }
751
+ await this.opts.crypto.forgetSession(peerUserId, peerDeviceId);
752
+ }
753
+ /** Test/diagnostic helper. */
754
+ async hasSession(peerUserId, peerDeviceId) {
755
+ return this.opts.crypto.hasSession(peerUserId, peerDeviceId);
756
+ }
757
+ /**
758
+ * Number of cached devices for `peerUserId` that we'd actually fanout
759
+ * to (excludes selfDeviceId when peerUserId === selfUserId). Returns
760
+ * `null` when no claim has been attempted yet for this peer — caller
761
+ * should treat that as "unknown, prefer refresh."
762
+ */
763
+ cachedFanoutSize(peerUserId) {
764
+ const cached = this.bundleCache.get(peerUserId);
765
+ if (!cached) return null;
766
+ if (this.opts.selfUserId && peerUserId === this.opts.selfUserId) {
767
+ return cached.filter((d) => d.deviceId !== this.opts.selfDeviceId).length;
768
+ }
769
+ return cached.length;
770
+ }
771
+ /**
772
+ * Force a refresh of peer bundles (e.g., from device_discovery on a
773
+ * detected new peer device). Calls claim_all again; subsequent sends
774
+ * use the new bundles. Pops fresh OTKs server-side.
775
+ */
776
+ async refreshPeerBundles(peerUserId) {
777
+ const res = await this.opts.http.claimAll(this.opts.selfDeviceId, peerUserId);
778
+ this.bundleCache.set(peerUserId, res.devices);
779
+ return res.devices;
780
+ }
781
+ // ── internal ───────────────────────────────────────────────────────────────
782
+ async ensurePeerBundles(peerUserId) {
783
+ const cached = this.bundleCache.get(peerUserId);
784
+ if (cached) return cached;
785
+ const inflight = this.inflightRefresh.get(peerUserId);
786
+ if (inflight) return inflight;
787
+ const p = (async () => {
788
+ try {
789
+ return await this.refreshPeerBundles(peerUserId);
790
+ } finally {
791
+ this.inflightRefresh.delete(peerUserId);
792
+ }
793
+ })();
794
+ this.inflightRefresh.set(peerUserId, p);
795
+ return p;
796
+ }
797
+ async encryptForOneDevice(peerUserId, bundle, plaintext) {
798
+ const lockKey = sessionKey2(peerUserId, bundle.deviceId);
799
+ const prev = this.locks.get(lockKey) ?? Promise.resolve();
800
+ let release;
801
+ const next = new Promise((r) => {
802
+ release = r;
803
+ });
804
+ this.locks.set(lockKey, prev.then(() => next));
805
+ await prev;
806
+ try {
807
+ return await this.opts.crypto.encryptForPeer(peerUserId, bundle.deviceId, bundle, plaintext);
808
+ } finally {
809
+ release();
810
+ if (this.locks.get(lockKey) === prev.then(() => next)) {
811
+ this.locks.delete(lockKey);
812
+ }
813
+ }
814
+ }
815
+ };
816
+ function sessionKey2(peerUserId, peerDeviceId) {
817
+ return `${peerUserId}|${peerDeviceId}`;
818
+ }
819
+
820
+ // src/status.ts
821
+ var StatusTracker = class {
822
+ outbound = /* @__PURE__ */ new Map();
823
+ /** Reverse index for quick lookup on chatSendResult. */
824
+ envelopeToMessage = /* @__PURE__ */ new Map();
825
+ /** Sorted list of (messageId, peerUserId) ordered by send time, used to
826
+ * resolve `read` watermarks against earlier messages. Append-only. */
827
+ byPeer = /* @__PURE__ */ new Map();
828
+ listeners = [];
829
+ on(fn) {
830
+ this.listeners.push(fn);
831
+ return () => {
832
+ const i = this.listeners.indexOf(fn);
833
+ if (i >= 0) this.listeners.splice(i, 1);
834
+ };
835
+ }
836
+ /**
837
+ * Register a freshly-sent message. envelopeToDevice maps each target's
838
+ * envelopeUuid to the peer device it was sent to — fed by the SDK from
839
+ * the chatSend frame's targets[].
840
+ */
841
+ trackOutbound(opts) {
842
+ const peerDevices = new Set(opts.envelopeToDevice.values());
843
+ this.outbound.set(opts.messageId, {
844
+ messageId: opts.messageId,
845
+ peerUserId: opts.peerUserId,
846
+ envelopeToDevice: opts.envelopeToDevice,
847
+ peerDevices,
848
+ receivedFrom: /* @__PURE__ */ new Set(),
849
+ status: "pending"
850
+ });
851
+ for (const uuid of opts.envelopeToDevice.keys()) {
852
+ this.envelopeToMessage.set(uuid, opts.messageId);
853
+ }
854
+ const list = this.byPeer.get(opts.peerUserId) ?? [];
855
+ list.push(opts.messageId);
856
+ this.byPeer.set(opts.peerUserId, list);
857
+ }
858
+ /**
859
+ * Process a chatSendResult outcome. Multiple outcomes per message (one
860
+ * per target). Marks the message at least "sent" once any target
861
+ * succeeds.
862
+ */
863
+ onSendResult(envelopeUuid, status) {
864
+ const messageId = this.envelopeToMessage.get(envelopeUuid);
865
+ if (!messageId) return;
866
+ const outbound = this.outbound.get(messageId);
867
+ if (!outbound) return;
868
+ if (status === "live" || status === "stored") {
869
+ this.bump(outbound, "sent");
870
+ }
871
+ }
872
+ /**
873
+ * Process an inbound `received` event from peer. Marks corresponding
874
+ * outbound messages as delivered (or deliveredAll once all peer
875
+ * devices have acknowledged).
876
+ */
877
+ onReceived(opts) {
878
+ for (const id of opts.messageIds) {
879
+ const outbound = this.outbound.get(id);
880
+ if (!outbound) continue;
881
+ if (outbound.peerUserId !== opts.peerUserId) continue;
882
+ if (!outbound.peerDevices.has(opts.peerDeviceId)) {
883
+ outbound.peerDevices.add(opts.peerDeviceId);
884
+ }
885
+ outbound.receivedFrom.add(opts.peerDeviceId);
886
+ const allReceived = outbound.receivedFrom.size >= outbound.peerDevices.size && outbound.peerDevices.size > 0;
887
+ this.bump(outbound, allReceived ? "deliveredAll" : "delivered");
888
+ }
889
+ }
890
+ /**
891
+ * Process a `read` watermark from the peer. All this peer's outbound
892
+ * messages with sentAt index <= upToId's index move to "read".
893
+ * Resolution is by send-order (insertion order in byPeer).
894
+ */
895
+ onRead(opts) {
896
+ const list = this.byPeer.get(opts.peerUserId);
897
+ if (!list) return;
898
+ const idx = list.indexOf(opts.upToId);
899
+ if (idx < 0) return;
900
+ for (let i = 0; i <= idx; i++) {
901
+ const outbound = this.outbound.get(list[i]);
902
+ if (!outbound) continue;
903
+ this.bump(outbound, "read");
904
+ }
905
+ }
906
+ /** Test/diagnostic helper. */
907
+ getStatus(messageId) {
908
+ return this.outbound.get(messageId)?.status;
909
+ }
910
+ // ── internal ───────────────────────────────────────────────────────────────
911
+ bump(outbound, candidate) {
912
+ if (rank(candidate) <= rank(outbound.status)) return;
913
+ outbound.status = candidate;
914
+ for (const fn of this.listeners) {
915
+ try {
916
+ fn(outbound.messageId, candidate, outbound.peerUserId);
917
+ } catch {
918
+ }
919
+ }
920
+ }
921
+ };
922
+ function rank(s) {
923
+ switch (s) {
924
+ case "pending":
925
+ return 0;
926
+ case "sent":
927
+ return 1;
928
+ case "delivered":
929
+ return 2;
930
+ case "deliveredAll":
931
+ return 3;
932
+ case "read":
933
+ return 4;
934
+ }
935
+ }
936
+
937
+ // src/store/web-adapter.ts
938
+ var DB_NAME = "dtelecom-secure-chat";
939
+ var STORE = "kv";
940
+ var VERSION = 1;
941
+ var dbPromise = null;
942
+ function openDB() {
943
+ if (dbPromise) return dbPromise;
944
+ dbPromise = new Promise((resolve, reject) => {
945
+ const req = indexedDB.open(DB_NAME, VERSION);
946
+ req.onupgradeneeded = () => {
947
+ const db = req.result;
948
+ if (!db.objectStoreNames.contains(STORE)) {
949
+ db.createObjectStore(STORE);
950
+ }
951
+ };
952
+ req.onsuccess = () => resolve(req.result);
953
+ req.onerror = () => reject(req.error);
954
+ });
955
+ return dbPromise;
956
+ }
957
+ async function withStore(mode, fn) {
958
+ const db = await openDB();
959
+ return new Promise((resolve, reject) => {
960
+ const tx = db.transaction(STORE, mode);
961
+ const store = tx.objectStore(STORE);
962
+ const result = fn(store);
963
+ tx.oncomplete = () => {
964
+ if (result && typeof result === "object" && "result" in result) {
965
+ resolve(result.result);
966
+ } else {
967
+ resolve(result);
968
+ }
969
+ };
970
+ tx.onerror = () => reject(tx.error);
971
+ tx.onabort = () => reject(tx.error);
972
+ });
973
+ }
974
+ var WebKVStore = class {
975
+ async getString(key) {
976
+ const v = await withStore("readonly", (s) => s.get(key));
977
+ if (!v) return null;
978
+ const sv = v;
979
+ return sv.type === "string" ? sv.value : null;
980
+ }
981
+ async setString(key, value) {
982
+ const sv = { type: "string", value };
983
+ await withStore("readwrite", (s) => s.put(sv, key));
984
+ }
985
+ async getBytes(key) {
986
+ const v = await withStore("readonly", (s) => s.get(key));
987
+ if (!v) return null;
988
+ const sv = v;
989
+ return sv.type === "bytes" ? sv.value : null;
990
+ }
991
+ async setBytes(key, value) {
992
+ const sv = { type: "bytes", value };
993
+ await withStore("readwrite", (s) => s.put(sv, key));
994
+ }
995
+ async delete(key) {
996
+ await withStore("readwrite", (s) => s.delete(key));
997
+ }
998
+ async listKeys(prefix) {
999
+ const db = await openDB();
1000
+ return new Promise((resolve, reject) => {
1001
+ const out = [];
1002
+ const tx = db.transaction(STORE, "readonly");
1003
+ const cursorReq = tx.objectStore(STORE).openKeyCursor();
1004
+ cursorReq.onsuccess = () => {
1005
+ const cursor = cursorReq.result;
1006
+ if (!cursor) {
1007
+ resolve(out);
1008
+ return;
1009
+ }
1010
+ const k = cursor.key;
1011
+ if (k.startsWith(prefix)) out.push(k);
1012
+ cursor.continue();
1013
+ };
1014
+ cursorReq.onerror = () => reject(cursorReq.error);
1015
+ });
1016
+ }
1017
+ };
1018
+
1019
+ // src/transport/http.ts
1020
+ var HttpError = class extends Error {
1021
+ constructor(status, code, message) {
1022
+ super(message);
1023
+ this.status = status;
1024
+ this.code = code;
1025
+ this.name = "HttpError";
1026
+ }
1027
+ status;
1028
+ code;
1029
+ };
1030
+ var HttpClient = class {
1031
+ apiBase;
1032
+ fetchToken;
1033
+ fetchImpl;
1034
+ // Cached MintTokenResponse, keyed by device id. Refreshed when expired.
1035
+ cached = null;
1036
+ cachedDeviceId = null;
1037
+ constructor(opts) {
1038
+ this.apiBase = opts.apiBaseURL.replace(/\/$/, "");
1039
+ this.fetchToken = opts.fetchChatToken;
1040
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
1041
+ }
1042
+ // ── token + node-url lifecycle ─────────────────────────────────────────────
1043
+ /**
1044
+ * Returns the full mint response (token + chatNodeWsUrl + expiry),
1045
+ * fetching afresh if no cached value exists or it expires within 60 seconds.
1046
+ * Also re-fetches when the device id changes.
1047
+ */
1048
+ async getMint(deviceId) {
1049
+ const now = Math.floor(Date.now() / 1e3);
1050
+ if (this.cached && this.cachedDeviceId === deviceId && this.cached.expiresAt - now > 60) {
1051
+ return this.cached;
1052
+ }
1053
+ const mint = await this.fetchToken(deviceId);
1054
+ if (!mint.chatToken || typeof mint.expiresAt !== "number" || !mint.chatNodeWsUrl) {
1055
+ throw new Error("fetchChatToken must return { chatToken, expiresAt, chatNodeWsUrl }");
1056
+ }
1057
+ this.cached = mint;
1058
+ this.cachedDeviceId = deviceId;
1059
+ return mint;
1060
+ }
1061
+ /** Convenience: just the JWT. Backed by getMint(). */
1062
+ async getToken(deviceId) {
1063
+ return (await this.getMint(deviceId)).chatToken;
1064
+ }
1065
+ /** Convenience: just the discovered node WS URL. Backed by getMint(). */
1066
+ async getNodeWsUrl(deviceId) {
1067
+ return (await this.getMint(deviceId)).chatNodeWsUrl;
1068
+ }
1069
+ // ── authed JSON helpers ────────────────────────────────────────────────────
1070
+ async authedJson(method, path, deviceId, body) {
1071
+ const token = await this.getToken(deviceId);
1072
+ const headers = {
1073
+ authorization: `Bearer ${token}`
1074
+ };
1075
+ let bodyText;
1076
+ if (body !== void 0) {
1077
+ headers["content-type"] = "application/json";
1078
+ bodyText = JSON.stringify(body);
1079
+ }
1080
+ const res = await this.fetchImpl(`${this.apiBase}${path}`, {
1081
+ method,
1082
+ headers,
1083
+ body: bodyText
1084
+ });
1085
+ if (!res.ok) {
1086
+ let code = "http_error";
1087
+ let msg = `${res.status} ${res.statusText}`;
1088
+ try {
1089
+ const errJson = await res.json();
1090
+ if (errJson.error) code = errJson.error;
1091
+ if (errJson.message) msg = errJson.message;
1092
+ } catch {
1093
+ }
1094
+ throw new HttpError(res.status, code, msg);
1095
+ }
1096
+ if (res.status === 204) return void 0;
1097
+ return await res.json();
1098
+ }
1099
+ // ── /api/chat/keys ─────────────────────────────────────────────────────────
1100
+ uploadKeyBundle(deviceId, body) {
1101
+ return this.authedJson("POST", "/api/chat/keys/upload", deviceId, body);
1102
+ }
1103
+ topupOtks(deviceId, keys) {
1104
+ const body = { deviceId, oneTimeKeys: keys };
1105
+ return this.authedJson("POST", "/api/chat/keys/topup", deviceId, body);
1106
+ }
1107
+ otkCount(deviceId) {
1108
+ return this.authedJson(
1109
+ "GET",
1110
+ `/api/chat/keys/count?deviceId=${encodeURIComponent(deviceId)}`,
1111
+ deviceId
1112
+ );
1113
+ }
1114
+ claimAll(deviceId, peerUserId) {
1115
+ return this.authedJson("POST", "/api/chat/keys/claim_all", deviceId, {
1116
+ peerUserId
1117
+ });
1118
+ }
1119
+ listDevices(deviceId, peerUserId) {
1120
+ return this.authedJson(
1121
+ "GET",
1122
+ `/api/chat/keys/list_devices?peerUserId=${encodeURIComponent(peerUserId)}`,
1123
+ deviceId
1124
+ );
1125
+ }
1126
+ // ── /api/chat/envelopes ────────────────────────────────────────────────────
1127
+ pending(deviceId, limit = 100) {
1128
+ return this.authedJson(
1129
+ "GET",
1130
+ `/api/chat/envelopes/pending?deviceId=${encodeURIComponent(deviceId)}&limit=${limit}`,
1131
+ deviceId
1132
+ );
1133
+ }
1134
+ ack(deviceId, envelopeUuids) {
1135
+ const body = { deviceId, envelopeUuids };
1136
+ return this.authedJson("POST", "/api/chat/envelopes/ack", deviceId, body);
1137
+ }
1138
+ // ── /api/chat/blocks ───────────────────────────────────────────────────────
1139
+ blockUser(deviceId, peerUserId) {
1140
+ return this.authedJson("POST", "/api/chat/blocks", deviceId, { peerUserId });
1141
+ }
1142
+ unblockUser(deviceId, peerUserId) {
1143
+ return this.authedJson(
1144
+ "DELETE",
1145
+ `/api/chat/blocks/${encodeURIComponent(peerUserId)}`,
1146
+ deviceId
1147
+ );
1148
+ }
1149
+ listBlocked(deviceId) {
1150
+ return this.authedJson("GET", "/api/chat/blocks", deviceId);
1151
+ }
1152
+ };
1153
+
1154
+ // src/transport/ws.ts
1155
+ var WsClient = class {
1156
+ opts;
1157
+ socket = null;
1158
+ state = "closed";
1159
+ attempt = 0;
1160
+ pingTimer = null;
1161
+ explicitClose = false;
1162
+ constructor(opts) {
1163
+ this.opts = {
1164
+ nodeBaseURL: opts.nodeBaseURL.replace(/\/$/, ""),
1165
+ getToken: opts.getToken,
1166
+ onFrame: opts.onFrame,
1167
+ onState: opts.onState ?? (() => {
1168
+ }),
1169
+ webSocketImpl: opts.webSocketImpl ?? globalThis.WebSocket,
1170
+ reconnect: opts.reconnect ?? true,
1171
+ pingIntervalMs: opts.pingIntervalMs ?? 25e3
1172
+ };
1173
+ }
1174
+ getState() {
1175
+ return this.state;
1176
+ }
1177
+ /** Open a connection. Resolves once the WS reaches `open`. */
1178
+ async connect() {
1179
+ if (this.state === "open" || this.state === "connecting") return;
1180
+ this.explicitClose = false;
1181
+ return this.connectInternal();
1182
+ }
1183
+ /** Send an outbound frame. Throws if not open. */
1184
+ send(frame) {
1185
+ if (!this.socket || this.state !== "open") {
1186
+ throw new Error(`ws not open (state=${this.state})`);
1187
+ }
1188
+ this.socket.send(JSON.stringify(frame));
1189
+ }
1190
+ /** Convenience for a chatSend frame. */
1191
+ sendChat(frame) {
1192
+ this.send({ kind: "chatSend", ...frame });
1193
+ }
1194
+ /** Convenience for a chatPing. */
1195
+ ping() {
1196
+ const f = { kind: "chatPing" };
1197
+ this.send(f);
1198
+ }
1199
+ /** Close the connection. After this, no auto-reconnect happens. */
1200
+ async close() {
1201
+ this.explicitClose = true;
1202
+ this.setState("closing");
1203
+ if (this.pingTimer) {
1204
+ clearInterval(this.pingTimer);
1205
+ this.pingTimer = null;
1206
+ }
1207
+ if (this.socket && this.socket.readyState === this.opts.webSocketImpl.OPEN) {
1208
+ this.socket.close(1e3, "client closing");
1209
+ }
1210
+ }
1211
+ // ── internal ───────────────────────────────────────────────────────────────
1212
+ async connectInternal() {
1213
+ this.setState("connecting");
1214
+ const token = await this.opts.getToken();
1215
+ const url = `${this.opts.nodeBaseURL}/chat/ws?access_token=${encodeURIComponent(token)}`;
1216
+ const Ctor = this.opts.webSocketImpl;
1217
+ const sock = new Ctor(url);
1218
+ this.socket = sock;
1219
+ return new Promise((resolve, reject) => {
1220
+ sock.onopen = () => {
1221
+ this.attempt = 0;
1222
+ this.setState("open");
1223
+ this.startPing();
1224
+ resolve();
1225
+ };
1226
+ sock.onmessage = (ev) => {
1227
+ let frame;
1228
+ try {
1229
+ frame = JSON.parse(typeof ev.data === "string" ? ev.data : "");
1230
+ } catch {
1231
+ return;
1232
+ }
1233
+ try {
1234
+ const ret = this.opts.onFrame(frame);
1235
+ if (ret && typeof ret.catch === "function") {
1236
+ ret.catch(() => {
1237
+ });
1238
+ }
1239
+ } catch {
1240
+ }
1241
+ };
1242
+ sock.onerror = (_ev) => {
1243
+ if (this.state === "connecting") reject(new Error("ws connect error"));
1244
+ };
1245
+ sock.onclose = () => {
1246
+ this.stopPing();
1247
+ this.socket = null;
1248
+ if (this.explicitClose || !this.opts.reconnect) {
1249
+ this.setState("closed");
1250
+ return;
1251
+ }
1252
+ this.setState("reconnecting");
1253
+ const delay = backoffMs(this.attempt++);
1254
+ setTimeout(() => {
1255
+ this.connectInternal().catch(() => {
1256
+ });
1257
+ }, delay);
1258
+ };
1259
+ });
1260
+ }
1261
+ startPing() {
1262
+ if (this.opts.pingIntervalMs <= 0) return;
1263
+ this.pingTimer = setInterval(() => {
1264
+ try {
1265
+ this.ping();
1266
+ } catch {
1267
+ }
1268
+ }, this.opts.pingIntervalMs);
1269
+ }
1270
+ stopPing() {
1271
+ if (this.pingTimer) {
1272
+ clearInterval(this.pingTimer);
1273
+ this.pingTimer = null;
1274
+ }
1275
+ }
1276
+ setState(s) {
1277
+ if (this.state === s) return;
1278
+ this.state = s;
1279
+ try {
1280
+ this.opts.onState(s);
1281
+ } catch {
1282
+ }
1283
+ }
1284
+ };
1285
+ function backoffMs(attempt) {
1286
+ const base = Math.min(500 * 2 ** attempt, 3e4);
1287
+ const jitter = base * (0.2 * (Math.random() - 0.5) * 2);
1288
+ return Math.max(250, Math.floor(base + jitter));
1289
+ }
1290
+
1291
+ // src/typing.ts
1292
+ var STARTED_REFRESH_MS = 3e3;
1293
+ var AUTO_STOPPED_AFTER_MS = 5e3;
1294
+ var TypingManager = class {
1295
+ constructor(emit) {
1296
+ this.emit = emit;
1297
+ }
1298
+ emit;
1299
+ peers = /* @__PURE__ */ new Map();
1300
+ /**
1301
+ * Called by the SDK on every keystroke change.
1302
+ * `isTyping=true` means "user is typing now"; `false` means "user
1303
+ * explicitly stopped (cleared input or blurred)".
1304
+ */
1305
+ setTyping(peerUserId, isTyping) {
1306
+ const now = Date.now();
1307
+ const state = this.peers.get(peerUserId);
1308
+ if (!isTyping) {
1309
+ if (state?.isActive) {
1310
+ this.cancelAutoStop(state);
1311
+ state.isActive = false;
1312
+ this.emit(peerUserId, "stopped");
1313
+ }
1314
+ return;
1315
+ }
1316
+ if (!state) {
1317
+ const fresh = {
1318
+ lastStartedAt: now,
1319
+ autoStopTimer: null,
1320
+ isActive: true
1321
+ };
1322
+ this.peers.set(peerUserId, fresh);
1323
+ this.scheduleAutoStop(peerUserId, fresh);
1324
+ this.emit(peerUserId, "started");
1325
+ return;
1326
+ }
1327
+ if (!state.isActive) {
1328
+ state.isActive = true;
1329
+ state.lastStartedAt = now;
1330
+ this.scheduleAutoStop(peerUserId, state);
1331
+ this.emit(peerUserId, "started");
1332
+ return;
1333
+ }
1334
+ this.cancelAutoStop(state);
1335
+ this.scheduleAutoStop(peerUserId, state);
1336
+ if (now - state.lastStartedAt >= STARTED_REFRESH_MS) {
1337
+ state.lastStartedAt = now;
1338
+ this.emit(peerUserId, "started");
1339
+ }
1340
+ }
1341
+ /**
1342
+ * Called by the SDK when it actually sends a (non-typing) message to
1343
+ * this peer — closes any in-progress typing state silently.
1344
+ */
1345
+ clearOnSend(peerUserId) {
1346
+ const state = this.peers.get(peerUserId);
1347
+ if (!state || !state.isActive) return;
1348
+ this.cancelAutoStop(state);
1349
+ state.isActive = false;
1350
+ this.emit(peerUserId, "stopped");
1351
+ }
1352
+ /** Tear down all timers (called on chat.disconnect()). */
1353
+ shutdown() {
1354
+ for (const state of this.peers.values()) {
1355
+ this.cancelAutoStop(state);
1356
+ }
1357
+ this.peers.clear();
1358
+ }
1359
+ // ── internal ───────────────────────────────────────────────────────────────
1360
+ scheduleAutoStop(peerUserId, state) {
1361
+ state.autoStopTimer = setTimeout(() => {
1362
+ state.autoStopTimer = null;
1363
+ if (!state.isActive) return;
1364
+ state.isActive = false;
1365
+ this.emit(peerUserId, "stopped");
1366
+ }, AUTO_STOPPED_AFTER_MS);
1367
+ }
1368
+ cancelAutoStop(state) {
1369
+ if (state.autoStopTimer) {
1370
+ clearTimeout(state.autoStopTimer);
1371
+ state.autoStopTimer = null;
1372
+ }
1373
+ }
1374
+ };
1375
+
1376
+ // src/crypto/fake-adapter.ts
1377
+ var PREKEY_MARKER = "FAKEPREKEY:";
1378
+ var NORMAL_MARKER = "FAKENORMAL:";
1379
+ var FakeCryptoAdapter = class {
1380
+ account = null;
1381
+ // session existence — keyed "<peerUser>|<peerDevice>"; value irrelevant
1382
+ sessions = /* @__PURE__ */ new Set();
1383
+ otkCounter = 0;
1384
+ async init() {
1385
+ }
1386
+ async hasAccount() {
1387
+ return this.account !== null;
1388
+ }
1389
+ async generateAccount(otkCount) {
1390
+ if (this.account) throw new Error("account already exists");
1391
+ const id = randomB64(32);
1392
+ this.account = {
1393
+ identityKeyCurve: id,
1394
+ identityKeyEd: randomB64(32),
1395
+ signedPrekey: randomB64(32),
1396
+ signedPrekeySig: randomB64(64),
1397
+ fallbackPrekey: randomB64(32),
1398
+ fallbackPrekeySig: randomB64(64),
1399
+ fingerprint: chunked(id),
1400
+ otkPool: this.makeOtks(otkCount)
1401
+ };
1402
+ return this.snapshot();
1403
+ }
1404
+ async getCurrentBundle() {
1405
+ if (!this.account) throw new Error("no account");
1406
+ return this.snapshot();
1407
+ }
1408
+ async generateOneTimeKeys(n) {
1409
+ if (!this.account) throw new Error("no account");
1410
+ const fresh = this.makeOtks(n);
1411
+ this.account.otkPool.push(...fresh);
1412
+ return fresh;
1413
+ }
1414
+ async unusedOneTimeKeyCount() {
1415
+ return this.account?.otkPool.length ?? 0;
1416
+ }
1417
+ async encryptForPeer(peerUserId, peerDeviceId, _peerBundle, plaintext) {
1418
+ if (!this.account) throw new Error("no account");
1419
+ const key = sessionKey3(peerUserId, peerDeviceId);
1420
+ const isFirst = !this.sessions.has(key);
1421
+ this.sessions.add(key);
1422
+ const marker = isFirst ? PREKEY_MARKER : NORMAL_MARKER;
1423
+ const ciphertext = btoa(marker + plaintext);
1424
+ return { ciphertext, msgType: isFirst ? "prekey" : "normal" };
1425
+ }
1426
+ async decryptFromPeer(peerUserId, peerDeviceId, ciphertext, msgType) {
1427
+ if (!this.account) throw new Error("no account");
1428
+ const key = sessionKey3(peerUserId, peerDeviceId);
1429
+ if (msgType === "prekey") {
1430
+ this.sessions.add(key);
1431
+ } else if (!this.sessions.has(key)) {
1432
+ throw new Error("no session for normal-type ciphertext");
1433
+ }
1434
+ const decoded = atob(ciphertext);
1435
+ const expected = msgType === "prekey" ? PREKEY_MARKER : NORMAL_MARKER;
1436
+ if (!decoded.startsWith(expected)) {
1437
+ throw new Error(`fake decrypt: expected ${expected} prefix, got ${decoded.slice(0, 16)}`);
1438
+ }
1439
+ return decoded.slice(expected.length);
1440
+ }
1441
+ async forgetSession(peerUserId, peerDeviceId) {
1442
+ this.sessions.delete(sessionKey3(peerUserId, peerDeviceId));
1443
+ }
1444
+ async hasSession(peerUserId, peerDeviceId) {
1445
+ return this.sessions.has(sessionKey3(peerUserId, peerDeviceId));
1446
+ }
1447
+ // ── helpers ────────────────────────────────────────────────────────────────
1448
+ snapshot() {
1449
+ if (!this.account) throw new Error("no account");
1450
+ const { otkPool, ...rest } = this.account;
1451
+ return { ...rest, oneTimeKeys: [...otkPool] };
1452
+ }
1453
+ makeOtks(n) {
1454
+ const out = [];
1455
+ for (let i = 0; i < n; i++) {
1456
+ this.otkCounter++;
1457
+ out.push({ id: `fake-otk-${this.otkCounter}`, public: randomB64(32) });
1458
+ }
1459
+ return out;
1460
+ }
1461
+ };
1462
+ function sessionKey3(peerUserId, peerDeviceId) {
1463
+ return `${peerUserId}|${peerDeviceId}`;
1464
+ }
1465
+ function randomB64(bytes) {
1466
+ const buf = new Uint8Array(bytes);
1467
+ globalThis.crypto.getRandomValues(buf);
1468
+ return btoa(String.fromCharCode(...buf)).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
1469
+ }
1470
+ function chunked(b64) {
1471
+ const hex = b64.slice(0, 32);
1472
+ return hex.match(/.{1,4}/g)?.join("-") ?? hex;
1473
+ }
1474
+
1475
+ // src/store/memory-adapter.ts
1476
+ var MemoryKVStore = class {
1477
+ map = /* @__PURE__ */ new Map();
1478
+ async getString(key) {
1479
+ const v = this.map.get(key);
1480
+ return v?.type === "string" ? v.value : null;
1481
+ }
1482
+ async setString(key, value) {
1483
+ this.map.set(key, { type: "string", value });
1484
+ }
1485
+ async getBytes(key) {
1486
+ const v = this.map.get(key);
1487
+ return v?.type === "bytes" ? v.value : null;
1488
+ }
1489
+ async setBytes(key, value) {
1490
+ this.map.set(key, { type: "bytes", value });
1491
+ }
1492
+ async delete(key) {
1493
+ this.map.delete(key);
1494
+ }
1495
+ async listKeys(prefix) {
1496
+ const out = [];
1497
+ for (const k of this.map.keys()) {
1498
+ if (k.startsWith(prefix)) out.push(k);
1499
+ }
1500
+ return out;
1501
+ }
1502
+ };
1503
+
1504
+ // src/index.ts
1505
+ var VERSION2 = "0.0.0";
1506
+ var CONTENT_PROTOCOL_VERSION2 = CONTENT_PROTOCOL_VERSION;
1507
+ var RECEIVED_BATCH_FLUSH_MS = 500;
1508
+ var RECEIVED_BATCH_FLUSH_SIZE = 50;
1509
+ var READ_RECEIPTS_KEY = "prefs/readReceiptsEnabled";
1510
+ function verifiedKey(peerUserId, peerDeviceId) {
1511
+ return `verifiedDevice/${peerUserId}/${peerDeviceId}`;
1512
+ }
1513
+ var DTelecomSecureChat = class _DTelecomSecureChat {
1514
+ deviceId;
1515
+ http;
1516
+ ws;
1517
+ crypto;
1518
+ store;
1519
+ keyBundle;
1520
+ sessions;
1521
+ peerDevices;
1522
+ messages;
1523
+ status;
1524
+ outbox = new Outbox();
1525
+ typingMgr;
1526
+ listeners = /* @__PURE__ */ new Map();
1527
+ /** Per-peer-device queue of received-event ids awaiting batch send. */
1528
+ pendingReceived = /* @__PURE__ */ new Map();
1529
+ receivedFlushTimer = null;
1530
+ /** Self user id derived from chat-token claims after first mint. */
1531
+ selfUserId = null;
1532
+ /** Devices we've already emitted `peerNewDevice` for, to avoid duplicates. */
1533
+ announcedNewDevices = /* @__PURE__ */ new Set();
1534
+ /** True after we've reuploaded the bundle once for this connection — set
1535
+ * after a "peer has zero devices" outcome that suggests the backend
1536
+ * forgot us (registry mismatch / wipe). Don't loop. */
1537
+ bundleReuploadAttempted = false;
1538
+ /** Cache of the read-receipts preference (loaded lazily). */
1539
+ readReceiptsCache = null;
1540
+ /**
1541
+ * Connect to the dtelecom mesh. Generates an Olm account on first run,
1542
+ * uploads the bundle, opens /chat/ws to the closest discovered node,
1543
+ * and pulls any pending offline envelopes.
1544
+ */
1545
+ static async connect(opts) {
1546
+ const chat = new _DTelecomSecureChat();
1547
+ await chat.bootstrap(opts);
1548
+ return chat;
1549
+ }
1550
+ // The constructor is private-by-convention — use connect().
1551
+ constructor() {
1552
+ }
1553
+ /** Stable per-install device id. Useful for app diagnostics. */
1554
+ get currentDeviceId() {
1555
+ return this.deviceId;
1556
+ }
1557
+ // ── public API: messaging ──────────────────────────────────────────────────
1558
+ async sendText(peerUserId, text, opts) {
1559
+ const event = newText(text, opts?.replyTo);
1560
+ await this.sendContent(peerUserId, event, { ephemeral: false });
1561
+ if (this.selfUserId) {
1562
+ await this.messages.put({
1563
+ id: event.id,
1564
+ peerUserId,
1565
+ senderUserId: this.selfUserId,
1566
+ text: event.text,
1567
+ sentAt: event.clientSentAt,
1568
+ editedAt: null,
1569
+ deletedAt: null,
1570
+ ...event.replyTo !== void 0 ? { replyTo: event.replyTo } : {}
1571
+ });
1572
+ }
1573
+ await this.selfEcho(peerUserId, event);
1574
+ this.typingMgr.clearOnSend(peerUserId);
1575
+ return event.id;
1576
+ }
1577
+ async editMessage(peerUserId, targetId, newText2) {
1578
+ const event = newEdit(targetId, newText2);
1579
+ await this.sendContent(peerUserId, event, { ephemeral: false });
1580
+ if (this.selfUserId) {
1581
+ await this.messages.applyEdit({
1582
+ targetId,
1583
+ editorUserId: this.selfUserId,
1584
+ newText: newText2,
1585
+ editedAt: event.clientSentAt
1586
+ });
1587
+ }
1588
+ await this.selfEcho(peerUserId, event);
1589
+ return event.id;
1590
+ }
1591
+ async deleteMessage(peerUserId, targetId) {
1592
+ const event = newDelete(targetId);
1593
+ await this.sendContent(peerUserId, event, { ephemeral: false });
1594
+ if (this.selfUserId) {
1595
+ await this.messages.applyDelete({
1596
+ targetId,
1597
+ deleterUserId: this.selfUserId,
1598
+ deletedAt: event.clientSentAt
1599
+ });
1600
+ }
1601
+ await this.selfEcho(peerUserId, event);
1602
+ return event.id;
1603
+ }
1604
+ /**
1605
+ * Send a read-watermark to `peerUserId`. No-op when read receipts are
1606
+ * disabled by `setReadReceiptsEnabled(false)` — the local user remains
1607
+ * invisible to senders, but inbound `read` events from peers are still
1608
+ * consumed (the sender's preference is their own call).
1609
+ */
1610
+ async markRead(peerUserId, upToMessageId) {
1611
+ if (!await this.areReadReceiptsEnabled()) return;
1612
+ const event = newRead(upToMessageId);
1613
+ await this.sendContent(peerUserId, event, { ephemeral: false });
1614
+ await this.selfEcho(peerUserId, event);
1615
+ }
1616
+ setTyping(peerUserId, isTyping) {
1617
+ this.typingMgr.setTyping(peerUserId, isTyping);
1618
+ }
1619
+ // ── public API: preferences ───────────────────────────────────────────────
1620
+ /** Enable/disable outbound read receipts. Persisted in the local KV store. */
1621
+ async setReadReceiptsEnabled(enabled) {
1622
+ await this.store.setString(READ_RECEIPTS_KEY, enabled ? "1" : "0");
1623
+ this.readReceiptsCache = enabled;
1624
+ }
1625
+ /** Read the current preference. Default true. */
1626
+ async areReadReceiptsEnabled() {
1627
+ if (this.readReceiptsCache !== null) return this.readReceiptsCache;
1628
+ const raw = await this.store.getString(READ_RECEIPTS_KEY);
1629
+ const enabled = raw === null ? true : raw === "1";
1630
+ this.readReceiptsCache = enabled;
1631
+ return enabled;
1632
+ }
1633
+ // ── public API: peer device verification ──────────────────────────────────
1634
+ /**
1635
+ * Returns the cached peer-device list for `peerUserId`. Refreshes via
1636
+ * `list_devices` if the local cache is empty or stale. Used to render
1637
+ * the "Known Devices" settings panel. Doesn't consume OTKs.
1638
+ */
1639
+ async getKnownPeerDevices(peerUserId) {
1640
+ const devices = await this.peerDevices.getPeerDevices(peerUserId);
1641
+ const out = [];
1642
+ for (const d of devices) {
1643
+ const verified = await this.isPeerDeviceVerified(peerUserId, d.deviceId);
1644
+ out.push({
1645
+ deviceId: d.deviceId,
1646
+ fingerprint: d.fingerprint,
1647
+ lastActiveAt: d.lastActiveAt,
1648
+ verified
1649
+ });
1650
+ }
1651
+ return out;
1652
+ }
1653
+ /** Single-device fingerprint accessor. Returns null if unknown. */
1654
+ async getPeerDeviceFingerprint(peerUserId, peerDeviceId) {
1655
+ const list = await this.peerDevices.getPeerDevices(peerUserId);
1656
+ return list.find((d) => d.deviceId === peerDeviceId)?.fingerprint ?? null;
1657
+ }
1658
+ /**
1659
+ * Mark a peer device as verified (or unverified). Local-only — doesn't
1660
+ * change the protocol's behavior, just exposes a flag the UI can render.
1661
+ */
1662
+ async markPeerDeviceVerified(peerUserId, peerDeviceId, verified) {
1663
+ const key = verifiedKey(peerUserId, peerDeviceId);
1664
+ if (verified) {
1665
+ await this.store.setString(key, "1");
1666
+ } else {
1667
+ await this.store.delete(key);
1668
+ }
1669
+ }
1670
+ async isPeerDeviceVerified(peerUserId, peerDeviceId) {
1671
+ return await this.store.getString(verifiedKey(peerUserId, peerDeviceId)) === "1";
1672
+ }
1673
+ /**
1674
+ * Read persisted message history with `peerUserId`, oldest→newest within
1675
+ * the page. Use `beforeSentAt` + `limit` to paginate older messages.
1676
+ * Returns include local-sent messages (sender = self), inbound messages
1677
+ * (sender = peer), and tombstoned/edited rows reflecting the latest state.
1678
+ */
1679
+ getHistory(peerUserId, opts = {}) {
1680
+ return this.messages.listForPeer(peerUserId, opts);
1681
+ }
1682
+ // ── public API: blocks ─────────────────────────────────────────────────────
1683
+ blockUser(peerUserId) {
1684
+ return this.http.blockUser(this.deviceId, peerUserId);
1685
+ }
1686
+ unblockUser(peerUserId) {
1687
+ return this.http.unblockUser(this.deviceId, peerUserId);
1688
+ }
1689
+ async getBlockedUsers() {
1690
+ const r = await this.http.listBlocked(this.deviceId);
1691
+ return r.blocked;
1692
+ }
1693
+ // ── public API: events ─────────────────────────────────────────────────────
1694
+ on(event, fn) {
1695
+ let set = this.listeners.get(event);
1696
+ if (!set) {
1697
+ set = /* @__PURE__ */ new Set();
1698
+ this.listeners.set(event, set);
1699
+ }
1700
+ set.add(fn);
1701
+ return () => set.delete(fn);
1702
+ }
1703
+ // ── lifecycle ──────────────────────────────────────────────────────────────
1704
+ async disconnect() {
1705
+ this.typingMgr.shutdown();
1706
+ this.flushReceivedBatch();
1707
+ await this.ws.close();
1708
+ }
1709
+ // ── internals ──────────────────────────────────────────────────────────────
1710
+ async bootstrap(opts) {
1711
+ this.store = opts.store ?? new WebKVStore();
1712
+ this.crypto = opts.crypto ?? new OlmCryptoAdapter({ store: this.store });
1713
+ await this.crypto.init();
1714
+ this.deviceId = await loadOrCreateDeviceId(this.store);
1715
+ this.http = new HttpClient({
1716
+ apiBaseURL: opts.apiBaseURL,
1717
+ fetchChatToken: opts.fetchChatToken,
1718
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
1719
+ });
1720
+ const mint = await this.http.getMint(this.deviceId);
1721
+ this.selfUserId = parseSubFromJwt(mint.chatToken);
1722
+ this.keyBundle = new KeyBundleManager({ http: this.http, crypto: this.crypto, deviceId: this.deviceId });
1723
+ await this.keyBundle.ensureKeyBundle();
1724
+ this.sessions = new SessionManager({
1725
+ http: this.http,
1726
+ crypto: this.crypto,
1727
+ selfDeviceId: this.deviceId,
1728
+ selfUserId: this.selfUserId
1729
+ });
1730
+ this.peerDevices = new PeerDeviceCache({ http: this.http, selfDeviceId: this.deviceId });
1731
+ this.messages = new MessageStore(this.store);
1732
+ this.status = new StatusTracker();
1733
+ this.status.on((messageId, status, peerUserId) => {
1734
+ this.dispatch("statusChange", { peerUserId, messageId, status });
1735
+ });
1736
+ this.typingMgr = new TypingManager((peerUserId, state) => {
1737
+ this.sendContent(peerUserId, newTyping(state), { ephemeral: true }).catch(() => {
1738
+ });
1739
+ });
1740
+ const nodeUrl = mint.chatNodeWsUrl.replace(/\/chat\/ws\/?$/, "");
1741
+ this.ws = new WsClient({
1742
+ nodeBaseURL: nodeUrl,
1743
+ getToken: () => this.http.getToken(this.deviceId),
1744
+ onFrame: (f) => this.onFrame(f),
1745
+ onState: (s) => this.onWsState(s)
1746
+ });
1747
+ await this.ws.connect();
1748
+ await this.drainPending();
1749
+ void this.keyBundle.topUpIfNeeded().catch(() => {
1750
+ });
1751
+ if (this.selfUserId) {
1752
+ void this.sessions.refreshPeerBundles(this.selfUserId).catch(() => {
1753
+ });
1754
+ }
1755
+ }
1756
+ /**
1757
+ * State-listener for the underlying WsClient. On every transition to
1758
+ * "open" (initial connect AND auto-reconnect), drain any queued
1759
+ * outbound sends and re-pull pending offline envelopes — closes the
1760
+ * gap during disconnect.
1761
+ */
1762
+ onWsState(s) {
1763
+ if (s !== "open") return;
1764
+ void this.outbox.tick();
1765
+ void this.drainPending().catch(() => {
1766
+ });
1767
+ void this.keyBundle.topUpIfNeeded().catch(() => {
1768
+ });
1769
+ }
1770
+ /** Set true while drainPending is running to avoid overlapping calls
1771
+ * (would otherwise hand the same envelope to two concurrent decrypt
1772
+ * invocations — Olm rejects the second). */
1773
+ drainingPending = false;
1774
+ async drainPending() {
1775
+ if (this.drainingPending) return;
1776
+ this.drainingPending = true;
1777
+ try {
1778
+ while (true) {
1779
+ const r = await this.http.pending(this.deviceId, 100);
1780
+ if (r.envelopes.length === 0) return;
1781
+ const ackUuids = [];
1782
+ for (const env of r.envelopes) {
1783
+ try {
1784
+ await this.handleInboundCiphertext({
1785
+ peerUserId: env.senderUserId,
1786
+ peerDeviceId: env.senderDeviceId,
1787
+ ciphertext: env.ciphertext,
1788
+ msgType: env.msgType
1789
+ });
1790
+ ackUuids.push(env.envelopeUuid);
1791
+ } catch {
1792
+ }
1793
+ }
1794
+ if (ackUuids.length === 0) return;
1795
+ await this.http.ack(this.deviceId, ackUuids);
1796
+ }
1797
+ } finally {
1798
+ this.drainingPending = false;
1799
+ }
1800
+ }
1801
+ async onFrame(frame) {
1802
+ if (frame.kind === "chatEnvelope") {
1803
+ await this.handleInboundCiphertext({
1804
+ peerUserId: frame.senderUserId,
1805
+ peerDeviceId: frame.senderDeviceId,
1806
+ ciphertext: frame.ciphertext,
1807
+ msgType: frame.msgType
1808
+ });
1809
+ return;
1810
+ }
1811
+ if (frame.kind === "chatSendResult") {
1812
+ for (const r of frame.results) {
1813
+ this.status.onSendResult(r.envelopeUuid, r.status);
1814
+ }
1815
+ return;
1816
+ }
1817
+ }
1818
+ async handleInboundCiphertext(opts) {
1819
+ let plaintext;
1820
+ try {
1821
+ plaintext = await this.sessions.decrypt(
1822
+ opts.peerUserId,
1823
+ opts.peerDeviceId,
1824
+ opts.ciphertext,
1825
+ opts.msgType
1826
+ );
1827
+ } catch (firstErr) {
1828
+ await this.sessions.forgetPeerDevice(opts.peerUserId, opts.peerDeviceId);
1829
+ this.peerDevices.invalidate(opts.peerUserId);
1830
+ try {
1831
+ await this.peerDevices.refresh(opts.peerUserId);
1832
+ } catch {
1833
+ }
1834
+ try {
1835
+ plaintext = await this.sessions.decrypt(
1836
+ opts.peerUserId,
1837
+ opts.peerDeviceId,
1838
+ opts.ciphertext,
1839
+ opts.msgType
1840
+ );
1841
+ } catch {
1842
+ throw firstErr;
1843
+ }
1844
+ }
1845
+ await this.maybeAnnouncePeerDevice(opts.peerUserId, opts.peerDeviceId);
1846
+ const event = decodeEventBytes(new TextEncoder().encode(plaintext));
1847
+ if (!event) return;
1848
+ await this.dispatchInboundEvent(opts.peerUserId, opts.peerDeviceId, event);
1849
+ }
1850
+ /**
1851
+ * If `peerDeviceId` is not in our local cache for `peerUserId`, refresh
1852
+ * the peer's device list and emit `peerNewDevice` exactly once. Idempotent
1853
+ * across repeated calls — second message from the same new device is a
1854
+ * cheap cache hit. Failures (HTTP error fetching the device list) are
1855
+ * swallowed; we'll re-attempt on the next inbound from this device.
1856
+ */
1857
+ async maybeAnnouncePeerDevice(peerUserId, peerDeviceId) {
1858
+ const flag = `${peerUserId}|${peerDeviceId}`;
1859
+ if (this.announcedNewDevices.has(flag)) return;
1860
+ let devices;
1861
+ try {
1862
+ devices = await this.peerDevices.getPeerDevices(peerUserId);
1863
+ } catch {
1864
+ return;
1865
+ }
1866
+ let entry = devices.find((d) => d.deviceId === peerDeviceId);
1867
+ if (!entry) {
1868
+ let fresh;
1869
+ try {
1870
+ fresh = await this.peerDevices.refresh(peerUserId);
1871
+ } catch {
1872
+ return;
1873
+ }
1874
+ entry = fresh.find((d) => d.deviceId === peerDeviceId);
1875
+ if (!entry) return;
1876
+ }
1877
+ this.announcedNewDevices.add(flag);
1878
+ try {
1879
+ await this.sessions.refreshPeerBundles(peerUserId);
1880
+ } catch {
1881
+ }
1882
+ this.dispatch("peerNewDevice", {
1883
+ peerUserId,
1884
+ peerDeviceId,
1885
+ fingerprint: entry.fingerprint
1886
+ });
1887
+ }
1888
+ async dispatchInboundEvent(peerUserId, peerDeviceId, event) {
1889
+ switch (event.type) {
1890
+ case "text": {
1891
+ await this.messages.put({
1892
+ id: event.id,
1893
+ peerUserId,
1894
+ senderUserId: peerUserId,
1895
+ text: event.text,
1896
+ sentAt: event.clientSentAt,
1897
+ editedAt: null,
1898
+ deletedAt: null,
1899
+ ...event.replyTo !== void 0 ? { replyTo: event.replyTo } : {}
1900
+ });
1901
+ this.dispatch("message", {
1902
+ peerUserId,
1903
+ peerDeviceId,
1904
+ senderUserId: peerUserId,
1905
+ message: {
1906
+ id: event.id,
1907
+ text: event.text,
1908
+ sentAt: event.clientSentAt,
1909
+ ...event.replyTo !== void 0 ? { replyTo: event.replyTo } : {}
1910
+ }
1911
+ });
1912
+ this.queueReceivedAck(peerUserId, peerDeviceId, event.id);
1913
+ return;
1914
+ }
1915
+ case "edit": {
1916
+ const updated = await this.messages.applyEdit({
1917
+ targetId: event.targetId,
1918
+ editorUserId: peerUserId,
1919
+ newText: event.text,
1920
+ editedAt: event.clientSentAt
1921
+ });
1922
+ if (updated) {
1923
+ this.dispatch("messageEdited", {
1924
+ peerUserId,
1925
+ editorUserId: peerUserId,
1926
+ targetId: event.targetId,
1927
+ newText: event.text,
1928
+ editedAt: event.clientSentAt
1929
+ });
1930
+ }
1931
+ this.queueReceivedAck(peerUserId, peerDeviceId, event.id);
1932
+ return;
1933
+ }
1934
+ case "delete": {
1935
+ const updated = await this.messages.applyDelete({
1936
+ targetId: event.targetId,
1937
+ deleterUserId: peerUserId,
1938
+ deletedAt: event.clientSentAt
1939
+ });
1940
+ if (updated) {
1941
+ this.dispatch("messageDeleted", {
1942
+ peerUserId,
1943
+ deleterUserId: peerUserId,
1944
+ targetId: event.targetId,
1945
+ deletedAt: event.clientSentAt
1946
+ });
1947
+ }
1948
+ this.queueReceivedAck(peerUserId, peerDeviceId, event.id);
1949
+ return;
1950
+ }
1951
+ case "read": {
1952
+ this.status.onRead({ peerUserId, upToId: event.upToId });
1953
+ this.dispatch("readReceipt", { peerUserId, peerDeviceId, upToId: event.upToId });
1954
+ return;
1955
+ }
1956
+ case "received": {
1957
+ this.status.onReceived({ peerUserId, peerDeviceId, messageIds: event.ids });
1958
+ return;
1959
+ }
1960
+ case "typing": {
1961
+ this.dispatch("typing", { peerUserId, peerDeviceId, state: event.state });
1962
+ return;
1963
+ }
1964
+ case "selfEcho": {
1965
+ if (peerUserId !== this.selfUserId) return;
1966
+ const inner = event.original;
1967
+ const originalPeer = event.originalPeer;
1968
+ switch (inner.type) {
1969
+ case "text": {
1970
+ await this.messages.put({
1971
+ id: inner.id,
1972
+ peerUserId: originalPeer,
1973
+ senderUserId: peerUserId,
1974
+ text: inner.text,
1975
+ sentAt: inner.clientSentAt,
1976
+ editedAt: null,
1977
+ deletedAt: null,
1978
+ ...inner.replyTo !== void 0 ? { replyTo: inner.replyTo } : {}
1979
+ });
1980
+ this.dispatch("message", {
1981
+ peerUserId: originalPeer,
1982
+ peerDeviceId,
1983
+ senderUserId: peerUserId,
1984
+ message: {
1985
+ id: inner.id,
1986
+ text: inner.text,
1987
+ sentAt: inner.clientSentAt,
1988
+ ...inner.replyTo !== void 0 ? { replyTo: inner.replyTo } : {}
1989
+ }
1990
+ });
1991
+ return;
1992
+ }
1993
+ case "edit": {
1994
+ const updated = await this.messages.applyEdit({
1995
+ targetId: inner.targetId,
1996
+ editorUserId: peerUserId,
1997
+ newText: inner.text,
1998
+ editedAt: inner.clientSentAt
1999
+ });
2000
+ if (updated) {
2001
+ this.dispatch("messageEdited", {
2002
+ peerUserId: originalPeer,
2003
+ editorUserId: peerUserId,
2004
+ targetId: inner.targetId,
2005
+ newText: inner.text,
2006
+ editedAt: inner.clientSentAt
2007
+ });
2008
+ }
2009
+ return;
2010
+ }
2011
+ case "delete": {
2012
+ const updated = await this.messages.applyDelete({
2013
+ targetId: inner.targetId,
2014
+ deleterUserId: peerUserId,
2015
+ deletedAt: inner.clientSentAt
2016
+ });
2017
+ if (updated) {
2018
+ this.dispatch("messageDeleted", {
2019
+ peerUserId: originalPeer,
2020
+ deleterUserId: peerUserId,
2021
+ targetId: inner.targetId,
2022
+ deletedAt: inner.clientSentAt
2023
+ });
2024
+ }
2025
+ return;
2026
+ }
2027
+ case "read": {
2028
+ this.status.onRead({ peerUserId: originalPeer, upToId: inner.upToId });
2029
+ return;
2030
+ }
2031
+ }
2032
+ return;
2033
+ }
2034
+ }
2035
+ }
2036
+ /**
2037
+ * Multi-device self-echo. Wraps the original event in a `selfEcho`
2038
+ * envelope and ships it to our own user (mesh fanout filters our
2039
+ * own device). Other devices belonging to the same user receive,
2040
+ * unwrap, and persist the event so their local history mirrors this
2041
+ * device's. No-op when:
2042
+ * - we don't yet know our own user id
2043
+ * - the original was addressed to ourselves (avoids loops)
2044
+ * - we have no other devices registered (encryptForPeer returns [])
2045
+ * Best-effort: failures here don't surface to the caller.
2046
+ */
2047
+ async selfEcho(originalPeer, original) {
2048
+ if (!this.selfUserId) return;
2049
+ if (originalPeer === this.selfUserId) return;
2050
+ const echo = newSelfEcho(originalPeer, original);
2051
+ try {
2052
+ const cached = this.sessions.cachedFanoutSize(this.selfUserId);
2053
+ if (cached === null || cached === 0) {
2054
+ try {
2055
+ await this.sessions.refreshPeerBundles(this.selfUserId);
2056
+ } catch {
2057
+ return;
2058
+ }
2059
+ }
2060
+ await this.sendContent(this.selfUserId, echo, { ephemeral: false });
2061
+ } catch {
2062
+ }
2063
+ }
2064
+ async sendContent(peerUserId, event, opts) {
2065
+ const plainBytes = encodeEventBytes(event);
2066
+ const plaintext = new TextDecoder().decode(plainBytes);
2067
+ const encrypted = await this.sessions.encryptForPeer(peerUserId, plaintext);
2068
+ if (encrypted.length === 0) {
2069
+ if (!opts.ephemeral && !this.bundleReuploadAttempted) {
2070
+ const seenBefore = (await this.messages.listForPeer(peerUserId, { limit: 1 })).length > 0;
2071
+ if (seenBefore) {
2072
+ this.bundleReuploadAttempted = true;
2073
+ try {
2074
+ await this.keyBundle.reuploadCurrentBundle();
2075
+ } catch {
2076
+ }
2077
+ }
2078
+ }
2079
+ return;
2080
+ }
2081
+ const targets = encrypted.map((e) => ({
2082
+ deviceId: e.peerDeviceId,
2083
+ ciphertext: e.ciphertext,
2084
+ envelopeUuid: globalThis.crypto.randomUUID()
2085
+ }));
2086
+ if (event.type === "text" || event.type === "edit" || event.type === "delete") {
2087
+ const map = /* @__PURE__ */ new Map();
2088
+ for (const t of targets) map.set(t.envelopeUuid, t.deviceId);
2089
+ this.status.trackOutbound({ messageId: event.id, peerUserId, envelopeToDevice: map });
2090
+ }
2091
+ const msgType = encrypted[0].msgType;
2092
+ const frame = {
2093
+ toUserId: peerUserId,
2094
+ ephemeral: opts.ephemeral || void 0,
2095
+ msgType,
2096
+ targets
2097
+ };
2098
+ if (opts.ephemeral) {
2099
+ if (this.ws.getState() === "open") {
2100
+ try {
2101
+ this.ws.sendChat(frame);
2102
+ } catch {
2103
+ }
2104
+ }
2105
+ return;
2106
+ }
2107
+ this.outbox.enqueue({
2108
+ messageId: event.id,
2109
+ peerUserId,
2110
+ ephemeral: false,
2111
+ attempt: async () => {
2112
+ const outcomes = /* @__PURE__ */ new Map();
2113
+ if (this.ws.getState() !== "open") {
2114
+ for (const t of targets) outcomes.set(t.envelopeUuid, "error");
2115
+ return outcomes;
2116
+ }
2117
+ try {
2118
+ this.ws.sendChat(frame);
2119
+ for (const t of targets) outcomes.set(t.envelopeUuid, "stored");
2120
+ } catch {
2121
+ for (const t of targets) outcomes.set(t.envelopeUuid, "error");
2122
+ }
2123
+ return outcomes;
2124
+ }
2125
+ });
2126
+ void this.outbox.tick();
2127
+ void this.peerDevices;
2128
+ }
2129
+ queueReceivedAck(peerUserId, peerDeviceId, eventId) {
2130
+ const key = `${peerUserId}|${peerDeviceId}`;
2131
+ const list = this.pendingReceived.get(key) ?? [];
2132
+ list.push(eventId);
2133
+ this.pendingReceived.set(key, list);
2134
+ if (list.length >= RECEIVED_BATCH_FLUSH_SIZE) {
2135
+ this.flushReceivedBatch();
2136
+ return;
2137
+ }
2138
+ if (!this.receivedFlushTimer) {
2139
+ this.receivedFlushTimer = setTimeout(() => this.flushReceivedBatch(), RECEIVED_BATCH_FLUSH_MS);
2140
+ }
2141
+ }
2142
+ flushReceivedBatch() {
2143
+ if (this.receivedFlushTimer) {
2144
+ clearTimeout(this.receivedFlushTimer);
2145
+ this.receivedFlushTimer = null;
2146
+ }
2147
+ if (this.pendingReceived.size === 0) return;
2148
+ for (const [key, ids] of this.pendingReceived.entries()) {
2149
+ const [peerUserId] = key.split("|");
2150
+ this.sendContent(peerUserId, newReceived(ids), { ephemeral: false }).catch(() => {
2151
+ });
2152
+ }
2153
+ this.pendingReceived.clear();
2154
+ }
2155
+ dispatch(name, event) {
2156
+ const set = this.listeners.get(name);
2157
+ if (!set) return;
2158
+ for (const fn of set) {
2159
+ try {
2160
+ fn(event);
2161
+ } catch {
2162
+ }
2163
+ }
2164
+ }
2165
+ };
2166
+ function parseSubFromJwt(jwt) {
2167
+ try {
2168
+ const parts = jwt.split(".");
2169
+ if (parts.length !== 3) return null;
2170
+ const padLen = (4 - parts[1].length % 4) % 4;
2171
+ const padded = parts[1] + "=".repeat(padLen);
2172
+ const std = padded.replace(/-/g, "+").replace(/_/g, "/");
2173
+ const decoded = atob(std);
2174
+ const claims = JSON.parse(decoded);
2175
+ return claims.sub ?? null;
2176
+ } catch {
2177
+ return null;
2178
+ }
2179
+ }
2180
+
2181
+ exports.CONTENT_PROTOCOL_VERSION = CONTENT_PROTOCOL_VERSION2;
2182
+ exports.DTelecomSecureChat = DTelecomSecureChat;
2183
+ exports.FakeCryptoAdapter = FakeCryptoAdapter;
2184
+ exports.MemoryKVStore = MemoryKVStore;
2185
+ exports.OlmCryptoAdapter = OlmCryptoAdapter;
2186
+ exports.VERSION = VERSION2;
2187
+ exports.WebKVStore = WebKVStore;
2188
+ //# sourceMappingURL=index.cjs.map
2189
+ //# sourceMappingURL=index.cjs.map