@droponair/sdk-js 0.7.0 → 0.8.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/README.md +2 -1
- package/dist/core/messaging-client.d.ts +26 -1
- package/dist/core/messaging-client.js +107 -5
- package/dist/core/types.d.ts +17 -2
- package/dist/transport/protobuf-codec.d.ts +9 -5
- package/dist/transport/protobuf-codec.js +18 -15
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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.8.0], 2026-05-19
|
|
10
|
+
|
|
11
|
+
### BREAKING
|
|
12
|
+
|
|
13
|
+
- `sendGroupMessage(groupId, plaintext)` is **renamed to `sendCleartextGroupMessage(groupId, plaintext)`**. The previous JS method was cleartext-only despite the name, while the Android and iOS SDKs reserved the same name for an end-to-end encrypted send. This release aligns all three SDKs on the same naming.
|
|
14
|
+
- **Update existing JS callers**: replace `client.sendGroupMessage(groupId, text)` with `client.sendCleartextGroupMessage(groupId, text)`.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **End-to-end encrypted group send.** New `sendGroupMessage(groupId, plaintext, memberUserIds, options?)` that mirrors the Android and iOS SDKs: the SDK encrypts the plaintext per-recipient-per-device (sender-side fan-out) before sending. Pass `options.attachments` for E2EE group attachments.
|
|
19
|
+
- **Group attachments.** Both `sendGroupMessage` and the wire `GroupEnvelope` now carry an optional `attachments` field; previously this was 1:1 only on JS.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **JS `GroupEnvelope` / `GroupMessageNotification` protobuf field numbers** now match the source-of-truth `messaging.proto`. The previous JS schema had a field-number drift versus the Android/iOS generated proto bindings, which meant group sends from JS arrived at the server with values in the wrong fields (and were silently tolerated by proto3's unknown-field handling). With this release JS group send/receive is wire-correct.
|
|
24
|
+
|
|
25
|
+
### Notes
|
|
26
|
+
|
|
27
|
+
- Wire protocol unchanged (PROTOCOL_VERSION stays 4). The proto schema was already correct; only the JS hand-rolled codec was out of sync.
|
|
28
|
+
- `protoFieldNumbers` for the group types are now documented in `droponair-sdk-shared/test-vectors/protobuf-vectors.json` for cross-platform reference.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
9
32
|
## [0.7.0], 2026-05-19
|
|
10
33
|
|
|
11
34
|
### Added
|
package/README.md
CHANGED
|
@@ -182,7 +182,8 @@ client.onMessage(async (msg) => {
|
|
|
182
182
|
| `addGroupMembers(groupId, userIds)` | `Promise<GroupInfo>` | Add members (owner/admin) |
|
|
183
183
|
| `removeGroupMember(groupId, userId)` | `Promise<GroupInfo>` | Remove a member (owner/admin) |
|
|
184
184
|
| `deleteGroup(groupId)` | `Promise<void>` | Delete a group (owner only) |
|
|
185
|
-
| `sendGroupMessage(groupId, plaintext)` | `Promise<{ messageId }>` | Send
|
|
185
|
+
| `sendGroupMessage(groupId, plaintext, memberUserIds, options?)` | `Promise<{ messageId }>` | Send an end-to-end encrypted group message (sender-side per-recipient fan-out). `options.attachments` for E2EE group attachments. |
|
|
186
|
+
| `sendCleartextGroupMessage(groupId, plaintext)` | `Promise<{ messageId }>` | Send a plaintext (non-encrypted) group message; server fans out to every other member. |
|
|
186
187
|
| `onGroupMessage(callback)` | `() => void` | Listen for group messages |
|
|
187
188
|
|
|
188
189
|
### 1-to-1 Calls
|
|
@@ -127,7 +127,32 @@ export declare class MessagingClient implements DropOnAirClient {
|
|
|
127
127
|
addGroupMembers(groupId: string, userIds: string[]): Promise<GroupInfo>;
|
|
128
128
|
removeGroupMember(groupId: string, userId: string): Promise<GroupInfo>;
|
|
129
129
|
deleteGroup(groupId: string): Promise<void>;
|
|
130
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Send an end-to-end encrypted group message. Mirrors the Android and iOS
|
|
132
|
+
* SDKs: the SDK encrypts the plaintext per recipient device (sender-side
|
|
133
|
+
* fan-out), producing one `GroupMemberPayload` per member that the server
|
|
134
|
+
* then routes to that member's devices without ever seeing the plaintext.
|
|
135
|
+
*
|
|
136
|
+
* `memberUserIds` should be the current group membership minus the sender;
|
|
137
|
+
* fetch it from your backend / `getGroup()` before calling.
|
|
138
|
+
*
|
|
139
|
+
* For unencrypted group messaging (bots, public channels), use
|
|
140
|
+
* {@link sendCleartextGroupMessage} instead.
|
|
141
|
+
*/
|
|
142
|
+
sendGroupMessage(groupId: string, plaintext: string, memberUserIds: string[], options?: {
|
|
143
|
+
attachments?: AttachmentRef[];
|
|
144
|
+
}): Promise<{
|
|
145
|
+
messageId: string;
|
|
146
|
+
}>;
|
|
147
|
+
/**
|
|
148
|
+
* Send a plaintext (non-encrypted) group message. The server fans out the
|
|
149
|
+
* cleartext to every other member of the group; useful for bots and public
|
|
150
|
+
* channels where E2EE isn't required.
|
|
151
|
+
*
|
|
152
|
+
* For E2EE group messaging, use {@link sendGroupMessage} which encrypts
|
|
153
|
+
* per-recipient before sending.
|
|
154
|
+
*/
|
|
155
|
+
sendCleartextGroupMessage(groupId: string, plaintext: string): Promise<{
|
|
131
156
|
messageId: string;
|
|
132
157
|
}>;
|
|
133
158
|
onGroupMessage(callback: GroupMessageCallback): () => void;
|
|
@@ -679,7 +679,19 @@ class MessagingClient {
|
|
|
679
679
|
// ---------------------------------------------------------------------------
|
|
680
680
|
// Group messaging
|
|
681
681
|
// ---------------------------------------------------------------------------
|
|
682
|
-
|
|
682
|
+
/**
|
|
683
|
+
* Send an end-to-end encrypted group message. Mirrors the Android and iOS
|
|
684
|
+
* SDKs: the SDK encrypts the plaintext per recipient device (sender-side
|
|
685
|
+
* fan-out), producing one `GroupMemberPayload` per member that the server
|
|
686
|
+
* then routes to that member's devices without ever seeing the plaintext.
|
|
687
|
+
*
|
|
688
|
+
* `memberUserIds` should be the current group membership minus the sender;
|
|
689
|
+
* fetch it from your backend / `getGroup()` before calling.
|
|
690
|
+
*
|
|
691
|
+
* For unencrypted group messaging (bots, public channels), use
|
|
692
|
+
* {@link sendCleartextGroupMessage} instead.
|
|
693
|
+
*/
|
|
694
|
+
async sendGroupMessage(groupId, plaintext, memberUserIds, options) {
|
|
683
695
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
684
696
|
throw new Error('DropOnAir websocket is not connected');
|
|
685
697
|
}
|
|
@@ -692,20 +704,110 @@ class MessagingClient {
|
|
|
692
704
|
if (this.dropOnAirJwt && (0, bytes_1.isJwtExpired)(this.dropOnAirJwt)) {
|
|
693
705
|
throw new Error('JWT_EXPIRED: Secure messaging session is reconnecting; please retry in a moment');
|
|
694
706
|
}
|
|
707
|
+
if (!memberUserIds || memberUserIds.length === 0) {
|
|
708
|
+
throw new Error('sendGroupMessage requires at least one recipient userId in memberUserIds');
|
|
709
|
+
}
|
|
695
710
|
const myDeviceId = this.deviceId ?? await this.getOrCreateDeviceId();
|
|
696
711
|
const messageId = crypto.randomUUID();
|
|
712
|
+
const clientMessageId = messageId;
|
|
697
713
|
const timestamp = Date.now();
|
|
698
|
-
|
|
699
|
-
|
|
714
|
+
const myIdentity = await this.cryptoService.getOrCreateIdentity();
|
|
715
|
+
const myPublicKeyBytes = (0, bytes_1.fromBase64)(myIdentity.publicKey);
|
|
716
|
+
// Per-member sender-side fan-out: one GroupMemberPayload per member, each
|
|
717
|
+
// containing one DeviceEncryptedPayload per device the member has registered.
|
|
718
|
+
const memberPayloads = [];
|
|
719
|
+
for (const memberId of memberUserIds) {
|
|
720
|
+
if (memberId === this.currentUserId)
|
|
721
|
+
continue; // skip self
|
|
722
|
+
const deviceKeys = await this.fetchDeviceKeys(memberId);
|
|
723
|
+
const devicePayloads = [];
|
|
724
|
+
if (deviceKeys.length > 0) {
|
|
725
|
+
for (const dk of deviceKeys) {
|
|
726
|
+
const sharedKey = await this.cryptoService.deriveSharedSecret(memberId, dk.publicKey, this.currentUserId);
|
|
727
|
+
const encrypted = await this.cryptoService.encrypt(plaintext, sharedKey, {
|
|
728
|
+
messageId,
|
|
729
|
+
senderId: this.currentUserId,
|
|
730
|
+
recipientId: memberId,
|
|
731
|
+
timestamp,
|
|
732
|
+
});
|
|
733
|
+
devicePayloads.push({
|
|
734
|
+
deviceId: dk.deviceId,
|
|
735
|
+
encryptedPayload: encrypted,
|
|
736
|
+
senderPublicKey: myPublicKeyBytes,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
// Legacy fallback: peer reports no multi-device keys. Encrypt once
|
|
742
|
+
// against the single legacy public key under deviceId="default", which
|
|
743
|
+
// matches the Android SDK convention.
|
|
744
|
+
const sharedKey = await this.getPeerSharedKey(memberId);
|
|
745
|
+
const encrypted = await this.cryptoService.encrypt(plaintext, sharedKey, {
|
|
746
|
+
messageId,
|
|
747
|
+
senderId: this.currentUserId,
|
|
748
|
+
recipientId: memberId,
|
|
749
|
+
timestamp,
|
|
750
|
+
});
|
|
751
|
+
devicePayloads.push({
|
|
752
|
+
deviceId: 'default',
|
|
753
|
+
encryptedPayload: encrypted,
|
|
754
|
+
senderPublicKey: myPublicKeyBytes,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
memberPayloads.push({ userId: memberId, devicePayloads });
|
|
758
|
+
}
|
|
700
759
|
const frame = {
|
|
701
760
|
messageId,
|
|
761
|
+
appId: this.options.appId,
|
|
702
762
|
groupId,
|
|
703
763
|
fromUserId: this.currentUserId,
|
|
764
|
+
timestamp,
|
|
765
|
+
clientMessageId,
|
|
766
|
+
senderDeviceId: myDeviceId,
|
|
767
|
+
encryptionType: 0, // E2EE
|
|
768
|
+
memberPayloads,
|
|
769
|
+
};
|
|
770
|
+
if (options?.attachments && options.attachments.length > 0) {
|
|
771
|
+
frame.attachments = options.attachments.map(a => this.attachmentClient.toWire(a));
|
|
772
|
+
}
|
|
773
|
+
this.ws.send(this.codec.encodeGroupEnvelope(frame));
|
|
774
|
+
return { messageId };
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Send a plaintext (non-encrypted) group message. The server fans out the
|
|
778
|
+
* cleartext to every other member of the group; useful for bots and public
|
|
779
|
+
* channels where E2EE isn't required.
|
|
780
|
+
*
|
|
781
|
+
* For E2EE group messaging, use {@link sendGroupMessage} which encrypts
|
|
782
|
+
* per-recipient before sending.
|
|
783
|
+
*/
|
|
784
|
+
async sendCleartextGroupMessage(groupId, plaintext) {
|
|
785
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
786
|
+
throw new Error('DropOnAir websocket is not connected');
|
|
787
|
+
}
|
|
788
|
+
if (!this.currentUserId) {
|
|
789
|
+
throw new Error('Missing sender identity from DropOnAir JWT subject');
|
|
790
|
+
}
|
|
791
|
+
if (this.rateLimited) {
|
|
792
|
+
throw new Error('Sending is temporarily blocked by LIMIT_REACHED');
|
|
793
|
+
}
|
|
794
|
+
if (this.dropOnAirJwt && (0, bytes_1.isJwtExpired)(this.dropOnAirJwt)) {
|
|
795
|
+
throw new Error('JWT_EXPIRED: Secure messaging session is reconnecting; please retry in a moment');
|
|
796
|
+
}
|
|
797
|
+
const myDeviceId = this.deviceId ?? await this.getOrCreateDeviceId();
|
|
798
|
+
const messageId = crypto.randomUUID();
|
|
799
|
+
const timestamp = Date.now();
|
|
800
|
+
const frame = {
|
|
801
|
+
messageId,
|
|
802
|
+
appId: this.options.appId,
|
|
803
|
+
groupId,
|
|
804
|
+
fromUserId: this.currentUserId,
|
|
805
|
+
timestamp,
|
|
806
|
+
clientMessageId: messageId,
|
|
704
807
|
senderDeviceId: myDeviceId,
|
|
705
|
-
memberPayloads: [],
|
|
706
808
|
encryptionType: 1, // CLEARTEXT
|
|
707
809
|
plaintextPayload: plaintext,
|
|
708
|
-
|
|
810
|
+
memberPayloads: [],
|
|
709
811
|
};
|
|
710
812
|
this.ws.send(this.codec.encodeGroupEnvelope(frame));
|
|
711
813
|
return { messageId };
|
package/dist/core/types.d.ts
CHANGED
|
@@ -238,8 +238,23 @@ export interface DropOnAirClient {
|
|
|
238
238
|
removeGroupMember(groupId: string, userId: string): Promise<GroupInfo>;
|
|
239
239
|
/** Delete a group (requires OWNER role). */
|
|
240
240
|
deleteGroup(groupId: string): Promise<void>;
|
|
241
|
-
/**
|
|
242
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Send an end-to-end encrypted group message. The SDK encrypts the
|
|
243
|
+
* plaintext per recipient device (sender-side fan-out) before sending.
|
|
244
|
+
* `memberUserIds` should be the current group membership minus the sender;
|
|
245
|
+
* pass `options.attachments` for per-recipient encrypted attachment refs.
|
|
246
|
+
*/
|
|
247
|
+
sendGroupMessage(groupId: string, plaintext: string, memberUserIds: string[], options?: {
|
|
248
|
+
attachments?: import('../attachment/attachment-types').AttachmentRef[];
|
|
249
|
+
}): Promise<{
|
|
250
|
+
messageId: string;
|
|
251
|
+
}>;
|
|
252
|
+
/**
|
|
253
|
+
* Send a plaintext (non-encrypted) group message. The server fans out the
|
|
254
|
+
* cleartext to every other member. Use this for bots and public channels;
|
|
255
|
+
* use {@link sendGroupMessage} for E2EE group messaging.
|
|
256
|
+
*/
|
|
257
|
+
sendCleartextGroupMessage(groupId: string, plaintext: string): Promise<{
|
|
243
258
|
messageId: string;
|
|
244
259
|
}>;
|
|
245
260
|
/** Register a listener for incoming group messages. Returns an unsubscribe function. */
|
|
@@ -82,23 +82,27 @@ export interface WireGroupMemberPayload {
|
|
|
82
82
|
}
|
|
83
83
|
export interface WireGroupEnvelope {
|
|
84
84
|
messageId: string;
|
|
85
|
+
appId?: string;
|
|
85
86
|
groupId: string;
|
|
86
87
|
fromUserId: string;
|
|
88
|
+
timestamp: number;
|
|
89
|
+
clientMessageId?: string;
|
|
87
90
|
senderDeviceId: string;
|
|
88
|
-
memberPayloads: WireGroupMemberPayload[];
|
|
89
91
|
encryptionType: number;
|
|
90
92
|
plaintextPayload?: string;
|
|
91
|
-
|
|
93
|
+
memberPayloads: WireGroupMemberPayload[];
|
|
94
|
+
attachments?: WireAttachmentRef[];
|
|
92
95
|
}
|
|
93
96
|
export interface WireGroupMessageNotification {
|
|
94
97
|
messageId: string;
|
|
95
98
|
groupId: string;
|
|
96
99
|
fromUserId: string;
|
|
97
|
-
|
|
98
|
-
devicePayloads: WireDeviceEncryptedPayload[];
|
|
100
|
+
timestamp: number;
|
|
99
101
|
encryptionType: number;
|
|
100
102
|
plaintextPayload?: string;
|
|
101
|
-
|
|
103
|
+
devicePayloads: WireDeviceEncryptedPayload[];
|
|
104
|
+
senderDeviceId: string;
|
|
105
|
+
attachments?: WireAttachmentRef[];
|
|
102
106
|
}
|
|
103
107
|
export interface WireGroupAck {
|
|
104
108
|
messageId: string;
|
|
@@ -124,20 +124,22 @@ const GroupMemberPayloadType = new protobuf.Type('GroupMemberPayload')
|
|
|
124
124
|
.add(new protobuf.Field('userId', 1, 'string'))
|
|
125
125
|
.add(new protobuf.Field('devicePayloads', 2, 'DeviceEncryptedPayload', 'repeated'));
|
|
126
126
|
const GroupEnvelopeEncryptionTypeEnum = new protobuf.Enum('EncryptionType', { E2EE: 0, CLEARTEXT: 1 });
|
|
127
|
-
// NOTE: GroupEnvelope JS field numbers diverge from the proto contract (tracked
|
|
128
|
-
// under phase 2.3 "JS encrypted group send parity"). Attachments support for
|
|
129
|
-
// groups in JS will land with phase 2.3 / 2.4 once field numbers are aligned.
|
|
130
127
|
const GroupEnvelopeType = new protobuf.Type('GroupEnvelope')
|
|
131
128
|
.add(GroupEnvelopeEncryptionTypeEnum)
|
|
132
129
|
.add(GroupMemberPayloadType)
|
|
130
|
+
.add(AttachmentRefType)
|
|
133
131
|
.add(new protobuf.Field('messageId', 1, 'string'))
|
|
134
|
-
.add(new protobuf.Field('
|
|
135
|
-
.add(new protobuf.Field('
|
|
136
|
-
.add(new protobuf.Field('
|
|
137
|
-
.add(new protobuf.Field('
|
|
138
|
-
.add(new protobuf.Field('
|
|
139
|
-
.add(new protobuf.Field('
|
|
140
|
-
.add(new protobuf.Field('
|
|
132
|
+
.add(new protobuf.Field('appId', 2, 'string'))
|
|
133
|
+
.add(new protobuf.Field('groupId', 3, 'string'))
|
|
134
|
+
.add(new protobuf.Field('fromUserId', 4, 'string'))
|
|
135
|
+
.add(new protobuf.Field('timestamp', 5, 'int64'))
|
|
136
|
+
.add(new protobuf.Field('clientMessageId', 6, 'string'))
|
|
137
|
+
.add(new protobuf.Field('senderDeviceId', 7, 'string'))
|
|
138
|
+
.add(new protobuf.Field('encryptionType', 8, 'EncryptionType'))
|
|
139
|
+
.add(new protobuf.Field('plaintextPayload', 9, 'string'))
|
|
140
|
+
.add(new protobuf.Field('memberPayloads', 10, 'GroupMemberPayload', 'repeated'))
|
|
141
|
+
// field 11 reserved upstream
|
|
142
|
+
.add(new protobuf.Field('attachments', 12, 'AttachmentRef', 'repeated'));
|
|
141
143
|
const GroupNotifEncryptionTypeEnum = new protobuf.Enum('EncryptionType', { E2EE: 0, CLEARTEXT: 1 });
|
|
142
144
|
const GroupNotifDeviceType = new protobuf.Type('DeviceEncryptedPayload')
|
|
143
145
|
.add(new protobuf.Field('deviceId', 1, 'string'))
|
|
@@ -149,11 +151,12 @@ const GroupMessageNotificationType = new protobuf.Type('GroupMessageNotification
|
|
|
149
151
|
.add(new protobuf.Field('messageId', 1, 'string'))
|
|
150
152
|
.add(new protobuf.Field('groupId', 2, 'string'))
|
|
151
153
|
.add(new protobuf.Field('fromUserId', 3, 'string'))
|
|
152
|
-
.add(new protobuf.Field('
|
|
153
|
-
.add(new protobuf.Field('
|
|
154
|
-
.add(new protobuf.Field('
|
|
155
|
-
.add(new protobuf.Field('
|
|
156
|
-
.add(new protobuf.Field('
|
|
154
|
+
.add(new protobuf.Field('timestamp', 4, 'int64'))
|
|
155
|
+
.add(new protobuf.Field('encryptionType', 5, 'EncryptionType'))
|
|
156
|
+
.add(new protobuf.Field('plaintextPayload', 6, 'string'))
|
|
157
|
+
.add(new protobuf.Field('devicePayloads', 7, 'DeviceEncryptedPayload', 'repeated'))
|
|
158
|
+
.add(new protobuf.Field('senderDeviceId', 8, 'string'))
|
|
159
|
+
.add(new protobuf.Field('attachments', 9, 'AttachmentRef', 'repeated'));
|
|
157
160
|
const GroupAckType = new protobuf.Type('GroupAck')
|
|
158
161
|
.add(new protobuf.Field('messageId', 1, 'string'))
|
|
159
162
|
.add(new protobuf.Field('groupId', 2, 'string'))
|
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.8.0";
|
|
11
11
|
/**
|
|
12
12
|
* Binary encrypted-payload format version.
|
|
13
13
|
* Included as the first byte of every encrypted payload so receivers can
|
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.8.0';
|
|
14
14
|
/**
|
|
15
15
|
* Binary encrypted-payload format version.
|
|
16
16
|
* Included as the first byte of every encrypted payload so receivers can
|