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