@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 +54 -0
- package/README.md +117 -0
- package/dist/attachment/attachment-client.d.ts +74 -0
- package/dist/attachment/attachment-client.js +323 -0
- package/dist/attachment/attachment-types.d.ts +77 -0
- package/dist/attachment/attachment-types.js +11 -0
- package/dist/core/messaging-client.d.ts +13 -1
- package/dist/core/messaging-client.js +68 -2
- package/dist/core/types.d.ts +47 -3
- package/dist/index.d.ts +1 -0
- package/dist/transport/protobuf-codec.d.ts +19 -0
- package/dist/transport/protobuf-codec.js +41 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,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
|
|
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);
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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 =
|
|
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.
|
|
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 =
|
|
25
|
+
exports.PROTOCOL_VERSION = 4;
|