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