@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.
- package/CHANGELOG.md +65 -0
- package/LICENSE +21 -0
- package/README.md +865 -0
- package/dist/core/bytes.d.ts +8 -0
- package/dist/core/bytes.js +75 -0
- package/dist/core/messaging-client.d.ts +159 -0
- package/dist/core/messaging-client.js +1293 -0
- package/dist/core/session-manager.d.ts +6 -0
- package/dist/core/session-manager.js +18 -0
- package/dist/core/types.d.ts +173 -0
- package/dist/core/types.js +2 -0
- package/dist/crypto/crypto-service.d.ts +28 -0
- package/dist/crypto/crypto-service.js +100 -0
- package/dist/crypto/payload-format.d.ts +8 -0
- package/dist/crypto/payload-format.js +63 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +29 -0
- package/dist/storage/indexeddb-key-storage.d.ts +9 -0
- package/dist/storage/indexeddb-key-storage.js +70 -0
- package/dist/storage/memory-key-storage.d.ts +7 -0
- package/dist/storage/memory-key-storage.js +18 -0
- package/dist/transport/protobuf-codec.d.ts +138 -0
- package/dist/transport/protobuf-codec.js +337 -0
- package/dist/version.d.ts +22 -0
- package/dist/version.js +25 -0
- package/package.json +49 -0
|
@@ -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;
|