@droponair/sdk-js 0.3.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.
@@ -0,0 +1,1293 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MessagingClient = void 0;
4
+ const bytes_1 = require("./bytes");
5
+ const protobuf_codec_1 = require("../transport/protobuf-codec");
6
+ const version_1 = require("../version");
7
+ const STORAGE_DEVICE_ID = 'droponair.device.id.v1';
8
+ class MessagingClient {
9
+ reconnectDelayMs() {
10
+ // Exponential backoff: 2s, 4s, 8s, 16s, 32s, 60s (capped)
11
+ const delay = Math.min(2000 * Math.pow(2, this.reconnectAttempt), 60000);
12
+ this.reconnectAttempt++;
13
+ return delay;
14
+ }
15
+ /**
16
+ * Register a document visibilitychange listener so that when the iOS app
17
+ * returns from background we can immediately assess the JWT state.
18
+ *
19
+ * Background problem: iOS WebView suspends JavaScript (including all
20
+ * setTimeout timers) while the app is backgrounded. A proactive-refresh
21
+ * timer scheduled for t+810 s effectively stops ticking, so if the user
22
+ * leaves the app open for 15+ minutes the JWT expires before the timer
23
+ * fires. This listener re-examines the JWT as soon as the user brings the
24
+ * app back to the foreground and either:
25
+ * a) reschedules the timer with the corrected remaining time, or
26
+ * b) triggers an immediate reconnect if the token has already expired or
27
+ * will expire within the next 90 seconds.
28
+ */
29
+ registerVisibilityChangeHandler() {
30
+ if (typeof document === 'undefined') {
31
+ return;
32
+ }
33
+ this.unregisterVisibilityChangeHandler();
34
+ this.visibilityChangeHandler = () => {
35
+ if (document.visibilityState !== 'visible') {
36
+ return;
37
+ }
38
+ this.onAppForeground();
39
+ };
40
+ document.addEventListener('visibilitychange', this.visibilityChangeHandler);
41
+ this.log('visibility_handler_registered');
42
+ }
43
+ unregisterVisibilityChangeHandler() {
44
+ if (typeof document === 'undefined' || !this.visibilityChangeHandler) {
45
+ return;
46
+ }
47
+ document.removeEventListener('visibilitychange', this.visibilityChangeHandler);
48
+ this.visibilityChangeHandler = null;
49
+ this.log('visibility_handler_unregistered');
50
+ }
51
+ /**
52
+ * Called every time the app returns to the foreground.
53
+ * Decides whether to reconnect immediately or just reschedule the proactive
54
+ * refresh timer with the time remaining from the current JWT's expiry.
55
+ */
56
+ onAppForeground() {
57
+ this.log('app_foreground_detected', {
58
+ wsState: this.ws?.readyState,
59
+ shouldReconnect: this.shouldReconnect,
60
+ ...this.jwtSummary(this.dropOnAirJwt),
61
+ });
62
+ if (!this.shouldReconnect) {
63
+ return;
64
+ }
65
+ // If the WebSocket is already gone (background disconnect), the existing
66
+ // onclose handler will already be scheduling a reconnect, nothing to do.
67
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
68
+ return;
69
+ }
70
+ const jwt = this.dropOnAirJwt;
71
+ if (!jwt) {
72
+ // No token at all, close and let the reconnect loop fetch a new one.
73
+ this.log('foreground_reconnect_no_jwt');
74
+ this.ws.close(1000, 'FOREGROUND_NO_JWT');
75
+ return;
76
+ }
77
+ if ((0, bytes_1.isJwtExpired)(jwt)) {
78
+ // Token already expired while we were in background, close NOW so the
79
+ // reconnect fetches a fresh token before the user tries to send anything.
80
+ this.log('foreground_reconnect_jwt_expired', this.jwtSummary(jwt));
81
+ this.ws.close(1000, 'FOREGROUND_JWT_EXPIRED');
82
+ return;
83
+ }
84
+ // Token is still valid, reschedule the proactive timer based on actual
85
+ // remaining time now that JS is running again (the previous timer may have
86
+ // been frozen). scheduleProactiveTokenRefresh re-arms the timer correctly.
87
+ this.log('foreground_reschedule_refresh', this.jwtSummary(jwt));
88
+ this.scheduleProactiveTokenRefresh(jwt);
89
+ }
90
+ /**
91
+ * Schedule a proactive token rotation 90 seconds before the JWT expires.
92
+ * When it fires, a fresh token is pre-fetched and the current WebSocket is
93
+ * closed cleanly so the reconnect loop picks up the new token immediately.
94
+ * This prevents the server from ever rejecting a message due to an expired JWT.
95
+ */
96
+ scheduleProactiveTokenRefresh(jwt) {
97
+ if (this.proactiveRefreshTimer) {
98
+ clearTimeout(this.proactiveRefreshTimer);
99
+ this.proactiveRefreshTimer = null;
100
+ }
101
+ try {
102
+ const payload = (0, bytes_1.parseJwtPayload)(jwt);
103
+ const exp = Number(payload.exp ?? 0);
104
+ if (!Number.isFinite(exp) || exp <= 0) {
105
+ return;
106
+ }
107
+ const nowSeconds = Math.floor(Date.now() / 1000);
108
+ const secondsUntilExpiry = exp - nowSeconds;
109
+ // Rotate 90 seconds before expiry so there is plenty of leeway even if
110
+ // the token exchange HTTP call takes a few seconds.
111
+ const rotateInSeconds = Math.max(0, secondsUntilExpiry - 90);
112
+ this.log('proactive_refresh_scheduled', { exp, secondsUntilExpiry, rotateInSeconds });
113
+ this.proactiveRefreshTimer = setTimeout(async () => {
114
+ this.proactiveRefreshTimer = null;
115
+ if (!this.shouldReconnect || !this.ws) {
116
+ return;
117
+ }
118
+ this.log('proactive_refresh_triggered', { exp });
119
+ // Pre-fetch a fresh token so the reconnect uses it without an extra round-trip.
120
+ try {
121
+ await this.getValidDropOnAirJwt(true);
122
+ }
123
+ catch (err) {
124
+ this.logError('proactive_refresh_token_failed', { error: String(err?.message ?? err) });
125
+ }
126
+ // Gracefully close the current connection; onclose will schedule reconnect.
127
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
128
+ this.ws.close(1000, 'PROACTIVE_TOKEN_REFRESH');
129
+ }
130
+ }, rotateInSeconds * 1000);
131
+ }
132
+ catch {
133
+ // If the JWT cannot be parsed, skip proactive rotation, expiry detection
134
+ // on the server side will still trigger a normal reconnect.
135
+ }
136
+ }
137
+ /** ------------------------------------------------------------------
138
+ * Lightweight structured logger. Only active when options.debug === true.
139
+ * Fields are safe to log: no plaintext, no private keys, no full JWTs.
140
+ * ------------------------------------------------------------------ */
141
+ log(stage, details = {}) {
142
+ if (!this.options.debug) {
143
+ return;
144
+ }
145
+ console.info('[DropOnAirSDK]', JSON.stringify({
146
+ stage,
147
+ ts: new Date().toISOString(),
148
+ ...details,
149
+ }));
150
+ }
151
+ logError(stage, details = {}) {
152
+ // Always log errors, regardless of debug flag.
153
+ console.error('[DropOnAirSDK]', JSON.stringify({
154
+ stage,
155
+ ts: new Date().toISOString(),
156
+ ...details,
157
+ }));
158
+ }
159
+ /**
160
+ * Build the common headers required on every backend API call.
161
+ * X-Platform and X-API-Version are forwarded when supplied in InitializeOptions.
162
+ */
163
+ backendHeaders(userJwt) {
164
+ return {
165
+ Authorization: `Bearer ${userJwt}`,
166
+ 'Content-Type': 'application/json',
167
+ ...(this.options.platformHeader ? { 'X-Platform': this.options.platformHeader } : {}),
168
+ ...(this.options.apiVersionHeader ? { 'X-API-Version': this.options.apiVersionHeader } : {}),
169
+ };
170
+ }
171
+ /** Redact a JWT to keep subject + expiry visible while hiding the signature. */
172
+ jwtSummary(jwt) {
173
+ if (!jwt) {
174
+ return { jwtPresent: false };
175
+ }
176
+ try {
177
+ const payload = (0, bytes_1.parseJwtPayload)(jwt);
178
+ return {
179
+ jwtPresent: true,
180
+ sub: payload.sub,
181
+ exp: payload.exp,
182
+ expired: (0, bytes_1.isJwtExpired)(jwt),
183
+ };
184
+ }
185
+ catch {
186
+ return { jwtPresent: true, jwtParseError: true };
187
+ }
188
+ }
189
+ constructor(options, cryptoService, sessionManager, storage) {
190
+ this.options = options;
191
+ this.cryptoService = cryptoService;
192
+ this.sessionManager = sessionManager;
193
+ this.storage = storage;
194
+ this.codec = new protobuf_codec_1.ProtobufCodec();
195
+ this.ws = null;
196
+ this.shouldReconnect = true;
197
+ this.reconnectTimer = null;
198
+ this.dropOnAirJwt = null;
199
+ this.currentUserId = null;
200
+ this.deviceId = null;
201
+ this.rateLimited = false;
202
+ this.reconnectAttempt = 0;
203
+ this.handlingJwtExpiry = false;
204
+ this.proactiveRefreshTimer = null;
205
+ this.visibilityChangeHandler = null;
206
+ this.deviceKeysCache = new Map();
207
+ // Call signaling
208
+ this.callListeners = new Set();
209
+ /** Pending startCall resolver, only one outgoing call can be in-flight at a time. */
210
+ this.pendingInviteResolve = null;
211
+ this.pendingInviteReject = null;
212
+ // Group messaging & calls
213
+ this.groupMessageListeners = new Set();
214
+ this.groupCallListeners = new Set();
215
+ /** Pending startGroupCall resolver. */
216
+ this.pendingGroupInviteResolve = null;
217
+ this.pendingGroupInviteReject = null;
218
+ this.messageListeners = new Set();
219
+ this.eventListeners = new Set();
220
+ this.broadcastListeners = new Set();
221
+ this.wsUrl = options.messagingWsUrl ?? 'wss://sdk.droponair.com/ws';
222
+ this.httpUrl = options.messagingHttpUrl ?? 'https://sdk.droponair.com';
223
+ this.tokenExchangeEndpoint = options.tokenExchangeEndpoint ?? '/api/messaging/token-exchange';
224
+ this.keyDirectoryEndpoint = options.keyDirectoryEndpoint ?? '/api/messaging/keys';
225
+ const providedFetch = options.fetchFn;
226
+ const globalFetch = typeof globalThis !== 'undefined' ? globalThis.fetch : undefined;
227
+ const resolvedFetch = providedFetch ?? globalFetch;
228
+ if (!resolvedFetch) {
229
+ throw new Error('Fetch API is not available in this environment');
230
+ }
231
+ this.fetchFn = ((input, init) => {
232
+ const fetchContext = typeof window !== 'undefined' ? window : globalThis;
233
+ return resolvedFetch.call(fetchContext, input, init);
234
+ });
235
+ this.autoAckIncomingMessages = options.autoAckIncomingMessages !== false;
236
+ }
237
+ async connect() {
238
+ this.log('connect_start');
239
+ this.shouldReconnect = true;
240
+ this.reconnectAttempt = 0;
241
+ this.registerVisibilityChangeHandler();
242
+ this.deviceId = await this.getOrCreateDeviceId();
243
+ await this.ensureIdentityPublished();
244
+ await this.connectWebSocket();
245
+ this.log('connect_done');
246
+ }
247
+ disconnect() {
248
+ this.shouldReconnect = false;
249
+ this.rateLimited = false;
250
+ this.reconnectAttempt = 0;
251
+ this.sessionManager.clear();
252
+ this.deviceKeysCache.clear();
253
+ this.unregisterVisibilityChangeHandler();
254
+ if (this.reconnectTimer) {
255
+ clearTimeout(this.reconnectTimer);
256
+ this.reconnectTimer = null;
257
+ }
258
+ if (this.proactiveRefreshTimer) {
259
+ clearTimeout(this.proactiveRefreshTimer);
260
+ this.proactiveRefreshTimer = null;
261
+ }
262
+ if (this.ws) {
263
+ this.ws.close();
264
+ this.ws = null;
265
+ }
266
+ this.emitEvent({ type: 'DISCONNECTED' });
267
+ }
268
+ async sendMessage(toUserId, plaintextMessage) {
269
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
270
+ throw new Error('DropOnAir websocket is not connected');
271
+ }
272
+ if (!this.currentUserId) {
273
+ throw new Error('Missing sender identity from DropOnAir JWT subject');
274
+ }
275
+ if (this.rateLimited) {
276
+ throw new Error('Sending is temporarily blocked by LIMIT_REACHED');
277
+ }
278
+ // Guard: if our cached JWT is already expired the server will reject this
279
+ // frame and close the connection (code 1008). Fail fast here instead so
280
+ // the UI immediately shows a retryable FAILED state rather than leaving the
281
+ // message stuck in PENDING while we wait for the server to kick us off.
282
+ if (this.dropOnAirJwt && (0, bytes_1.isJwtExpired)(this.dropOnAirJwt)) {
283
+ throw new Error('JWT_EXPIRED: Secure messaging session is reconnecting; please retry in a moment');
284
+ }
285
+ const myDeviceId = this.deviceId ?? await this.getOrCreateDeviceId();
286
+ const messageId = crypto.randomUUID();
287
+ const timestamp = Date.now();
288
+ const myIdentity = await this.cryptoService.getOrCreateIdentity();
289
+ const myPublicKeyBytes = (0, bytes_1.fromBase64)(myIdentity.publicKey);
290
+ // Fetch all device keys for the recipient
291
+ const peerDeviceKeys = await this.fetchDeviceKeys(toUserId);
292
+ // Fetch my own other device keys for self-sync
293
+ const myOtherDeviceKeys = await this.fetchMyOtherDeviceKeys(myDeviceId);
294
+ // Determine whether to use multi-device envelopes or legacy
295
+ const allTargetKeys = [
296
+ ...peerDeviceKeys.map(dk => ({ ...dk, isRecipient: true })),
297
+ ...myOtherDeviceKeys.map(dk => ({ ...dk, isRecipient: false })),
298
+ ];
299
+ if (allTargetKeys.length > 0) {
300
+ // Multi-device path: encrypt once per device key
301
+ const devicePayloads = [];
302
+ for (const target of allTargetKeys) {
303
+ const peerUserIdForHkdf = target.isRecipient ? toUserId : this.currentUserId;
304
+ const sharedKey = await this.cryptoService.deriveSharedSecret(peerUserIdForHkdf, target.publicKey, this.currentUserId);
305
+ const encrypted = await this.cryptoService.encrypt(plaintextMessage, sharedKey, {
306
+ messageId,
307
+ senderId: this.currentUserId,
308
+ recipientId: toUserId,
309
+ timestamp
310
+ });
311
+ devicePayloads.push({
312
+ deviceId: target.deviceId,
313
+ encryptedPayload: encrypted,
314
+ senderPublicKey: myPublicKeyBytes,
315
+ });
316
+ }
317
+ const envelope = {
318
+ messageId,
319
+ appId: this.options.appId,
320
+ fromUserId: this.currentUserId,
321
+ toUserId,
322
+ timestamp,
323
+ encryptedPayload: new Uint8Array(0),
324
+ clientMessageId: messageId,
325
+ devicePayloads,
326
+ senderDeviceId: myDeviceId,
327
+ };
328
+ this.ws.send(this.codec.encodeEnvelope(envelope));
329
+ }
330
+ else {
331
+ // Legacy fallback: peer has no device keys (old client)
332
+ const sharedKey = await this.getPeerSharedKey(toUserId);
333
+ const encryptedPayload = await this.cryptoService.encrypt(plaintextMessage, sharedKey, {
334
+ messageId,
335
+ senderId: this.currentUserId,
336
+ recipientId: toUserId,
337
+ timestamp
338
+ });
339
+ const envelope = {
340
+ messageId,
341
+ appId: this.options.appId,
342
+ fromUserId: this.currentUserId,
343
+ toUserId,
344
+ timestamp,
345
+ encryptedPayload,
346
+ clientMessageId: messageId
347
+ };
348
+ this.ws.send(this.codec.encodeEnvelope(envelope));
349
+ }
350
+ return { messageId };
351
+ }
352
+ async ack(messageId) {
353
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
354
+ return;
355
+ }
356
+ this.ws.send(this.codec.encodeAck({ messageId, type: 'PROCESSED' }));
357
+ }
358
+ onMessage(callback) {
359
+ this.messageListeners.add(callback);
360
+ return () => this.messageListeners.delete(callback);
361
+ }
362
+ onEvent(callback) {
363
+ this.eventListeners.add(callback);
364
+ return () => this.eventListeners.delete(callback);
365
+ }
366
+ // ---------------------------------------------------------------------------
367
+ // Cleartext messaging (no E2EE key exchange required)
368
+ // ---------------------------------------------------------------------------
369
+ async sendCleartextMessage(toUserId, plaintext) {
370
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
371
+ throw new Error('DropOnAir websocket is not connected');
372
+ }
373
+ if (!this.currentUserId) {
374
+ throw new Error('Missing sender identity from DropOnAir JWT subject');
375
+ }
376
+ if (this.rateLimited) {
377
+ throw new Error('Sending is temporarily blocked by LIMIT_REACHED');
378
+ }
379
+ if (this.dropOnAirJwt && (0, bytes_1.isJwtExpired)(this.dropOnAirJwt)) {
380
+ throw new Error('JWT_EXPIRED: Secure messaging session is reconnecting; please retry in a moment');
381
+ }
382
+ const messageId = crypto.randomUUID();
383
+ const envelope = {
384
+ messageId,
385
+ appId: this.options.appId,
386
+ fromUserId: this.currentUserId,
387
+ toUserId,
388
+ timestamp: Date.now(),
389
+ encryptedPayload: new Uint8Array(0),
390
+ clientMessageId: messageId,
391
+ encryptionType: 1, // CLEARTEXT
392
+ plaintextPayload: plaintext,
393
+ };
394
+ this.ws.send(this.codec.encodeEnvelope(envelope));
395
+ return { messageId };
396
+ }
397
+ // ---------------------------------------------------------------------------
398
+ // Broadcast / Pub-Sub
399
+ // ---------------------------------------------------------------------------
400
+ async subscribeBroadcast(channelId) {
401
+ const jwt = await this.getValidDropOnAirJwt(false);
402
+ const resp = await this.fetchFn(`${this.httpUrl}/v1/broadcast/channels/${encodeURIComponent(channelId)}/subscribe`, {
403
+ method: 'POST',
404
+ headers: { Authorization: `Bearer ${jwt}` },
405
+ });
406
+ if (!resp.ok) {
407
+ throw new Error(`Failed to subscribe to broadcast channel: ${resp.status}`);
408
+ }
409
+ }
410
+ async unsubscribeBroadcast(channelId) {
411
+ const jwt = await this.getValidDropOnAirJwt(false);
412
+ const resp = await this.fetchFn(`${this.httpUrl}/v1/broadcast/channels/${encodeURIComponent(channelId)}/unsubscribe`, {
413
+ method: 'POST',
414
+ headers: { Authorization: `Bearer ${jwt}` },
415
+ });
416
+ if (!resp.ok) {
417
+ throw new Error(`Failed to unsubscribe from broadcast channel: ${resp.status}`);
418
+ }
419
+ }
420
+ async publishBroadcast(channelId, plaintext) {
421
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
422
+ throw new Error('DropOnAir websocket is not connected');
423
+ }
424
+ if (!this.currentUserId) {
425
+ throw new Error('Missing sender identity from DropOnAir JWT subject');
426
+ }
427
+ if (this.rateLimited) {
428
+ throw new Error('Sending is temporarily blocked by LIMIT_REACHED');
429
+ }
430
+ const broadcastId = crypto.randomUUID();
431
+ const frame = {
432
+ broadcastId,
433
+ appId: this.options.appId,
434
+ channelId,
435
+ publisherId: this.currentUserId,
436
+ timestamp: Date.now(),
437
+ encryptionType: 1, // CLEARTEXT
438
+ encryptedPayload: new Uint8Array(0),
439
+ plaintextPayload: plaintext,
440
+ sequenceNumber: 0,
441
+ };
442
+ this.ws.send(this.codec.encodeBroadcastFrame(frame));
443
+ return { broadcastId };
444
+ }
445
+ onBroadcast(callback) {
446
+ this.broadcastListeners.add(callback);
447
+ return () => this.broadcastListeners.delete(callback);
448
+ }
449
+ onCallEvent(callback) {
450
+ this.callListeners.add(callback);
451
+ return () => this.callListeners.delete(callback);
452
+ }
453
+ // ---------------------------------------------------------------------------
454
+ // Group management (REST API)
455
+ // ---------------------------------------------------------------------------
456
+ async createGroup(name, memberUserIds) {
457
+ const jwt = await this.getValidDropOnAirJwt(false);
458
+ const res = await this.fetchFn(`${this.httpUrl}/api/groups`, {
459
+ method: 'POST',
460
+ headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
461
+ body: JSON.stringify({ name, memberUserIds: memberUserIds ?? [] }),
462
+ });
463
+ if (!res.ok)
464
+ throw new Error(`createGroup failed (HTTP ${res.status})`);
465
+ return res.json();
466
+ }
467
+ async listGroups() {
468
+ const jwt = await this.getValidDropOnAirJwt(false);
469
+ const res = await this.fetchFn(`${this.httpUrl}/api/groups`, {
470
+ method: 'GET',
471
+ headers: { Authorization: `Bearer ${jwt}` },
472
+ });
473
+ if (!res.ok)
474
+ throw new Error(`listGroups failed (HTTP ${res.status})`);
475
+ return res.json();
476
+ }
477
+ async getGroup(groupId) {
478
+ const jwt = await this.getValidDropOnAirJwt(false);
479
+ const res = await this.fetchFn(`${this.httpUrl}/api/groups/${encodeURIComponent(groupId)}`, {
480
+ method: 'GET',
481
+ headers: { Authorization: `Bearer ${jwt}` },
482
+ });
483
+ if (!res.ok)
484
+ throw new Error(`getGroup failed (HTTP ${res.status})`);
485
+ return res.json();
486
+ }
487
+ async addGroupMembers(groupId, userIds) {
488
+ const jwt = await this.getValidDropOnAirJwt(false);
489
+ const res = await this.fetchFn(`${this.httpUrl}/api/groups/${encodeURIComponent(groupId)}/members`, {
490
+ method: 'PUT',
491
+ headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
492
+ body: JSON.stringify({ userIds }),
493
+ });
494
+ if (!res.ok)
495
+ throw new Error(`addGroupMembers failed (HTTP ${res.status})`);
496
+ return res.json();
497
+ }
498
+ async removeGroupMember(groupId, userId) {
499
+ const jwt = await this.getValidDropOnAirJwt(false);
500
+ const res = await this.fetchFn(`${this.httpUrl}/api/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(userId)}`, {
501
+ method: 'DELETE',
502
+ headers: { Authorization: `Bearer ${jwt}` },
503
+ });
504
+ if (!res.ok)
505
+ throw new Error(`removeGroupMember failed (HTTP ${res.status})`);
506
+ return res.json();
507
+ }
508
+ async deleteGroup(groupId) {
509
+ const jwt = await this.getValidDropOnAirJwt(false);
510
+ const res = await this.fetchFn(`${this.httpUrl}/api/groups/${encodeURIComponent(groupId)}`, {
511
+ method: 'DELETE',
512
+ headers: { Authorization: `Bearer ${jwt}` },
513
+ });
514
+ if (!res.ok)
515
+ throw new Error(`deleteGroup failed (HTTP ${res.status})`);
516
+ }
517
+ // ---------------------------------------------------------------------------
518
+ // Group messaging
519
+ // ---------------------------------------------------------------------------
520
+ async sendGroupMessage(groupId, plaintext) {
521
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
522
+ throw new Error('DropOnAir websocket is not connected');
523
+ }
524
+ if (!this.currentUserId) {
525
+ throw new Error('Missing sender identity from DropOnAir JWT subject');
526
+ }
527
+ if (this.rateLimited) {
528
+ throw new Error('Sending is temporarily blocked by LIMIT_REACHED');
529
+ }
530
+ if (this.dropOnAirJwt && (0, bytes_1.isJwtExpired)(this.dropOnAirJwt)) {
531
+ throw new Error('JWT_EXPIRED: Secure messaging session is reconnecting; please retry in a moment');
532
+ }
533
+ const myDeviceId = this.deviceId ?? await this.getOrCreateDeviceId();
534
+ const messageId = crypto.randomUUID();
535
+ const timestamp = Date.now();
536
+ // For now, group messages use cleartext (E2EE group fanout requires
537
+ // fetching device keys for every member, which is a future enhancement).
538
+ const frame = {
539
+ messageId,
540
+ groupId,
541
+ fromUserId: this.currentUserId,
542
+ senderDeviceId: myDeviceId,
543
+ memberPayloads: [],
544
+ encryptionType: 1, // CLEARTEXT
545
+ plaintextPayload: plaintext,
546
+ timestamp,
547
+ };
548
+ this.ws.send(this.codec.encodeGroupEnvelope(frame));
549
+ return { messageId };
550
+ }
551
+ onGroupMessage(callback) {
552
+ this.groupMessageListeners.add(callback);
553
+ return () => this.groupMessageListeners.delete(callback);
554
+ }
555
+ // ---------------------------------------------------------------------------
556
+ // Group call signaling
557
+ // ---------------------------------------------------------------------------
558
+ startGroupCall(groupId) {
559
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
560
+ return Promise.reject(new Error('DropOnAir websocket is not connected'));
561
+ }
562
+ return new Promise((resolve, reject) => {
563
+ this.pendingGroupInviteResolve = resolve;
564
+ this.pendingGroupInviteReject = reject;
565
+ const frame = { type: 'GROUP_CALL_INVITE', callId: '', groupId };
566
+ this.ws.send(this.codec.encodeGroupCallFrame(frame));
567
+ });
568
+ }
569
+ async joinGroupCall(callId, groupId) {
570
+ this.sendGroupCallFrame({ type: 'GROUP_CALL_JOIN', callId, groupId });
571
+ }
572
+ async leaveGroupCall(callId) {
573
+ this.sendGroupCallFrame({ type: 'GROUP_CALL_LEAVE', callId, groupId: '' });
574
+ }
575
+ async endGroupCall(callId) {
576
+ this.sendGroupCallFrame({ type: 'GROUP_CALL_END', callId, groupId: '' });
577
+ }
578
+ sendGroupCallSignal(type, callId, groupId, targetUserId, payload) {
579
+ this.sendGroupCallFrame({ type, callId, groupId, targetUserId, payload });
580
+ }
581
+ onGroupCallEvent(callback) {
582
+ this.groupCallListeners.add(callback);
583
+ return () => this.groupCallListeners.delete(callback);
584
+ }
585
+ sendGroupCallFrame(frame) {
586
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
587
+ throw new Error('DropOnAir websocket is not connected');
588
+ }
589
+ this.ws.send(this.codec.encodeGroupCallFrame(frame));
590
+ }
591
+ /**
592
+ * Initiate an outgoing call.
593
+ * Sends CALL_INVITE to the server; resolves with the server-assigned callId
594
+ * once the server echoes back CALL_RINGING (which contains the real callId).
595
+ */
596
+ startCall(targetUserId) {
597
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
598
+ return Promise.reject(new Error('DropOnAir websocket is not connected'));
599
+ }
600
+ return new Promise((resolve, reject) => {
601
+ this.pendingInviteResolve = resolve;
602
+ this.pendingInviteReject = reject;
603
+ const frame = { type: 'CALL_INVITE', targetUserId };
604
+ this.ws.send(this.codec.encodeCallFrame(frame));
605
+ });
606
+ }
607
+ async acceptCall(callId) {
608
+ this.sendCallFrame({ type: 'CALL_ACCEPTED', callId });
609
+ }
610
+ async rejectCall(callId) {
611
+ this.sendCallFrame({ type: 'CALL_REJECTED', callId });
612
+ }
613
+ async endCall(callId) {
614
+ this.sendCallFrame({ type: 'CALL_ENDED', callId });
615
+ }
616
+ toggleVideo(callId, enabled) {
617
+ this.sendCallFrame({ type: 'CALL_VIDEO_TOGGLE', callId, payload: JSON.stringify({ enabled }) });
618
+ }
619
+ sendCallSignal(type, callId, payload) {
620
+ this.sendCallFrame({ type, callId, payload });
621
+ }
622
+ async fetchTurnCredentials() {
623
+ const jwt = await this.getValidDropOnAirJwt(false);
624
+ const url = `${this.httpUrl}/api/v1/turn/credentials`;
625
+ const response = await this.fetchFn(url, {
626
+ method: 'POST',
627
+ headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' }
628
+ });
629
+ if (!response.ok) {
630
+ throw new Error(`Failed to fetch TURN credentials (HTTP ${response.status})`);
631
+ }
632
+ return response.json();
633
+ }
634
+ sendCallFrame(frame) {
635
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
636
+ throw new Error('DropOnAir websocket is not connected');
637
+ }
638
+ this.ws.send(this.codec.encodeCallFrame(frame));
639
+ }
640
+ emitCallEvent(wire) {
641
+ // Resolve a pending startCall() promise when CALL_RINGING arrives.
642
+ if (wire.type === 'CALL_RINGING' && wire.callId && this.pendingInviteResolve) {
643
+ const resolve = this.pendingInviteResolve;
644
+ this.pendingInviteResolve = null;
645
+ this.pendingInviteReject = null;
646
+ resolve(wire.callId);
647
+ }
648
+ // Reject the pending startCall() promise when the server denies the call
649
+ // (e.g. CALLEE_OFFLINE, CALL_LIMIT_REACHED). Without this the promise
650
+ // would hang indefinitely.
651
+ if (wire.type === 'CALL_DENIED_LIMIT_REACHED' && this.pendingInviteReject) {
652
+ const reject = this.pendingInviteReject;
653
+ this.pendingInviteResolve = null;
654
+ this.pendingInviteReject = null;
655
+ reject(new Error(`CALL_DENIED:${wire.payload ?? ''}`));
656
+ }
657
+ const event = {
658
+ type: wire.type,
659
+ callId: wire.callId,
660
+ targetUserId: wire.targetUserId,
661
+ payload: wire.payload
662
+ };
663
+ for (const listener of this.callListeners) {
664
+ listener(event);
665
+ }
666
+ }
667
+ emitEvent(event) {
668
+ for (const listener of this.eventListeners) {
669
+ listener(event);
670
+ }
671
+ }
672
+ emitMessage(message) {
673
+ for (const listener of this.messageListeners) {
674
+ listener(message);
675
+ }
676
+ }
677
+ emitBroadcast(wire) {
678
+ const msg = {
679
+ broadcastId: wire.broadcastId,
680
+ channelId: wire.channelId,
681
+ publisherId: wire.publisherId,
682
+ timestamp: wire.timestamp,
683
+ plaintext: wire.plaintextPayload ?? '',
684
+ sequenceNumber: wire.sequenceNumber,
685
+ };
686
+ for (const listener of this.broadcastListeners) {
687
+ listener(msg);
688
+ }
689
+ }
690
+ // ---------------------------------------------------------------------------
691
+ // Group message handling
692
+ // ---------------------------------------------------------------------------
693
+ async handleIncomingGroupMessage(notif) {
694
+ try {
695
+ this.log('incoming_group_message', {
696
+ messageId: notif.messageId,
697
+ groupId: notif.groupId,
698
+ fromUserId: notif.fromUserId,
699
+ encryptionType: notif.encryptionType,
700
+ hasDevicePayloads: !!(notif.devicePayloads && notif.devicePayloads.length > 0),
701
+ });
702
+ let plaintext;
703
+ if (notif.encryptionType === 1) {
704
+ // Cleartext
705
+ plaintext = notif.plaintextPayload ?? '';
706
+ }
707
+ else if (notif.devicePayloads && notif.devicePayloads.length > 0) {
708
+ // E2EE, find our device's payload
709
+ const myDeviceId = this.deviceId ?? await this.getOrCreateDeviceId();
710
+ const myPayload = notif.devicePayloads.find(dp => dp.deviceId === myDeviceId);
711
+ if (!myPayload) {
712
+ this.log('incoming_group_message_no_device_payload', {
713
+ messageId: notif.messageId,
714
+ myDeviceId,
715
+ availableDeviceIds: notif.devicePayloads.map(dp => dp.deviceId),
716
+ });
717
+ return;
718
+ }
719
+ const senderPublicKeyBase64 = (0, bytes_1.toBase64)(myPayload.senderPublicKey);
720
+ const peerUserIdForHkdf = notif.fromUserId === this.currentUserId
721
+ ? this.currentUserId
722
+ : notif.fromUserId;
723
+ const sharedKey = await this.cryptoService.deriveSharedSecret(peerUserIdForHkdf, senderPublicKeyBase64, this.currentUserId);
724
+ plaintext = await this.cryptoService.decrypt(myPayload.encryptedPayload, sharedKey, {
725
+ messageId: notif.messageId,
726
+ senderId: notif.fromUserId,
727
+ recipientId: notif.groupId,
728
+ timestamp: notif.timestamp,
729
+ });
730
+ }
731
+ else {
732
+ this.log('incoming_group_message_no_payload', { messageId: notif.messageId });
733
+ return;
734
+ }
735
+ const msg = {
736
+ messageId: notif.messageId,
737
+ groupId: notif.groupId,
738
+ fromUserId: notif.fromUserId,
739
+ timestamp: notif.timestamp,
740
+ plaintext,
741
+ };
742
+ for (const listener of this.groupMessageListeners) {
743
+ listener(msg);
744
+ }
745
+ }
746
+ catch (err) {
747
+ this.logError('incoming_group_message_error', {
748
+ messageId: notif.messageId,
749
+ error: String(err?.message ?? err),
750
+ });
751
+ }
752
+ }
753
+ emitGroupCallEvent(wire) {
754
+ // Resolve a pending startGroupCall() when RINGING arrives
755
+ if (wire.type === 'GROUP_CALL_RINGING' && wire.callId && this.pendingGroupInviteResolve) {
756
+ const resolve = this.pendingGroupInviteResolve;
757
+ this.pendingGroupInviteResolve = null;
758
+ this.pendingGroupInviteReject = null;
759
+ resolve(wire.callId);
760
+ }
761
+ if (wire.type === 'GROUP_CALL_ALREADY_ACTIVE' && this.pendingGroupInviteReject) {
762
+ const reject = this.pendingGroupInviteReject;
763
+ this.pendingGroupInviteResolve = null;
764
+ this.pendingGroupInviteReject = null;
765
+ reject(new Error(`GROUP_CALL_ALREADY_ACTIVE:${wire.payload ?? ''}`));
766
+ }
767
+ const event = {
768
+ type: wire.type,
769
+ callId: wire.callId,
770
+ groupId: wire.groupId,
771
+ targetUserId: wire.targetUserId,
772
+ payload: wire.payload,
773
+ };
774
+ for (const listener of this.groupCallListeners) {
775
+ listener(event);
776
+ }
777
+ }
778
+ async connectWebSocket() {
779
+ // Only exchange a fresh token when the cached one is absent or expired/near-expiry.
780
+ // forceRefresh=true on every reconnect caused a token-exchange call every 2 s.
781
+ this.dropOnAirJwt = await this.getValidDropOnAirJwt(false);
782
+ this.currentUserId = this.extractSubject(this.dropOnAirJwt);
783
+ if ((0, bytes_1.isJwtExpired)(this.dropOnAirJwt)) {
784
+ this.logError('ws_connect_jwt_expired', this.jwtSummary(this.dropOnAirJwt));
785
+ throw new Error('DropOnAir JWT expired before websocket connect');
786
+ }
787
+ this.log('ws_connect_start', {
788
+ wsUrl: this.wsUrl,
789
+ currentUserId: this.currentUserId,
790
+ ...this.jwtSummary(this.dropOnAirJwt),
791
+ });
792
+ await new Promise((resolve, reject) => {
793
+ const myDeviceId = this.deviceId;
794
+ let wsUrlWithParams = `${this.wsUrl}?token=${encodeURIComponent(this.dropOnAirJwt)}`;
795
+ if (myDeviceId) {
796
+ wsUrlWithParams += `&deviceId=${encodeURIComponent(myDeviceId)}`;
797
+ }
798
+ wsUrlWithParams += `&sdkVersion=${encodeURIComponent(version_1.SDK_VERSION)}`;
799
+ wsUrlWithParams += `&protocolVersion=${version_1.PROTOCOL_VERSION}`;
800
+ const ws = new WebSocket(wsUrlWithParams);
801
+ ws.binaryType = 'arraybuffer';
802
+ ws.onopen = () => {
803
+ this.ws = ws;
804
+ this.rateLimited = false;
805
+ this.log('ws_connected', { wsUrl: this.wsUrl });
806
+ this.emitEvent({ type: 'CONNECTED' });
807
+ // Schedule proactive token rotation before this JWT expires so we never
808
+ // send a message on a token the server will immediately reject.
809
+ if (this.dropOnAirJwt) {
810
+ this.scheduleProactiveTokenRefresh(this.dropOnAirJwt);
811
+ }
812
+ this.fetchAndProcessOfflineMessages().catch((err) => {
813
+ this.logError('offline_fetch_failed', { error: String(err?.message ?? err) });
814
+ this.emitEvent({ type: 'ERROR', reason: 'OFFLINE_FETCH_FAILED' });
815
+ });
816
+ resolve();
817
+ };
818
+ ws.onerror = (event) => {
819
+ this.logError('ws_error', { wsUrl: this.wsUrl, eventType: event?.type });
820
+ reject(new Error('Failed to connect DropOnAir websocket'));
821
+ };
822
+ ws.onclose = (event) => {
823
+ this.ws = null;
824
+ this.handlingJwtExpiry = false;
825
+ // Cancel any pending proactive refresh, we are already disconnecting.
826
+ if (this.proactiveRefreshTimer) {
827
+ clearTimeout(this.proactiveRefreshTimer);
828
+ this.proactiveRefreshTimer = null;
829
+ }
830
+ // When the server closes with 1008 it means the JWT was expired at the
831
+ // time our frame arrived. Emit an ERROR event first so any message that
832
+ // was sent just before the disconnect (and is therefore stuck in PENDING)
833
+ // gets marked FAILED and shown with a retry button in the UI.
834
+ if (event.code === 1008) {
835
+ this.emitEvent({ type: 'ERROR', reason: 'JWT_EXPIRED' });
836
+ }
837
+ this.emitEvent({ type: 'DISCONNECTED' });
838
+ if (this.shouldReconnect) {
839
+ const delay = this.reconnectDelayMs();
840
+ this.log('ws_reconnect_scheduled', { attempt: this.reconnectAttempt, delayMs: delay });
841
+ this.emitEvent({ type: 'RECONNECTING' });
842
+ this.reconnectTimer = setTimeout(() => {
843
+ this.connectWebSocket().then(() => {
844
+ // Successful reconnect, reset backoff counter.
845
+ this.reconnectAttempt = 0;
846
+ }).catch(() => {
847
+ this.emitEvent({ type: 'ERROR', reason: 'RECONNECT_FAILED' });
848
+ });
849
+ }, delay);
850
+ }
851
+ };
852
+ ws.onmessage = async (event) => {
853
+ if (!(event.data instanceof ArrayBuffer)) {
854
+ return;
855
+ }
856
+ const frame = this.codec.decodeFrame(new Uint8Array(event.data));
857
+ if (frame.kind === 'event') {
858
+ if (frame.data.type === 'LIMIT_REACHED') {
859
+ this.rateLimited = true;
860
+ }
861
+ if (frame.data.type === 'ERROR' && frame.data.reason === 'JWT_EXPIRED') {
862
+ this.dropOnAirJwt = null;
863
+ this.currentUserId = null;
864
+ this.rateLimited = false;
865
+ if (!this.handlingJwtExpiry) {
866
+ this.handlingJwtExpiry = true;
867
+ this.log('ws_jwt_expired_received', {
868
+ metadata: frame.data.metadata,
869
+ wsState: this.ws?.readyState,
870
+ });
871
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
872
+ this.ws.close(4001, 'JWT_EXPIRED');
873
+ }
874
+ }
875
+ }
876
+ this.emitEvent({
877
+ type: frame.data.type,
878
+ reason: frame.data.reason,
879
+ metadata: frame.data.metadata
880
+ });
881
+ return;
882
+ }
883
+ if (frame.kind === 'ack') {
884
+ this.emitEvent({
885
+ type: frame.data.type,
886
+ metadata: frame.data.messageId
887
+ });
888
+ return;
889
+ }
890
+ if (frame.kind === 'call') {
891
+ this.emitCallEvent(frame.data);
892
+ return;
893
+ }
894
+ if (frame.kind === 'groupCall') {
895
+ this.emitGroupCallEvent(frame.data);
896
+ return;
897
+ }
898
+ if (frame.kind === 'groupMessage') {
899
+ await this.handleIncomingGroupMessage(frame.data);
900
+ return;
901
+ }
902
+ if (frame.kind === 'groupAck') {
903
+ this.emitEvent({
904
+ type: frame.data.type,
905
+ metadata: JSON.stringify({ messageId: frame.data.messageId, groupId: frame.data.groupId }),
906
+ });
907
+ return;
908
+ }
909
+ if (frame.kind === 'broadcast') {
910
+ this.emitBroadcast(frame.data);
911
+ return;
912
+ }
913
+ await this.handleIncomingEnvelope(frame.data);
914
+ };
915
+ });
916
+ }
917
+ async handleIncomingEnvelope(envelope) {
918
+ try {
919
+ this.log('incoming_envelope_received', {
920
+ messageId: envelope.messageId,
921
+ fromUserId: envelope.fromUserId,
922
+ toUserId: envelope.toUserId,
923
+ timestamp: envelope.timestamp,
924
+ hasDevicePayloads: !!(envelope.devicePayloads && envelope.devicePayloads.length > 0),
925
+ senderDeviceId: envelope.senderDeviceId,
926
+ autoAckIncomingMessages: this.autoAckIncomingMessages,
927
+ });
928
+ let plaintext;
929
+ // Cleartext path, no crypto needed
930
+ if (envelope.encryptionType === 1) {
931
+ plaintext = envelope.plaintextPayload ?? '';
932
+ }
933
+ else if (envelope.devicePayloads && envelope.devicePayloads.length > 0) {
934
+ const myDeviceId = this.deviceId ?? await this.getOrCreateDeviceId();
935
+ const myPayload = envelope.devicePayloads.find(dp => dp.deviceId === myDeviceId);
936
+ if (!myPayload) {
937
+ this.log('incoming_envelope_no_device_payload', {
938
+ messageId: envelope.messageId,
939
+ myDeviceId,
940
+ availableDeviceIds: envelope.devicePayloads.map(dp => dp.deviceId),
941
+ });
942
+ return; // Not intended for this device
943
+ }
944
+ const senderPublicKeyBase64 = (0, bytes_1.toBase64)(myPayload.senderPublicKey);
945
+ const isSelfSync = envelope.fromUserId === this.currentUserId;
946
+ // For HKDF context: when it's a self-sync message, peerUserId = myUserId so
947
+ // ordering becomes "myId:myId" on both send and receive sides.
948
+ const peerUserIdForHkdf = isSelfSync ? this.currentUserId : envelope.fromUserId;
949
+ const sharedKey = await this.cryptoService.deriveSharedSecret(peerUserIdForHkdf, senderPublicKeyBase64, this.currentUserId);
950
+ plaintext = await this.cryptoService.decrypt(myPayload.encryptedPayload, sharedKey, {
951
+ messageId: envelope.messageId,
952
+ senderId: envelope.fromUserId,
953
+ recipientId: envelope.toUserId,
954
+ timestamp: envelope.timestamp
955
+ });
956
+ }
957
+ else {
958
+ // Legacy single-payload path
959
+ const sharedKey = await this.getPeerSharedKey(envelope.fromUserId);
960
+ plaintext = await this.cryptoService.decrypt(envelope.encryptedPayload, sharedKey, {
961
+ messageId: envelope.messageId,
962
+ senderId: envelope.fromUserId,
963
+ recipientId: envelope.toUserId,
964
+ timestamp: envelope.timestamp
965
+ });
966
+ }
967
+ this.emitMessage({
968
+ messageId: envelope.messageId,
969
+ fromUserId: envelope.fromUserId,
970
+ toUserId: envelope.toUserId,
971
+ timestamp: envelope.timestamp,
972
+ plaintext
973
+ });
974
+ this.log('incoming_envelope_emitted', {
975
+ messageId: envelope.messageId,
976
+ });
977
+ if (this.autoAckIncomingMessages) {
978
+ await this.ack(envelope.messageId);
979
+ this.log('incoming_envelope_auto_acked', {
980
+ messageId: envelope.messageId,
981
+ });
982
+ }
983
+ }
984
+ catch (error) {
985
+ this.logError('incoming_envelope_failed', {
986
+ messageId: envelope.messageId,
987
+ fromUserId: envelope.fromUserId,
988
+ toUserId: envelope.toUserId,
989
+ error: String(error?.message ?? error),
990
+ });
991
+ this.emitEvent({ type: 'ERROR', reason: 'DECRYPT_FAILED', metadata: envelope.messageId });
992
+ }
993
+ }
994
+ async getPeerSharedKey(peerUserId) {
995
+ if (!this.currentUserId) {
996
+ throw new Error('Current user identity is missing');
997
+ }
998
+ const userJwt = await this.options.getUserJwt();
999
+ const url = `${this.keyDirectoryEndpoint}/${encodeURIComponent(peerUserId)}`;
1000
+ this.log('key_fetch_start', { url, peerUserId });
1001
+ const response = await this.fetchFn(url, {
1002
+ method: 'GET',
1003
+ headers: this.backendHeaders(userJwt),
1004
+ });
1005
+ if (!response.ok) {
1006
+ let errorBody = '';
1007
+ try {
1008
+ errorBody = await response.text();
1009
+ }
1010
+ catch { /* ignore */ }
1011
+ this.logError('key_fetch_failed', {
1012
+ url,
1013
+ peerUserId,
1014
+ status: response.status,
1015
+ statusText: response.statusText,
1016
+ responseBody: errorBody.slice(0, 400),
1017
+ });
1018
+ throw new Error(`Unable to fetch recipient public key from backend (HTTP ${response.status})`);
1019
+ }
1020
+ this.log('key_fetch_ok', { url, peerUserId, status: response.status });
1021
+ const body = (await response.json());
1022
+ const publicKey = body.publicKey;
1023
+ if (!publicKey) {
1024
+ throw new Error(`Recipient ${peerUserId} has no legacy public key available`);
1025
+ }
1026
+ return this.cryptoService.deriveSharedSecret(peerUserId, publicKey, this.currentUserId);
1027
+ }
1028
+ // ---------------------------------------------------------------------------
1029
+ // Multi-device helpers
1030
+ // ---------------------------------------------------------------------------
1031
+ /** Get or create a persistent device UUID for this SDK instance. */
1032
+ async getOrCreateDeviceId() {
1033
+ const existing = await this.storage.get(STORAGE_DEVICE_ID);
1034
+ if (existing) {
1035
+ this.deviceId = existing;
1036
+ return existing;
1037
+ }
1038
+ const id = crypto.randomUUID();
1039
+ await this.storage.set(STORAGE_DEVICE_ID, id);
1040
+ this.deviceId = id;
1041
+ this.log('device_id_created', { deviceId: id });
1042
+ return id;
1043
+ }
1044
+ /**
1045
+ * Fetch all device keys for a given user (cached with short TTL).
1046
+ * Falls back to wrapping the legacy single key into a device key entry.
1047
+ */
1048
+ async fetchDeviceKeys(userId) {
1049
+ // Check cache
1050
+ const cached = this.deviceKeysCache.get(userId);
1051
+ if (cached && (Date.now() - cached.fetchedAt) < MessagingClient.DEVICE_KEYS_CACHE_TTL_MS) {
1052
+ this.log('device_keys_cached', { userId, keyCount: cached.keys.length });
1053
+ return cached.keys;
1054
+ }
1055
+ const userJwt = await this.options.getUserJwt();
1056
+ const url = `${this.keyDirectoryEndpoint}/${encodeURIComponent(userId)}`;
1057
+ this.log('device_keys_fetch_start', { url, userId });
1058
+ const response = await this.fetchFn(url, {
1059
+ method: 'GET',
1060
+ headers: this.backendHeaders(userJwt),
1061
+ });
1062
+ if (!response.ok) {
1063
+ let errorBody = '';
1064
+ try {
1065
+ errorBody = await response.text();
1066
+ }
1067
+ catch { /* ignore */ }
1068
+ this.logError('device_keys_fetch_failed', {
1069
+ url, userId, status: response.status, responseBody: errorBody.slice(0, 400),
1070
+ });
1071
+ throw new Error(`Unable to fetch device keys for ${userId} (HTTP ${response.status})`);
1072
+ }
1073
+ const body = (await response.json());
1074
+ let keys = [];
1075
+ // Prefer multi-device keys if available
1076
+ if (body.deviceKeys && body.deviceKeys.length > 0) {
1077
+ keys = body.deviceKeys;
1078
+ }
1079
+ else if (body.publicKey) {
1080
+ // Legacy fallback: wrap single key as a synthetic device key
1081
+ // No real deviceId available for legacy, empty array means the caller
1082
+ // falls back to the legacy path in sendMessage
1083
+ keys = [];
1084
+ }
1085
+ this.deviceKeysCache.set(userId, { keys, fetchedAt: Date.now() });
1086
+ this.log('device_keys_fetch_ok', { userId, keyCount: keys.length });
1087
+ return keys;
1088
+ }
1089
+ /**
1090
+ * Fetch my own device keys and filter out the current device.
1091
+ * Used for self-sync: encrypt sent messages for my other devices.
1092
+ */
1093
+ async fetchMyOtherDeviceKeys(myDeviceId) {
1094
+ if (!this.currentUserId) {
1095
+ return [];
1096
+ }
1097
+ try {
1098
+ const allMyKeys = await this.fetchDeviceKeys(this.currentUserId);
1099
+ return allMyKeys.filter(dk => dk.deviceId !== myDeviceId);
1100
+ }
1101
+ catch {
1102
+ // If fetching my own keys fails, skip self-sync gracefully
1103
+ this.log('self_sync_keys_fetch_failed', { myDeviceId });
1104
+ return [];
1105
+ }
1106
+ }
1107
+ async ensureIdentityPublished() {
1108
+ const identity = await this.cryptoService.getOrCreateIdentity();
1109
+ const myDeviceId = this.deviceId ?? await this.getOrCreateDeviceId();
1110
+ const userJwt = await this.options.getUserJwt();
1111
+ const url = `${this.keyDirectoryEndpoint}/me`;
1112
+ this.log('key_publish_start', { url, publicKeyLength: identity.publicKey?.length, deviceId: myDeviceId, ...this.jwtSummary(userJwt) });
1113
+ const response = await this.fetchFn(url, {
1114
+ method: 'PUT',
1115
+ headers: this.backendHeaders(userJwt),
1116
+ body: JSON.stringify({ publicKey: identity.publicKey, deviceId: myDeviceId })
1117
+ });
1118
+ if (!response.ok) {
1119
+ let errorBody = '';
1120
+ try {
1121
+ errorBody = await response.text();
1122
+ }
1123
+ catch { /* ignore */ }
1124
+ this.logError('key_publish_failed', {
1125
+ url,
1126
+ status: response.status,
1127
+ statusText: response.statusText,
1128
+ responseBody: errorBody.slice(0, 400),
1129
+ });
1130
+ throw new Error(`Failed to publish identity key to backend (HTTP ${response.status})`);
1131
+ }
1132
+ this.log('key_publish_ok', { url, status: response.status, deviceId: myDeviceId });
1133
+ }
1134
+ async fetchAndProcessOfflineMessages() {
1135
+ const jwt = await this.getValidDropOnAirJwt(false);
1136
+ let page = 0;
1137
+ let totalPages = 1;
1138
+ let totalFetched = 0;
1139
+ let totalProcessed = 0;
1140
+ this.log('offline_fetch_started', {
1141
+ httpUrl: this.httpUrl,
1142
+ pageSize: 100,
1143
+ });
1144
+ while (page < totalPages) {
1145
+ const response = await this.fetchFn(`${this.httpUrl}/v1/messages/offline?page=${page}&size=100`, {
1146
+ method: 'GET',
1147
+ headers: {
1148
+ Authorization: `Bearer ${jwt}`,
1149
+ 'Content-Type': 'application/json'
1150
+ }
1151
+ });
1152
+ if (!response.ok) {
1153
+ this.logError('offline_fetch_page_failed', {
1154
+ page,
1155
+ status: response.status,
1156
+ statusText: response.statusText,
1157
+ });
1158
+ return;
1159
+ }
1160
+ const body = (await response.json());
1161
+ totalPages = Math.max(body.totalPages || 1, 1);
1162
+ totalFetched += body.messages.length;
1163
+ this.log('offline_fetch_page_ok', {
1164
+ page,
1165
+ pageSize: body.size,
1166
+ pageMessages: body.messages.length,
1167
+ totalElements: body.totalElements,
1168
+ totalPages,
1169
+ });
1170
+ for (const offline of body.messages) {
1171
+ const wireEnvelope = {
1172
+ messageId: offline.messageId,
1173
+ appId: offline.appId,
1174
+ fromUserId: offline.fromUserId,
1175
+ toUserId: offline.toUserId,
1176
+ timestamp: new Date(offline.createdAt).getTime(),
1177
+ encryptedPayload: offline.encryptedPayloadBase64 ? (0, bytes_1.fromBase64)(offline.encryptedPayloadBase64) : new Uint8Array(0),
1178
+ encryptionType: offline.encryptionType === 'CLEARTEXT' ? 1 : 0,
1179
+ plaintextPayload: offline.plaintextPayload,
1180
+ };
1181
+ // Multi-device offline payloads
1182
+ if (offline.devicePayloads && offline.devicePayloads.length > 0) {
1183
+ wireEnvelope.devicePayloads = offline.devicePayloads.map(dp => ({
1184
+ deviceId: dp.deviceId,
1185
+ encryptedPayload: (0, bytes_1.fromBase64)(dp.encryptedPayloadBase64),
1186
+ senderPublicKey: (0, bytes_1.fromBase64)(dp.senderPublicKeyBase64),
1187
+ }));
1188
+ wireEnvelope.senderDeviceId = offline.senderDeviceId;
1189
+ }
1190
+ await this.handleIncomingEnvelope(wireEnvelope);
1191
+ totalProcessed += 1;
1192
+ }
1193
+ page += 1;
1194
+ }
1195
+ this.log('offline_fetch_completed', {
1196
+ fetchedMessages: totalFetched,
1197
+ processedMessages: totalProcessed,
1198
+ pagesFetched: page,
1199
+ });
1200
+ }
1201
+ async getValidDropOnAirJwt(forceRefresh) {
1202
+ if (!forceRefresh
1203
+ && this.dropOnAirJwt
1204
+ && !(0, bytes_1.isJwtExpired)(this.dropOnAirJwt)
1205
+ && !this.isJwtExpiringSoon(this.dropOnAirJwt, 30)) {
1206
+ this.log('token_exchange_skipped', { reason: 'cached_jwt_still_valid', ...this.jwtSummary(this.dropOnAirJwt) });
1207
+ return this.dropOnAirJwt;
1208
+ }
1209
+ const userJwt = await this.options.getUserJwt();
1210
+ const url = this.tokenExchangeEndpoint;
1211
+ this.log('token_exchange_start', {
1212
+ url,
1213
+ appId: this.options.appId,
1214
+ publicApiKeyPrefix: this.options.publicApiKey?.slice(0, 10),
1215
+ forceRefresh,
1216
+ ...this.jwtSummary(userJwt),
1217
+ });
1218
+ let response;
1219
+ try {
1220
+ response = await this.fetchFn(url, {
1221
+ method: 'POST',
1222
+ headers: {
1223
+ ...this.backendHeaders(userJwt),
1224
+ 'X-SDK-Version': version_1.SDK_VERSION,
1225
+ },
1226
+ body: JSON.stringify({
1227
+ appId: this.options.appId,
1228
+ publicApiKey: this.options.publicApiKey
1229
+ })
1230
+ });
1231
+ }
1232
+ catch (err) {
1233
+ this.logError('token_exchange_fetch_threw', {
1234
+ url,
1235
+ error: String(err?.message ?? err),
1236
+ });
1237
+ throw new Error(`Token exchange network error: ${err?.message ?? String(err)}`);
1238
+ }
1239
+ if (!response.ok) {
1240
+ let errorBody = '';
1241
+ try {
1242
+ errorBody = await response.text();
1243
+ }
1244
+ catch { /* ignore */ }
1245
+ this.logError('token_exchange_failed', {
1246
+ url,
1247
+ status: response.status,
1248
+ statusText: response.statusText,
1249
+ responseBody: errorBody.slice(0, 800),
1250
+ });
1251
+ throw new Error(`Token exchange failed (HTTP ${response.status}): ${errorBody.slice(0, 200)}`);
1252
+ }
1253
+ const payload = (await response.json());
1254
+ const token = payload.accessToken ?? payload.token;
1255
+ if (!token) {
1256
+ this.logError('token_exchange_missing_token', { url, payloadKeys: Object.keys(payload) });
1257
+ throw new Error('Token exchange response missing accessToken');
1258
+ }
1259
+ if ((0, bytes_1.isJwtExpired)(token)) {
1260
+ this.logError('token_exchange_received_expired_jwt', { url, ...this.jwtSummary(token) });
1261
+ throw new Error('Received expired DropOnAir JWT');
1262
+ }
1263
+ this.dropOnAirJwt = token;
1264
+ this.currentUserId = this.extractSubject(token);
1265
+ this.log('token_exchange_ok', { url, currentUserId: this.currentUserId, expiresIn: payload.expiresIn });
1266
+ return token;
1267
+ }
1268
+ extractSubject(token) {
1269
+ const payload = (0, bytes_1.parseJwtPayload)(token);
1270
+ const sub = payload.sub;
1271
+ if (typeof sub !== 'string' || !sub) {
1272
+ throw new Error('DropOnAir JWT does not contain subject (sub)');
1273
+ }
1274
+ return sub;
1275
+ }
1276
+ isJwtExpiringSoon(jwt, thresholdSeconds) {
1277
+ try {
1278
+ const payload = (0, bytes_1.parseJwtPayload)(jwt);
1279
+ const exp = Number(payload.exp ?? 0);
1280
+ if (!Number.isFinite(exp) || exp <= 0) {
1281
+ return true;
1282
+ }
1283
+ const nowSeconds = Math.floor(Date.now() / 1000);
1284
+ return nowSeconds + thresholdSeconds >= exp;
1285
+ }
1286
+ catch {
1287
+ return true;
1288
+ }
1289
+ }
1290
+ }
1291
+ exports.MessagingClient = MessagingClient;
1292
+ // Multi-device: cached device keys per user with TTL
1293
+ MessagingClient.DEVICE_KEYS_CACHE_TTL_MS = 60000;