@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/LICENSE +202 -0
- package/README.md +134 -0
- package/dist/index.cjs +2189 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +435 -0
- package/dist/index.d.ts +435 -0
- package/dist/index.js +2162 -0
- package/dist/index.js.map +1 -0
- package/package.json +82 -0
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
|