Skip to main content

openrtc/
lib.rs

1#![allow(deprecated)]
2
3pub mod application_crypto;
4pub mod application_crypto_streams;
5pub mod client;
6pub mod connection;
7pub mod coordination;
8pub mod explicit_transfer_crypto;
9pub(crate) mod generated;
10pub mod heartbeat;
11pub(crate) mod iroh_connection_policy;
12pub mod key_agreement;
13pub mod lifecycle_reason;
14pub(crate) mod native_moq_policy;
15pub mod native_protocol;
16#[cfg(not(target_arch = "wasm32"))]
17pub(crate) mod native_send_policy;
18pub(crate) mod native_webrtc_policy;
19pub mod presence;
20pub(crate) mod presence_policy;
21pub mod route_policy;
22pub mod runtime_policy;
23pub mod session_token;
24pub mod signaling;
25pub mod stream_metadata;
26pub(crate) mod transport_generation;
27pub(crate) mod transport_label;
28
29#[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
30pub mod local_discovery;
31
32#[cfg(all(target_arch = "wasm32", feature = "transport-webrtc"))]
33compile_error!(
34    "feature `transport-webrtc` is native-only and must not be enabled for wasm32 targets"
35);
36
37#[cfg(all(target_arch = "wasm32", feature = "transport-moq"))]
38compile_error!("feature `transport-moq` is native-only and must not be enabled for wasm32 targets");
39
40/// Test constants — use these instead of hardcoding project IDs in tests.
41/// Unit tests that don't hit real Firestore should use TEST_PROJECT_ID.
42/// Live/integration tests must use LIVE_PROJECT_ID ("pluto-rtc-prod").
43#[cfg(test)]
44pub mod test_constants {
45    pub const TEST_PROJECT_ID: &str = "test-project";
46    pub const TEST_API_KEY: &str = "pk_test_0000000000000000000000000000000000000000";
47}
48
49/// Re-export for downstream crates' tests.
50pub const LIVE_PROJECT_ID: &str = "pluto-rtc-prod";
51
52/// Validate the only public platform credential accepted by an OpenRTC 2.0
53/// client constructor. The API key identifies the developer application; it is
54/// not a secret and does not authorize a live avenue by itself.
55pub fn validate_v2_public_api_key(api_key: &str) -> anyhow::Result<&str> {
56    let trimmed = api_key.trim();
57    let valid_prefix = trimmed.starts_with("pk_live_") || trimmed.starts_with("pk_test_");
58    let suffix = trimmed.get(8..).unwrap_or_default();
59    if !valid_prefix || suffix.len() != 40 || !suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
60        anyhow::bail!("OpenRTC 2.0 requires a public pk_live_ or pk_test_ API key");
61    }
62    Ok(trimmed)
63}
64
65pub fn app_tag_from_api_key(api_key: &str) -> String {
66    let trimmed = api_key.trim();
67    if trimmed.is_empty() {
68        return "app_anonymous".to_string();
69    }
70
71    let suffix_len = trimmed.len().min(16);
72    format!("app_{}", &trimmed[trimmed.len() - suffix_len..])
73}
74
75pub fn space_app_tag_from_keys(api_key: &str, space_key: &str) -> String {
76    let input = format!("{}:{}", api_key.trim(), space_key.trim());
77    let digest = <sha2::Sha256 as sha2::Digest>::digest(input.as_bytes());
78    format!("space::{}", hex::encode(digest))
79}
80
81#[cfg(test)]
82mod v2_constructor_contract_tests {
83    use super::*;
84
85    #[test]
86    fn rust_v2_constructor_is_provider_neutral_and_side_effect_free() {
87        let api_key = test_constants::TEST_API_KEY;
88        let client = client::Client::new_v2(api_key.to_string()).expect("valid public API key");
89
90        assert_eq!(client.app_tag(), app_tag_from_api_key(api_key));
91        assert!(client::Client::new_v2("firebase-project-id".to_string()).is_err());
92    }
93}
94
95#[cfg(not(target_arch = "wasm32"))]
96pub fn ensure_default_rustls_provider() {
97    if rustls::crypto::CryptoProvider::get_default().is_none() {
98        let _ = rustls::crypto::ring::default_provider().install_default();
99    }
100}
101
102#[cfg(not(target_arch = "wasm32"))]
103pub mod adapters;
104
105#[cfg(not(target_arch = "wasm32"))]
106pub mod native_coordination_gateway;
107
108#[cfg(not(target_arch = "wasm32"))]
109pub mod native_v2;
110
111pub mod connection_manager;
112pub mod logging;
113
114#[cfg(not(target_arch = "wasm32"))]
115pub mod protocol_registry;
116
117#[cfg(not(target_arch = "wasm32"))]
118pub mod runtime_manager;
119
120#[cfg(not(target_arch = "wasm32"))]
121pub mod transport;
122
123#[cfg(not(target_arch = "wasm32"))]
124pub use client::EndpointHandle;
125
126#[cfg(all(
127    not(target_arch = "wasm32"),
128    not(any(target_os = "ios", target_os = "android"))
129))]
130pub mod sso;
131
132#[cfg(not(target_arch = "wasm32"))]
133pub mod native_node;
134
135#[cfg(not(target_arch = "wasm32"))]
136pub mod native_device;
137
138#[cfg(not(target_arch = "wasm32"))]
139pub mod native_auth;
140
141#[cfg(target_arch = "wasm32")]
142pub mod wasm_node;
143
144#[cfg(target_arch = "wasm32")]
145#[macro_export]
146macro_rules! console_log {
147    ($($t:tt)*) => (web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format_args!($($t)*).to_string())))
148}
149
150// WASM entry point bindings
151#[cfg(target_arch = "wasm32")]
152pub mod wasm_api {
153    use crate::client::Client;
154    use crate::session_token::split_compound_ticket;
155    use crate::wasm_node::{
156        into_js_readable_stream, peer_uni_stream_from_send, BiStream, PeerUniStream,
157    };
158    use iroh_tickets::endpoint::EndpointTicket;
159    use std::str::FromStr;
160    use std::sync::{Arc, Mutex};
161    use wasm_bindgen::prelude::*;
162    use wasm_streams::readable::sys::ReadableStream as JsReadableStream;
163
164    #[wasm_bindgen]
165    pub struct WasmClient {
166        inner: Arc<Client>,
167        identity_credential: Arc<Mutex<Option<String>>>,
168        last_auth_log: Arc<Mutex<Option<(bool, usize)>>>,
169    }
170
171    #[wasm_bindgen]
172    impl WasmClient {
173        #[cfg(feature = "legacy-v1")]
174        #[deprecated(
175            since = "2.0.0",
176            note = "rollback-only: use WasmClient.newV2(apiKey) through the openrtc/runtime adapter"
177        )]
178        #[wasm_bindgen(constructor)]
179        pub fn new(project_id: String, tag: String) -> Result<WasmClient, JsValue> {
180            Self::new_with_app_tag(project_id, tag)
181        }
182
183        #[cfg(feature = "legacy-v1")]
184        #[deprecated(
185            since = "2.0.0",
186            note = "rollback-only: use WasmClient.newV2(apiKey) through the openrtc/runtime adapter"
187        )]
188        #[wasm_bindgen(js_name = newWithAppTag)]
189        pub fn new_with_app_tag(
190            project_id: String,
191            app_tag: String,
192        ) -> Result<WasmClient, JsValue> {
193            let identity_credential: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
194            let token_state = identity_credential.clone();
195            let token_provider = Box::new(move || -> Option<String> {
196                token_state.lock().ok().and_then(|guard| guard.clone())
197            });
198
199            Ok(Self {
200                inner: Arc::new(Client::new_with_app_tag(
201                    project_id,
202                    app_tag,
203                    token_provider,
204                )),
205                identity_credential,
206                last_auth_log: Arc::new(Mutex::new(None)),
207            })
208        }
209
210        /// OpenRTC 2.0 WASM transport constructor. It accepts only the public
211        /// API key, derives the app identity locally, and never initializes a
212        /// Firebase project or performs network work.
213        #[wasm_bindgen(js_name = newV2)]
214        pub fn new_v2(api_key: String) -> Result<WasmClient, JsValue> {
215            let api_key = crate::validate_v2_public_api_key(&api_key)
216                .map_err(|error| JsValue::from_str(&error.to_string()))?;
217            let app_tag = crate::app_tag_from_api_key(api_key);
218            let identity_credential: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
219            let credential_state = identity_credential.clone();
220            let identity_credential_provider = Box::new(move || -> Option<String> {
221                credential_state.lock().ok().and_then(|guard| guard.clone())
222            });
223
224            Ok(Self {
225                inner: Arc::new(Client::new_provider_neutral(
226                    app_tag,
227                    identity_credential_provider,
228                )),
229                identity_credential,
230                last_auth_log: Arc::new(Mutex::new(None)),
231            })
232        }
233
234        #[wasm_bindgen(js_name = setIdentityCredential)]
235        pub fn set_identity_credential(&self, credential: Option<String>) {
236            let has_credential = credential
237                .as_ref()
238                .map(|value| !value.is_empty())
239                .unwrap_or(false);
240            let credential_len = credential.as_ref().map(|value| value.len()).unwrap_or(0);
241
242            if let Ok(mut guard) = self.identity_credential.lock() {
243                *guard = credential.filter(|value| !value.is_empty());
244            }
245
246            let should_log = if let Ok(mut guard) = self.last_auth_log.lock() {
247                let next = (has_credential, credential_len);
248                if guard.as_ref() == Some(&next) {
249                    false
250                } else {
251                    *guard = Some(next);
252                    true
253                }
254            } else {
255                true
256            };
257
258            if should_log {
259                web_sys::console::log_1(&JsValue::from_str(&format!(
260                    "[OPENRTC][WASM-IDENTITY] credential updated present={} len={}",
261                    has_credential, credential_len
262                )));
263            }
264        }
265
266        /// Clear the provider-owned identity assertion without routing an
267        /// optional string through the wasm-bindgen ABI. Some generated
268        /// bindings retain the previous string length when `null` is passed
269        /// for `Option<String>`, which can turn a legitimate logout into a
270        /// null-pointer trap before Rust receives the call.
271        #[wasm_bindgen(js_name = clearIdentityCredential)]
272        pub fn clear_identity_credential(&self) {
273            self.set_identity_credential(None);
274        }
275
276        /// Rollback-only v1 alias. New SDK code calls setIdentityCredential.
277        #[cfg(feature = "legacy-v1")]
278        #[deprecated(since = "2.0.0", note = "rollback-only: use setIdentityCredential")]
279        pub fn set_auth_token(&self, token: Option<String>) {
280            self.set_identity_credential(token);
281        }
282
283        /// Normalize and rank eligible route labels using the Rust-owned pure
284        /// policy. This does not dial, retry, promote, demote, or mutate state.
285        #[wasm_bindgen(js_name = rankRoutes)]
286        pub fn rank_routes(
287            &self,
288            configured_priority: Vec<String>,
289            candidates: Vec<String>,
290        ) -> Vec<String> {
291            crate::route_policy::rank_routes(&configured_priority, &candidates)
292        }
293
294        pub async fn init_iroh(&self, secret_key: Option<Vec<u8>>) -> Result<String, JsValue> {
295            let started_at = js_sys::Date::now();
296            web_sys::console::log_1(&JsValue::from_str(&format!(
297                "[OPENRTC][WASM-API] init_iroh called has_secret_key={} secret_key_len={}",
298                secret_key.as_ref().is_some(),
299                secret_key.as_ref().map(|k| k.len()).unwrap_or(0)
300            )));
301            match self.inner.init_iroh(secret_key, vec![]).await {
302                Ok(node_id) => {
303                    self.inner.clone().start_wasm_accept_bridge();
304                    let elapsed = js_sys::Date::now() - started_at;
305                    web_sys::console::log_1(&JsValue::from_str(&format!(
306                        "[OPENRTC][WASM-API] init_iroh success elapsed_ms={:.0} node_id={}",
307                        elapsed, node_id
308                    )));
309                    Ok(node_id)
310                }
311                Err(err) => {
312                    let elapsed = js_sys::Date::now() - started_at;
313                    web_sys::console::error_1(&JsValue::from_str(&format!(
314                        "[OPENRTC][WASM-API] init_iroh failed elapsed_ms={:.0} error={}",
315                        elapsed, err
316                    )));
317                    Err(JsValue::from_str(&err.to_string()))
318                }
319            }
320        }
321
322        /// Local-harness-only endpoint initialization. The Rust runtime rejects
323        /// every non-loopback or non-HTTPS relay URL before binding.
324        #[wasm_bindgen(js_name = initIrohWithTestRelay)]
325        pub async fn init_iroh_with_test_relay(
326            &self,
327            secret_key: Option<Vec<u8>>,
328            test_relay_url: Option<String>,
329        ) -> Result<String, JsValue> {
330            let node_id = self
331                .inner
332                .init_iroh_with_test_relay(secret_key, vec![], test_relay_url.as_deref())
333                .await
334                .map_err(|error| JsValue::from_str(&error.to_string()))?;
335            // The local relay changes only relay selection. It must retain the
336            // normal browser connection lifecycle, including the accepted
337            // transport bridge that promotes an inbound replacement generation.
338            self.inner.clone().start_wasm_accept_bridge();
339            Ok(node_id)
340        }
341
342        pub async fn iroh_secret_key(&self) -> Result<Vec<u8>, JsValue> {
343            let node_guard = self.inner.iroh_node.read().await;
344            if let Some(node) = node_guard.as_ref() {
345                Ok(node.secret_key())
346            } else {
347                Err(JsValue::from_str("Iroh node not initialized"))
348            }
349        }
350
351        pub async fn node_addr(&self) -> Result<String, JsValue> {
352            let node_guard = self.inner.iroh_node.read().await;
353            if let Some(node) = node_guard.as_ref() {
354                let addr = node
355                    .node_addr()
356                    .await
357                    .map_err(|e| JsValue::from_str(&e.to_string()))?;
358                serde_json::to_string(&addr).map_err(|e| JsValue::from_str(&e.to_string()))
359            } else {
360                Err(JsValue::from_str("Iroh node not initialized"))
361            }
362        }
363
364        pub async fn endpoint_ticket(&self) -> Result<String, JsValue> {
365            self.inner
366                .endpoint_ticket()
367                .await
368                .map_err(|e| JsValue::from_str(&e.to_string()))
369        }
370
371        /// Build a compound ticket with an embedded session token.
372        /// Registers the token on the canonical Rust client and returns the compound ticket string.
373        /// `scope`: logical label (e.g. "share"). `max_connections`: 0 = unlimited.
374        pub async fn endpoint_ticket_with_token(
375            &self,
376            grant_scope: String,
377            max_connections: u32,
378        ) -> Result<String, JsValue> {
379            self.inner
380                .endpoint_ticket_with_token(&grant_scope, max_connections)
381                .await
382                .map_err(|e| JsValue::from_str(&e.to_string()))
383        }
384
385        /// Register a session token on the canonical Rust client.
386        pub fn register_session_token(
387            &self,
388            token: String,
389            grant_scope: String,
390            max_connections: u32,
391        ) {
392            self.inner
393                .register_session_token(token, grant_scope, max_connections);
394        }
395
396        /// Register a session token with an absolute Unix-millisecond expiry.
397        pub fn register_session_token_with_expiry_ms(
398            &self,
399            token: String,
400            grant_scope: String,
401            max_connections: u32,
402            expires_at_ms: u64,
403        ) {
404            self.inner.register_session_token_with_expiry_ms(
405                token,
406                grant_scope,
407                max_connections,
408                expires_at_ms,
409            );
410        }
411
412        /// Mark a connection as requiring application crypto on native outbound paths.
413        #[wasm_bindgen(js_name = setConnectionApplicationCryptoRequired)]
414        pub fn set_connection_application_crypto_required(
415            &self,
416            connection_id: String,
417        ) -> Result<(), JsValue> {
418            self.inner
419                .set_connection_application_crypto_required(&connection_id);
420            Ok(())
421        }
422
423        /// Install a negotiated per-connection application crypto key for native send paths.
424        #[wasm_bindgen(js_name = setConnectionApplicationCryptoKey)]
425        pub async fn set_connection_application_crypto_key(
426            &self,
427            connection_id: String,
428            key: Vec<u8>,
429        ) -> Result<(), JsValue> {
430            if key.len() != crate::application_crypto::APPLICATION_KEY_BYTES {
431                return Err(JsValue::from_str("application crypto key must be 32 bytes"));
432            }
433            let mut key_bytes = [0u8; crate::application_crypto::APPLICATION_KEY_BYTES];
434            key_bytes.copy_from_slice(&key);
435            self.inner
436                .set_connection_application_crypto_key(&connection_id, key_bytes);
437            self.inner
438                .emit_current_wasm_connection_state(&connection_id)
439                .await;
440            Ok(())
441        }
442
443        /// Retire the negotiated application key and ephemeral agreement for a
444        /// logical connection. Connection ids may be reused after an ACL revoke
445        /// and regrant, so lifecycle cleanup must clear the WASM runtime together
446        /// with the TypeScript crypto indexes before a fresh handshake begins.
447        #[wasm_bindgen(js_name = clearConnectionApplicationCryptoKey)]
448        pub fn clear_connection_application_crypto_key(&self, connection_id: String) {
449            self.inner
450                .clear_connection_application_crypto_key(&connection_id);
451        }
452
453        /// Validate (and consume one use of) a session token.
454        /// Returns the scope string on success, throws on failure.
455        /// If the registry is empty, always succeeds (backward-compat gate).
456        pub fn validate_session_token(&self, token: String) -> Result<String, JsValue> {
457            self.inner
458                .validate_session_token(&token)
459                .map_err(|e| JsValue::from_str(&e))
460        }
461
462        /// Validate and record token admission for a specific connection.
463        pub async fn validate_session_token_for_connection(
464            &self,
465            token: String,
466            connection_id: String,
467        ) -> Result<String, JsValue> {
468            self.inner
469                .validate_session_token_for_connection(&token, &connection_id)
470                .await
471                .map_err(|e| JsValue::from_str(&e))
472        }
473
474        pub async fn validate_session_token_for_connection_with_payload(
475            &self,
476            token: String,
477            connection_id: String,
478            token_payload: Option<String>,
479        ) -> Result<String, JsValue> {
480            self.inner
481                .validate_session_token_for_connection_with_payload(
482                    &token,
483                    &connection_id,
484                    token_payload.as_deref(),
485                )
486                .await
487                .map_err(|e| JsValue::from_str(&e))
488        }
489
490        /// Present a session token to the remote host over the SDK-owned
491        /// native main stream before application traffic starts.
492        /// Returns the approved scope string once the host acknowledges admission.
493        ///
494        /// Requires a managed transport record: call [`Self::connect_device`]
495        /// (or another dial that runs `ensure_connected_addr`) before presenting.
496        pub async fn present_session_token_to_host(
497            &self,
498            endpoint_id: String,
499            token: String,
500        ) -> Result<String, JsValue> {
501            self.present_session_token_to_host_with_payload(endpoint_id, token, None)
502                .await
503        }
504
505        pub async fn present_session_token_to_host_with_payload(
506            &self,
507            endpoint_id: String,
508            token: String,
509            token_payload: Option<String>,
510        ) -> Result<String, JsValue> {
511            self.present_session_token_to_host_with_payload_and_device_id(
512                endpoint_id,
513                token,
514                token_payload,
515                None,
516            )
517            .await
518        }
519
520        pub async fn present_session_token_to_host_with_payload_and_device_id(
521            &self,
522            endpoint_id: String,
523            token: String,
524            token_payload: Option<String>,
525            device_id: Option<String>,
526        ) -> Result<String, JsValue> {
527            crate::console_log!(
528                "[OpenRTC][session-admission][wasm-present] endpoint_id={} claimed_local_device_id={}",
529                endpoint_id,
530                device_id.as_deref().unwrap_or("<none>")
531            );
532            let endpoint_id_parsed: iroh::EndpointId = endpoint_id
533                .parse()
534                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
535            let local_node_id = self.inner.current_node_id().await.ok_or_else(|| {
536                JsValue::from_str("missing local node id for session-token presentation")
537            })?;
538            let connection_id =
539                crate::client::Client::deterministic_connection_id(&local_node_id, &endpoint_id);
540            let approval_scope = self
541                .inner
542                .present_and_accept_session_token_with_local_claim(
543                    endpoint_id_parsed,
544                    &connection_id,
545                    &token,
546                    token_payload.as_deref(),
547                    None,
548                    device_id,
549                )
550                .await
551                .map_err(|e| JsValue::from_str(&e))?;
552            self.inner
553                .emit_current_wasm_connection_state(&connection_id)
554                .await;
555
556            Ok(approval_scope)
557        }
558
559        /// Report whether this runtime has the current transport-generation
560        /// outbound admission proof required by an endpoint ticket.
561        pub async fn remote_session_admission_ready_for_ticket(
562            &self,
563            endpoint_ticket: String,
564        ) -> Result<bool, JsValue> {
565            self.inner
566                .remote_session_admission_ready_for_ticket(&endpoint_ticket)
567                .await
568                .map_err(|error| JsValue::from_str(&error.to_string()))
569        }
570
571        /// Fence one browser-relayed reciprocal presentation in the Rust
572        /// admission owner before the adapter writes it to the stream.
573        #[allow(clippy::too_many_arguments)]
574        pub async fn prepare_inline_reciprocal_session_admission(
575            &self,
576            endpoint_id: String,
577            expected_transport_stable_id: u64,
578            stream_instance_id: String,
579            presentation_id: String,
580            token: String,
581            token_payload: String,
582            device_id: String,
583            stream_contract: String,
584        ) -> Result<bool, JsValue> {
585            let endpoint_id: iroh::EndpointId = endpoint_id
586                .parse()
587                .map_err(|error| JsValue::from_str(&format!("{error}")))?;
588            let stream_contract = match stream_contract.trim() {
589                "one-shot-admission" => {
590                    crate::native_protocol::SessionTokenStreamContract::OneShotAdmission
591                }
592                "persistent-control" => {
593                    crate::native_protocol::SessionTokenStreamContract::PersistentControl
594                }
595                other => {
596                    return Err(JsValue::from_str(&format!(
597                        "unsupported reciprocal stream contract: {other}"
598                    )))
599                }
600            };
601            self.inner
602                .prepare_inline_reciprocal_session_admission(
603                    endpoint_id,
604                    expected_transport_stable_id,
605                    stream_instance_id.as_str(),
606                    presentation_id.as_str(),
607                    token.as_str(),
608                    token_payload.as_str(),
609                    device_id.as_str(),
610                    stream_contract,
611                )
612                .await
613                .map_err(|error| JsValue::from_str(&error))?;
614            Ok(true)
615        }
616
617        /// Commit an inline reciprocal session admission only when the ACK
618        /// belongs to the exact Rust-owned transcript and physical generation.
619        pub async fn confirm_inline_reciprocal_session_admission(
620            &self,
621            endpoint_id: String,
622            expected_transport_stable_id: u64,
623            stream_instance_id: String,
624            presentation_id: String,
625            accepted: bool,
626            approval_scope: Option<String>,
627        ) -> Result<bool, JsValue> {
628            let endpoint_id: iroh::EndpointId = endpoint_id
629                .parse()
630                .map_err(|error| JsValue::from_str(&format!("{error}")))?;
631            self.inner
632                .confirm_inline_reciprocal_session_admission(
633                    endpoint_id,
634                    expected_transport_stable_id,
635                    stream_instance_id.as_str(),
636                    presentation_id.as_str(),
637                    accepted,
638                    approval_scope.as_deref(),
639                )
640                .await
641                .map_err(|error| JsValue::from_str(&error))?;
642            self.inner
643                .emit_current_wasm_connection_state(
644                    &crate::client::Client::deterministic_connection_id(
645                        &self.inner.current_node_id().await.ok_or_else(|| {
646                            JsValue::from_str(
647                                "missing local node id after reciprocal admission ACK",
648                            )
649                        })?,
650                        &endpoint_id.to_string(),
651                    ),
652                )
653                .await;
654            Ok(true)
655        }
656
657        /// Revoke a single token by value.
658        pub fn revoke_session_token(&self, token: String) -> Result<JsValue, JsValue> {
659            serde_wasm_bindgen::to_value(&self.inner.revoke_session_token(&token))
660                .map_err(|error| JsValue::from_str(&error.to_string()))
661        }
662
663        /// Revoke all tokens that match the given scope and disconnect affected peers.
664        pub async fn revoke_tokens_by_scope(
665            &self,
666            grant_scope: String,
667        ) -> Result<JsValue, JsValue> {
668            let affected = self.inner.revoke_tokens_by_scope(&grant_scope).await;
669            serde_wasm_bindgen::to_value(&affected).map_err(|e| JsValue::from_str(&e.to_string()))
670        }
671
672        /// Clear all short-lived session tokens and admission state.
673        pub fn clear_session_tokens(&self) {
674            self.inner.clear_session_tokens();
675        }
676
677        pub fn endpoint_id_from_ticket(&self, ticket: String) -> Result<String, JsValue> {
678            let (iroh_ticket, _token_suffix) = split_compound_ticket(ticket.trim());
679            let parsed = EndpointTicket::from_str(iroh_ticket)
680                .map_err(|e| JsValue::from_str(&format!("Invalid endpoint ticket: {}", e)))?;
681            Ok(parsed.endpoint_addr().id.to_string())
682        }
683
684        /// Deprecated: product code must dial through [`Self::connect_device`], which
685        /// registers the connection and starts the wasm connect-event bridge.
686        /// This raw stream export remains for legacy harness callers only.
687        pub async fn connect(&self, ticket: String) -> Result<JsReadableStream, JsValue> {
688            web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(
689                "[OPENRTC][WASM-API] WasmClient.connect() is deprecated; use connect_device() for managed product dials.",
690            ));
691            let (iroh_ticket, _token_suffix) = split_compound_ticket(ticket.trim());
692            let parsed = EndpointTicket::from_str(iroh_ticket)
693                .map_err(|e| JsValue::from_str(&format!("Invalid endpoint ticket: {}", e)))?;
694            let endpoint_addr = parsed.endpoint_addr().clone();
695            let endpoint_id = endpoint_addr.id;
696            let stream = {
697                let node_guard = self.inner.iroh_node.read().await;
698                if let Some(node) = node_guard.as_ref() {
699                    node.connect_addr(endpoint_id, endpoint_addr)
700                } else {
701                    return Err(JsValue::from_str("Iroh node not initialized"));
702                }
703            };
704            Ok(into_js_readable_stream(stream))
705        }
706
707        pub async fn disconnect(&self, endpoint_id: String) -> Result<(), JsValue> {
708            let endpoint_id: iroh::EndpointId = endpoint_id
709                .parse()
710                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
711            let node_guard = self.inner.iroh_node.read().await;
712            if let Some(node) = node_guard.as_ref() {
713                node.disconnect(endpoint_id)
714                    .await
715                    .map_err(|e| JsValue::from_str(&e.to_string()))
716            } else {
717                Err(JsValue::from_str("Iroh node not initialized"))
718            }
719        }
720
721        /// Drop the iroh transport to a peer with a **transient** reason — a
722        /// simulated network flap, as opposed to [`disconnect`] which signals a
723        /// user/manual disconnect.
724        ///
725        /// `disconnect()` (and the generic close) reports `disconnected by user`,
726        /// which `lifecycle_reason` classifies as `ManualDisconnect` — terminal and
727        /// sticky: the remote will NOT auto-reconnect and WebRTC is retired
728        /// immediately. That is correct for a real user action, but wrong for a
729        /// transient transport drop. This variant uses a transient reason code
730        /// (`network-change-forced-reconnect`, `is_transient_reconnect()`), so both
731        /// peers treat the drop as a recoverable transition and auto-reconnect —
732        /// the browser equivalent of the native test harness's `irohDisconnect`.
733        pub async fn disconnect_transient(&self, endpoint_id: String) -> Result<(), JsValue> {
734            let endpoint_id: iroh::EndpointId = endpoint_id
735                .parse()
736                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
737            self.inner
738                .disconnect_with_reason(
739                    endpoint_id,
740                    crate::lifecycle_reason::REASON_NETWORK_CHANGE_RECONNECT,
741                )
742                .await
743                .map_err(|e| JsValue::from_str(&e.to_string()))?;
744            // `disconnect_with_reason` has only an `&Client`; wake from this
745            // WASM boundary, which owns the Arc and the single browser
746            // desired-peer actor. This is a recoverable edge change, not a new
747            // provider revision, so the actor would otherwise stay idle after
748            // a previously healthy peer is marked replacement-pending.
749            self.inner.wake_browser_auto_connect();
750            Ok(())
751        }
752
753        pub async fn is_connected(&self, endpoint_id: String) -> Result<bool, JsValue> {
754            let endpoint_id: iroh::EndpointId = endpoint_id
755                .parse()
756                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
757            Ok(self.inner.is_connected(endpoint_id).await)
758        }
759
760        pub fn runtime_policy(&self) -> Result<JsValue, JsValue> {
761            serde_wasm_bindgen::to_value(&self.inner.runtime_policy_snapshot())
762                .map_err(|e| JsValue::from_str(&e.to_string()))
763        }
764
765        pub async fn add_peer_scope(&self, id: String, scope: String) -> Result<JsValue, JsValue> {
766            let scopes = self.inner.add_peer_scope(&id, &scope).await;
767            serde_wasm_bindgen::to_value(&scopes).map_err(|e| JsValue::from_str(&e.to_string()))
768        }
769
770        pub async fn release_peer_scope(
771            &self,
772            id: String,
773            scope: Option<String>,
774        ) -> Result<JsValue, JsValue> {
775            let scopes = self.inner.release_peer_scope(&id, scope.as_deref()).await;
776            serde_wasm_bindgen::to_value(&scopes).map_err(|e| JsValue::from_str(&e.to_string()))
777        }
778
779        pub async fn peer_scopes(&self, id: String) -> Result<JsValue, JsValue> {
780            let scopes = self.inner.peer_scopes(&id).await;
781            serde_wasm_bindgen::to_value(&scopes).map_err(|e| JsValue::from_str(&e.to_string()))
782        }
783
784        pub async fn same_peer(&self, left: String, right: String) -> Result<bool, JsValue> {
785            Ok(self.inner.same_peer(&left, &right).await)
786        }
787
788        pub async fn peer_snapshot(&self, id: String) -> Result<JsValue, JsValue> {
789            let snapshot = self.inner.peer_snapshot(&id).await;
790            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
791        }
792
793        // U9: the `peer_snapshots()` wasm binding was retired; `peer_sessions()`
794        // (below) is the single settled Rust projection exposed to TS.
795
796        pub async fn peer_session(&self, id: String) -> Result<JsValue, JsValue> {
797            let snapshot = self.inner.peer_session(&id).await;
798            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
799        }
800
801        pub async fn peer_sessions(&self) -> Result<JsValue, JsValue> {
802            let snapshots = self.inner.peer_sessions().await;
803            serde_wasm_bindgen::to_value(&snapshots).map_err(|e| JsValue::from_str(&e.to_string()))
804        }
805
806        pub async fn connection_state(&self, connection_id: String) -> Result<JsValue, JsValue> {
807            let snapshot = self.inner.connection_state(&connection_id).await;
808            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
809        }
810
811        pub async fn connection_states(&self) -> Result<JsValue, JsValue> {
812            let snapshots = self.inner.connection_states().await;
813            serde_wasm_bindgen::to_value(&snapshots).map_err(|e| JsValue::from_str(&e.to_string()))
814        }
815
816        pub async fn wait_for_settled_peer(
817            &self,
818            id: String,
819            timeout_ms: Option<u32>,
820        ) -> Result<JsValue, JsValue> {
821            let snapshot = self
822                .inner
823                .wait_for_settled_peer(&id, timeout_ms.map(|value| value as u64))
824                .await;
825            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
826        }
827
828        pub async fn resolve_peer_connection_records(
829            &self,
830            id: String,
831        ) -> Result<JsValue, JsValue> {
832            let records = self.inner.resolve_peer_connection_records(&id).await;
833            serde_wasm_bindgen::to_value(&records).map_err(|e| JsValue::from_str(&e.to_string()))
834        }
835
836        pub async fn list_managed_connections(&self) -> Result<JsValue, JsValue> {
837            let records = self.inner.list_managed_connections().await;
838            serde_wasm_bindgen::to_value(&records).map_err(|e| JsValue::from_str(&e.to_string()))
839        }
840
841        pub async fn bind_connection_device_id(
842            &self,
843            connection_id: String,
844            device_id: String,
845        ) -> Result<JsValue, JsValue> {
846            let snapshot = self
847                .inner
848                .bind_connection_device_id(&connection_id, &device_id)
849                .await;
850            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
851        }
852
853        pub async fn bind_node_device_id(
854            &self,
855            node_id: String,
856            device_id: String,
857        ) -> Result<(), JsValue> {
858            self.inner.bind_node_device_id(&node_id, &device_id).await;
859            Ok(())
860        }
861
862        pub async fn reject_connection_admission(
863            &self,
864            connection_id: String,
865            reason: String,
866        ) -> Result<JsValue, JsValue> {
867            self.inner
868                .reject_session_connection(&connection_id, &reason);
869            self.inner
870                .emit_current_wasm_connection_state(&connection_id)
871                .await;
872            let snapshot = self.inner.connection_state(&connection_id).await;
873            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
874        }
875
876        pub async fn report_managed_connection_settled(
877            &self,
878            connection_id: String,
879            settled: bool,
880            device_id: Option<String>,
881            transport_stable_id: Option<u64>,
882            transport_generation: Option<u64>,
883            route_generation: Option<u64>,
884        ) -> Result<JsValue, JsValue> {
885            let snapshot = match (transport_stable_id, transport_generation, route_generation) {
886                (Some(transport_stable_id), Some(transport_generation), Some(route_generation)) => {
887                    self.inner
888                        .report_managed_connection_settled_for_transport(
889                            &connection_id,
890                            settled,
891                            transport_stable_id,
892                            transport_generation,
893                            route_generation,
894                        )
895                        .await
896                }
897                _ => None,
898            };
899            let _ = device_id;
900            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
901        }
902
903        pub async fn report_transport_status(
904            &self,
905            connection_id: String,
906            active_transport: String,
907            parallel_transport: Option<String>,
908            transport_stable_id: Option<u64>,
909            transport_generation: Option<u64>,
910            route_generation: Option<u64>,
911        ) -> Result<JsValue, JsValue> {
912            let snapshot = match (transport_stable_id, transport_generation, route_generation) {
913                (Some(transport_stable_id), Some(transport_generation), Some(route_generation)) => {
914                    self.inner
915                        .report_transport_status_for_generation(
916                            &connection_id,
917                            &active_transport,
918                            parallel_transport.as_deref(),
919                            transport_stable_id,
920                            transport_generation,
921                            route_generation,
922                        )
923                        .await
924                }
925                _ => None,
926            };
927            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
928        }
929
930        pub async fn is_current_transport_stable_id(
931            &self,
932            endpoint_id: String,
933            transport_stable_id: u64,
934        ) -> Result<bool, JsValue> {
935            let endpoint_id = endpoint_id
936                .parse::<iroh::EndpointId>()
937                .map_err(|error| JsValue::from_str(&error.to_string()))?;
938            Ok(self
939                .inner
940                .is_current_transport_stable_id(endpoint_id, transport_stable_id)
941                .await)
942        }
943
944        pub async fn open_bi(&self, endpoint_id: String) -> Result<BiStream, JsValue> {
945            let endpoint_id: iroh::EndpointId = endpoint_id
946                .parse()
947                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
948            self.inner
949                .assert_raw_peer_stream_allowed(&endpoint_id)
950                .await
951                .map_err(|e| JsValue::from_str(&e.to_string()))?;
952            // Parity with the native `Client::open_bi`: register/finalize the
953            // connection_manager record for the (already-alive) transport before
954            // handing out a raw peer stream, so browser raw-stream opens
955            // (explicit transfer, native-main signaling) participate in
956            // connection lifecycle / close tracking. Idempotent, and a no-op when
957            // the transport is not alive. `Client::open_bi` itself is native-only
958            // (it returns native iroh stream types), so the wasm binding cannot
959            // delegate to it and must mirror its guard + record + open sequence.
960            self.inner
961                .ensure_connection_manager_record_before_peer_stream(&endpoint_id)
962                .await
963                .map_err(|e| JsValue::from_str(&e.to_string()))?;
964            let (send, recv) = self
965                .inner
966                .open_bi_internal(endpoint_id)
967                .await
968                .map_err(|e| JsValue::from_str(&e.to_string()))?;
969            Ok(BiStream::from_parts(send, recv, endpoint_id.to_string()))
970        }
971
972        /// Open a raw bi-stream for the SDK-owned native-main **control plane**
973        /// (WebRTC signaling: SDP / ICE candidates / renegotiate, and the
974        /// bootstrap application-route handshake).
975        ///
976        /// Unlike [`open_bi`], this is deliberately **exempt** from the
977        /// application-crypto raw-open guard (`assert_raw_peer_stream_allowed`).
978        /// That guard protects application *data* — but the control plane is not
979        /// application data:
980        ///   1. Signaling bootstraps the very application route (and key
981        ///      agreement) it would otherwise depend on, so it cannot require app
982        ///      crypto that has not been negotiated yet.
983        ///   2. It is already authenticated by the iroh QUIC TLS that binds the
984        ///      sender's node id.
985        ///   3. The receiver classifies the stream by its `[0x00][len]["main"]`
986        ///      native-main label and routes it to the signal handler; bytes sent
987        ///      here can never be delivered as application data, so this cannot be
988        ///      abused to smuggle unencrypted app payloads past the guard.
989        ///
990        /// This binding is intentionally named for native-main. It is not a
991        /// general-purpose raw-stream escape hatch: JS callers must immediately
992        /// write the `[0x00][len]["main"]` native-main label and then framed
993        /// control payloads. Application data must continue to use `open_peer_bi`
994        /// / `open_peer_uni` so the application-crypto guard stays fail-closed.
995        ///
996        /// Without this, a peer whose inbound native-main control writer was lost
997        /// (e.g. after a rapid disconnect/reconnect flap) and that requires app
998        /// crypto could not (re)open a signaling stream at all, stranding the
999        /// edge on base Iroh because SDP offers/answers can never be exchanged.
1000        pub async fn open_native_main_control_bi(
1001            &self,
1002            endpoint_id: String,
1003        ) -> Result<BiStream, JsValue> {
1004            let endpoint_id: iroh::EndpointId = endpoint_id
1005                .parse()
1006                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
1007            self.inner
1008                .ensure_connection_manager_record_before_peer_stream(&endpoint_id)
1009                .await
1010                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1011            let (send, recv) = self
1012                .inner
1013                .open_bi_internal(endpoint_id)
1014                .await
1015                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1016            Ok(BiStream::from_parts(send, recv, endpoint_id.to_string()))
1017        }
1018
1019        pub async fn open_peer_bi(
1020            &self,
1021            id: String,
1022            timeout_ms: Option<u32>,
1023        ) -> Result<BiStream, JsValue> {
1024            let (_connection_id, remote_node_id, send, recv) = self
1025                .inner
1026                .open_peer_bi(&id, timeout_ms.map(|value| value as u64))
1027                .await
1028                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1029            Ok(BiStream::from_peer_parts(send, recv, remote_node_id))
1030        }
1031
1032        /// Send a complete protected application frame on a fresh peer stream.
1033        /// Rust owns the QUIC FIN so JS readable cancellation cannot reset the
1034        /// one-shot stream before the remote runtime admits it.
1035        pub async fn send_peer_application_frame(
1036            &self,
1037            id: String,
1038            frame: Vec<u8>,
1039            timeout_ms: Option<u32>,
1040        ) -> Result<(), JsValue> {
1041            self.inner
1042                .send_peer_application_frame(&id, &frame, timeout_ms.map(|value| value as u64))
1043                .await
1044                .map_err(|error| JsValue::from_str(&error.to_string()))
1045        }
1046
1047        /// Open a settled peer stream for explicit file transfer.
1048        ///
1049        /// The runtime writes the plaintext explicit-file protocol byte (`0x02`)
1050        /// before returning the send stream, then wraps only the transfer body
1051        /// with application crypto when a key is active for the peer.
1052        pub async fn open_peer_bi_explicit_file_sender(
1053            &self,
1054            id: String,
1055            timeout_ms: Option<u32>,
1056        ) -> Result<PeerUniStream, JsValue> {
1057            let (_connection_id, _remote_node_id, send) = self
1058                .inner
1059                .open_peer_bi_explicit_file_sender(&id, timeout_ms.map(|value| value as u64))
1060                .await
1061                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1062            Ok(peer_uni_stream_from_send(send))
1063        }
1064
1065        /// Open a bi-stream to a peer that is transport-connected but may not yet
1066        /// be settled (auth-ready). Use for latency probes and other transport-level
1067        /// diagnostics where `settled_ready` is not required.
1068        pub async fn open_peer_bi_transport_only(
1069            &self,
1070            id: String,
1071            timeout_ms: Option<u32>,
1072        ) -> Result<BiStream, JsValue> {
1073            let (_connection_id, remote_node_id, send, recv) = self
1074                .inner
1075                .open_peer_bi_transport_only(&id, timeout_ms.map(|value| value as u64))
1076                .await
1077                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1078            Ok(BiStream::from_parts(send, recv, remote_node_id))
1079        }
1080
1081        /// Open a settled peer bi-stream for a native host protocol and write
1082        /// the standard OpenRTC channel envelope before returning it to JS.
1083        ///
1084        /// This is intentionally narrower than `open_peer_bi_transport_only`:
1085        /// native Plutonium drive-view hosts authorize with OpenRTC session
1086        /// admission + drive scopes. The channel envelope is written through the
1087        /// protected peer stream, so keyed and unkeyed sessions use the same wire
1088        /// contract and never expose a plaintext product label beside encrypted
1089        /// payloads.
1090        pub async fn open_peer_native_bi(
1091            &self,
1092            id: String,
1093            label: String,
1094            timeout_ms: Option<u32>,
1095        ) -> Result<BiStream, JsValue> {
1096            if label != "drive-view" {
1097                return Err(JsValue::from_str(
1098                    "unsupported native peer stream label; only drive-view is allowed",
1099                ));
1100            }
1101
1102            let (_connection_id, remote_node_id, mut send, recv) = self
1103                .inner
1104                .open_peer_bi(&id, timeout_ms.map(|value| value as u64))
1105                .await
1106                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1107            let envelope = crate::stream_metadata::encode_channel_envelope(&label, None)
1108                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1109            send.write_all(&envelope)
1110                .await
1111                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1112
1113            Ok(BiStream::from_peer_parts(send, recv, remote_node_id))
1114        }
1115
1116        pub async fn open_uni(&self, endpoint_id: String) -> Result<PeerUniStream, JsValue> {
1117            let endpoint_id: iroh::EndpointId = endpoint_id
1118                .parse()
1119                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
1120            self.inner
1121                .assert_raw_peer_stream_allowed(&endpoint_id)
1122                .await
1123                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1124            // Parity with the native `Client::open_uni`, which also finalizes the
1125            // connection_manager record before opening a raw uni peer stream.
1126            self.inner
1127                .ensure_connection_manager_record_before_peer_stream(&endpoint_id)
1128                .await
1129                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1130            let node_guard = self.inner.iroh_node.read().await;
1131            if let Some(node) = node_guard.as_ref() {
1132                let send = node
1133                    .open_uni(endpoint_id.clone())
1134                    .await
1135                    .map_err(|e| JsValue::from_str(&e.to_string()))?;
1136                Ok(peer_uni_stream_from_send(
1137                    crate::application_crypto_streams::PeerSendStream::plain(send),
1138                ))
1139            } else {
1140                Err(JsValue::from_str("Iroh node not initialized"))
1141            }
1142        }
1143
1144        pub async fn open_peer_uni(
1145            &self,
1146            id: String,
1147            timeout_ms: Option<u32>,
1148        ) -> Result<PeerUniStream, JsValue> {
1149            let (_connection_id, _remote_node_id, send) = self
1150                .inner
1151                .open_peer_uni(&id, timeout_ms.map(|value| value as u64))
1152                .await
1153                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1154            Ok(peer_uni_stream_from_send(send))
1155        }
1156
1157        /// Send `data` to `peer_id` using the best available transport.
1158        ///
1159        /// Native route order is edge-aware: proven upgraded routes stay first,
1160        /// relay/unknown iroh can probe upgraded transports first, and direct
1161        /// iroh/LAN/BLE paths stay primary with upgraded transports as fallback.
1162        ///
1163        /// On WASM this always routes through iroh (relay or QUIC as iroh determines).
1164        /// Use the TypeScript `Connection.sendTyped()` for full transport priority on the
1165        /// browser side — this binding is primarily for symmetry and native callers.
1166        pub async fn send_peer(&self, id: String, data: Vec<u8>) -> Result<(), JsValue> {
1167            self.inner
1168                .send_peer(&id, &data)
1169                .await
1170                .map_err(|e| JsValue::from_str(&e.to_string()))
1171        }
1172
1173        /// Returns the current iroh path kind for a connected peer.
1174        ///
1175        /// Returns one of `"direct-quic"`, `"relay"`, or `"unknown"`.
1176        pub async fn iroh_path_kind(&self, peer_id: String) -> String {
1177            match self.inner.iroh_path_kind(&peer_id).await {
1178                crate::client::IrohPathKind::DirectQuic => "direct-quic".to_string(),
1179                crate::client::IrohPathKind::DirectLan => "direct-lan".to_string(),
1180                crate::client::IrohPathKind::Relay => "relay".to_string(),
1181                crate::client::IrohPathKind::Ble => "ble".to_string(),
1182                crate::client::IrohPathKind::Unknown => "unknown".to_string(),
1183            }
1184        }
1185
1186        /// Returns the current iroh transport RTT in milliseconds, when iroh has
1187        /// selected a live path and published path stats.
1188        pub async fn iroh_transport_rtt_ms(&self, peer_id: String) -> Option<u32> {
1189            self.inner
1190                .iroh_transport_rtt_ms(&peer_id)
1191                .await
1192                .map(|value| value.min(u32::MAX as u64) as u32)
1193        }
1194
1195        pub async fn incoming_streams(&self) -> Result<JsReadableStream, JsValue> {
1196            let (node, stream) = {
1197                let node_guard = self.inner.iroh_node.read().await;
1198                if let Some(node) = node_guard.as_ref() {
1199                    (node.clone(), node.incoming_streams_stream())
1200                } else {
1201                    return Err(JsValue::from_str("Iroh node not initialized"));
1202                }
1203            };
1204
1205            use futures::StreamExt;
1206            let mapped_stream = stream.filter_map(move |incoming| {
1207                let node = node.clone();
1208                async move {
1209                    node.incoming_stream_is_current(&incoming)
1210                        .await
1211                        .then(|| crate::wasm_node::BiStream::incoming_to_js_value(incoming))
1212                }
1213            });
1214
1215            Ok(wasm_streams::ReadableStream::from_stream(mapped_stream).into_raw())
1216        }
1217
1218        pub async fn update_presence(
1219            &self,
1220            user_id: String,
1221            device_name: String,
1222            ticket: String,
1223            metadata: Option<String>,
1224            ttl_ms: Option<u64>,
1225        ) -> Result<(), JsValue> {
1226            self.inner
1227                .update_presence_with_ttl(
1228                    &user_id,
1229                    &device_name,
1230                    &ticket,
1231                    ttl_ms.unwrap_or(300_000),
1232                    metadata.as_deref(),
1233                )
1234                .await
1235                .map_err(|e| JsValue::from_str(&e.to_string()))
1236        }
1237
1238        pub async fn send_message(
1239            &self,
1240            target_id: String,
1241            payload: String,
1242            state: Option<String>,
1243            reply_payload: Option<String>,
1244        ) -> Result<String, JsValue> {
1245            self.inner
1246                .send_message(
1247                    &target_id,
1248                    &payload,
1249                    state.as_deref(),
1250                    reply_payload.as_deref(),
1251                )
1252                .await
1253                .map_err(|e| JsValue::from_str(&e.to_string()))
1254        }
1255
1256        pub async fn set_offline(&self, user_id: String) -> Result<(), JsValue> {
1257            self.inner
1258                .set_offline(&user_id)
1259                .await
1260                .map_err(|e| JsValue::from_str(&e.to_string()))
1261        }
1262
1263        pub async fn update_device(
1264            &self,
1265            user_id: String,
1266            device_id: String,
1267            device_name: Option<String>,
1268            capabilities: Option<JsValue>,
1269            metadata: Option<String>,
1270        ) -> Result<(), JsValue> {
1271            let parsed_capabilities = match capabilities {
1272                Some(value) if !value.is_null() && !value.is_undefined() => Some(
1273                    serde_wasm_bindgen::from_value::<crate::signaling::DeviceCapabilities>(value)
1274                        .map_err(|e| JsValue::from_str(&e.to_string()))?,
1275                ),
1276                _ => None,
1277            };
1278
1279            self.inner
1280                .update_device(
1281                    &user_id,
1282                    &device_id,
1283                    device_name.as_deref(),
1284                    parsed_capabilities,
1285                    metadata.as_deref(),
1286                )
1287                .await
1288                .map_err(|e| JsValue::from_str(&e.to_string()))
1289        }
1290
1291        pub async fn delete_device(
1292            &self,
1293            user_id: String,
1294            device_id: String,
1295        ) -> Result<(), JsValue> {
1296            self.inner
1297                .delete_device(&user_id, &device_id)
1298                .await
1299                .map_err(|e| JsValue::from_str(&e.to_string()))
1300        }
1301
1302        pub fn force_reconnect_snapshot(&self) {
1303            self.inner.clone().force_reconnect_snapshot();
1304        }
1305
1306        pub fn stop_presence_loop(&self) {
1307            self.inner.stop_presence_loop();
1308        }
1309
1310        pub fn stop_auto_connect(&self) {
1311            self.inner.stop_auto_connect();
1312            self.inner.stop_browser_auto_connect();
1313        }
1314
1315        pub fn start_auto_connect(
1316            &self,
1317            user_id: String,
1318            local_device_id: String,
1319        ) -> Result<(), JsValue> {
1320            self.inner
1321                .start_browser_auto_connect(user_id, local_device_id)
1322                .map_err(|error| JsValue::from_str(&error.to_string()))
1323        }
1324
1325        pub fn submit_browser_desired_peers(
1326            &self,
1327            revision: u32,
1328            peers_json: String,
1329        ) -> Result<bool, JsValue> {
1330            self.inner
1331                .submit_browser_desired_peers(u64::from(revision), &peers_json)
1332                .map_err(|error| JsValue::from_str(&error.to_string()))
1333        }
1334
1335        pub fn wake_browser_auto_connect(&self) -> bool {
1336            self.inner.wake_browser_auto_connect()
1337        }
1338
1339        pub async fn set_auto_connect_excluded(&self, device_id: String, excluded: bool) {
1340            if excluded {
1341                self.inner.exclude_peer_and_publish(&device_id).await;
1342            } else {
1343                self.inner.unexclude_peer_and_publish(&device_id).await;
1344            }
1345            self.inner.wake_browser_auto_connect();
1346        }
1347
1348        pub fn is_auto_connect_excluded(&self, device_id: String) -> bool {
1349            self.inner.is_auto_connect_excluded(&device_id)
1350        }
1351
1352        pub async fn disconnect_device(
1353            &self,
1354            device_id: String,
1355            node_id_hint: Option<String>,
1356        ) -> Result<JsValue, JsValue> {
1357            let retired = self
1358                .inner
1359                .disconnect_device(&device_id, node_id_hint.as_deref())
1360                .await;
1361            serde_wasm_bindgen::to_value(&retired).map_err(|e| JsValue::from_str(&e.to_string()))
1362        }
1363
1364        pub fn stop_auth_scoped_activity(&self) {
1365            self.inner.stop_auth_scoped_activity();
1366            self.inner.stop_browser_auto_connect();
1367        }
1368
1369        pub fn start_presence_loop(
1370            &self,
1371            user_id: String,
1372            device_name: String,
1373            ticket: String,
1374            metadata: Option<String>,
1375        ) {
1376            self.inner
1377                .clone()
1378                .start_signaling_loop(user_id, device_name, ticket, metadata);
1379        }
1380
1381        pub async fn search_devices(&self, user_id: String) -> Result<JsValue, JsValue> {
1382            let devices = self
1383                .inner
1384                .search_devices(&user_id)
1385                .await
1386                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1387            serde_wasm_bindgen::to_value(&devices).map_err(|e| JsValue::from_str(&e.to_string()))
1388        }
1389
1390        pub async fn devices_with_status(&self, user_id: String) -> Result<JsValue, JsValue> {
1391            let devices = self
1392                .inner
1393                .devices_with_status(&user_id)
1394                .await
1395                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1396            serde_wasm_bindgen::to_value(&devices).map_err(|e| JsValue::from_str(&e.to_string()))
1397        }
1398
1399        pub async fn connect_device(
1400            &self,
1401            device_id: Option<String>,
1402            endpoint_ticket: String,
1403        ) -> Result<JsValue, JsValue> {
1404            let result = self
1405                .inner
1406                .connect_device(device_id.as_deref(), &endpoint_ticket)
1407                .await
1408                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1409            serde_wasm_bindgen::to_value(&result).map_err(|e| JsValue::from_str(&e.to_string()))
1410        }
1411
1412        pub async fn create_session(&self, session_json: String) -> Result<(), JsValue> {
1413            let session: crate::signaling::SignalingSession =
1414                serde_json::from_str(&session_json)
1415                    .map_err(|e| JsValue::from_str(&e.to_string()))?;
1416            self.inner
1417                .create_session(session)
1418                .await
1419                .map_err(|e| JsValue::from_str(&e.to_string()))
1420        }
1421
1422        pub async fn update_session(
1423            &self,
1424            session_id: String,
1425            update_json: String,
1426        ) -> Result<(), JsValue> {
1427            let update_data: serde_json::Value = serde_json::from_str(&update_json)
1428                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1429            self.inner
1430                .update_session(&session_id, update_data)
1431                .await
1432                .map_err(|e| JsValue::from_str(&e.to_string()))
1433        }
1434
1435        // --- Room Management API ---
1436
1437        pub async fn create_room(
1438            &self,
1439            room_id: String,
1440            user_id: String,
1441            ticket_str: String,
1442            my_node_id: String,
1443            tag: String,
1444            max_members: Option<u32>,
1445        ) -> Result<bool, JsValue> {
1446            self.inner
1447                .room
1448                .create_room(
1449                    &room_id,
1450                    &user_id,
1451                    &ticket_str,
1452                    &my_node_id,
1453                    &tag,
1454                    max_members,
1455                )
1456                .await
1457                .map_err(|e| JsValue::from_str(&e.to_string()))
1458        }
1459
1460        pub async fn join_room(
1461            &self,
1462            room_id: String,
1463            user_id: String,
1464            ticket_str: String,
1465            my_node_id: String,
1466            tag: String,
1467        ) -> Result<(), JsValue> {
1468            self.inner
1469                .room
1470                .join_room(&room_id, &user_id, &ticket_str, &my_node_id, &tag)
1471                .await
1472                .map_err(|e| JsValue::from_str(&e.to_string()))
1473        }
1474
1475        pub async fn get_members(
1476            &self,
1477            room_id: String,
1478            my_node_id: String,
1479            tag: String,
1480        ) -> Result<String, JsValue> {
1481            let members = self
1482                .inner
1483                .room
1484                .get_members(&room_id, &my_node_id, &tag)
1485                .await
1486                .map_err(|e| JsValue::from_str(&e.to_string()))?;
1487            serde_json::to_string(&members).map_err(|e| JsValue::from_str(&e.to_string()))
1488        }
1489
1490        pub async fn leave_room(
1491            &self,
1492            room_id: String,
1493            my_node_id: String,
1494            tag: String,
1495        ) -> Result<(), JsValue> {
1496            self.inner
1497                .room
1498                .leave_room(&room_id, &my_node_id, &tag)
1499                .await
1500                .map_err(|e| JsValue::from_str(&e.to_string()))
1501        }
1502
1503        pub async fn heartbeat_tick(
1504            &self,
1505            room_id: String,
1506            member_id: String,
1507            tag: String,
1508        ) -> Result<(), JsValue> {
1509            self.inner
1510                .room
1511                .heartbeat_tick(&room_id, &member_id, &tag)
1512                .await
1513                .map_err(|e| JsValue::from_str(&e.to_string()))
1514        }
1515    }
1516
1517    #[wasm_bindgen(start)]
1518    pub fn start() {
1519        console_error_panic_hook::set_once();
1520    }
1521}