Skip to main content

gbp_stack_wasm/
lib.rs

1//! Browser/WASM bindings for the Group Protocol Stack.
2//!
3//! Exported JS classes: [`MlsContext`], [`GroupNode`], [`GtpClient`],
4//! [`GapClient`], [`GspClient`], [`SFrameSession`], [`SFrameEncryptor`], plus
5//! the [`PayloadCodec`], [`SignalType`], [`ControlOpcode`] and [`CipherSuite`]
6//! enums. This surface mirrors the C-ABI (`gbp-stack-ffi`) and the C#/Python/JS
7//! SDKs so a browser client has the same gap (audio) / gsp (signalling) / gtp
8//! (text) / sframe (media E2EE) capabilities as every other binding.
9//! The async init function is generated automatically by wasm-pack.
10
11#![cfg(target_arch = "wasm32")]
12
13use gap::{GapAccept, GapClient as RustGapClient};
14use gbp_core::MemberId;
15use gbp_node::{Event, GroupNode as RustGroupNode};
16use gbp_sframe::{
17    CipherSuite as RustCipherSuite, SFrameDecryptor as RustSFrameDecryptor,
18    SFrameEncryptor as RustSFrameEncryptor, SFrameSession as RustSFrameSession,
19};
20use gsp::{GspAccept, GspClient as RustGspClient, GspError};
21use gtp::{GtpAccept, GtpClient as RustGtpClient};
22use js_sys::{Array, Object, Reflect, Uint8Array};
23use openmls::prelude::tls_codec::Serialize as TlsSerialize;
24use openmls::prelude::{KeyPackageIn, OpenMlsProvider, ProtocolVersion};
25use tls_codec::Deserialize as TlsDeserialize;
26use std::cell::RefCell;
27use wasm_bindgen::prelude::*;
28
29// ─── helpers ────────────────────────────────────────────────────────────────
30
31fn set(obj: &Object, key: &str, val: &JsValue) {
32    Reflect::set(obj, &JsValue::from_str(key), val).unwrap_throw();
33}
34
35fn u8s(bytes: &[u8]) -> JsValue {
36    Uint8Array::from(bytes).into()
37}
38
39fn js_err(msg: impl std::fmt::Display) -> JsValue {
40    JsValue::from_str(&msg.to_string())
41}
42
43fn event_to_js(ev: Event) -> JsValue {
44    let obj = Object::new();
45    match ev {
46        Event::PayloadReceived(p) => {
47            set(&obj, "kind", &"payload_received".into());
48            set(&obj, "streamType", &JsValue::from_f64(p.stream_type.as_u8() as f64));
49            set(&obj, "plaintext", &u8s(&p.plaintext));
50            set(&obj, "sequenceNo", &JsValue::from_f64(p.sequence_no as f64));
51            set(&obj, "codec", &JsValue::from_f64(p.codec as u8 as f64));
52        }
53        Event::StateChanged { from, to } => {
54            set(&obj, "kind", &"state_changed".into());
55            set(&obj, "from", &JsValue::from_str(&from.to_string()));
56            set(&obj, "to", &JsValue::from_str(&to.to_string()));
57        }
58        Event::EpochAdvanced { epoch, transition_id } => {
59            set(&obj, "kind", &"epoch_advanced".into());
60            set(&obj, "epoch", &js_sys::BigInt::from(epoch).into());
61            set(&obj, "transitionId", &JsValue::from_f64(transition_id as f64));
62        }
63        Event::Error { code, reason, fatal, retryable, .. } => {
64            set(&obj, "kind", &"error".into());
65            set(&obj, "code", &JsValue::from_f64(code as f64));
66            set(&obj, "reason", &JsValue::from_str(&reason));
67            set(&obj, "fatal", &JsValue::from_bool(fatal));
68            set(&obj, "retryable", &JsValue::from_bool(retryable));
69        }
70        Event::Control { from, opcode, transition_id, .. } => {
71            set(&obj, "kind", &"control".into());
72            set(&obj, "from", &JsValue::from_f64(from as f64));
73            set(&obj, "opcode", &JsValue::from_f64(opcode as u8 as f64));
74            set(&obj, "transitionId", &JsValue::from_f64(transition_id as f64));
75        }
76        _ => {
77            set(&obj, "kind", &"other".into());
78        }
79    }
80    obj.into()
81}
82
83/// Converts an optional JS codec selector into the canonical payload codec,
84/// defaulting to CBOR — the same fallback the C ABI uses. JS callers pass
85/// `undefined` (or omit the argument) for the default, or a [`PayloadCodec`]
86/// value / raw `0|1|2` to select an encoding.
87fn codec_from(c: Option<u8>) -> gbp_core::PayloadCodec {
88    c.and_then(gbp_core::PayloadCodec::from_u8)
89        .unwrap_or(gbp_core::PayloadCodec::Cbor)
90}
91
92// ─── Exported enums (parity with the C#/Python/JS SDKs) ───────────────────────
93
94/// Payload wire-encoding selector. `Cbor` is the interoperable default;
95/// `FlatBuffers` minimises decode latency and is preferred for audio.
96#[wasm_bindgen]
97#[derive(Clone, Copy, PartialEq, Eq, Debug)]
98pub enum PayloadCodec {
99    Cbor = 0,
100    Protobuf = 1,
101    FlatBuffers = 2,
102}
103
104/// GSP signal kinds (membership / role / stream / codec control).
105#[wasm_bindgen]
106#[derive(Clone, Copy, PartialEq, Eq, Debug)]
107pub enum SignalType {
108    Join = 100,
109    Leave = 101,
110    RoleChange = 102,
111    Mute = 200,
112    Unmute = 201,
113    StreamStart = 300,
114    StreamStop = 301,
115    CodecUpdate = 400,
116}
117
118/// GBP control-plane opcodes (epoch-transition coordination + diagnostics).
119#[wasm_bindgen]
120#[derive(Clone, Copy, PartialEq, Eq, Debug)]
121pub enum ControlOpcode {
122    PrepareTransition = 0x0001,
123    ReadyForTransition = 0x0002,
124    ExecuteTransition = 0x0003,
125    AbortTransition = 0x0004,
126    GroupStateDigestRequest = 0x0005,
127    GroupStateDigestResponse = 0x0006,
128    ReportInvalidCommit = 0x0007,
129    CapabilitiesAdvertise = 0x0008,
130    Ack = 0x0009,
131    Nack = 0x000A,
132}
133
134/// SFrame AEAD ciphersuite. `Aes128Gcm` is the standard choice.
135#[wasm_bindgen]
136#[derive(Clone, Copy, PartialEq, Eq, Debug)]
137pub enum CipherSuite {
138    Aes128Gcm = 0,
139    Aes256Gcm = 1,
140}
141
142// ─── MlsContext ──────────────────────────────────────────────────────────────
143
144/// MLS group state for one member.
145///
146/// JS usage:
147/// ```js
148/// const alice = MlsContext.create("alice");
149/// const bob   = MlsContext.create("bob");
150/// const welcome = alice.invite(bob.keyPackage);
151/// bob.acceptWelcome(welcome);
152/// // alice.epoch === bob.epoch === 1n
153/// ```
154#[wasm_bindgen]
155pub struct MlsContext {
156    inner: RefCell<gbp_mls::MlsContext>,
157    kp_bytes: Vec<u8>,
158}
159
160#[wasm_bindgen]
161impl MlsContext {
162    /// Creates a new member identity.
163    ///
164    /// The returned object holds a pre-generated key package that another
165    /// member can pass to [`invite`] to add this member to a group.
166    #[wasm_bindgen(js_name = "create")]
167    pub fn create(user_id: &str) -> Result<MlsContext, JsValue> {
168        let (ctx, kpb) = gbp_mls::MlsContext::new_member(user_id.as_bytes())
169            .map_err(|e| js_err(e))?;
170        let kp_bytes = kpb.key_package()
171            .tls_serialize_detached()
172            .map_err(|e| js_err(format!("kp serialize: {e:?}")))?;
173        Ok(MlsContext { inner: RefCell::new(ctx), kp_bytes })
174    }
175
176    /// TLS-serialised key package for this member (pass to the inviter's
177    /// [`invite`]).
178    #[wasm_bindgen(getter, js_name = "keyPackage")]
179    pub fn key_package(&self) -> Uint8Array {
180        Uint8Array::from(self.kp_bytes.as_slice())
181    }
182
183    /// Current MLS group epoch.
184    #[wasm_bindgen(getter)]
185    pub fn epoch(&self) -> u64 {
186        self.inner.borrow().epoch()
187    }
188
189    /// 16-byte group identifier (all zeros before the first invite).
190    #[wasm_bindgen(getter, js_name = "groupId")]
191    pub fn group_id(&self) -> Uint8Array {
192        Uint8Array::from(self.inner.borrow().group_id_16().as_slice())
193    }
194
195    /// Invites another member into this group.
196    ///
197    /// `keyPackageBytes` is the raw TLS bytes from the joiner's
198    /// [`keyPackage`] getter. Returns the Welcome bytes the joiner must pass
199    /// to [`acceptWelcome`]. This call merges the commit immediately and
200    /// advances this member's epoch.
201    #[wasm_bindgen(js_name = "invite")]
202    pub fn invite(&self, key_package_bytes: &[u8]) -> Result<Uint8Array, JsValue> {
203        let mut ctx = self.inner.borrow_mut();
204        let kp_in = KeyPackageIn::tls_deserialize(&mut key_package_bytes.as_ref())
205            .map_err(|e| js_err(format!("kp parse: {e:?}")))?;
206        let kp = kp_in
207            .validate(ctx.provider.crypto(), ProtocolVersion::Mls10)
208            .map_err(|e| js_err(format!("kp validate: {e:?}")))?;
209        let welcome = ctx.invite(&[kp]).map_err(|e| js_err(e))?;
210        Ok(Uint8Array::from(welcome.as_slice()))
211    }
212
213    /// Joins a group from a Welcome message produced by [`invite`].
214    ///
215    /// After this call [`epoch`] will match the inviter's epoch and
216    /// [`groupId`] will match the inviter's group id.
217    #[wasm_bindgen(js_name = "acceptWelcome")]
218    pub fn accept_welcome(&self, welcome_bytes: &[u8]) -> Result<(), JsValue> {
219        self.inner.borrow_mut()
220            .accept_welcome(welcome_bytes)
221            .map_err(|e| js_err(e))
222    }
223
224    /// Invites a member and returns BOTH the Commit and the Welcome as
225    /// `{ commit: Uint8Array, welcome: Uint8Array }`. Unlike [`invite`], this
226    /// stages a pending commit instead of merging immediately — broadcast the
227    /// Commit to existing members, unicast the Welcome to the joiner, then call
228    /// [`finalizeCommit`] (or [`clearPendingCommit`] to roll back). This is the
229    /// two-phase flow used for coordinated epoch transitions.
230    #[wasm_bindgen(js_name = "inviteFull")]
231    pub fn invite_full(&self, key_package_bytes: &[u8]) -> Result<JsValue, JsValue> {
232        let mut ctx = self.inner.borrow_mut();
233        let kp_in = KeyPackageIn::tls_deserialize(&mut key_package_bytes.as_ref())
234            .map_err(|e| js_err(format!("kp parse: {e:?}")))?;
235        let kp = kp_in
236            .validate(ctx.provider.crypto(), ProtocolVersion::Mls10)
237            .map_err(|e| js_err(format!("kp validate: {e:?}")))?;
238        let (commit, welcome) = ctx.invite_full(&[kp]).map_err(|e| js_err(e))?;
239        let obj = Object::new();
240        set(&obj, "commit", &u8s(&commit));
241        set(&obj, "welcome", &u8s(&welcome));
242        Ok(obj.into())
243    }
244
245    /// Removes the member at `leafIndex` and returns the Commit to broadcast to
246    /// the remaining members. Stages a pending commit; pair with
247    /// [`finalizeCommit`] / [`clearPendingCommit`]. This is the membership
248    /// change that SFrame keys rotate on — create a new [`SFrameSession`] after
249    /// the epoch advances.
250    #[wasm_bindgen(js_name = "removeMember")]
251    pub fn remove_member(&self, leaf_index: u32) -> Result<Uint8Array, JsValue> {
252        let mut ctx = self.inner.borrow_mut();
253        let commit = ctx.remove_members(&[leaf_index]).map_err(|e| js_err(e))?;
254        Ok(Uint8Array::from(commit.as_slice()))
255    }
256
257    /// Applies an inbound MLS message and returns the processed kind as a
258    /// string: `"commit"` (epoch advanced), `"application"`, `"proposal"`
259    /// (staged) or `"external"`.
260    #[wasm_bindgen(js_name = "processMessage")]
261    pub fn process_message(&self, msg_bytes: &[u8]) -> Result<String, JsValue> {
262        let mut ctx = self.inner.borrow_mut();
263        let kind = ctx.process_message(msg_bytes).map_err(|e| js_err(e))?;
264        Ok(match kind {
265            gbp_mls::ProcessedKind::Commit => "commit",
266            gbp_mls::ProcessedKind::Application => "application",
267            gbp_mls::ProcessedKind::Proposal => "proposal",
268            gbp_mls::ProcessedKind::External => "external",
269        }
270        .to_string())
271    }
272
273    /// Merges a pending Commit produced by [`inviteFull`] / [`removeMember`],
274    /// advancing this member's epoch.
275    #[wasm_bindgen(js_name = "finalizeCommit")]
276    pub fn finalize_commit(&self) -> Result<(), JsValue> {
277        self.inner.borrow_mut()
278            .finalize_pending_commit()
279            .map_err(|e| js_err(e))
280    }
281
282    /// Discards a pending Commit without applying it — used on
283    /// `ABORT_TRANSITION` to roll back to the pre-commit MLS state.
284    #[wasm_bindgen(js_name = "clearPendingCommit")]
285    pub fn clear_pending_commit(&self) -> Result<(), JsValue> {
286        self.inner.borrow_mut()
287            .clear_pending_commit()
288            .map_err(|e| js_err(e))
289    }
290
291    /// Serialises the full MLS state into an opaque blob that [`restoreState`]
292    /// can reconstruct, so a browser client can persist the context (e.g. to
293    /// IndexedDB) and survive a reload. The blob contains **private key
294    /// material** — store it encrypted at rest.
295    #[wasm_bindgen(js_name = "exportState")]
296    pub fn export_state(&self) -> Result<Uint8Array, JsValue> {
297        let bytes = self.inner.borrow().export_state().map_err(|e| js_err(e))?;
298        Ok(Uint8Array::from(bytes.as_slice()))
299    }
300
301    /// Reconstructs a context from a blob produced by [`exportState`]. The
302    /// restored context is at the same epoch / group state and can immediately
303    /// send and receive again.
304    #[wasm_bindgen(js_name = "restoreState")]
305    pub fn restore_state(blob: &[u8]) -> Result<MlsContext, JsValue> {
306        let ctx = gbp_mls::MlsContext::restore_state(blob).map_err(|e| js_err(e))?;
307        Ok(MlsContext { inner: RefCell::new(ctx), kp_bytes: Vec::new() })
308    }
309}
310
311// ─── GroupNode ───────────────────────────────────────────────────────────────
312
313/// GBP group node — framing, AEAD, replay window, control plane.
314///
315/// JS usage:
316/// ```js
317/// const node = GroupNode.create(1, groupId);
318/// node.bootstrapAsCreator(mls.epoch);
319/// const events = node.onWire(mls, wireBytes);
320/// ```
321#[wasm_bindgen]
322pub struct GroupNode {
323    inner: RefCell<RustGroupNode>,
324}
325
326#[wasm_bindgen]
327impl GroupNode {
328    /// Creates a node for `leafIndex` (member id) and the given 16-byte group id.
329    #[wasm_bindgen(js_name = "create")]
330    pub fn create(leaf_index: u32, group_id_bytes: &[u8]) -> GroupNode {
331        let gid: [u8; 16] = group_id_bytes.try_into().unwrap_or([0u8; 16]);
332        GroupNode { inner: RefCell::new(RustGroupNode::new(leaf_index as MemberId, gid)) }
333    }
334
335    /// Drives the node to `ACTIVE` as the group creator at the given epoch.
336    #[wasm_bindgen(js_name = "bootstrapAsCreator")]
337    pub fn bootstrap_as_creator(&self, epoch: u64) {
338        self.inner.borrow_mut().bootstrap_as_creator(epoch);
339    }
340
341    /// Drives the node to `ACTIVE` as a joiner.
342    ///
343    /// Pass `expectedFirstTid = 0` unless you know the in-flight
344    /// `transition_id` the coordinator will send in `EXECUTE_TRANSITION`.
345    #[wasm_bindgen(js_name = "bootstrapAsJoiner")]
346    pub fn bootstrap_as_joiner(&self, epoch: u64, expected_first_tid: u32) {
347        self.inner.borrow_mut().bootstrap_as_joiner(epoch, expected_first_tid);
348    }
349
350    /// Delivers a wire frame and returns the resulting events array.
351    ///
352    /// Each element is a plain JS object with at minimum `{ kind: string }`.
353    ///
354    /// | `kind` | Extra fields |
355    /// |--------|-------------|
356    /// | `"payload_received"` | `streamType`, `plaintext`, `sequenceNo`, `codec` |
357    /// | `"state_changed"` | `from`, `to` |
358    /// | `"epoch_advanced"` | `epoch` (bigint), `transitionId` |
359    /// | `"error"` | `code`, `reason`, `fatal`, `retryable` |
360    /// | `"control"` | `from`, `opcode`, `transitionId` |
361    #[wasm_bindgen(js_name = "onWire")]
362    pub fn on_wire(&self, mls: &MlsContext, wire_bytes: &[u8]) -> Array {
363        let mut node = self.inner.borrow_mut();
364        let mut mls_inner = mls.inner.borrow_mut();
365        let events = node.on_wire(&mut *mls_inner, wire_bytes).unwrap_or_default();
366        let arr = Array::new();
367        for ev in events {
368            arr.push(&event_to_js(ev));
369        }
370        arr
371    }
372
373    /// Polls pending timeout events — call ~every 500 ms from the app loop.
374    #[wasm_bindgen(js_name = "checkTimeouts")]
375    pub fn check_timeouts(&self) -> Array {
376        let arr = Array::new();
377        for ev in self.inner.borrow_mut().check_timeouts() {
378            arr.push(&event_to_js(ev));
379        }
380        arr
381    }
382
383    /// The `transition_id` of the last applied epoch transition.
384    #[wasm_bindgen(getter, js_name = "lastTransitionId")]
385    pub fn last_transition_id(&self) -> u32 {
386        self.inner.borrow().last_transition_id
387    }
388
389    /// Current epoch as seen by the GBP layer.
390    #[wasm_bindgen(getter, js_name = "currentEpoch")]
391    pub fn current_epoch(&self) -> u64 {
392        self.inner.borrow().current_epoch
393    }
394
395    /// This node's member id (leaf index).
396    #[wasm_bindgen(getter, js_name = "memberId")]
397    pub fn member_id(&self) -> u32 {
398        self.inner.borrow().member_id
399    }
400
401    /// Sends a control-plane message on Stream 0 — epoch-transition coordination
402    /// (PREPARE/READY/EXECUTE/ABORT), capabilities advertise, ACK/NACK.
403    /// `opcode` is a [`ControlOpcode`] value. Returns `{ wire: Uint8Array, to: number }`
404    /// or throws. Pass `target = 0` to broadcast; pass an empty `args` array
405    /// when the opcode carries no arguments.
406    #[wasm_bindgen(js_name = "sendControl")]
407    pub fn send_control(
408        &self,
409        mls: &MlsContext,
410        target: u32,
411        opcode: u16,
412        transition_id: u32,
413        request_id: u32,
414        args: &[u8],
415    ) -> Result<JsValue, JsValue> {
416        let op = gbp_core::ControlOpcode::try_from(opcode)
417            .map_err(|_| js_err(format!("bad opcode 0x{opcode:04X}")))?;
418        let mut node = self.inner.borrow_mut();
419        let mut m = mls.inner.borrow_mut();
420        let of = node
421            .send_control(&mut *m, target as MemberId, op, transition_id, request_id, args.to_vec())
422            .map_err(|e| js_err(e))?;
423        let obj = Object::new();
424        set(&obj, "wire", &u8s(&of.wire));
425        set(&obj, "to", &JsValue::from_f64(of.to as f64));
426        Ok(obj.into())
427    }
428
429    /// Applies an epoch transition locally (advances `currentEpoch` and
430    /// `lastTransitionId`).
431    #[wasm_bindgen(js_name = "applyTransition")]
432    pub fn apply_transition(&self, tid: u32) {
433        self.inner.borrow_mut().apply_transition(tid);
434    }
435
436    /// Drains queued events without consuming wire bytes. Each element has the
437    /// same shape as the array returned by [`onWire`].
438    #[wasm_bindgen(js_name = "drainEvents")]
439    pub fn drain_events(&self) -> Array {
440        let arr = Array::new();
441        for ev in self.inner.borrow_mut().drain_events() {
442            arr.push(&event_to_js(ev));
443        }
444        arr
445    }
446}
447
448// ─── GtpClient ───────────────────────────────────────────────────────────────
449
450/// Group Text Protocol client — idempotent text delivery over GBP.
451///
452/// JS usage:
453/// ```js
454/// const gtp = GtpClient.create();
455/// const frame = gtp.send(node, mls, 0, 1n, "hello");
456/// // frame.wire: Uint8Array — hand to transport
457///
458/// // on receive:
459/// const result = gtp.accept(ev.plaintext, mls.epoch);
460/// // result.text / result.messageId / result.senderId
461/// ```
462#[wasm_bindgen]
463pub struct GtpClient {
464    inner: RefCell<RustGtpClient>,
465}
466
467#[wasm_bindgen]
468impl GtpClient {
469    /// Creates an empty GTP client.
470    #[wasm_bindgen(js_name = "create")]
471    pub fn create() -> GtpClient {
472        GtpClient { inner: RefCell::new(RustGtpClient::new()) }
473    }
474
475    /// Sends a text message.
476    ///
477    /// Returns `{ wire: Uint8Array, to: number }` or `null` on error.
478    /// Pass `target = 0` to broadcast to all members. `codec` is optional
479    /// (a [`PayloadCodec`] value); omit it for the CBOR default.
480    #[wasm_bindgen(js_name = "send")]
481    pub fn send(
482        &self,
483        node: &GroupNode,
484        mls: &MlsContext,
485        target: u32,
486        message_id: u64,
487        text: &str,
488        codec: Option<u8>,
489    ) -> JsValue {
490        let mut gtp = self.inner.borrow_mut();
491        let mut n = node.inner.borrow_mut();
492        let mut m = mls.inner.borrow_mut();
493        match gtp.send(&mut *n, &mut *m, target as MemberId, message_id, text, codec_from(codec)) {
494            Ok(frame) => {
495                let obj = Object::new();
496                set(&obj, "wire", &u8s(&frame.wire));
497                set(&obj, "to", &JsValue::from_f64(frame.to as f64));
498                obj.into()
499            }
500            Err(_) => JsValue::NULL,
501        }
502    }
503
504    /// Accepts a plaintext GTP payload delivered from a `payload_received` event.
505    ///
506    /// Returns `{ text: string, messageId: bigint, senderId: number }` or
507    /// `null` if the payload is malformed.
508    /// The `status` field is `"new"` or `"duplicate"` based on idempotency.
509    /// `codec` is optional and must match the encoding used by the sender
510    /// (defaults to CBOR).
511    #[wasm_bindgen(js_name = "accept")]
512    pub fn accept(&self, plaintext: &[u8], epoch: u64, codec: Option<u8>) -> JsValue {
513        let mut gtp = self.inner.borrow_mut();
514        match gtp.accept(plaintext, epoch, codec_from(codec)) {
515            Ok(result) => {
516                let (msg, status) = match result {
517                    GtpAccept::New(m) => (m, "new"),
518                    GtpAccept::Duplicate(m) => (m, "duplicate"),
519                };
520                let text = String::from_utf8_lossy(&msg.content).into_owned();
521                let obj = Object::new();
522                set(&obj, "text", &JsValue::from_str(&text));
523                set(&obj, "messageId", &js_sys::BigInt::from(msg.message_id).into());
524                set(&obj, "senderId", &JsValue::from_f64(msg.sender_id as f64));
525                set(&obj, "status", &JsValue::from_str(status));
526                obj.into()
527            }
528            Err(_) => JsValue::NULL,
529        }
530    }
531
532    /// Resets the idempotency set unconditionally.
533    #[wasm_bindgen(js_name = "reset")]
534    pub fn reset(&self) {
535        self.inner.borrow_mut().reset();
536    }
537}
538
539// ─── Shared frame helpers ─────────────────────────────────────────────────────
540
541/// `OutboundFrame` → `{ wire: Uint8Array, to: number }`.
542fn outbound_to_js(of: gbp_node::OutboundFrame) -> JsValue {
543    let obj = Object::new();
544    set(&obj, "wire", &u8s(&of.wire));
545    set(&obj, "to", &JsValue::from_f64(of.to as f64));
546    obj.into()
547}
548
549fn gap_payload_to_js(status: &str, p: gap::GapPayload) -> JsValue {
550    let obj = Object::new();
551    set(&obj, "status", &JsValue::from_str(status));
552    set(&obj, "source", &JsValue::from_f64(p.media_source_id as f64));
553    set(&obj, "seq", &JsValue::from_f64(p.rtp_sequence as f64));
554    set(&obj, "rtpTimestamp", &js_sys::BigInt::from(p.rtp_timestamp).into());
555    set(&obj, "opus", &u8s(&p.opus_frame.into_vec()));
556    obj.into()
557}
558
559fn cipher_suite_from(v: u8) -> Result<RustCipherSuite, JsValue> {
560    RustCipherSuite::from_u8(v).ok_or_else(|| js_err(format!("unknown ciphersuite {v}")))
561}
562
563// ─── GapClient ─────────────────────────────────────────────────────────────────
564
565/// Group Audio Protocol client — Opus frame delivery with per-source replay
566/// protection over GBP. The Opus payload is opaque bytes (encode/decode audio
567/// in the app, e.g. WebCodecs). Combine with [`SFrameSession`] for media E2EE.
568///
569/// JS usage:
570/// ```js
571/// const gap = GapClient.create();
572/// const frame = gap.send(node, mls, 0, mediaSourceId, rtpTimestamp, opusBytes, PayloadCodec.FlatBuffers);
573/// // on receive (payload_received event whose streamType is audio):
574/// const r = gap.accept(ev.plaintext, mls.epoch);
575/// // r.status ("new"|"late"), r.source, r.seq, r.opus (Uint8Array)
576/// ```
577#[wasm_bindgen]
578pub struct GapClient {
579    inner: RefCell<RustGapClient>,
580}
581
582#[wasm_bindgen]
583impl GapClient {
584    /// Creates an empty GAP client.
585    #[wasm_bindgen(js_name = "create")]
586    pub fn create() -> GapClient {
587        GapClient { inner: RefCell::new(RustGapClient::new()) }
588    }
589
590    /// Sends one Opus audio frame. Returns `{ wire: Uint8Array, to: number }`
591    /// or `null` on error. Pass `target = 0` to broadcast. `codec` is optional;
592    /// for audio prefer `PayloadCodec.FlatBuffers` for lowest decode latency.
593    #[wasm_bindgen(js_name = "send")]
594    pub fn send(
595        &self,
596        node: &GroupNode,
597        mls: &MlsContext,
598        target: u32,
599        media_source_id: u32,
600        rtp_timestamp: u64,
601        opus: &[u8],
602        codec: Option<u8>,
603    ) -> JsValue {
604        let mut gap = self.inner.borrow_mut();
605        let mut n = node.inner.borrow_mut();
606        let mut m = mls.inner.borrow_mut();
607        match gap.send(
608            &mut *n,
609            &mut *m,
610            target as MemberId,
611            media_source_id,
612            rtp_timestamp,
613            opus.to_vec(),
614            codec_from(codec),
615        ) {
616            Ok(of) => outbound_to_js(of),
617            Err(_) => JsValue::NULL,
618        }
619    }
620
621    /// Accepts a GAP audio payload from a `payload_received` event. Returns
622    /// `{ status, source, seq, rtpTimestamp, opus }` where status is `"new"`
623    /// or `"late"`, or `null` on a malformed/stale payload. `codec` must match
624    /// the sender's encoding (defaults to CBOR).
625    #[wasm_bindgen(js_name = "accept")]
626    pub fn accept(&self, plaintext: &[u8], epoch: u64, codec: Option<u8>) -> JsValue {
627        let mut gap = self.inner.borrow_mut();
628        match gap.accept(plaintext, epoch, codec_from(codec)) {
629            Ok(GapAccept::New(p)) => gap_payload_to_js("new", p),
630            Ok(GapAccept::Late(p)) => gap_payload_to_js("late", p),
631            Err(_) => JsValue::NULL,
632        }
633    }
634
635    /// Clears outbound counters + replay window (use after an epoch change).
636    #[wasm_bindgen(js_name = "reset")]
637    pub fn reset(&self) {
638        self.inner.borrow_mut().reset();
639    }
640}
641
642// ─── GspClient ─────────────────────────────────────────────────────────────────
643
644/// Group Signaling Protocol client — membership / role / stream / codec control
645/// signals over GBP. Drives call membership and mute/stream state.
646///
647/// JS usage:
648/// ```js
649/// const gsp = GspClient.create();
650/// const f  = gsp.send(node, mls, 0, SignalType.Join, 0, requestId);
651/// const f2 = gsp.sendWithArgs(node, mls, 0, SignalType.Mute, 0, requestId, argsBytes);
652/// const r  = gsp.accept(ev.plaintext, mls.epoch);
653/// // r.status, r.signal, r.signalCode, r.sender, r.roleClaim, r.requestId
654/// ```
655#[wasm_bindgen]
656pub struct GspClient {
657    inner: RefCell<RustGspClient>,
658}
659
660#[wasm_bindgen]
661impl GspClient {
662    /// Creates an empty GSP client.
663    #[wasm_bindgen(js_name = "create")]
664    pub fn create() -> GspClient {
665        GspClient { inner: RefCell::new(RustGspClient::new()) }
666    }
667
668    /// Sends a bare signal with no arguments (e.g. `SignalType.Join` /
669    /// `SignalType.Leave`). Returns `{ wire, to }` or throws. `target = 0`
670    /// broadcasts.
671    #[wasm_bindgen(js_name = "send")]
672    pub fn send(
673        &self,
674        node: &GroupNode,
675        mls: &MlsContext,
676        target: u32,
677        signal_type: u32,
678        role_claim: u32,
679        request_id: u32,
680        codec: Option<u8>,
681    ) -> Result<JsValue, JsValue> {
682        let sig = gbp_core::SignalType::try_from(signal_type)
683            .map_err(|_| js_err(format!("bad signal {signal_type}")))?;
684        let mut gsp = self.inner.borrow_mut();
685        let mut n = node.inner.borrow_mut();
686        let mut m = mls.inner.borrow_mut();
687        gsp.send(&mut *n, &mut *m, target as MemberId, sig, role_claim, request_id, codec_from(codec))
688            .map(outbound_to_js)
689            .map_err(|e| js_err(e))
690    }
691
692    /// Sends a signal carrying opcode-specific CBOR `args` — MUTE, UNMUTE,
693    /// ROLE_CHANGE, STREAM_START, STREAM_STOP, CODEC_UPDATE.
694    #[wasm_bindgen(js_name = "sendWithArgs")]
695    pub fn send_with_args(
696        &self,
697        node: &GroupNode,
698        mls: &MlsContext,
699        target: u32,
700        signal_type: u32,
701        role_claim: u32,
702        request_id: u32,
703        args: &[u8],
704        codec: Option<u8>,
705    ) -> Result<JsValue, JsValue> {
706        let sig = gbp_core::SignalType::try_from(signal_type)
707            .map_err(|_| js_err(format!("bad signal {signal_type}")))?;
708        let mut gsp = self.inner.borrow_mut();
709        let mut n = node.inner.borrow_mut();
710        let mut m = mls.inner.borrow_mut();
711        gsp.send_with_args(&mut *n, &mut *m, target as MemberId, sig, role_claim, request_id, args, codec_from(codec))
712            .map(outbound_to_js)
713            .map_err(|e| js_err(e))
714    }
715
716    /// Accepts a GSP signal payload. Returns
717    /// `{ status, signal, signalCode, sender, roleClaim, requestId }` for a new
718    /// signal, `{ status: "duplicate", requestId }` for a replayed request, or
719    /// throws on a hard error. `codec` must match the sender (defaults to CBOR).
720    #[wasm_bindgen(js_name = "accept")]
721    pub fn accept(&self, plaintext: &[u8], epoch: u64, codec: Option<u8>) -> Result<JsValue, JsValue> {
722        let mut gsp = self.inner.borrow_mut();
723        match gsp.accept(plaintext, epoch, codec_from(codec)) {
724            Ok(GspAccept { signal, sender_id, role_claim, request_id }) => {
725                let obj = Object::new();
726                set(&obj, "status", &JsValue::from_str("new"));
727                set(&obj, "signal", &JsValue::from_str(signal.name()));
728                set(&obj, "signalCode", &JsValue::from_f64(signal as u32 as f64));
729                set(&obj, "sender", &JsValue::from_f64(sender_id as f64));
730                set(&obj, "roleClaim", &JsValue::from_f64(role_claim as f64));
731                set(&obj, "requestId", &JsValue::from_f64(request_id as f64));
732                Ok(obj.into())
733            }
734            Err(GspError::DuplicateRequest(rid)) => {
735                let obj = Object::new();
736                set(&obj, "status", &JsValue::from_str("duplicate"));
737                set(&obj, "requestId", &JsValue::from_f64(rid as f64));
738                Ok(obj.into())
739            }
740            Err(e) => Err(js_err(e)),
741        }
742    }
743
744    /// Clears dedup state (use after an epoch change).
745    #[wasm_bindgen(js_name = "reset")]
746    pub fn reset(&self) {
747        self.inner.borrow_mut().reset();
748    }
749}
750
751// ─── SFrame (media E2EE) ──────────────────────────────────────────────────────
752
753/// SFrame E2EE session for one MLS epoch — wraps the receiver-side decryptor.
754/// Derive a fresh session after every epoch change (invite/remove/commit), as
755/// the base key rotates with the MLS exporter secret. Create per-sender
756/// encryptors via [`createEncryptor`].
757///
758/// JS usage:
759/// ```js
760/// const session = SFrameSession.create(mls, "gbp/sframe v1", CipherSuite.Aes128Gcm);
761/// const enc = session.createEncryptor(mls, myLeafIndex, "gbp/sframe v1", CipherSuite.Aes128Gcm);
762/// const ct  = enc.encrypt(opusBytes, new Uint8Array());     // wrap before gap.send
763/// const { plaintext, senderLeaf } = session.decrypt(ct, new Uint8Array());
764/// ```
765#[wasm_bindgen]
766pub struct SFrameSession {
767    inner: RefCell<RustSFrameDecryptor>,
768}
769
770#[wasm_bindgen]
771impl SFrameSession {
772    /// Derives an SFrame session from the current MLS group state via
773    /// `MLS.ExportSecret(label, epoch, 32)`. `suite` is a [`CipherSuite`] value
774    /// (0 = AES-128-GCM, 1 = AES-256-GCM). `label` must match across the group.
775    #[wasm_bindgen(js_name = "create")]
776    pub fn create(mls: &MlsContext, label: &str, suite: u8) -> Result<SFrameSession, JsValue> {
777        let suite = cipher_suite_from(suite)?;
778        let m = mls.inner.borrow();
779        let session = RustSFrameSession::from_mls(&m, label, suite).map_err(|e| js_err(e))?;
780        Ok(SFrameSession { inner: RefCell::new(session.decryptor()) })
781    }
782
783    /// Creates a sender-side encryptor for `leafIndex` in this epoch. Re-derives
784    /// the session from MLS (same `label` + `suite`) so encryptor and decryptor
785    /// share the epoch base key.
786    #[wasm_bindgen(js_name = "createEncryptor")]
787    pub fn create_encryptor(
788        &self,
789        mls: &MlsContext,
790        leaf_index: u32,
791        label: &str,
792        suite: u8,
793    ) -> Result<SFrameEncryptor, JsValue> {
794        let suite = cipher_suite_from(suite)?;
795        let m = mls.inner.borrow();
796        let session = RustSFrameSession::from_mls(&m, label, suite).map_err(|e| js_err(e))?;
797        Ok(SFrameEncryptor { inner: RefCell::new(session.encryptor(leaf_index)) })
798    }
799
800    /// Decrypts an SFrame payload, returning
801    /// `{ plaintext: Uint8Array, senderLeaf: number }` or throwing on failure.
802    /// `aad` must equal the sender's `extra_aad` (pass an empty array if none).
803    #[wasm_bindgen(js_name = "decrypt")]
804    pub fn decrypt(&self, payload: &[u8], aad: &[u8]) -> Result<JsValue, JsValue> {
805        let mut dec = self.inner.borrow_mut();
806        match dec.decrypt(payload, aad) {
807            Ok((plaintext, leaf)) => {
808                let obj = Object::new();
809                set(&obj, "plaintext", &u8s(&plaintext));
810                set(&obj, "senderLeaf", &JsValue::from_f64(leaf as f64));
811                Ok(obj.into())
812            }
813            Err(e) => Err(js_err(e)),
814        }
815    }
816}
817
818/// Sender-side SFrame encryptor (one per sender per epoch). Created via
819/// [`SFrameSession::createEncryptor`]; maintains an internal frame counter.
820#[wasm_bindgen]
821pub struct SFrameEncryptor {
822    inner: RefCell<RustSFrameEncryptor>,
823}
824
825#[wasm_bindgen]
826impl SFrameEncryptor {
827    /// Encrypts one frame, returning `sframe_header ‖ ciphertext ‖ tag`.
828    /// `aad` is additional authenticated data (e.g. an RTP header); pass an
829    /// empty array if none.
830    #[wasm_bindgen(js_name = "encrypt")]
831    pub fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Uint8Array, JsValue> {
832        let mut enc = self.inner.borrow_mut();
833        let ct = enc.encrypt(plaintext, aad).map_err(|e| js_err(e))?;
834        Ok(Uint8Array::from(ct.as_slice()))
835    }
836}
837
838// ─── Tests ──────────────────────────────────────────────────────────────────
839
840#[cfg(test)]
841mod tests;