@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 +23 -0
- package/dist/core/messaging-client.d.ts +16 -1
- package/dist/core/messaging-client.js +270 -1
- package/dist/core/types.d.ts +52 -0
- package/dist/index.d.ts +1 -1
- package/dist/transport/protobuf-codec.d.ts +42 -0
- package/dist/transport/protobuf-codec.js +98 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/package.json +1 -1
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
|
-
|
|
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', {
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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.
|
|
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 =
|
|
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.
|
|
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 =
|
|
25
|
+
exports.PROTOCOL_VERSION = 3;
|