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
292// ─── GroupNode ───────────────────────────────────────────────────────────────
293
294/// GBP group node — framing, AEAD, replay window, control plane.
295///
296/// JS usage:
297/// ```js
298/// const node = GroupNode.create(1, groupId);
299/// node.bootstrapAsCreator(mls.epoch);
300/// const events = node.onWire(mls, wireBytes);
301/// ```
302#[wasm_bindgen]
303pub struct GroupNode {
304    inner: RefCell<RustGroupNode>,
305}
306
307#[wasm_bindgen]
308impl GroupNode {
309    /// Creates a node for `leafIndex` (member id) and the given 16-byte group id.
310    #[wasm_bindgen(js_name = "create")]
311    pub fn create(leaf_index: u32, group_id_bytes: &[u8]) -> GroupNode {
312        let gid: [u8; 16] = group_id_bytes.try_into().unwrap_or([0u8; 16]);
313        GroupNode { inner: RefCell::new(RustGroupNode::new(leaf_index as MemberId, gid)) }
314    }
315
316    /// Drives the node to `ACTIVE` as the group creator at the given epoch.
317    #[wasm_bindgen(js_name = "bootstrapAsCreator")]
318    pub fn bootstrap_as_creator(&self, epoch: u64) {
319        self.inner.borrow_mut().bootstrap_as_creator(epoch);
320    }
321
322    /// Drives the node to `ACTIVE` as a joiner.
323    ///
324    /// Pass `expectedFirstTid = 0` unless you know the in-flight
325    /// `transition_id` the coordinator will send in `EXECUTE_TRANSITION`.
326    #[wasm_bindgen(js_name = "bootstrapAsJoiner")]
327    pub fn bootstrap_as_joiner(&self, epoch: u64, expected_first_tid: u32) {
328        self.inner.borrow_mut().bootstrap_as_joiner(epoch, expected_first_tid);
329    }
330
331    /// Delivers a wire frame and returns the resulting events array.
332    ///
333    /// Each element is a plain JS object with at minimum `{ kind: string }`.
334    ///
335    /// | `kind` | Extra fields |
336    /// |--------|-------------|
337    /// | `"payload_received"` | `streamType`, `plaintext`, `sequenceNo`, `codec` |
338    /// | `"state_changed"` | `from`, `to` |
339    /// | `"epoch_advanced"` | `epoch` (bigint), `transitionId` |
340    /// | `"error"` | `code`, `reason`, `fatal`, `retryable` |
341    /// | `"control"` | `from`, `opcode`, `transitionId` |
342    #[wasm_bindgen(js_name = "onWire")]
343    pub fn on_wire(&self, mls: &MlsContext, wire_bytes: &[u8]) -> Array {
344        let mut node = self.inner.borrow_mut();
345        let mut mls_inner = mls.inner.borrow_mut();
346        let events = node.on_wire(&mut *mls_inner, wire_bytes).unwrap_or_default();
347        let arr = Array::new();
348        for ev in events {
349            arr.push(&event_to_js(ev));
350        }
351        arr
352    }
353
354    /// Polls pending timeout events — call ~every 500 ms from the app loop.
355    #[wasm_bindgen(js_name = "checkTimeouts")]
356    pub fn check_timeouts(&self) -> Array {
357        let arr = Array::new();
358        for ev in self.inner.borrow_mut().check_timeouts() {
359            arr.push(&event_to_js(ev));
360        }
361        arr
362    }
363
364    /// The `transition_id` of the last applied epoch transition.
365    #[wasm_bindgen(getter, js_name = "lastTransitionId")]
366    pub fn last_transition_id(&self) -> u32 {
367        self.inner.borrow().last_transition_id
368    }
369
370    /// Current epoch as seen by the GBP layer.
371    #[wasm_bindgen(getter, js_name = "currentEpoch")]
372    pub fn current_epoch(&self) -> u64 {
373        self.inner.borrow().current_epoch
374    }
375
376    /// This node's member id (leaf index).
377    #[wasm_bindgen(getter, js_name = "memberId")]
378    pub fn member_id(&self) -> u32 {
379        self.inner.borrow().member_id
380    }
381
382    /// Sends a control-plane message on Stream 0 — epoch-transition coordination
383    /// (PREPARE/READY/EXECUTE/ABORT), capabilities advertise, ACK/NACK.
384    /// `opcode` is a [`ControlOpcode`] value. Returns `{ wire: Uint8Array, to: number }`
385    /// or throws. Pass `target = 0` to broadcast; pass an empty `args` array
386    /// when the opcode carries no arguments.
387    #[wasm_bindgen(js_name = "sendControl")]
388    pub fn send_control(
389        &self,
390        mls: &MlsContext,
391        target: u32,
392        opcode: u16,
393        transition_id: u32,
394        request_id: u32,
395        args: &[u8],
396    ) -> Result<JsValue, JsValue> {
397        let op = gbp_core::ControlOpcode::try_from(opcode)
398            .map_err(|_| js_err(format!("bad opcode 0x{opcode:04X}")))?;
399        let mut node = self.inner.borrow_mut();
400        let mut m = mls.inner.borrow_mut();
401        let of = node
402            .send_control(&mut *m, target as MemberId, op, transition_id, request_id, args.to_vec())
403            .map_err(|e| js_err(e))?;
404        let obj = Object::new();
405        set(&obj, "wire", &u8s(&of.wire));
406        set(&obj, "to", &JsValue::from_f64(of.to as f64));
407        Ok(obj.into())
408    }
409
410    /// Applies an epoch transition locally (advances `currentEpoch` and
411    /// `lastTransitionId`).
412    #[wasm_bindgen(js_name = "applyTransition")]
413    pub fn apply_transition(&self, tid: u32) {
414        self.inner.borrow_mut().apply_transition(tid);
415    }
416
417    /// Drains queued events without consuming wire bytes. Each element has the
418    /// same shape as the array returned by [`onWire`].
419    #[wasm_bindgen(js_name = "drainEvents")]
420    pub fn drain_events(&self) -> Array {
421        let arr = Array::new();
422        for ev in self.inner.borrow_mut().drain_events() {
423            arr.push(&event_to_js(ev));
424        }
425        arr
426    }
427}
428
429// ─── GtpClient ───────────────────────────────────────────────────────────────
430
431/// Group Text Protocol client — idempotent text delivery over GBP.
432///
433/// JS usage:
434/// ```js
435/// const gtp = GtpClient.create();
436/// const frame = gtp.send(node, mls, 0, 1n, "hello");
437/// // frame.wire: Uint8Array — hand to transport
438///
439/// // on receive:
440/// const result = gtp.accept(ev.plaintext, mls.epoch);
441/// // result.text / result.messageId / result.senderId
442/// ```
443#[wasm_bindgen]
444pub struct GtpClient {
445    inner: RefCell<RustGtpClient>,
446}
447
448#[wasm_bindgen]
449impl GtpClient {
450    /// Creates an empty GTP client.
451    #[wasm_bindgen(js_name = "create")]
452    pub fn create() -> GtpClient {
453        GtpClient { inner: RefCell::new(RustGtpClient::new()) }
454    }
455
456    /// Sends a text message.
457    ///
458    /// Returns `{ wire: Uint8Array, to: number }` or `null` on error.
459    /// Pass `target = 0` to broadcast to all members. `codec` is optional
460    /// (a [`PayloadCodec`] value); omit it for the CBOR default.
461    #[wasm_bindgen(js_name = "send")]
462    pub fn send(
463        &self,
464        node: &GroupNode,
465        mls: &MlsContext,
466        target: u32,
467        message_id: u64,
468        text: &str,
469        codec: Option<u8>,
470    ) -> JsValue {
471        let mut gtp = self.inner.borrow_mut();
472        let mut n = node.inner.borrow_mut();
473        let mut m = mls.inner.borrow_mut();
474        match gtp.send(&mut *n, &mut *m, target as MemberId, message_id, text, codec_from(codec)) {
475            Ok(frame) => {
476                let obj = Object::new();
477                set(&obj, "wire", &u8s(&frame.wire));
478                set(&obj, "to", &JsValue::from_f64(frame.to as f64));
479                obj.into()
480            }
481            Err(_) => JsValue::NULL,
482        }
483    }
484
485    /// Accepts a plaintext GTP payload delivered from a `payload_received` event.
486    ///
487    /// Returns `{ text: string, messageId: bigint, senderId: number }` or
488    /// `null` if the payload is malformed.
489    /// The `status` field is `"new"` or `"duplicate"` based on idempotency.
490    /// `codec` is optional and must match the encoding used by the sender
491    /// (defaults to CBOR).
492    #[wasm_bindgen(js_name = "accept")]
493    pub fn accept(&self, plaintext: &[u8], epoch: u64, codec: Option<u8>) -> JsValue {
494        let mut gtp = self.inner.borrow_mut();
495        match gtp.accept(plaintext, epoch, codec_from(codec)) {
496            Ok(result) => {
497                let (msg, status) = match result {
498                    GtpAccept::New(m) => (m, "new"),
499                    GtpAccept::Duplicate(m) => (m, "duplicate"),
500                };
501                let text = String::from_utf8_lossy(&msg.content).into_owned();
502                let obj = Object::new();
503                set(&obj, "text", &JsValue::from_str(&text));
504                set(&obj, "messageId", &js_sys::BigInt::from(msg.message_id).into());
505                set(&obj, "senderId", &JsValue::from_f64(msg.sender_id as f64));
506                set(&obj, "status", &JsValue::from_str(status));
507                obj.into()
508            }
509            Err(_) => JsValue::NULL,
510        }
511    }
512
513    /// Resets the idempotency set unconditionally.
514    #[wasm_bindgen(js_name = "reset")]
515    pub fn reset(&self) {
516        self.inner.borrow_mut().reset();
517    }
518}
519
520// ─── Shared frame helpers ─────────────────────────────────────────────────────
521
522/// `OutboundFrame` → `{ wire: Uint8Array, to: number }`.
523fn outbound_to_js(of: gbp_node::OutboundFrame) -> JsValue {
524    let obj = Object::new();
525    set(&obj, "wire", &u8s(&of.wire));
526    set(&obj, "to", &JsValue::from_f64(of.to as f64));
527    obj.into()
528}
529
530fn gap_payload_to_js(status: &str, p: gap::GapPayload) -> JsValue {
531    let obj = Object::new();
532    set(&obj, "status", &JsValue::from_str(status));
533    set(&obj, "source", &JsValue::from_f64(p.media_source_id as f64));
534    set(&obj, "seq", &JsValue::from_f64(p.rtp_sequence as f64));
535    set(&obj, "rtpTimestamp", &js_sys::BigInt::from(p.rtp_timestamp).into());
536    set(&obj, "opus", &u8s(&p.opus_frame.into_vec()));
537    obj.into()
538}
539
540fn cipher_suite_from(v: u8) -> Result<RustCipherSuite, JsValue> {
541    RustCipherSuite::from_u8(v).ok_or_else(|| js_err(format!("unknown ciphersuite {v}")))
542}
543
544// ─── GapClient ─────────────────────────────────────────────────────────────────
545
546/// Group Audio Protocol client — Opus frame delivery with per-source replay
547/// protection over GBP. The Opus payload is opaque bytes (encode/decode audio
548/// in the app, e.g. WebCodecs). Combine with [`SFrameSession`] for media E2EE.
549///
550/// JS usage:
551/// ```js
552/// const gap = GapClient.create();
553/// const frame = gap.send(node, mls, 0, mediaSourceId, rtpTimestamp, opusBytes, PayloadCodec.FlatBuffers);
554/// // on receive (payload_received event whose streamType is audio):
555/// const r = gap.accept(ev.plaintext, mls.epoch);
556/// // r.status ("new"|"late"), r.source, r.seq, r.opus (Uint8Array)
557/// ```
558#[wasm_bindgen]
559pub struct GapClient {
560    inner: RefCell<RustGapClient>,
561}
562
563#[wasm_bindgen]
564impl GapClient {
565    /// Creates an empty GAP client.
566    #[wasm_bindgen(js_name = "create")]
567    pub fn create() -> GapClient {
568        GapClient { inner: RefCell::new(RustGapClient::new()) }
569    }
570
571    /// Sends one Opus audio frame. Returns `{ wire: Uint8Array, to: number }`
572    /// or `null` on error. Pass `target = 0` to broadcast. `codec` is optional;
573    /// for audio prefer `PayloadCodec.FlatBuffers` for lowest decode latency.
574    #[wasm_bindgen(js_name = "send")]
575    pub fn send(
576        &self,
577        node: &GroupNode,
578        mls: &MlsContext,
579        target: u32,
580        media_source_id: u32,
581        rtp_timestamp: u64,
582        opus: &[u8],
583        codec: Option<u8>,
584    ) -> JsValue {
585        let mut gap = self.inner.borrow_mut();
586        let mut n = node.inner.borrow_mut();
587        let mut m = mls.inner.borrow_mut();
588        match gap.send(
589            &mut *n,
590            &mut *m,
591            target as MemberId,
592            media_source_id,
593            rtp_timestamp,
594            opus.to_vec(),
595            codec_from(codec),
596        ) {
597            Ok(of) => outbound_to_js(of),
598            Err(_) => JsValue::NULL,
599        }
600    }
601
602    /// Accepts a GAP audio payload from a `payload_received` event. Returns
603    /// `{ status, source, seq, rtpTimestamp, opus }` where status is `"new"`
604    /// or `"late"`, or `null` on a malformed/stale payload. `codec` must match
605    /// the sender's encoding (defaults to CBOR).
606    #[wasm_bindgen(js_name = "accept")]
607    pub fn accept(&self, plaintext: &[u8], epoch: u64, codec: Option<u8>) -> JsValue {
608        let mut gap = self.inner.borrow_mut();
609        match gap.accept(plaintext, epoch, codec_from(codec)) {
610            Ok(GapAccept::New(p)) => gap_payload_to_js("new", p),
611            Ok(GapAccept::Late(p)) => gap_payload_to_js("late", p),
612            Err(_) => JsValue::NULL,
613        }
614    }
615
616    /// Clears outbound counters + replay window (use after an epoch change).
617    #[wasm_bindgen(js_name = "reset")]
618    pub fn reset(&self) {
619        self.inner.borrow_mut().reset();
620    }
621}
622
623// ─── GspClient ─────────────────────────────────────────────────────────────────
624
625/// Group Signaling Protocol client — membership / role / stream / codec control
626/// signals over GBP. Drives call membership and mute/stream state.
627///
628/// JS usage:
629/// ```js
630/// const gsp = GspClient.create();
631/// const f  = gsp.send(node, mls, 0, SignalType.Join, 0, requestId);
632/// const f2 = gsp.sendWithArgs(node, mls, 0, SignalType.Mute, 0, requestId, argsBytes);
633/// const r  = gsp.accept(ev.plaintext, mls.epoch);
634/// // r.status, r.signal, r.signalCode, r.sender, r.roleClaim, r.requestId
635/// ```
636#[wasm_bindgen]
637pub struct GspClient {
638    inner: RefCell<RustGspClient>,
639}
640
641#[wasm_bindgen]
642impl GspClient {
643    /// Creates an empty GSP client.
644    #[wasm_bindgen(js_name = "create")]
645    pub fn create() -> GspClient {
646        GspClient { inner: RefCell::new(RustGspClient::new()) }
647    }
648
649    /// Sends a bare signal with no arguments (e.g. `SignalType.Join` /
650    /// `SignalType.Leave`). Returns `{ wire, to }` or throws. `target = 0`
651    /// broadcasts.
652    #[wasm_bindgen(js_name = "send")]
653    pub fn send(
654        &self,
655        node: &GroupNode,
656        mls: &MlsContext,
657        target: u32,
658        signal_type: u32,
659        role_claim: u32,
660        request_id: u32,
661        codec: Option<u8>,
662    ) -> Result<JsValue, JsValue> {
663        let sig = gbp_core::SignalType::try_from(signal_type)
664            .map_err(|_| js_err(format!("bad signal {signal_type}")))?;
665        let mut gsp = self.inner.borrow_mut();
666        let mut n = node.inner.borrow_mut();
667        let mut m = mls.inner.borrow_mut();
668        gsp.send(&mut *n, &mut *m, target as MemberId, sig, role_claim, request_id, codec_from(codec))
669            .map(outbound_to_js)
670            .map_err(|e| js_err(e))
671    }
672
673    /// Sends a signal carrying opcode-specific CBOR `args` — MUTE, UNMUTE,
674    /// ROLE_CHANGE, STREAM_START, STREAM_STOP, CODEC_UPDATE.
675    #[wasm_bindgen(js_name = "sendWithArgs")]
676    pub fn send_with_args(
677        &self,
678        node: &GroupNode,
679        mls: &MlsContext,
680        target: u32,
681        signal_type: u32,
682        role_claim: u32,
683        request_id: u32,
684        args: &[u8],
685        codec: Option<u8>,
686    ) -> Result<JsValue, JsValue> {
687        let sig = gbp_core::SignalType::try_from(signal_type)
688            .map_err(|_| js_err(format!("bad signal {signal_type}")))?;
689        let mut gsp = self.inner.borrow_mut();
690        let mut n = node.inner.borrow_mut();
691        let mut m = mls.inner.borrow_mut();
692        gsp.send_with_args(&mut *n, &mut *m, target as MemberId, sig, role_claim, request_id, args, codec_from(codec))
693            .map(outbound_to_js)
694            .map_err(|e| js_err(e))
695    }
696
697    /// Accepts a GSP signal payload. Returns
698    /// `{ status, signal, signalCode, sender, roleClaim, requestId }` for a new
699    /// signal, `{ status: "duplicate", requestId }` for a replayed request, or
700    /// throws on a hard error. `codec` must match the sender (defaults to CBOR).
701    #[wasm_bindgen(js_name = "accept")]
702    pub fn accept(&self, plaintext: &[u8], epoch: u64, codec: Option<u8>) -> Result<JsValue, JsValue> {
703        let mut gsp = self.inner.borrow_mut();
704        match gsp.accept(plaintext, epoch, codec_from(codec)) {
705            Ok(GspAccept { signal, sender_id, role_claim, request_id }) => {
706                let obj = Object::new();
707                set(&obj, "status", &JsValue::from_str("new"));
708                set(&obj, "signal", &JsValue::from_str(signal.name()));
709                set(&obj, "signalCode", &JsValue::from_f64(signal as u32 as f64));
710                set(&obj, "sender", &JsValue::from_f64(sender_id as f64));
711                set(&obj, "roleClaim", &JsValue::from_f64(role_claim as f64));
712                set(&obj, "requestId", &JsValue::from_f64(request_id as f64));
713                Ok(obj.into())
714            }
715            Err(GspError::DuplicateRequest(rid)) => {
716                let obj = Object::new();
717                set(&obj, "status", &JsValue::from_str("duplicate"));
718                set(&obj, "requestId", &JsValue::from_f64(rid as f64));
719                Ok(obj.into())
720            }
721            Err(e) => Err(js_err(e)),
722        }
723    }
724
725    /// Clears dedup state (use after an epoch change).
726    #[wasm_bindgen(js_name = "reset")]
727    pub fn reset(&self) {
728        self.inner.borrow_mut().reset();
729    }
730}
731
732// ─── SFrame (media E2EE) ──────────────────────────────────────────────────────
733
734/// SFrame E2EE session for one MLS epoch — wraps the receiver-side decryptor.
735/// Derive a fresh session after every epoch change (invite/remove/commit), as
736/// the base key rotates with the MLS exporter secret. Create per-sender
737/// encryptors via [`createEncryptor`].
738///
739/// JS usage:
740/// ```js
741/// const session = SFrameSession.create(mls, "gbp/sframe v1", CipherSuite.Aes128Gcm);
742/// const enc = session.createEncryptor(mls, myLeafIndex, "gbp/sframe v1", CipherSuite.Aes128Gcm);
743/// const ct  = enc.encrypt(opusBytes, new Uint8Array());     // wrap before gap.send
744/// const { plaintext, senderLeaf } = session.decrypt(ct, new Uint8Array());
745/// ```
746#[wasm_bindgen]
747pub struct SFrameSession {
748    inner: RefCell<RustSFrameDecryptor>,
749}
750
751#[wasm_bindgen]
752impl SFrameSession {
753    /// Derives an SFrame session from the current MLS group state via
754    /// `MLS.ExportSecret(label, epoch, 32)`. `suite` is a [`CipherSuite`] value
755    /// (0 = AES-128-GCM, 1 = AES-256-GCM). `label` must match across the group.
756    #[wasm_bindgen(js_name = "create")]
757    pub fn create(mls: &MlsContext, label: &str, suite: u8) -> Result<SFrameSession, JsValue> {
758        let suite = cipher_suite_from(suite)?;
759        let m = mls.inner.borrow();
760        let session = RustSFrameSession::from_mls(&m, label, suite).map_err(|e| js_err(e))?;
761        Ok(SFrameSession { inner: RefCell::new(session.decryptor()) })
762    }
763
764    /// Creates a sender-side encryptor for `leafIndex` in this epoch. Re-derives
765    /// the session from MLS (same `label` + `suite`) so encryptor and decryptor
766    /// share the epoch base key.
767    #[wasm_bindgen(js_name = "createEncryptor")]
768    pub fn create_encryptor(
769        &self,
770        mls: &MlsContext,
771        leaf_index: u32,
772        label: &str,
773        suite: u8,
774    ) -> Result<SFrameEncryptor, JsValue> {
775        let suite = cipher_suite_from(suite)?;
776        let m = mls.inner.borrow();
777        let session = RustSFrameSession::from_mls(&m, label, suite).map_err(|e| js_err(e))?;
778        Ok(SFrameEncryptor { inner: RefCell::new(session.encryptor(leaf_index)) })
779    }
780
781    /// Decrypts an SFrame payload, returning
782    /// `{ plaintext: Uint8Array, senderLeaf: number }` or throwing on failure.
783    /// `aad` must equal the sender's `extra_aad` (pass an empty array if none).
784    #[wasm_bindgen(js_name = "decrypt")]
785    pub fn decrypt(&self, payload: &[u8], aad: &[u8]) -> Result<JsValue, JsValue> {
786        let mut dec = self.inner.borrow_mut();
787        match dec.decrypt(payload, aad) {
788            Ok((plaintext, leaf)) => {
789                let obj = Object::new();
790                set(&obj, "plaintext", &u8s(&plaintext));
791                set(&obj, "senderLeaf", &JsValue::from_f64(leaf as f64));
792                Ok(obj.into())
793            }
794            Err(e) => Err(js_err(e)),
795        }
796    }
797}
798
799/// Sender-side SFrame encryptor (one per sender per epoch). Created via
800/// [`SFrameSession::createEncryptor`]; maintains an internal frame counter.
801#[wasm_bindgen]
802pub struct SFrameEncryptor {
803    inner: RefCell<RustSFrameEncryptor>,
804}
805
806#[wasm_bindgen]
807impl SFrameEncryptor {
808    /// Encrypts one frame, returning `sframe_header ‖ ciphertext ‖ tag`.
809    /// `aad` is additional authenticated data (e.g. an RTP header); pass an
810    /// empty array if none.
811    #[wasm_bindgen(js_name = "encrypt")]
812    pub fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Uint8Array, JsValue> {
813        let mut enc = self.inner.borrow_mut();
814        let ct = enc.encrypt(plaintext, aad).map_err(|e| js_err(e))?;
815        Ok(Uint8Array::from(ct.as_slice()))
816    }
817}
818
819// ─── Tests ──────────────────────────────────────────────────────────────────
820
821#[cfg(test)]
822mod tests;