@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.
@@ -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): Promise<{
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
- await this.handleIncomingEnvelope(frame.data);
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', {
@@ -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
- sendMessage(toUserId: string, plaintextMessage: string): Promise<{
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
  }