@droponair/sdk-js 0.25.1 → 0.26.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
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.26.0
4
+
5
+ ### Added
6
+
7
+ - **Push payloads on outgoing messages.** `sendMessage` and `sendGroupMessage` accept
8
+ `options.push`, and `defaultPushPayload` on the client config attaches one to every
9
+ message that does not carry its own. Setting the default once is the recommended
10
+ route: passing a payload per send means forgetting it once stops push with no error
11
+ to notice.
12
+ - **`PushDelivery` chooses how a push behaves** on the recipient's device, and the
13
+ platform translates it per transport rather than deciding for you:
14
+ - `alert` shows title and body as supplied. Suits apps sending cleartext.
15
+ - `mutable` shows them as a placeholder and lets your app replace the text before it
16
+ appears, which is how an end-to-end encrypted app puts real content on a lock
17
+ screen without the relay seeing it. On iOS this is also the reliable way to be
18
+ woken, because the system throttles silent pushes.
19
+ - `silent` wakes the app and shows nothing.
20
+ - `voip` is the call invite path, superseding the `voip` boolean, which is still honoured.
21
+
22
+ ### Notes
23
+
24
+ - Push previously could not be triggered at all: no SDK exposed a way to attach a
25
+ payload, and the server only pushes when the sender supplies one.
26
+ - Never put message content in `title` or `body`. Every field is readable by the relay,
27
+ which is why it can act on them, and putting plaintext there leaks it through the one
28
+ path the platform promises never sees it.
29
+ - Additive and backward compatible. Messages sent without a payload behave exactly as
30
+ before, and an absent or unrecognised delivery mode is treated as `alert`.
31
+
3
32
  ## 0.25.1
4
33
 
5
34
  ### Changed
@@ -483,7 +512,7 @@ This project follows [Semantic Versioning](https://semver.org/).
483
512
  ### Added
484
513
  - **SDK versioning infrastructure:** `SDK_VERSION`, `PROTOCOL_VERSION`, and `PAYLOAD_FORMAT_VERSION` exported from `src/version.ts`
485
514
  - **Version telemetry:** `X-SDK-Version` header sent on token exchange; `sdkVersion` and `protocolVersion` query params on WebSocket handshake
486
- - **Server info endpoint:** SDK-BE now exposes `GET /api/info` (no auth) returning `protocolVersion`, `minSdkVersion`, and supported `features`
515
+ - **Server info endpoint:** the platform now exposes `GET /api/info` (no auth) returning `protocolVersion`, `minSdkVersion`, and supported `features`
487
516
  - **Multi-device E2EE:** Per-device encrypted payloads (`devicePayloads[]` in Envelope proto), sender encrypts once per recipient device + self-sync to own other devices
488
517
  - **Self-sync:** Sent messages are encrypted for the sender's other devices so all devices see the conversation in real time
489
518
  - **Legacy fallback:** When recipient has no device keys, SDK falls back to single `encryptedPayload` path for backward compat with older clients
package/README.md CHANGED
@@ -224,6 +224,45 @@ await client.registerPushToken({ platform: 'WEB_PUSH', token: JSON.stringify(sub
224
224
  await client.unregisterPushToken({ platform: 'APNS' });
225
225
  ```
226
226
 
227
+ #### Attaching a payload
228
+
229
+ A push is only sent when the sender attaches a payload, so set a default once and
230
+ every message carries it. Passing one on each send also works, but forgetting once
231
+ stops push with no error to notice.
232
+
233
+ ```typescript
234
+ const client = await initialize({
235
+ appId, publicApiKey, getUserJwt,
236
+ defaultPushPayload: { title: 'New message', delivery: 'mutable' },
237
+ });
238
+
239
+ // Override for a single message
240
+ await client.sendMessage(userId, text, {
241
+ push: { title: 'New photo', delivery: 'mutable', threadId: conversationId },
242
+ });
243
+ ```
244
+
245
+ #### Choosing how the push behaves
246
+
247
+ `delivery` says what the recipient's device should do. The platform translates it
248
+ per transport, so you pick what suits your app rather than adopting somebody else's
249
+ model.
250
+
251
+ | `delivery` | What happens |
252
+ | --- | --- |
253
+ | `'alert'` | Title and body are shown exactly as supplied. Suits apps sending cleartext. |
254
+ | `'mutable'` | Shown as a placeholder, then your app replaces the text before it appears. This is how an end-to-end encrypted app shows real content without the relay seeing it. On iOS it needs a notification service extension; on Android it arrives as a data message. |
255
+ | `'silent'` | The app is woken and nothing is shown. **iOS throttles these and does not guarantee delivery**, so use `'mutable'` when being woken matters. |
256
+ | `'voip'` | Call invite: PushKit on iOS, high priority data on Android. Requires a registered VoIP token. |
257
+
258
+ Omit `delivery` and it defaults to `'alert'`.
259
+
260
+ **Never put message content in `title` or `body`.** Every field in the payload is
261
+ readable by the relay, which is exactly why it can act on them, so putting plaintext
262
+ there leaks it through the one path that otherwise never sees it. Send a placeholder
263
+ and use `'mutable'` to replace it on the device.
264
+
265
+
227
266
  E2EE invariant: the push body is sender-supplied cleartext metadata only (e.g. "1 new message from Alice"), never the encrypted message contents. The recipient SDK decrypts the real message after the push wakes the device and the WebSocket reconnects.
228
267
 
229
268
  ### Message Edit & Delete
@@ -1,6 +1,6 @@
1
1
  import { CryptoService } from '../crypto/crypto-service';
2
2
  import { SessionManager } from './session-manager';
3
- import { BroadcastCallback, CallEventCallback, DropOnAirClient, EventCallback, CreateRoomOptions, GroupCallEventCallback, GroupInfo, GroupMessageCallback, Room, UpdateRoomOptions, SfuToken, SfuRecording, InitializeOptions, MessageCallback, MessageDeleteCallback, MessageEditCallback, TurnCredentials, DeviceInfo, ReadReceiptCallback, NotificationClearCallback, DraftSyncCallback, KeyCustody } from './types';
3
+ import { BroadcastCallback, CallEventCallback, DropOnAirClient, EventCallback, CreateRoomOptions, GroupCallEventCallback, GroupInfo, GroupMessageCallback, Room, UpdateRoomOptions, SfuToken, SfuRecording, InitializeOptions, MessageCallback, MessageDeleteCallback, MessageEditCallback, TurnCredentials, DeviceInfo, ReadReceiptCallback, NotificationClearCallback, DraftSyncCallback, KeyCustody, PushPayload } from './types';
4
4
  import { AttachmentRef, CreateUploadSessionOptions, DownloadedAttachment, PrepareAttachmentOptions, UploadSession } from '../attachment/attachment-types';
5
5
  export declare class MessagingClient implements DropOnAirClient {
6
6
  private readonly options;
@@ -11,6 +11,7 @@ export declare class MessagingClient implements DropOnAirClient {
11
11
  private readonly httpUrl;
12
12
  private readonly tokenExchangeEndpoint;
13
13
  private readonly keyDirectoryEndpoint;
14
+ private readonly defaultPushPayload?;
14
15
  private readonly fetchFn;
15
16
  private readonly codec;
16
17
  private attachmentClient;
@@ -29,6 +30,12 @@ export declare class MessagingClient implements DropOnAirClient {
29
30
  private visibilityChangeHandler;
30
31
  private readonly autoAckIncomingMessages;
31
32
  private static readonly DEVICE_KEYS_CACHE_TTL_MS;
33
+ /**
34
+ * Resolves the payload for a send: what the caller passed, otherwise the
35
+ * client default, otherwise none at all. Returns undefined when there is
36
+ * nothing to send, so no push is requested rather than an empty one.
37
+ */
38
+ private resolvePushPayload;
32
39
  private readonly deviceKeysCache;
33
40
  private readonly callListeners;
34
41
  /** Pending startCall resolver, only one outgoing call can be in-flight at a time. */
@@ -112,6 +119,7 @@ export declare class MessagingClient implements DropOnAirClient {
112
119
  sendMessage(toUserId: string, plaintextMessage: string, options?: {
113
120
  attachments?: AttachmentRef[];
114
121
  clientMessageId?: string;
122
+ push?: PushPayload;
115
123
  }): Promise<{
116
124
  messageId: string;
117
125
  }>;
@@ -282,6 +290,7 @@ export declare class MessagingClient implements DropOnAirClient {
282
290
  */
283
291
  sendGroupMessage(groupId: string, plaintext: string, memberUserIds: string[], options?: {
284
292
  attachments?: AttachmentRef[];
293
+ push?: PushPayload;
285
294
  }): Promise<{
286
295
  messageId: string;
287
296
  }>;
@@ -11,6 +11,30 @@ const version_1 = require("../version");
11
11
  const attachment_client_1 = require("../attachment/attachment-client");
12
12
  const STORAGE_DEVICE_ID = 'droponair.device.id.v1';
13
13
  class MessagingClient {
14
+ /**
15
+ * Resolves the payload for a send: what the caller passed, otherwise the
16
+ * client default, otherwise none at all. Returns undefined when there is
17
+ * nothing to send, so no push is requested rather than an empty one.
18
+ */
19
+ resolvePushPayload(override) {
20
+ const payload = override ?? this.defaultPushPayload;
21
+ if (!payload)
22
+ return undefined;
23
+ const deliveryCodes = { alert: 0, mutable: 1, silent: 2, voip: 3 };
24
+ const delivery = deliveryCodes[payload.delivery ?? 'alert'] ?? 0;
25
+ return {
26
+ title: payload.title ?? '',
27
+ body: payload.body ?? '',
28
+ badge: payload.badge,
29
+ sound: payload.sound,
30
+ category: payload.category,
31
+ threadId: payload.threadId,
32
+ customJson: payload.customJson,
33
+ delivery,
34
+ // The server still reads the older boolean, so keep it consistent.
35
+ voip: delivery === 3 ? true : undefined,
36
+ };
37
+ }
14
38
  reconnectDelayMs() {
15
39
  // Exponential backoff with jitter: ~2s, 4s, 8s, 16s, 32s, 60s (capped), +/-20%.
16
40
  // Jitter avoids many clients reconnecting in lockstep after a server blip.
@@ -260,6 +284,7 @@ class MessagingClient {
260
284
  this.httpUrl = options.messagingHttpUrl ?? 'https://sdk.droponair.com';
261
285
  this.tokenExchangeEndpoint = options.tokenExchangeEndpoint ?? '/api/messaging/token-exchange';
262
286
  this.keyDirectoryEndpoint = options.keyDirectoryEndpoint ?? '/api/messaging/keys';
287
+ this.defaultPushPayload = options.defaultPushPayload;
263
288
  const providedFetch = options.fetchFn;
264
289
  const globalFetch = typeof globalThis !== 'undefined' ? globalThis.fetch : undefined;
265
290
  const resolvedFetch = providedFetch ?? globalFetch;
@@ -419,6 +444,7 @@ class MessagingClient {
419
444
  if (options?.attachments && options.attachments.length > 0) {
420
445
  envelope.attachments = options.attachments.map(a => this.attachmentClient.toWire(a));
421
446
  }
447
+ envelope.pushPayload = this.resolvePushPayload(options?.push);
422
448
  this.sendFrame(this.codec.encodeEnvelope(envelope));
423
449
  }
424
450
  else {
@@ -442,6 +468,7 @@ class MessagingClient {
442
468
  if (options?.attachments && options.attachments.length > 0) {
443
469
  envelope.attachments = options.attachments.map(a => this.attachmentClient.toWire(a));
444
470
  }
471
+ envelope.pushPayload = this.resolvePushPayload(options?.push);
445
472
  this.sendFrame(this.codec.encodeEnvelope(envelope));
446
473
  }
447
474
  return { messageId };
@@ -1223,6 +1250,7 @@ class MessagingClient {
1223
1250
  encryptionType: 0, // E2EE
1224
1251
  memberPayloads,
1225
1252
  };
1253
+ frame.pushPayload = this.resolvePushPayload(options?.push);
1226
1254
  if (options?.attachments && options.attachments.length > 0) {
1227
1255
  frame.attachments = options.attachments.map(a => this.attachmentClient.toWire(a));
1228
1256
  }
@@ -1,3 +1,46 @@
1
+ /**
2
+ * How a push should behave on the recipient's device.
3
+ *
4
+ * The platform translates this per transport; it does not decide for you. Pick
5
+ * the one that matches your app, not the one that matches ours.
6
+ */
7
+ export type PushDelivery =
8
+ /** Show title and body exactly as supplied. Suits apps sending cleartext. */
9
+ 'alert'
10
+ /**
11
+ * Show them as a placeholder, then replace the text on the device before it
12
+ * appears. This is how an end-to-end encrypted app shows real content on a
13
+ * lock screen without the relay ever seeing it. On iOS it is also the reliable
14
+ * way to be woken, because silent pushes are throttled by the system.
15
+ */
16
+ | 'mutable'
17
+ /** Wake the app, show nothing. Delivery is not guaranteed on iOS. */
18
+ | 'silent'
19
+ /** Call invite. PushKit on iOS, high priority data on Android. */
20
+ | 'voip';
21
+ /**
22
+ * Cleartext metadata that wakes a recipient's device when they have no live
23
+ * connection.
24
+ *
25
+ * Every field here is readable by the relay, which is precisely why it can act on
26
+ * them. Never put message content in `title` or `body`: it would leak through the
27
+ * one path the platform promises never sees it. A placeholder such as
28
+ * "New message" is the point, and `mutable` lets the device replace it.
29
+ */
30
+ export interface PushPayload {
31
+ title?: string;
32
+ body?: string;
33
+ badge?: number;
34
+ sound?: string;
35
+ /** iOS UNNotificationCategory, Android channel id. */
36
+ category?: string;
37
+ /** Groups related notifications together. */
38
+ threadId?: string;
39
+ /** App-defined JSON passed through to the provider payload untouched. */
40
+ customJson?: string;
41
+ /** Defaults to 'alert'. */
42
+ delivery?: PushDelivery;
43
+ }
1
44
  export type DropOnAirEventType = 'SERVER_RECEIVED' | 'DELIVERED' | 'PROCESSED' | 'LIMIT_REACHED' | 'IMPERSONATION_DETECTED' | 'ERROR' | 'CONNECTED' | 'DISCONNECTED' | 'RECONNECTING';
2
45
  export interface DropOnAirEvent {
3
46
  type: DropOnAirEventType | string;
@@ -311,6 +354,14 @@ export interface InitializeOptions {
311
354
  messagingHttpUrl?: string;
312
355
  tokenExchangeEndpoint?: string;
313
356
  keyDirectoryEndpoint?: string;
357
+ /**
358
+ * Attached to every message that does not carry its own.
359
+ *
360
+ * Set this once and push works; the alternative is remembering to pass a
361
+ * payload on every send, where forgetting once means push silently stops with
362
+ * no error to notice. Omit it entirely and no push is ever requested.
363
+ */
364
+ defaultPushPayload?: PushPayload;
314
365
  fetchFn?: typeof fetch;
315
366
  storage?: KeyStorageAdapter;
316
367
  /**
@@ -395,6 +446,8 @@ export interface DropOnAirClient {
395
446
  sendMessage(toUserId: string, plaintextMessage: string, options?: {
396
447
  attachments?: import('../attachment/attachment-types').AttachmentRef[];
397
448
  clientMessageId?: string;
449
+ /** Overrides `defaultPushPayload` for this message. */
450
+ push?: PushPayload;
398
451
  }): Promise<{
399
452
  messageId: string;
400
453
  }>;
@@ -565,6 +618,8 @@ export interface DropOnAirClient {
565
618
  */
566
619
  sendGroupMessage(groupId: string, plaintext: string, memberUserIds: string[], options?: {
567
620
  attachments?: import('../attachment/attachment-types').AttachmentRef[];
621
+ /** Overrides `defaultPushPayload` for this message. */
622
+ push?: PushPayload;
568
623
  }): Promise<{
569
624
  messageId: string;
570
625
  }>;
@@ -22,6 +22,26 @@ export interface WireAttachmentRef {
22
22
  wrappedKeys?: WireDeviceWrappedKey[];
23
23
  thumbnailAttachmentId?: string;
24
24
  }
25
+ /**
26
+ * Cleartext metadata that wakes a recipient's device. Never message content: the
27
+ * relay can read every field here, which is the whole reason it can act on them.
28
+ *
29
+ * `delivery` says how the device should treat the push. The platform translates
30
+ * it per transport rather than deciding for the app.
31
+ */
32
+ export interface WirePushPayload {
33
+ title?: string;
34
+ body?: string;
35
+ badge?: number;
36
+ sound?: string;
37
+ category?: string;
38
+ threadId?: string;
39
+ customJson?: string;
40
+ /** Superseded by `delivery: 3`; still honoured by the server. */
41
+ voip?: boolean;
42
+ /** 0 ALERT, 1 MUTABLE, 2 SILENT, 3 VOIP. */
43
+ delivery?: number;
44
+ }
25
45
  export interface WireEnvelope {
26
46
  messageId: string;
27
47
  appId: string;
@@ -40,6 +60,8 @@ export interface WireEnvelope {
40
60
  plaintextPayload?: string;
41
61
  /** Zero or more attachment pointers (PROTOCOL_VERSION 4+). */
42
62
  attachments?: WireAttachmentRef[];
63
+ /** Wakes the recipient when they have no live session (PROTOCOL_VERSION 5+). */
64
+ pushPayload?: WirePushPayload;
43
65
  }
44
66
  export interface WireAck {
45
67
  messageId: string;
@@ -93,6 +115,8 @@ export interface WireGroupEnvelope {
93
115
  plaintextPayload?: string;
94
116
  memberPayloads: WireGroupMemberPayload[];
95
117
  attachments?: WireAttachmentRef[];
118
+ /** Wakes offline members (PROTOCOL_VERSION 5+). */
119
+ pushPayload?: WirePushPayload;
96
120
  }
97
121
  export interface WireGroupMessageNotification {
98
122
  messageId: string;
@@ -70,10 +70,29 @@ function buildAttachmentRefType() {
70
70
  .add(new protobuf.Field('wrappedKeys', 7, 'DeviceWrappedKey', 'repeated'))
71
71
  .add(new protobuf.Field('thumbnailAttachmentId', 8, 'string'));
72
72
  }
73
+ // Same rule as AttachmentRef above: Envelope and GroupEnvelope each nest
74
+ // PushPayload, so each needs its own instance. Sharing one would re-parent it
75
+ // onto whichever added it last and orphan it from the other, which is how the
76
+ // AttachmentRef outage happened. The Delivery enum nests inside it and is
77
+ // rebuilt per call for the same reason.
78
+ function buildPushPayloadType() {
79
+ return new protobuf.Type('PushPayload')
80
+ .add(new protobuf.Enum('Delivery', { ALERT: 0, MUTABLE: 1, SILENT: 2, VOIP: 3 }))
81
+ .add(new protobuf.Field('title', 1, 'string'))
82
+ .add(new protobuf.Field('body', 2, 'string'))
83
+ .add(new protobuf.Field('badge', 3, 'int32'))
84
+ .add(new protobuf.Field('sound', 4, 'string'))
85
+ .add(new protobuf.Field('category', 5, 'string'))
86
+ .add(new protobuf.Field('threadId', 6, 'string'))
87
+ .add(new protobuf.Field('customJson', 7, 'string'))
88
+ .add(new protobuf.Field('voip', 8, 'bool'))
89
+ .add(new protobuf.Field('delivery', 9, 'Delivery'));
90
+ }
73
91
  const EnvelopeType = new protobuf.Type('Envelope')
74
92
  .add(EncryptionTypeEnum)
75
93
  .add(DeviceEncryptedPayloadType) // nested type must be added first
76
94
  .add(buildAttachmentRefType())
95
+ .add(buildPushPayloadType())
77
96
  .add(new protobuf.Field('messageId', 1, 'string'))
78
97
  .add(new protobuf.Field('appId', 2, 'string'))
79
98
  .add(new protobuf.Field('fromUserId', 3, 'string'))
@@ -85,7 +104,8 @@ const EnvelopeType = new protobuf.Type('Envelope')
85
104
  .add(new protobuf.Field('senderDeviceId', 9, 'string'))
86
105
  .add(new protobuf.Field('encryptionType', 10, 'EncryptionType'))
87
106
  .add(new protobuf.Field('plaintextPayload', 11, 'string'))
88
- .add(new protobuf.Field('attachments', 12, 'AttachmentRef', 'repeated'));
107
+ .add(new protobuf.Field('attachments', 12, 'AttachmentRef', 'repeated'))
108
+ .add(new protobuf.Field('pushPayload', 13, 'PushPayload'));
89
109
  const AckType = new protobuf.Type('Ack')
90
110
  .add(new protobuf.Field('messageId', 1, 'string'))
91
111
  .add(new protobuf.Field('type', 2, 'string'));
@@ -138,6 +158,7 @@ const GroupEnvelopeType = new protobuf.Type('GroupEnvelope')
138
158
  .add(GroupEnvelopeEncryptionTypeEnum)
139
159
  .add(GroupMemberPayloadType)
140
160
  .add(buildAttachmentRefType())
161
+ .add(buildPushPayloadType())
141
162
  .add(new protobuf.Field('messageId', 1, 'string'))
142
163
  .add(new protobuf.Field('appId', 2, 'string'))
143
164
  .add(new protobuf.Field('groupId', 3, 'string'))
@@ -149,7 +170,8 @@ const GroupEnvelopeType = new protobuf.Type('GroupEnvelope')
149
170
  .add(new protobuf.Field('plaintextPayload', 9, 'string'))
150
171
  .add(new protobuf.Field('memberPayloads', 10, 'GroupMemberPayload', 'repeated'))
151
172
  // field 11 reserved upstream
152
- .add(new protobuf.Field('attachments', 12, 'AttachmentRef', 'repeated'));
173
+ .add(new protobuf.Field('attachments', 12, 'AttachmentRef', 'repeated'))
174
+ .add(new protobuf.Field('pushPayload', 13, 'PushPayload'));
153
175
  const GroupNotifEncryptionTypeEnum = new protobuf.Enum('EncryptionType', { E2EE: 0, CLEARTEXT: 1 });
154
176
  const GroupNotifDeviceType = new protobuf.Type('DeviceEncryptedPayload')
155
177
  .add(new protobuf.Field('deviceId', 1, 'string'))
package/dist/version.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * MINOR, additive feature (e.g. multi-device payloads, new call event type)
8
8
  * PATCH, bug-fix / perf improvement with no wire or API change
9
9
  */
10
- export declare const SDK_VERSION = "0.25.1";
10
+ export declare const SDK_VERSION = "0.26.0";
11
11
  /**
12
12
  * Binary encrypted-payload format version.
13
13
  * Included as the first byte of every encrypted payload so receivers can
package/dist/version.js CHANGED
@@ -10,7 +10,7 @@ exports.PROTOCOL_VERSION = exports.PAYLOAD_FORMAT_VERSION = exports.SDK_VERSION
10
10
  * MINOR, additive feature (e.g. multi-device payloads, new call event type)
11
11
  * PATCH, bug-fix / perf improvement with no wire or API change
12
12
  */
13
- exports.SDK_VERSION = '0.25.1';
13
+ exports.SDK_VERSION = '0.26.0';
14
14
  /**
15
15
  * Binary encrypted-payload format version.
16
16
  * Included as the first byte of every encrypted payload so receivers can
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@droponair/sdk-js",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "description": "End-to-end encrypted messaging, voice and video calling SDK. The relay never sees your keys or message content.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",