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//! The async init function is generated automatically by wasm-pack.
5
6#![cfg(target_arch = "wasm32")]
7
8use gbp_core::{MemberId, PayloadCodec};
9use gbp_node::{Event, GroupNode as RustGroupNode};
10use gtp::{GtpAccept, GtpClient as RustGtpClient};
11use js_sys::{Array, Object, Reflect, Uint8Array};
12use openmls::prelude::tls_codec::Serialize as TlsSerialize;
13use openmls::prelude::{KeyPackageIn, OpenMlsProvider, ProtocolVersion};
14use tls_codec::Deserialize as TlsDeserialize;
15use std::cell::RefCell;
16use wasm_bindgen::prelude::*;
17
18// ─── helpers ────────────────────────────────────────────────────────────────
19
20fn set(obj: &Object, key: &str, val: &JsValue) {
21    Reflect::set(obj, &JsValue::from_str(key), val).unwrap_throw();
22}
23
24fn u8s(bytes: &[u8]) -> JsValue {
25    Uint8Array::from(bytes).into()
26}
27
28fn js_err(msg: impl std::fmt::Display) -> JsValue {
29    JsValue::from_str(&msg.to_string())
30}
31
32fn event_to_js(ev: Event) -> JsValue {
33    let obj = Object::new();
34    match ev {
35        Event::PayloadReceived(p) => {
36            set(&obj, "kind", &"payload_received".into());
37            set(&obj, "streamType", &JsValue::from_f64(p.stream_type.as_u8() as f64));
38            set(&obj, "plaintext", &u8s(&p.plaintext));
39            set(&obj, "sequenceNo", &JsValue::from_f64(p.sequence_no as f64));
40            set(&obj, "codec", &JsValue::from_f64(p.codec as u8 as f64));
41        }
42        Event::StateChanged { from, to } => {
43            set(&obj, "kind", &"state_changed".into());
44            set(&obj, "from", &JsValue::from_str(&from.to_string()));
45            set(&obj, "to", &JsValue::from_str(&to.to_string()));
46        }
47        Event::EpochAdvanced { epoch, transition_id } => {
48            set(&obj, "kind", &"epoch_advanced".into());
49            set(&obj, "epoch", &js_sys::BigInt::from(epoch).into());
50            set(&obj, "transitionId", &JsValue::from_f64(transition_id as f64));
51        }
52        Event::Error { code, reason, fatal, retryable, .. } => {
53            set(&obj, "kind", &"error".into());
54            set(&obj, "code", &JsValue::from_f64(code as f64));
55            set(&obj, "reason", &JsValue::from_str(&reason));
56            set(&obj, "fatal", &JsValue::from_bool(fatal));
57            set(&obj, "retryable", &JsValue::from_bool(retryable));
58        }
59        Event::Control { from, opcode, transition_id, .. } => {
60            set(&obj, "kind", &"control".into());
61            set(&obj, "from", &JsValue::from_f64(from as f64));
62            set(&obj, "opcode", &JsValue::from_f64(opcode as u8 as f64));
63            set(&obj, "transitionId", &JsValue::from_f64(transition_id as f64));
64        }
65        _ => {
66            set(&obj, "kind", &"other".into());
67        }
68    }
69    obj.into()
70}
71
72// ─── MlsContext ──────────────────────────────────────────────────────────────
73
74/// MLS group state for one member.
75///
76/// JS usage:
77/// ```js
78/// const alice = MlsContext.create("alice");
79/// const bob   = MlsContext.create("bob");
80/// const welcome = alice.invite(bob.keyPackage);
81/// bob.acceptWelcome(welcome);
82/// // alice.epoch === bob.epoch === 1n
83/// ```
84#[wasm_bindgen]
85pub struct MlsContext {
86    inner: RefCell<gbp_mls::MlsContext>,
87    kp_bytes: Vec<u8>,
88}
89
90#[wasm_bindgen]
91impl MlsContext {
92    /// Creates a new member identity.
93    ///
94    /// The returned object holds a pre-generated key package that another
95    /// member can pass to [`invite`] to add this member to a group.
96    #[wasm_bindgen(js_name = "create")]
97    pub fn create(user_id: &str) -> Result<MlsContext, JsValue> {
98        let (ctx, kpb) = gbp_mls::MlsContext::new_member(user_id.as_bytes())
99            .map_err(|e| js_err(e))?;
100        let kp_bytes = kpb.key_package()
101            .tls_serialize_detached()
102            .map_err(|e| js_err(format!("kp serialize: {e:?}")))?;
103        Ok(MlsContext { inner: RefCell::new(ctx), kp_bytes })
104    }
105
106    /// TLS-serialised key package for this member (pass to the inviter's
107    /// [`invite`]).
108    #[wasm_bindgen(getter, js_name = "keyPackage")]
109    pub fn key_package(&self) -> Uint8Array {
110        Uint8Array::from(self.kp_bytes.as_slice())
111    }
112
113    /// Current MLS group epoch.
114    #[wasm_bindgen(getter)]
115    pub fn epoch(&self) -> u64 {
116        self.inner.borrow().epoch()
117    }
118
119    /// 16-byte group identifier (all zeros before the first invite).
120    #[wasm_bindgen(getter, js_name = "groupId")]
121    pub fn group_id(&self) -> Uint8Array {
122        Uint8Array::from(self.inner.borrow().group_id_16().as_slice())
123    }
124
125    /// Invites another member into this group.
126    ///
127    /// `keyPackageBytes` is the raw TLS bytes from the joiner's
128    /// [`keyPackage`] getter. Returns the Welcome bytes the joiner must pass
129    /// to [`acceptWelcome`]. This call merges the commit immediately and
130    /// advances this member's epoch.
131    #[wasm_bindgen(js_name = "invite")]
132    pub fn invite(&self, key_package_bytes: &[u8]) -> Result<Uint8Array, JsValue> {
133        let mut ctx = self.inner.borrow_mut();
134        let kp_in = KeyPackageIn::tls_deserialize(&mut key_package_bytes.as_ref())
135            .map_err(|e| js_err(format!("kp parse: {e:?}")))?;
136        let kp = kp_in
137            .validate(ctx.provider.crypto(), ProtocolVersion::Mls10)
138            .map_err(|e| js_err(format!("kp validate: {e:?}")))?;
139        let welcome = ctx.invite(&[kp]).map_err(|e| js_err(e))?;
140        Ok(Uint8Array::from(welcome.as_slice()))
141    }
142
143    /// Joins a group from a Welcome message produced by [`invite`].
144    ///
145    /// After this call [`epoch`] will match the inviter's epoch and
146    /// [`groupId`] will match the inviter's group id.
147    #[wasm_bindgen(js_name = "acceptWelcome")]
148    pub fn accept_welcome(&self, welcome_bytes: &[u8]) -> Result<(), JsValue> {
149        self.inner.borrow_mut()
150            .accept_welcome(welcome_bytes)
151            .map_err(|e| js_err(e))
152    }
153}
154
155// ─── GroupNode ───────────────────────────────────────────────────────────────
156
157/// GBP group node — framing, AEAD, replay window, control plane.
158///
159/// JS usage:
160/// ```js
161/// const node = GroupNode.create(1, groupId);
162/// node.bootstrapAsCreator(mls.epoch);
163/// const events = node.onWire(mls, wireBytes);
164/// ```
165#[wasm_bindgen]
166pub struct GroupNode {
167    inner: RefCell<RustGroupNode>,
168}
169
170#[wasm_bindgen]
171impl GroupNode {
172    /// Creates a node for `leafIndex` (member id) and the given 16-byte group id.
173    #[wasm_bindgen(js_name = "create")]
174    pub fn create(leaf_index: u32, group_id_bytes: &[u8]) -> GroupNode {
175        let gid: [u8; 16] = group_id_bytes.try_into().unwrap_or([0u8; 16]);
176        GroupNode { inner: RefCell::new(RustGroupNode::new(leaf_index as MemberId, gid)) }
177    }
178
179    /// Drives the node to `ACTIVE` as the group creator at the given epoch.
180    #[wasm_bindgen(js_name = "bootstrapAsCreator")]
181    pub fn bootstrap_as_creator(&self, epoch: u64) {
182        self.inner.borrow_mut().bootstrap_as_creator(epoch);
183    }
184
185    /// Drives the node to `ACTIVE` as a joiner.
186    ///
187    /// Pass `expectedFirstTid = 0` unless you know the in-flight
188    /// `transition_id` the coordinator will send in `EXECUTE_TRANSITION`.
189    #[wasm_bindgen(js_name = "bootstrapAsJoiner")]
190    pub fn bootstrap_as_joiner(&self, epoch: u64, expected_first_tid: u32) {
191        self.inner.borrow_mut().bootstrap_as_joiner(epoch, expected_first_tid);
192    }
193
194    /// Delivers a wire frame and returns the resulting events array.
195    ///
196    /// Each element is a plain JS object with at minimum `{ kind: string }`.
197    ///
198    /// | `kind` | Extra fields |
199    /// |--------|-------------|
200    /// | `"payload_received"` | `streamType`, `plaintext`, `sequenceNo`, `codec` |
201    /// | `"state_changed"` | `from`, `to` |
202    /// | `"epoch_advanced"` | `epoch` (bigint), `transitionId` |
203    /// | `"error"` | `code`, `reason`, `fatal`, `retryable` |
204    /// | `"control"` | `from`, `opcode`, `transitionId` |
205    #[wasm_bindgen(js_name = "onWire")]
206    pub fn on_wire(&self, mls: &MlsContext, wire_bytes: &[u8]) -> Array {
207        let mut node = self.inner.borrow_mut();
208        let mut mls_inner = mls.inner.borrow_mut();
209        let events = node.on_wire(&mut *mls_inner, wire_bytes).unwrap_or_default();
210        let arr = Array::new();
211        for ev in events {
212            arr.push(&event_to_js(ev));
213        }
214        arr
215    }
216
217    /// Polls pending timeout events — call ~every 500 ms from the app loop.
218    #[wasm_bindgen(js_name = "checkTimeouts")]
219    pub fn check_timeouts(&self) -> Array {
220        let arr = Array::new();
221        for ev in self.inner.borrow_mut().check_timeouts() {
222            arr.push(&event_to_js(ev));
223        }
224        arr
225    }
226
227    /// The `transition_id` of the last applied epoch transition.
228    #[wasm_bindgen(getter, js_name = "lastTransitionId")]
229    pub fn last_transition_id(&self) -> u32 {
230        self.inner.borrow().last_transition_id
231    }
232
233    /// Current epoch as seen by the GBP layer.
234    #[wasm_bindgen(getter, js_name = "currentEpoch")]
235    pub fn current_epoch(&self) -> u64 {
236        self.inner.borrow().current_epoch
237    }
238
239    /// This node's member id (leaf index).
240    #[wasm_bindgen(getter, js_name = "memberId")]
241    pub fn member_id(&self) -> u32 {
242        self.inner.borrow().member_id
243    }
244}
245
246// ─── GtpClient ───────────────────────────────────────────────────────────────
247
248/// Group Text Protocol client — idempotent text delivery over GBP.
249///
250/// JS usage:
251/// ```js
252/// const gtp = GtpClient.create();
253/// const frame = gtp.send(node, mls, 0, 1n, "hello");
254/// // frame.wire: Uint8Array — hand to transport
255///
256/// // on receive:
257/// const result = gtp.accept(ev.plaintext, mls.epoch);
258/// // result.text / result.messageId / result.senderId
259/// ```
260#[wasm_bindgen]
261pub struct GtpClient {
262    inner: RefCell<RustGtpClient>,
263}
264
265#[wasm_bindgen]
266impl GtpClient {
267    /// Creates an empty GTP client.
268    #[wasm_bindgen(js_name = "create")]
269    pub fn create() -> GtpClient {
270        GtpClient { inner: RefCell::new(RustGtpClient::new()) }
271    }
272
273    /// Sends a text message.
274    ///
275    /// Returns `{ wire: Uint8Array, to: number }` or `null` on error.
276    /// Pass `target = 0` to broadcast to all members.
277    #[wasm_bindgen(js_name = "send")]
278    pub fn send(
279        &self,
280        node: &GroupNode,
281        mls: &MlsContext,
282        target: u32,
283        message_id: u64,
284        text: &str,
285    ) -> JsValue {
286        let mut gtp = self.inner.borrow_mut();
287        let mut n = node.inner.borrow_mut();
288        let mut m = mls.inner.borrow_mut();
289        match gtp.send(&mut *n, &mut *m, target as MemberId, message_id, text, PayloadCodec::Cbor) {
290            Ok(frame) => {
291                let obj = Object::new();
292                set(&obj, "wire", &u8s(&frame.wire));
293                set(&obj, "to", &JsValue::from_f64(frame.to as f64));
294                obj.into()
295            }
296            Err(_) => JsValue::NULL,
297        }
298    }
299
300    /// Accepts a plaintext GTP payload delivered from a `payload_received` event.
301    ///
302    /// Returns `{ text: string, messageId: bigint, senderId: number }` or
303    /// `null` if the payload is malformed.
304    /// The `status` field is `"new"` or `"duplicate"` based on idempotency.
305    #[wasm_bindgen(js_name = "accept")]
306    pub fn accept(&self, plaintext: &[u8], epoch: u64) -> JsValue {
307        let mut gtp = self.inner.borrow_mut();
308        match gtp.accept(plaintext, epoch, PayloadCodec::Cbor) {
309            Ok(result) => {
310                let (msg, status) = match result {
311                    GtpAccept::New(m) => (m, "new"),
312                    GtpAccept::Duplicate(m) => (m, "duplicate"),
313                };
314                let text = String::from_utf8_lossy(&msg.content).into_owned();
315                let obj = Object::new();
316                set(&obj, "text", &JsValue::from_str(&text));
317                set(&obj, "messageId", &js_sys::BigInt::from(msg.message_id).into());
318                set(&obj, "senderId", &JsValue::from_f64(msg.sender_id as f64));
319                set(&obj, "status", &JsValue::from_str(status));
320                obj.into()
321            }
322            Err(_) => JsValue::NULL,
323        }
324    }
325
326    /// Resets the idempotency set unconditionally.
327    #[wasm_bindgen(js_name = "reset")]
328    pub fn reset(&self) {
329        self.inner.borrow_mut().reset();
330    }
331}
332
333// ─── Tests ──────────────────────────────────────────────────────────────────
334
335#[cfg(test)]
336mod tests;