@droponair/sdk-js 0.6.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 CHANGED
@@ -6,6 +6,65 @@ 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
+
32
+ ## [0.7.0], 2026-05-19
33
+
34
+ ### Added
35
+ - **Conference moderation and waiting room.** Host designated to the call
36
+ initiator and transferable mid-call; host can appoint co-hosts. Host and
37
+ co-host can mute and remove participants and admit / reject users from a
38
+ waiting room. If the host leaves without transferring, the server
39
+ auto-promotes the longest-joined remaining participant.
40
+ - New `client` methods:
41
+ - `transferHost(callId, groupId, newHostUserId)`
42
+ - `appointCoHost(callId, groupId, userId)` / `revokeCoHost(callId, groupId, userId)`
43
+ - `muteParticipant(callId, groupId, targetUserId)` (signaling hint; the
44
+ target SDK applies the mute locally)
45
+ - `removeParticipant(callId, groupId, targetUserId)` (server-enforced; the
46
+ target is dropped from the call session)
47
+ - `requestWaitingRoomEntry(callId, groupId)` (would-be joiner side)
48
+ - `admitFromWaitingRoom(callId, groupId, userId)` / `rejectFromWaitingRoom(callId, groupId, userId)`
49
+ - New `GroupCallEventType` values: `'GROUP_CALL_HOST_TRANSFER'`,
50
+ `'GROUP_CALL_COHOST_APPOINT'`, `'GROUP_CALL_COHOST_REVOKE'`,
51
+ `'GROUP_CALL_ROLE_CHANGED'`, `'GROUP_CALL_MUTE_PARTICIPANT'`,
52
+ `'GROUP_CALL_REMOVE_PARTICIPANT'`, `'GROUP_CALL_PARTICIPANT_REMOVED'`,
53
+ `'GROUP_CALL_WAITING_ROOM_REQUEST'`, `'GROUP_CALL_WAITING_ROOM_JOINED'`,
54
+ `'GROUP_CALL_WAITING_ROOM_ADMIT'`, `'GROUP_CALL_WAITING_ROOM_ADMITTED'`,
55
+ `'GROUP_CALL_WAITING_ROOM_REJECT'`, `'GROUP_CALL_WAITING_ROOM_REJECTED'`.
56
+ - `GET /api/info` now advertises `"moderation"` in the `features` array.
57
+
58
+ ### Notes
59
+ - Wire-level additive: legacy 0.6.x clients ignore the new signal type
60
+ strings (string discriminators on the existing `GroupCallFrame.type`).
61
+ No proto schema change, no `PROTOCOL_VERSION` bump.
62
+ - Availability depends on your plan. See the
63
+ [pricing page](https://www.droponair.com/pricing) and your dashboard
64
+ Subscription page for what's enabled on your app.
65
+
66
+ ---
67
+
9
68
  ## [0.6.0], 2026-05-18
10
69
 
11
70
  ### Added
package/README.md CHANGED
@@ -160,7 +160,7 @@ client.onMessage(async (msg) => {
160
160
  | `sendMessage(toUserId, text, { attachments })` | `Promise<{ messageId }>` | Send with attachments |
161
161
 
162
162
  - E2EE: a random AES-256-GCM file key encrypts the bytes; the file key is wrapped per recipient device using X25519 + HKDF (same model as message payloads). Server never sees the unwrapped file key.
163
- - Plan limits: `maxAttachmentSizeMb` per file + `maxAttachmentsPerMonth`. Enforced server-side before the presigned URL is issued.
163
+ - Availability and per-file / per-month limits depend on your plan and are enforced server-side before the presigned URL is issued. See the [pricing page](https://www.droponair.com/pricing) and your dashboard Subscription page for what's enabled on your app.
164
164
  - Upload URL TTL = 15 min. Download URL TTL = 5 min. Download authorization checks that the requester is the original sender or in the captured recipient list.
165
165
 
166
166
  ### Broadcast Channels
@@ -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 a group message |
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
@@ -252,7 +253,46 @@ client.onGroupCallEvent((evt) => {
252
253
 
253
254
  - The server enforces **one concurrent sharer per group call**. If another participant already holds the slot when you call `startGroupScreenShare`, the SDK delivers a `GROUP_CALL_SCREEN_SHARE_STOPPED` event with the current holder's userId in `payload`, so you can roll back the optimistic UI.
254
255
  - When a sharer leaves the call, the server broadcasts a STOPPED event to remaining participants before they see `PARTICIPANT_LEFT`.
255
- - Plan gate: `screen_sharing` is on `PRO`/`GROWTH`/`PAYG`/`ENTERPRISE`, off on `FREE`. Read `subscription.featuresEnabled.screen_sharing` on your backend to decide whether to expose the share button.
256
+ - Availability depends on your plan. See the [pricing page](https://www.droponair.com/pricing) and your dashboard Subscription page for what's enabled on your app.
257
+
258
+ ### Conference moderation and waiting room
259
+
260
+ Available since SDK `0.7.0`. The call initiator is the default host; host can transfer the role mid-call or appoint co-hosts. Host and co-host can mute, remove, and gate joiners through a waiting room. If the host leaves without transferring, the server auto-promotes the longest-joined remaining participant and broadcasts `GROUP_CALL_ROLE_CHANGED`.
261
+
262
+ ```typescript
263
+ // Host actions
264
+ client.transferHost(callId, groupId, 'alice');
265
+ client.appointCoHost(callId, groupId, 'bob');
266
+ client.muteParticipant(callId, groupId, 'eve'); // signaling hint
267
+ client.removeParticipant(callId, groupId, 'mallory'); // server-enforced drop
268
+
269
+ // Waiting room
270
+ client.requestWaitingRoomEntry(callId, groupId); // would-be joiner side
271
+ client.admitFromWaitingRoom(callId, groupId, 'newbie'); // host / co-host
272
+ client.rejectFromWaitingRoom(callId, groupId, 'troll');
273
+
274
+ client.onGroupCallEvent((evt) => {
275
+ if (evt.type === 'GROUP_CALL_ROLE_CHANGED') updateHostUi(evt.payload); // {"userId":"...","role":"HOST|CO_HOST|PARTICIPANT"}
276
+ if (evt.type === 'GROUP_CALL_WAITING_ROOM_JOINED') showWaitingApprovalPrompt(evt.payload);
277
+ if (evt.type === 'GROUP_CALL_WAITING_ROOM_ADMITTED') joinTheCall(evt.callId, evt.groupId);
278
+ if (evt.type === 'GROUP_CALL_MUTE_PARTICIPANT') muteMyMicLocally();
279
+ if (evt.type === 'GROUP_CALL_PARTICIPANT_REMOVED') exitCallUi();
280
+ });
281
+ ```
282
+
283
+ | Method | Returns | Description |
284
+ |--------|---------|-------------|
285
+ | `transferHost(callId, groupId, newHostUserId)` | `void` | Host only |
286
+ | `appointCoHost(callId, groupId, userId)` | `void` | Host only |
287
+ | `revokeCoHost(callId, groupId, userId)` | `void` | Host only |
288
+ | `muteParticipant(callId, groupId, targetUserId)` | `void` | Host / co-host. Signaling hint - the target SDK mutes the local mic. |
289
+ | `removeParticipant(callId, groupId, targetUserId)` | `void` | Host / co-host. Server drops the target from the call session. |
290
+ | `requestWaitingRoomEntry(callId, groupId)` | `void` | Would-be joiner |
291
+ | `admitFromWaitingRoom(callId, groupId, userId)` | `void` | Host / co-host |
292
+ | `rejectFromWaitingRoom(callId, groupId, userId)` | `void` | Host / co-host |
293
+
294
+ - Mute is intentionally a signaling hint: WebRTC media tracks can't be muted from outside the producing client. Remove is server-enforced - the target SDK receives `GROUP_CALL_PARTICIPANT_REMOVED` and the others receive `PARTICIPANT_LEFT`.
295
+ - Availability depends on your plan. See the [pricing page](https://www.droponair.com/pricing) and your dashboard Subscription page for what's enabled on your app.
256
296
 
257
297
  ## Security
258
298
 
@@ -21,8 +21,8 @@ interface AttachmentClientDeps {
21
21
  logError: (stage: string, data?: Record<string, unknown>) => void;
22
22
  }
23
23
  /**
24
- * SDK-side coordinator for phase1/attachments. Handles:
25
- * - createUploadSession: reserves an attachmentId + presigned PUT URL via sdk-be.
24
+ * SDK-side coordinator for attachments. Handles:
25
+ * - createUploadSession: reserves an attachmentId + presigned PUT URL.
26
26
  * - uploadBytes: PUTs bytes directly to the customer's bucket.
27
27
  * - finalize: confirms upload + commits sha256.
28
28
  * - prepareAttachmentAndUpload: convenience wrapper that does upload session,
@@ -20,8 +20,8 @@ async function sha256Hex(bytes) {
20
20
  return hex.join('');
21
21
  }
22
22
  /**
23
- * SDK-side coordinator for phase1/attachments. Handles:
24
- * - createUploadSession: reserves an attachmentId + presigned PUT URL via sdk-be.
23
+ * SDK-side coordinator for attachments. Handles:
24
+ * - createUploadSession: reserves an attachmentId + presigned PUT URL.
25
25
  * - uploadBytes: PUTs bytes directly to the customer's bucket.
26
26
  * - finalize: confirms upload + commits sha256.
27
27
  * - prepareAttachmentAndUpload: convenience wrapper that does upload session,
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // DropOnAir never holds file bytes. The SDK uploads/downloads directly
6
6
  // against the customer's storage bucket via short-lived presigned URLs
7
- // minted by sdk-be. For E2EE messages a per-attachment AES-256-GCM file
7
+ // minted by the DropOnAir platform. For E2EE messages a per-attachment AES-256-GCM file
8
8
  // key is wrapped per recipient device using the same X25519 + HKDF path
9
9
  // used for message payloads.
10
10
  // ---------------------------------------------------------------------------
@@ -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
- sendGroupMessage(groupId: string, plaintext: string): Promise<{
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;
@@ -139,6 +164,14 @@ export declare class MessagingClient implements DropOnAirClient {
139
164
  startGroupScreenShare(callId: string, groupId: string, payload?: string): void;
140
165
  stopGroupScreenShare(callId: string, groupId: string, payload?: string): void;
141
166
  onGroupCallEvent(callback: GroupCallEventCallback): () => void;
167
+ transferHost(callId: string, groupId: string, newHostUserId: string): void;
168
+ appointCoHost(callId: string, groupId: string, userId: string): void;
169
+ revokeCoHost(callId: string, groupId: string, userId: string): void;
170
+ muteParticipant(callId: string, groupId: string, targetUserId: string): void;
171
+ removeParticipant(callId: string, groupId: string, targetUserId: string): void;
172
+ requestWaitingRoomEntry(callId: string, groupId: string): void;
173
+ admitFromWaitingRoom(callId: string, groupId: string, userId: string): void;
174
+ rejectFromWaitingRoom(callId: string, groupId: string, userId: string): void;
142
175
  private sendGroupCallFrame;
143
176
  /**
144
177
  * Initiate an outgoing call.
@@ -254,7 +254,7 @@ class MessagingClient {
254
254
  logError: (stage, data) => this.logError(stage, data),
255
255
  });
256
256
  }
257
- // Attachment API (phase1/attachments, PROTOCOL_VERSION 4+)
257
+ // Attachment API (PROTOCOL_VERSION 4+)
258
258
  async prepareAttachmentAndUpload(bytes, options) {
259
259
  return this.attachmentClient.prepareAttachmentAndUpload(bytes, options);
260
260
  }
@@ -679,7 +679,19 @@ class MessagingClient {
679
679
  // ---------------------------------------------------------------------------
680
680
  // Group messaging
681
681
  // ---------------------------------------------------------------------------
682
- async sendGroupMessage(groupId, plaintext) {
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
- // For now, group messages use cleartext (E2EE group fanout requires
699
- // fetching device keys for every member, which is a future enhancement).
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
- timestamp,
810
+ memberPayloads: [],
709
811
  };
710
812
  this.ws.send(this.codec.encodeGroupEnvelope(frame));
711
813
  return { messageId };
@@ -762,6 +864,81 @@ class MessagingClient {
762
864
  this.groupCallListeners.add(callback);
763
865
  return () => this.groupCallListeners.delete(callback);
764
866
  }
867
+ // Group call moderation + waiting room. The server validates host/co-host
868
+ // authority and the `featuresEnabled.moderation` plan flag; on denial it
869
+ // emits a LIMIT_REACHED event with reason="MODERATION_NOT_ENABLED".
870
+ transferHost(callId, groupId, newHostUserId) {
871
+ this.sendGroupCallFrame({
872
+ type: 'GROUP_CALL_HOST_TRANSFER',
873
+ callId,
874
+ groupId,
875
+ targetUserId: '',
876
+ payload: JSON.stringify({ userId: newHostUserId }),
877
+ });
878
+ }
879
+ appointCoHost(callId, groupId, userId) {
880
+ this.sendGroupCallFrame({
881
+ type: 'GROUP_CALL_COHOST_APPOINT',
882
+ callId,
883
+ groupId,
884
+ targetUserId: '',
885
+ payload: JSON.stringify({ userId }),
886
+ });
887
+ }
888
+ revokeCoHost(callId, groupId, userId) {
889
+ this.sendGroupCallFrame({
890
+ type: 'GROUP_CALL_COHOST_REVOKE',
891
+ callId,
892
+ groupId,
893
+ targetUserId: '',
894
+ payload: JSON.stringify({ userId }),
895
+ });
896
+ }
897
+ muteParticipant(callId, groupId, targetUserId) {
898
+ this.sendGroupCallFrame({
899
+ type: 'GROUP_CALL_MUTE_PARTICIPANT',
900
+ callId,
901
+ groupId,
902
+ targetUserId: '',
903
+ payload: JSON.stringify({ targetUserId }),
904
+ });
905
+ }
906
+ removeParticipant(callId, groupId, targetUserId) {
907
+ this.sendGroupCallFrame({
908
+ type: 'GROUP_CALL_REMOVE_PARTICIPANT',
909
+ callId,
910
+ groupId,
911
+ targetUserId: '',
912
+ payload: JSON.stringify({ targetUserId }),
913
+ });
914
+ }
915
+ requestWaitingRoomEntry(callId, groupId) {
916
+ this.sendGroupCallFrame({
917
+ type: 'GROUP_CALL_WAITING_ROOM_REQUEST',
918
+ callId,
919
+ groupId,
920
+ targetUserId: '',
921
+ payload: '',
922
+ });
923
+ }
924
+ admitFromWaitingRoom(callId, groupId, userId) {
925
+ this.sendGroupCallFrame({
926
+ type: 'GROUP_CALL_WAITING_ROOM_ADMIT',
927
+ callId,
928
+ groupId,
929
+ targetUserId: '',
930
+ payload: JSON.stringify({ userId }),
931
+ });
932
+ }
933
+ rejectFromWaitingRoom(callId, groupId, userId) {
934
+ this.sendGroupCallFrame({
935
+ type: 'GROUP_CALL_WAITING_ROOM_REJECT',
936
+ callId,
937
+ groupId,
938
+ targetUserId: '',
939
+ payload: JSON.stringify({ userId }),
940
+ });
941
+ }
765
942
  sendGroupCallFrame(frame) {
766
943
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
767
944
  throw new Error('DropOnAir websocket is not connected');
@@ -11,7 +11,7 @@ export interface DecryptedMessage {
11
11
  timestamp: number;
12
12
  plaintext: string;
13
13
  /**
14
- * Attachment pointers (phase1/attachments, PROTOCOL_VERSION 4+).
14
+ * Attachment pointers (PROTOCOL_VERSION 4+).
15
15
  * Empty/undefined for messages with no attachments. To fetch and decrypt the
16
16
  * bytes, call {@code client.downloadAttachment(ref)}.
17
17
  */
@@ -92,7 +92,7 @@ export interface DecryptedGroupMessage {
92
92
  plaintext: string;
93
93
  }
94
94
  export type GroupMessageCallback = (message: DecryptedGroupMessage) => void;
95
- export type GroupCallEventType = 'GROUP_CALL_INVITE' | 'GROUP_CALL_RINGING' | 'GROUP_CALL_JOIN' | 'GROUP_CALL_LEAVE' | 'GROUP_CALL_END' | 'GROUP_CALL_ENDED' | 'GROUP_CALL_PARTICIPANT_JOINED' | 'GROUP_CALL_PARTICIPANT_LEFT' | 'GROUP_CALL_ALREADY_ACTIVE' | 'GROUP_CALL_SDP_OFFER' | 'GROUP_CALL_SDP_ANSWER' | 'GROUP_CALL_ICE_CANDIDATE' | 'GROUP_CALL_SCREEN_SHARE_STARTED' | 'GROUP_CALL_SCREEN_SHARE_STOPPED';
95
+ export type GroupCallEventType = 'GROUP_CALL_INVITE' | 'GROUP_CALL_RINGING' | 'GROUP_CALL_JOIN' | 'GROUP_CALL_LEAVE' | 'GROUP_CALL_END' | 'GROUP_CALL_ENDED' | 'GROUP_CALL_PARTICIPANT_JOINED' | 'GROUP_CALL_PARTICIPANT_LEFT' | 'GROUP_CALL_PARTICIPANT_REMOVED' | 'GROUP_CALL_ALREADY_ACTIVE' | 'GROUP_CALL_SDP_OFFER' | 'GROUP_CALL_SDP_ANSWER' | 'GROUP_CALL_ICE_CANDIDATE' | 'GROUP_CALL_SCREEN_SHARE_STARTED' | 'GROUP_CALL_SCREEN_SHARE_STOPPED' | 'GROUP_CALL_HOST_TRANSFER' | 'GROUP_CALL_COHOST_APPOINT' | 'GROUP_CALL_COHOST_REVOKE' | 'GROUP_CALL_ROLE_CHANGED' | 'GROUP_CALL_MUTE_PARTICIPANT' | 'GROUP_CALL_REMOVE_PARTICIPANT' | 'GROUP_CALL_WAITING_ROOM_REQUEST' | 'GROUP_CALL_WAITING_ROOM_JOINED' | 'GROUP_CALL_WAITING_ROOM_ADMIT' | 'GROUP_CALL_WAITING_ROOM_ADMITTED' | 'GROUP_CALL_WAITING_ROOM_REJECT' | 'GROUP_CALL_WAITING_ROOM_REJECTED';
96
96
  export interface GroupCallEvent {
97
97
  type: GroupCallEventType | string;
98
98
  callId: string;
@@ -214,7 +214,7 @@ export interface DropOnAirClient {
214
214
  sendCallSignal(type: 'CALL_SDP_OFFER' | 'CALL_SDP_ANSWER' | 'CALL_ICE_CANDIDATE', callId: string, payload: string): void;
215
215
  /**
216
216
  * Signal that this user started sharing their screen in a 1:1 call
217
- * (phase1/screen-sharing, PROTOCOL_VERSION 4+). The actual screen MediaStreamTrack
217
+ * (PROTOCOL_VERSION 4+). The actual screen MediaStreamTrack
218
218
  * is added to the existing peer connection by the application; the SDK only
219
219
  * broadcasts the start/stop notification so the remote SDK can update its UI.
220
220
  * Optional `payload` (JSON string) can carry track-id or app-specific metadata.
@@ -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
- /** Send a cleartext message to a group. */
242
- sendGroupMessage(groupId: string, plaintext: string): Promise<{
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. */
@@ -266,4 +281,20 @@ export interface DropOnAirClient {
266
281
  stopGroupScreenShare(callId: string, groupId: string, payload?: string): void;
267
282
  /** Register a listener for group call events. Returns an unsubscribe function. */
268
283
  onGroupCallEvent(callback: GroupCallEventCallback): () => void;
284
+ /** Transfer host to another participant (current host only). */
285
+ transferHost(callId: string, groupId: string, newHostUserId: string): void;
286
+ /** Appoint a participant as co-host (host only). */
287
+ appointCoHost(callId: string, groupId: string, userId: string): void;
288
+ /** Revoke a participant's co-host status (host only). */
289
+ revokeCoHost(callId: string, groupId: string, userId: string): void;
290
+ /** Mute a participant's mic (host / co-host). Signaling hint; the target SDK applies it locally. */
291
+ muteParticipant(callId: string, groupId: string, targetUserId: string): void;
292
+ /** Remove a participant from the call (host / co-host). Server-enforced. */
293
+ removeParticipant(callId: string, groupId: string, targetUserId: string): void;
294
+ /** Request entry into a call's waiting room. Sent by would-be joiners; host receives WAITING_ROOM_JOINED. */
295
+ requestWaitingRoomEntry(callId: string, groupId: string): void;
296
+ /** Admit a user from the waiting room (host / co-host). */
297
+ admitFromWaitingRoom(callId: string, groupId: string, userId: string): void;
298
+ /** Reject a user waiting to join (host / co-host). */
299
+ rejectFromWaitingRoom(callId: string, groupId: string, userId: string): void;
269
300
  }
@@ -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
- timestamp: number;
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
- senderDeviceId: string;
98
- devicePayloads: WireDeviceEncryptedPayload[];
100
+ timestamp: number;
99
101
  encryptionType: number;
100
102
  plaintextPayload?: string;
101
- timestamp: number;
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('groupId', 2, 'string'))
135
- .add(new protobuf.Field('fromUserId', 3, 'string'))
136
- .add(new protobuf.Field('senderDeviceId', 4, 'string'))
137
- .add(new protobuf.Field('memberPayloads', 5, 'GroupMemberPayload', 'repeated'))
138
- .add(new protobuf.Field('encryptionType', 6, 'EncryptionType'))
139
- .add(new protobuf.Field('plaintextPayload', 7, 'string'))
140
- .add(new protobuf.Field('timestamp', 8, 'int64'));
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('senderDeviceId', 4, 'string'))
153
- .add(new protobuf.Field('devicePayloads', 5, 'DeviceEncryptedPayload', 'repeated'))
154
- .add(new protobuf.Field('encryptionType', 6, 'EncryptionType'))
155
- .add(new protobuf.Field('plaintextPayload', 7, 'string'))
156
- .add(new protobuf.Field('timestamp', 8, 'int64'));
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.6.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.6.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@droponair/sdk-js",
3
- "version": "0.6.0",
3
+ "version": "0.8.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",