@droponair/sdk-js 0.4.0 → 0.6.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,60 @@ This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.6.0], 2026-05-18
10
+
11
+ ### Added
12
+ - **Screen sharing signaling.** New client APIs and event types so apps can
13
+ coordinate the start/stop of a screen-share track on top of the existing
14
+ WebRTC call. The SDK only signals - capture (`getDisplayMedia()` on web) and
15
+ track management remain in the app layer.
16
+ - **1:1 calls:**
17
+ - `client.startScreenShare(callId, payload?)` and `client.stopScreenShare(callId, payload?)`.
18
+ - New `CallEventType` values: `'CALL_SCREEN_SHARE_STARTED'`, `'CALL_SCREEN_SHARE_STOPPED'`.
19
+ - **Group calls:**
20
+ - `client.startGroupScreenShare(callId, groupId, payload?)` and `client.stopGroupScreenShare(callId, groupId, payload?)`.
21
+ - New `GroupCallEventType` values: `'GROUP_CALL_SCREEN_SHARE_STARTED'`, `'GROUP_CALL_SCREEN_SHARE_STOPPED'`.
22
+ - Server enforces a single concurrent sharer per group call. If another
23
+ participant already holds the slot when you call `startGroupScreenShare`,
24
+ you'll receive a `GROUP_CALL_SCREEN_SHARE_STOPPED` event with the current
25
+ holder's userId in `payload`, so the app can roll back its optimistic UI.
26
+ - When a sharer leaves the call, the server broadcasts a STOPPED event to
27
+ remaining participants before they see `PARTICIPANT_LEFT`.
28
+ - `GET /api/info` now advertises `"screen_sharing"` in the `features` array.
29
+
30
+ ### Notes
31
+ - Wire-level additive: legacy 0.5.x clients ignore the new signal type strings
32
+ (string discriminators on existing `CallFrame.type` / `GroupCallFrame.type`).
33
+ No proto schema change, no `PROTOCOL_VERSION` bump.
34
+ - Screen content is opaque WebRTC media; the server never sees it. Use
35
+ `getDisplayMedia()` (web) / `MediaProjection` (Android) / `ReplayKit` (iOS)
36
+ in the app, add the track to the existing peer connection, then call
37
+ `startScreenShare(...)` to notify the peer.
38
+ - Plan gate: `screen_sharing` is enabled on PRO/GROWTH/PAYG/ENTERPRISE and
39
+ disabled on FREE. Check `subscription.featuresEnabled.screen_sharing` (server
40
+ side, on the customer's backend).
41
+
42
+ ---
43
+
44
+ ## [0.5.0], 2026-05-18
45
+
46
+ ### Added
47
+ - **Attachment pointers with customer-managed storage.** DropOnAir never holds file bytes; the SDK uploads/downloads directly against the customer's bucket via short-lived presigned URLs.
48
+ - **New high-level API:** `client.prepareAttachmentAndUpload(bytes, options)` encrypts (for E2EE), uploads to the customer's bucket, finalizes, and returns an `AttachmentRef` ready to drop into `sendMessage(toUserId, text, { attachments: [...] })`.
49
+ - **New low-level API:** `client.createUploadSession(options)`, `client.finalizeAttachment(id, sha256)`, `client.downloadAttachment(ref)`.
50
+ - **E2EE attachments:** per-attachment AES-256-GCM file key wrapped per recipient device using X25519 + HKDF, mirroring the per-device key model used for message payloads. Server never sees the unwrapped file key.
51
+ - **`DecryptedMessage.attachments`:** incoming messages now carry the optional `attachments: AttachmentRef[]` field.
52
+ - **PROTOCOL_VERSION = 4.** `features` now advertises `attachments` from `GET /api/info`.
53
+ - New public types: `AttachmentRef`, `DeviceWrappedKey`, `AttachmentEncryptionType`, `AttachmentConversationType`, `CreateUploadSessionOptions`, `UploadSession`, `PrepareAttachmentOptions`, `DownloadedAttachment`.
54
+
55
+ ### Notes
56
+ - Wire-level additive: legacy 0.4.x clients ignore the new `Envelope.attachments` field (proto3 forwards-compat). Existing apps continue to work unchanged.
57
+ - v1 storage adapters supported by the server: S3-compatible (S3, R2, MinIO, B2, Wasabi), Google Cloud Storage, Azure Blob.
58
+ - Plan limits: `maxAttachmentSizeMb` per file + `maxAttachmentsPerMonth`. FREE = attachments disabled. PRO = 25 MB / 1000 per month. GROWTH = 100 MB / 10000. PAYG = 250 MB / metered. ENTERPRISE = custom.
59
+ - Requires sdk-be `0.5.0` server.
60
+
61
+ ---
62
+
9
63
  ## [0.4.0], 2026-04-21
10
64
 
11
65
  ### Added
package/README.md CHANGED
@@ -45,6 +45,7 @@ await client.sendMessage('recipient-user-id', 'Hello!');
45
45
  ## Features
46
46
 
47
47
  - **E2EE Messaging** - X25519 key agreement, AES-256-GCM encryption, multi-device support
48
+ - **Message Edit & Delete** - Edit sent messages or tombstone them for everyone or only your own devices
48
49
  - **Cleartext Messaging** - Lightweight messages without E2EE overhead
49
50
  - **Broadcast Channels** - Pub/sub for announcements and notifications
50
51
  - **Group Messaging** - Server-managed groups with member roles
@@ -86,11 +87,82 @@ const client = await initialize(options);
86
87
  | Method | Returns | Description |
87
88
  |--------|---------|-------------|
88
89
  | `sendMessage(toUserId, plaintext)` | `Promise<{ messageId }>` | Send an encrypted message |
90
+ | `editMessage(originalMessageId, toUserId, newText)` | `Promise<{ editId }>` | Edit a previously sent encrypted message |
91
+ | `deleteMessage(originalMessageId, toUserId, scope)` | `Promise<{ deleteId }>` | Delete a previously sent 1:1 message. `scope` is `FOR_EVERYONE` or `FOR_ME` |
89
92
  | `sendCleartextMessage(toUserId, plaintext)` | `Promise<{ messageId }>` | Send a cleartext message (no E2EE) |
93
+ | `editCleartextMessage(originalMessageId, toUserId, newText)` | `Promise<{ editId }>` | Edit a previously sent cleartext message |
90
94
  | `onMessage(callback)` | `() => void` | Listen for incoming messages. Returns unsubscribe function |
95
+ | `onMessageEdit(callback)` | `() => void` | Listen for inbound edits to 1:1 messages |
96
+ | `onMessageDelete(callback)` | `() => void` | Listen for inbound delete tombstones for 1:1 messages |
91
97
  | `onEvent(callback)` | `() => void` | Listen for system events (CONNECTED, DELIVERED, ERROR, etc.) |
92
98
  | `ack(messageId)` | `Promise<void>` | Manually acknowledge a message |
93
99
 
100
+ ### Message Edit & Delete
101
+
102
+ Available since SDK `0.4.0` and requires sdk-be `0.4.0`.
103
+
104
+ ```typescript
105
+ // Edit an encrypted message
106
+ await client.editMessage(originalMessageId, 'recipient-user-id', 'Updated text');
107
+
108
+ // Edit a cleartext message
109
+ await client.editCleartextMessage(originalMessageId, 'recipient-user-id', 'Updated text');
110
+
111
+ // Delete for everyone
112
+ await client.deleteMessage(originalMessageId, 'recipient-user-id', 'FOR_EVERYONE');
113
+
114
+ // Delete only on the sender's own devices
115
+ await client.deleteMessage(originalMessageId, 'recipient-user-id', 'FOR_ME');
116
+
117
+ client.onMessageEdit((edit) => {
118
+ console.log('edited', edit.originalMessageId, edit.text);
119
+ });
120
+
121
+ client.onMessageDelete((del) => {
122
+ console.log('deleted', del.originalMessageId, del.scope);
123
+ });
124
+ ```
125
+
126
+ - E2EE edits are re-encrypted per recipient device; the relay never sees the new plaintext.
127
+ - `FOR_EVERYONE` notifies the recipient; `FOR_ME` only syncs the tombstone to the sender's other devices.
128
+ - Each edit and each `FOR_EVERYONE` delete counts as one `MESSAGE` usage record.
129
+
130
+ ### Attachments (Customer-Managed Storage)
131
+
132
+ Available since SDK `0.5.0` and requires sdk-be `0.5.0`. Configure your bucket once in the DropOnAir panel (S3-compatible, GCS, or Azure Blob). DropOnAir never holds file bytes - the SDK uploads/downloads directly against your bucket via short-lived presigned URLs.
133
+
134
+ ```typescript
135
+ // Convenience: encrypt + upload + finalize in one call, then send with the message.
136
+ const ref = await client.prepareAttachmentAndUpload(fileBytes, {
137
+ toUserId: 'recipient-user-id',
138
+ mimeType: 'image/jpeg',
139
+ encryptionType: 'E2EE', // or 'CLEARTEXT' for public files
140
+ });
141
+
142
+ await client.sendMessage('recipient-user-id', 'Here is the file', { attachments: [ref] });
143
+
144
+ // Recipient side: incoming DecryptedMessage carries optional attachments[]
145
+ client.onMessage(async (msg) => {
146
+ console.log(msg.plaintext);
147
+ for (const att of msg.attachments ?? []) {
148
+ const dl = await client.downloadAttachment(att);
149
+ // dl.bytes is Uint8Array, dl.mimeType / dl.sizeBytes / dl.sha256 are populated
150
+ }
151
+ });
152
+ ```
153
+
154
+ | Method | Returns | Description |
155
+ |--------|---------|-------------|
156
+ | `prepareAttachmentAndUpload(bytes, options)` | `Promise<AttachmentRef>` | Encrypt (E2EE), upload, finalize, return ref |
157
+ | `createUploadSession(options)` | `Promise<UploadSession>` | Low-level: presigned PUT URL only |
158
+ | `finalizeAttachment(attachmentId, sha256)` | `Promise<void>` | Low-level: commit integrity hash |
159
+ | `downloadAttachment(ref)` | `Promise<DownloadedAttachment>` | Get presigned URL + download + decrypt |
160
+ | `sendMessage(toUserId, text, { attachments })` | `Promise<{ messageId }>` | Send with attachments |
161
+
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.
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
+
94
166
  ### Broadcast Channels
95
167
 
96
168
  | Method | Returns | Description |
@@ -137,6 +209,51 @@ const client = await initialize(options);
137
209
  | `sendGroupCallSignal(type, callId, groupId, targetUserId, payload)` | `void` | Send SDP/ICE to a peer |
138
210
  | `onGroupCallEvent(callback)` | `() => void` | Listen for group call events |
139
211
 
212
+ ### Screen Sharing
213
+
214
+ Available since SDK `0.6.0`. The SDK only signals start/stop - capture (via the browser's `getDisplayMedia()`) and adding the resulting `MediaStreamTrack` to the existing peer connection are the app's responsibility.
215
+
216
+ ```typescript
217
+ // 1:1 call
218
+ async function shareMyScreen(callId: string, peerConnection: RTCPeerConnection) {
219
+ const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
220
+ const track = stream.getVideoTracks()[0];
221
+ peerConnection.addTrack(track, stream);
222
+ client.startScreenShare(callId);
223
+ track.onended = () => client.stopScreenShare(callId);
224
+ }
225
+
226
+ // Group call
227
+ async function shareMyScreenInGroup(callId: string, groupId: string, peerConnections: RTCPeerConnection[]) {
228
+ const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
229
+ const track = stream.getVideoTracks()[0];
230
+ peerConnections.forEach(pc => pc.addTrack(track, stream));
231
+ client.startGroupScreenShare(callId, groupId);
232
+ track.onended = () => client.stopGroupScreenShare(callId, groupId);
233
+ }
234
+
235
+ client.onCallEvent((evt) => {
236
+ if (evt.type === 'CALL_SCREEN_SHARE_STARTED') showShareIndicator(evt.callId);
237
+ if (evt.type === 'CALL_SCREEN_SHARE_STOPPED') hideShareIndicator(evt.callId);
238
+ });
239
+
240
+ client.onGroupCallEvent((evt) => {
241
+ if (evt.type === 'GROUP_CALL_SCREEN_SHARE_STARTED') showShareIndicator(evt.callId, evt.payload); // payload = sharer userId
242
+ if (evt.type === 'GROUP_CALL_SCREEN_SHARE_STOPPED') hideShareIndicator(evt.callId);
243
+ });
244
+ ```
245
+
246
+ | Method | Returns | Description |
247
+ |--------|---------|-------------|
248
+ | `startScreenShare(callId, payload?)` | `void` | Signal start in a 1:1 call |
249
+ | `stopScreenShare(callId, payload?)` | `void` | Signal stop in a 1:1 call |
250
+ | `startGroupScreenShare(callId, groupId, payload?)` | `void` | Signal start in a group call |
251
+ | `stopGroupScreenShare(callId, groupId, payload?)` | `void` | Signal stop in a group call |
252
+
253
+ - 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
+ - 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
+
140
257
  ## Security
141
258
 
142
259
  - **X25519 ECDH** key agreement for shared secrets
@@ -0,0 +1,74 @@
1
+ import { CryptoService } from '../crypto/crypto-service';
2
+ import { WireAttachmentRef } from '../transport/protobuf-codec';
3
+ interface DeviceKeyInfo {
4
+ deviceId: string;
5
+ publicKey: string;
6
+ }
7
+ import { AttachmentRef, CreateUploadSessionOptions, DownloadedAttachment, PrepareAttachmentOptions, UploadSession } from './attachment-types';
8
+ interface AttachmentClientDeps {
9
+ httpUrl: string;
10
+ fetchFn: typeof fetch;
11
+ getValidDropOnAirJwt: () => Promise<string>;
12
+ fetchDeviceKeys: (userId: string) => Promise<DeviceKeyInfo[]>;
13
+ fetchMyOtherDeviceKeys: (myDeviceId: string) => Promise<DeviceKeyInfo[]>;
14
+ getCurrentUserId: () => string;
15
+ getCurrentDeviceId: () => Promise<string>;
16
+ getKeyStorage: () => {
17
+ get(k: string): Promise<string | null>;
18
+ };
19
+ cryptoService: CryptoService;
20
+ log: (stage: string, data?: Record<string, unknown>) => void;
21
+ logError: (stage: string, data?: Record<string, unknown>) => void;
22
+ }
23
+ /**
24
+ * SDK-side coordinator for phase1/attachments. Handles:
25
+ * - createUploadSession: reserves an attachmentId + presigned PUT URL via sdk-be.
26
+ * - uploadBytes: PUTs bytes directly to the customer's bucket.
27
+ * - finalize: confirms upload + commits sha256.
28
+ * - prepareAttachmentAndUpload: convenience wrapper that does upload session,
29
+ * optional E2EE encryption + per-device key wrap, PUT, finalize, and returns
30
+ * a fully-populated AttachmentRef ready to drop into sendMessage.
31
+ * - downloadAttachment: fetches presigned GET, downloads, decrypts if E2EE.
32
+ *
33
+ * DropOnAir never holds file bytes - the SDK transfers bytes directly between
34
+ * the client and the customer's storage bucket.
35
+ */
36
+ export declare class AttachmentClient {
37
+ private readonly deps;
38
+ constructor(deps: AttachmentClientDeps);
39
+ createUploadSession(opts: CreateUploadSessionOptions): Promise<UploadSession>;
40
+ finalize(attachmentId: string, sha256: string): Promise<void>;
41
+ /**
42
+ * Convenience: encrypts bytes if E2EE, uploads to the customer's bucket,
43
+ * finalizes, and returns a fully-formed AttachmentRef for sendMessage.
44
+ */
45
+ prepareAttachmentAndUpload(input: Uint8Array, opts: PrepareAttachmentOptions): Promise<AttachmentRef>;
46
+ getDownloadUrl(attachmentId: string): Promise<{
47
+ url: string;
48
+ method: string;
49
+ headers: Record<string, string>;
50
+ mimeType: string;
51
+ sizeBytes: number;
52
+ sha256: string;
53
+ }>;
54
+ /**
55
+ * Downloads + decrypts an attachment. For E2EE, finds the wrappedKey for the
56
+ * current device, unwraps it, then decrypts the stored bytes.
57
+ */
58
+ downloadAttachment(ref: AttachmentRef): Promise<DownloadedAttachment>;
59
+ /** Convert public AttachmentRef into the wire-format type for proto encoding. */
60
+ toWire(ref: AttachmentRef): WireAttachmentRef;
61
+ /** Convert a wire AttachmentRef (from a received message) into the public type. */
62
+ static fromWire(wire: WireAttachmentRef): AttachmentRef;
63
+ private uploadBytes;
64
+ private wrapKeyForRecipients;
65
+ private unwrapKey;
66
+ /**
67
+ * HKDF for the attachment file-key wrap. Sender and recipient both compute
68
+ * the same X25519 shared secret (by symmetry of curve25519) and derive the
69
+ * same AES-GCM key via this HKDF. Distinct info/salt from the message-payload
70
+ * HKDF in CryptoService to avoid key reuse across protocols.
71
+ */
72
+ private deriveAttachmentWrapKey;
73
+ }
74
+ export {};
@@ -0,0 +1,323 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.AttachmentClient = void 0;
7
+ const tweetnacl_1 = __importDefault(require("tweetnacl"));
8
+ const bytes_1 = require("../core/bytes");
9
+ const STORAGE_PRIVATE = 'droponair.identity.privateKey.v1';
10
+ function asBufferSource(data) {
11
+ return new Uint8Array(data);
12
+ }
13
+ async function sha256Hex(bytes) {
14
+ const digest = await crypto.subtle.digest('SHA-256', asBufferSource(bytes));
15
+ const hex = [];
16
+ const view = new Uint8Array(digest);
17
+ for (let i = 0; i < view.length; i += 1) {
18
+ hex.push(view[i].toString(16).padStart(2, '0'));
19
+ }
20
+ return hex.join('');
21
+ }
22
+ /**
23
+ * SDK-side coordinator for phase1/attachments. Handles:
24
+ * - createUploadSession: reserves an attachmentId + presigned PUT URL via sdk-be.
25
+ * - uploadBytes: PUTs bytes directly to the customer's bucket.
26
+ * - finalize: confirms upload + commits sha256.
27
+ * - prepareAttachmentAndUpload: convenience wrapper that does upload session,
28
+ * optional E2EE encryption + per-device key wrap, PUT, finalize, and returns
29
+ * a fully-populated AttachmentRef ready to drop into sendMessage.
30
+ * - downloadAttachment: fetches presigned GET, downloads, decrypts if E2EE.
31
+ *
32
+ * DropOnAir never holds file bytes - the SDK transfers bytes directly between
33
+ * the client and the customer's storage bucket.
34
+ */
35
+ class AttachmentClient {
36
+ constructor(deps) {
37
+ this.deps = deps;
38
+ }
39
+ async createUploadSession(opts) {
40
+ const jwt = await this.deps.getValidDropOnAirJwt();
41
+ const body = {
42
+ mimeType: opts.mimeType ?? '',
43
+ sizeBytes: opts.sizeBytes,
44
+ encryptionType: opts.encryptionType ?? 'E2EE',
45
+ conversationType: opts.conversationType ?? 'ONE_TO_ONE',
46
+ conversationId: opts.conversationId ?? '',
47
+ recipientUserIds: opts.recipientUserIds ?? [],
48
+ };
49
+ const resp = await this.deps.fetchFn(`${this.deps.httpUrl}/v1/attachments/upload-session`, {
50
+ method: 'POST',
51
+ headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
52
+ body: JSON.stringify(body),
53
+ });
54
+ if (!resp.ok) {
55
+ const text = await resp.text().catch(() => '');
56
+ this.deps.logError('attachment_upload_session_failed', { status: resp.status, body: text.slice(0, 400) });
57
+ throw new Error(`Failed to create upload session (HTTP ${resp.status}): ${text}`);
58
+ }
59
+ return (await resp.json());
60
+ }
61
+ async finalize(attachmentId, sha256) {
62
+ const jwt = await this.deps.getValidDropOnAirJwt();
63
+ const resp = await this.deps.fetchFn(`${this.deps.httpUrl}/v1/attachments/${encodeURIComponent(attachmentId)}/finalize`, {
64
+ method: 'POST',
65
+ headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
66
+ body: JSON.stringify({ sha256 }),
67
+ });
68
+ if (!resp.ok) {
69
+ const text = await resp.text().catch(() => '');
70
+ throw new Error(`Failed to finalize attachment (HTTP ${resp.status}): ${text}`);
71
+ }
72
+ }
73
+ /**
74
+ * Convenience: encrypts bytes if E2EE, uploads to the customer's bucket,
75
+ * finalizes, and returns a fully-formed AttachmentRef for sendMessage.
76
+ */
77
+ async prepareAttachmentAndUpload(input, opts) {
78
+ if (!input || input.length === 0) {
79
+ throw new Error('attachment bytes are empty');
80
+ }
81
+ const encryptionType = opts.encryptionType ?? 'E2EE';
82
+ const conversationType = opts.groupId ? 'GROUP' : 'ONE_TO_ONE';
83
+ if (conversationType === 'ONE_TO_ONE' && !opts.toUserId) {
84
+ throw new Error('toUserId is required for one-to-one attachments');
85
+ }
86
+ if (conversationType === 'GROUP' && (!opts.recipientUserIds || opts.recipientUserIds.length === 0)) {
87
+ throw new Error('recipientUserIds is required for group attachments');
88
+ }
89
+ let storedBytes;
90
+ let fileKey = null;
91
+ let fileNonce = null;
92
+ if (encryptionType === 'E2EE') {
93
+ fileKey = (0, bytes_1.randomBytes)(32);
94
+ fileNonce = (0, bytes_1.randomBytes)(12);
95
+ const aesKey = await crypto.subtle.importKey('raw', asBufferSource(fileKey), { name: 'AES-GCM' }, false, ['encrypt']);
96
+ const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: asBufferSource(fileNonce), tagLength: 128 }, aesKey, asBufferSource(input));
97
+ // Pack nonce || ciphertext so download side can split without separate transport.
98
+ const ct = new Uint8Array(cipher);
99
+ storedBytes = new Uint8Array(fileNonce.length + ct.length);
100
+ storedBytes.set(fileNonce, 0);
101
+ storedBytes.set(ct, fileNonce.length);
102
+ }
103
+ else {
104
+ storedBytes = input;
105
+ }
106
+ const sha256 = await sha256Hex(storedBytes);
107
+ // Reserve upload session up-front so the server can validate plan limits
108
+ // before we burn bandwidth on a bucket PUT we know would fail later.
109
+ const recipientUserIds = opts.recipientUserIds ?? (opts.toUserId ? [opts.toUserId] : []);
110
+ const session = await this.createUploadSession({
111
+ mimeType: opts.mimeType ?? '',
112
+ sizeBytes: storedBytes.length,
113
+ encryptionType,
114
+ conversationType,
115
+ conversationId: opts.groupId,
116
+ recipientUserIds,
117
+ });
118
+ await this.uploadBytes(session, storedBytes, opts.onUploadProgress);
119
+ await this.finalize(session.attachmentId, sha256);
120
+ let wrappedKeys = [];
121
+ if (encryptionType === 'E2EE' && fileKey) {
122
+ wrappedKeys = await this.wrapKeyForRecipients(fileKey, opts);
123
+ }
124
+ return {
125
+ attachmentId: session.attachmentId,
126
+ storageHint: session.storageHint,
127
+ mimeType: opts.mimeType ?? '',
128
+ sizeBytes: storedBytes.length,
129
+ sha256,
130
+ encryptionType,
131
+ wrappedKeys,
132
+ };
133
+ }
134
+ async getDownloadUrl(attachmentId) {
135
+ const jwt = await this.deps.getValidDropOnAirJwt();
136
+ const resp = await this.deps.fetchFn(`${this.deps.httpUrl}/v1/attachments/${encodeURIComponent(attachmentId)}/download-url`, {
137
+ method: 'GET',
138
+ headers: { Authorization: `Bearer ${jwt}` },
139
+ });
140
+ if (!resp.ok) {
141
+ const text = await resp.text().catch(() => '');
142
+ throw new Error(`Failed to get download URL (HTTP ${resp.status}): ${text}`);
143
+ }
144
+ const body = await resp.json();
145
+ return {
146
+ url: body.downloadUrl,
147
+ method: body.downloadMethod,
148
+ headers: body.downloadHeaders ?? {},
149
+ mimeType: body.mimeType,
150
+ sizeBytes: body.sizeBytes,
151
+ sha256: body.sha256,
152
+ };
153
+ }
154
+ /**
155
+ * Downloads + decrypts an attachment. For E2EE, finds the wrappedKey for the
156
+ * current device, unwraps it, then decrypts the stored bytes.
157
+ */
158
+ async downloadAttachment(ref) {
159
+ const info = await this.getDownloadUrl(ref.attachmentId);
160
+ const resp = await this.deps.fetchFn(info.url, { method: info.method, headers: info.headers });
161
+ if (!resp.ok) {
162
+ const text = await resp.text().catch(() => '');
163
+ throw new Error(`Failed to download attachment bytes (HTTP ${resp.status}): ${text}`);
164
+ }
165
+ const arrayBuf = await resp.arrayBuffer();
166
+ let stored = new Uint8Array(arrayBuf);
167
+ if (ref.encryptionType === 'CLEARTEXT') {
168
+ return { attachmentId: ref.attachmentId, mimeType: info.mimeType, sizeBytes: info.sizeBytes, sha256: info.sha256, bytes: stored };
169
+ }
170
+ const myDeviceId = await this.deps.getCurrentDeviceId();
171
+ const myWrapped = ref.wrappedKeys.find(wk => wk.deviceId === myDeviceId);
172
+ if (!myWrapped) {
173
+ throw new Error('No wrapped key for the current device on this attachment');
174
+ }
175
+ const fileKey = await this.unwrapKey(myWrapped);
176
+ // stored = fileNonce(12) || ciphertext
177
+ if (stored.length < 13) {
178
+ throw new Error('Encrypted attachment payload too short');
179
+ }
180
+ const fileNonce = stored.slice(0, 12);
181
+ const ciphertext = stored.slice(12);
182
+ const aesKey = await crypto.subtle.importKey('raw', asBufferSource(fileKey), { name: 'AES-GCM' }, false, ['decrypt']);
183
+ const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: asBufferSource(fileNonce), tagLength: 128 }, aesKey, asBufferSource(ciphertext));
184
+ return {
185
+ attachmentId: ref.attachmentId,
186
+ mimeType: info.mimeType,
187
+ sizeBytes: info.sizeBytes,
188
+ sha256: info.sha256,
189
+ bytes: new Uint8Array(plain),
190
+ };
191
+ }
192
+ /** Convert public AttachmentRef into the wire-format type for proto encoding. */
193
+ toWire(ref) {
194
+ return {
195
+ attachmentId: ref.attachmentId,
196
+ storageHint: ref.storageHint,
197
+ mimeType: ref.mimeType,
198
+ sizeBytes: ref.sizeBytes,
199
+ sha256: ref.sha256,
200
+ encryptionType: ref.encryptionType === 'CLEARTEXT' ? 1 : 0,
201
+ wrappedKeys: ref.wrappedKeys.map(wk => ({
202
+ deviceId: wk.deviceId,
203
+ wrappedKey: wk.wrappedKey,
204
+ senderPublicKey: wk.senderPublicKey,
205
+ nonce: wk.nonce,
206
+ })),
207
+ };
208
+ }
209
+ /** Convert a wire AttachmentRef (from a received message) into the public type. */
210
+ static fromWire(wire) {
211
+ return {
212
+ attachmentId: wire.attachmentId,
213
+ storageHint: wire.storageHint,
214
+ mimeType: wire.mimeType,
215
+ sizeBytes: Number(wire.sizeBytes),
216
+ sha256: wire.sha256,
217
+ encryptionType: wire.encryptionType === 1 ? 'CLEARTEXT' : 'E2EE',
218
+ wrappedKeys: (wire.wrappedKeys ?? []).map((wk) => ({
219
+ deviceId: wk.deviceId,
220
+ wrappedKey: new Uint8Array(wk.wrappedKey),
221
+ senderPublicKey: new Uint8Array(wk.senderPublicKey),
222
+ nonce: new Uint8Array(wk.nonce),
223
+ })),
224
+ };
225
+ }
226
+ // ─── internals ───
227
+ async uploadBytes(session, bytes, onProgress) {
228
+ const resp = await this.deps.fetchFn(session.uploadUrl, {
229
+ method: session.uploadMethod || 'PUT',
230
+ headers: session.uploadHeaders,
231
+ body: asBufferSource(bytes),
232
+ });
233
+ if (!resp.ok) {
234
+ const text = await resp.text().catch(() => '');
235
+ throw new Error(`Storage upload failed (HTTP ${resp.status}): ${text.slice(0, 400)}`);
236
+ }
237
+ // fetch does not surface upload-progress; report the final byte count.
238
+ if (onProgress) {
239
+ onProgress(bytes.length, bytes.length);
240
+ }
241
+ }
242
+ async wrapKeyForRecipients(fileKey, opts) {
243
+ const currentUserId = this.deps.getCurrentUserId();
244
+ const myDeviceId = await this.deps.getCurrentDeviceId();
245
+ const myIdentity = await this.deps.cryptoService.getOrCreateIdentity();
246
+ const myPublicKeyBytes = (0, bytes_1.fromBase64)(myIdentity.publicKey);
247
+ const myPrivBase64 = await this.deps.getKeyStorage().get(STORAGE_PRIVATE);
248
+ if (!myPrivBase64) {
249
+ throw new Error('Local identity keypair is missing; cannot wrap attachment key');
250
+ }
251
+ const myPriv = (0, bytes_1.fromBase64)(myPrivBase64);
252
+ // Resolve target devices: recipients' devices + sender's other devices.
253
+ const recipientUserIds = opts.recipientUserIds ?? (opts.toUserId ? [opts.toUserId] : []);
254
+ const seenDeviceIds = new Set();
255
+ const targets = [];
256
+ for (const uid of recipientUserIds) {
257
+ const keys = await this.deps.fetchDeviceKeys(uid);
258
+ for (const k of keys) {
259
+ if (seenDeviceIds.has(k.deviceId))
260
+ continue;
261
+ seenDeviceIds.add(k.deviceId);
262
+ targets.push({ deviceId: k.deviceId, publicKey: k.publicKey });
263
+ }
264
+ }
265
+ const myOther = await this.deps.fetchMyOtherDeviceKeys(myDeviceId);
266
+ for (const k of myOther) {
267
+ if (seenDeviceIds.has(k.deviceId))
268
+ continue;
269
+ seenDeviceIds.add(k.deviceId);
270
+ targets.push({ deviceId: k.deviceId, publicKey: k.publicKey });
271
+ }
272
+ const wrapped = [];
273
+ for (const target of targets) {
274
+ const peerPub = (0, bytes_1.fromBase64)(target.publicKey);
275
+ if (peerPub.length !== 32) {
276
+ this.deps.logError('attachment_invalid_peer_pubkey', { deviceId: target.deviceId, len: peerPub.length });
277
+ continue;
278
+ }
279
+ const shared = tweetnacl_1.default.scalarMult(myPriv, peerPub);
280
+ const aesKey = await this.deriveAttachmentWrapKey(shared);
281
+ const nonce = (0, bytes_1.randomBytes)(12);
282
+ const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: asBufferSource(nonce), tagLength: 128 }, aesKey, asBufferSource(fileKey));
283
+ wrapped.push({
284
+ deviceId: target.deviceId,
285
+ wrappedKey: new Uint8Array(cipherBuf),
286
+ senderPublicKey: myPublicKeyBytes,
287
+ nonce,
288
+ });
289
+ }
290
+ return wrapped;
291
+ }
292
+ async unwrapKey(wrapped) {
293
+ const privBase64 = await this.deps.getKeyStorage().get(STORAGE_PRIVATE);
294
+ if (!privBase64) {
295
+ throw new Error('Local identity keypair is missing; cannot unwrap attachment key');
296
+ }
297
+ const myPriv = (0, bytes_1.fromBase64)(privBase64);
298
+ if (myPriv.length !== 32 || wrapped.senderPublicKey.length !== 32) {
299
+ throw new Error('Invalid X25519 key length on wrapped attachment key');
300
+ }
301
+ const shared = tweetnacl_1.default.scalarMult(myPriv, wrapped.senderPublicKey);
302
+ const aesKey = await this.deriveAttachmentWrapKey(shared);
303
+ const plainBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: asBufferSource(wrapped.nonce), tagLength: 128 }, aesKey, asBufferSource(wrapped.wrappedKey));
304
+ const plain = new Uint8Array(plainBuf);
305
+ if (plain.length !== 32) {
306
+ throw new Error(`unwrapped attachment key has unexpected length ${plain.length}`);
307
+ }
308
+ return plain;
309
+ }
310
+ /**
311
+ * HKDF for the attachment file-key wrap. Sender and recipient both compute
312
+ * the same X25519 shared secret (by symmetry of curve25519) and derive the
313
+ * same AES-GCM key via this HKDF. Distinct info/salt from the message-payload
314
+ * HKDF in CryptoService to avoid key reuse across protocols.
315
+ */
316
+ async deriveAttachmentWrapKey(sharedSecret) {
317
+ const info = (0, bytes_1.utf8Encode)('droponair-attachment-key-wrap-v1');
318
+ const salt = (0, bytes_1.utf8Encode)('droponair-attachment-hkdf-salt-v1');
319
+ const ikm = await crypto.subtle.importKey('raw', asBufferSource(sharedSecret), 'HKDF', false, ['deriveKey']);
320
+ return crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: asBufferSource(salt), info: asBufferSource(info) }, ikm, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
321
+ }
322
+ }
323
+ exports.AttachmentClient = AttachmentClient;
@@ -0,0 +1,77 @@
1
+ /** Encryption mode for an attachment. */
2
+ export type AttachmentEncryptionType = 'E2EE' | 'CLEARTEXT';
3
+ /** Conversation scope used for download authorization on the server side. */
4
+ export type AttachmentConversationType = 'ONE_TO_ONE' | 'GROUP';
5
+ /**
6
+ * Per-device wrapped AES-256-GCM file key for an E2EE attachment.
7
+ * The wrapped bytes decrypt to a 32-byte raw AES key that decrypts the file.
8
+ */
9
+ export interface DeviceWrappedKey {
10
+ deviceId: string;
11
+ wrappedKey: Uint8Array;
12
+ senderPublicKey: Uint8Array;
13
+ nonce: Uint8Array;
14
+ }
15
+ /**
16
+ * Reference to an attachment hosted in the customer's storage bucket,
17
+ * carried inside Envelope.attachments. After the SDK uploads the bytes
18
+ * and finalizes, this is the structure the sender embeds in the message.
19
+ */
20
+ export interface AttachmentRef {
21
+ attachmentId: string;
22
+ storageHint: string;
23
+ mimeType: string;
24
+ sizeBytes: number;
25
+ /** Hex-encoded SHA-256 of stored bytes (ciphertext for E2EE, plaintext for CLEARTEXT). */
26
+ sha256: string;
27
+ encryptionType: AttachmentEncryptionType;
28
+ /** Empty for CLEARTEXT. One entry per recipient device for E2EE. */
29
+ wrappedKeys: DeviceWrappedKey[];
30
+ }
31
+ /** Optional metadata for {@link DropOnAirClient.createUploadSession}. */
32
+ export interface CreateUploadSessionOptions {
33
+ mimeType?: string;
34
+ sizeBytes: number;
35
+ encryptionType?: AttachmentEncryptionType;
36
+ conversationType?: AttachmentConversationType;
37
+ /** For GROUP conversations: the groupId. For ONE_TO_ONE: omit. */
38
+ conversationId?: string;
39
+ /** Recipient user IDs (used by the server for download authorization). */
40
+ recipientUserIds?: string[];
41
+ }
42
+ /** Result of reserving an upload session. */
43
+ export interface UploadSession {
44
+ attachmentId: string;
45
+ storageHint: string;
46
+ uploadUrl: string;
47
+ uploadMethod: string;
48
+ uploadHeaders: Record<string, string>;
49
+ expiresAt: string;
50
+ }
51
+ /** Options for the convenience method {@link DropOnAirClient.prepareAttachmentAndUpload}. */
52
+ export interface PrepareAttachmentOptions {
53
+ /** Recipient user ID for ONE_TO_ONE conversations. */
54
+ toUserId?: string;
55
+ /** Group ID for GROUP conversations. Mutually exclusive with toUserId. */
56
+ groupId?: string;
57
+ /**
58
+ * Roster of recipient user IDs the server should authorize for download.
59
+ * For ONE_TO_ONE: defaults to {@code [toUserId]} if omitted.
60
+ * For GROUP: required - the SDK does not infer group membership.
61
+ */
62
+ recipientUserIds?: string[];
63
+ /** Defaults to {@code 'E2EE'}. Use {@code 'CLEARTEXT'} for public/unencrypted content. */
64
+ encryptionType?: AttachmentEncryptionType;
65
+ /** Override the mime type detected from the File object. */
66
+ mimeType?: string;
67
+ /** Progress callback called with bytes uploaded so far. */
68
+ onUploadProgress?: (bytesUploaded: number, totalBytes: number) => void;
69
+ }
70
+ /** Bytes payload returned by downloadAttachment. */
71
+ export interface DownloadedAttachment {
72
+ attachmentId: string;
73
+ mimeType: string;
74
+ sizeBytes: number;
75
+ sha256: string;
76
+ bytes: Uint8Array;
77
+ }
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ // ---------------------------------------------------------------------------
3
+ // Attachment types (PROTOCOL_VERSION 4+)
4
+ //
5
+ // DropOnAir never holds file bytes. The SDK uploads/downloads directly
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
8
+ // key is wrapped per recipient device using the same X25519 + HKDF path
9
+ // used for message payloads.
10
+ // ---------------------------------------------------------------------------
11
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,6 +1,7 @@
1
1
  import { CryptoService } from '../crypto/crypto-service';
2
2
  import { SessionManager } from './session-manager';
3
3
  import { BroadcastCallback, CallEventCallback, DropOnAirClient, EventCallback, GroupCallEventCallback, GroupInfo, GroupMessageCallback, InitializeOptions, MessageCallback, MessageDeleteCallback, MessageEditCallback, TurnCredentials } from './types';
4
+ import { AttachmentRef, CreateUploadSessionOptions, DownloadedAttachment, PrepareAttachmentOptions, UploadSession } from '../attachment/attachment-types';
4
5
  export declare class MessagingClient implements DropOnAirClient {
5
6
  private readonly options;
6
7
  private readonly cryptoService;
@@ -12,6 +13,7 @@ export declare class MessagingClient implements DropOnAirClient {
12
13
  private readonly keyDirectoryEndpoint;
13
14
  private readonly fetchFn;
14
15
  private readonly codec;
16
+ private attachmentClient;
15
17
  private ws;
16
18
  private shouldReconnect;
17
19
  private reconnectTimer;
@@ -84,9 +86,15 @@ export declare class MessagingClient implements DropOnAirClient {
84
86
  /** Redact a JWT to keep subject + expiry visible while hiding the signature. */
85
87
  private jwtSummary;
86
88
  constructor(options: InitializeOptions, cryptoService: CryptoService, sessionManager: SessionManager, storage: import('./types').KeyStorageAdapter);
89
+ prepareAttachmentAndUpload(bytes: Uint8Array, options: PrepareAttachmentOptions): Promise<AttachmentRef>;
90
+ createUploadSession(options: CreateUploadSessionOptions): Promise<UploadSession>;
91
+ finalizeAttachment(attachmentId: string, sha256: string): Promise<void>;
92
+ downloadAttachment(ref: AttachmentRef): Promise<DownloadedAttachment>;
87
93
  connect(): Promise<void>;
88
94
  disconnect(): void;
89
- sendMessage(toUserId: string, plaintextMessage: string): Promise<{
95
+ sendMessage(toUserId: string, plaintextMessage: string, options?: {
96
+ attachments?: AttachmentRef[];
97
+ }): Promise<{
90
98
  messageId: string;
91
99
  }>;
92
100
  ack(messageId: string): Promise<void>;
@@ -128,6 +136,8 @@ export declare class MessagingClient implements DropOnAirClient {
128
136
  leaveGroupCall(callId: string): Promise<void>;
129
137
  endGroupCall(callId: string): Promise<void>;
130
138
  sendGroupCallSignal(type: 'GROUP_CALL_SDP_OFFER' | 'GROUP_CALL_SDP_ANSWER' | 'GROUP_CALL_ICE_CANDIDATE', callId: string, groupId: string, targetUserId: string, payload: string): void;
139
+ startGroupScreenShare(callId: string, groupId: string, payload?: string): void;
140
+ stopGroupScreenShare(callId: string, groupId: string, payload?: string): void;
131
141
  onGroupCallEvent(callback: GroupCallEventCallback): () => void;
132
142
  private sendGroupCallFrame;
133
143
  /**
@@ -141,6 +151,8 @@ export declare class MessagingClient implements DropOnAirClient {
141
151
  endCall(callId: string): Promise<void>;
142
152
  toggleVideo(callId: string, enabled: boolean): void;
143
153
  sendCallSignal(type: 'CALL_SDP_OFFER' | 'CALL_SDP_ANSWER' | 'CALL_ICE_CANDIDATE', callId: string, payload: string): void;
154
+ startScreenShare(callId: string, payload?: string): void;
155
+ stopScreenShare(callId: string, payload?: string): void;
144
156
  fetchTurnCredentials(): Promise<TurnCredentials>;
145
157
  private sendCallFrame;
146
158
  private emitCallEvent;
@@ -4,6 +4,7 @@ exports.MessagingClient = void 0;
4
4
  const bytes_1 = require("./bytes");
5
5
  const protobuf_codec_1 = require("../transport/protobuf-codec");
6
6
  const version_1 = require("../version");
7
+ const attachment_client_1 = require("../attachment/attachment-client");
7
8
  const STORAGE_DEVICE_ID = 'droponair.device.id.v1';
8
9
  class MessagingClient {
9
10
  reconnectDelayMs() {
@@ -235,6 +236,36 @@ class MessagingClient {
235
236
  return resolvedFetch.call(fetchContext, input, init);
236
237
  });
237
238
  this.autoAckIncomingMessages = options.autoAckIncomingMessages !== false;
239
+ this.attachmentClient = new attachment_client_1.AttachmentClient({
240
+ httpUrl: this.httpUrl,
241
+ fetchFn: this.fetchFn,
242
+ getValidDropOnAirJwt: () => this.getValidDropOnAirJwt(false),
243
+ fetchDeviceKeys: (userId) => this.fetchDeviceKeys(userId),
244
+ fetchMyOtherDeviceKeys: (myDeviceId) => this.fetchMyOtherDeviceKeys(myDeviceId),
245
+ getCurrentUserId: () => {
246
+ if (!this.currentUserId)
247
+ throw new Error('Not connected; userId unknown');
248
+ return this.currentUserId;
249
+ },
250
+ getCurrentDeviceId: () => this.deviceId ? Promise.resolve(this.deviceId) : this.getOrCreateDeviceId(),
251
+ getKeyStorage: () => this.storage,
252
+ cryptoService: this.cryptoService,
253
+ log: (stage, data) => this.log(stage, data),
254
+ logError: (stage, data) => this.logError(stage, data),
255
+ });
256
+ }
257
+ // Attachment API (phase1/attachments, PROTOCOL_VERSION 4+)
258
+ async prepareAttachmentAndUpload(bytes, options) {
259
+ return this.attachmentClient.prepareAttachmentAndUpload(bytes, options);
260
+ }
261
+ async createUploadSession(options) {
262
+ return this.attachmentClient.createUploadSession(options);
263
+ }
264
+ async finalizeAttachment(attachmentId, sha256) {
265
+ return this.attachmentClient.finalize(attachmentId, sha256);
266
+ }
267
+ async downloadAttachment(ref) {
268
+ return this.attachmentClient.downloadAttachment(ref);
238
269
  }
239
270
  async connect() {
240
271
  this.log('connect_start');
@@ -267,7 +298,7 @@ class MessagingClient {
267
298
  }
268
299
  this.emitEvent({ type: 'DISCONNECTED' });
269
300
  }
270
- async sendMessage(toUserId, plaintextMessage) {
301
+ async sendMessage(toUserId, plaintextMessage, options) {
271
302
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
272
303
  throw new Error('DropOnAir websocket is not connected');
273
304
  }
@@ -327,6 +358,9 @@ class MessagingClient {
327
358
  devicePayloads,
328
359
  senderDeviceId: myDeviceId,
329
360
  };
361
+ if (options?.attachments && options.attachments.length > 0) {
362
+ envelope.attachments = options.attachments.map(a => this.attachmentClient.toWire(a));
363
+ }
330
364
  this.ws.send(this.codec.encodeEnvelope(envelope));
331
365
  }
332
366
  else {
@@ -347,6 +381,9 @@ class MessagingClient {
347
381
  encryptedPayload,
348
382
  clientMessageId: messageId
349
383
  };
384
+ if (options?.attachments && options.attachments.length > 0) {
385
+ envelope.attachments = options.attachments.map(a => this.attachmentClient.toWire(a));
386
+ }
350
387
  this.ws.send(this.codec.encodeEnvelope(envelope));
351
388
  }
352
389
  return { messageId };
@@ -703,6 +740,24 @@ class MessagingClient {
703
740
  sendGroupCallSignal(type, callId, groupId, targetUserId, payload) {
704
741
  this.sendGroupCallFrame({ type, callId, groupId, targetUserId, payload });
705
742
  }
743
+ startGroupScreenShare(callId, groupId, payload) {
744
+ this.sendGroupCallFrame({
745
+ type: 'GROUP_CALL_SCREEN_SHARE_STARTED',
746
+ callId,
747
+ groupId,
748
+ targetUserId: '',
749
+ payload: payload ?? '',
750
+ });
751
+ }
752
+ stopGroupScreenShare(callId, groupId, payload) {
753
+ this.sendGroupCallFrame({
754
+ type: 'GROUP_CALL_SCREEN_SHARE_STOPPED',
755
+ callId,
756
+ groupId,
757
+ targetUserId: '',
758
+ payload: payload ?? '',
759
+ });
760
+ }
706
761
  onGroupCallEvent(callback) {
707
762
  this.groupCallListeners.add(callback);
708
763
  return () => this.groupCallListeners.delete(callback);
@@ -744,6 +799,12 @@ class MessagingClient {
744
799
  sendCallSignal(type, callId, payload) {
745
800
  this.sendCallFrame({ type, callId, payload });
746
801
  }
802
+ startScreenShare(callId, payload) {
803
+ this.sendCallFrame({ type: 'CALL_SCREEN_SHARE_STARTED', callId, payload: payload ?? '' });
804
+ }
805
+ stopScreenShare(callId, payload) {
806
+ this.sendCallFrame({ type: 'CALL_SCREEN_SHARE_STOPPED', callId, payload: payload ?? '' });
807
+ }
747
808
  async fetchTurnCredentials() {
748
809
  const jwt = await this.getValidDropOnAirJwt(false);
749
810
  const url = `${this.httpUrl}/api/v1/turn/credentials`;
@@ -1191,15 +1252,20 @@ class MessagingClient {
1191
1252
  timestamp: envelope.timestamp
1192
1253
  });
1193
1254
  }
1255
+ const attachments = envelope.attachments && envelope.attachments.length > 0
1256
+ ? envelope.attachments.map(w => attachment_client_1.AttachmentClient.fromWire(w))
1257
+ : undefined;
1194
1258
  this.emitMessage({
1195
1259
  messageId: envelope.messageId,
1196
1260
  fromUserId: envelope.fromUserId,
1197
1261
  toUserId: envelope.toUserId,
1198
1262
  timestamp: envelope.timestamp,
1199
- plaintext
1263
+ plaintext,
1264
+ attachments,
1200
1265
  });
1201
1266
  this.log('incoming_envelope_emitted', {
1202
1267
  messageId: envelope.messageId,
1268
+ attachmentCount: attachments?.length ?? 0,
1203
1269
  });
1204
1270
  if (this.autoAckIncomingMessages) {
1205
1271
  await this.ack(envelope.messageId);
@@ -10,6 +10,12 @@ export interface DecryptedMessage {
10
10
  toUserId: string;
11
11
  timestamp: number;
12
12
  plaintext: string;
13
+ /**
14
+ * Attachment pointers (phase1/attachments, PROTOCOL_VERSION 4+).
15
+ * Empty/undefined for messages with no attachments. To fetch and decrypt the
16
+ * bytes, call {@code client.downloadAttachment(ref)}.
17
+ */
18
+ attachments?: import('../attachment/attachment-types').AttachmentRef[];
13
19
  }
14
20
  export type MessageCallback = (message: DecryptedMessage) => void;
15
21
  export type EventCallback = (event: DropOnAirEvent) => void;
@@ -51,7 +57,7 @@ export interface BroadcastMessage {
51
57
  sequenceNumber: number;
52
58
  }
53
59
  export type BroadcastCallback = (message: BroadcastMessage) => void;
54
- export type CallEventType = 'CALL_INVITE' | 'CALL_RINGING' | 'CALL_ACCEPTED' | 'CALL_REJECTED' | 'CALL_ENDED' | 'CALL_CANCELLED' | 'CALL_SDP_OFFER' | 'CALL_SDP_ANSWER' | 'CALL_ICE_CANDIDATE' | 'CALL_VIDEO_TOGGLE' | 'CALL_DENIED_LIMIT_REACHED';
60
+ export type CallEventType = 'CALL_INVITE' | 'CALL_RINGING' | 'CALL_ACCEPTED' | 'CALL_REJECTED' | 'CALL_ENDED' | 'CALL_CANCELLED' | 'CALL_SDP_OFFER' | 'CALL_SDP_ANSWER' | 'CALL_ICE_CANDIDATE' | 'CALL_VIDEO_TOGGLE' | 'CALL_SCREEN_SHARE_STARTED' | 'CALL_SCREEN_SHARE_STOPPED' | 'CALL_DENIED_LIMIT_REACHED';
55
61
  export interface CallEvent {
56
62
  type: CallEventType | string;
57
63
  callId?: string;
@@ -86,7 +92,7 @@ export interface DecryptedGroupMessage {
86
92
  plaintext: string;
87
93
  }
88
94
  export type GroupMessageCallback = (message: DecryptedGroupMessage) => void;
89
- 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';
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';
90
96
  export interface GroupCallEvent {
91
97
  type: GroupCallEventType | string;
92
98
  callId: string;
@@ -129,7 +135,13 @@ export interface InitializeOptions {
129
135
  export interface DropOnAirClient {
130
136
  connect(): Promise<void>;
131
137
  disconnect(): void;
132
- sendMessage(toUserId: string, plaintextMessage: string): Promise<{
138
+ /**
139
+ * Send a 1:1 E2EE message. Optionally attach one or more attachments
140
+ * prepared via {@link prepareAttachmentAndUpload} or {@link createUploadSession}.
141
+ */
142
+ sendMessage(toUserId: string, plaintextMessage: string, options?: {
143
+ attachments?: import('../attachment/attachment-types').AttachmentRef[];
144
+ }): Promise<{
133
145
  messageId: string;
134
146
  }>;
135
147
  onMessage(callback: MessageCallback): () => void;
@@ -158,6 +170,18 @@ export interface DropOnAirClient {
158
170
  onMessageEdit(callback: MessageEditCallback): () => void;
159
171
  /** Register a listener for incoming message-delete notifications. */
160
172
  onMessageDelete(callback: MessageDeleteCallback): () => void;
173
+ /**
174
+ * Convenience: encrypts (E2EE), uploads to the customer's bucket, finalizes,
175
+ * and returns an AttachmentRef ready to pass into {@link sendMessage}.
176
+ * For CLEARTEXT, bytes are uploaded unencrypted.
177
+ */
178
+ prepareAttachmentAndUpload(bytes: Uint8Array, options: import('../attachment/attachment-types').PrepareAttachmentOptions): Promise<import('../attachment/attachment-types').AttachmentRef>;
179
+ /** Low-level: reserve an upload session (presigned URL) without uploading. */
180
+ createUploadSession(options: import('../attachment/attachment-types').CreateUploadSessionOptions): Promise<import('../attachment/attachment-types').UploadSession>;
181
+ /** Low-level: finalize an upload, committing the integrity hash. */
182
+ finalizeAttachment(attachmentId: string, sha256: string): Promise<void>;
183
+ /** Download (and for E2EE decrypt) an attachment referenced inside a received message. */
184
+ downloadAttachment(ref: import('../attachment/attachment-types').AttachmentRef): Promise<import('../attachment/attachment-types').DownloadedAttachment>;
161
185
  /** Send a cleartext message to a user (no encryption). */
162
186
  sendCleartextMessage(toUserId: string, plaintext: string): Promise<{
163
187
  messageId: string;
@@ -188,6 +212,16 @@ export interface DropOnAirClient {
188
212
  * The payload is forwarded opaquely, the server does NOT inspect or store it.
189
213
  */
190
214
  sendCallSignal(type: 'CALL_SDP_OFFER' | 'CALL_SDP_ANSWER' | 'CALL_ICE_CANDIDATE', callId: string, payload: string): void;
215
+ /**
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
218
+ * is added to the existing peer connection by the application; the SDK only
219
+ * broadcasts the start/stop notification so the remote SDK can update its UI.
220
+ * Optional `payload` (JSON string) can carry track-id or app-specific metadata.
221
+ */
222
+ startScreenShare(callId: string, payload?: string): void;
223
+ /** Signal that this user stopped sharing their screen in a 1:1 call. */
224
+ stopScreenShare(callId: string, payload?: string): void;
191
225
  /** Register a listener for all incoming call events. Returns an unsubscribe function. */
192
226
  onCallEvent(callback: CallEventCallback): () => void;
193
227
  /** Fetch short-lived TURN credentials for ICE negotiation. */
@@ -220,6 +254,16 @@ export interface DropOnAirClient {
220
254
  endGroupCall(callId: string): Promise<void>;
221
255
  /** Send a signaling frame to a specific peer in a group call. */
222
256
  sendGroupCallSignal(type: 'GROUP_CALL_SDP_OFFER' | 'GROUP_CALL_SDP_ANSWER' | 'GROUP_CALL_ICE_CANDIDATE', callId: string, groupId: string, targetUserId: string, payload: string): void;
257
+ /**
258
+ * Signal that this user started sharing their screen in a group call.
259
+ * The server enforces one concurrent sharer per group call; if another
260
+ * participant already holds the slot, the SDK will receive a
261
+ * `GROUP_CALL_SCREEN_SHARE_STOPPED` event echoing the current holder's
262
+ * userId in `payload` so the app can roll back its optimistic UI.
263
+ */
264
+ startGroupScreenShare(callId: string, groupId: string, payload?: string): void;
265
+ /** Signal that this user stopped sharing their screen in a group call. */
266
+ stopGroupScreenShare(callId: string, groupId: string, payload?: string): void;
223
267
  /** Register a listener for group call events. Returns an unsubscribe function. */
224
268
  onGroupCallEvent(callback: GroupCallEventCallback): () => void;
225
269
  }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ 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
4
  export type { InitializeOptions, DropOnAirClient, DropOnAirEvent, MessageCallback, EventCallback, DecryptedMessage, KeyStorageAdapter, CallEvent, CallEventType, CallEventCallback, TurnCredentials, BroadcastMessage, BroadcastCallback, GroupInfo, GroupMemberInfo, DecryptedGroupMessage, GroupMessageCallback, GroupCallEvent, GroupCallEventType, GroupCallEventCallback, MessageEditEvent, MessageEditCallback, MessageDeleteEvent, MessageDeleteCallback, } from './core/types';
5
+ export type { AttachmentEncryptionType, AttachmentConversationType, AttachmentRef, DeviceWrappedKey, CreateUploadSessionOptions, UploadSession, PrepareAttachmentOptions, DownloadedAttachment, } from './attachment/attachment-types';
5
6
  declare const _default: {
6
7
  initialize: typeof initialize;
7
8
  };
@@ -4,6 +4,23 @@ export interface WireDeviceEncryptedPayload {
4
4
  encryptedPayload: Uint8Array;
5
5
  senderPublicKey: Uint8Array;
6
6
  }
7
+ /** Per-device wrapped AES-256-GCM file key for E2EE attachments. */
8
+ export interface WireDeviceWrappedKey {
9
+ deviceId: string;
10
+ wrappedKey: Uint8Array;
11
+ senderPublicKey: Uint8Array;
12
+ nonce: Uint8Array;
13
+ }
14
+ /** Pointer to an attachment in the customer's storage bucket. */
15
+ export interface WireAttachmentRef {
16
+ attachmentId: string;
17
+ storageHint: string;
18
+ mimeType: string;
19
+ sizeBytes: number;
20
+ sha256: string;
21
+ encryptionType: number;
22
+ wrappedKeys?: WireDeviceWrappedKey[];
23
+ }
7
24
  export interface WireEnvelope {
8
25
  messageId: string;
9
26
  appId: string;
@@ -20,6 +37,8 @@ export interface WireEnvelope {
20
37
  encryptionType?: number;
21
38
  /** Populated only when encryptionType = CLEARTEXT. */
22
39
  plaintextPayload?: string;
40
+ /** Zero or more attachment pointers (PROTOCOL_VERSION 4+). */
41
+ attachments?: WireAttachmentRef[];
23
42
  }
24
43
  export interface WireAck {
25
44
  messageId: string;
@@ -42,9 +42,28 @@ const DeviceEncryptedPayloadType = new protobuf.Type('DeviceEncryptedPayload')
42
42
  .add(new protobuf.Field('deviceId', 1, 'string'))
43
43
  .add(new protobuf.Field('encryptedPayload', 2, 'bytes'))
44
44
  .add(new protobuf.Field('senderPublicKey', 3, 'bytes'));
45
+ // Per-device wrapped file key for E2EE attachments (PROTOCOL_VERSION 4+)
46
+ const DeviceWrappedKeyType = new protobuf.Type('DeviceWrappedKey')
47
+ .add(new protobuf.Field('deviceId', 1, 'string'))
48
+ .add(new protobuf.Field('wrappedKey', 2, 'bytes'))
49
+ .add(new protobuf.Field('senderPublicKey', 3, 'bytes'))
50
+ .add(new protobuf.Field('nonce', 4, 'bytes'));
51
+ // Reference to an out-of-band attachment in customer-managed storage.
52
+ const AttachmentRefEncTypeEnum = new protobuf.Enum('EncryptionType', { E2EE: 0, CLEARTEXT: 1 });
53
+ const AttachmentRefType = new protobuf.Type('AttachmentRef')
54
+ .add(AttachmentRefEncTypeEnum)
55
+ .add(DeviceWrappedKeyType)
56
+ .add(new protobuf.Field('attachmentId', 1, 'string'))
57
+ .add(new protobuf.Field('storageHint', 2, 'string'))
58
+ .add(new protobuf.Field('mimeType', 3, 'string'))
59
+ .add(new protobuf.Field('sizeBytes', 4, 'int64'))
60
+ .add(new protobuf.Field('sha256', 5, 'string'))
61
+ .add(new protobuf.Field('encryptionType', 6, 'EncryptionType'))
62
+ .add(new protobuf.Field('wrappedKeys', 7, 'DeviceWrappedKey', 'repeated'));
45
63
  const EnvelopeType = new protobuf.Type('Envelope')
46
64
  .add(EncryptionTypeEnum)
47
65
  .add(DeviceEncryptedPayloadType) // nested type must be added first
66
+ .add(AttachmentRefType)
48
67
  .add(new protobuf.Field('messageId', 1, 'string'))
49
68
  .add(new protobuf.Field('appId', 2, 'string'))
50
69
  .add(new protobuf.Field('fromUserId', 3, 'string'))
@@ -55,7 +74,8 @@ const EnvelopeType = new protobuf.Type('Envelope')
55
74
  .add(new protobuf.Field('devicePayloads', 8, 'DeviceEncryptedPayload', 'repeated'))
56
75
  .add(new protobuf.Field('senderDeviceId', 9, 'string'))
57
76
  .add(new protobuf.Field('encryptionType', 10, 'EncryptionType'))
58
- .add(new protobuf.Field('plaintextPayload', 11, 'string'));
77
+ .add(new protobuf.Field('plaintextPayload', 11, 'string'))
78
+ .add(new protobuf.Field('attachments', 12, 'AttachmentRef', 'repeated'));
59
79
  const AckType = new protobuf.Type('Ack')
60
80
  .add(new protobuf.Field('messageId', 1, 'string'))
61
81
  .add(new protobuf.Field('type', 2, 'string'));
@@ -104,6 +124,9 @@ const GroupMemberPayloadType = new protobuf.Type('GroupMemberPayload')
104
124
  .add(new protobuf.Field('userId', 1, 'string'))
105
125
  .add(new protobuf.Field('devicePayloads', 2, 'DeviceEncryptedPayload', 'repeated'));
106
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.
107
130
  const GroupEnvelopeType = new protobuf.Type('GroupEnvelope')
108
131
  .add(GroupEnvelopeEncryptionTypeEnum)
109
132
  .add(GroupMemberPayloadType)
@@ -289,6 +312,23 @@ class ProtobufCodec {
289
312
  senderPublicKey: new Uint8Array(dp.senderPublicKey),
290
313
  }));
291
314
  }
315
+ // Normalize attachments (PROTOCOL_VERSION 4+)
316
+ if (decoded.attachments && decoded.attachments.length > 0) {
317
+ result.attachments = decoded.attachments.map(att => ({
318
+ attachmentId: att.attachmentId,
319
+ storageHint: att.storageHint,
320
+ mimeType: att.mimeType,
321
+ sizeBytes: Number(att.sizeBytes),
322
+ sha256: att.sha256,
323
+ encryptionType: att.encryptionType ?? 0,
324
+ wrappedKeys: att.wrappedKeys?.map(wk => ({
325
+ deviceId: wk.deviceId,
326
+ wrappedKey: new Uint8Array(wk.wrappedKey),
327
+ senderPublicKey: new Uint8Array(wk.senderPublicKey),
328
+ nonce: new Uint8Array(wk.nonce),
329
+ })) ?? [],
330
+ }));
331
+ }
292
332
  return result;
293
333
  }
294
334
  catch {
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.4.0";
10
+ export declare const SDK_VERSION = "0.6.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 = 3;
22
+ export declare const PROTOCOL_VERSION = 4;
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.4.0';
13
+ exports.SDK_VERSION = '0.6.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 = 3;
25
+ exports.PROTOCOL_VERSION = 4;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@droponair/sdk-js",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",