# DropOnAir JS SDK

End-to-end encrypted messaging, group chat, voice/video calls, and broadcast channels for any app.

DropOnAir operates as a **blind encrypted relay** - your users' encryption keys and message content never leave their devices.

## Getting Started

1. Create a free account at [panel.droponair.com](https://panel.droponair.com)
2. Create an app in the dashboard to get your **App ID** and **Public API Key**
3. Install the SDK:

## Installation

```bash
npm install @droponair/sdk-js
```

## Quick Start

```typescript
import { initialize } from '@droponair/sdk-js';

const client = await initialize({
  appId: 'your-app-id',                  // From the DropOnAir dashboard
  publicApiKey: 'your-public-api-key',    // From the DropOnAir dashboard
  getUserJwt: async () => {
    const res = await fetch('/api/auth/me', { credentials: 'include' });
    const { jwt } = await res.json();
    return jwt;
  },
  keyDirectoryEndpoint: '/api/messaging/keys',
  tokenExchangeEndpoint: '/api/messaging/token-exchange',
});

// Listen for incoming messages (already decrypted)
client.onMessage(({ fromUserId, plaintext, timestamp }) => {
  console.log(`${fromUserId}: ${plaintext}`);
});

// Send an E2EE message
await client.sendMessage('recipient-user-id', 'Hello!');
```

## Features

- **E2EE Messaging** - X25519 key agreement, AES-256-GCM encryption, multi-device support
- **Message Edit & Delete** - Edit sent messages or tombstone them for everyone or only your own devices
- **Cleartext Messaging** - Lightweight messages without E2EE overhead
- **Broadcast Channels** - Pub/sub for announcements and notifications
- **Group Messaging** - Server-managed groups with member roles
- **Voice & Video Calls** - 1-to-1 WebRTC call signaling with TURN support
- **Group Calls** - Mesh WebRTC group calls with per-participant signaling
- **Offline Delivery** - Messages queued and delivered when recipients reconnect
- **Multi-Device** - Per-device encryption with automatic self-sync

## API Reference

### Initialization

```typescript
const client = await initialize(options);
```

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `appId` | string | Yes | Your DropOnAir app ID |
| `publicApiKey` | string | Yes | Your DropOnAir public API key |
| `getUserJwt` | `() => Promise<string>` | Yes | Returns a fresh user JWT from your backend |
| `keyDirectoryEndpoint` | string | No | Public key directory URL (default: `/api/messaging/keys`) |
| `tokenExchangeEndpoint` | string | No | Token exchange URL (default: `/api/messaging/token-exchange`) |
| `messagingWsUrl` | string | No | WebSocket URL (default: `wss://sdk.droponair.com/ws/messages`) |
| `messagingHttpUrl` | string | No | HTTP URL (default: `https://sdk.droponair.com`) |
| `autoConnect` | boolean | No | Connect on init (default: `true`) |
| `storage` | KeyStorageAdapter | No | Custom key storage (default: IndexedDB) |
| `debug` | boolean | No | Enable debug logging (default: `false`) |

### Connection

| Method | Returns | Description |
|--------|---------|-------------|
| `connect()` | `Promise<void>` | Connect to DropOnAir (generates keys, exchanges JWT, opens WebSocket) |
| `disconnect()` | `void` | Disconnect and stop auto-reconnect |

### E2EE Messaging

| Method | Returns | Description |
|--------|---------|-------------|
| `sendMessage(toUserId, plaintext)` | `Promise<{ messageId }>` | Send an encrypted message |
| `editMessage(originalMessageId, toUserId, newText)` | `Promise<{ editId }>` | Edit a previously sent encrypted message |
| `deleteMessage(originalMessageId, toUserId, scope)` | `Promise<{ deleteId }>` | Delete a previously sent 1:1 message. `scope` is `FOR_EVERYONE` or `FOR_ME` |
| `sendCleartextMessage(toUserId, plaintext)` | `Promise<{ messageId }>` | Send a cleartext message (no E2EE) |
| `editCleartextMessage(originalMessageId, toUserId, newText)` | `Promise<{ editId }>` | Edit a previously sent cleartext message |
| `onMessage(callback)` | `() => void` | Listen for incoming messages. Returns unsubscribe function |
| `onMessageEdit(callback)` | `() => void` | Listen for inbound edits to 1:1 messages |
| `editGroupMessage(groupId, originalMessageId, newText, memberUserIds)` | `Promise<{ editId }>` | Edit a message you sent to a group, encrypted per member |
| `editCleartextGroupMessage(groupId, originalMessageId, newText)` | `Promise<{ editId }>` | Edit a cleartext message you sent to a group |
| `deleteGroupMessage(groupId, originalMessageId, scope?)` | `Promise<{ deleteId }>` | Delete a message you sent to a group. `scope` defaults to `FOR_EVERYONE` |
| `onGroupMessageEdit(callback)` | `() => void` | Listen for edits to group messages |
| `onGroupMessageDelete(callback)` | `() => void` | Listen for deletes of group messages |
| `onMessageDelete(callback)` | `() => void` | Listen for inbound delete tombstones for 1:1 messages |
| `onEvent(callback)` | `() => void` | Listen for system events (CONNECTED, DELIVERED, ERROR, etc.) |
| `ack(messageId)` | `Promise<void>` | Manually acknowledge a message |
| `registerPushToken({ platform, token, voipToken?, apnsEnvironment? })` | `Promise<void>` | Register this device for push notifications. `platform` is `'APNS'`, `'FCM'`, or `'WEB_PUSH'`. For APNs, `apnsEnvironment` (`'sandbox'` or `'production'`) says which APNs host issued the token. |
| `unregisterPushToken({ platform })` | `Promise<void>` | Unregister this device for push notifications (e.g. on logout). |
| `listMyDevices()` | `Promise<DeviceInfo[]>` | List the current user's registered devices. |
| `revokeMyDevice(deviceId)` | `Promise<DeviceInfo>` | Revoke one of the current user's devices. Permanent. |
| `markRead(messageId, conversationId?)` | `void` | Mark a message as read; relays a receipt to the user's other devices. |
| `onReadReceipt(callback)` | `() => void` | Listen for read receipts from the user's other devices. |
| `clearNotification(conversationId)` | `void` | Tell the user's other devices a conversation's notifications were dismissed. |
| `onNotificationCleared(callback)` | `() => void` | Listen for notification-clear syncs from the user's other devices. |
| `syncDraft(conversationId, draftText)` | `void` | Push a conversation draft to the user's other devices (opt-in, cleartext). |
| `onDraftSync(callback)` | `() => void` | Listen for draft syncs from the user's other devices. |

### Key custody

Every client reports how it is holding the identity private key:

```ts
const client = await initialize({ /* ... */ });
console.log(client.keyCustody); // 'software' | 'non-extractable'
```

`'software'` means the key is bytes the process can read. That is what every release before
0.25.0 did, and what this one still does unless you opt in below.

`'non-extractable'` means the key exists only inside the browser's crypto agent: your code can
ask it to derive a shared secret, and cannot read the key itself. Script that runs on your
origin can therefore use the identity while the page is open, and cannot copy it and decrypt
elsewhere or later.

Opt in by supplying an identity:

```ts
import { initialize, createSecureIdentity } from '@droponair/sdk-js';

const identity = await createSecureIdentity();
if (identity) {
  // Optional, once: carry an existing key over so the identity and its history survive.
  await identity.migrateFromKeyStorage(myStorage);
}

const client = await initialize({
  appId, publicApiKey, getUserJwt,
  identity: identity ?? undefined,   // null means this browser cannot provide it
});
```

`createSecureIdentity()` returns `null` where the platform cannot deliver the guarantee, rather
than throwing or quietly giving you something weaker. It checks two things: whether the
primitive exists, and whether a key survives being stored and read back. That second check
matters, because one major engine generates and derives with the key correctly and then loses
it on write while reporting success. Read `client.keyCustody` afterwards to see what you
actually got.

Migration is yours to trigger, not automatic, because it removes the readable copy of the key.
It imports the existing key, verifies it, and only then deletes the original. The public key is
unchanged, so peers still address the same identity and existing conversations keep decrypting.

### Cross-device read receipts

Available since SDK `0.11.0`. **Your app decides when a message is read**, and the platform never infers it. Call `markRead()` at that moment; the receipt syncs to the user's *other* devices so they can clear their unread UI. It is not sent to the message's sender. The app owner can switch read receipts off entirely from the dashboard.

```typescript
// When your UI decides the message has been read
client.markRead(messageId, peerUserId);

// On the user's other devices
client.onReadReceipt(e => {
  // e.messageId was read elsewhere, so clear your unread state for it
});
```

**Group read receipts.** Since SDK `0.13.0`, a group can opt into sharing read receipts with every member. Call `updateGroup(groupId, { readReceiptsVisibleToGroup: true })` (owner/admin); after that, a member's `markRead()` on a group message is broadcast to every other member, and `onReadReceipt` fires with `e.fromUserId` set to the member who read it. It is **opt-in per group**. The platform never enables it for you, and it has no effect unless read receipts are also enabled for your app.

```typescript
await client.updateGroup(groupId, { readReceiptsVisibleToGroup: true });

client.onReadReceipt(e => {
  // e.fromUserId is the group member who read e.messageId
});
```

### Notification clear & draft sync

Available since SDK `0.12.0`, both own-device only. `clearNotification()` tells your user's other devices a conversation's notifications were dismissed, and is always available. `syncDraft()` pushes a draft so the user can keep typing on another device, and is **opt-in** (the app owner enables it in the dashboard) and the draft text crosses the relay in cleartext.

```typescript
client.clearNotification(conversationId);
client.onNotificationCleared(e => { /* clear badge for e.conversationId */ });

client.syncDraft(conversationId, composerText);
client.onDraftSync(e => { /* pre-fill composer with e.draftText */ });
```

### Device trust

Available since SDK `0.10.0`. Any device that completes a connection is implicitly trusted. `listMyDevices()` powers a "Your devices" screen; `revokeMyDevice()` cuts a device off immediately. When the *current* device is revoked, the SDK stops reconnecting and emits a `DEVICE_REVOKED` event via `onEvent`. Listen for it to clear local key storage and prompt re-registration.

```typescript
const devices = await client.listMyDevices();
await client.revokeMyDevice('old-phone-device-id');

client.onEvent(e => {
  if (e.type === 'DEVICE_REVOKED') {
    // This device was revoked elsewhere. Clear local keys and show a re-register screen.
  }
});
```

### Push notifications

Available since SDK `0.9.0`. Customers configure their own APNs / FCM / VAPID credentials in the dashboard. The platform delivers a push only when the sender attaches a `pushPayload` to a message and the recipient device has no live WebSocket session. See the per-product availability and limits on your dashboard's Subscription page.


Register the token as soon as you have it. The SDK holds it until a connection
exists and registers it then, and re-registers on every connect, so there is no
need to wait for the client to connect or to retry yourself.
```typescript
// iOS, after didRegisterForRemoteNotificationsWithDeviceToken
await client.registerPushToken({ platform: 'APNS', token: deviceTokenHex });

// iOS + PushKit/CallKit (VoIP)
await client.registerPushToken({ platform: 'APNS', token: deviceTokenHex, voipToken: voipTokenHex });

// Say which APNs host issued the tokens, when your native layer knows: 'sandbox'
// for a build run from Xcode, 'production' for App Store, TestFlight and ad hoc.
// The relay then sends each token where it can be delivered, so development and
// store builds of your app both get pushes. Left out, the app's own setting decides.
await client.registerPushToken({
  platform: 'APNS', token: deviceTokenHex, voipToken: voipTokenHex, apnsEnvironment: 'production',
});

// Android, after FirebaseMessaging.getInstance().token
await client.registerPushToken({ platform: 'FCM', token: fcmToken });

// Browser, the token is the JSON returned by PushManager.subscribe()
await client.registerPushToken({ platform: 'WEB_PUSH', token: JSON.stringify(subscription) });

// On logout
await client.unregisterPushToken({ platform: 'APNS' });
```

#### Attaching a payload

A push is only sent when the sender attaches a payload, so set a default once and
every message carries it. Passing one on each send also works, but forgetting once
stops push with no error to notice.

```typescript
const client = await initialize({
  appId, publicApiKey, getUserJwt,
  defaultPushPayload: { title: 'New message', delivery: 'mutable' },
});

// Override for a single message
await client.sendMessage(userId, text, {
  push: { title: 'New photo', delivery: 'mutable', threadId: conversationId },
});
```

#### Choosing how the push behaves

`delivery` says what the recipient's device should do. The platform translates it
per transport, so you pick what suits your app rather than adopting somebody else's
model.

| `delivery` | What happens |
| --- | --- |
| `'alert'` | Title and body are shown exactly as supplied. Suits apps sending cleartext. |
| `'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. |
| `'silent'` | The app is woken and nothing is shown. **iOS throttles these and does not guarantee delivery**, so use `'mutable'` when being woken matters. |
| `'voip'` | Call invite: PushKit on iOS, high priority data on Android. Requires a registered VoIP token. |

Omit `delivery` and it defaults to `'alert'`.

**Never put message content in `title` or `body`.** Every field in the payload is
readable by the relay, which is exactly why it can act on them, so putting plaintext
there leaks it through the one path that otherwise never sees it. Send a placeholder
and use `'mutable'` to replace it on the device.


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.

### Message Edit & Delete

Available since SDK `0.4.0`.

```typescript
// Edit an encrypted message
await client.editMessage(originalMessageId, 'recipient-user-id', 'Updated text');

// Edit a cleartext message
await client.editCleartextMessage(originalMessageId, 'recipient-user-id', 'Updated text');

// Delete for everyone
await client.deleteMessage(originalMessageId, 'recipient-user-id', 'FOR_EVERYONE');

// Delete only on the sender's own devices
await client.deleteMessage(originalMessageId, 'recipient-user-id', 'FOR_ME');

// A group message: name the group, and pass the members you sent to
await client.editGroupMessage(groupId, messageId, 'Updated text', memberUserIds);
await client.deleteGroupMessage(groupId, messageId); // FOR_EVERYONE by default

client.onGroupMessageEdit((edit) => {
  console.log('group edit', edit.groupId, edit.originalMessageId, edit.plaintext);
});
client.onGroupMessageDelete((del) => {
  console.log('group delete', del.groupId, del.originalMessageId);
});
// Group edits and deletes also arrive after an absence, so applying one twice
// must be harmless, and a delete can name a message this device never received.

client.onMessageEdit((edit) => {
  console.log('edited', edit.originalMessageId, edit.text);
});

client.onMessageDelete((del) => {
  console.log('deleted', del.originalMessageId, del.scope);
});
```

- E2EE edits are re-encrypted per recipient device; the relay never sees the new plaintext.
- `FOR_EVERYONE` notifies the recipient; `FOR_ME` only syncs the tombstone to the sender's other devices.
- Each edit and each `FOR_EVERYONE` delete counts as one `MESSAGE` usage record.

### Attachments (Customer-Managed Storage)

Available since SDK `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.

```typescript
// Convenience: encrypt + upload + finalize in one call, then send with the message.
const ref = await client.prepareAttachmentAndUpload(fileBytes, {
  toUserId: 'recipient-user-id',
  mimeType: 'image/jpeg',
  encryptionType: 'E2EE',          // or 'CLEARTEXT' for public files
});

await client.sendMessage('recipient-user-id', 'Here is the file', { attachments: [ref] });

// Recipient side: incoming DecryptedMessage carries optional attachments[]
client.onMessage(async (msg) => {
  console.log(msg.plaintext);
  for (const att of msg.attachments ?? []) {
    const dl = await client.downloadAttachment(att);
    // dl.bytes is Uint8Array, dl.mimeType / dl.sizeBytes / dl.sha256 are populated
  }
});
```

| Method | Returns | Description |
|--------|---------|-------------|
| `prepareAttachmentAndUpload(bytes, options)` | `Promise<AttachmentRef>` | Encrypt (E2EE), upload, finalize, return ref |
| `createUploadSession(options)` | `Promise<UploadSession>` | Low-level: presigned PUT URL only |
| `finalizeAttachment(attachmentId, sha256)` | `Promise<void>` | Low-level: commit integrity hash |
| `retentionSeconds` (upload option) | | How long this one file should live. Never granted longer than your own retention. |
| `deleteWhenEveryoneHasIt` (upload option) | | Delete the stored bytes as soon as every recipient has fetched them. A later request is answered "no longer available". |
| `downloadAttachment(ref)` | `Promise<DownloadedAttachment>` | Get presigned URL + download + decrypt |
| `revokeAttachment(attachmentId \| ref)` | `Promise<void>` | Revoke an attachment you sent; recipients get `ATTACHMENT_REVOKED` |
| `sendMessage(toUserId, text, { attachments })` | `Promise<{ messageId }>` | Send with attachments |

- **Preview thumbnails (optional).** Pass `thumbnail` (bytes) in `prepareAttachmentAndUpload` options and the SDK uploads it as a separate encrypted attachment, linked via `AttachmentRef.thumbnailAttachmentId`. Purely the developer's choice; omit it and there is no thumbnail. Download the thumbnail like any attachment.
- **Revoke.** `revokeAttachment()` stops the platform issuing new download URLs and notifies recipients. Bytes a recipient already downloaded cannot be recalled. For GROUP attachments, download is authorized against *current* group membership, so a removed member loses access. Pass an `AttachmentRef` instead of an id (`revokeAttachment(ref)`, since 0.13.1) to revoke the linked preview thumbnail in the same call; a thumbnail is a separate attachment, so revoking a bare id leaves its thumbnail untouched.

- 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.
- Availability and per-file / per-month limits depend on your plan and are enforced server-side before the presigned URL is issued. See the [pricing page](https://www.droponair.com/pricing) and your dashboard Subscription page for what's enabled on your app.
- 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.

### Broadcast Channels

| Method | Returns | Description |
|--------|---------|-------------|
| `subscribeBroadcast(channelId)` | `Promise<void>` | Subscribe to a channel |
| `unsubscribeBroadcast(channelId)` | `Promise<void>` | Unsubscribe from a channel |
| `publishBroadcast(channelId, plaintext)` | `Promise<{ broadcastId }>` | Publish to a channel |
| `onBroadcast(callback)` | `() => void` | Listen for broadcast messages |

### Groups

| Method | Returns | Description |
|--------|---------|-------------|
| `createGroup(name, memberUserIds?)` | `Promise<GroupInfo>` | Create a group with optional members |
| `listGroups()` | `Promise<GroupInfo[]>` | List your groups |
| `getGroup(groupId)` | `Promise<GroupInfo>` | Get group details |
| `addGroupMembers(groupId, userIds)` | `Promise<GroupInfo>` | Add members (owner/admin) |
| `removeGroupMember(groupId, userId)` | `Promise<GroupInfo>` | Remove a member (owner/admin) |
| `updateGroup(groupId, { name?, readReceiptsVisibleToGroup? })` | `Promise<GroupInfo>` | Update group settings (owner/admin) |
| `deleteGroup(groupId)` | `Promise<void>` | Delete a group (owner only) |
| `sendGroupMessage(groupId, plaintext, memberUserIds, options?)` | `Promise<{ messageId }>` | Send an end-to-end encrypted group message (sender-side per-recipient fan-out). `options.attachments` for E2EE group attachments. |
| `sendCleartextGroupMessage(groupId, plaintext)` | `Promise<{ messageId }>` | Send a plaintext (non-encrypted) group message; server fans out to every other member. |
| `onGroupMessage(callback)` | `() => void` | Listen for group messages |

Delivered and seen are caught up on every connect, so a sender that was away
while someone fetched or read still learns about it. The state arrives through
the same delivered and read callbacks a live receipt uses, so there is nothing
extra to handle; make sure your handling is idempotent and only ever moves a
mark forward, since catch-up replays the current state rather than the changes.

**Group delivery is per member.** A group message has many recipients, so the platform
reports delivery as a set rather than a single flag and leaves the presentation to you.
`DELIVERED` and `SEEN` arrive as events whose `metadata` carries `messageId`, `groupId`
and `memberUserId`, the member the acknowledgement is about. Show one tick once the set
covers the group, or a read-by list, whichever suits your app. `memberUserId` is absent
on `SERVER_RECEIVED`, which is about the relay rather than any member.

**Group messages missed while offline arrive on connect.** Anything sent while the app
was closed or disconnected is drained automatically when it reconnects and delivered
through `onGroupMessage` just like a live message. There is nothing to call.

### Typing indicators

Say when someone starts and stops composing, and hear it about others. Nothing is
stored: it reaches only those connected at the time and is never replayed, so
treat a start as expiring on its own rather than waiting for the stop, which is
lost if the sender disconnects mid-sentence.

```typescript
client.sendTyping(groupId, true);
client.sendTyping(groupId, false);

client.onTyping((event) => {
  // event.conversationId, event.fromUserId, event.isTyping
});
```

### Signals, your own ephemeral messages

Typing says one thing. A signal says whatever your app means: the name is yours,
the data is yours, and the platform carries both without reading either. Two apps
can use the same relay for entirely different signals without knowing about each
other.

Ephemeral in the same way typing is: nothing is stored, it reaches only those
connected at the time, and it is never replayed. Treat a signal as expiring on
its own rather than waiting for one that cancels it, because that one is lost if
the sender disconnects.

A signal is a hint rather than a message. It is capped at two kilobytes and rate
limited per sender per conversation; anything past either is dropped without an
error.

```typescript
// Somebody started sending a photo, and the room can say so before it lands.
client.sendSignal(groupId, 'file.sending', 'photo');
client.sendSignal(groupId, 'file.sent');

const stop = client.onSignal((event) => {
  // event.name, event.data, event.fromUserId, event.conversationId
});
```

Available where the platform advertises `app_signals` in `GET /api/info`.

### 1-to-1 Calls

| Method | Returns | Description |
|--------|---------|-------------|
| `startCall(targetUserId, options?)` | `Promise<string>` | Start a call, returns callId. `options.push` rings a phone that is not connected |
| `acceptCall(callId)` | `Promise<void>` | Accept incoming call |
| `rejectCall(callId)` | `Promise<void>` | Reject incoming call |
| `endCall(callId)` | `Promise<void>` | End or cancel a call |
| `toggleVideo(callId, enabled)` | `void` | Toggle video on/off |
| `sendCallSignal(type, callId, payload)` | `void` | Send SDP/ICE signaling data |
| `onCallEvent(callback)` | `() => void` | Listen for call events |
| `fetchTurnCredentials()` | `Promise<TurnCredentials>` | Get TURN server credentials for NAT traversal |

#### Ringing a phone that is not connected

Without `options.push`, a call only reaches a device that already holds a
connection open, and an invite to anybody else is refused with `CALLEE_OFFLINE`.
That matters most for an app that closes its connection when it goes to the
background, which is the right thing to do, since the platform only pushes to
someone it believes is away.

```ts
const callId = await client.startCall(targetUserId, {
  push: {
    title: 'Ana',
    body: 'is calling',
    delivery: 'voip',
    customJson: JSON.stringify({ roomId }),
  },
});
```

The platform adds two keys to your `customJson` and takes nothing else away:

| Key | Meaning |
|-----|---------|
| `callId` | Which call this is, so a woken app can answer it without a connection |
| `call` | `"ring"` when it starts, `"stop"` when there is nothing left to answer |

Handle `"stop"` as well as `"ring"`. It arrives when the call is answered
elsewhere, refused, called off or never answered, and it is what stops a
notification claiming a call is waiting long after it is over. A device already
in the call receives it too and should ignore it: stop a call you are still
ringing, never one you are already in.

In a group this is decided per member, so anybody already connected is rung over
the connection and only the rest are woken.

A call never inherits `defaultPushPayload`. That default describes an arriving
message, and an app that set one has not thereby asked for its calls to start
reaching phones that are away.

Available where the platform advertises `call_push` in `GET /api/info`.

### Group Calls

| Method | Returns | Description |
|--------|---------|-------------|
| `startGroupCall(groupId, options?)` | `Promise<string>` | Start a group call, returns callId. `options.push` rings members who are not connected |
| `joinGroupCall(callId, groupId)` | `Promise<void>` | Join an active group call |
| `rejectGroupCall(callId, groupId)` | `Promise<void>` | Decline an incoming group call invite |
| `leaveGroupCall(callId)` | `Promise<void>` | Leave a group call |
| `endGroupCall(callId)` | `Promise<void>` | End a group call for all |
| `sendGroupCallSignal(type, callId, groupId, targetUserId, payload)` | `void` | Send SDP/ICE to a peer |
| `onGroupCallEvent(callback)` | `() => void` | Listen for group call events |

### Scheduled & Persistent Rooms

Available since SDK `0.14.0`. A **room** is an addressable container a live multi-party call runs inside - unlike a group, it is not tied to a member roster. Anyone in your app with the room id can attempt to join; the room's policy is the gate. "Scheduled" vs "persistent" is just how you compose the knobs - the platform imposes no meeting model.

| Method | Returns | Description |
|--------|---------|-------------|
| `createRoom(options?)` | `Promise<Room>` | Create a room (name, schedule, policy, hosts) |
| `listRooms()` | `Promise<Room[]>` | Rooms you created or host |
| `getRoom(roomId)` | `Promise<Room>` | One room, including live-call state |
| `updateRoom(roomId, update)` | `Promise<Room>` | Update name/schedule/policy/hosts (creator only) |
| `deleteRoom(roomId)` | `Promise<void>` | Delete a room (creator only) |
| `joinRoom(roomId)` | `Promise<string>` | Join the room's live call, returns callId |
| `leaveRoom(callId)` | `Promise<void>` | Leave the room's live call |
| `getSfuToken(roomId)` | `Promise<SfuToken>` | Token to join the room's SFU media (SFU-mode rooms only) |
| `startSfuRecording(roomId, destinationId)` | `Promise<SfuRecording>` | Start server-side recording (SFU + `mediaEncryption: 'SFU'` only) |
| `stopSfuRecording(roomId, recordingId)` | `Promise<SfuRecording>` | Stop a running SFU recording |
| `listSfuRecordings(roomId)` | `Promise<SfuRecording[]>` | List SFU recordings for a room, newest first |

Room policy (`RoomPolicy`, all optional): `waitingRoom` (non-hosts wait for host admit), `requireHost` (non-hosts cannot open the call), `maxParticipants` (per-room cap), `autoCloseWhenEmpty` (room flips to `CLOSED` when the last participant leaves), `mediaMode` (`'MESH'` default | `'SFU'`), `mediaEncryption` (`'E2EE'` default | `'SFU'`).

```typescript
const room = await client.createRoom({
  name: 'Weekly sync',
  scheduledStartAt: Date.now() + 3_600_000,   // metadata - your app drives the countdown UX
  policy: { waitingRoom: true, requireHost: true },
});

// Join the live call. The returned callId works with every group-call
// signaling method - pass '' for groupId.
const callId = await client.joinRoom(room.roomId);
client.onGroupCallEvent((e) => {
  // room-call events carry e.roomId; e.type GROUP_CALL_PARTICIPANT_JOINED, etc.
});
client.sendGroupCallSignal('GROUP_CALL_SDP_OFFER', callId, '', peerUserId, sdp);
```

`joinRoom` rejects with `HOST_REQUIRED`, `ROOM_CLOSED`, or `WAITING_ROOM_PENDING` (listen for `GROUP_CALL_WAITING_ROOM_ADMITTED`, then call `joinRoom` again).

### Live Stage

Available since SDK `0.15.0`. Create a room with `policy.stageMode` and its live call runs as a stage: hosts join as **speakers**, everyone else joins as receive-only **audience**. Audience can raise a hand; a host promotes them to speaker. The media topology is still the WebRTC mesh, so a stage is suited to panels and small/medium audiences - large-audience streaming needs an SFU and is out of scope.

| Method | Description |
|--------|-------------|
| `raiseHand(callId)` | Request promotion to speaker (any participant) |
| `lowerHand(callId)` | Lower your raised hand |
| `promoteToSpeaker(callId, userId)` | Promote an audience member (host / co-host) |
| `demoteToAudience(callId, userId)` | Demote a speaker (host / co-host) |
| `submitStageQuestion(callId, text)` | Submit a text question to the stage's speakers |

```typescript
const room = await client.createRoom({ name: 'AMA', policy: { stageMode: true, requireHost: true } });
const callId = await client.joinRoom(room.roomId);

// Audience side
client.raiseHand(callId);
client.submitStageQuestion(callId, 'How does E2EE key rotation work?');

// Host side - role changes arrive as GROUP_CALL_ROLE_CHANGED events
client.onGroupCallEvent((e) => {
  if (e.type === 'GROUP_CALL_STAGE_HAND_RAISED') client.promoteToSpeaker(callId, e.payload!);
  if (e.type === 'GROUP_CALL_ROLE_CHANGED') updateStageUi(e.payload);   // {"userId","role":"SPEAKER"|"AUDIENCE"}
});
```

Audience members are receive-only by convention: on `GROUP_CALL_ROLE_CHANGED` to `AUDIENCE`, your app simply does not publish a local media track. The platform signals the role; your app owns the WebRTC tracks.

### Routed media (SFU)

Available since SDK `0.17.0`. A room can opt out of peer-to-peer mesh and route its live-call media through the platform's media server. Set `policy.mediaMode` to `'SFU'` at create time, then ask the SDK for a join token; hand the token to your SFU client (e.g. LiveKit) to connect. `policy.mediaEncryption` controls whether the media server can decrypt the media: `'E2EE'` (default) keeps the platform blind, `'SFU'` lets the server terminate media so it can be recorded server-side.

```typescript
const room = await client.createRoom({
  name: 'All hands',
  policy: { mediaMode: 'SFU', mediaEncryption: 'E2EE' },
});

const sfu = await client.getSfuToken(room.roomId);
// sfu = { url, token, room, mediaEncryption, expiresAt }
// connect with your SFU client using sfu.url + sfu.token
```

Mesh remains the default; only switch to SFU once the participant count outgrows mesh or you specifically need server-side recording. The `getSfuToken` call returns `409` for mesh-mode rooms and `503` if the media server is not available.

### SFU recording

Available since SDK `0.18.0`. Server-side recording for SFU-mode rooms with `mediaEncryption: 'SFU'`. The platform's media server captures the room composite and uploads the finalized file directly to your storage bucket (S3, GCS, or Azure); the platform never holds the recorded bytes. Storage destinations are registered in the panel; the SDK references each by an opaque `destinationId`.

```typescript
const rec = await client.startSfuRecording(room.roomId, 'dst_aBc123');
// { recordingId, status: 'STARTING', startedAt, ... }

// Later, when the host ends the recording:
await client.stopSfuRecording(room.roomId, rec.recordingId);
```

Completion lands as the `sfu-recording.completed` webhook (or by polling `listSfuRecordings`) and carries `locationUri` pointing at the finalized file in your bucket. E2EE-encrypted SFU rooms cannot be server-recorded by design (the media server forwards traffic it cannot decrypt) - use the client-side `startRecording` for those.

Live call participants also receive in-band notification frames via the existing group-call event callback so a "🔴 RECORDING" UI badge stays in sync. The event types are `GROUP_CALL_SFU_RECORDING_STARTED` (payload = recordingId), `GROUP_CALL_SFU_RECORDING_STOPPED` (recordingId), and `GROUP_CALL_SFU_RECORDING_AVAILABLE` (`recordingId|locationUri`); the platform also notifies late joiners about any already-active SFU recording on the room. Plan limits (`maxSfuRecordingMinutesPerMonth`) are enforced server-side; deny at `startSfuRecording` returns HTTP 403 with a deny reason such as `MONTHLY_SFU_RECORDING_MINUTES_LIMIT_REACHED`.

### HTTP fallback (SSE) transport

Available since SDK `0.19.0`. For environments where WebSocket upgrades are blocked by a corporate firewall but plain HTTPS GET/POST work, the SDK exports a standalone `SseTransport` primitive that rides the platform's `/v1/transport/stream` (long-lived SSE) and `/v1/transport/send` (POST) endpoints. WebSocket on `/ws` remains the default for `client.connect()`; this primitive is opt-in for the corporate-firewall use case.

```typescript
import { SseTransport } from '@droponair/sdk-js';

const sse = new SseTransport({
  httpUrl: 'https://sdk.droponair.com',
  getJwt: async () => jwt,
});
sse.onFrame((bytes) => { /* protobuf decode */ });
await sse.connect();
await sse.sendEnvelope(envelopeBytes);
```

EventSource-based receive (auto-reconnects + 25s keep-alives that defeat proxy idle timeouts), `fetch` POST for send. Bring your own protobuf decoder (use the codec already exported by the SDK). Verify the lane is enabled on the server via `GET /api/info`: the `transports` array includes `"sse"` and `features` includes `"transport_sse"`. v1 covers 1:1 Envelope delivery; call signaling stays on WebSocket. Full `init({ transport: 'sse' })` integration into `MessagingClient` lands in a follow-up.

### WebTransport (HTTP/3) transport

Available since SDK `0.20.0`. For modern browsers + environments with native HTTP/3 support, the SDK exports a `WebTransportTransport` primitive that rides the platform's `/v1/transport/wt` endpoint. Multi-stream and lower head-of-line blocking versus WebSocket; particularly useful on flaky cellular networks.

```typescript
import { WebTransportTransport } from '@droponair/sdk-js';

const wt = new WebTransportTransport({
  httpUrl: 'https://sdk.droponair.com',
  getJwt: async () => jwt,
});
wt.onFrame((bytes) => { /* protobuf decode */ });
await wt.connect();
await wt.sendEnvelope(envelopeBytes);
```

Uses the browser-native `WebTransport` API; opens a single bidirectional stream and rides raw bytes both ways. Browser support: Chromium 97+ (stable), Firefox 125+ (stable), Safari Technology Preview only, Node 22+ behind experimental flags. The endpoint advertises `transports: ["webtransport"]` only where the lane is available; check `GET /api/info.transports` and `features.transport_webtransport` to detect it. Bring your own protobuf decoder. WebSocket remains the default; this primitive is purely opt-in.

### Transport auto-select

Available since SDK `0.21.0`. `selectTransport({ httpUrl })` queries `/api/info.transports`, intersects with what the runtime supports (`WebTransport` / `WebSocket` / `EventSource`), and returns the best lane for this client + platform pair. Default preference: WebTransport → WebSocket → SSE (overridable).

```typescript
import { selectTransport, WebTransportTransport, SseTransport } from '@droponair/sdk-js';

const lane = await selectTransport({ httpUrl: 'https://sdk.droponair.com' });
switch (lane) {
  case 'webtransport': /* new WebTransportTransport(...) */ break;
  case 'sse':          /* new SseTransport(...) */          break;
  default:             /* fall through to your WebSocket client */
}
```

Foundation for an `init({ transport: 'auto' })` shortcut once full `MessagingClient` integration lands (a follow-up). Today it's a small standalone helper so existing customers can adopt the pattern without waiting.

### Call Recording

Available since SDK `0.16.0`. The SDK **signals** recording state on a group or room call; your app does the actual media capture (`MediaRecorder`) and uploads the file to your own storage. The platform never holds the media. The recording signal is broadcast to every participant (including anyone who joins later); that transparency is enforced server-side.

| Method | Description |
|--------|-------------|
| `startRecording(callId)` | Signal that you started recording |
| `stopRecording(callId)` | Signal that you stopped recording |
| `markRecordingAvailable(callId, location)` | Announce an uploaded recording (`location` references it in your storage) |

```typescript
client.startRecording(callId);
// ... your app records the streams and uploads the file ...
client.markRecordingAvailable(callId, 's3://my-bucket/recordings/call-123.webm');
client.stopRecording(callId);

client.onGroupCallEvent((e) => {
  if (e.type === 'GROUP_CALL_RECORDING_STARTED') showRecordingBanner(e.payload);  // recorder userId
});
```

The platform also fires `recording.started` / `recording.stopped` / `recording.available` webhooks. For E2EE calls, recording stays on the client (the platform has no media plane); a server-side SFU recording mode is a future option.

### Screen Sharing

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.

```typescript
// 1:1 call
async function shareMyScreen(callId: string, peerConnection: RTCPeerConnection) {
  const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
  const track  = stream.getVideoTracks()[0];
  peerConnection.addTrack(track, stream);
  client.startScreenShare(callId);
  track.onended = () => client.stopScreenShare(callId);
}

// Group call
async function shareMyScreenInGroup(callId: string, groupId: string, peerConnections: RTCPeerConnection[]) {
  const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
  const track  = stream.getVideoTracks()[0];
  peerConnections.forEach(pc => pc.addTrack(track, stream));
  client.startGroupScreenShare(callId, groupId);
  track.onended = () => client.stopGroupScreenShare(callId, groupId);
}

client.onCallEvent((evt) => {
  if (evt.type === 'CALL_SCREEN_SHARE_STARTED')  showShareIndicator(evt.callId);
  if (evt.type === 'CALL_SCREEN_SHARE_STOPPED')  hideShareIndicator(evt.callId);
});

client.onGroupCallEvent((evt) => {
  if (evt.type === 'GROUP_CALL_SCREEN_SHARE_STARTED')  showShareIndicator(evt.callId, evt.payload);  // payload = sharer userId
  if (evt.type === 'GROUP_CALL_SCREEN_SHARE_STOPPED')  hideShareIndicator(evt.callId);
});
```

| Method | Returns | Description |
|--------|---------|-------------|
| `startScreenShare(callId, payload?)` | `void` | Signal start in a 1:1 call |
| `stopScreenShare(callId, payload?)` | `void` | Signal stop in a 1:1 call |
| `startGroupScreenShare(callId, groupId, payload?)` | `void` | Signal start in a group call |
| `stopGroupScreenShare(callId, groupId, payload?)` | `void` | Signal stop in a group call |

- 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.
- When a sharer leaves the call, the server broadcasts a STOPPED event to remaining participants before they see `PARTICIPANT_LEFT`.
- Availability depends on your plan. See the [pricing page](https://www.droponair.com/pricing) and your dashboard Subscription page for what's enabled on your app.

### Conference moderation and waiting room

Available since SDK `0.7.0`. The call initiator is the default host; host can transfer the role mid-call or appoint co-hosts. Host and co-host can mute, remove, and gate joiners through a waiting room. If the host leaves without transferring, the server auto-promotes the longest-joined remaining participant and broadcasts `GROUP_CALL_ROLE_CHANGED`.

```typescript
// Host actions
client.transferHost(callId, groupId, 'alice');
client.appointCoHost(callId, groupId, 'bob');
client.muteParticipant(callId, groupId, 'eve');           // signaling hint
client.removeParticipant(callId, groupId, 'mallory');     // server-enforced drop

// Waiting room
client.requestWaitingRoomEntry(callId, groupId);          // would-be joiner side
client.admitFromWaitingRoom(callId, groupId, 'newbie');   // host / co-host
client.rejectFromWaitingRoom(callId, groupId, 'troll');

client.onGroupCallEvent((evt) => {
  if (evt.type === 'GROUP_CALL_ROLE_CHANGED')          updateHostUi(evt.payload);   // {"userId":"...","role":"HOST|CO_HOST|PARTICIPANT"}
  if (evt.type === 'GROUP_CALL_WAITING_ROOM_JOINED')   showWaitingApprovalPrompt(evt.payload);
  if (evt.type === 'GROUP_CALL_WAITING_ROOM_ADMITTED') joinTheCall(evt.callId, evt.groupId);
  if (evt.type === 'GROUP_CALL_MUTE_PARTICIPANT')      muteMyMicLocally();
  if (evt.type === 'GROUP_CALL_PARTICIPANT_REMOVED')   exitCallUi();
});
```

| Method | Returns | Description |
|--------|---------|-------------|
| `transferHost(callId, groupId, newHostUserId)` | `void` | Host only |
| `appointCoHost(callId, groupId, userId)` | `void` | Host only |
| `revokeCoHost(callId, groupId, userId)` | `void` | Host only |
| `muteParticipant(callId, groupId, targetUserId)` | `void` | Host / co-host. Signaling hint - the target SDK mutes the local mic. |
| `removeParticipant(callId, groupId, targetUserId)` | `void` | Host / co-host. Server drops the target from the call session. |
| `requestWaitingRoomEntry(callId, groupId)` | `void` | Would-be joiner |
| `admitFromWaitingRoom(callId, groupId, userId)` | `void` | Host / co-host |
| `rejectFromWaitingRoom(callId, groupId, userId)` | `void` | Host / co-host |

- Mute is intentionally a signaling hint: WebRTC media tracks can't be muted from outside the producing client. Remove is server-enforced - the target SDK receives `GROUP_CALL_PARTICIPANT_REMOVED` and the others receive `PARTICIPANT_LEFT`.
- Availability depends on your plan. See the [pricing page](https://www.droponair.com/pricing) and your dashboard Subscription page for what's enabled on your app.

## Two identities at once

Every message carries the publisher's user id to whoever receives it, so a single
identity is a single visible identity everywhere the app speaks. When that is not
what you want, name a second instance. Each named instance has its own
connection, its own token, and its own key storage, so nobody observing them can
tell the two identities belong to one device.

The usual shape is a long-lived identity for the user's own conversations plus a
short-lived or anonymous one for an ephemeral context, running side by side.

```ts
const mine = await initialize({ ...options });
const anon = await initialize({ ...options, instanceName: 'nearby' });

await anon.publishBroadcast(channelId, 'hello');
```

| Option | Description |
|---|---|
| `instanceName` | Names this instance. Defaults to `'default'`. |

Only the default key storage is namespaced for you. A `storage` adapter you
supply yourself is used exactly as given, so give each instance its own.

Calls without a name use the instance named `default`, which keeps the key
storage an app already has. Upgrading changes nothing for existing code.

## Security

- **X25519 ECDH** key agreement for shared secrets
- **HKDF-SHA256** key derivation
- **AES-256-GCM** authenticated encryption with AAD binding (messageId, senderId, recipientId, timestamp)
- Private keys generated and stored locally, never sent to DropOnAir
- Multi-device: each device has its own keypair, messages encrypted per-device

## Requirements

- Node.js >= 18.0.0
- Browser with Web Crypto API support (all modern browsers)

## License

MIT

## Support

- **Website:** [droponair.com](https://droponair.com)
- **Email:** info@droponair.com
