@droponair/sdk-js 0.3.1 → 0.5.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 +42 -0
- package/README.md +72 -0
- package/dist/attachment/attachment-client.d.ts +74 -0
- package/dist/attachment/attachment-client.js +323 -0
- package/dist/attachment/attachment-types.d.ts +77 -0
- package/dist/attachment/attachment-types.js +11 -0
- package/dist/core/messaging-client.d.ts +25 -2
- package/dist/core/messaging-client.js +314 -3
- package/dist/core/types.d.ts +77 -1
- package/dist/index.d.ts +2 -1
- package/dist/transport/protobuf-codec.d.ts +61 -0
- package/dist/transport/protobuf-codec.js +139 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CryptoService } from '../crypto/crypto-service';
|
|
2
2
|
import { SessionManager } from './session-manager';
|
|
3
|
-
import { BroadcastCallback, CallEventCallback, DropOnAirClient, EventCallback, GroupCallEventCallback, GroupInfo, GroupMessageCallback, InitializeOptions, MessageCallback, TurnCredentials } from './types';
|
|
3
|
+
import { BroadcastCallback, CallEventCallback, DropOnAirClient, EventCallback, GroupCallEventCallback, GroupInfo, GroupMessageCallback, InitializeOptions, MessageCallback, MessageDeleteCallback, MessageEditCallback, TurnCredentials } from './types';
|
|
4
|
+
import { AttachmentRef, CreateUploadSessionOptions, DownloadedAttachment, PrepareAttachmentOptions, UploadSession } from '../attachment/attachment-types';
|
|
4
5
|
export declare class MessagingClient implements DropOnAirClient {
|
|
5
6
|
private readonly options;
|
|
6
7
|
private readonly cryptoService;
|
|
@@ -12,6 +13,7 @@ export declare class MessagingClient implements DropOnAirClient {
|
|
|
12
13
|
private readonly keyDirectoryEndpoint;
|
|
13
14
|
private readonly fetchFn;
|
|
14
15
|
private readonly codec;
|
|
16
|
+
private attachmentClient;
|
|
15
17
|
private ws;
|
|
16
18
|
private shouldReconnect;
|
|
17
19
|
private reconnectTimer;
|
|
@@ -68,6 +70,8 @@ export declare class MessagingClient implements DropOnAirClient {
|
|
|
68
70
|
private readonly messageListeners;
|
|
69
71
|
private readonly eventListeners;
|
|
70
72
|
private readonly broadcastListeners;
|
|
73
|
+
private readonly messageEditListeners;
|
|
74
|
+
private readonly messageDeleteListeners;
|
|
71
75
|
/** ------------------------------------------------------------------
|
|
72
76
|
* Lightweight structured logger. Only active when options.debug === true.
|
|
73
77
|
* Fields are safe to log: no plaintext, no private keys, no full JWTs.
|
|
@@ -82,14 +86,31 @@ export declare class MessagingClient implements DropOnAirClient {
|
|
|
82
86
|
/** Redact a JWT to keep subject + expiry visible while hiding the signature. */
|
|
83
87
|
private jwtSummary;
|
|
84
88
|
constructor(options: InitializeOptions, cryptoService: CryptoService, sessionManager: SessionManager, storage: import('./types').KeyStorageAdapter);
|
|
89
|
+
prepareAttachmentAndUpload(bytes: Uint8Array, options: PrepareAttachmentOptions): Promise<AttachmentRef>;
|
|
90
|
+
createUploadSession(options: CreateUploadSessionOptions): Promise<UploadSession>;
|
|
91
|
+
finalizeAttachment(attachmentId: string, sha256: string): Promise<void>;
|
|
92
|
+
downloadAttachment(ref: AttachmentRef): Promise<DownloadedAttachment>;
|
|
85
93
|
connect(): Promise<void>;
|
|
86
94
|
disconnect(): void;
|
|
87
|
-
sendMessage(toUserId: string, plaintextMessage: string
|
|
95
|
+
sendMessage(toUserId: string, plaintextMessage: string, options?: {
|
|
96
|
+
attachments?: AttachmentRef[];
|
|
97
|
+
}): Promise<{
|
|
88
98
|
messageId: string;
|
|
89
99
|
}>;
|
|
90
100
|
ack(messageId: string): Promise<void>;
|
|
91
101
|
onMessage(callback: MessageCallback): () => void;
|
|
92
102
|
onEvent(callback: EventCallback): () => void;
|
|
103
|
+
onMessageEdit(callback: MessageEditCallback): () => void;
|
|
104
|
+
onMessageDelete(callback: MessageDeleteCallback): () => void;
|
|
105
|
+
editMessage(originalMessageId: string, toUserId: string, newPlaintext: string): Promise<{
|
|
106
|
+
editId: string;
|
|
107
|
+
}>;
|
|
108
|
+
editCleartextMessage(originalMessageId: string, toUserId: string, newPlaintext: string): Promise<{
|
|
109
|
+
editId: string;
|
|
110
|
+
}>;
|
|
111
|
+
deleteMessage(originalMessageId: string, toUserId: string, scope: 'FOR_EVERYONE' | 'FOR_ME'): Promise<{
|
|
112
|
+
deleteId: string;
|
|
113
|
+
}>;
|
|
93
114
|
sendCleartextMessage(toUserId: string, plaintext: string): Promise<{
|
|
94
115
|
messageId: string;
|
|
95
116
|
}>;
|
|
@@ -137,6 +158,8 @@ export declare class MessagingClient implements DropOnAirClient {
|
|
|
137
158
|
private handleIncomingGroupMessage;
|
|
138
159
|
private emitGroupCallEvent;
|
|
139
160
|
private connectWebSocket;
|
|
161
|
+
private handleIncomingMessageEdit;
|
|
162
|
+
private handleIncomingMessageDelete;
|
|
140
163
|
private handleIncomingEnvelope;
|
|
141
164
|
private getPeerSharedKey;
|
|
142
165
|
/** Get or create a persistent device UUID for this SDK instance. */
|
|
@@ -4,6 +4,7 @@ exports.MessagingClient = void 0;
|
|
|
4
4
|
const bytes_1 = require("./bytes");
|
|
5
5
|
const protobuf_codec_1 = require("../transport/protobuf-codec");
|
|
6
6
|
const version_1 = require("../version");
|
|
7
|
+
const attachment_client_1 = require("../attachment/attachment-client");
|
|
7
8
|
const STORAGE_DEVICE_ID = 'droponair.device.id.v1';
|
|
8
9
|
class MessagingClient {
|
|
9
10
|
reconnectDelayMs() {
|
|
@@ -218,6 +219,8 @@ class MessagingClient {
|
|
|
218
219
|
this.messageListeners = new Set();
|
|
219
220
|
this.eventListeners = new Set();
|
|
220
221
|
this.broadcastListeners = new Set();
|
|
222
|
+
this.messageEditListeners = new Set();
|
|
223
|
+
this.messageDeleteListeners = new Set();
|
|
221
224
|
this.wsUrl = options.messagingWsUrl ?? 'wss://sdk.droponair.com/ws';
|
|
222
225
|
this.httpUrl = options.messagingHttpUrl ?? 'https://sdk.droponair.com';
|
|
223
226
|
this.tokenExchangeEndpoint = options.tokenExchangeEndpoint ?? '/api/messaging/token-exchange';
|
|
@@ -233,6 +236,36 @@ class MessagingClient {
|
|
|
233
236
|
return resolvedFetch.call(fetchContext, input, init);
|
|
234
237
|
});
|
|
235
238
|
this.autoAckIncomingMessages = options.autoAckIncomingMessages !== false;
|
|
239
|
+
this.attachmentClient = new attachment_client_1.AttachmentClient({
|
|
240
|
+
httpUrl: this.httpUrl,
|
|
241
|
+
fetchFn: this.fetchFn,
|
|
242
|
+
getValidDropOnAirJwt: () => this.getValidDropOnAirJwt(false),
|
|
243
|
+
fetchDeviceKeys: (userId) => this.fetchDeviceKeys(userId),
|
|
244
|
+
fetchMyOtherDeviceKeys: (myDeviceId) => this.fetchMyOtherDeviceKeys(myDeviceId),
|
|
245
|
+
getCurrentUserId: () => {
|
|
246
|
+
if (!this.currentUserId)
|
|
247
|
+
throw new Error('Not connected; userId unknown');
|
|
248
|
+
return this.currentUserId;
|
|
249
|
+
},
|
|
250
|
+
getCurrentDeviceId: () => this.deviceId ? Promise.resolve(this.deviceId) : this.getOrCreateDeviceId(),
|
|
251
|
+
getKeyStorage: () => this.storage,
|
|
252
|
+
cryptoService: this.cryptoService,
|
|
253
|
+
log: (stage, data) => this.log(stage, data),
|
|
254
|
+
logError: (stage, data) => this.logError(stage, data),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
// Attachment API (phase1/attachments, PROTOCOL_VERSION 4+)
|
|
258
|
+
async prepareAttachmentAndUpload(bytes, options) {
|
|
259
|
+
return this.attachmentClient.prepareAttachmentAndUpload(bytes, options);
|
|
260
|
+
}
|
|
261
|
+
async createUploadSession(options) {
|
|
262
|
+
return this.attachmentClient.createUploadSession(options);
|
|
263
|
+
}
|
|
264
|
+
async finalizeAttachment(attachmentId, sha256) {
|
|
265
|
+
return this.attachmentClient.finalize(attachmentId, sha256);
|
|
266
|
+
}
|
|
267
|
+
async downloadAttachment(ref) {
|
|
268
|
+
return this.attachmentClient.downloadAttachment(ref);
|
|
236
269
|
}
|
|
237
270
|
async connect() {
|
|
238
271
|
this.log('connect_start');
|
|
@@ -265,7 +298,7 @@ class MessagingClient {
|
|
|
265
298
|
}
|
|
266
299
|
this.emitEvent({ type: 'DISCONNECTED' });
|
|
267
300
|
}
|
|
268
|
-
async sendMessage(toUserId, plaintextMessage) {
|
|
301
|
+
async sendMessage(toUserId, plaintextMessage, options) {
|
|
269
302
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
270
303
|
throw new Error('DropOnAir websocket is not connected');
|
|
271
304
|
}
|
|
@@ -325,6 +358,9 @@ class MessagingClient {
|
|
|
325
358
|
devicePayloads,
|
|
326
359
|
senderDeviceId: myDeviceId,
|
|
327
360
|
};
|
|
361
|
+
if (options?.attachments && options.attachments.length > 0) {
|
|
362
|
+
envelope.attachments = options.attachments.map(a => this.attachmentClient.toWire(a));
|
|
363
|
+
}
|
|
328
364
|
this.ws.send(this.codec.encodeEnvelope(envelope));
|
|
329
365
|
}
|
|
330
366
|
else {
|
|
@@ -345,6 +381,9 @@ class MessagingClient {
|
|
|
345
381
|
encryptedPayload,
|
|
346
382
|
clientMessageId: messageId
|
|
347
383
|
};
|
|
384
|
+
if (options?.attachments && options.attachments.length > 0) {
|
|
385
|
+
envelope.attachments = options.attachments.map(a => this.attachmentClient.toWire(a));
|
|
386
|
+
}
|
|
348
387
|
this.ws.send(this.codec.encodeEnvelope(envelope));
|
|
349
388
|
}
|
|
350
389
|
return { messageId };
|
|
@@ -363,6 +402,129 @@ class MessagingClient {
|
|
|
363
402
|
this.eventListeners.add(callback);
|
|
364
403
|
return () => this.eventListeners.delete(callback);
|
|
365
404
|
}
|
|
405
|
+
onMessageEdit(callback) {
|
|
406
|
+
this.messageEditListeners.add(callback);
|
|
407
|
+
return () => this.messageEditListeners.delete(callback);
|
|
408
|
+
}
|
|
409
|
+
onMessageDelete(callback) {
|
|
410
|
+
this.messageDeleteListeners.add(callback);
|
|
411
|
+
return () => this.messageDeleteListeners.delete(callback);
|
|
412
|
+
}
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// Message edit and delete (PROTOCOL_VERSION 3+)
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
async editMessage(originalMessageId, toUserId, newPlaintext) {
|
|
417
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
418
|
+
throw new Error('DropOnAir websocket is not connected');
|
|
419
|
+
}
|
|
420
|
+
if (!this.currentUserId) {
|
|
421
|
+
throw new Error('Missing sender identity from DropOnAir JWT subject');
|
|
422
|
+
}
|
|
423
|
+
if (this.rateLimited) {
|
|
424
|
+
throw new Error('Sending is temporarily blocked by LIMIT_REACHED');
|
|
425
|
+
}
|
|
426
|
+
if (this.dropOnAirJwt && (0, bytes_1.isJwtExpired)(this.dropOnAirJwt)) {
|
|
427
|
+
throw new Error('JWT_EXPIRED: Secure messaging session is reconnecting; please retry in a moment');
|
|
428
|
+
}
|
|
429
|
+
const myDeviceId = this.deviceId ?? await this.getOrCreateDeviceId();
|
|
430
|
+
const editId = crypto.randomUUID();
|
|
431
|
+
const timestamp = Date.now();
|
|
432
|
+
// Encrypt new content for all recipient devices + sender's other devices,
|
|
433
|
+
// exactly mirroring sendMessage. The relay never sees plaintext.
|
|
434
|
+
const myIdentity = await this.cryptoService.getOrCreateIdentity();
|
|
435
|
+
const myPublicKeyBytes = (0, bytes_1.fromBase64)(myIdentity.publicKey);
|
|
436
|
+
const peerDeviceKeys = await this.fetchDeviceKeys(toUserId);
|
|
437
|
+
const myOtherDeviceKeys = await this.fetchMyOtherDeviceKeys(myDeviceId);
|
|
438
|
+
const allTargetKeys = [
|
|
439
|
+
...peerDeviceKeys.map(dk => ({ ...dk, isRecipient: true })),
|
|
440
|
+
...myOtherDeviceKeys.map(dk => ({ ...dk, isRecipient: false })),
|
|
441
|
+
];
|
|
442
|
+
if (allTargetKeys.length === 0) {
|
|
443
|
+
throw new Error('No recipient device keys available for edit');
|
|
444
|
+
}
|
|
445
|
+
const devicePayloads = [];
|
|
446
|
+
for (const target of allTargetKeys) {
|
|
447
|
+
const peerUserIdForHkdf = target.isRecipient ? toUserId : this.currentUserId;
|
|
448
|
+
const sharedKey = await this.cryptoService.deriveSharedSecret(peerUserIdForHkdf, target.publicKey, this.currentUserId);
|
|
449
|
+
const encrypted = await this.cryptoService.encrypt(newPlaintext, sharedKey, {
|
|
450
|
+
messageId: editId,
|
|
451
|
+
senderId: this.currentUserId,
|
|
452
|
+
recipientId: toUserId,
|
|
453
|
+
timestamp,
|
|
454
|
+
});
|
|
455
|
+
devicePayloads.push({
|
|
456
|
+
deviceId: target.deviceId,
|
|
457
|
+
encryptedPayload: encrypted,
|
|
458
|
+
senderPublicKey: myPublicKeyBytes,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
const frame = {
|
|
462
|
+
type: 'MESSAGE_EDIT',
|
|
463
|
+
editId,
|
|
464
|
+
originalMessageId,
|
|
465
|
+
appId: this.options.appId,
|
|
466
|
+
fromUserId: this.currentUserId,
|
|
467
|
+
toUserId,
|
|
468
|
+
timestamp,
|
|
469
|
+
encryptionType: 0, // E2EE
|
|
470
|
+
devicePayloads,
|
|
471
|
+
senderDeviceId: myDeviceId,
|
|
472
|
+
clientEditId: editId,
|
|
473
|
+
};
|
|
474
|
+
this.ws.send(this.codec.encodeMessageEditFrame(frame));
|
|
475
|
+
return { editId };
|
|
476
|
+
}
|
|
477
|
+
async editCleartextMessage(originalMessageId, toUserId, newPlaintext) {
|
|
478
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
479
|
+
throw new Error('DropOnAir websocket is not connected');
|
|
480
|
+
}
|
|
481
|
+
if (!this.currentUserId) {
|
|
482
|
+
throw new Error('Missing sender identity from DropOnAir JWT subject');
|
|
483
|
+
}
|
|
484
|
+
if (this.dropOnAirJwt && (0, bytes_1.isJwtExpired)(this.dropOnAirJwt)) {
|
|
485
|
+
throw new Error('JWT_EXPIRED: Secure messaging session is reconnecting; please retry in a moment');
|
|
486
|
+
}
|
|
487
|
+
const editId = crypto.randomUUID();
|
|
488
|
+
const frame = {
|
|
489
|
+
type: 'MESSAGE_EDIT',
|
|
490
|
+
editId,
|
|
491
|
+
originalMessageId,
|
|
492
|
+
appId: this.options.appId,
|
|
493
|
+
fromUserId: this.currentUserId,
|
|
494
|
+
toUserId,
|
|
495
|
+
timestamp: Date.now(),
|
|
496
|
+
encryptionType: 1, // CLEARTEXT
|
|
497
|
+
plaintextPayload: newPlaintext,
|
|
498
|
+
clientEditId: editId,
|
|
499
|
+
};
|
|
500
|
+
this.ws.send(this.codec.encodeMessageEditFrame(frame));
|
|
501
|
+
return { editId };
|
|
502
|
+
}
|
|
503
|
+
async deleteMessage(originalMessageId, toUserId, scope) {
|
|
504
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
505
|
+
throw new Error('DropOnAir websocket is not connected');
|
|
506
|
+
}
|
|
507
|
+
if (!this.currentUserId) {
|
|
508
|
+
throw new Error('Missing sender identity from DropOnAir JWT subject');
|
|
509
|
+
}
|
|
510
|
+
if (this.dropOnAirJwt && (0, bytes_1.isJwtExpired)(this.dropOnAirJwt)) {
|
|
511
|
+
throw new Error('JWT_EXPIRED: Secure messaging session is reconnecting; please retry in a moment');
|
|
512
|
+
}
|
|
513
|
+
const deleteId = crypto.randomUUID();
|
|
514
|
+
const frame = {
|
|
515
|
+
type: 'MESSAGE_DELETE',
|
|
516
|
+
deleteId,
|
|
517
|
+
originalMessageId,
|
|
518
|
+
appId: this.options.appId,
|
|
519
|
+
fromUserId: this.currentUserId,
|
|
520
|
+
toUserId,
|
|
521
|
+
timestamp: Date.now(),
|
|
522
|
+
scope,
|
|
523
|
+
clientDeleteId: deleteId,
|
|
524
|
+
};
|
|
525
|
+
this.ws.send(this.codec.encodeMessageDeleteFrame(frame));
|
|
526
|
+
return { deleteId };
|
|
527
|
+
}
|
|
366
528
|
// ---------------------------------------------------------------------------
|
|
367
529
|
// Cleartext messaging (no E2EE key exchange required)
|
|
368
530
|
// ---------------------------------------------------------------------------
|
|
@@ -910,9 +1072,111 @@ class MessagingClient {
|
|
|
910
1072
|
this.emitBroadcast(frame.data);
|
|
911
1073
|
return;
|
|
912
1074
|
}
|
|
913
|
-
|
|
1075
|
+
if (frame.kind === 'messageEdit') {
|
|
1076
|
+
await this.handleIncomingMessageEdit(frame.data);
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (frame.kind === 'messageDelete') {
|
|
1080
|
+
this.handleIncomingMessageDelete(frame.data);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
if (frame.kind === 'envelope') {
|
|
1084
|
+
await this.handleIncomingEnvelope(frame.data);
|
|
1085
|
+
}
|
|
1086
|
+
};
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
async handleIncomingMessageEdit(frame) {
|
|
1090
|
+
try {
|
|
1091
|
+
this.log('incoming_message_edit_received', {
|
|
1092
|
+
editId: frame.editId,
|
|
1093
|
+
originalMessageId: frame.originalMessageId,
|
|
1094
|
+
fromUserId: frame.fromUserId,
|
|
1095
|
+
toUserId: frame.toUserId,
|
|
1096
|
+
timestamp: frame.timestamp,
|
|
1097
|
+
encryptionType: frame.encryptionType,
|
|
1098
|
+
hasDevicePayloads: !!(frame.devicePayloads && frame.devicePayloads.length > 0),
|
|
1099
|
+
});
|
|
1100
|
+
let plaintext;
|
|
1101
|
+
if (frame.encryptionType === 1) {
|
|
1102
|
+
plaintext = frame.plaintextPayload ?? '';
|
|
1103
|
+
}
|
|
1104
|
+
else if (frame.devicePayloads && frame.devicePayloads.length > 0) {
|
|
1105
|
+
const myDeviceId = this.deviceId ?? await this.getOrCreateDeviceId();
|
|
1106
|
+
const myPayload = frame.devicePayloads.find(dp => dp.deviceId === myDeviceId);
|
|
1107
|
+
if (!myPayload) {
|
|
1108
|
+
this.log('incoming_message_edit_no_device_payload', {
|
|
1109
|
+
editId: frame.editId,
|
|
1110
|
+
myDeviceId,
|
|
1111
|
+
availableDeviceIds: frame.devicePayloads.map(dp => dp.deviceId),
|
|
1112
|
+
});
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
const senderPublicKeyBase64 = (0, bytes_1.toBase64)(myPayload.senderPublicKey);
|
|
1116
|
+
const isSelfSync = frame.fromUserId === this.currentUserId;
|
|
1117
|
+
const peerUserIdForHkdf = isSelfSync ? this.currentUserId : frame.fromUserId;
|
|
1118
|
+
const sharedKey = await this.cryptoService.deriveSharedSecret(peerUserIdForHkdf, senderPublicKeyBase64, this.currentUserId);
|
|
1119
|
+
plaintext = await this.cryptoService.decrypt(myPayload.encryptedPayload, sharedKey, {
|
|
1120
|
+
messageId: frame.editId,
|
|
1121
|
+
senderId: frame.fromUserId,
|
|
1122
|
+
recipientId: frame.toUserId,
|
|
1123
|
+
timestamp: frame.timestamp,
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
else if (frame.encryptedPayload && frame.encryptedPayload.length > 0) {
|
|
1127
|
+
const sharedKey = await this.getPeerSharedKey(frame.fromUserId);
|
|
1128
|
+
plaintext = await this.cryptoService.decrypt(frame.encryptedPayload, sharedKey, {
|
|
1129
|
+
messageId: frame.editId,
|
|
1130
|
+
senderId: frame.fromUserId,
|
|
1131
|
+
recipientId: frame.toUserId,
|
|
1132
|
+
timestamp: frame.timestamp,
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
else {
|
|
1136
|
+
this.log('incoming_message_edit_no_payload', { editId: frame.editId });
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
const event = {
|
|
1140
|
+
editId: frame.editId,
|
|
1141
|
+
originalMessageId: frame.originalMessageId,
|
|
1142
|
+
fromUserId: frame.fromUserId,
|
|
1143
|
+
toUserId: frame.toUserId,
|
|
1144
|
+
timestamp: frame.timestamp,
|
|
1145
|
+
plaintext,
|
|
914
1146
|
};
|
|
1147
|
+
for (const listener of this.messageEditListeners) {
|
|
1148
|
+
listener(event);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
catch (error) {
|
|
1152
|
+
this.logError('incoming_message_edit_failed', {
|
|
1153
|
+
editId: frame.editId,
|
|
1154
|
+
originalMessageId: frame.originalMessageId,
|
|
1155
|
+
error: String(error?.message ?? error),
|
|
1156
|
+
});
|
|
1157
|
+
this.emitEvent({ type: 'ERROR', reason: 'DECRYPT_FAILED', metadata: frame.editId });
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
handleIncomingMessageDelete(frame) {
|
|
1161
|
+
this.log('incoming_message_delete_received', {
|
|
1162
|
+
deleteId: frame.deleteId,
|
|
1163
|
+
originalMessageId: frame.originalMessageId,
|
|
1164
|
+
fromUserId: frame.fromUserId,
|
|
1165
|
+
toUserId: frame.toUserId,
|
|
1166
|
+
scope: frame.scope,
|
|
1167
|
+
timestamp: frame.timestamp,
|
|
915
1168
|
});
|
|
1169
|
+
const event = {
|
|
1170
|
+
deleteId: frame.deleteId,
|
|
1171
|
+
originalMessageId: frame.originalMessageId,
|
|
1172
|
+
fromUserId: frame.fromUserId,
|
|
1173
|
+
toUserId: frame.toUserId,
|
|
1174
|
+
timestamp: frame.timestamp,
|
|
1175
|
+
scope: frame.scope,
|
|
1176
|
+
};
|
|
1177
|
+
for (const listener of this.messageDeleteListeners) {
|
|
1178
|
+
listener(event);
|
|
1179
|
+
}
|
|
916
1180
|
}
|
|
917
1181
|
async handleIncomingEnvelope(envelope) {
|
|
918
1182
|
try {
|
|
@@ -964,15 +1228,20 @@ class MessagingClient {
|
|
|
964
1228
|
timestamp: envelope.timestamp
|
|
965
1229
|
});
|
|
966
1230
|
}
|
|
1231
|
+
const attachments = envelope.attachments && envelope.attachments.length > 0
|
|
1232
|
+
? envelope.attachments.map(w => attachment_client_1.AttachmentClient.fromWire(w))
|
|
1233
|
+
: undefined;
|
|
967
1234
|
this.emitMessage({
|
|
968
1235
|
messageId: envelope.messageId,
|
|
969
1236
|
fromUserId: envelope.fromUserId,
|
|
970
1237
|
toUserId: envelope.toUserId,
|
|
971
1238
|
timestamp: envelope.timestamp,
|
|
972
|
-
plaintext
|
|
1239
|
+
plaintext,
|
|
1240
|
+
attachments,
|
|
973
1241
|
});
|
|
974
1242
|
this.log('incoming_envelope_emitted', {
|
|
975
1243
|
messageId: envelope.messageId,
|
|
1244
|
+
attachmentCount: attachments?.length ?? 0,
|
|
976
1245
|
});
|
|
977
1246
|
if (this.autoAckIncomingMessages) {
|
|
978
1247
|
await this.ack(envelope.messageId);
|
|
@@ -1190,6 +1459,48 @@ class MessagingClient {
|
|
|
1190
1459
|
await this.handleIncomingEnvelope(wireEnvelope);
|
|
1191
1460
|
totalProcessed += 1;
|
|
1192
1461
|
}
|
|
1462
|
+
// Replay pending edits/tombstones for this page
|
|
1463
|
+
if (body.pendingEdits && body.pendingEdits.length > 0) {
|
|
1464
|
+
for (const offlineEdit of body.pendingEdits) {
|
|
1465
|
+
const wireEdit = {
|
|
1466
|
+
type: 'MESSAGE_EDIT',
|
|
1467
|
+
editId: offlineEdit.editId,
|
|
1468
|
+
originalMessageId: offlineEdit.originalMessageId,
|
|
1469
|
+
appId: offlineEdit.appId,
|
|
1470
|
+
fromUserId: offlineEdit.fromUserId,
|
|
1471
|
+
toUserId: offlineEdit.toUserId,
|
|
1472
|
+
timestamp: new Date(offlineEdit.createdAt).getTime(),
|
|
1473
|
+
encryptionType: offlineEdit.encryptionType === 'CLEARTEXT' ? 1 : 0,
|
|
1474
|
+
encryptedPayload: offlineEdit.encryptedPayloadBase64
|
|
1475
|
+
? (0, bytes_1.fromBase64)(offlineEdit.encryptedPayloadBase64)
|
|
1476
|
+
: new Uint8Array(0),
|
|
1477
|
+
plaintextPayload: offlineEdit.plaintextPayload,
|
|
1478
|
+
};
|
|
1479
|
+
if (offlineEdit.devicePayloads && offlineEdit.devicePayloads.length > 0) {
|
|
1480
|
+
wireEdit.devicePayloads = offlineEdit.devicePayloads.map(dp => ({
|
|
1481
|
+
deviceId: dp.deviceId,
|
|
1482
|
+
encryptedPayload: (0, bytes_1.fromBase64)(dp.encryptedPayloadBase64),
|
|
1483
|
+
senderPublicKey: (0, bytes_1.fromBase64)(dp.senderPublicKeyBase64),
|
|
1484
|
+
}));
|
|
1485
|
+
wireEdit.senderDeviceId = offlineEdit.senderDeviceId;
|
|
1486
|
+
}
|
|
1487
|
+
await this.handleIncomingMessageEdit(wireEdit);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
if (body.pendingTombstones && body.pendingTombstones.length > 0) {
|
|
1491
|
+
for (const offlineTomb of body.pendingTombstones) {
|
|
1492
|
+
this.handleIncomingMessageDelete({
|
|
1493
|
+
type: 'MESSAGE_DELETE',
|
|
1494
|
+
deleteId: offlineTomb.deleteId,
|
|
1495
|
+
originalMessageId: offlineTomb.originalMessageId,
|
|
1496
|
+
appId: offlineTomb.appId,
|
|
1497
|
+
fromUserId: offlineTomb.fromUserId,
|
|
1498
|
+
toUserId: offlineTomb.toUserId,
|
|
1499
|
+
timestamp: new Date(offlineTomb.createdAt).getTime(),
|
|
1500
|
+
scope: offlineTomb.scope,
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1193
1504
|
page += 1;
|
|
1194
1505
|
}
|
|
1195
1506
|
this.log('offline_fetch_completed', {
|
package/dist/core/types.d.ts
CHANGED
|
@@ -10,9 +10,44 @@ export interface DecryptedMessage {
|
|
|
10
10
|
toUserId: string;
|
|
11
11
|
timestamp: number;
|
|
12
12
|
plaintext: string;
|
|
13
|
+
/**
|
|
14
|
+
* Attachment pointers (phase1/attachments, PROTOCOL_VERSION 4+).
|
|
15
|
+
* Empty/undefined for messages with no attachments. To fetch and decrypt the
|
|
16
|
+
* bytes, call {@code client.downloadAttachment(ref)}.
|
|
17
|
+
*/
|
|
18
|
+
attachments?: import('../attachment/attachment-types').AttachmentRef[];
|
|
13
19
|
}
|
|
14
20
|
export type MessageCallback = (message: DecryptedMessage) => void;
|
|
15
21
|
export type EventCallback = (event: DropOnAirEvent) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Delivered to recipient devices when a sender edits a previously sent message.
|
|
24
|
+
* For E2EE messages the SDK has already decrypted the new payload.
|
|
25
|
+
* UI is the application's responsibility, render an "edited" badge if desired.
|
|
26
|
+
*/
|
|
27
|
+
export interface MessageEditEvent {
|
|
28
|
+
editId: string;
|
|
29
|
+
originalMessageId: string;
|
|
30
|
+
fromUserId: string;
|
|
31
|
+
toUserId: string;
|
|
32
|
+
timestamp: number;
|
|
33
|
+
/** New plaintext for both E2EE (after decryption) and CLEARTEXT messages. */
|
|
34
|
+
plaintext: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Delivered to recipient devices when a sender deletes a previously sent message
|
|
38
|
+
* with scope FOR_EVERYONE. Application should remove or replace the displayed
|
|
39
|
+
* content with a "message deleted" placeholder.
|
|
40
|
+
*/
|
|
41
|
+
export interface MessageDeleteEvent {
|
|
42
|
+
deleteId: string;
|
|
43
|
+
originalMessageId: string;
|
|
44
|
+
fromUserId: string;
|
|
45
|
+
toUserId: string;
|
|
46
|
+
timestamp: number;
|
|
47
|
+
scope: 'FOR_EVERYONE' | 'FOR_ME' | string;
|
|
48
|
+
}
|
|
49
|
+
export type MessageEditCallback = (event: MessageEditEvent) => void;
|
|
50
|
+
export type MessageDeleteCallback = (event: MessageDeleteEvent) => void;
|
|
16
51
|
export interface BroadcastMessage {
|
|
17
52
|
broadcastId: string;
|
|
18
53
|
channelId: string;
|
|
@@ -100,12 +135,53 @@ export interface InitializeOptions {
|
|
|
100
135
|
export interface DropOnAirClient {
|
|
101
136
|
connect(): Promise<void>;
|
|
102
137
|
disconnect(): void;
|
|
103
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Send a 1:1 E2EE message. Optionally attach one or more attachments
|
|
140
|
+
* prepared via {@link prepareAttachmentAndUpload} or {@link createUploadSession}.
|
|
141
|
+
*/
|
|
142
|
+
sendMessage(toUserId: string, plaintextMessage: string, options?: {
|
|
143
|
+
attachments?: import('../attachment/attachment-types').AttachmentRef[];
|
|
144
|
+
}): Promise<{
|
|
104
145
|
messageId: string;
|
|
105
146
|
}>;
|
|
106
147
|
onMessage(callback: MessageCallback): () => void;
|
|
107
148
|
onEvent(callback: EventCallback): () => void;
|
|
108
149
|
ack(messageId: string): Promise<void>;
|
|
150
|
+
/**
|
|
151
|
+
* Edit a previously sent message. For E2EE messages the new plaintext is
|
|
152
|
+
* re-encrypted per recipient device exactly like a new message. For
|
|
153
|
+
* CLEARTEXT messages the server overwrites the stored plaintext.
|
|
154
|
+
* Edit window is unlimited.
|
|
155
|
+
*/
|
|
156
|
+
editMessage(originalMessageId: string, toUserId: string, newPlaintext: string): Promise<{
|
|
157
|
+
editId: string;
|
|
158
|
+
}>;
|
|
159
|
+
/**
|
|
160
|
+
* Delete a previously sent message. Scope FOR_EVERYONE records a server
|
|
161
|
+
* tombstone and fans out a notification to recipient devices. Scope FOR_ME
|
|
162
|
+
* is local-only by convention but the server still records the tombstone
|
|
163
|
+
* (without fan-out) so the deletion survives across the sender's own devices.
|
|
164
|
+
* Delete window is unlimited.
|
|
165
|
+
*/
|
|
166
|
+
deleteMessage(originalMessageId: string, toUserId: string, scope: 'FOR_EVERYONE' | 'FOR_ME'): Promise<{
|
|
167
|
+
deleteId: string;
|
|
168
|
+
}>;
|
|
169
|
+
/** Register a listener for incoming message-edit notifications. */
|
|
170
|
+
onMessageEdit(callback: MessageEditCallback): () => void;
|
|
171
|
+
/** Register a listener for incoming message-delete notifications. */
|
|
172
|
+
onMessageDelete(callback: MessageDeleteCallback): () => void;
|
|
173
|
+
/**
|
|
174
|
+
* Convenience: encrypts (E2EE), uploads to the customer's bucket, finalizes,
|
|
175
|
+
* and returns an AttachmentRef ready to pass into {@link sendMessage}.
|
|
176
|
+
* For CLEARTEXT, bytes are uploaded unencrypted.
|
|
177
|
+
*/
|
|
178
|
+
prepareAttachmentAndUpload(bytes: Uint8Array, options: import('../attachment/attachment-types').PrepareAttachmentOptions): Promise<import('../attachment/attachment-types').AttachmentRef>;
|
|
179
|
+
/** Low-level: reserve an upload session (presigned URL) without uploading. */
|
|
180
|
+
createUploadSession(options: import('../attachment/attachment-types').CreateUploadSessionOptions): Promise<import('../attachment/attachment-types').UploadSession>;
|
|
181
|
+
/** Low-level: finalize an upload, committing the integrity hash. */
|
|
182
|
+
finalizeAttachment(attachmentId: string, sha256: string): Promise<void>;
|
|
183
|
+
/** Download (and for E2EE decrypt) an attachment referenced inside a received message. */
|
|
184
|
+
downloadAttachment(ref: import('../attachment/attachment-types').AttachmentRef): Promise<import('../attachment/attachment-types').DownloadedAttachment>;
|
|
109
185
|
/** Send a cleartext message to a user (no encryption). */
|
|
110
186
|
sendCleartextMessage(toUserId: string, plaintext: string): Promise<{
|
|
111
187
|
messageId: string;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { InitializeOptions, DropOnAirClient } from './core/types';
|
|
2
2
|
export { SDK_VERSION, PROTOCOL_VERSION, PAYLOAD_FORMAT_VERSION } from './version';
|
|
3
3
|
export declare function initialize(options: InitializeOptions): Promise<DropOnAirClient>;
|
|
4
|
-
export type { InitializeOptions, DropOnAirClient, DropOnAirEvent, MessageCallback, EventCallback, DecryptedMessage, KeyStorageAdapter, CallEvent, CallEventType, CallEventCallback, TurnCredentials, BroadcastMessage, BroadcastCallback, GroupInfo, GroupMemberInfo, DecryptedGroupMessage, GroupMessageCallback, GroupCallEvent, GroupCallEventType, GroupCallEventCallback, } from './core/types';
|
|
4
|
+
export type { InitializeOptions, DropOnAirClient, DropOnAirEvent, MessageCallback, EventCallback, DecryptedMessage, KeyStorageAdapter, CallEvent, CallEventType, CallEventCallback, TurnCredentials, BroadcastMessage, BroadcastCallback, GroupInfo, GroupMemberInfo, DecryptedGroupMessage, GroupMessageCallback, GroupCallEvent, GroupCallEventType, GroupCallEventCallback, MessageEditEvent, MessageEditCallback, MessageDeleteEvent, MessageDeleteCallback, } from './core/types';
|
|
5
|
+
export type { AttachmentEncryptionType, AttachmentConversationType, AttachmentRef, DeviceWrappedKey, CreateUploadSessionOptions, UploadSession, PrepareAttachmentOptions, DownloadedAttachment, } from './attachment/attachment-types';
|
|
5
6
|
declare const _default: {
|
|
6
7
|
initialize: typeof initialize;
|
|
7
8
|
};
|
|
@@ -4,6 +4,23 @@ export interface WireDeviceEncryptedPayload {
|
|
|
4
4
|
encryptedPayload: Uint8Array;
|
|
5
5
|
senderPublicKey: Uint8Array;
|
|
6
6
|
}
|
|
7
|
+
/** Per-device wrapped AES-256-GCM file key for E2EE attachments. */
|
|
8
|
+
export interface WireDeviceWrappedKey {
|
|
9
|
+
deviceId: string;
|
|
10
|
+
wrappedKey: Uint8Array;
|
|
11
|
+
senderPublicKey: Uint8Array;
|
|
12
|
+
nonce: Uint8Array;
|
|
13
|
+
}
|
|
14
|
+
/** Pointer to an attachment in the customer's storage bucket. */
|
|
15
|
+
export interface WireAttachmentRef {
|
|
16
|
+
attachmentId: string;
|
|
17
|
+
storageHint: string;
|
|
18
|
+
mimeType: string;
|
|
19
|
+
sizeBytes: number;
|
|
20
|
+
sha256: string;
|
|
21
|
+
encryptionType: number;
|
|
22
|
+
wrappedKeys?: WireDeviceWrappedKey[];
|
|
23
|
+
}
|
|
7
24
|
export interface WireEnvelope {
|
|
8
25
|
messageId: string;
|
|
9
26
|
appId: string;
|
|
@@ -20,6 +37,8 @@ export interface WireEnvelope {
|
|
|
20
37
|
encryptionType?: number;
|
|
21
38
|
/** Populated only when encryptionType = CLEARTEXT. */
|
|
22
39
|
plaintextPayload?: string;
|
|
40
|
+
/** Zero or more attachment pointers (PROTOCOL_VERSION 4+). */
|
|
41
|
+
attachments?: WireAttachmentRef[];
|
|
23
42
|
}
|
|
24
43
|
export interface WireAck {
|
|
25
44
|
messageId: string;
|
|
@@ -93,6 +112,38 @@ export interface WireGroupCallFrame {
|
|
|
93
112
|
targetUserId?: string;
|
|
94
113
|
payload?: string;
|
|
95
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Edit frame, sender re-encrypts new content for all recipient devices.
|
|
117
|
+
* Same shape sent in both directions, client to server (request) and
|
|
118
|
+
* server to recipient devices (notification).
|
|
119
|
+
*/
|
|
120
|
+
export interface WireMessageEditFrame {
|
|
121
|
+
type: string;
|
|
122
|
+
editId: string;
|
|
123
|
+
originalMessageId: string;
|
|
124
|
+
appId: string;
|
|
125
|
+
fromUserId: string;
|
|
126
|
+
toUserId: string;
|
|
127
|
+
timestamp: number;
|
|
128
|
+
encryptionType: number;
|
|
129
|
+
encryptedPayload?: Uint8Array;
|
|
130
|
+
devicePayloads?: WireDeviceEncryptedPayload[];
|
|
131
|
+
senderDeviceId?: string;
|
|
132
|
+
plaintextPayload?: string;
|
|
133
|
+
clientEditId?: string;
|
|
134
|
+
}
|
|
135
|
+
/** Tombstone frame, scope FOR_EVERYONE or FOR_ME. */
|
|
136
|
+
export interface WireMessageDeleteFrame {
|
|
137
|
+
type: string;
|
|
138
|
+
deleteId: string;
|
|
139
|
+
originalMessageId: string;
|
|
140
|
+
appId: string;
|
|
141
|
+
fromUserId: string;
|
|
142
|
+
toUserId: string;
|
|
143
|
+
timestamp: number;
|
|
144
|
+
scope: string;
|
|
145
|
+
clientDeleteId?: string;
|
|
146
|
+
}
|
|
96
147
|
export type InboundFrame = {
|
|
97
148
|
kind: 'envelope';
|
|
98
149
|
data: WireEnvelope;
|
|
@@ -117,6 +168,12 @@ export type InboundFrame = {
|
|
|
117
168
|
} | {
|
|
118
169
|
kind: 'groupCall';
|
|
119
170
|
data: WireGroupCallFrame;
|
|
171
|
+
} | {
|
|
172
|
+
kind: 'messageEdit';
|
|
173
|
+
data: WireMessageEditFrame;
|
|
174
|
+
} | {
|
|
175
|
+
kind: 'messageDelete';
|
|
176
|
+
data: WireMessageDeleteFrame;
|
|
120
177
|
};
|
|
121
178
|
export declare class ProtobufCodec {
|
|
122
179
|
encodeEnvelope(value: WireEnvelope): Uint8Array;
|
|
@@ -126,6 +183,8 @@ export declare class ProtobufCodec {
|
|
|
126
183
|
encodeGroupEnvelope(value: WireGroupEnvelope): Uint8Array;
|
|
127
184
|
encodeGroupCallFrame(value: WireGroupCallFrame): Uint8Array;
|
|
128
185
|
encodeGroupAck(value: WireGroupAck): Uint8Array;
|
|
186
|
+
encodeMessageEditFrame(value: WireMessageEditFrame): Uint8Array;
|
|
187
|
+
encodeMessageDeleteFrame(value: WireMessageDeleteFrame): Uint8Array;
|
|
129
188
|
decodeFrame(payload: Uint8Array): InboundFrame;
|
|
130
189
|
private tryDecodeEnvelope;
|
|
131
190
|
private tryDecodeAck;
|
|
@@ -135,4 +194,6 @@ export declare class ProtobufCodec {
|
|
|
135
194
|
private tryDecodeGroupMessageNotification;
|
|
136
195
|
private tryDecodeGroupAck;
|
|
137
196
|
private tryDecodeGroupCallFrame;
|
|
197
|
+
private tryDecodeMessageEditFrame;
|
|
198
|
+
private tryDecodeMessageDeleteFrame;
|
|
138
199
|
}
|