# Changelog

## 0.45.0

### Added

- **`registerPushToken` takes `apnsEnvironment`** (`'sandbox'` or `'production'`),
  which tells the relay which APNs host issued the tokens. Each token is then
  sent to the host that can deliver it, so a developer's Xcode build and
  everybody's store build of the same app both receive pushes and calls. Left
  out, nothing changes: the app's own sandbox setting decides, as before. A
  JavaScript runtime usually cannot tell which build it is, so pass it from the
  native layer; the iOS SDK fills it in on its own.
- `GROUP_CALL_CANCELLED` in the group call event types: a phone that is ringing
  is told there is nothing left to answer (the caller ended or left, or the
  person answered or refused on another of their devices). The payload is JSON
  with `reason` and, when answered elsewhere, `answeredOn`.

### Fixed

- **Works on pages with a strict Content Security Policy.** The SDK no longer builds
  code at run time: the wire encoders and decoders are generated when the SDK is
  built. Before, on a page whose policy does not allow `'unsafe-eval'`, every
  incoming frame failed to decode (`frame_decode_failed`), so broadcasts, messages
  and call signals never arrived. The fix changes no API, and the bytes on the wire are
  identical.

### Changed

- `PROTOCOL_VERSION` 8. Versions are aligned across the JS, Android and iOS
  SDKs from here, so there is no JS 0.44.0.

## 0.42.0

### Changed

- **A member who has not published a messaging key no longer breaks the send for
  everybody else.** Such a member has joined but has nothing to encrypt to yet,
  and the key lookup used to fail the whole group send: one person still
  arriving made the conversation unsendable for all the others, with nothing
  saying why.

  They are skipped now, everybody reachable gets the message, and a
  `MEMBERS_NOT_REACHED` event says who was left out: `reason` is the
  conversation and `metadata` is a JSON array of user ids. The SDK reports the
  fact and does not decide what it means, so a conversation that says nothing
  here is one where somebody quietly missed something.

  The new exported `NoKeyPublishedYet` error distinguishes that case from a key
  directory answering with nonsense, because only one of the two is safe to
  carry on past.

## 0.41.0

### Added

- **Call some of a group rather than all of it.** `startGroupCall(groupId, { ring })`
  takes the user ids to ring. A group can hold more people than one call can
  carry, and only your app knows which of them a given call is for: naming them
  is the difference between offering a choice and ringing everybody and then
  hanging up on whoever answers.

  Leave `ring` out, or pass an empty list, and everybody in the group is rung,
  which is what this has always done. The field the names travel in was never
  read on the way in before, so nothing that exists today changes.

  A name that is not a member of the group rings nobody rather than quietly
  widening back to everybody: you asked for one person. The platform announces
  this as `group_call_ring_some` in `features`.

## 0.40.0

### Added

- **Ring a phone whose app is not connected.** `startCall(targetUserId, { push })`
  and `startGroupCall(groupId, { push })` take the same push payload shape a
  message takes. Attach one and somebody who is away is woken and can answer;
  attach nothing and behaviour is exactly as before, including the `CALLEE_OFFLINE`
  refusal for a 1:1 invite.

  This matters most for an app that closes its connection when it goes to the
  background, which is the right thing to do because the platform only pushes to
  someone it believes is away. Such an app previously could not be called at all.

  The platform adds two things to your `customJson` and takes nothing away:
  `callId`, so a woken app knows which call it is being asked to answer without
  a connection, and `call`, which is `"ring"` when it starts and `"stop"` when
  there is no longer anything to answer. Everything else, including the delivery
  mode, is forwarded untouched, because only your app knows what it has on each
  platform.

  Handle `"stop"` as well as `"ring"`. It arrives when the call is answered
  elsewhere, refused, called off or simply never answered, and it is what stops a
  notification sitting in the tray claiming a call is waiting long after it is
  over. A device that is 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.

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

### Fixed

- Declining a group call now reaches the rest of the group. `rejectGroupCall`
  has always sent the frame and the platform had no handler for it, so a refusal
  reached nobody and everybody else went on watching that name ring until the
  call ended. The group is told, and told who.

## 0.39.0

### Added

- **Signals: your own ephemeral messages to a conversation.** `sendSignal(conversationId, name, data)`
  sends something the platform carries and never reads. The name is whatever your
  app calls it, for example `"file.sending"`, and the data is whatever rides with
  it; `onSignal(callback)` receives them and returns an unsubscribe.

  Ephemeral in the same way typing is: never stored, delivered only to whoever is
  connected at that moment, never replayed afterwards. 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.

  Signals are capped at two kilobytes and rate limited per sender per
  conversation. A signal is a hint, not a message, and anything over the cap or
  past the rate is dropped without an error: a hint nobody received is not worth
  interrupting an app for.

  Requires a platform that advertises `app_signals` in `GET /api/info`.

## 0.38.0

### Added

- **An upload can be taken up again after the page was closed.** Pass
  `PrepareAttachmentOptions.onUploadStarted`, keep the `UploadHandoff` it hands
  over, and give it back as `resume` on the next try: the same bytes go back up
  the same session, and the parts the storage already has are not sent again. The
  handoff carries the file's own key, so keep it where the file is kept and
  nowhere else.

## 0.37.0

### Added

- **Uploads carry on where they stopped.** A file over 8 MB goes up in parts, and
  an upload broken by a dead connection sends only the parts the storage does not
  have yet. Nothing to change in an app: it happens inside
  `prepareAttachmentAndUpload`, and `onUploadProgress` counts the whole file as
  before.

## 0.36.0

### Added

- **Downloads carry on where they stopped.** A download that breaks keeps what it
  already had and asks the storage for the rest by range, retrying six times with a
  growing wait. Nothing to change in an app: it happens inside `downloadAttachment`.

## 0.35.0

### Added

- **Download progress.** `downloadAttachment(ref, { onProgress })` reports bytes as
  they arrive.
- **Real upload progress in browsers.** `onUploadProgress` now reports as the bytes
  go up where `XMLHttpRequest` exists; elsewhere it still reports once at the end.
- **`AttachmentUnavailableError`.** `downloadAttachment` throws it, with the HTTP
  status, when the file is no longer there to fetch: deleted once everybody had it,
  past its deadline, or revoked. A failure worth trying again stays a plain `Error`.

## 0.34.2

### Fixed

- **`retentionSeconds` and `deleteWhenEveryoneHasIt` were ignored by
  `prepareAttachmentAndUpload`.** They reached the upload session only when it was
  called directly; the everyday path dropped them, so a file lived for the app's
  full retention and was never deleted early. Both now pass through, thumbnail
  included.

## 0.34.1

### Fixed

- **A file was deleted before the last recipient could download it.** With
  `deleteWhenEveryoneHasIt`, the platform counted a recipient as having the file the
  moment it issued their download link, so the final recipient got a link to a file
  already gone. The SDK now confirms receipt only after the download and decrypt
  succeed, and the platform deletes only on those confirmations. Nothing to change
  in an app: it happens inside `downloadAttachment`.

## 0.34.0

### Added

- **Files in group messages.** `sendGroupMessage(groupId, text, memberUserIds, { attachments })`
  now carries attachment references, and a received `DecryptedGroupMessage` carries
  `attachments`. Each member is handed the pointer and only the file keys wrapped for
  their own devices. A file sent while a member was away arrives with the message when
  they catch up, which previously carried nothing.
- **A lifetime per file.** `retentionSeconds` on an upload session asks for one file to
  live a set time. Never longer than the app's own retention.
- **`deleteWhenEveryoneHasIt`.** The platform deletes the stored bytes as soon as every
  recipient has fetched them, and answers a later request with "no longer available"
  rather than a broken link.

## 0.33.0

### Added

- **Edit and delete group messages.** `editGroupMessage(groupId, originalMessageId,
  newText, memberUserIds)`, `editCleartextGroupMessage` and `deleteGroupMessage`
  (scope `FOR_EVERYONE` by default, or `FOR_ME`). An E2EE edit is encrypted for each
  member and device, as a group message is. Only the sender of a message can change it.
- **`onGroupMessageEdit` and `onGroupMessageDelete`.** Fired live, and again from
  the offline fetch for a member who was away, after that member's messages, so
  applying one twice must be harmless. A member who fetches after a delete for
  everyone is not handed the message.

## 0.32.1

### Fixed

- **A sender reported an error for every message it sent.** A message is echoed
  back to every device of the sender, including the one that sent it, and what it
  carries is encrypted for the recipients. The sending device tried to decrypt it,
  which cannot work, and reported the failure each time. Such an echo is now
  recognised and skipped. Nothing was lost before; the error was always wrong,
  and an error that is always wrong hides the ones that are not.

- **Reconnecting could stop for good.** A reconnect attempt that failed before a
  socket existed, typically fetching a token with no network, emitted
  `RECONNECT_FAILED` and scheduled nothing further, so the client stayed offline.
  It now schedules the next attempt while reconnecting is still wanted.

## 0.32.0

### Added

- **Run a second identity alongside the first.** `initialize` now takes a name, and
  each named instance gets its own connection, its own token, and its own key
  storage. The identities are not linkable to each other by anyone observing
  them, which a single instance could not offer: every message carries the
  publisher's user id to its recipients, so one identity meant one visible
  identity everywhere the app spoke.

  Use it when an app needs a long-lived identity for the user's own
  conversations plus a short-lived or anonymous one for an ephemeral context.
  
  ```ts
  const mine = await initialize({ ...opts });
  const anon = await initialize({ ...opts, instanceName: 'nearby' });
  ```

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

  Existing code is unaffected. Calls without a name use the instance named
  `default`, which keeps the key storage an app already has, so upgrading does
  not change the identity a device has been using.


## 0.31.0

### Fixed

- **People on different platforms could not send to each other.** Public keys are
  published to a shared directory, and the SDKs disagreed on how to write them:
  one used the standard base64 alphabet and another the URL-safe one. Each could
  read what it had written and none could read the others, so encrypting for a
  member on another platform failed outright with a decoding error, and the send
  failed for the sender rather than for that member.
- Keys are now decoded in either alphabet, with or without padding, so a
  directory shared between platforms works whatever wrote the entry.

### Notes

- Nothing needs republishing: existing entries in either form are readable.
- This affects any app whose users are on more than one platform.

## 0.30.0

### Added

- **Typing indicators.** `sendTyping(conversationId, isTyping)` and `onTyping(callback)`. Nothing in the platform carried this before, so an
  app that wanted to show someone composing had nowhere to send it.
- Typing is never stored. It describes this moment and is worth nothing after it,
  so it reaches only those connected at the time and is never replayed. Treat a
  start as expiring on its own after a few seconds rather than waiting for the
  matching stop, which is lost if the sender disconnects mid-sentence.
- Gated separately from read receipts, which name a message that was read and are
  opted into per group. Typing says only that somebody is composing and discloses
  nothing about what, so it needs no per-group opt-in.

## 0.29.0

### Fixed

- **Delivered and seen no longer depend on the sender being online.** A receipt
  existed only as a frame on a live socket, so a sender who was away when someone
  fetched or read the message never learned it and the mark stayed where it was
  for good. An app that disconnects in the background, which is what an app has to
  do to be pushed at all, missed nearly all of them. The SDK now catches up on
  connect and reports the state as the ordinary delivered and read callbacks, so
  there is no new API to adopt: existing receipt handling starts working.

### Notes

- Catch-up covers the messages you sent most recently and replays their current
  state, so handling must be idempotent. Marks should only ever move forward.
- Additive and backward compatible. A relay that predates this answers 404 and
  the SDK treats that as nothing to catch up on.

## 0.28.0

### Fixed

- **A push token offered before the client connected was thrown away.** The push
  service hands an app its device token at launch, always before a connection
  exists, so `registerPushToken` refusing it there lost the token exactly when
  every app first offers it, and push simply never arrived. The token is now held
  and registered as soon as a connection exists. It is also re-registered on every
  connect, because the relay ties a token to a session: after a reconnect or a
  relay restart the device would otherwise be silently unreachable.

### Notes

- `registerPushToken` no longer rejects a call made before connecting. Existing
  code that catches that error still compiles and simply stops seeing it.
- `unregisterPushToken` drops the held token, so it is not re-registered later.

## 0.27.0

### Added

- **Group messages sent while you were offline are now delivered.** On connect the SDK
  drains anything it missed and hands each message to `onGroupMessage` exactly as if it
  had arrived live, so a message sent to a closed or disconnected app is no longer lost.
  Nothing to call: it happens on every connect, beside the one-to-one drain.
- **Delivery receipts on group messages name the member they are about.** The group
  acknowledgement now carries `memberUserId`. A group message has many recipients, so
  delivery is a set rather than a flag, and the platform hands you the set rather than
  collapsing it: show a single tick once everyone has it, or a read-by list, whichever
  your app wants. `memberUserId` is empty on `SERVER_RECEIVED`, which is about the relay
  rather than any member.

### Notes

- Group messages were always stored for absent members but nothing ever returned them,
  so they were silently lost and `DELIVERED` could never arrive. Both are fixed.
- Additive and backward compatible. An older SDK simply never asks for the missed
  messages and behaves exactly as it does today; a relay that predates this answers
  404 and the SDK treats that as nothing to catch up on.

## 0.26.0

### Added

- **Push payloads on outgoing messages.** `sendMessage` and `sendGroupMessage` accept
  `options.push`, and `defaultPushPayload` on the client config attaches one to every
  message that does not carry its own. Setting the default once is the recommended
  route: passing a payload per send means forgetting it once stops push with no error
  to notice.
- **`PushDelivery` chooses how a push behaves** on the recipient's device, and the
  platform translates it per transport rather than deciding for you:
  - `alert` shows title and body as supplied. Suits apps sending cleartext.
  - `mutable` shows them as a placeholder and lets your app replace the text before it
    appears, which is how an end-to-end encrypted app puts real content on a lock
    screen without the relay seeing it. On iOS this is also the reliable way to be
    woken, because the system throttles silent pushes.
  - `silent` wakes the app and shows nothing.
  - `voip` is the call invite path, superseding the `voip` boolean, which is still honoured.

### Notes

- Push previously could not be triggered at all: no SDK exposed a way to attach a
  payload, and the server only pushes when the sender supplies one.
- Never put message content in `title` or `body`. Every field is readable by the relay,
  which is why it can act on them, and putting plaintext there leaks it through the one
  path the platform promises never sees it.
- Additive and backward compatible. Messages sent without a payload behave exactly as
  before, and an absent or unrecognised delivery mode is treated as `alert`.

## 0.25.1

### Changed

- Documentation only, no behaviour or API change. Comments and notes that shipped in the
  package described the platform's own internals rather than the integration surface: some
  named components a consumer never talks to, and some carried planning tags that mean nothing
  outside our own backlog. They are rewritten in terms of what the code does and what the
  platform offers.
- Where availability used to be described as a specific plan flag, it now points at the pricing
  page and the app's subscription, which are the two places that stay correct as plans change.
- `PROTOCOL_VERSION` references are untouched. That is the contract a consumer integrates
  against and it remains the way to tell what a relay supports.
- Em dashes are gone from the shipped text, per the house style. Each was judged in place
  rather than swapped mechanically, so clauses read as sentences rather than comma splices.

## 0.25.0

### Added

- **`keyCustody` on the client**, reporting how the identity private key is held:
  `'non-extractable'` when the key exists only inside the platform's crypto agent and cannot
  be read by script, `'software'` when it is bytes the process can read. Every release before
  this one behaved as `'software'`, and still does unless the new option below is used. Read it
  rather than assuming: a consumer that believes it has the stronger custody and does not is
  worse off than one that knows.
- **`createSecureIdentity()`** and **`InitializeOptions.identity`**, an opt-in identity whose
  private key script cannot read. The key is generated non-extractable and used through the
  agent for the key agreement, so an attacker who runs script on the origin can ask it to
  derive while the page is open, and cannot steal the key for later or elsewhere.
- **`WebCryptoIdentityProvider`**, **`IndexedDbIdentityRecordStore`**,
  **`MemoryIdentityRecordStore`** and the **`IdentityRecordStore`** interface are exported, so
  custody can be implemented against something else entirely.

### Notes

- **Opt-in, and unchanged by default.** Passing no `identity` keeps exactly the previous
  behaviour. The stronger path is offered, not imposed.
- **`createSecureIdentity()` returns `null` where the platform cannot deliver it**, rather than
  throwing or quietly substituting something weaker. Two conditions are checked: whether the
  primitive exists, and whether a key survives being stored and read back.
- **Availability differs by engine.** The protected path requires an X25519 key that persists.
  Chromium provides that. WebKit generates and derives correctly and then loses the key when it
  is stored, reporting the write as successful, so `createSecureIdentity()` returns `null`
  there and the ordinary path is used. This is checked at runtime rather than assumed from a
  user agent.
- **Existing identities can be carried over** with `migrateFromKeyStorage()`, which imports the
  stored key, verifies it, and only then removes the readable copy. The public key is unchanged,
  so peers still address the same identity and existing conversations keep decrypting. Nothing
  is re-keyed and no history is lost.
- The wire format is untouched. Both paths perform the same X25519 agreement and derive the
  same message key, which is covered by tests in both directions.

## 0.24.3

- Fix: broadcasts (and events/acks) misrouted. Inbound Ack/GroupAck discriminators now require a real SCREAMING_SNAKE ack token, so a BroadcastNotification (publisherId in the type slot) is no longer decoded as a GroupAck before reaching the broadcast branch. Completes the frame-classifier fix.

## 0.24.2

- Fix: E2EE group message decryption failed because the decrypt AAD bound `recipientId` to the group id instead of the receiving user's own id (which is what the sender encrypts against). Now matches the sender and the other SDKs. Requires a relay that preserves the sender timestamp on group notifications.

## 0.24.1

- Fix: incoming frame classifier misrouted server-pushed Event and Broadcast frames as group messages (protobuf field overlap), so `onBroadcast` never fired and events like `LIMIT_REACHED` were swallowed. Event is now probed with a strict type check before group/broadcast/ack, and group notifications additionally require `senderDeviceId`.

All notable changes to the DropOnAir JS SDK will be documented in this file.

This project follows [Semantic Versioning](https://semver.org/).

---

## [0.24.0], 2026-07-30

### Added

- **`rejectGroupCall(callId, groupId)`** - decline an incoming group-call invite (sends `GROUP_CALL_REJECTED`). Brings the JS SDK to parity with the Android, iOS, and Unity SDKs, which already exposed it.

---

## [0.23.2], 2026-06-04

### Fixed

- **Status acks (SERVER_RECEIVED / DELIVERED / PROCESSED) were mis-decoded and never reached the app.** Inbound `Ack {messageId, type}` frames were being decoded as `GroupMessageNotification` (the ack's `type` lands in the notification's `groupId`, field 2) because the group-notification / group-ack / broadcast decode guards only checked fields 1-2, which an ack satisfies. They now also require field 3 (`fromUserId` / `type` / `publisherId`), which every real group/broadcast frame carries but an ack lacks, so acks fall through to the ack decoder. This regressed in 0.22.1: making `GroupMessageNotification` decodable (the AttachmentRef fix) removed the decode-time throw that previously let acks fall through. Net effect of the bug: a sender's message never advanced past "sending" even though it was delivered. Also hardened the inbound frame handler to never drop the channel on an undecodable frame.

---

## [0.23.1], 2026-06-01

### Fixed

- **WebSocket reconnect storm / eviction loop.** Reconnect is now single-flight: each connect attempt claims a generation, and a superseded socket's `onOpen`/`onClose`/`onError` callbacks are ignored. A stale socket closing (notably the old socket the server evicts with close code `4000 REPLACED_BY_SAME_DEVICE` right after our own reconnect) no longer nulls the live transport or schedules another reconnect, which was causing rapid connect/disconnect flapping. The client also no longer reconnects on close code `4000` (another connection is authoritative), reconnect timers never stack, and backoff gained jitter.
- **`Cannot read properties of null (reading 'send')` on send.** `sendMessage`'s open-check happens before a long async encryption phase during which the socket can close. Sends now go through a guarded `sendFrame` that re-checks the captured transport and throws a clean, retryable "not connected" error instead of dereferencing null.

---

## [0.23.0], 2026-06-01

### Added

- **Idempotent retries via `sendMessage(..., { clientMessageId })`.** Pass a stable id to reuse across retries of the same logical message. The relay now dedups by `(appId, fromUserId, messageId)`, so re-sending after a lost ack or a reconnect delivers the message at most once instead of creating duplicates. Omit it for new messages and a fresh id is generated (unchanged default behavior). Requires relay support shipped alongside this release; wire format and `PROTOCOL_VERSION` are unchanged (the `clientMessageId` field already existed).

---

## [0.22.1], 2026-05-31

### Fixed

- **Message send/receive broken when attachments support is compiled in.** The protobuf reflection tree shared a single `AttachmentRef` type instance across `Envelope`, `GroupEnvelope`, and `GroupMessageNotification`. Because a protobufjs reflection object can have only one parent, adding it to a later message re-parented it and removed it from `Envelope`, so encoding any 1:1 message threw `no such Type or Enum 'AttachmentRef' in Type Envelope` before a frame was ever produced. Each message now builds its own `AttachmentRef` (and nested `DeviceWrappedKey` / `EncryptionType`) instance. Wire format is unchanged, this is purely a client-side construction fix. Also resolves the same latent failure on `GroupMessageNotification` decode.

---

## [0.22.0], 2026-05-25

### Added

- **MessagingClient transport selection.** `initialize({ transport: 'ws' | 'sse' | 'wt' | 'auto' })` lets your app choose the signaling lane at init time. Default stays `'ws'`, so existing integrations are byte-identical. `'auto'` queries `GET /api/info.transports` and picks the best available lane from what the runtime supports. WebSocket close-code semantics (1008 JWT-expiry surfacing, 4001 JWT-refresh-on-event) are preserved on the WebSocket path; SSE/WT synthesize close events.
- **MQTT (IoT) broadcast lane.** Broadcasts now mirror to MQTT topic `droponair/v1/broadcast/{appId}/{channelId}` on `mqtts://mqtt.droponair.com:8883`. For IoT subscribers that consume your broadcasts without our full SDK. Subscribe-only, per-app credentials, TLS only. Visible via `GET /api/info` (`transports` includes `"mqtt"`, `features` includes `"transport_mqtt"`). No SDK API change, the lane is consumed directly by MQTT clients.

### Changed

- `MessagingClient` now uses an internal `MessagingTransport` adapter rather than a direct `WebSocket` reference. WebSocket remains the default; no protocol change. Adapters: `WebSocketMessagingTransport` (default), `SseMessagingTransport`, `WtMessagingTransport`.

---

## [0.21.0], 2026-05-24

### Added

- **Transport auto-select helper.** New `selectTransport({ httpUrl })` reads `/api/info.transports`, intersects with the runtime's `WebTransport`/`WebSocket`/`EventSource` availability, and returns the best lane (preference: WebTransport -> WebSocket -> SSE; overridable via `preference`). Caller then constructs the corresponding primitive. Pluggable `fetchFn` for Node + tests.

### Notes

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

---

## [0.20.0], 2026-05-24

### Added

- **WebTransport (HTTP/3) transport primitive.** New `WebTransportTransport` class uses the browser-native `WebTransport` API to ride the platform's `/v1/transport/wt` HTTP/3 endpoint,. Same shape as `SseTransport`: `onFrame`, `sendEnvelope`, `connect`, `close`. Caller brings own protobuf decoder. Browser-only for v1 (Chromium 97+, Firefox 125+, Safari TP-only); Node 22+ has experimental support behind flags.

### Notes

- WebTransport gives multi-stream perf wins over WebSocket on flaky cellular and similar networks. WebSocket on `/ws` remains the default; this primitive is opt-in for environments that have native WebTransport.
- The droponair.com endpoint only advertises `transports: ["webtransport"]` once the HTTP/3 lane is enabled and `app.transport.webtransport-enabled` is flipped on the platform. Check `GET /api/info.transports` for the live list.

---

## [0.19.0], 2026-05-24

### Added

- **SSE transport primitive.** New `SseTransport` class exposes the platform's HTTP-fallback lane (`/v1/transport/stream` + `/v1/transport/send`) as a small standalone client, for environments where WebSocket upgrades are blocked by corporate firewalls but HTTPS GET/POST work. EventSource-based receive (auto-reconnects), `fetch` POST for send. Bring your own protobuf decoder (use the codec already exported from the SDK).

### Notes

- v1 is a primitive; full `init({ transport: 'sse' })` integration into `MessagingClient` lands in a follow-up release. Customers who need the SSE lane today can use `SseTransport` directly alongside the existing WS client.
- WebSocket on `/ws` remains the default for all SDK calls; this addition is purely opt-in. Older code paths and existing apps are unaffected.

---

## [0.18.0], 2026-05-23

### Added

- **SFU recording.** Server-side recording for SFU-mode rooms with `mediaEncryption: 'SFU'`. The platform's media server records the composite of every track in the room and uploads the finalized file directly to the customer's storage bucket - the platform never holds the recorded bytes. Storage destinations (S3 / GCS / Azure) are registered once in the panel; the SDK references each by an opaque `destinationId`.
- Three new client methods:
  - `startSfuRecording(roomId, destinationId)` -> `SfuRecording`
  - `stopSfuRecording(roomId, recordingId)` -> `SfuRecording`
  - `listSfuRecordings(roomId)` -> `SfuRecording[]`
- New `SfuRecording` type exported from the package root.
- Completion is also observable via the new runtime webhooks: `sfu-recording.started`, `sfu-recording.completed`, `sfu-recording.failed`.

### Notes

- Only rooms with both `policy.mediaMode: 'SFU'` and `policy.mediaEncryption: 'SFU'` are server-recordable; E2EE-encrypted SFU rooms cannot be recorded by design (the media server forwards traffic it cannot decrypt). For E2EE rooms, continue using the client-side `startRecording` (0.16.0).

---

## [0.17.0], 2026-05-22

### Added

- **SFU media transport (opt-in per room).** Two new `RoomPolicy` fields choose how a room's live call moves media: `mediaMode: 'MESH' | 'SFU'` (default `'MESH'`) and `mediaEncryption: 'E2EE' | 'SFU'` (default `'E2EE'`). Mesh stays peer-to-peer; SFU routes through the platform's media server, suited to larger calls. With `E2EE` the media server forwards traffic it cannot decrypt, so the platform stays a blind relay even in routed mode.
- **`getSfuToken(roomId)`** - fetches a short-lived access token to join a room's SFU media. Returns `{ url, token, room, mediaEncryption, expiresAt }`. Hand the URL + token to your SFU client (e.g. LiveKit) to connect; mesh-mode rooms don't need this call. New `SfuToken` type exported from the package root.

### Notes

- The SFU is one transport option you compose per room - mesh remains the default and is fine for small calls. SFU mode is required only when the participant count outgrows mesh or when you need server-side recording (`mediaEncryption: 'SFU'`).

---

## [0.16.0], 2026-05-21

### Added

- **Call recording signal.** `startRecording(callId)` / `stopRecording(callId)` / `markRecordingAvailable(callId, location)` for group and room calls. The SDK signals recording state; your app owns the media capture and uploads the file to your own storage (the same split as screen sharing - the platform never holds media). The platform broadcasts the signal to every participant, including late joiners, and fires `recording.started` / `recording.stopped` / `recording.available` webhooks. New `GROUP_CALL_RECORDING_*` event types.

### Notes

- A recording is always signaled to every participant - that transparency is enforced server-side and is not optional. Control-plane signaling only; no proto change.

---

## [0.15.0], 2026-05-21

### Added

- **Live stage roles & audience controls.** A room created with `policy.stageMode` runs its live call as a stage: hosts join as speakers, everyone else joins as receive-only audience. New `raiseHand` / `lowerHand` (any participant), `promoteToSpeaker` / `demoteToAudience` (host / co-host), and `submitStageQuestion` - all act on the callId from `joinRoom`. Role changes surface via `GROUP_CALL_ROLE_CHANGED` events (role `SPEAKER` / `AUDIENCE`); the room-call participant list now carries each participant's role.
- New `GROUP_CALL_STAGE_*` event types.

### Notes

- Stage mode is a roles + signaling layer, the media topology is still the WebRTC mesh, suited to panels and small/medium stages. Large-audience streaming needs an SFU and is out of scope. Pure control-plane signaling; no proto change.

---

## [0.14.0], 2026-05-21

### Added

- **Scheduled & persistent rooms.** New `createRoom` / `listRooms` / `getRoom` / `updateRoom` / `deleteRoom` REST methods and `joinRoom(roomId)` / `leaveRoom(callId)`. A room is an addressable container a live multi-party call runs inside - it is not tied to a group's member roster. Per-room `policy` knobs (`waitingRoom`, `requireHost`, `maxParticipants`, `autoCloseWhenEmpty`) and optional `scheduledStartAt` / `scheduledEndAt` metadata let you compose scheduled or persistent meetings; the platform imposes no meeting model.
- `joinRoom` resolves with a callId that works with the existing group-call signaling methods (`sendGroupCallSignal`, screen-share, moderation, `leaveGroupCall`) - pass an empty string for `groupId`. Room-call events arrive via `onGroupCallEvent` and carry `roomId`.
- New `Room`, `RoomPolicy`, `CreateRoomOptions`, `UpdateRoomOptions` types.

### Notes

- Additive only. The live room call reuses the group-call mesh; on the wire it is the existing `GroupCallFrame` with the new optional `roomId` field. No PROTOCOL_VERSION change.

---

## [0.13.1], 2026-05-21

### Changed

- **`revokeAttachment` accepts an `AttachmentRef`.** Calling `revokeAttachment(ref)` now also revokes the linked preview thumbnail (`ref.thumbnailAttachmentId`) in the same call. `revokeAttachment(attachmentId)` with a plain id string is unchanged and revokes only that one attachment. Additive, so no breaking change.

---

## [0.13.0], 2026-05-21

### Added

- **Group read receipts.** When a group has `readReceiptsVisibleToGroup` enabled, a member's `markRead(messageId, groupId)` is fanned to every other member, not just the reader's own devices. The inbound `ReadReceiptEvent` now carries `fromUserId` so you know which member read the message. New `client.updateGroup(groupId, { name?, readReceiptsVisibleToGroup? })` controls the per-group flag (it is a group-level choice; off by default). Broadcasts stay own-device only by design.
- **Attachment revoke.** New `client.revokeAttachment(attachmentId)`. The platform stops issuing download URLs for it and recipients with a live connection get an `ATTACHMENT_REVOKED` event via `onEvent` (metadata = attachmentId). Bytes already downloaded cannot be recalled.
- **Attachment preview thumbnails.** `prepareAttachmentAndUpload` accepts an optional `thumbnail` (and `thumbnailMimeType`). Entirely optional: if provided, the SDK uploads it as a separate attachment encrypted the same way and links it via `AttachmentRef.thumbnailAttachmentId`; download it like any attachment. If omitted, there is simply no thumbnail.

### Notes

- Download authorization for GROUP attachments is now checked against *current* group membership: a user removed from a group can no longer download its attachments even if they were a recipient at upload time.
- No PROTOCOL_VERSION change: additive proto fields (`SyncFrame.fromUserId`, `AttachmentRef.thumbnailAttachmentId`) and an additive event type.

---

## [0.12.0], 2026-05-20

### Added

- **Notification clear sync.** `client.clearNotification(conversationId)` tells the user's other devices a conversation's notifications were dismissed; `client.onNotificationCleared(cb)` listens for it. Always available (no opt-out), since it is plain own-device hygiene.
- **Draft sync.** `client.syncDraft(conversationId, draftText)` pushes a conversation draft to the user's other devices; `client.onDraftSync(cb)` listens for it. **Opt-in:** the app owner enables it in the dashboard, and the draft text crosses the relay in cleartext (fanned out, never stored). When disabled the server drops the frame.
- New exported types: `NotificationClearEvent` / `NotificationClearCallback`, `DraftSyncEvent` / `DraftSyncCallback`.

### Notes

- Both ride the existing `SyncFrame` wire type (own-device fan-out only, never delivered to a different user). No PROTOCOL_VERSION change.
- Your app decides when to call `clearNotification()` / `syncDraft()`. The platform never infers dismissal or tracks drafts.

---

## [0.11.0], 2026-05-20

### Added

- **Cross-device read receipts.** New `client.markRead(messageId, conversationId?)` reports a message as read; the receipt is relayed to the user's *other* devices (never to the message's sender). New `client.onReadReceipt(callback)` listens for receipts reported by the user's other devices. Use it to clear unread state your app maintains.
- New exported `ReadReceiptEvent` type and `ReadReceiptCallback`.
- **PROTOCOL_VERSION bumped to 6.** Additive only: new `SyncFrame` wire type. Existing 0.10.x clients keep working against the new server.

### Notes

- The platform is deliberately unopinionated: **your app decides when `markRead()` is called**. The server never infers read state, and unread counts stay client-side. The app owner can disable read receipts entirely from the dashboard, in which case the server silently drops the frame.
- This release is own-device sync only: the *sender* of a message is not told when a recipient read it. Sender-side read notifications are a separate future capability.

---

## [0.10.0], 2026-05-20

### Added

- **Device trust.** New `client.listMyDevices()` returns the current user's registered devices; `client.revokeMyDevice(deviceId)` revokes one of them. A revoked device's live session is closed immediately and all future connections from it are denied (revoke is permanent, and re-registering yields a fresh deviceId).
- **`DEVICE_REVOKED` event.** When this device is revoked (from another of the user's devices, or by the app owner), the SDK stops its reconnect loop, closes the socket, and surfaces a `DEVICE_REVOKED` event through `onEvent` so the app can clear local key storage and show a re-register prompt.
- New exported `DeviceInfo` type.

### Notes

- No wire-format change; PROTOCOL_VERSION stays at 5. The new surface is REST (`/v1/devices`) plus an additive event type.
- Any device that completes a connection is implicitly trusted. There is no explicit pairing/attestation step in this release.

---

## [0.9.1], 2026-05-19

### Fixed

- **`registerPushToken` / `unregisterPushToken` now exposed on the public `DropOnAirClient` type.** In 0.9.0 the methods were implemented on the `MessagingClient` class but missing from the exported `DropOnAirClient` interface, so TypeScript consumers couldn't call them through the `initialize()` return value. No wire-format change; this is a pure type-surface fix.

---

## [0.9.0], 2026-05-19

### Added

- **Push notification token registration.** New `client.registerPushToken({ platform, token, voipToken? })` and `client.unregisterPushToken({ platform })` methods. The platform delivers a push via APNs (iOS), FCM v1 (Android), or VAPID/Web Push (browser) whenever a sender supplies a `pushPayload` on a message and the recipient device has no live WebSocket session. The token is opaque to the server. For iOS VoIP/CallKit pushes, pass `voipToken` alongside the regular APNs token.
- **PROTOCOL_VERSION bumped to 5.** Additive only: new optional `pushPayload` field on `Envelope`, `GroupEnvelope`, `CallFrame`, `GroupCallFrame`; new `PushRegistrationFrame` for token registration. Existing 0.8.x clients keep working against the new server (proto3 ignores unknown fields).

### Notes

- E2EE invariant preserved: the push body is sender-supplied cleartext metadata only ("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.
- Customers configure their own APNs (.p8 + Team/Key/Bundle IDs), FCM service-account JSON, and VAPID keypair via the dashboard. Per-app, not per-environment; use two apps for dev/prod separation.

---

## [0.8.0], 2026-05-19

### BREAKING

- `sendGroupMessage(groupId, plaintext)` is **renamed to `sendCleartextGroupMessage(groupId, plaintext)`**. The previous JS method was cleartext-only despite the name, while the Android and iOS SDKs reserved the same name for an end-to-end encrypted send. This release aligns all three SDKs on the same naming.
  - **Update existing JS callers**: replace `client.sendGroupMessage(groupId, text)` with `client.sendCleartextGroupMessage(groupId, text)`.

### Added

- **End-to-end encrypted group send.** New `sendGroupMessage(groupId, plaintext, memberUserIds, options?)` that mirrors the Android and iOS SDKs: the SDK encrypts the plaintext per-recipient-per-device (sender-side fan-out) before sending. Pass `options.attachments` for E2EE group attachments.
- **Group attachments.** Both `sendGroupMessage` and the wire `GroupEnvelope` now carry an optional `attachments` field; previously this was 1:1 only on JS.

### Fixed

- **JS `GroupEnvelope` / `GroupMessageNotification` protobuf field numbers** now match the source-of-truth `messaging.proto`. The previous JS schema had a field-number drift versus the Android/iOS generated proto bindings, which meant group sends from JS arrived at the server with values in the wrong fields (and were silently tolerated by proto3's unknown-field handling). With this release JS group send/receive is wire-correct.

### Notes

- Wire protocol unchanged (PROTOCOL_VERSION stays 4). The proto schema was already correct; only the JS hand-rolled codec was out of sync.
- `protoFieldNumbers` for the group types are now documented alongside the shared protocol test vectors, for cross-platform reference.

---

## [0.7.0], 2026-05-19

### Added
- **Conference moderation and waiting room.** Host designated to the call
  initiator and transferable mid-call; host can appoint co-hosts. Host and
  co-host can mute and remove participants and admit / reject users from a
  waiting room. If the host leaves without transferring, the server
  auto-promotes the longest-joined remaining participant.
- New `client` methods:
  - `transferHost(callId, groupId, newHostUserId)`
  - `appointCoHost(callId, groupId, userId)` / `revokeCoHost(callId, groupId, userId)`
  - `muteParticipant(callId, groupId, targetUserId)` (signaling hint; the
    target SDK applies the mute locally)
  - `removeParticipant(callId, groupId, targetUserId)` (server-enforced; the
    target is dropped from the call session)
  - `requestWaitingRoomEntry(callId, groupId)` (would-be joiner side)
  - `admitFromWaitingRoom(callId, groupId, userId)` / `rejectFromWaitingRoom(callId, groupId, userId)`
- New `GroupCallEventType` values: `'GROUP_CALL_HOST_TRANSFER'`,
  `'GROUP_CALL_COHOST_APPOINT'`, `'GROUP_CALL_COHOST_REVOKE'`,
  `'GROUP_CALL_ROLE_CHANGED'`, `'GROUP_CALL_MUTE_PARTICIPANT'`,
  `'GROUP_CALL_REMOVE_PARTICIPANT'`, `'GROUP_CALL_PARTICIPANT_REMOVED'`,
  `'GROUP_CALL_WAITING_ROOM_REQUEST'`, `'GROUP_CALL_WAITING_ROOM_JOINED'`,
  `'GROUP_CALL_WAITING_ROOM_ADMIT'`, `'GROUP_CALL_WAITING_ROOM_ADMITTED'`,
  `'GROUP_CALL_WAITING_ROOM_REJECT'`, `'GROUP_CALL_WAITING_ROOM_REJECTED'`.
- `GET /api/info` now advertises `"moderation"` in the `features` array.

### Notes
- Wire-level additive: legacy 0.6.x clients ignore the new signal type
  strings (string discriminators on the existing `GroupCallFrame.type`).
  No proto schema change, no `PROTOCOL_VERSION` bump.
- 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.

---

## [0.6.0], 2026-05-18

### Added
- **Screen sharing signaling.** New client APIs and event types so apps can
  coordinate the start/stop of a screen-share track on top of the existing
  WebRTC call. The SDK only signals - capture (`getDisplayMedia()` on web) and
  track management remain in the app layer.
- **1:1 calls:**
  - `client.startScreenShare(callId, payload?)` and `client.stopScreenShare(callId, payload?)`.
  - New `CallEventType` values: `'CALL_SCREEN_SHARE_STARTED'`, `'CALL_SCREEN_SHARE_STOPPED'`.
- **Group calls:**
  - `client.startGroupScreenShare(callId, groupId, payload?)` and `client.stopGroupScreenShare(callId, groupId, payload?)`.
  - New `GroupCallEventType` values: `'GROUP_CALL_SCREEN_SHARE_STARTED'`, `'GROUP_CALL_SCREEN_SHARE_STOPPED'`.
  - Server enforces a single concurrent sharer per group call. If another
    participant already holds the slot when you call `startGroupScreenShare`,
    you'll receive a `GROUP_CALL_SCREEN_SHARE_STOPPED` event with the current
    holder's userId in `payload`, so the app can roll back its optimistic UI.
  - When a sharer leaves the call, the server broadcasts a STOPPED event to
    remaining participants before they see `PARTICIPANT_LEFT`.
- `GET /api/info` now advertises `"screen_sharing"` in the `features` array.

### Notes
- Wire-level additive: legacy 0.5.x clients ignore the new signal type strings
  (string discriminators on existing `CallFrame.type` / `GroupCallFrame.type`).
  No proto schema change, no `PROTOCOL_VERSION` bump.
- Screen content is opaque WebRTC media; the server never sees it. Use
  `getDisplayMedia()` (web) / `MediaProjection` (Android) / `ReplayKit` (iOS)
  in the app, add the track to the existing peer connection, then call
  `startScreenShare(...)` to notify the peer.
- Availability depends on the app's plan; see the pricing page or the app's
  subscription in the dashboard.

---

## [0.5.0], 2026-05-18

### Added
- **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.
- **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: [...] })`.
- **New low-level API:** `client.createUploadSession(options)`, `client.finalizeAttachment(id, sha256)`, `client.downloadAttachment(ref)`.
- **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.
- **`DecryptedMessage.attachments`:** incoming messages now carry the optional `attachments: AttachmentRef[]` field.
- **PROTOCOL_VERSION = 4.** `features` now advertises `attachments` from `GET /api/info`.
- New public types: `AttachmentRef`, `DeviceWrappedKey`, `AttachmentEncryptionType`, `AttachmentConversationType`, `CreateUploadSessionOptions`, `UploadSession`, `PrepareAttachmentOptions`, `DownloadedAttachment`.

### Notes
- Wire-level additive: legacy 0.4.x clients ignore the new `Envelope.attachments` field (proto3 forwards-compat). Existing apps continue to work unchanged.
- v1 storage adapters supported by the server: S3-compatible (S3, R2, MinIO, B2, Wasabi), Google Cloud Storage, Azure Blob.
- 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.

---

## [0.4.0], 2026-04-21

### Added
- **Message edit (E2EE + cleartext):** `editMessage(originalMessageId, toUserId, newText)` and `editCleartextMessage(originalMessageId, toUserId, newText)`. Edits are re-encrypted per-device with the same shared key as the original message; the server stores them as immutable separate records and forwards opaquely.
- **Message delete:** `deleteMessage(originalMessageId, toUserId, scope)` with `scope` of `"FOR_EVERYONE"` (recipient receives tombstone, cleartext plaintext is also wiped server-side) or `"FOR_ME"` (only sender's other devices receive the tombstone).
- **New event types:** `MessageEditEvent` and `MessageDeleteEvent` with `onMessageEdit()` / `onMessageDelete()` listeners.
- **Offline catch-up:** Pending edits and tombstones are now replayed alongside offline messages when the client reconnects (`pendingEdits` and `pendingTombstones` returned by `/api/messages/offline`).
- **PROTOCOL_VERSION = 3** and `features` now advertises `message_edit` + `message_delete` from `GET /api/info`.

### Notes
- Each edit and each `FOR_EVERYONE` delete counts as **one** MESSAGE usage record (subject to plan quotas + rate limits).
- Wire-level additive: legacy 0.3.x clients ignore unknown frame types (proto3 forwards-compat), so existing TwinFlame and CrowdSyncer apps continue to work unchanged.

---

## [0.3.1], 2026-04-19

### Changed
- Patch version bump for npm publish alignment

---

## [0.3.0], 2026-04-04

### Added
- **Group messaging (E2EE + cleartext):** Create groups, manage members, and send encrypted or cleartext messages to all group members at once
- **Group management REST API:** `createGroup()`, `listGroups()`, `getGroupMembers()`, `addGroupMembers()`, `removeGroupMembers()`, `deleteGroup()`
- **E2EE group send:** `sendGroupMessage()`, sender-side fan-out encrypts per-member per-device using existing X25519 + AES-256-GCM
- **Cleartext group send:** Send plaintext group messages with zero crypto overhead for public groups
- **Group message events:** `onGroupMessage()` / `offGroupMessage()` callbacks for inbound group messages
- **Group voice & video calls:** Mesh WebRTC group calling with signaling over the same encrypted WebSocket
- **Group call lifecycle:** `startGroupCall()`, `joinGroupCall()`, `leaveGroupCall()`, `endGroupCall()`
- **Group call signaling:** `sendGroupCallSignal()` for SDP/ICE exchange between mesh peers
- **Group call events:** `onGroupCallEvent()` / `offGroupCallEvent()` for OFFER, ANSWER, ICE, JOIN, LEAVE, END events
- **New proto frame types:** `GroupEnvelope`, `GroupMessageNotification`, `GroupAck`, `GroupCallFrame`, `GroupMemberPayload`
- **Per-plan group limits:** `maxGroupsPerApp`, `maxGroupMembers`, `maxGroupMessagesPerMonth`, `maxGroupCallMinutesPerMonth`, `maxGroupCallParticipants`
- **New usage types:** `GROUP_MESSAGE`, `GROUP_CALL_MINUTE` with independent billing and quotas

### Changed
- `PROTOCOL_VERSION` bumped from `1` to `2` (new group proto frame types)
- Package version bumped from `0.2.0` to `0.3.0`

---

## [0.2.0], 2026-02-28

### Added
- **SDK versioning infrastructure:** `SDK_VERSION`, `PROTOCOL_VERSION`, and `PAYLOAD_FORMAT_VERSION` exported from `src/version.ts`
- **Version telemetry:** `X-SDK-Version` header sent on token exchange; `sdkVersion` and `protocolVersion` query params on WebSocket handshake
- **Server info endpoint:** the platform now exposes `GET /api/info` (no auth) returning `protocolVersion`, `minSdkVersion`, and supported `features`
- **Multi-device E2EE:** Per-device encrypted payloads (`devicePayloads[]` in Envelope proto), sender encrypts once per recipient device + self-sync to own other devices
- **Self-sync:** Sent messages are encrypted for the sender's other devices so all devices see the conversation in real time
- **Legacy fallback:** When recipient has no device keys, SDK falls back to single `encryptedPayload` path for backward compat with older clients
- **Call signaling:** Full WebRTC call lifecycle (invite → ringing → accept/reject → SDP/ICE relay → end) over the same WebSocket
- **TURN credentials:** `fetchTurnCredentials()` for NAT traversal
- **Manual ack support:** `autoAckIncomingMessages` option to defer PROCESSED acks until app decides
- **Proactive token rotation:** JWT refreshed 90s before expiry to prevent silent failures
- **iOS background recovery:** Visibility-change handler detects when app returns from background and re-evaluates JWT state

### Changed
- Package version bumped from `0.1.0` to `0.2.0`
- Encrypted payload format now imports version constant from `src/version.ts` instead of inline magic number

### Fixed
- JWT expiry race condition: `sendMessage()` now fails fast with `JWT_EXPIRED` error instead of letting the server reject the frame

---

## [0.1.0], 2026-02-15

### Added
- Initial SDK release
- X25519 ECDH key agreement + AES-256-GCM encryption
- Protobuf binary WebSocket transport
- IndexedDB secure key storage with in-memory fallback
- Automatic reconnect with exponential backoff
- Rate limit detection (LIMIT_REACHED event)
- Impersonation detection
- Offline message fetch on reconnect
