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 std::collections::HashMap;
28use wasm_bindgen::prelude::*;
29
30// ─── helpers ────────────────────────────────────────────────────────────────
31
32fn set(obj: &Object, key: &str, val: &JsValue) {
33    Reflect::set(obj, &JsValue::from_str(key), val).unwrap_throw();
34}
35
36fn u8s(bytes: &[u8]) -> JsValue {
37    Uint8Array::from(bytes).into()
38}
39
40fn js_err(msg: impl std::fmt::Display) -> JsValue {
41    JsValue::from_str(&msg.to_string())
42}
43
44fn event_to_js(ev: Event) -> JsValue {
45    let obj = Object::new();
46    match ev {
47        Event::PayloadReceived(p) => {
48            set(&obj, "kind", &"payload_received".into());
49            set(
50                &obj,
51                "streamType",
52                &JsValue::from_f64(p.stream_type.as_u8() as f64),
53            );
54            set(&obj, "plaintext", &u8s(&p.plaintext));
55            set(&obj, "sequenceNo", &JsValue::from_f64(p.sequence_no as f64));
56            set(&obj, "codec", &JsValue::from_f64(p.codec as u8 as f64));
57        }
58        Event::StateChanged { from, to } => {
59            set(&obj, "kind", &"state_changed".into());
60            set(&obj, "from", &JsValue::from_str(&from.to_string()));
61            set(&obj, "to", &JsValue::from_str(&to.to_string()));
62        }
63        Event::EpochAdvanced {
64            epoch,
65            transition_id,
66        } => {
67            set(&obj, "kind", &"epoch_advanced".into());
68            set(&obj, "epoch", &js_sys::BigInt::from(epoch).into());
69            set(
70                &obj,
71                "transitionId",
72                &JsValue::from_f64(transition_id as f64),
73            );
74        }
75        Event::Error {
76            code,
77            reason,
78            fatal,
79            retryable,
80            ..
81        } => {
82            set(&obj, "kind", &"error".into());
83            set(&obj, "code", &JsValue::from_f64(code as f64));
84            set(&obj, "reason", &JsValue::from_str(&reason));
85            set(&obj, "fatal", &JsValue::from_bool(fatal));
86            set(&obj, "retryable", &JsValue::from_bool(retryable));
87        }
88        Event::Control {
89            from,
90            opcode,
91            transition_id,
92            ..
93        } => {
94            set(&obj, "kind", &"control".into());
95            set(&obj, "from", &JsValue::from_f64(from as f64));
96            set(&obj, "opcode", &JsValue::from_f64(opcode as u8 as f64));
97            set(
98                &obj,
99                "transitionId",
100                &JsValue::from_f64(transition_id as f64),
101            );
102        }
103        _ => {
104            set(&obj, "kind", &"other".into());
105        }
106    }
107    obj.into()
108}
109
110/// Converts an optional JS codec selector into the canonical payload codec,
111/// defaulting to CBOR — the same fallback the C ABI uses. JS callers pass
112/// `undefined` (or omit the argument) for the default, or a [`PayloadCodec`]
113/// value / raw `0|1|2` to select an encoding.
114fn codec_from(c: Option<u8>) -> gbp_core::PayloadCodec {
115    c.and_then(gbp_core::PayloadCodec::from_u8)
116        .unwrap_or(gbp_core::PayloadCodec::Cbor)
117}
118
119// ─── Exported enums (parity with the C#/Python/JS SDKs) ───────────────────────
120
121/// Payload wire-encoding selector. `Cbor` is the interoperable default;
122/// `FlatBuffers` minimises decode latency and is preferred for audio.
123#[wasm_bindgen]
124#[derive(Clone, Copy, PartialEq, Eq, Debug)]
125pub enum PayloadCodec {
126    Cbor = 0,
127    Protobuf = 1,
128    FlatBuffers = 2,
129}
130
131/// GSP signal kinds (membership / role / stream / codec control).
132#[wasm_bindgen]
133#[derive(Clone, Copy, PartialEq, Eq, Debug)]
134pub enum SignalType {
135    Join = 100,
136    Leave = 101,
137    RoleChange = 102,
138    Mute = 200,
139    Unmute = 201,
140    StreamStart = 300,
141    StreamStop = 301,
142    CodecUpdate = 400,
143}
144
145/// GBP control-plane opcodes (epoch-transition coordination + diagnostics).
146#[wasm_bindgen]
147#[derive(Clone, Copy, PartialEq, Eq, Debug)]
148pub enum ControlOpcode {
149    PrepareTransition = 0x0001,
150    ReadyForTransition = 0x0002,
151    ExecuteTransition = 0x0003,
152    AbortTransition = 0x0004,
153    GroupStateDigestRequest = 0x0005,
154    GroupStateDigestResponse = 0x0006,
155    ReportInvalidCommit = 0x0007,
156    CapabilitiesAdvertise = 0x0008,
157    Ack = 0x0009,
158    Nack = 0x000A,
159}
160
161/// SFrame AEAD ciphersuite. `Aes128Gcm` is the standard choice.
162#[wasm_bindgen]
163#[derive(Clone, Copy, PartialEq, Eq, Debug)]
164pub enum CipherSuite {
165    Aes128Gcm = 0,
166    Aes256Gcm = 1,
167}
168
169// ─── MlsContext ──────────────────────────────────────────────────────────────
170
171/// MLS group state for one member.
172///
173/// JS usage:
174/// ```js
175/// const alice = MlsContext.create("alice");
176/// const bob   = MlsContext.create("bob");
177/// const welcome = alice.invite(bob.keyPackage);
178/// bob.acceptWelcome(welcome);
179/// // alice.epoch === bob.epoch === 1n
180/// ```
181#[wasm_bindgen]
182pub struct MlsContext {
183    inner: RefCell<gbp_mls::MlsContext>,
184    kp_bytes: Vec<u8>,
185}
186
187#[wasm_bindgen]
188impl MlsContext {
189    /// Creates a new member identity.
190    ///
191    /// The returned object holds a pre-generated key package that another
192    /// member can pass to [`invite`] to add this member to a group.
193    #[wasm_bindgen(js_name = "create")]
194    pub fn create(user_id: &str) -> Result<MlsContext, JsValue> {
195        let (ctx, kpb) = gbp_mls::MlsContext::new_member(user_id.as_bytes()).map_err(js_err)?;
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, mut 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)
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(js_err)?;
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(js_err)?;
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(js_err)
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, mut 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)
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(js_err)?;
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(js_err)?;
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(js_err)?;
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(js_err)
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(js_err)
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(js_err)?;
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(js_err)?;
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(js_err)?;
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    // `&mut *m` is required, not redundant: `send`'s `seal: &mut S` is generic,
575    // and deref coercion doesn't apply across a generic bound (only `MlsContext`,
576    // not `RefMut<MlsContext>`, implements `Sealer`).
577    #[allow(clippy::explicit_auto_deref)]
578    #[wasm_bindgen(js_name = "send")]
579    pub fn send(
580        &self,
581        node: &GroupNode,
582        mls: &MlsContext,
583        target: u32,
584        message_id: u64,
585        text: &str,
586        codec: Option<u8>,
587    ) -> JsValue {
588        let mut gtp = self.inner.borrow_mut();
589        let mut n = node.inner.borrow_mut();
590        let mut m = mls.inner.borrow_mut();
591        match gtp.send(
592            &mut n,
593            &mut *m,
594            target as MemberId,
595            message_id,
596            text,
597            codec_from(codec),
598        ) {
599            Ok(frame) => {
600                let obj = Object::new();
601                set(&obj, "wire", &u8s(&frame.wire));
602                set(&obj, "to", &JsValue::from_f64(frame.to as f64));
603                obj.into()
604            }
605            Err(_) => JsValue::NULL,
606        }
607    }
608
609    /// Accepts a plaintext GTP payload delivered from a `payload_received` event.
610    ///
611    /// Returns `{ text: string, messageId: bigint, senderId: number }` or
612    /// `null` if the payload is malformed.
613    /// The `status` field is `"new"` or `"duplicate"` based on idempotency.
614    /// `codec` is optional and must match the encoding used by the sender
615    /// (defaults to CBOR).
616    #[wasm_bindgen(js_name = "accept")]
617    pub fn accept(&self, plaintext: &[u8], epoch: u64, codec: Option<u8>) -> JsValue {
618        let mut gtp = self.inner.borrow_mut();
619        match gtp.accept(plaintext, epoch, codec_from(codec)) {
620            Ok(result) => {
621                let (msg, status) = match result {
622                    GtpAccept::New(m) => (m, "new"),
623                    GtpAccept::Duplicate(m) => (m, "duplicate"),
624                };
625                let text = String::from_utf8_lossy(&msg.content).into_owned();
626                let obj = Object::new();
627                set(&obj, "text", &JsValue::from_str(&text));
628                set(
629                    &obj,
630                    "messageId",
631                    &js_sys::BigInt::from(msg.message_id).into(),
632                );
633                set(&obj, "senderId", &JsValue::from_f64(msg.sender_id as f64));
634                set(&obj, "status", &JsValue::from_str(status));
635                obj.into()
636            }
637            Err(_) => JsValue::NULL,
638        }
639    }
640
641    /// Resets the idempotency set unconditionally.
642    #[wasm_bindgen(js_name = "reset")]
643    pub fn reset(&self) {
644        self.inner.borrow_mut().reset();
645    }
646}
647
648// ─── Shared frame helpers ─────────────────────────────────────────────────────
649
650/// `OutboundFrame` → `{ wire: Uint8Array, to: number }`.
651fn outbound_to_js(of: gbp_node::OutboundFrame) -> JsValue {
652    let obj = Object::new();
653    set(&obj, "wire", &u8s(&of.wire));
654    set(&obj, "to", &JsValue::from_f64(of.to as f64));
655    obj.into()
656}
657
658fn gap_payload_to_js(status: &str, p: gap::GapPayload) -> JsValue {
659    let obj = Object::new();
660    set(&obj, "status", &JsValue::from_str(status));
661    set(&obj, "source", &JsValue::from_f64(p.media_source_id as f64));
662    set(&obj, "seq", &JsValue::from_f64(p.rtp_sequence as f64));
663    set(
664        &obj,
665        "rtpTimestamp",
666        &js_sys::BigInt::from(p.rtp_timestamp).into(),
667    );
668    set(&obj, "opus", &u8s(&p.opus_frame.into_vec()));
669    obj.into()
670}
671
672fn cipher_suite_from(v: u8) -> Result<RustCipherSuite, JsValue> {
673    RustCipherSuite::from_u8(v).ok_or_else(|| js_err(format!("unknown ciphersuite {v}")))
674}
675
676// ─── GapClient ─────────────────────────────────────────────────────────────────
677
678/// Group Audio Protocol client — Opus frame delivery with per-source replay
679/// protection over GBP. The Opus payload is opaque bytes (encode/decode audio
680/// in the app, e.g. WebCodecs). Combine with [`SFrameSession`] for media E2EE.
681///
682/// JS usage:
683/// ```js
684/// const gap = GapClient.create();
685/// const frame = gap.send(node, mls, 0, mediaSourceId, rtpTimestamp, opusBytes, PayloadCodec.FlatBuffers);
686/// // on receive (payload_received event whose streamType is audio):
687/// const r = gap.accept(ev.plaintext, mls.epoch);
688/// // r.status ("new"|"late"), r.source, r.seq, r.opus (Uint8Array)
689/// ```
690#[wasm_bindgen]
691pub struct GapClient {
692    inner: RefCell<RustGapClient>,
693}
694
695#[wasm_bindgen]
696impl GapClient {
697    /// Creates an empty GAP client.
698    #[wasm_bindgen(js_name = "create")]
699    pub fn create() -> GapClient {
700        GapClient {
701            inner: RefCell::new(RustGapClient::new()),
702        }
703    }
704
705    /// Sends one Opus audio frame. Returns `{ wire: Uint8Array, to: number }`
706    /// or `null` on error. Pass `target = 0` to broadcast. `codec` is optional;
707    /// for audio prefer `PayloadCodec.FlatBuffers` for lowest decode latency.
708    // One parameter per RTP/GAP field passed through to the inner `GapClient`;
709    // `&mut *m` is required, not redundant: `send`'s `seal: &mut S` is generic,
710    // and deref coercion doesn't apply across a generic bound (only `MlsContext`,
711    // not `RefMut<MlsContext>`, implements `Sealer`).
712    #[allow(clippy::too_many_arguments, clippy::explicit_auto_deref)]
713    #[wasm_bindgen(js_name = "send")]
714    pub fn send(
715        &self,
716        node: &GroupNode,
717        mls: &MlsContext,
718        target: u32,
719        media_source_id: u32,
720        rtp_timestamp: u64,
721        opus: &[u8],
722        codec: Option<u8>,
723    ) -> JsValue {
724        let mut gap = self.inner.borrow_mut();
725        let mut n = node.inner.borrow_mut();
726        let mut m = mls.inner.borrow_mut();
727        match gap.send(
728            &mut n,
729            &mut *m,
730            target as MemberId,
731            media_source_id,
732            rtp_timestamp,
733            opus.to_vec(),
734            codec_from(codec),
735        ) {
736            Ok(of) => outbound_to_js(of),
737            Err(_) => JsValue::NULL,
738        }
739    }
740
741    /// Accepts a GAP audio payload from a `payload_received` event. Returns
742    /// `{ status, source, seq, rtpTimestamp, opus }` where status is `"new"`
743    /// or `"late"`, or `null` on a malformed/stale payload. `codec` must match
744    /// the sender's encoding (defaults to CBOR).
745    #[wasm_bindgen(js_name = "accept")]
746    pub fn accept(&self, plaintext: &[u8], epoch: u64, codec: Option<u8>) -> JsValue {
747        let mut gap = self.inner.borrow_mut();
748        match gap.accept(plaintext, epoch, codec_from(codec)) {
749            Ok(GapAccept::New(p)) => gap_payload_to_js("new", p),
750            Ok(GapAccept::Late(p)) => gap_payload_to_js("late", p),
751            Err(_) => JsValue::NULL,
752        }
753    }
754
755    /// Clears outbound counters + replay window (use after an epoch change).
756    #[wasm_bindgen(js_name = "reset")]
757    pub fn reset(&self) {
758        self.inner.borrow_mut().reset();
759    }
760}
761
762// ─── GspClient ─────────────────────────────────────────────────────────────────
763
764/// Group Signaling Protocol client — membership / role / stream / codec control
765/// signals over GBP. Drives call membership and mute/stream state.
766///
767/// JS usage:
768/// ```js
769/// const gsp = GspClient.create();
770/// const f  = gsp.send(node, mls, 0, SignalType.Join, 0, requestId);
771/// const f2 = gsp.sendWithArgs(node, mls, 0, SignalType.Mute, 0, requestId, argsBytes);
772/// const r  = gsp.accept(ev.plaintext, mls.epoch);
773/// // r.status, r.signal, r.signalCode, r.sender, r.roleClaim, r.requestId
774/// ```
775#[wasm_bindgen]
776pub struct GspClient {
777    inner: RefCell<RustGspClient>,
778}
779
780#[wasm_bindgen]
781impl GspClient {
782    /// Creates an empty GSP client.
783    #[wasm_bindgen(js_name = "create")]
784    pub fn create() -> GspClient {
785        GspClient {
786            inner: RefCell::new(RustGspClient::new()),
787        }
788    }
789
790    /// Sends a bare signal with no arguments (e.g. `SignalType.Join` /
791    /// `SignalType.Leave`). Returns `{ wire, to }` or throws. `target = 0`
792    /// broadcasts.
793    // `&mut *m` is required, not redundant: `send`'s `seal: &mut S` is generic,
794    // and deref coercion doesn't apply across a generic bound (only `MlsContext`,
795    // not `RefMut<MlsContext>`, implements `Sealer`).
796    #[allow(clippy::too_many_arguments, clippy::explicit_auto_deref)]
797    #[wasm_bindgen(js_name = "send")]
798    pub fn send(
799        &self,
800        node: &GroupNode,
801        mls: &MlsContext,
802        target: u32,
803        signal_type: u32,
804        role_claim: u32,
805        request_id: u32,
806        codec: Option<u8>,
807    ) -> Result<JsValue, JsValue> {
808        let sig = gbp_core::SignalType::try_from(signal_type)
809            .map_err(|_| js_err(format!("bad signal {signal_type}")))?;
810        let mut gsp = self.inner.borrow_mut();
811        let mut n = node.inner.borrow_mut();
812        let mut m = mls.inner.borrow_mut();
813        gsp.send(
814            &mut n,
815            &mut *m,
816            target as MemberId,
817            sig,
818            role_claim,
819            request_id,
820            codec_from(codec),
821        )
822        .map(outbound_to_js)
823        .map_err(js_err)
824    }
825
826    /// Sends a signal carrying opcode-specific CBOR `args` — MUTE, UNMUTE,
827    /// ROLE_CHANGE, STREAM_START, STREAM_STOP, CODEC_UPDATE.
828    // `&mut *m` is required, not redundant: `send_with_args`'s `seal: &mut S` is
829    // generic, and deref coercion doesn't apply across a generic bound (only
830    // `MlsContext`, not `RefMut<MlsContext>`, implements `Sealer`).
831    #[allow(clippy::too_many_arguments, clippy::explicit_auto_deref)]
832    #[wasm_bindgen(js_name = "sendWithArgs")]
833    pub fn send_with_args(
834        &self,
835        node: &GroupNode,
836        mls: &MlsContext,
837        target: u32,
838        signal_type: u32,
839        role_claim: u32,
840        request_id: u32,
841        args: &[u8],
842        codec: Option<u8>,
843    ) -> Result<JsValue, JsValue> {
844        let sig = gbp_core::SignalType::try_from(signal_type)
845            .map_err(|_| js_err(format!("bad signal {signal_type}")))?;
846        let mut gsp = self.inner.borrow_mut();
847        let mut n = node.inner.borrow_mut();
848        let mut m = mls.inner.borrow_mut();
849        gsp.send_with_args(
850            &mut n,
851            &mut *m,
852            target as MemberId,
853            sig,
854            role_claim,
855            request_id,
856            args,
857            codec_from(codec),
858        )
859        .map(outbound_to_js)
860        .map_err(js_err)
861    }
862
863    /// Accepts a GSP signal payload. Returns
864    /// `{ status, signal, signalCode, sender, roleClaim, requestId }` for a new
865    /// signal, `{ status: "duplicate", requestId }` for a replayed request, or
866    /// throws on a hard error. `codec` must match the sender (defaults to CBOR).
867    #[wasm_bindgen(js_name = "accept")]
868    pub fn accept(
869        &self,
870        plaintext: &[u8],
871        epoch: u64,
872        codec: Option<u8>,
873    ) -> Result<JsValue, JsValue> {
874        let mut gsp = self.inner.borrow_mut();
875        match gsp.accept(plaintext, epoch, codec_from(codec)) {
876            Ok(GspAccept {
877                signal,
878                sender_id,
879                role_claim,
880                request_id,
881            }) => {
882                let obj = Object::new();
883                set(&obj, "status", &JsValue::from_str("new"));
884                set(&obj, "signal", &JsValue::from_str(signal.name()));
885                set(&obj, "signalCode", &JsValue::from_f64(signal as u32 as f64));
886                set(&obj, "sender", &JsValue::from_f64(sender_id as f64));
887                set(&obj, "roleClaim", &JsValue::from_f64(role_claim as f64));
888                set(&obj, "requestId", &JsValue::from_f64(request_id as f64));
889                Ok(obj.into())
890            }
891            Err(GspError::DuplicateRequest(rid)) => {
892                let obj = Object::new();
893                set(&obj, "status", &JsValue::from_str("duplicate"));
894                set(&obj, "requestId", &JsValue::from_f64(rid as f64));
895                Ok(obj.into())
896            }
897            Err(e) => Err(js_err(e)),
898        }
899    }
900
901    /// Clears dedup state (use after an epoch change).
902    #[wasm_bindgen(js_name = "reset")]
903    pub fn reset(&self) {
904        self.inner.borrow_mut().reset();
905    }
906}
907
908// ─── SFrame (media E2EE) ──────────────────────────────────────────────────────
909
910/// SFrame E2EE session for one MLS epoch — wraps the receiver-side decryptor.
911/// Derive a fresh session after every epoch change (invite/remove/commit), as
912/// the base key rotates with the MLS exporter secret. Create per-sender
913/// encryptors via [`createEncryptor`].
914///
915/// JS usage:
916/// ```js
917/// const session = SFrameSession.create(mls, "gbp/sframe v1", CipherSuite.Aes128Gcm);
918/// const enc = session.createEncryptor(mls, myLeafIndex, "gbp/sframe v1", CipherSuite.Aes128Gcm);
919/// const ct  = enc.encrypt(opusBytes, new Uint8Array());     // wrap before gap.send
920/// const { plaintext, senderLeaf } = session.decrypt(ct, new Uint8Array());
921/// ```
922#[wasm_bindgen]
923pub struct SFrameSession {
924    inner: RefCell<RustSFrameDecryptor>,
925    /// Caches the encryptor handle per `leafIndex` so repeated
926    /// `createEncryptor` calls (each of which re-derives a fresh
927    /// [`RustSFrameSession`] from MLS) reconnect to the same counter instead
928    /// of resetting it - re-deriving the session alone would otherwise hand
929    /// out a fresh, independent `MonotonicCounter` for the same KID on every
930    /// call, reusing `(key, KID, CTR)` and breaking AEAD confidentiality.
931    encryptors: RefCell<HashMap<u32, RustSFrameEncryptor>>,
932}
933
934#[wasm_bindgen]
935impl SFrameSession {
936    /// Derives an SFrame session from the current MLS group state via
937    /// `MLS.ExportSecret(label, epoch, 32)`. `suite` is a [`CipherSuite`] value
938    /// (0 = AES-128-GCM, 1 = AES-256-GCM). `label` must match across the group.
939    #[wasm_bindgen(js_name = "create")]
940    pub fn create(mls: &MlsContext, label: &str, suite: u8) -> Result<SFrameSession, JsValue> {
941        let suite = cipher_suite_from(suite)?;
942        let m = mls.inner.borrow();
943        let session = RustSFrameSession::from_mls(&m, label, suite).map_err(js_err)?;
944        Ok(SFrameSession {
945            inner: RefCell::new(session.decryptor()),
946            encryptors: RefCell::new(HashMap::new()),
947        })
948    }
949
950    /// Creates (or reconnects to) a sender-side encryptor for `leafIndex` in
951    /// this epoch. Calling this more than once for the same `leafIndex`
952    /// always returns a handle sharing the *same* counter - it never resets
953    /// it, so it is always safe against AEAD nonce reuse.
954    #[wasm_bindgen(js_name = "createEncryptor")]
955    pub fn create_encryptor(
956        &self,
957        mls: &MlsContext,
958        leaf_index: u32,
959        label: &str,
960        suite: u8,
961    ) -> Result<SFrameEncryptor, JsValue> {
962        if let Some(existing) = self.encryptors.borrow().get(&leaf_index) {
963            return Ok(SFrameEncryptor {
964                inner: RefCell::new(existing.clone()),
965            });
966        }
967
968        let suite = cipher_suite_from(suite)?;
969        let m = mls.inner.borrow();
970        let session = RustSFrameSession::from_mls(&m, label, suite).map_err(js_err)?;
971        let encryptor = session.encryptor(leaf_index);
972        self.encryptors
973            .borrow_mut()
974            .insert(leaf_index, encryptor.clone());
975        Ok(SFrameEncryptor {
976            inner: RefCell::new(encryptor),
977        })
978    }
979
980    /// Decrypts an SFrame payload, returning
981    /// `{ plaintext: Uint8Array, senderLeaf: number }` or throwing on failure.
982    /// `aad` must equal the sender's `extra_aad` (pass an empty array if none).
983    #[wasm_bindgen(js_name = "decrypt")]
984    pub fn decrypt(&self, payload: &[u8], aad: &[u8]) -> Result<JsValue, JsValue> {
985        let mut dec = self.inner.borrow_mut();
986        match dec.decrypt(payload, aad) {
987            Ok((plaintext, leaf)) => {
988                let obj = Object::new();
989                set(&obj, "plaintext", &u8s(&plaintext));
990                set(&obj, "senderLeaf", &JsValue::from_f64(leaf as f64));
991                Ok(obj.into())
992            }
993            Err(e) => Err(js_err(e)),
994        }
995    }
996}
997
998/// Sender-side SFrame encryptor (one per sender per epoch). Created via
999/// [`SFrameSession::createEncryptor`]; maintains an internal frame counter.
1000#[wasm_bindgen]
1001pub struct SFrameEncryptor {
1002    inner: RefCell<RustSFrameEncryptor>,
1003}
1004
1005#[wasm_bindgen]
1006impl SFrameEncryptor {
1007    /// Encrypts one frame, returning `sframe_header ‖ ciphertext ‖ tag`.
1008    /// `aad` is additional authenticated data (e.g. an RTP header); pass an
1009    /// empty array if none.
1010    #[wasm_bindgen(js_name = "encrypt")]
1011    pub fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Uint8Array, JsValue> {
1012        let mut enc = self.inner.borrow_mut();
1013        let ct = enc.encrypt(plaintext, aad).map_err(js_err)?;
1014        Ok(Uint8Array::from(ct.as_slice()))
1015    }
1016}
1017
1018// ─── Tests ──────────────────────────────────────────────────────────────────
1019
1020#[cfg(test)]
1021mod tests;