@droponair/sdk-js 0.3.1 → 0.4.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 CHANGED
@@ -6,6 +6,29 @@ This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.4.0], 2026-04-21
10
+
11
+ ### Added
12
+ - **Message edit (E2EE + cleartext):** `editMessage(originalMessageId, toUserId, newText)` and `editCleartextMessage(originalMessageId, toUserId, newText)`. Edits are re-encrypted per-device with the same shared key as the original message; the server stores them as immutable separate records and forwards opaquely.
13
+ - **Message delete:** `deleteMessage(originalMessageId, toUserId, scope)` with `scope` of `"FOR_EVERYONE"` (recipient receives tombstone, cleartext plaintext is also wiped server-side) or `"FOR_ME"` (only sender's other devices receive the tombstone).
14
+ - **New event types:** `MessageEditEvent` and `MessageDeleteEvent` with `onMessageEdit()` / `onMessageDelete()` listeners.
15
+ - **Offline catch-up:** Pending edits and tombstones are now replayed alongside offline messages when the client reconnects (`pendingEdits` and `pendingTombstones` returned by `/api/messages/offline`).
16
+ - **PROTOCOL_VERSION = 3** and `features` now advertises `message_edit` + `message_delete` from `GET /api/info`.
17
+
18
+ ### Notes
19
+ - Each edit and each `FOR_EVERYONE` delete counts as **one** MESSAGE usage record (subject to plan quotas + rate limits).
20
+ - Wire-level additive: legacy 0.3.x clients ignore unknown frame types (proto3 forwards-compat), so existing TwinFlame and CrowdSyncer apps continue to work unchanged.
21
+ - Requires sdk-be `0.4.0` server.
22
+
23
+ ---
24
+
25
+ ## [0.3.1], 2026-04-19
26
+
27
+ ### Changed
28
+ - Patch version bump for npm publish alignment
29
+
30
+ ---
31
+
9
32
  ## [0.3.0], 2026-04-04
10
33
 
11
34
  ### Added
@@ -1,6 +1,6 @@
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
4
  export declare class MessagingClient implements DropOnAirClient {
5
5
  private readonly options;
6
6
  private readonly cryptoService;
@@ -68,6 +68,8 @@ export declare class MessagingClient implements DropOnAirClient {
68
68
  private readonly messageListeners;
69
69
  private readonly eventListeners;
70
70
  private readonly broadcastListeners;
71
+ private readonly messageEditListeners;
72
+ private readonly messageDeleteListeners;
71
73
  /** ------------------------------------------------------------------
72
74
  * Lightweight structured logger. Only active when options.debug === true.
73
75
  * Fields are safe to log: no plaintext, no private keys, no full JWTs.
@@ -90,6 +92,17 @@ export declare class MessagingClient implements DropOnAirClient {
90
92
  ack(messageId: string): Promise<void>;
91
93
  onMessage(callback: MessageCallback): () => void;
92
94
  onEvent(callback: EventCallback): () => void;
95
+ onMessageEdit(callback: MessageEditCallback): () => void;
96
+ onMessageDelete(callback: MessageDeleteCallback): () => void;
97
+ editMessage(originalMessageId: string, toUserId: string, newPlaintext: string): Promise<{
98
+ editId: string;
99
+ }>;
100
+ editCleartextMessage(originalMessageId: string, toUserId: string, newPlaintext: string): Promise<{
101
+ editId: string;
102
+ }>;
103
+ deleteMessage(originalMessageId: string, toUserId: string, scope: 'FOR_EVERYONE' | 'FOR_ME'): Promise<{
104
+ deleteId: string;
105
+ }>;
93
106
  sendCleartextMessage(toUserId: string, plaintext: string): Promise<{
94
107
  messageId: string;
95
108
  }>;
@@ -137,6 +150,8 @@ export declare class MessagingClient implements DropOnAirClient {
137
150
  private handleIncomingGroupMessage;
138
151
  private emitGroupCallEvent;
139
152
  private connectWebSocket;
153
+ private handleIncomingMessageEdit;
154
+ private handleIncomingMessageDelete;
140
155
  private handleIncomingEnvelope;
141
156
  private getPeerSharedKey;
142
157
  /** Get or create a persistent device UUID for this SDK instance. */
@@ -218,6 +218,8 @@ class MessagingClient {
218
218
  this.messageListeners = new Set();
219
219
  this.eventListeners = new Set();
220
220
  this.broadcastListeners = new Set();
221
+ this.messageEditListeners = new Set();
222
+ this.messageDeleteListeners = new Set();
221
223
  this.wsUrl = options.messagingWsUrl ?? 'wss://sdk.droponair.com/ws';
222
224
  this.httpUrl = options.messagingHttpUrl ?? 'https://sdk.droponair.com';
223
225
  this.tokenExchangeEndpoint = options.tokenExchangeEndpoint ?? '/api/messaging/token-exchange';
@@ -363,6 +365,129 @@ class MessagingClient {
363
365
  this.eventListeners.add(callback);
364
366
  return () => this.eventListeners.delete(callback);
365
367
  }
368
+ onMessageEdit(callback) {
369
+ this.messageEditListeners.add(callback);
370
+ return () => this.messageEditListeners.delete(callback);
371
+ }
372
+ onMessageDelete(callback) {
373
+ this.messageDeleteListeners.add(callback);
374
+ return () => this.messageDeleteListeners.delete(callback);
375
+ }
376
+ // ---------------------------------------------------------------------------
377
+ // Message edit and delete (PROTOCOL_VERSION 3+)
378
+ // ---------------------------------------------------------------------------
379
+ async editMessage(originalMessageId, toUserId, newPlaintext) {
380
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
381
+ throw new Error('DropOnAir websocket is not connected');
382
+ }
383
+ if (!this.currentUserId) {
384
+ throw new Error('Missing sender identity from DropOnAir JWT subject');
385
+ }
386
+ if (this.rateLimited) {
387
+ throw new Error('Sending is temporarily blocked by LIMIT_REACHED');
388
+ }
389
+ if (this.dropOnAirJwt && (0, bytes_1.isJwtExpired)(this.dropOnAirJwt)) {
390
+ throw new Error('JWT_EXPIRED: Secure messaging session is reconnecting; please retry in a moment');
391
+ }
392
+ const myDeviceId = this.deviceId ?? await this.getOrCreateDeviceId();
393
+ const editId = crypto.randomUUID();
394
+ const timestamp = Date.now();
395
+ // Encrypt new content for all recipient devices + sender's other devices,
396
+ // exactly mirroring sendMessage. The relay never sees plaintext.
397
+ const myIdentity = await this.cryptoService.getOrCreateIdentity();
398
+ const myPublicKeyBytes = (0, bytes_1.fromBase64)(myIdentity.publicKey);
399
+ const peerDeviceKeys = await this.fetchDeviceKeys(toUserId);
400
+ const myOtherDeviceKeys = await this.fetchMyOtherDeviceKeys(myDeviceId);
401
+ const allTargetKeys = [
402
+ ...peerDeviceKeys.map(dk => ({ ...dk, isRecipient: true })),
403
+ ...myOtherDeviceKeys.map(dk => ({ ...dk, isRecipient: false })),
404
+ ];
405
+ if (allTargetKeys.length === 0) {
406
+ throw new Error('No recipient device keys available for edit');
407
+ }
408
+ const devicePayloads = [];
409
+ for (const target of allTargetKeys) {
410
+ const peerUserIdForHkdf = target.isRecipient ? toUserId : this.currentUserId;
411
+ const sharedKey = await this.cryptoService.deriveSharedSecret(peerUserIdForHkdf, target.publicKey, this.currentUserId);
412
+ const encrypted = await this.cryptoService.encrypt(newPlaintext, sharedKey, {
413
+ messageId: editId,
414
+ senderId: this.currentUserId,
415
+ recipientId: toUserId,
416
+ timestamp,
417
+ });
418
+ devicePayloads.push({
419
+ deviceId: target.deviceId,
420
+ encryptedPayload: encrypted,
421
+ senderPublicKey: myPublicKeyBytes,
422
+ });
423
+ }
424
+ const frame = {
425
+ type: 'MESSAGE_EDIT',
426
+ editId,
427
+ originalMessageId,
428
+ appId: this.options.appId,
429
+ fromUserId: this.currentUserId,
430
+ toUserId,
431
+ timestamp,
432
+ encryptionType: 0, // E2EE
433
+ devicePayloads,
434
+ senderDeviceId: myDeviceId,
435
+ clientEditId: editId,
436
+ };
437
+ this.ws.send(this.codec.encodeMessageEditFrame(frame));
438
+ return { editId };
439
+ }
440
+ async editCleartextMessage(originalMessageId, toUserId, newPlaintext) {
441
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
442
+ throw new Error('DropOnAir websocket is not connected');
443
+ }
444
+ if (!this.currentUserId) {
445
+ throw new Error('Missing sender identity from DropOnAir JWT subject');
446
+ }
447
+ if (this.dropOnAirJwt && (0, bytes_1.isJwtExpired)(this.dropOnAirJwt)) {
448
+ throw new Error('JWT_EXPIRED: Secure messaging session is reconnecting; please retry in a moment');
449
+ }
450
+ const editId = crypto.randomUUID();
451
+ const frame = {
452
+ type: 'MESSAGE_EDIT',
453
+ editId,
454
+ originalMessageId,
455
+ appId: this.options.appId,
456
+ fromUserId: this.currentUserId,
457
+ toUserId,
458
+ timestamp: Date.now(),
459
+ encryptionType: 1, // CLEARTEXT
460
+ plaintextPayload: newPlaintext,
461
+ clientEditId: editId,
462
+ };
463
+ this.ws.send(this.codec.encodeMessageEditFrame(frame));
464
+ return { editId };
465
+ }
466
+ async deleteMessage(originalMessageId, toUserId, scope) {
467
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
468
+ throw new Error('DropOnAir websocket is not connected');
469
+ }
470
+ if (!this.currentUserId) {
471
+ throw new Error('Missing sender identity from DropOnAir JWT subject');
472
+ }
473
+ if (this.dropOnAirJwt && (0, bytes_1.isJwtExpired)(this.dropOnAirJwt)) {
474
+ throw new Error('JWT_EXPIRED: Secure messaging session is reconnecting; please retry in a moment');
475
+ }
476
+ const deleteId = crypto.randomUUID();
477
+ const frame = {
478
+ type: 'MESSAGE_DELETE',
479
+ deleteId,
480
+ originalMessageId,
481
+ appId: this.options.appId,
482
+ fromUserId: this.currentUserId,
483
+ toUserId,
484
+ timestamp: Date.now(),
485
+ scope,
486
+ clientDeleteId: deleteId,
487
+ };
488
+ this.ws.send(this.codec.encodeMessageDeleteFrame(frame));
489
+ return { deleteId };
490
+ }
366
491
  // ---------------------------------------------------------------------------
367
492
  // Cleartext messaging (no E2EE key exchange required)
368
493
  // ---------------------------------------------------------------------------
@@ -910,10 +1035,112 @@ class MessagingClient {
910
1035
  this.emitBroadcast(frame.data);
911
1036
  return;
912
1037
  }
913
- await this.handleIncomingEnvelope(frame.data);
1038
+ if (frame.kind === 'messageEdit') {
1039
+ await this.handleIncomingMessageEdit(frame.data);
1040
+ return;
1041
+ }
1042
+ if (frame.kind === 'messageDelete') {
1043
+ this.handleIncomingMessageDelete(frame.data);
1044
+ return;
1045
+ }
1046
+ if (frame.kind === 'envelope') {
1047
+ await this.handleIncomingEnvelope(frame.data);
1048
+ }
914
1049
  };
915
1050
  });
916
1051
  }
1052
+ async handleIncomingMessageEdit(frame) {
1053
+ try {
1054
+ this.log('incoming_message_edit_received', {
1055
+ editId: frame.editId,
1056
+ originalMessageId: frame.originalMessageId,
1057
+ fromUserId: frame.fromUserId,
1058
+ toUserId: frame.toUserId,
1059
+ timestamp: frame.timestamp,
1060
+ encryptionType: frame.encryptionType,
1061
+ hasDevicePayloads: !!(frame.devicePayloads && frame.devicePayloads.length > 0),
1062
+ });
1063
+ let plaintext;
1064
+ if (frame.encryptionType === 1) {
1065
+ plaintext = frame.plaintextPayload ?? '';
1066
+ }
1067
+ else if (frame.devicePayloads && frame.devicePayloads.length > 0) {
1068
+ const myDeviceId = this.deviceId ?? await this.getOrCreateDeviceId();
1069
+ const myPayload = frame.devicePayloads.find(dp => dp.deviceId === myDeviceId);
1070
+ if (!myPayload) {
1071
+ this.log('incoming_message_edit_no_device_payload', {
1072
+ editId: frame.editId,
1073
+ myDeviceId,
1074
+ availableDeviceIds: frame.devicePayloads.map(dp => dp.deviceId),
1075
+ });
1076
+ return;
1077
+ }
1078
+ const senderPublicKeyBase64 = (0, bytes_1.toBase64)(myPayload.senderPublicKey);
1079
+ const isSelfSync = frame.fromUserId === this.currentUserId;
1080
+ const peerUserIdForHkdf = isSelfSync ? this.currentUserId : frame.fromUserId;
1081
+ const sharedKey = await this.cryptoService.deriveSharedSecret(peerUserIdForHkdf, senderPublicKeyBase64, this.currentUserId);
1082
+ plaintext = await this.cryptoService.decrypt(myPayload.encryptedPayload, sharedKey, {
1083
+ messageId: frame.editId,
1084
+ senderId: frame.fromUserId,
1085
+ recipientId: frame.toUserId,
1086
+ timestamp: frame.timestamp,
1087
+ });
1088
+ }
1089
+ else if (frame.encryptedPayload && frame.encryptedPayload.length > 0) {
1090
+ const sharedKey = await this.getPeerSharedKey(frame.fromUserId);
1091
+ plaintext = await this.cryptoService.decrypt(frame.encryptedPayload, sharedKey, {
1092
+ messageId: frame.editId,
1093
+ senderId: frame.fromUserId,
1094
+ recipientId: frame.toUserId,
1095
+ timestamp: frame.timestamp,
1096
+ });
1097
+ }
1098
+ else {
1099
+ this.log('incoming_message_edit_no_payload', { editId: frame.editId });
1100
+ return;
1101
+ }
1102
+ const event = {
1103
+ editId: frame.editId,
1104
+ originalMessageId: frame.originalMessageId,
1105
+ fromUserId: frame.fromUserId,
1106
+ toUserId: frame.toUserId,
1107
+ timestamp: frame.timestamp,
1108
+ plaintext,
1109
+ };
1110
+ for (const listener of this.messageEditListeners) {
1111
+ listener(event);
1112
+ }
1113
+ }
1114
+ catch (error) {
1115
+ this.logError('incoming_message_edit_failed', {
1116
+ editId: frame.editId,
1117
+ originalMessageId: frame.originalMessageId,
1118
+ error: String(error?.message ?? error),
1119
+ });
1120
+ this.emitEvent({ type: 'ERROR', reason: 'DECRYPT_FAILED', metadata: frame.editId });
1121
+ }
1122
+ }
1123
+ handleIncomingMessageDelete(frame) {
1124
+ this.log('incoming_message_delete_received', {
1125
+ deleteId: frame.deleteId,
1126
+ originalMessageId: frame.originalMessageId,
1127
+ fromUserId: frame.fromUserId,
1128
+ toUserId: frame.toUserId,
1129
+ scope: frame.scope,
1130
+ timestamp: frame.timestamp,
1131
+ });
1132
+ const event = {
1133
+ deleteId: frame.deleteId,
1134
+ originalMessageId: frame.originalMessageId,
1135
+ fromUserId: frame.fromUserId,
1136
+ toUserId: frame.toUserId,
1137
+ timestamp: frame.timestamp,
1138
+ scope: frame.scope,
1139
+ };
1140
+ for (const listener of this.messageDeleteListeners) {
1141
+ listener(event);
1142
+ }
1143
+ }
917
1144
  async handleIncomingEnvelope(envelope) {
918
1145
  try {
919
1146
  this.log('incoming_envelope_received', {
@@ -1190,6 +1417,48 @@ class MessagingClient {
1190
1417
  await this.handleIncomingEnvelope(wireEnvelope);
1191
1418
  totalProcessed += 1;
1192
1419
  }
1420
+ // Replay pending edits/tombstones for this page
1421
+ if (body.pendingEdits && body.pendingEdits.length > 0) {
1422
+ for (const offlineEdit of body.pendingEdits) {
1423
+ const wireEdit = {
1424
+ type: 'MESSAGE_EDIT',
1425
+ editId: offlineEdit.editId,
1426
+ originalMessageId: offlineEdit.originalMessageId,
1427
+ appId: offlineEdit.appId,
1428
+ fromUserId: offlineEdit.fromUserId,
1429
+ toUserId: offlineEdit.toUserId,
1430
+ timestamp: new Date(offlineEdit.createdAt).getTime(),
1431
+ encryptionType: offlineEdit.encryptionType === 'CLEARTEXT' ? 1 : 0,
1432
+ encryptedPayload: offlineEdit.encryptedPayloadBase64
1433
+ ? (0, bytes_1.fromBase64)(offlineEdit.encryptedPayloadBase64)
1434
+ : new Uint8Array(0),
1435
+ plaintextPayload: offlineEdit.plaintextPayload,
1436
+ };
1437
+ if (offlineEdit.devicePayloads && offlineEdit.devicePayloads.length > 0) {
1438
+ wireEdit.devicePayloads = offlineEdit.devicePayloads.map(dp => ({
1439
+ deviceId: dp.deviceId,
1440
+ encryptedPayload: (0, bytes_1.fromBase64)(dp.encryptedPayloadBase64),
1441
+ senderPublicKey: (0, bytes_1.fromBase64)(dp.senderPublicKeyBase64),
1442
+ }));
1443
+ wireEdit.senderDeviceId = offlineEdit.senderDeviceId;
1444
+ }
1445
+ await this.handleIncomingMessageEdit(wireEdit);
1446
+ }
1447
+ }
1448
+ if (body.pendingTombstones && body.pendingTombstones.length > 0) {
1449
+ for (const offlineTomb of body.pendingTombstones) {
1450
+ this.handleIncomingMessageDelete({
1451
+ type: 'MESSAGE_DELETE',
1452
+ deleteId: offlineTomb.deleteId,
1453
+ originalMessageId: offlineTomb.originalMessageId,
1454
+ appId: offlineTomb.appId,
1455
+ fromUserId: offlineTomb.fromUserId,
1456
+ toUserId: offlineTomb.toUserId,
1457
+ timestamp: new Date(offlineTomb.createdAt).getTime(),
1458
+ scope: offlineTomb.scope,
1459
+ });
1460
+ }
1461
+ }
1193
1462
  page += 1;
1194
1463
  }
1195
1464
  this.log('offline_fetch_completed', {
@@ -13,6 +13,35 @@ export interface DecryptedMessage {
13
13
  }
14
14
  export type MessageCallback = (message: DecryptedMessage) => void;
15
15
  export type EventCallback = (event: DropOnAirEvent) => void;
16
+ /**
17
+ * Delivered to recipient devices when a sender edits a previously sent message.
18
+ * For E2EE messages the SDK has already decrypted the new payload.
19
+ * UI is the application's responsibility, render an "edited" badge if desired.
20
+ */
21
+ export interface MessageEditEvent {
22
+ editId: string;
23
+ originalMessageId: string;
24
+ fromUserId: string;
25
+ toUserId: string;
26
+ timestamp: number;
27
+ /** New plaintext for both E2EE (after decryption) and CLEARTEXT messages. */
28
+ plaintext: string;
29
+ }
30
+ /**
31
+ * Delivered to recipient devices when a sender deletes a previously sent message
32
+ * with scope FOR_EVERYONE. Application should remove or replace the displayed
33
+ * content with a "message deleted" placeholder.
34
+ */
35
+ export interface MessageDeleteEvent {
36
+ deleteId: string;
37
+ originalMessageId: string;
38
+ fromUserId: string;
39
+ toUserId: string;
40
+ timestamp: number;
41
+ scope: 'FOR_EVERYONE' | 'FOR_ME' | string;
42
+ }
43
+ export type MessageEditCallback = (event: MessageEditEvent) => void;
44
+ export type MessageDeleteCallback = (event: MessageDeleteEvent) => void;
16
45
  export interface BroadcastMessage {
17
46
  broadcastId: string;
18
47
  channelId: string;
@@ -106,6 +135,29 @@ export interface DropOnAirClient {
106
135
  onMessage(callback: MessageCallback): () => void;
107
136
  onEvent(callback: EventCallback): () => void;
108
137
  ack(messageId: string): Promise<void>;
138
+ /**
139
+ * Edit a previously sent message. For E2EE messages the new plaintext is
140
+ * re-encrypted per recipient device exactly like a new message. For
141
+ * CLEARTEXT messages the server overwrites the stored plaintext.
142
+ * Edit window is unlimited.
143
+ */
144
+ editMessage(originalMessageId: string, toUserId: string, newPlaintext: string): Promise<{
145
+ editId: string;
146
+ }>;
147
+ /**
148
+ * Delete a previously sent message. Scope FOR_EVERYONE records a server
149
+ * tombstone and fans out a notification to recipient devices. Scope FOR_ME
150
+ * is local-only by convention but the server still records the tombstone
151
+ * (without fan-out) so the deletion survives across the sender's own devices.
152
+ * Delete window is unlimited.
153
+ */
154
+ deleteMessage(originalMessageId: string, toUserId: string, scope: 'FOR_EVERYONE' | 'FOR_ME'): Promise<{
155
+ deleteId: string;
156
+ }>;
157
+ /** Register a listener for incoming message-edit notifications. */
158
+ onMessageEdit(callback: MessageEditCallback): () => void;
159
+ /** Register a listener for incoming message-delete notifications. */
160
+ onMessageDelete(callback: MessageDeleteCallback): () => void;
109
161
  /** Send a cleartext message to a user (no encryption). */
110
162
  sendCleartextMessage(toUserId: string, plaintext: string): Promise<{
111
163
  messageId: string;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
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
5
  declare const _default: {
6
6
  initialize: typeof initialize;
7
7
  };
@@ -93,6 +93,38 @@ export interface WireGroupCallFrame {
93
93
  targetUserId?: string;
94
94
  payload?: string;
95
95
  }
96
+ /**
97
+ * Edit frame, sender re-encrypts new content for all recipient devices.
98
+ * Same shape sent in both directions, client to server (request) and
99
+ * server to recipient devices (notification).
100
+ */
101
+ export interface WireMessageEditFrame {
102
+ type: string;
103
+ editId: string;
104
+ originalMessageId: string;
105
+ appId: string;
106
+ fromUserId: string;
107
+ toUserId: string;
108
+ timestamp: number;
109
+ encryptionType: number;
110
+ encryptedPayload?: Uint8Array;
111
+ devicePayloads?: WireDeviceEncryptedPayload[];
112
+ senderDeviceId?: string;
113
+ plaintextPayload?: string;
114
+ clientEditId?: string;
115
+ }
116
+ /** Tombstone frame, scope FOR_EVERYONE or FOR_ME. */
117
+ export interface WireMessageDeleteFrame {
118
+ type: string;
119
+ deleteId: string;
120
+ originalMessageId: string;
121
+ appId: string;
122
+ fromUserId: string;
123
+ toUserId: string;
124
+ timestamp: number;
125
+ scope: string;
126
+ clientDeleteId?: string;
127
+ }
96
128
  export type InboundFrame = {
97
129
  kind: 'envelope';
98
130
  data: WireEnvelope;
@@ -117,6 +149,12 @@ export type InboundFrame = {
117
149
  } | {
118
150
  kind: 'groupCall';
119
151
  data: WireGroupCallFrame;
152
+ } | {
153
+ kind: 'messageEdit';
154
+ data: WireMessageEditFrame;
155
+ } | {
156
+ kind: 'messageDelete';
157
+ data: WireMessageDeleteFrame;
120
158
  };
121
159
  export declare class ProtobufCodec {
122
160
  encodeEnvelope(value: WireEnvelope): Uint8Array;
@@ -126,6 +164,8 @@ export declare class ProtobufCodec {
126
164
  encodeGroupEnvelope(value: WireGroupEnvelope): Uint8Array;
127
165
  encodeGroupCallFrame(value: WireGroupCallFrame): Uint8Array;
128
166
  encodeGroupAck(value: WireGroupAck): Uint8Array;
167
+ encodeMessageEditFrame(value: WireMessageEditFrame): Uint8Array;
168
+ encodeMessageDeleteFrame(value: WireMessageDeleteFrame): Uint8Array;
129
169
  decodeFrame(payload: Uint8Array): InboundFrame;
130
170
  private tryDecodeEnvelope;
131
171
  private tryDecodeAck;
@@ -135,4 +175,6 @@ export declare class ProtobufCodec {
135
175
  private tryDecodeGroupMessageNotification;
136
176
  private tryDecodeGroupAck;
137
177
  private tryDecodeGroupCallFrame;
178
+ private tryDecodeMessageEditFrame;
179
+ private tryDecodeMessageDeleteFrame;
138
180
  }
@@ -141,6 +141,47 @@ const GroupCallFrameType = new protobuf.Type('GroupCallFrame')
141
141
  .add(new protobuf.Field('groupId', 3, 'string'))
142
142
  .add(new protobuf.Field('targetUserId', 4, 'string'))
143
143
  .add(new protobuf.Field('payload', 5, 'string'));
144
+ // ---------------------------------------------------------------------------
145
+ // Message Edit and Delete (PROTOCOL_VERSION 3+)
146
+ // ---------------------------------------------------------------------------
147
+ //
148
+ // E2EE invariant, server never sees plaintext for E2EE edits. The same frame
149
+ // type is sent on the wire in both directions, client to server (request) and
150
+ // server to recipient devices (notification).
151
+ //
152
+ // Type discriminator at field 1 mirrors the CallFrame pattern. Probed BEFORE
153
+ // Envelope to avoid mis-routing.
154
+ const MessageEditDeviceType = new protobuf.Type('DeviceEncryptedPayload')
155
+ .add(new protobuf.Field('deviceId', 1, 'string'))
156
+ .add(new protobuf.Field('encryptedPayload', 2, 'bytes'))
157
+ .add(new protobuf.Field('senderPublicKey', 3, 'bytes'));
158
+ const MessageEditEncryptionTypeEnum = new protobuf.Enum('EncryptionType', { E2EE: 0, CLEARTEXT: 1 });
159
+ const MessageEditFrameType = new protobuf.Type('MessageEditFrame')
160
+ .add(MessageEditEncryptionTypeEnum)
161
+ .add(MessageEditDeviceType)
162
+ .add(new protobuf.Field('type', 1, 'string'))
163
+ .add(new protobuf.Field('editId', 2, 'string'))
164
+ .add(new protobuf.Field('originalMessageId', 3, 'string'))
165
+ .add(new protobuf.Field('appId', 4, 'string'))
166
+ .add(new protobuf.Field('fromUserId', 5, 'string'))
167
+ .add(new protobuf.Field('toUserId', 6, 'string'))
168
+ .add(new protobuf.Field('timestamp', 7, 'int64'))
169
+ .add(new protobuf.Field('encryptionType', 8, 'EncryptionType'))
170
+ .add(new protobuf.Field('encryptedPayload', 9, 'bytes'))
171
+ .add(new protobuf.Field('devicePayloads', 10, 'DeviceEncryptedPayload', 'repeated'))
172
+ .add(new protobuf.Field('senderDeviceId', 11, 'string'))
173
+ .add(new protobuf.Field('plaintextPayload', 12, 'string'))
174
+ .add(new protobuf.Field('clientEditId', 13, 'string'));
175
+ const MessageDeleteFrameType = new protobuf.Type('MessageDeleteFrame')
176
+ .add(new protobuf.Field('type', 1, 'string'))
177
+ .add(new protobuf.Field('deleteId', 2, 'string'))
178
+ .add(new protobuf.Field('originalMessageId', 3, 'string'))
179
+ .add(new protobuf.Field('appId', 4, 'string'))
180
+ .add(new protobuf.Field('fromUserId', 5, 'string'))
181
+ .add(new protobuf.Field('toUserId', 6, 'string'))
182
+ .add(new protobuf.Field('timestamp', 7, 'int64'))
183
+ .add(new protobuf.Field('scope', 8, 'string'))
184
+ .add(new protobuf.Field('clientDeleteId', 9, 'string'));
144
185
  class ProtobufCodec {
145
186
  encodeEnvelope(value) {
146
187
  return EnvelopeType.encode(value).finish();
@@ -163,7 +204,24 @@ class ProtobufCodec {
163
204
  encodeGroupAck(value) {
164
205
  return GroupAckType.encode(value).finish();
165
206
  }
207
+ encodeMessageEditFrame(value) {
208
+ return MessageEditFrameType.encode(value).finish();
209
+ }
210
+ encodeMessageDeleteFrame(value) {
211
+ return MessageDeleteFrameType.encode(value).finish();
212
+ }
166
213
  decodeFrame(payload) {
214
+ // Edit and delete frames must be probed BEFORE Envelope, the type
215
+ // discriminator at field 1 ("MESSAGE_EDIT" / "MESSAGE_DELETE") would
216
+ // otherwise parse as Envelope.messageId and could be mis-routed.
217
+ const asEdit = this.tryDecodeMessageEditFrame(payload);
218
+ if (asEdit) {
219
+ return { kind: 'messageEdit', data: asEdit };
220
+ }
221
+ const asDelete = this.tryDecodeMessageDeleteFrame(payload);
222
+ if (asDelete) {
223
+ return { kind: 'messageDelete', data: asDelete };
224
+ }
167
225
  const asEnvelope = this.tryDecodeEnvelope(payload);
168
226
  if (asEnvelope) {
169
227
  return { kind: 'envelope', data: asEnvelope };
@@ -333,5 +391,45 @@ class ProtobufCodec {
333
391
  return null;
334
392
  }
335
393
  }
394
+ tryDecodeMessageEditFrame(payload) {
395
+ try {
396
+ const decoded = MessageEditFrameType.decode(payload);
397
+ if (decoded.type !== 'MESSAGE_EDIT') {
398
+ return null;
399
+ }
400
+ const result = {
401
+ ...decoded,
402
+ timestamp: Number(decoded.timestamp),
403
+ encryptedPayload: decoded.encryptedPayload && decoded.encryptedPayload.length > 0
404
+ ? new Uint8Array(decoded.encryptedPayload) : undefined,
405
+ };
406
+ if (decoded.devicePayloads && decoded.devicePayloads.length > 0) {
407
+ result.devicePayloads = decoded.devicePayloads.map(dp => ({
408
+ deviceId: dp.deviceId,
409
+ encryptedPayload: new Uint8Array(dp.encryptedPayload),
410
+ senderPublicKey: new Uint8Array(dp.senderPublicKey),
411
+ }));
412
+ }
413
+ return result;
414
+ }
415
+ catch {
416
+ return null;
417
+ }
418
+ }
419
+ tryDecodeMessageDeleteFrame(payload) {
420
+ try {
421
+ const decoded = MessageDeleteFrameType.decode(payload);
422
+ if (decoded.type !== 'MESSAGE_DELETE') {
423
+ return null;
424
+ }
425
+ return {
426
+ ...decoded,
427
+ timestamp: Number(decoded.timestamp),
428
+ };
429
+ }
430
+ catch {
431
+ return null;
432
+ }
433
+ }
336
434
  }
337
435
  exports.ProtobufCodec = ProtobufCodec;
package/dist/version.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * MINOR, additive feature (e.g. multi-device payloads, new call event type)
8
8
  * PATCH, bug-fix / perf improvement with no wire or API change
9
9
  */
10
- export declare const SDK_VERSION = "0.3.1";
10
+ export declare const SDK_VERSION = "0.4.0";
11
11
  /**
12
12
  * Binary encrypted-payload format version.
13
13
  * Included as the first byte of every encrypted payload so receivers can
@@ -19,4 +19,4 @@ export declare const PAYLOAD_FORMAT_VERSION = 1;
19
19
  * Increment when adding required proto fields or changing frame semantics.
20
20
  * The server advertises its supported range via GET /api/info.
21
21
  */
22
- export declare const PROTOCOL_VERSION = 2;
22
+ export declare const PROTOCOL_VERSION = 3;
package/dist/version.js CHANGED
@@ -10,7 +10,7 @@ exports.PROTOCOL_VERSION = exports.PAYLOAD_FORMAT_VERSION = exports.SDK_VERSION
10
10
  * MINOR, additive feature (e.g. multi-device payloads, new call event type)
11
11
  * PATCH, bug-fix / perf improvement with no wire or API change
12
12
  */
13
- exports.SDK_VERSION = '0.3.1';
13
+ exports.SDK_VERSION = '0.4.0';
14
14
  /**
15
15
  * Binary encrypted-payload format version.
16
16
  * Included as the first byte of every encrypted payload so receivers can
@@ -22,4 +22,4 @@ exports.PAYLOAD_FORMAT_VERSION = 1;
22
22
  * Increment when adding required proto fields or changing frame semantics.
23
23
  * The server advertises its supported range via GET /api/info.
24
24
  */
25
- exports.PROTOCOL_VERSION = 2;
25
+ exports.PROTOCOL_VERSION = 3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@droponair/sdk-js",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "DropOnAir SDK for end-to-end encrypted messaging",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",