Skip to main content

openrtc/
lib.rs

1#![allow(deprecated)]
2
3pub mod application_crypto;
4pub mod application_crypto_streams;
5pub mod broadcast;
6pub mod client;
7pub mod coordination;
8pub mod datagrams;
9pub mod explicit_transfer_crypto;
10pub(crate) mod generated;
11pub mod heartbeat;
12#[cfg(feature = "iroh-carrier-core")]
13pub mod iroh_carrier;
14#[cfg(feature = "iroh-carrier-core")]
15pub mod iroh_carrier_bootstrap;
16// Carrier IDs are dependency-free protocol metadata. Admission code must be
17// able to classify reserved IDs even when no custom carrier is compiled.
18pub mod iroh_carrier_kind;
19#[cfg(feature = "iroh-carrier-core")]
20pub mod iroh_carrier_proof;
21pub(crate) mod iroh_connection_policy;
22pub mod key_agreement;
23pub mod lifecycle_reason;
24#[cfg(feature = "managed-group-encryption")]
25#[allow(
26    dead_code,
27    reason = "gated managed-room controller; consumed only by managed gateway adapters"
28)]
29pub(crate) mod managed_group_controller;
30#[cfg(feature = "managed-group-encryption")]
31#[allow(
32    dead_code,
33    reason = "gated managed-room crypto owner; enabled only after the tracked persistence and router milestones"
34)]
35pub(crate) mod managed_group_crypto;
36pub mod media;
37pub mod native_protocol;
38pub mod offline;
39#[cfg(feature = "iroh-carrier-core")]
40pub mod packet_carrier_transport;
41pub mod presence;
42pub(crate) mod presence_policy;
43pub mod protocol_config;
44pub mod route_policy;
45pub mod runtime_policy;
46pub mod service_errors;
47pub mod session_token;
48pub mod signaling;
49pub mod sparse_fanout;
50pub mod stream_metadata;
51pub(crate) mod transport_generation;
52pub(crate) mod transport_label;
53
54#[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
55pub mod local_discovery;
56
57/// Test constants — use these instead of hardcoding project IDs in tests.
58/// Unit tests that don't hit real Firestore should use TEST_PROJECT_ID.
59/// Live/integration tests must use LIVE_PROJECT_ID ("pluto-rtc-prod").
60#[cfg(test)]
61pub mod test_constants {
62    pub const TEST_PROJECT_ID: &str = "test-project";
63    pub const TEST_API_KEY: &str = "pk_test_0000000000000000000000000000000000000000";
64}
65
66/// Re-export for downstream crates' tests.
67pub const LIVE_PROJECT_ID: &str = "pluto-rtc-prod";
68
69/// Validate the only public platform credential accepted by an OpenRTC 2.0
70/// client constructor. The API key identifies the developer application; it is
71/// not a secret and does not authorize a live avenue by itself.
72pub fn validate_api_key(api_key: &str) -> anyhow::Result<&str> {
73    let trimmed = api_key.trim();
74    let valid_prefix = trimmed.starts_with("pk_live_") || trimmed.starts_with("pk_test_");
75    let suffix = trimmed.get(8..).unwrap_or_default();
76    if !valid_prefix || suffix.len() != 40 || !suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
77        anyhow::bail!("OpenRTC 2.0 requires a public pk_live_ or pk_test_ API key");
78    }
79    Ok(trimmed)
80}
81
82pub fn app_tag_from_api_key(api_key: &str) -> String {
83    let trimmed = api_key.trim();
84    if trimmed.is_empty() {
85        return "app_anonymous".to_string();
86    }
87
88    let suffix_len = trimmed.len().min(16);
89    format!("app_{}", &trimmed[trimmed.len() - suffix_len..])
90}
91
92pub fn space_app_tag(api_key: &str, space_key: &str) -> String {
93    let input = format!("{}:{}", api_key.trim(), space_key.trim());
94    let digest = <sha2::Sha256 as sha2::Digest>::digest(input.as_bytes());
95    format!("space::{}", hex::encode(digest))
96}
97
98#[cfg(test)]
99mod constructor_contract_tests {
100    use super::*;
101
102    #[test]
103    fn rust_constructor_is_provider_neutral_and_side_effect_free() {
104        let api_key = test_constants::TEST_API_KEY;
105        let client = client::Client::new(api_key.to_string()).expect("valid public API key");
106
107        assert_eq!(client.app_tag(), app_tag_from_api_key(api_key));
108        assert!(client::Client::new("firebase-project-id".to_string()).is_err());
109    }
110}
111
112#[cfg(not(target_arch = "wasm32"))]
113pub fn ensure_rustls() {
114    if rustls::crypto::CryptoProvider::get_default().is_none() {
115        let _ = rustls::crypto::ring::default_provider().install_default();
116    }
117}
118
119#[cfg(not(target_arch = "wasm32"))]
120pub mod adapters;
121
122#[cfg(not(target_arch = "wasm32"))]
123pub(crate) mod native_coordination_gateway;
124
125#[cfg(not(target_arch = "wasm32"))]
126pub mod native;
127
128#[cfg(not(target_arch = "wasm32"))]
129mod native_key_store;
130
131pub use client::Client;
132pub mod ticket_mesh;
133#[cfg(not(target_arch = "wasm32"))]
134pub use native::ControlPlane;
135#[cfg(not(target_arch = "wasm32"))]
136pub use ticket_mesh::TicketMesh;
137pub use ticket_mesh::{TicketMeshError, TicketMeshErrorCode, TicketMeshOptions};
138
139pub mod connection_manager;
140
141pub mod protocol_registry;
142
143#[cfg(not(target_arch = "wasm32"))]
144pub mod runtime_manager;
145
146#[cfg(not(target_arch = "wasm32"))]
147pub mod transport;
148
149#[cfg(not(target_arch = "wasm32"))]
150pub use client::EndpointHandle;
151
152#[cfg(all(
153    not(target_arch = "wasm32"),
154    not(any(target_os = "ios", target_os = "android"))
155))]
156pub mod sso;
157
158#[cfg(not(target_arch = "wasm32"))]
159pub mod native_node;
160
161#[cfg(not(target_arch = "wasm32"))]
162pub mod native_device;
163
164#[cfg(all(
165    test,
166    not(target_arch = "wasm32"),
167    feature = "iroh-protocols-wasm",
168    any(feature = "transport-webrtc", feature = "transport-moq")
169))]
170mod native_carrier_protocol_test;
171#[cfg(all(not(target_arch = "wasm32"), feature = "transport-moq"))]
172pub mod native_moq_carrier;
173#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
174pub mod native_webrtc_carrier;
175
176#[cfg(all(target_arch = "wasm32", feature = "iroh-protocols-wasm"))]
177mod wasm_docs_persistence;
178#[cfg(all(target_arch = "wasm32", feature = "iroh-protocols-wasm"))]
179mod wasm_indexeddb_blob_store;
180#[cfg(all(target_arch = "wasm32", feature = "transport-moq"))]
181pub mod wasm_moq_carrier;
182#[cfg(target_arch = "wasm32")]
183pub mod wasm_node;
184#[cfg(all(target_arch = "wasm32", feature = "transport-webrtc"))]
185pub mod wasm_webrtc_carrier;
186
187#[cfg(target_arch = "wasm32")]
188#[macro_export]
189macro_rules! console_log {
190    ($($t:tt)*) => (web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format_args!($($t)*).to_string())))
191}
192
193// WASM entry point bindings
194#[cfg(target_arch = "wasm32")]
195pub mod wasm_api {
196    use crate::client::Client;
197    use crate::session_token::split_ticket;
198    use crate::wasm_node::{peer_uni_stream_from_send, BiStream, PeerUniStream};
199    use iroh_tickets::endpoint::EndpointTicket;
200    use std::cell::RefCell;
201    use std::collections::{HashMap, HashSet};
202    use std::rc::Rc;
203    use std::str::FromStr;
204    use std::sync::{Arc, Mutex};
205    use wasm_bindgen::prelude::*;
206    #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
207    use wasm_bindgen_futures::spawn_local;
208    use wasm_streams::readable::sys::ReadableStream as JsReadableStream;
209
210    async fn send_native_signal_control_frame(
211        inner: &Client,
212        endpoint_id: String,
213        frame: Vec<u8>,
214    ) -> Result<(), JsValue> {
215        let endpoint_id: iroh::EndpointId = endpoint_id
216            .parse()
217            .map_err(|error| JsValue::from_str(&format!("{error}")))?;
218        inner
219            .ensure_connection_manager_record_before_peer_stream(&endpoint_id)
220            .await
221            .map_err(|error| JsValue::from_str(&error.to_string()))?;
222        let (mut send, _recv) = inner
223            .open_bi_internal(endpoint_id)
224            .await
225            .map_err(|error| JsValue::from_str(&error.to_string()))?;
226        send.write_all(&[0x00])
227            .await
228            .map_err(|error| JsValue::from_str(&error.to_string()))?;
229        send.write_all(&(b"signal".len() as u32).to_be_bytes())
230            .await
231            .map_err(|error| JsValue::from_str(&error.to_string()))?;
232        send.write_all(b"signal")
233            .await
234            .map_err(|error| JsValue::from_str(&error.to_string()))?;
235        send.write_all(&frame)
236            .await
237            .map_err(|error| JsValue::from_str(&error.to_string()))?;
238        crate::application_crypto_streams::PeerSendStream::plain(send)
239            .finish_and_wait_for_peer(std::time::Duration::from_secs(2))
240            .await
241            .map_err(|error| JsValue::from_str(&error.to_string()))
242    }
243
244    #[cfg(feature = "transport-webrtc")]
245    #[derive(Debug, Clone)]
246    struct WasmWebRtcCarrierAttempt {
247        connection_id: String,
248        remote_endpoint_id: iroh::EndpointId,
249        bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
250        generation: crate::client::WasmPeerDataGeneration,
251        role: &'static str,
252        prepared: bool,
253        retry_sent: bool,
254        offer_started: bool,
255        remote_ready: bool,
256        completion_started: bool,
257        retry_count: u8,
258        inbound_authorization_expires_at_ms: Option<f64>,
259    }
260
261    #[cfg(feature = "transport-moq")]
262    #[derive(Debug, Clone)]
263    struct WasmMoqCarrierAttempt {
264        connection_id: String,
265        remote_endpoint_id: iroh::EndpointId,
266        bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
267        generation: crate::client::WasmPeerDataGeneration,
268        role: &'static str,
269        prepared: bool,
270        retry_sent: bool,
271        retry_count: u8,
272        inbound_authorization_expires_at_ms: Option<f64>,
273    }
274
275    #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
276    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
277    struct WasmRemoteCarrierCapabilities {
278        webrtc: bool,
279        moq: bool,
280    }
281
282    #[wasm_bindgen]
283    pub struct WasmPeerDatagramPolicy {
284        inner: RefCell<crate::datagrams::PeerDatagramPolicy>,
285    }
286
287    #[wasm_bindgen]
288    impl WasmPeerDatagramPolicy {
289        #[wasm_bindgen(js_name = setIncomingMaxAge)]
290        pub fn set_incoming_max_age(&self, value: Option<u32>, now_ms: f64) {
291            self.inner
292                .borrow_mut()
293                .set_incoming_max_age_ms(value.map(u64::from), now_ms.max(0.0) as u64);
294        }
295
296        #[wasm_bindgen(js_name = setOutgoingMaxAge)]
297        pub fn set_outgoing_max_age(&self, value: Option<u32>) {
298            self.inner
299                .borrow_mut()
300                .set_outgoing_max_age_ms(value.map(u64::from));
301        }
302
303        #[wasm_bindgen(js_name = setIncomingMaxBufferedDatagrams)]
304        pub fn set_incoming_max_buffered_datagrams(&self, value: u32) -> Result<(), JsValue> {
305            self.inner
306                .borrow_mut()
307                .set_incoming_max_buffered(value as usize)
308                .map_err(|error| JsValue::from_str(&error.to_string()))
309        }
310
311        #[wasm_bindgen(js_name = setOutgoingMaxBufferedDatagrams)]
312        pub fn set_outgoing_max_buffered_datagrams(&self, value: u32) -> Result<(), JsValue> {
313            self.inner
314                .borrow_mut()
315                .set_outgoing_max_buffered(value as usize)
316                .map_err(|error| JsValue::from_str(&error.to_string()))
317        }
318
319        #[wasm_bindgen(js_name = pushIncoming)]
320        pub fn push_incoming(&self, payload: Vec<u8>, now_ms: f64) -> Result<(), JsValue> {
321            self.inner
322                .borrow_mut()
323                .push_incoming(payload, now_ms.max(0.0) as u64)
324                .map_err(|error| JsValue::from_str(&error.to_string()))
325        }
326
327        #[wasm_bindgen(js_name = popIncoming)]
328        pub fn pop_incoming(&self, now_ms: f64) -> Option<Vec<u8>> {
329            self.inner.borrow_mut().pop_incoming(now_ms.max(0.0) as u64)
330        }
331
332        #[wasm_bindgen(js_name = pushOutgoing)]
333        pub fn push_outgoing(&self, payload: Vec<u8>, now_ms: f64) -> Result<bool, JsValue> {
334            self.inner
335                .borrow_mut()
336                .push_outgoing(payload, now_ms.max(0.0) as u64)
337                .map_err(|error| JsValue::from_str(&error.to_string()))
338        }
339
340        #[wasm_bindgen(js_name = popOutgoing)]
341        pub fn pop_outgoing(&self, now_ms: f64) -> Result<JsValue, JsValue> {
342            let Some(outgoing) = self.inner.borrow_mut().pop_outgoing(now_ms.max(0.0) as u64)
343            else {
344                return Ok(JsValue::NULL);
345            };
346            let result = js_sys::Object::new();
347            js_sys::Reflect::set(
348                &result,
349                &JsValue::from_str("payload"),
350                &js_sys::Uint8Array::from(outgoing.payload.as_slice()),
351            )?;
352            js_sys::Reflect::set(
353                &result,
354                &JsValue::from_str("remainingMaxAgeMs"),
355                &outgoing
356                    .remaining_max_age_ms
357                    .map(|value| JsValue::from_f64(value as f64))
358                    .unwrap_or(JsValue::NULL),
359            )?;
360            Ok(result.into())
361        }
362
363        #[wasm_bindgen(js_name = recordSent)]
364        pub fn record_sent(&self, bytes: usize) {
365            self.inner.borrow_mut().record_sent(bytes);
366        }
367
368        #[wasm_bindgen(js_name = recordExpiredOutgoing)]
369        pub fn record_expired_outgoing(&self) {
370            self.inner.borrow_mut().record_expired_outgoing();
371        }
372
373        #[wasm_bindgen(js_name = recordSendFailure)]
374        pub fn record_send_failure(&self) {
375            self.inner.borrow_mut().record_send_failure();
376        }
377
378        #[wasm_bindgen(js_name = getStats)]
379        pub fn get_stats(&self, now_ms: f64) -> Result<JsValue, JsValue> {
380            let stats = self.inner.borrow_mut().stats(now_ms.max(0.0) as u64);
381            serde::Serialize::serialize(&stats, &serde_wasm_bindgen::Serializer::json_compatible())
382                .map_err(|error| JsValue::from_str(&error.to_string()))
383        }
384
385        #[wasm_bindgen(js_name = closeIncoming)]
386        pub fn close_incoming(&self) {
387            self.inner.borrow_mut().close_incoming();
388        }
389
390        #[wasm_bindgen(js_name = closeOutgoing)]
391        pub fn close_outgoing(&self) {
392            self.inner.borrow_mut().close_outgoing();
393        }
394
395        pub fn close(&self) {
396            self.inner.borrow_mut().close();
397        }
398    }
399
400    #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
401    fn emit_wasm_carrier_action(
402        handler: &Rc<RefCell<Option<js_sys::Function>>>,
403        action: serde_json::Value,
404    ) {
405        let Some(handler) = handler.borrow().as_ref().cloned() else {
406            return;
407        };
408        // `serde_json::Value::Object` is a Serde map. The default
409        // serde-wasm-bindgen serializer turns maps into JavaScript `Map`s,
410        // while the browser adapter consumes a discriminated plain object.
411        // Keep this internal ABI JSON-compatible so `action.type` and the
412        // remaining carrier fields are visible to TypeScript.
413        let Ok(value) = serde::Serialize::serialize(
414            &action,
415            &serde_wasm_bindgen::Serializer::json_compatible(),
416        ) else {
417            return;
418        };
419        if let Err(error) = handler.call1(&JsValue::UNDEFINED, &value) {
420            web_sys::console::error_2(
421                &JsValue::from_str("[OpenRTC][WASM carrier] action handler failed"),
422                &error,
423            );
424        }
425    }
426
427    #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
428    fn wasm_carrier_failure_code(error: &anyhow::Error) -> &'static str {
429        let message = format!("{error:#}");
430        if message.contains("base generation changed before candidate acknowledgement")
431            || message.contains("incumbent generation changed")
432            || message.contains("replacement incumbent is stale")
433        {
434            "carrier-base-generation-stale"
435        } else if message.contains("authorization epoch changed") {
436            "carrier-authorization-stale"
437        } else if message.contains("stale before atomic commit")
438            || message.contains("became stale during atomic commit")
439        {
440            "carrier-logical-generation-stale"
441        } else if message.contains("attempt was retired") || message.contains("retired upgrade") {
442            "carrier-attempt-retired"
443        } else {
444            "carrier-proof-failed"
445        }
446    }
447
448    struct TicketMeshStartGuard<'a> {
449        starts: &'a RefCell<HashSet<String>>,
450        id: String,
451    }
452
453    impl Drop for TicketMeshStartGuard<'_> {
454        fn drop(&mut self) {
455            self.starts.borrow_mut().remove(&self.id);
456        }
457    }
458
459    #[wasm_bindgen]
460    pub struct WasmClient {
461        inner: Arc<Client>,
462        portable_media: RefCell<crate::media::PortableMediaSession>,
463        broadcast_sessions: RefCell<HashMap<String, crate::broadcast::BroadcastSession>>,
464        broadcast_signers: RefCell<HashMap<String, crate::broadcast::BroadcastPublisherSigner>>,
465        ticket_meshes:
466            RefCell<HashMap<String, Rc<crate::ticket_mesh::wasm_session::WasmTicketMesh>>>,
467        ticket_mesh_starts: RefCell<HashSet<String>>,
468        identity_credential: Arc<Mutex<Option<String>>>,
469        last_auth_log: Arc<Mutex<Option<(bool, usize)>>>,
470        #[cfg(feature = "iroh-protocols-wasm")]
471        persistent_protocols:
472            Rc<tokio::sync::Mutex<Option<crate::wasm_docs_persistence::WasmPersistentDocsActor>>>,
473        #[cfg(feature = "managed-group-encryption")]
474        managed_group_controllers:
475            Rc<RefCell<HashMap<String, crate::managed_group_controller::ManagedGroupController>>>,
476        #[cfg(feature = "transport-webrtc")]
477        wasm_webrtc_carrier_sessions:
478            Rc<RefCell<HashMap<String, crate::wasm_webrtc_carrier::WasmWebRtcCarrierSession>>>,
479        #[cfg(feature = "transport-webrtc")]
480        wasm_webrtc_carrier_attempts: Rc<RefCell<HashMap<String, WasmWebRtcCarrierAttempt>>>,
481        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
482        wasm_carrier_action_handler: Rc<RefCell<Option<js_sys::Function>>>,
483        #[cfg(feature = "transport-moq")]
484        wasm_moq_carrier_sessions:
485            Rc<RefCell<HashMap<String, crate::wasm_moq_carrier::WasmMoqCarrierSession>>>,
486        #[cfg(feature = "transport-moq")]
487        wasm_moq_carrier_attempts: Rc<RefCell<HashMap<String, WasmMoqCarrierAttempt>>>,
488        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
489        wasm_remote_carrier_capabilities:
490            Rc<RefCell<HashMap<String, WasmRemoteCarrierCapabilities>>>,
491        /// Serializes capability observation, carrier selection, and logical
492        /// peer retirement. Browser message delivery may start overlapping
493        /// async calls on the single WASM thread; one Rust owner must still
494        /// decide their order so a late handshake cannot resurrect a retired
495        /// peer or start a carrier from stale capability arguments.
496        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
497        wasm_carrier_peer_lifecycle: Rc<tokio::sync::Mutex<()>>,
498    }
499
500    #[cfg(feature = "transport-webrtc")]
501    async fn fail_wasm_webrtc_attempt(
502        inner: Arc<Client>,
503        attempts: Rc<RefCell<HashMap<String, WasmWebRtcCarrierAttempt>>>,
504        sessions: Rc<
505            RefCell<HashMap<String, crate::wasm_webrtc_carrier::WasmWebRtcCarrierSession>>,
506        >,
507        handler: Rc<RefCell<Option<js_sys::Function>>>,
508        attempt: WasmWebRtcCarrierAttempt,
509        failure_code: &'static str,
510        notify_peer: bool,
511    ) {
512        let kind = crate::client::IrohPathKind::WebRtc;
513        if !inner
514            .retire_wasm_carrier_upgrade(
515                &attempt.connection_id,
516                kind,
517                &attempt.bootstrap.upgrade_id,
518                attempt.generation,
519            )
520            .await
521        {
522            return;
523        }
524        if attempts
525            .borrow()
526            .get(&attempt.connection_id)
527            .is_some_and(|current| current.bootstrap.upgrade_id == attempt.bootstrap.upgrade_id)
528        {
529            attempts.borrow_mut().remove(&attempt.connection_id);
530        }
531        sessions.borrow_mut().remove(&attempt.bootstrap.upgrade_id);
532        if notify_peer {
533            if let Ok(failed) = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::failed_from(
534                &attempt.bootstrap,
535                failure_code,
536            ) {
537                emit_wasm_carrier_action(
538                    &handler,
539                    serde_json::json!({
540                        "type": "send-control",
541                        "connectionId": attempt.connection_id,
542                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
543                        "envelope": failed,
544                    }),
545                );
546            }
547        }
548        emit_wasm_carrier_action(
549            &handler,
550            serde_json::json!({
551                "type": "retire-webrtc",
552                "connectionId": attempt.connection_id,
553                "upgradeId": attempt.bootstrap.upgrade_id,
554                "failureCode": failure_code,
555            }),
556        );
557        let retry_pending = attempt.retry_count == 0
558            && matches!(
559                failure_code,
560                "data-channel-failed"
561                    | "ice-failed"
562                    | "signaling-failed"
563                    | "carrier-base-generation-stale"
564            );
565        if attempt.role == "initiator" && !retry_pending {
566            emit_wasm_carrier_action(
567                &handler,
568                serde_json::json!({
569                    "type": "advance-carrier",
570                    "connectionId": attempt.connection_id,
571                    "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
572                    "failedRoute": "webrtc",
573                }),
574            );
575        }
576    }
577
578    #[cfg(feature = "transport-webrtc")]
579    async fn retire_selected_wasm_webrtc_carrier(
580        inner: Arc<Client>,
581        attempts: Rc<RefCell<HashMap<String, WasmWebRtcCarrierAttempt>>>,
582        sessions: Rc<
583            RefCell<HashMap<String, crate::wasm_webrtc_carrier::WasmWebRtcCarrierSession>>,
584        >,
585        handler: Rc<RefCell<Option<js_sys::Function>>>,
586        attempt: &WasmWebRtcCarrierAttempt,
587        failure_code: &'static str,
588        lifecycle_reason: &'static str,
589    ) -> bool {
590        if !inner
591            .close_current_iroh_carrier_generation_with_reason(
592                &attempt.connection_id,
593                attempt.remote_endpoint_id,
594                crate::client::IrohPathKind::WebRtc,
595                attempt.generation.transport_generation.saturating_add(1),
596                lifecycle_reason,
597            )
598            .await
599        {
600            return false;
601        }
602        web_sys::console::warn_1(&JsValue::from_str(&format!(
603            "[OpenRTC][WebRTC carrier] selected mechanism ended connection_id={} upgrade_id={} failure_code={failure_code}",
604            attempt.connection_id, attempt.bootstrap.upgrade_id,
605        )));
606        if attempts
607            .borrow()
608            .get(&attempt.connection_id)
609            .is_some_and(|current| current.bootstrap.upgrade_id == attempt.bootstrap.upgrade_id)
610        {
611            attempts.borrow_mut().remove(&attempt.connection_id);
612        }
613        sessions.borrow_mut().remove(&attempt.bootstrap.upgrade_id);
614        emit_wasm_carrier_action(
615            &handler,
616            serde_json::json!({
617                "type": "retire-webrtc",
618                "connectionId": attempt.connection_id,
619                "upgradeId": attempt.bootstrap.upgrade_id,
620                "failureCode": failure_code,
621            }),
622        );
623        true
624    }
625
626    #[cfg(feature = "transport-webrtc")]
627    impl WasmClient {
628        fn schedule_wasm_webrtc_carrier_watchdog(
629            &self,
630            bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
631        ) {
632            let inner = self.inner.clone();
633            let attempts = self.wasm_webrtc_carrier_attempts.clone();
634            let sessions = self.wasm_webrtc_carrier_sessions.clone();
635            let handler = self.wasm_carrier_action_handler.clone();
636            let lifecycle = self.wasm_carrier_peer_lifecycle.clone();
637            spawn_local(async move {
638                let mut completion_phase_observed = false;
639                loop {
640                    let timeout = if completion_phase_observed { 45 } else { 30 };
641                    gloo_timers::future::sleep(std::time::Duration::from_secs(timeout)).await;
642                    let _lifecycle = lifecycle.lock().await;
643                    let attempt = attempts
644                        .borrow()
645                        .values()
646                        .find(|attempt| attempt.bootstrap.upgrade_id == bootstrap.upgrade_id)
647                        .cloned();
648                    let Some(attempt) = attempt else {
649                        return;
650                    };
651                    if !inner
652                        .wasm_carrier_upgrade_is_current(
653                            &attempt.connection_id,
654                            crate::client::IrohPathKind::WebRtc,
655                            &attempt.bootstrap.upgrade_id,
656                            attempt.generation,
657                        )
658                        .await
659                    {
660                        return;
661                    }
662                    if attempt.completion_started && !completion_phase_observed {
663                        // Setup may consume most of its budget while an
664                        // admitted relay generation settles after browser
665                        // network recovery. Once the DataChannel is ready,
666                        // give the Rust-owned candidate proof its own bounded
667                        // phase instead of letting the setup watchdog tear
668                        // down a healthy carrier mid-dial.
669                        completion_phase_observed = true;
670                        continue;
671                    }
672                    fail_wasm_webrtc_attempt(
673                        inner,
674                        attempts,
675                        sessions,
676                        handler,
677                        attempt,
678                        "carrier-timeout",
679                        true,
680                    )
681                    .await;
682                    return;
683                }
684            });
685        }
686
687        async fn fail_wasm_webrtc_carrier_attempt(
688            &self,
689            attempt: WasmWebRtcCarrierAttempt,
690            failure_code: &'static str,
691            notify_peer: bool,
692        ) {
693            fail_wasm_webrtc_attempt(
694                self.inner.clone(),
695                self.wasm_webrtc_carrier_attempts.clone(),
696                self.wasm_webrtc_carrier_sessions.clone(),
697                self.wasm_carrier_action_handler.clone(),
698                attempt,
699                failure_code,
700                notify_peer,
701            )
702            .await;
703        }
704
705        fn spawn_outbound_wasm_webrtc_carrier_completion(&self, attempt: WasmWebRtcCarrierAttempt) {
706            let inner = self.inner.clone();
707            let attempts = self.wasm_webrtc_carrier_attempts.clone();
708            let sessions = self.wasm_webrtc_carrier_sessions.clone();
709            let handler = self.wasm_carrier_action_handler.clone();
710            let lifecycle = self.wasm_carrier_peer_lifecycle.clone();
711            spawn_local(async move {
712                let kind = crate::client::IrohPathKind::WebRtc;
713                let result = inner
714                    .complete_outbound_wasm_carrier_upgrade(
715                        &attempt.connection_id,
716                        &attempt.remote_endpoint_id.to_string(),
717                        &attempt.bootstrap.upgrade_id,
718                        attempt.generation,
719                        kind,
720                    )
721                    .await;
722                let _lifecycle = lifecycle.lock().await;
723                if let Err(error) = result {
724                    let failure_code = wasm_carrier_failure_code(&error);
725                    web_sys::console::error_1(&JsValue::from_str(&format!(
726                        "[OpenRTC][WebRTC carrier] outbound candidate completion failed connection_id={} upgrade_id={} failure_code={} error={error:#}",
727                        attempt.connection_id, attempt.bootstrap.upgrade_id, failure_code,
728                    )));
729                    fail_wasm_webrtc_attempt(
730                        inner,
731                        attempts,
732                        sessions,
733                        handler,
734                        attempt,
735                        failure_code,
736                        true,
737                    )
738                    .await;
739                    return;
740                }
741                if !inner
742                    .retire_wasm_carrier_upgrade(
743                        &attempt.connection_id,
744                        kind,
745                        &attempt.bootstrap.upgrade_id,
746                        attempt.generation,
747                    )
748                    .await
749                {
750                    return;
751                }
752                emit_wasm_carrier_action(
753                    &handler,
754                    serde_json::json!({
755                        "type": "selected",
756                        "connectionId": attempt.connection_id,
757                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
758                        "upgradeId": attempt.bootstrap.upgrade_id,
759                        "family": "iroh",
760                        "carrier": "webrtc",
761                        "transportGeneration": attempt.generation.transport_generation.saturating_add(1),
762                        "routeGeneration": 0,
763                    }),
764                );
765            });
766        }
767
768        fn take_ready_outbound_wasm_webrtc_carrier_attempt(
769            &self,
770            connection_id: &str,
771            upgrade_id: &str,
772        ) -> Option<WasmWebRtcCarrierAttempt> {
773            if !self
774                .wasm_webrtc_carrier_sessions
775                .borrow()
776                .contains_key(upgrade_id)
777            {
778                return None;
779            }
780            let mut attempts = self.wasm_webrtc_carrier_attempts.borrow_mut();
781            let attempt = attempts.get_mut(connection_id).filter(|attempt| {
782                attempt.bootstrap.upgrade_id == upgrade_id
783                    && attempt.role == "initiator"
784                    && attempt.remote_ready
785                    && !attempt.completion_started
786            })?;
787            attempt.completion_started = true;
788            Some(attempt.clone())
789        }
790
791        fn spawn_inbound_wasm_webrtc_carrier_completion(&self, attempt: WasmWebRtcCarrierAttempt) {
792            let inner = self.inner.clone();
793            let attempts = self.wasm_webrtc_carrier_attempts.clone();
794            let sessions = self.wasm_webrtc_carrier_sessions.clone();
795            let handler = self.wasm_carrier_action_handler.clone();
796            let lifecycle = self.wasm_carrier_peer_lifecycle.clone();
797            spawn_local(async move {
798                let kind = crate::client::IrohPathKind::WebRtc;
799                let node = inner.iroh_node.read().await.as_ref().cloned();
800                let result = async {
801                    let node = node.ok_or_else(|| anyhow::anyhow!("Iroh node is unavailable"))?;
802                    let authorization_expires_at_ms = attempt
803                        .inbound_authorization_expires_at_ms
804                        .ok_or_else(|| anyhow::anyhow!("browser WebRTC inbound authorization is missing"))?;
805                    let candidate = node
806                        .wait_for_inbound_replacement_candidate(
807                            attempt.remote_endpoint_id,
808                            crate::iroh_carrier_kind::EXPERIMENTAL_WEBRTC_TRANSPORT_ID,
809                            authorization_expires_at_ms,
810                            std::time::Duration::from_secs(40),
811                        )
812                        .await?;
813                    let policy_epoch = inner
814                        .require_current_wasm_carrier_upgrade_fence(
815                            &attempt.connection_id,
816                            kind,
817                            &attempt.bootstrap.upgrade_id,
818                            attempt.generation,
819                        )
820                        .await?;
821                    let authorization_fence = inner
822                        .capture_wasm_iroh_carrier_authorization_fence(
823                            &attempt.connection_id,
824                            attempt.generation,
825                            kind,
826                            policy_epoch,
827                        )?;
828                    let proof = inner.wasm_candidate_proof_probe(
829                        &attempt.connection_id,
830                        &attempt.bootstrap.upgrade_id,
831                        attempt.bootstrap.base.transport_generation,
832                        attempt.bootstrap.base.route_generation,
833                        kind,
834                        crate::iroh_carrier_kind::EXPERIMENTAL_WEBRTC_TRANSPORT_ID,
835                    )?;
836                    let (send, probe, ack) = inner
837                        .receive_inbound_wasm_carrier_candidate_proof(
838                            &attempt.connection_id,
839                            &candidate,
840                            &proof,
841                        )
842                        .await?;
843                    anyhow::ensure!(
844                        inner
845                            .current_wasm_peer_data_generation(&attempt.connection_id, None)
846                            .await
847                            == Some(attempt.generation),
848                        "browser WebRTC inbound carrier base generation changed before candidate acknowledgement"
849                    );
850                    inner
851                        .send_inbound_wasm_carrier_candidate_ack(send, &ack)
852                        .await?;
853                    let (commit_send, committed) = inner
854                        .receive_inbound_wasm_carrier_commit(
855                            &attempt.connection_id,
856                            &candidate,
857                            &probe,
858                        )
859                        .await?;
860                    let committed_replacement = inner
861                        .commit_proven_wasm_carrier_candidate(
862                            &attempt.connection_id,
863                            &attempt.bootstrap.upgrade_id,
864                            attempt.generation,
865                            kind,
866                            authorization_fence,
867                            candidate,
868                        )
869                        .await?;
870                    inner
871                        .send_inbound_wasm_carrier_candidate_ack(commit_send, &committed)
872                        .await?;
873                    let replacement_transport_stable_id = committed_replacement
874                        .logical_result()
875                        .transport_stable_id
876                        .ok_or_else(|| {
877                            anyhow::anyhow!(
878                                "browser WebRTC replacement has no stable ID"
879                            )
880                        })?;
881                    committed_replacement.finish(b"wasm-custom-transport-upgrade");
882                    inner
883                        .publish_committed_wasm_carrier_route(
884                            &attempt.connection_id,
885                            kind,
886                            replacement_transport_stable_id,
887                        )
888                        .await?;
889                    Ok::<(), anyhow::Error>(())
890                }
891                .await;
892                if let Some(expires_at_ms) = attempt.inbound_authorization_expires_at_ms {
893                    if let Some(node) = inner.iroh_node.read().await.as_ref().cloned() {
894                        node.revoke_inbound_replacement_if_current(
895                            attempt.remote_endpoint_id,
896                            crate::iroh_carrier_kind::EXPERIMENTAL_WEBRTC_TRANSPORT_ID,
897                            expires_at_ms,
898                        )
899                        .await;
900                    }
901                }
902                let _lifecycle = lifecycle.lock().await;
903                if let Err(error) = result {
904                    let failure_code = wasm_carrier_failure_code(&error);
905                    web_sys::console::error_1(&JsValue::from_str(&format!(
906                        "[OpenRTC][WebRTC carrier] inbound candidate completion failed connection_id={} upgrade_id={} failure_code={} error={error:#}",
907                        attempt.connection_id, attempt.bootstrap.upgrade_id, failure_code,
908                    )));
909                    fail_wasm_webrtc_attempt(
910                        inner,
911                        attempts,
912                        sessions,
913                        handler,
914                        attempt,
915                        failure_code,
916                        true,
917                    )
918                    .await;
919                    return;
920                }
921                if !inner
922                    .retire_wasm_carrier_upgrade(
923                        &attempt.connection_id,
924                        kind,
925                        &attempt.bootstrap.upgrade_id,
926                        attempt.generation,
927                    )
928                    .await
929                {
930                    return;
931                }
932                emit_wasm_carrier_action(
933                    &handler,
934                    serde_json::json!({
935                        "type": "selected",
936                        "connectionId": attempt.connection_id,
937                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
938                        "upgradeId": attempt.bootstrap.upgrade_id,
939                        "family": "iroh",
940                        "carrier": "webrtc",
941                        "transportGeneration": attempt.generation.transport_generation.saturating_add(1),
942                        "routeGeneration": 0,
943                    }),
944                );
945            });
946        }
947    }
948
949    #[cfg(feature = "transport-moq")]
950    fn wasm_moq_carrier_namespaces(
951        local_endpoint_id: &str,
952        remote_endpoint_id: &str,
953        carrier_session_id: &str,
954    ) -> (String, String, &'static str) {
955        let (first, second) = if local_endpoint_id <= remote_endpoint_id {
956            (local_endpoint_id, remote_endpoint_id)
957        } else {
958            (remote_endpoint_id, local_endpoint_id)
959        };
960        let base = format!("openrtc/iroh-carrier/moq/{first}/{second}/{carrier_session_id}");
961        (
962            format!("{base}/from/{local_endpoint_id}"),
963            format!("{base}/from/{remote_endpoint_id}"),
964            "iroh-packets",
965        )
966    }
967
968    #[cfg(feature = "transport-moq")]
969    async fn fail_wasm_moq_attempt(
970        inner: Arc<Client>,
971        attempts: Rc<RefCell<HashMap<String, WasmMoqCarrierAttempt>>>,
972        sessions: Rc<RefCell<HashMap<String, crate::wasm_moq_carrier::WasmMoqCarrierSession>>>,
973        handler: Rc<RefCell<Option<js_sys::Function>>>,
974        attempt: WasmMoqCarrierAttempt,
975        failure_code: &'static str,
976        notify_peer: bool,
977    ) {
978        let kind = crate::client::IrohPathKind::Moq;
979        if !inner
980            .retire_wasm_carrier_upgrade(
981                &attempt.connection_id,
982                kind,
983                &attempt.bootstrap.upgrade_id,
984                attempt.generation,
985            )
986            .await
987        {
988            return;
989        }
990        if attempts
991            .borrow()
992            .get(&attempt.connection_id)
993            .is_some_and(|current| current.bootstrap.upgrade_id == attempt.bootstrap.upgrade_id)
994        {
995            attempts.borrow_mut().remove(&attempt.connection_id);
996        }
997        sessions.borrow_mut().remove(&attempt.bootstrap.upgrade_id);
998        if notify_peer {
999            if let Ok(failed) = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::failed_from(
1000                &attempt.bootstrap,
1001                failure_code,
1002            ) {
1003                emit_wasm_carrier_action(
1004                    &handler,
1005                    serde_json::json!({
1006                        "type": "send-control",
1007                        "connectionId": attempt.connection_id,
1008                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
1009                        "envelope": failed,
1010                    }),
1011                );
1012            }
1013        }
1014        emit_wasm_carrier_action(
1015            &handler,
1016            serde_json::json!({
1017                "type": "retire-moq",
1018                "connectionId": attempt.connection_id,
1019                "upgradeId": attempt.bootstrap.upgrade_id,
1020                "failureCode": failure_code,
1021            }),
1022        );
1023        let retry_pending = attempt.role == "initiator"
1024            && attempt.retry_count == 0
1025            && failure_code == "carrier-base-generation-stale";
1026        if attempt.role == "initiator" && !retry_pending {
1027            emit_wasm_carrier_action(
1028                &handler,
1029                serde_json::json!({
1030                    "type": "advance-carrier",
1031                    "connectionId": attempt.connection_id,
1032                    "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
1033                    "failedRoute": "moq",
1034                }),
1035            );
1036        }
1037    }
1038
1039    #[cfg(feature = "transport-moq")]
1040    async fn retire_selected_wasm_moq_carrier(
1041        inner: Arc<Client>,
1042        attempts: Rc<RefCell<HashMap<String, WasmMoqCarrierAttempt>>>,
1043        sessions: Rc<RefCell<HashMap<String, crate::wasm_moq_carrier::WasmMoqCarrierSession>>>,
1044        handler: Rc<RefCell<Option<js_sys::Function>>>,
1045        attempt: &WasmMoqCarrierAttempt,
1046        failure_code: &'static str,
1047        lifecycle_reason: &'static str,
1048    ) -> bool {
1049        if !inner
1050            .close_current_iroh_carrier_generation_with_reason(
1051                &attempt.connection_id,
1052                attempt.remote_endpoint_id,
1053                crate::client::IrohPathKind::Moq,
1054                attempt.generation.transport_generation.saturating_add(1),
1055                lifecycle_reason,
1056            )
1057            .await
1058        {
1059            return false;
1060        }
1061        web_sys::console::warn_1(&JsValue::from_str(&format!(
1062            "[OpenRTC][MoQ carrier] selected mechanism ended connection_id={} upgrade_id={} failure_code={failure_code}",
1063            attempt.connection_id, attempt.bootstrap.upgrade_id,
1064        )));
1065        if attempts
1066            .borrow()
1067            .get(&attempt.connection_id)
1068            .is_some_and(|current| current.bootstrap.upgrade_id == attempt.bootstrap.upgrade_id)
1069        {
1070            attempts.borrow_mut().remove(&attempt.connection_id);
1071        }
1072        sessions.borrow_mut().remove(&attempt.bootstrap.upgrade_id);
1073        emit_wasm_carrier_action(
1074            &handler,
1075            serde_json::json!({
1076                "type": "retire-moq",
1077                "connectionId": attempt.connection_id,
1078                "upgradeId": attempt.bootstrap.upgrade_id,
1079                "failureCode": failure_code,
1080            }),
1081        );
1082        true
1083    }
1084
1085    #[cfg(feature = "transport-moq")]
1086    impl WasmClient {
1087        fn schedule_wasm_moq_carrier_watchdog(
1088            &self,
1089            bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
1090        ) {
1091            let inner = self.inner.clone();
1092            let attempts = self.wasm_moq_carrier_attempts.clone();
1093            let sessions = self.wasm_moq_carrier_sessions.clone();
1094            let handler = self.wasm_carrier_action_handler.clone();
1095            let lifecycle = self.wasm_carrier_peer_lifecycle.clone();
1096            spawn_local(async move {
1097                gloo_timers::future::sleep(std::time::Duration::from_secs(30)).await;
1098                let _lifecycle = lifecycle.lock().await;
1099                let attempt = attempts
1100                    .borrow()
1101                    .values()
1102                    .find(|attempt| attempt.bootstrap.upgrade_id == bootstrap.upgrade_id)
1103                    .cloned();
1104                if let Some(attempt) = attempt {
1105                    if !inner
1106                        .wasm_carrier_upgrade_is_current(
1107                            &attempt.connection_id,
1108                            crate::client::IrohPathKind::Moq,
1109                            &attempt.bootstrap.upgrade_id,
1110                            attempt.generation,
1111                        )
1112                        .await
1113                    {
1114                        return;
1115                    }
1116                    fail_wasm_moq_attempt(
1117                        inner,
1118                        attempts,
1119                        sessions,
1120                        handler,
1121                        attempt,
1122                        "carrier-timeout",
1123                        true,
1124                    )
1125                    .await;
1126                }
1127            });
1128        }
1129
1130        async fn fail_wasm_moq_carrier_attempt(
1131            &self,
1132            attempt: WasmMoqCarrierAttempt,
1133            failure_code: &'static str,
1134            notify_peer: bool,
1135        ) {
1136            fail_wasm_moq_attempt(
1137                self.inner.clone(),
1138                self.wasm_moq_carrier_attempts.clone(),
1139                self.wasm_moq_carrier_sessions.clone(),
1140                self.wasm_carrier_action_handler.clone(),
1141                attempt,
1142                failure_code,
1143                notify_peer,
1144            )
1145            .await;
1146        }
1147
1148        fn spawn_outbound_wasm_moq_carrier_completion(&self, attempt: WasmMoqCarrierAttempt) {
1149            let inner = self.inner.clone();
1150            let attempts = self.wasm_moq_carrier_attempts.clone();
1151            let sessions = self.wasm_moq_carrier_sessions.clone();
1152            let handler = self.wasm_carrier_action_handler.clone();
1153            let lifecycle = self.wasm_carrier_peer_lifecycle.clone();
1154            spawn_local(async move {
1155                let kind = crate::client::IrohPathKind::Moq;
1156                let result = inner
1157                    .complete_outbound_wasm_carrier_upgrade(
1158                        &attempt.connection_id,
1159                        &attempt.remote_endpoint_id.to_string(),
1160                        &attempt.bootstrap.upgrade_id,
1161                        attempt.generation,
1162                        kind,
1163                    )
1164                    .await;
1165                let _lifecycle = lifecycle.lock().await;
1166                if let Err(error) = result {
1167                    let failure_code = wasm_carrier_failure_code(&error);
1168                    web_sys::console::error_1(&JsValue::from_str(&format!(
1169                        "[OpenRTC][MoQ carrier] outbound candidate completion failed connection_id={} upgrade_id={} failure_code={} error={error:#}",
1170                        attempt.connection_id, attempt.bootstrap.upgrade_id, failure_code,
1171                    )));
1172                    fail_wasm_moq_attempt(
1173                        inner,
1174                        attempts,
1175                        sessions,
1176                        handler,
1177                        attempt,
1178                        failure_code,
1179                        true,
1180                    )
1181                    .await;
1182                    return;
1183                }
1184                if !inner
1185                    .retire_wasm_carrier_upgrade(
1186                        &attempt.connection_id,
1187                        kind,
1188                        &attempt.bootstrap.upgrade_id,
1189                        attempt.generation,
1190                    )
1191                    .await
1192                {
1193                    return;
1194                }
1195                emit_wasm_carrier_action(
1196                    &handler,
1197                    serde_json::json!({
1198                        "type": "selected",
1199                        "connectionId": attempt.connection_id,
1200                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
1201                        "upgradeId": attempt.bootstrap.upgrade_id,
1202                        "family": "iroh",
1203                        "carrier": "moq",
1204                        "transportGeneration": attempt.generation.transport_generation.saturating_add(1),
1205                        "routeGeneration": 0,
1206                    }),
1207                );
1208            });
1209        }
1210
1211        fn spawn_inbound_wasm_moq_carrier_completion(&self, attempt: WasmMoqCarrierAttempt) {
1212            let inner = self.inner.clone();
1213            let attempts = self.wasm_moq_carrier_attempts.clone();
1214            let sessions = self.wasm_moq_carrier_sessions.clone();
1215            let handler = self.wasm_carrier_action_handler.clone();
1216            let lifecycle = self.wasm_carrier_peer_lifecycle.clone();
1217            spawn_local(async move {
1218                let kind = crate::client::IrohPathKind::Moq;
1219                let node = inner.iroh_node.read().await.as_ref().cloned();
1220                let result = async {
1221                    let node = node.ok_or_else(|| anyhow::anyhow!("Iroh node is unavailable"))?;
1222                    let authorization_expires_at_ms = attempt
1223                        .inbound_authorization_expires_at_ms
1224                        .ok_or_else(|| anyhow::anyhow!("browser MoQ inbound authorization is missing"))?;
1225                    let candidate = node
1226                        .wait_for_inbound_replacement_candidate(
1227                            attempt.remote_endpoint_id,
1228                            crate::iroh_carrier_kind::EXPERIMENTAL_MOQ_TRANSPORT_ID,
1229                            authorization_expires_at_ms,
1230                            std::time::Duration::from_secs(40),
1231                        )
1232                        .await?;
1233                    let policy_epoch = inner
1234                        .require_current_wasm_carrier_upgrade_fence(
1235                            &attempt.connection_id,
1236                            kind,
1237                            &attempt.bootstrap.upgrade_id,
1238                            attempt.generation,
1239                        )
1240                        .await?;
1241                    let authorization_fence = inner
1242                        .capture_wasm_iroh_carrier_authorization_fence(
1243                            &attempt.connection_id,
1244                            attempt.generation,
1245                            kind,
1246                            policy_epoch,
1247                        )?;
1248                    let proof = inner.wasm_candidate_proof_probe(
1249                        &attempt.connection_id,
1250                        &attempt.bootstrap.upgrade_id,
1251                        attempt.bootstrap.base.transport_generation,
1252                        attempt.bootstrap.base.route_generation,
1253                        kind,
1254                        crate::iroh_carrier_kind::EXPERIMENTAL_MOQ_TRANSPORT_ID,
1255                    )?;
1256                    let (send, probe, ack) = inner
1257                        .receive_inbound_wasm_carrier_candidate_proof(
1258                            &attempt.connection_id,
1259                            &candidate,
1260                            &proof,
1261                        )
1262                        .await?;
1263                    anyhow::ensure!(
1264                        inner
1265                            .current_wasm_peer_data_generation(&attempt.connection_id, None)
1266                            .await
1267                            == Some(attempt.generation),
1268                        "browser MoQ inbound carrier base generation changed before candidate acknowledgement"
1269                    );
1270                    inner
1271                        .send_inbound_wasm_carrier_candidate_ack(send, &ack)
1272                        .await?;
1273                    let (commit_send, committed) = inner
1274                        .receive_inbound_wasm_carrier_commit(
1275                            &attempt.connection_id,
1276                            &candidate,
1277                            &probe,
1278                        )
1279                        .await?;
1280                    let committed_replacement = inner
1281                        .commit_proven_wasm_carrier_candidate(
1282                            &attempt.connection_id,
1283                            &attempt.bootstrap.upgrade_id,
1284                            attempt.generation,
1285                            kind,
1286                            authorization_fence,
1287                            candidate,
1288                        )
1289                        .await?;
1290                    inner
1291                        .send_inbound_wasm_carrier_candidate_ack(commit_send, &committed)
1292                        .await?;
1293                    let replacement_transport_stable_id = committed_replacement
1294                        .logical_result()
1295                        .transport_stable_id
1296                        .ok_or_else(|| {
1297                            anyhow::anyhow!("browser MoQ replacement has no stable ID")
1298                        })?;
1299                    committed_replacement.finish(b"wasm-custom-transport-upgrade");
1300                    inner
1301                        .publish_committed_wasm_carrier_route(
1302                            &attempt.connection_id,
1303                            kind,
1304                            replacement_transport_stable_id,
1305                        )
1306                        .await?;
1307                    Ok::<(), anyhow::Error>(())
1308                }
1309                .await;
1310                if let Some(expires_at_ms) = attempt.inbound_authorization_expires_at_ms {
1311                    if let Some(node) = inner.iroh_node.read().await.as_ref().cloned() {
1312                        node.revoke_inbound_replacement_if_current(
1313                            attempt.remote_endpoint_id,
1314                            crate::iroh_carrier_kind::EXPERIMENTAL_MOQ_TRANSPORT_ID,
1315                            expires_at_ms,
1316                        )
1317                        .await;
1318                    }
1319                }
1320                let _lifecycle = lifecycle.lock().await;
1321                if let Err(error) = result {
1322                    let failure_code = wasm_carrier_failure_code(&error);
1323                    web_sys::console::error_1(&JsValue::from_str(&format!(
1324                        "[OpenRTC][MoQ carrier] inbound candidate completion failed connection_id={} upgrade_id={} failure_code={} error={error:#}",
1325                        attempt.connection_id, attempt.bootstrap.upgrade_id, failure_code,
1326                    )));
1327                    fail_wasm_moq_attempt(
1328                        inner,
1329                        attempts,
1330                        sessions,
1331                        handler,
1332                        attempt,
1333                        failure_code,
1334                        true,
1335                    )
1336                    .await;
1337                    return;
1338                }
1339                if !inner
1340                    .retire_wasm_carrier_upgrade(
1341                        &attempt.connection_id,
1342                        kind,
1343                        &attempt.bootstrap.upgrade_id,
1344                        attempt.generation,
1345                    )
1346                    .await
1347                {
1348                    return;
1349                }
1350                emit_wasm_carrier_action(
1351                    &handler,
1352                    serde_json::json!({
1353                        "type": "selected",
1354                        "connectionId": attempt.connection_id,
1355                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
1356                        "upgradeId": attempt.bootstrap.upgrade_id,
1357                        "family": "iroh",
1358                        "carrier": "moq",
1359                        "transportGeneration": attempt.generation.transport_generation.saturating_add(1),
1360                        "routeGeneration": 0,
1361                    }),
1362                );
1363            });
1364        }
1365
1366        async fn handle_wasm_moq_bootstrap(
1367            &self,
1368            connection_id: String,
1369            remote_endpoint_id: String,
1370            bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
1371        ) -> Result<bool, JsValue> {
1372            let endpoint_id = remote_endpoint_id
1373                .parse::<iroh::EndpointId>()
1374                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1375            let kind = crate::client::IrohPathKind::Moq;
1376            match bootstrap.action {
1377                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Request => {
1378                    let local_endpoint_id = self
1379                        .inner
1380                        .current_node_id()
1381                        .await
1382                        .ok_or_else(|| JsValue::from_str("local endpoint id is unavailable"))?;
1383                    if local_endpoint_id.as_str() >= remote_endpoint_id.as_str()
1384                        || !self.inner.is_moq_carrier_enabled().await
1385                    {
1386                        return Ok(true);
1387                    }
1388                    if !crate::iroh_connection_policy::custom_carrier_base_allows(
1389                        self.inner.iroh_path_kind(&remote_endpoint_id).await,
1390                        crate::client::IrohPathKind::Moq,
1391                        false,
1392                    ) {
1393                        return Ok(true);
1394                    }
1395                    let base_connection = self
1396                        .inner
1397                        .get_connection(endpoint_id)
1398                        .await
1399                        .ok_or_else(|| JsValue::from_str("MoQ carrier base is unavailable"))?;
1400                    let generation = self
1401                        .inner
1402                        .current_wasm_peer_data_generation(&connection_id, None)
1403                        .await
1404                        .ok_or_else(|| {
1405                            JsValue::from_str("MoQ carrier generation is unavailable")
1406                        })?;
1407                    if crate::transport_generation::for_connection(&base_connection)
1408                        != generation.transport_stable_id
1409                    {
1410                        let failed =
1411                            crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::failed_from(
1412                                &bootstrap,
1413                                "carrier-base-generation-stale",
1414                            )
1415                            .map_err(|error| JsValue::from_str(&error.to_string()))?;
1416                        emit_wasm_carrier_action(
1417                            &self.wasm_carrier_action_handler,
1418                            serde_json::json!({
1419                                "type": "send-control",
1420                                "connectionId": connection_id,
1421                                "remoteEndpointId": remote_endpoint_id,
1422                                "envelope": failed,
1423                            }),
1424                        );
1425                        return Ok(true);
1426                    }
1427                    if !matches!(
1428                        self.inner
1429                            .reserve_wasm_carrier_upgrade(
1430                                &connection_id,
1431                                kind,
1432                                &bootstrap.upgrade_id,
1433                                generation,
1434                            )
1435                            .await,
1436                        crate::client::WasmCarrierUpgradeReservation::Reserved
1437                    ) {
1438                        return Ok(true);
1439                    }
1440                    let node = self
1441                        .inner
1442                        .iroh_node
1443                        .read()
1444                        .await
1445                        .as_ref()
1446                        .cloned()
1447                        .ok_or_else(|| JsValue::from_str("Iroh node is unavailable"))?;
1448                    let authorization_expiry = node
1449                        .authorize_pending_inbound_replacement(
1450                            endpoint_id,
1451                            crate::iroh_carrier_kind::EXPERIMENTAL_MOQ_TRANSPORT_ID,
1452                            std::time::Duration::from_secs(45),
1453                        )
1454                        .await;
1455                    let previous = self.wasm_moq_carrier_attempts.borrow_mut().insert(
1456                        connection_id.clone(),
1457                        WasmMoqCarrierAttempt {
1458                            connection_id: connection_id.clone(),
1459                            remote_endpoint_id: endpoint_id,
1460                            bootstrap: bootstrap.clone(),
1461                            generation,
1462                            role: "responder",
1463                            prepared: false,
1464                            retry_sent: false,
1465                            retry_count: bootstrap.attempt.saturating_sub(1),
1466                            inbound_authorization_expires_at_ms: Some(authorization_expiry),
1467                        },
1468                    );
1469                    if let Some(previous) = previous {
1470                        self.wasm_moq_carrier_sessions
1471                            .borrow_mut()
1472                            .remove(&previous.bootstrap.upgrade_id);
1473                    }
1474                    let (publish_namespace, subscribe_namespace, track_name) =
1475                        wasm_moq_carrier_namespaces(
1476                            &local_endpoint_id,
1477                            &remote_endpoint_id,
1478                            &bootstrap.carrier_session_id,
1479                        );
1480                    emit_wasm_carrier_action(
1481                        &self.wasm_carrier_action_handler,
1482                        serde_json::json!({
1483                            "type": "prepare-moq",
1484                            "connectionId": connection_id,
1485                            "remoteEndpointId": remote_endpoint_id,
1486                            "role": "responder",
1487                            "upgradeId": bootstrap.upgrade_id,
1488                            "carrierSessionId": bootstrap.carrier_session_id,
1489                            "transportGeneration": generation.transport_generation.saturating_add(1),
1490                            "publishNamespace": publish_namespace,
1491                            "subscribeNamespace": subscribe_namespace,
1492                            "trackName": track_name,
1493                        }),
1494                    );
1495                    self.schedule_wasm_moq_carrier_watchdog(bootstrap);
1496                }
1497                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Ready => {
1498                    let attempt = self
1499                        .wasm_moq_carrier_attempts
1500                        .borrow()
1501                        .get(&connection_id)
1502                        .filter(|attempt| bootstrap.is_response_to(&attempt.bootstrap))
1503                        .cloned();
1504                    if let Some(attempt) = attempt {
1505                        emit_wasm_carrier_action(
1506                            &self.wasm_carrier_action_handler,
1507                            serde_json::json!({
1508                                "type": "activate-moq",
1509                                "connectionId": attempt.connection_id,
1510                                "upgradeId": attempt.bootstrap.upgrade_id,
1511                            }),
1512                        );
1513                    }
1514                }
1515                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Failed => {
1516                    let attempt = self
1517                        .wasm_moq_carrier_attempts
1518                        .borrow()
1519                        .get(&connection_id)
1520                        .filter(|attempt| bootstrap.is_response_to(&attempt.bootstrap))
1521                        .cloned();
1522                    if let Some(attempt) = attempt {
1523                        let failure_code = crate::client::wasm_peer_carrier_failure_code(
1524                            bootstrap.failure_code.as_deref(),
1525                        );
1526                        if crate::client::should_rearm_wasm_carrier_event_retry(
1527                            failure_code,
1528                            attempt.role == "initiator",
1529                            false,
1530                        ) {
1531                            if let Some(current) = self
1532                                .wasm_moq_carrier_attempts
1533                                .borrow_mut()
1534                                .get_mut(&connection_id)
1535                                .filter(|current| {
1536                                    current.bootstrap.upgrade_id == attempt.bootstrap.upgrade_id
1537                                })
1538                            {
1539                                // The peer's current base is not admitted yet.
1540                                // Re-arm this exact attempt so the next Rust
1541                                // settlement/capability event resends it through
1542                                // `begin_iroh_moq_carrier_attempt`; do not create
1543                                // a timer or competing lifecycle.
1544                                current.retry_sent = false;
1545                            }
1546                            return Ok(true);
1547                        }
1548                        let should_retry = attempt.role == "initiator"
1549                            && attempt.retry_count == 0
1550                            && failure_code == "carrier-base-generation-stale";
1551                        let remote_endpoint_id = attempt.remote_endpoint_id.to_string();
1552                        self.fail_wasm_moq_carrier_attempt(attempt, failure_code, false)
1553                            .await;
1554                        if should_retry {
1555                            // Retry once on the current admitted base. The MoQ
1556                            // relay and admitted peer control stream are the only
1557                            // resources touched; gateway coordination is not.
1558                            gloo_timers::future::sleep(std::time::Duration::from_millis(500)).await;
1559                            let _ = self
1560                                .begin_iroh_moq_carrier_attempt(
1561                                    connection_id,
1562                                    remote_endpoint_id,
1563                                    1,
1564                                )
1565                                .await;
1566                        }
1567                    }
1568                }
1569            }
1570            Ok(true)
1571        }
1572    }
1573
1574    #[wasm_bindgen]
1575    impl WasmClient {
1576        /// OpenRTC WASM transport constructor. It accepts only the public
1577        /// API key, derives the app identity locally, and never initializes a
1578        /// Firebase project or performs network work.
1579        #[wasm_bindgen(constructor)]
1580        pub fn new(api_key: String) -> Result<WasmClient, JsValue> {
1581            let api_key = crate::validate_api_key(&api_key)
1582                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1583            let app_tag = crate::app_tag_from_api_key(api_key);
1584            let identity_credential: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
1585            let credential_state = identity_credential.clone();
1586            let identity_credential_provider = Box::new(move || -> Option<String> {
1587                credential_state.lock().ok().and_then(|guard| guard.clone())
1588            });
1589
1590            Ok(Self {
1591                inner: Arc::new(Client::new_provider_neutral(
1592                    app_tag,
1593                    identity_credential_provider,
1594                )),
1595                portable_media: RefCell::new(crate::media::PortableMediaSession::default()),
1596                broadcast_sessions: RefCell::new(HashMap::new()),
1597                broadcast_signers: RefCell::new(HashMap::new()),
1598                ticket_meshes: RefCell::new(HashMap::new()),
1599                ticket_mesh_starts: RefCell::new(HashSet::new()),
1600                identity_credential,
1601                last_auth_log: Arc::new(Mutex::new(None)),
1602                #[cfg(feature = "iroh-protocols-wasm")]
1603                persistent_protocols: Rc::new(tokio::sync::Mutex::new(None)),
1604                #[cfg(feature = "managed-group-encryption")]
1605                managed_group_controllers: Rc::new(RefCell::new(HashMap::new())),
1606                #[cfg(feature = "transport-webrtc")]
1607                wasm_webrtc_carrier_sessions: Rc::new(RefCell::new(HashMap::new())),
1608                #[cfg(feature = "transport-webrtc")]
1609                wasm_webrtc_carrier_attempts: Rc::new(RefCell::new(HashMap::new())),
1610                #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
1611                wasm_carrier_action_handler: Rc::new(RefCell::new(None)),
1612                #[cfg(feature = "transport-moq")]
1613                wasm_moq_carrier_sessions: Rc::new(RefCell::new(HashMap::new())),
1614                #[cfg(feature = "transport-moq")]
1615                wasm_moq_carrier_attempts: Rc::new(RefCell::new(HashMap::new())),
1616                #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
1617                wasm_remote_carrier_capabilities: Rc::new(RefCell::new(HashMap::new())),
1618                #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
1619                wasm_carrier_peer_lifecycle: Rc::new(tokio::sync::Mutex::new(())),
1620            })
1621        }
1622
1623        /// Private managed-room bridge. The browser host supplies an opaque
1624        /// persisted snapshot and a wrapping key recovered through its
1625        /// non-extractable WebCrypto store; public consumers never see either.
1626        #[cfg(feature = "managed-group-encryption")]
1627        #[wasm_bindgen(js_name = __initManagedRoomGroup)]
1628        pub fn init_managed_room_group(
1629            &self,
1630            avenue_key: String,
1631            device_id: String,
1632            wrapping_key: Vec<u8>,
1633            sealed_state: Option<Vec<u8>>,
1634        ) -> Result<JsValue, JsValue> {
1635            let wrapping_key: [u8; 32] = wrapping_key
1636                .try_into()
1637                .map_err(|_| JsValue::from_str("managed room wrapping key must be 32 bytes"))?;
1638            let mut controllers = self.managed_group_controllers.borrow_mut();
1639            if !controllers.contains_key(&avenue_key) {
1640                let controller = crate::managed_group_controller::ManagedGroupController::new(
1641                    device_id,
1642                    wrapping_key,
1643                    sealed_state.as_deref(),
1644                )
1645                .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
1646                controllers.insert(avenue_key.clone(), controller);
1647            }
1648            let controller = controllers
1649                .get(&avenue_key)
1650                .ok_or_else(|| JsValue::from_str("managed room group initialization failed"))?;
1651            let action = controller
1652                .publish_key_package()
1653                .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
1654            serde::Serialize::serialize(&action, &serde_wasm_bindgen::Serializer::json_compatible())
1655                .map_err(|error| JsValue::from_str(&error.to_string()))
1656        }
1657
1658        #[cfg(feature = "managed-group-encryption")]
1659        #[wasm_bindgen(js_name = __handleManagedRoomPreparePage)]
1660        pub fn handle_managed_room_prepare_page(
1661            &self,
1662            avenue_key: String,
1663            page: JsValue,
1664        ) -> Result<JsValue, JsValue> {
1665            let page = serde_wasm_bindgen::from_value(page)
1666                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1667            let mut controllers = self.managed_group_controllers.borrow_mut();
1668            let controller = controllers
1669                .get_mut(&avenue_key)
1670                .ok_or_else(|| JsValue::from_str("managed room group is not initialized"))?;
1671            let actions = controller
1672                .handle_prepare_page(page)
1673                .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
1674            serde::Serialize::serialize(
1675                &actions,
1676                &serde_wasm_bindgen::Serializer::json_compatible(),
1677            )
1678            .map_err(|error| JsValue::from_str(&error.to_string()))
1679        }
1680
1681        #[cfg(feature = "managed-group-encryption")]
1682        #[wasm_bindgen(js_name = __handleManagedRoomArtifactChunk)]
1683        pub fn handle_managed_room_artifact_chunk(
1684            &self,
1685            avenue_key: String,
1686            chunk: JsValue,
1687        ) -> Result<JsValue, JsValue> {
1688            let chunk = serde_wasm_bindgen::from_value(chunk)
1689                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1690            let mut controllers = self.managed_group_controllers.borrow_mut();
1691            let controller = controllers
1692                .get_mut(&avenue_key)
1693                .ok_or_else(|| JsValue::from_str("managed room group is not initialized"))?;
1694            let actions = controller
1695                .handle_artifact_chunk(chunk)
1696                .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
1697            serde::Serialize::serialize(
1698                &actions,
1699                &serde_wasm_bindgen::Serializer::json_compatible(),
1700            )
1701            .map_err(|error| JsValue::from_str(&error.to_string()))
1702        }
1703
1704        #[cfg(feature = "managed-group-encryption")]
1705        #[wasm_bindgen(js_name = __sealManagedRoomPayload)]
1706        #[allow(clippy::too_many_arguments)]
1707        pub fn seal_managed_room_payload(
1708            &self,
1709            avenue_key: String,
1710            architecture_epoch: u64,
1711            encryption_epoch: u64,
1712            message_id: String,
1713            channel: String,
1714            priority: u8,
1715            zone_id: Option<String>,
1716            payload: Vec<u8>,
1717        ) -> Result<JsValue, JsValue> {
1718            let protected = self
1719                .managed_group_controllers
1720                .borrow_mut()
1721                .get_mut(&avenue_key)
1722                .ok_or_else(|| JsValue::from_str("managed room group is not initialized"))?
1723                .seal_payload(
1724                    architecture_epoch,
1725                    encryption_epoch,
1726                    &message_id,
1727                    &channel,
1728                    priority,
1729                    zone_id.as_deref(),
1730                    &payload,
1731                )
1732                .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
1733            serde_wasm_bindgen::to_value(&serde_json::json!({
1734                "payload": base64::Engine::encode(
1735                    &base64::engine::general_purpose::URL_SAFE_NO_PAD,
1736                    protected.data,
1737                ),
1738                "sealedState": base64::Engine::encode(
1739                    &base64::engine::general_purpose::URL_SAFE_NO_PAD,
1740                    protected.sealed_state,
1741                ),
1742            }))
1743            .map_err(|error| JsValue::from_str(&error.to_string()))
1744        }
1745
1746        #[cfg(feature = "managed-group-encryption")]
1747        #[wasm_bindgen(js_name = __openManagedRoomPayload)]
1748        #[allow(clippy::too_many_arguments)]
1749        pub fn open_managed_room_payload(
1750            &self,
1751            avenue_key: String,
1752            architecture_epoch: u64,
1753            encryption_epoch: u64,
1754            message_id: String,
1755            channel: String,
1756            priority: u8,
1757            zone_id: Option<String>,
1758            ciphertext: Vec<u8>,
1759        ) -> Result<JsValue, JsValue> {
1760            let protected = self
1761                .managed_group_controllers
1762                .borrow_mut()
1763                .get_mut(&avenue_key)
1764                .ok_or_else(|| JsValue::from_str("managed room group is not initialized"))?
1765                .open_payload(
1766                    architecture_epoch,
1767                    encryption_epoch,
1768                    &message_id,
1769                    &channel,
1770                    priority,
1771                    zone_id.as_deref(),
1772                    &ciphertext,
1773                )
1774                .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
1775            serde_wasm_bindgen::to_value(&serde_json::json!({
1776                "payload": base64::Engine::encode(
1777                    &base64::engine::general_purpose::URL_SAFE_NO_PAD,
1778                    protected.data,
1779                ),
1780                "sealedState": base64::Engine::encode(
1781                    &base64::engine::general_purpose::URL_SAFE_NO_PAD,
1782                    protected.sealed_state,
1783                ),
1784            }))
1785            .map_err(|error| JsValue::from_str(&error.to_string()))
1786        }
1787
1788        #[cfg(feature = "managed-group-encryption")]
1789        #[wasm_bindgen(js_name = __forgetManagedRoomGroup)]
1790        pub fn forget_managed_room_group(&self, avenue_key: String) {
1791            self.managed_group_controllers
1792                .borrow_mut()
1793                .remove(&avenue_key);
1794        }
1795
1796        /// Request one public, one-shot device-binding challenge from the
1797        /// shared lifecycle owner. JavaScript may pass these bytes to the
1798        /// existing sign-only device-key callback, but never receives the key.
1799        #[wasm_bindgen(js_name = __prepareOpenRtcBroadcastGrant)]
1800        pub fn prepare_openrtc_broadcast_grant(
1801            &self,
1802            grant_token: String,
1803            issuer_public_key: Vec<u8>,
1804            now_ms: f64,
1805        ) -> Result<JsValue, JsValue> {
1806            let issuer_public_key: [u8; 32] = issuer_public_key
1807                .try_into()
1808                .map_err(|_| JsValue::from_str("broadcast issuer key must be 32 bytes"))?;
1809            let issuer = ed25519_dalek::VerifyingKey::from_bytes(&issuer_public_key)
1810                .map_err(|_| JsValue::from_str("broadcast issuer key is invalid"))?;
1811            if !now_ms.is_finite()
1812                || now_ms < 0.0
1813                || now_ms.fract() != 0.0
1814                || now_ms > crate::media::MAX_JAVASCRIPT_SAFE_INTEGER as f64
1815            {
1816                return Err(JsValue::from_str(
1817                    "broadcast now must be a non-negative JavaScript-safe integer",
1818                ));
1819            }
1820            let challenge = self
1821                .inner
1822                .broadcasts()
1823                .prepare_grant_verification(&grant_token, &issuer, now_ms as u64)
1824                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1825            let object = js_sys::Object::new();
1826            js_sys::Reflect::set(
1827                &object,
1828                &JsValue::from_str("handle"),
1829                &JsValue::from_str(&challenge.handle),
1830            )?;
1831            js_sys::Reflect::set(
1832                &object,
1833                &JsValue::from_str("signingBytes"),
1834                &js_sys::Uint8Array::from(challenge.signing_bytes.as_slice()),
1835            )?;
1836            js_sys::Reflect::set(
1837                &object,
1838                &JsValue::from_str("expiresAtMs"),
1839                &JsValue::from_f64(challenge.expires_at_ms as f64),
1840            )?;
1841            Ok(object.into())
1842        }
1843
1844        /// Internal browser adapter boundary. Authorization and lifecycle are
1845        /// still decided by the shared Rust core; JavaScript receives only the
1846        /// mechanics commands for the admitted generation.
1847        #[wasm_bindgen(js_name = __openOpenRtcBroadcast)]
1848        pub fn open_openrtc_broadcast(
1849            &self,
1850            grant_token: String,
1851            issuer_public_key: Vec<u8>,
1852            binding_challenge_handle: String,
1853            binding_signature: Vec<u8>,
1854            publication_signer_handle: Option<String>,
1855            now_ms: f64,
1856        ) -> Result<JsValue, JsValue> {
1857            let issuer_public_key: [u8; 32] = issuer_public_key
1858                .try_into()
1859                .map_err(|_| JsValue::from_str("broadcast issuer key must be 32 bytes"))?;
1860            let issuer = ed25519_dalek::VerifyingKey::from_bytes(&issuer_public_key)
1861                .map_err(|_| JsValue::from_str("broadcast issuer key is invalid"))?;
1862            if !now_ms.is_finite()
1863                || now_ms < 0.0
1864                || now_ms.fract() != 0.0
1865                || now_ms > crate::media::MAX_JAVASCRIPT_SAFE_INTEGER as f64
1866            {
1867                return Err(JsValue::from_str(
1868                    "broadcast now must be a non-negative JavaScript-safe integer",
1869                ));
1870            }
1871            let now_ms = now_ms as u64;
1872            let grant = self
1873                .inner
1874                .broadcasts()
1875                .complete_grant_verification(
1876                    &grant_token,
1877                    &issuer,
1878                    &binding_challenge_handle,
1879                    &binding_signature,
1880                    now_ms,
1881                )
1882                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1883            let grant_generation = grant.grant_generation();
1884            let session = match publication_signer_handle {
1885                Some(handle) => {
1886                    let signers = self.broadcast_signers.borrow();
1887                    let signer = signers
1888                        .get(&handle)
1889                        .ok_or_else(|| JsValue::from_str("broadcast signer handle is invalid"))?;
1890                    self.inner
1891                        .broadcasts()
1892                        .open_publisher(grant, signer, now_ms)
1893                }
1894                None => self.inner.broadcasts().open(grant, now_ms),
1895            }
1896            .map_err(|error| JsValue::from_str(&error.to_string()))?;
1897            let handle_id = format!("{}:{grant_generation}", session.id());
1898            let result = serde_json::json!({
1899                "id": session.id(),
1900                "handleId": handle_id,
1901                "role": session.role(),
1902                "state": session.state(),
1903                "budget": session.budget(),
1904                "sourceSlots": session.source_slots(),
1905            });
1906            self.broadcast_sessions
1907                .borrow_mut()
1908                .insert(handle_id, session);
1909            serde::Serialize::serialize(&result, &serde_wasm_bindgen::Serializer::json_compatible())
1910                .map_err(|error| JsValue::from_str(&error.to_string()))
1911        }
1912
1913        /// Create a Rust/WASM-owned sign-only handle before requesting a
1914        /// publisher grant. JavaScript receives only the opaque handle and
1915        /// public key; private key bytes never cross the wasm-bindgen ABI.
1916        #[wasm_bindgen(js_name = __createOpenRtcBroadcastPublisherSigner)]
1917        pub fn create_openrtc_broadcast_publisher_signer(&self) -> Result<JsValue, JsValue> {
1918            let signer = crate::broadcast::BroadcastPublisherSigner::generate()
1919                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1920            let mut handle_bytes = [0_u8; 16];
1921            getrandom::getrandom(&mut handle_bytes)
1922                .map_err(|_| JsValue::from_str("broadcast signer handle entropy failed"))?;
1923            let handle = hex::encode(handle_bytes);
1924            let public_key = signer.verifying_key();
1925            let object = js_sys::Object::new();
1926            js_sys::Reflect::set(
1927                &object,
1928                &JsValue::from_str("handle"),
1929                &JsValue::from_str(&handle),
1930            )?;
1931            js_sys::Reflect::set(
1932                &object,
1933                &JsValue::from_str("publicKey"),
1934                &js_sys::Uint8Array::from(public_key.as_slice()),
1935            )?;
1936            self.broadcast_signers.borrow_mut().insert(handle, signer);
1937            Ok(object.into())
1938        }
1939
1940        #[wasm_bindgen(js_name = __releaseOpenRtcBroadcastPublisherSigner)]
1941        pub fn release_openrtc_broadcast_publisher_signer(&self, handle: String) -> bool {
1942            self.broadcast_signers
1943                .borrow_mut()
1944                .remove(&handle)
1945                .is_some()
1946        }
1947
1948        #[wasm_bindgen(js_name = __takeOpenRtcBroadcastActions)]
1949        pub fn take_openrtc_broadcast_actions(
1950            &self,
1951            handle_id: String,
1952            max: usize,
1953        ) -> Result<JsValue, JsValue> {
1954            let sessions = self.broadcast_sessions.borrow();
1955            let session = sessions
1956                .get(&handle_id)
1957                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?;
1958            serde::Serialize::serialize(
1959                &session.take_actions(max),
1960                &serde_wasm_bindgen::Serializer::json_compatible(),
1961            )
1962            .map_err(|error| JsValue::from_str(&error.to_string()))
1963        }
1964
1965        #[wasm_bindgen(js_name = __getOpenRtcBroadcastStats)]
1966        pub fn get_openrtc_broadcast_stats(&self, handle_id: String) -> Result<JsValue, JsValue> {
1967            let sessions = self.broadcast_sessions.borrow();
1968            let stats = sessions
1969                .get(&handle_id)
1970                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?
1971                .stats();
1972            serde::Serialize::serialize(&stats, &serde_wasm_bindgen::Serializer::json_compatible())
1973                .map_err(|error| JsValue::from_str(&error.to_string()))
1974        }
1975
1976        #[wasm_bindgen(js_name = __observeOpenRtcBroadcastAdapter)]
1977        pub fn observe_openrtc_broadcast_adapter(
1978            &self,
1979            handle_id: String,
1980            observation: JsValue,
1981        ) -> Result<(), JsValue> {
1982            let observation = serde_wasm_bindgen::from_value(observation)
1983                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1984            self.broadcast_sessions
1985                .borrow()
1986                .get(&handle_id)
1987                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?
1988                .observe(observation)
1989                .map_err(|error| JsValue::from_str(&error.to_string()))
1990        }
1991
1992        #[wasm_bindgen(js_name = __closeOpenRtcBroadcast)]
1993        pub fn close_openrtc_broadcast(&self, handle_id: String) -> Result<(), JsValue> {
1994            let session = self
1995                .broadcast_sessions
1996                .borrow_mut()
1997                .remove(&handle_id)
1998                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?;
1999            session.close();
2000            Ok(())
2001        }
2002
2003        /// Admit one browser publication through the broadcast role/source
2004        /// owner. Codec work stays at the existing browser media edge.
2005        #[wasm_bindgen(js_name = __beginOpenRtcBroadcastPublication)]
2006        #[allow(clippy::too_many_arguments)]
2007        pub fn begin_openrtc_broadcast_publication(
2008            &self,
2009            handle_id: String,
2010            source_slot: String,
2011            publication_id: Option<String>,
2012            kind: String,
2013            codec: String,
2014            clock_rate: u32,
2015            coded_width: Option<u32>,
2016            coded_height: Option<u32>,
2017            channels: Option<u16>,
2018        ) -> Result<JsValue, JsValue> {
2019            let publication_id = match publication_id {
2020                Some(value) if !value.is_empty() => {
2021                    value
2022                        .parse()
2023                        .map_err(|error: crate::media::MediaProtocolError| {
2024                            JsValue::from_str(&error.to_string())
2025                        })?
2026                }
2027                _ => {
2028                    let mut bytes = [0_u8; 16];
2029                    getrandom::getrandom(&mut bytes)
2030                        .map_err(|_| JsValue::from_str("media publication entropy failed"))?;
2031                    crate::media::PublicationId::from_bytes(bytes)
2032                }
2033            };
2034            let kind: crate::media::MediaKind =
2035                kind.parse()
2036                    .map_err(|error: crate::media::MediaProtocolError| {
2037                        JsValue::from_str(&error.to_string())
2038                    })?;
2039            if !matches!(
2040                kind,
2041                crate::media::MediaKind::Audio | crate::media::MediaKind::Video
2042            ) {
2043                return Err(JsValue::from_str(
2044                    "browser broadcast publications must be audio or video",
2045                ));
2046            }
2047            let codec: crate::media::MediaCodec =
2048                codec
2049                    .parse()
2050                    .map_err(|error: crate::media::MediaProtocolError| {
2051                        JsValue::from_str(&error.to_string())
2052                    })?;
2053            let publication = self
2054                .broadcast_sessions
2055                .borrow()
2056                .get(&handle_id)
2057                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?
2058                .begin_publication(
2059                    &source_slot,
2060                    crate::media::MediaPublicationConfig {
2061                        publication_id,
2062                        media_generation: 0,
2063                        kind,
2064                        codec,
2065                        clock_rate,
2066                        coded_width,
2067                        coded_height,
2068                        channels,
2069                    },
2070                )
2071                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2072            serde::Serialize::serialize(
2073                &serde_json::json!({
2074                    "publicationId": publication.publication_id.to_string(),
2075                    "mediaGeneration": publication.media_generation,
2076                    "control": Vec::<u8>::new(),
2077                }),
2078                &serde_wasm_bindgen::Serializer::json_compatible(),
2079            )
2080            .map_err(|error| JsValue::from_str(&error.to_string()))
2081        }
2082
2083        /// Queue one encoded browser sample only after Rust role, generation,
2084        /// source, signature, and hard-backpressure checks succeed.
2085        #[wasm_bindgen(js_name = __publishOpenRtcBroadcastSample)]
2086        #[allow(clippy::too_many_arguments)]
2087        pub fn publish_openrtc_broadcast_sample(
2088            &self,
2089            handle_id: String,
2090            publication_id: String,
2091            timestamp_us: f64,
2092            duration_us: u32,
2093            keyframe: bool,
2094            discardable: bool,
2095            payload: Vec<u8>,
2096        ) -> Result<(), JsValue> {
2097            if !timestamp_us.is_finite()
2098                || timestamp_us < 0.0
2099                || timestamp_us.fract() != 0.0
2100                || timestamp_us > crate::media::MAX_JAVASCRIPT_SAFE_INTEGER as f64
2101            {
2102                return Err(JsValue::from_str(
2103                    "media timestamp must be a non-negative JavaScript-safe integer",
2104                ));
2105            }
2106            let publication_id =
2107                publication_id
2108                    .parse()
2109                    .map_err(|error: crate::media::MediaProtocolError| {
2110                        JsValue::from_str(&error.to_string())
2111                    })?;
2112            self.broadcast_sessions
2113                .borrow()
2114                .get(&handle_id)
2115                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?
2116                .publish(
2117                    publication_id,
2118                    crate::media::EncodedMediaSample {
2119                        timestamp_us: timestamp_us as u64,
2120                        duration_us,
2121                        keyframe,
2122                        discardable,
2123                        payload,
2124                    },
2125                )
2126                .map_err(|error| JsValue::from_str(&error.to_string()))
2127        }
2128
2129        #[wasm_bindgen(js_name = __retireOpenRtcBroadcastPublication)]
2130        pub fn retire_openrtc_broadcast_publication(
2131            &self,
2132            handle_id: String,
2133            publication_id: String,
2134        ) -> Result<(), JsValue> {
2135            let publication_id =
2136                publication_id
2137                    .parse()
2138                    .map_err(|error: crate::media::MediaProtocolError| {
2139                        JsValue::from_str(&error.to_string())
2140                    })?;
2141            self.broadcast_sessions
2142                .borrow()
2143                .get(&handle_id)
2144                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?
2145                .retire_publication(publication_id)
2146                .map_err(|error| JsValue::from_str(&error.to_string()))
2147        }
2148
2149        #[wasm_bindgen(js_name = __pauseOpenRtcBroadcastPublication)]
2150        pub fn pause_openrtc_broadcast_publication(
2151            &self,
2152            handle_id: String,
2153            publication_id: String,
2154        ) -> Result<(), JsValue> {
2155            let publication_id =
2156                publication_id
2157                    .parse()
2158                    .map_err(|error: crate::media::MediaProtocolError| {
2159                        JsValue::from_str(&error.to_string())
2160                    })?;
2161            self.broadcast_sessions
2162                .borrow()
2163                .get(&handle_id)
2164                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?
2165                .pause_publication(publication_id)
2166                .map_err(|error| JsValue::from_str(&error.to_string()))
2167        }
2168
2169        /// Verify source authorization, signature, generation, integrity, and
2170        /// replay before portable bytes reach the browser decoder.
2171        #[wasm_bindgen(js_name = __acceptOpenRtcBroadcastObject)]
2172        pub fn accept_openrtc_broadcast_object(
2173            &self,
2174            handle_id: String,
2175            source_slot: String,
2176            encoded: Vec<u8>,
2177        ) -> Result<JsValue, JsValue> {
2178            let accepted = self
2179                .broadcast_sessions
2180                .borrow()
2181                .get(&handle_id)
2182                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?
2183                .accept_media_for_source(&source_slot, &encoded)
2184                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2185            serde::Serialize::serialize(
2186                &accepted,
2187                &serde_wasm_bindgen::Serializer::json_compatible(),
2188            )
2189            .map_err(|error| JsValue::from_str(&error.to_string()))
2190        }
2191
2192        /// Begin or replace one browser publication in the shared Rust media
2193        /// state machine. Publication IDs remain stable while Rust alone
2194        /// advances media generation and resets sequence state.
2195        #[wasm_bindgen(js_name = __beginOpenRtcMediaPublication)]
2196        #[allow(clippy::too_many_arguments)]
2197        pub fn begin_openrtc_media_publication(
2198            &self,
2199            publication_id: Option<String>,
2200            kind: String,
2201            codec: String,
2202            clock_rate: u32,
2203            coded_width: Option<u32>,
2204            coded_height: Option<u32>,
2205            channels: Option<u16>,
2206        ) -> Result<JsValue, JsValue> {
2207            let publication_id = match publication_id {
2208                Some(value) if !value.is_empty() => {
2209                    value
2210                        .parse()
2211                        .map_err(|error: crate::media::MediaProtocolError| {
2212                            JsValue::from_str(&error.to_string())
2213                        })?
2214                }
2215                _ => {
2216                    let mut bytes = [0_u8; 16];
2217                    getrandom::getrandom(&mut bytes)
2218                        .map_err(|_| JsValue::from_str("media publication entropy failed"))?;
2219                    crate::media::PublicationId::from_bytes(bytes)
2220                }
2221            };
2222            let kind: crate::media::MediaKind =
2223                kind.parse()
2224                    .map_err(|error: crate::media::MediaProtocolError| {
2225                        JsValue::from_str(&error.to_string())
2226                    })?;
2227            if !matches!(
2228                kind,
2229                crate::media::MediaKind::Audio | crate::media::MediaKind::Video
2230            ) {
2231                return Err(JsValue::from_str(
2232                    "browser media publications must be audio or video",
2233                ));
2234            }
2235            let codec: crate::media::MediaCodec =
2236                codec
2237                    .parse()
2238                    .map_err(|error: crate::media::MediaProtocolError| {
2239                        JsValue::from_str(&error.to_string())
2240                    })?;
2241            let publication = crate::media::MediaPublicationConfig {
2242                publication_id,
2243                media_generation: 0,
2244                kind,
2245                codec,
2246                clock_rate,
2247                coded_width,
2248                coded_height,
2249                channels,
2250            };
2251            let (publication, control) = self
2252                .portable_media
2253                .borrow_mut()
2254                .begin_publication(publication)
2255                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2256
2257            let object = js_sys::Object::new();
2258            js_sys::Reflect::set(
2259                &object,
2260                &JsValue::from_str("publicationId"),
2261                &JsValue::from_str(&publication_id.to_string()),
2262            )?;
2263            js_sys::Reflect::set(
2264                &object,
2265                &JsValue::from_str("mediaGeneration"),
2266                &JsValue::from_f64(publication.media_generation as f64),
2267            )?;
2268            js_sys::Reflect::set(
2269                &object,
2270                &JsValue::from_str("control"),
2271                &js_sys::Uint8Array::from(control.as_slice()),
2272            )?;
2273            Ok(object.into())
2274        }
2275
2276        /// Encode one browser-produced sample using Rust-owned publication,
2277        /// generation, codec, and sequence state.
2278        #[wasm_bindgen(js_name = __encodeOpenRtcMediaSample)]
2279        pub fn encode_openrtc_media_sample(
2280            &self,
2281            publication_id: String,
2282            timestamp_us: f64,
2283            duration_us: u32,
2284            keyframe: bool,
2285            discardable: bool,
2286            payload: Vec<u8>,
2287        ) -> Result<Vec<u8>, JsValue> {
2288            fn safe_u64(value: f64, field: &str) -> Result<u64, JsValue> {
2289                if !value.is_finite()
2290                    || value < 0.0
2291                    || value.fract() != 0.0
2292                    || value > crate::media::MAX_JAVASCRIPT_SAFE_INTEGER as f64
2293                {
2294                    return Err(JsValue::from_str(&format!(
2295                        "media {field} must be a non-negative JavaScript-safe integer"
2296                    )));
2297                }
2298                Ok(value as u64)
2299            }
2300            let publication_id: crate::media::PublicationId =
2301                publication_id
2302                    .parse()
2303                    .map_err(|error: crate::media::MediaProtocolError| {
2304                        JsValue::from_str(&error.to_string())
2305                    })?;
2306            self.portable_media
2307                .borrow_mut()
2308                .encode_sample(
2309                    publication_id,
2310                    crate::media::EncodedMediaSample {
2311                        timestamp_us: safe_u64(timestamp_us, "timestamp")?,
2312                        duration_us,
2313                        keyframe,
2314                        discardable,
2315                        payload,
2316                    },
2317                )
2318                .map_err(|error| JsValue::from_str(&error.to_string()))
2319        }
2320
2321        #[wasm_bindgen(js_name = __pauseOpenRtcMediaPublication)]
2322        pub fn pause_openrtc_media_publication(
2323            &self,
2324            publication_id: String,
2325        ) -> Result<(), JsValue> {
2326            let publication_id: crate::media::PublicationId =
2327                publication_id
2328                    .parse()
2329                    .map_err(|error: crate::media::MediaProtocolError| {
2330                        JsValue::from_str(&error.to_string())
2331                    })?;
2332            self.portable_media
2333                .borrow_mut()
2334                .pause_publication(publication_id)
2335                .map_err(|error| JsValue::from_str(&error.to_string()))
2336        }
2337
2338        #[wasm_bindgen(js_name = __retireOpenRtcMediaPublication)]
2339        pub fn retire_openrtc_media_publication(
2340            &self,
2341            publication_id: String,
2342        ) -> Result<(), JsValue> {
2343            let publication_id: crate::media::PublicationId =
2344                publication_id
2345                    .parse()
2346                    .map_err(|error: crate::media::MediaProtocolError| {
2347                        JsValue::from_str(&error.to_string())
2348                    })?;
2349            self.portable_media
2350                .borrow_mut()
2351                .retire_publication(publication_id);
2352            Ok(())
2353        }
2354
2355        #[wasm_bindgen(js_name = __retireOpenRtcMediaReceiver)]
2356        pub fn retire_openrtc_media_receiver(
2357            &self,
2358            publication_id: String,
2359            media_generation: u32,
2360        ) -> Result<(), JsValue> {
2361            let publication_id: crate::media::PublicationId =
2362                publication_id
2363                    .parse()
2364                    .map_err(|error: crate::media::MediaProtocolError| {
2365                        JsValue::from_str(&error.to_string())
2366                    })?;
2367            self.portable_media
2368                .borrow_mut()
2369                .retire_receiver(publication_id, media_generation);
2370            Ok(())
2371        }
2372
2373        /// Decode and replay-check one portable media chunk. A duplicate,
2374        /// retired generation, length mismatch, or integrity failure is
2375        /// rejected before browser decoders can observe the payload.
2376        #[wasm_bindgen(js_name = __decodeOpenRtcMediaChunk)]
2377        pub fn decode_openrtc_media_chunk(&self, encoded: Vec<u8>) -> Result<JsValue, JsValue> {
2378            let chunk = self
2379                .portable_media
2380                .borrow_mut()
2381                .decode_chunk(&encoded)
2382                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2383            crate::media::ensure_javascript_safe_integer(chunk.sequence, "sequence")
2384                .and_then(|_| {
2385                    crate::media::ensure_javascript_safe_integer(chunk.timestamp_us, "timestamp")
2386                })
2387                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2388
2389            let object = js_sys::Object::new();
2390            let set = |name: &str, value: JsValue| -> Result<(), JsValue> {
2391                js_sys::Reflect::set(&object, &JsValue::from_str(name), &value).map(|_| ())
2392            };
2393            set(
2394                "publicationId",
2395                JsValue::from_str(&chunk.publication_id.to_string()),
2396            )?;
2397            set(
2398                "mediaGeneration",
2399                JsValue::from_f64(chunk.media_generation as f64),
2400            )?;
2401            set("sequence", JsValue::from_f64(chunk.sequence as f64))?;
2402            set("timestampUs", JsValue::from_f64(chunk.timestamp_us as f64))?;
2403            set("durationUs", JsValue::from_f64(chunk.duration_us as f64))?;
2404            set(
2405                "kind",
2406                JsValue::from_str(match chunk.kind {
2407                    crate::media::MediaKind::Audio => "audio",
2408                    crate::media::MediaKind::Video => "video",
2409                    crate::media::MediaKind::Screen => "screen",
2410                    crate::media::MediaKind::Data => "data",
2411                }),
2412            )?;
2413            set(
2414                "codec",
2415                JsValue::from_str(match chunk.codec {
2416                    crate::media::MediaCodec::Opus => "opus",
2417                    crate::media::MediaCodec::H264 => "h264",
2418                    crate::media::MediaCodec::Vp8 => "vp8",
2419                    crate::media::MediaCodec::Vp9 => "vp9",
2420                    crate::media::MediaCodec::Av1 => "av1",
2421                    crate::media::MediaCodec::Pcm => "pcm",
2422                    crate::media::MediaCodec::Opaque => "opaque",
2423                }),
2424            )?;
2425            set("keyframe", JsValue::from_bool(chunk.keyframe))?;
2426            set("discardable", JsValue::from_bool(chunk.discardable))?;
2427            set(
2428                "payload",
2429                js_sys::Uint8Array::from(chunk.payload.as_slice()).into(),
2430            )?;
2431            Ok(object.into())
2432        }
2433
2434        #[wasm_bindgen(js_name = __decodeOpenRtcMediaControl)]
2435        pub fn decode_openrtc_media_control(&self, encoded: Vec<u8>) -> Result<JsValue, JsValue> {
2436            let control = self
2437                .portable_media
2438                .borrow_mut()
2439                .decode_control(&encoded)
2440                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2441            // Keep the WASM boundary explicit and browser-friendly. The Rust
2442            // wire representation is allowed to evolve independently of the
2443            // TypeScript property convention, and PublicationId must never
2444            // depend on newtype-array serde behavior in JavaScript.
2445            let portable = match control {
2446                crate::media::MediaControlFrame::Publish {
2447                    publication_id,
2448                    media_generation,
2449                    kind,
2450                    codec,
2451                    clock_rate,
2452                    coded_width,
2453                    coded_height,
2454                    channels,
2455                } => serde_json::json!({
2456                    "type": "publish",
2457                    "publicationId": publication_id.to_string(),
2458                    "mediaGeneration": media_generation,
2459                    "kind": kind,
2460                    "codec": codec,
2461                    "clockRate": clock_rate,
2462                    "codedWidth": coded_width,
2463                    "codedHeight": coded_height,
2464                    "channels": channels,
2465                }),
2466                crate::media::MediaControlFrame::SetEnabled {
2467                    publication_id,
2468                    media_generation,
2469                    enabled,
2470                } => serde_json::json!({
2471                    "type": "set-enabled",
2472                    "publicationId": publication_id.to_string(),
2473                    "mediaGeneration": media_generation,
2474                    "enabled": enabled,
2475                }),
2476                crate::media::MediaControlFrame::RequestKeyframe {
2477                    publication_id,
2478                    media_generation,
2479                } => serde_json::json!({
2480                    "type": "request-keyframe",
2481                    "publicationId": publication_id.to_string(),
2482                    "mediaGeneration": media_generation,
2483                }),
2484                crate::media::MediaControlFrame::Stop {
2485                    publication_id,
2486                    media_generation,
2487                    reason,
2488                } => serde_json::json!({
2489                    "type": "stop",
2490                    "publicationId": publication_id.to_string(),
2491                    "mediaGeneration": media_generation,
2492                    "reason": reason,
2493                }),
2494            };
2495            serde::Serialize::serialize(
2496                &portable,
2497                &serde_wasm_bindgen::Serializer::json_compatible(),
2498            )
2499            .map_err(|error| JsValue::from_str(&error.to_string()))
2500        }
2501
2502        /// Install the browser mechanism callback for Rust-owned carrier
2503        /// actions. The callback must enqueue actions in order; it must not
2504        /// choose attempts, retry, or promote a transport itself.
2505        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2506        #[wasm_bindgen(js_name = __setIrohCarrierActionHandler)]
2507        pub fn set_iroh_carrier_action_handler(&self, handler: Option<js_sys::Function>) {
2508            *self.wasm_carrier_action_handler.borrow_mut() = handler;
2509        }
2510
2511        /// Internal bootstrap diagnostic for the one Rust-owned browser
2512        /// carrier configuration. JavaScript uses this only as an awaited
2513        /// readiness barrier; it does not select a carrier or inspect a peer.
2514        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2515        #[wasm_bindgen(js_name = __getIrohCarrierReadiness)]
2516        pub async fn get_iroh_carrier_readiness(&self) -> Result<JsValue, JsValue> {
2517            let readiness = serde_json::json!({
2518                "webrtcConfigured": self.inner.is_webrtc_carrier_configured().await,
2519                "moqConfigured": self.inner.is_moq_carrier_configured().await,
2520                "webrtcAvailable": self.inner.is_webrtc_carrier_enabled().await,
2521                "moqAvailable": self.inner.is_moq_carrier_enabled().await,
2522                "actionHandlerInstalled": self.wasm_carrier_action_handler.borrow().is_some(),
2523            });
2524            serde::Serialize::serialize(
2525                &readiness,
2526                &serde_wasm_bindgen::Serializer::json_compatible(),
2527            )
2528            .map_err(|error| JsValue::from_str(&error.to_string()))
2529        }
2530
2531        #[cfg(feature = "transport-webrtc")]
2532        #[wasm_bindgen(js_name = __configureWebRtcCarrier)]
2533        pub async fn configure_iroh_webrtc_carrier(
2534            &self,
2535            enabled: bool,
2536            privacy_mode: bool,
2537        ) -> Result<(), JsValue> {
2538            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2539            self.inner.set_webrtc_carrier(enabled, privacy_mode).await;
2540            Ok(())
2541        }
2542
2543        /// Apply privacy, optimization, and exact-route ordering before any
2544        /// browser carrier attempt. The Rust peer actor remains the selector.
2545        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2546        #[wasm_bindgen(js_name = __configureIrohRoutePolicy)]
2547        pub async fn configure_iroh_route_policy(
2548            &self,
2549            relay_only: bool,
2550            optimize_for: Option<String>,
2551            route_priority: JsValue,
2552        ) -> Result<(), JsValue> {
2553            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2554            let optimize_for = match optimize_for.as_deref().unwrap_or("balanced") {
2555                "balanced" => crate::route_policy::RouteOptimization::Balanced,
2556                "lowest-latency" => crate::route_policy::RouteOptimization::LowestLatency,
2557                _ => {
2558                    return Err(JsValue::from_str(
2559                        "transport optimization must be balanced or lowest-latency",
2560                    ))
2561                }
2562            };
2563            let route_priority = if route_priority.is_null() || route_priority.is_undefined() {
2564                Vec::new()
2565            } else {
2566                serde_wasm_bindgen::from_value(route_priority)
2567                    .map_err(|error| JsValue::from_str(&error.to_string()))?
2568            };
2569            self.inner
2570                .configure_wasm_route_policy(relay_only, optimize_for, route_priority)
2571                .await;
2572            Ok(())
2573        }
2574
2575        /// Submit the currently configured global carrier policy to the Rust
2576        /// peer-session owner. This is the only browser policy-switch command:
2577        /// JavaScript supplies no peer-specific winner and closes no route.
2578        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2579        #[wasm_bindgen(js_name = __applyIrohCarrierPolicy)]
2580        pub async fn apply_iroh_carrier_policy(&self) -> Result<u32, JsValue> {
2581            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2582            let capabilities: Vec<_> = self
2583                .wasm_remote_carrier_capabilities
2584                .borrow()
2585                .iter()
2586                .map(|(connection_id, capabilities)| (connection_id.clone(), *capabilities))
2587                .collect();
2588            let mut reconciled = 0u32;
2589            for (connection_id, capabilities) in capabilities {
2590                let Some(remote_endpoint_id) = self
2591                    .inner
2592                    .wasm_iroh_carrier_remote_endpoint_id(&connection_id)
2593                    .await
2594                else {
2595                    continue;
2596                };
2597                if self
2598                    .begin_preferred_iroh_carrier_locked(
2599                        connection_id,
2600                        remote_endpoint_id,
2601                        capabilities.webrtc,
2602                        capabilities.moq,
2603                    )
2604                    .await?
2605                {
2606                    reconciled = reconciled.saturating_add(1);
2607                }
2608            }
2609            Ok(reconciled)
2610        }
2611
2612        /// Consume a browser availability transition as typed input to the
2613        /// existing Rust peer-session owner. A very short offline interval can
2614        /// leave WebTransport or WebRTC apparently open while its selected
2615        /// Iroh path is no longer usable. Retire only the exact current custom
2616        /// generation; the canonical browser actor still owns redial, policy,
2617        /// admission, and the next carrier selection.
2618        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2619        #[wasm_bindgen(js_name = __reconcileIrohCarriersAfterNetworkChange)]
2620        pub async fn reconcile_iroh_carriers_after_network_change(&self) -> u32 {
2621            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2622            let mut reconciled = 0u32;
2623            let mut recoveries = Vec::new();
2624            #[cfg(feature = "transport-webrtc")]
2625            {
2626                let attempts: Vec<_> = self
2627                    .wasm_webrtc_carrier_attempts
2628                    .borrow()
2629                    .values()
2630                    .cloned()
2631                    .collect();
2632                let probed = futures::future::join_all(attempts.into_iter().map(|attempt| {
2633                    let inner = self.inner.clone();
2634                    async move {
2635                        let needs_recovery = inner
2636                            .wasm_carrier_generation_needs_network_recovery(
2637                                &attempt.connection_id,
2638                                attempt.remote_endpoint_id,
2639                                crate::client::IrohPathKind::WebRtc,
2640                                attempt.generation.transport_generation.saturating_add(1),
2641                            )
2642                            .await;
2643                        (attempt, needs_recovery)
2644                    }
2645                }))
2646                .await;
2647                for (attempt, needs_recovery) in probed {
2648                    if !needs_recovery {
2649                        continue;
2650                    }
2651                    if retire_selected_wasm_webrtc_carrier(
2652                        self.inner.clone(),
2653                        self.wasm_webrtc_carrier_attempts.clone(),
2654                        self.wasm_webrtc_carrier_sessions.clone(),
2655                        self.wasm_carrier_action_handler.clone(),
2656                        &attempt,
2657                        "network-change",
2658                        crate::lifecycle_reason::REASON_NETWORK_CHANGE_RECONNECT,
2659                    )
2660                    .await
2661                    {
2662                        reconciled = reconciled.saturating_add(1);
2663                        recoveries.push((
2664                            attempt.connection_id.clone(),
2665                            attempt.remote_endpoint_id,
2666                            attempt.generation.transport_generation.saturating_add(1),
2667                        ));
2668                    }
2669                }
2670            }
2671            #[cfg(feature = "transport-moq")]
2672            {
2673                let attempts: Vec<_> = self
2674                    .wasm_moq_carrier_attempts
2675                    .borrow()
2676                    .values()
2677                    .cloned()
2678                    .collect();
2679                let probed = futures::future::join_all(attempts.into_iter().map(|attempt| {
2680                    let inner = self.inner.clone();
2681                    async move {
2682                        let needs_recovery = inner
2683                            .wasm_carrier_generation_needs_network_recovery(
2684                                &attempt.connection_id,
2685                                attempt.remote_endpoint_id,
2686                                crate::client::IrohPathKind::Moq,
2687                                attempt.generation.transport_generation.saturating_add(1),
2688                            )
2689                            .await;
2690                        (attempt, needs_recovery)
2691                    }
2692                }))
2693                .await;
2694                for (attempt, needs_recovery) in probed {
2695                    if !needs_recovery {
2696                        continue;
2697                    }
2698                    if retire_selected_wasm_moq_carrier(
2699                        self.inner.clone(),
2700                        self.wasm_moq_carrier_attempts.clone(),
2701                        self.wasm_moq_carrier_sessions.clone(),
2702                        self.wasm_carrier_action_handler.clone(),
2703                        &attempt,
2704                        "network-change",
2705                        crate::lifecycle_reason::REASON_NETWORK_CHANGE_RECONNECT,
2706                    )
2707                    .await
2708                    {
2709                        reconciled = reconciled.saturating_add(1);
2710                        recoveries.push((
2711                            attempt.connection_id.clone(),
2712                            attempt.remote_endpoint_id,
2713                            attempt.generation.transport_generation.saturating_add(1),
2714                        ));
2715                    }
2716                }
2717            }
2718            for (connection_id, remote_endpoint_id, transport_generation) in recoveries {
2719                let recovered = self
2720                    .inner
2721                    .recover_retired_wasm_carrier_generation(
2722                        &connection_id,
2723                        remote_endpoint_id,
2724                        transport_generation,
2725                    )
2726                    .await;
2727                if !recovered {
2728                    web_sys::console::warn_1(&JsValue::from_str(&format!(
2729                        "[OpenRTC][Iroh carrier] base recovery remained pending connection_id={connection_id} transport_generation={transport_generation}"
2730                    )));
2731                }
2732            }
2733            if reconciled > 0 {
2734                // Retiring a half-open custom carrier also closes its exact
2735                // underlying Iroh generation. The desired-peer revision did
2736                // not change, so no coordination update is guaranteed to wake
2737                // the serial browser actor. Submit the availability edge to
2738                // that existing owner after settlement; it alone decides
2739                // whether and when to redial the retained desired peer.
2740                self.inner.wake_browser_auto_connect();
2741            }
2742            reconciled
2743        }
2744
2745        /// Forget carrier capabilities only when the logical peer is retired.
2746        /// Physical WebRTC/MoQ mechanism replacement must retain this knowledge
2747        /// so the Rust lifecycle owner can select the next carrier deterministically.
2748        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2749        #[wasm_bindgen(js_name = __forgetIrohCarrierPeer)]
2750        pub async fn forget_iroh_carrier_peer(&self, connection_id: String) {
2751            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2752            self.forget_iroh_carrier_peer_locked(&connection_id);
2753        }
2754
2755        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2756        fn forget_iroh_carrier_peer_locked(&self, connection_id: &str) {
2757            self.wasm_remote_carrier_capabilities
2758                .borrow_mut()
2759                .remove(connection_id);
2760            self.inner
2761                .forget_iroh_carrier_peer_policy_epoch(connection_id);
2762        }
2763
2764        /// Retire every browser carrier owned by one logical peer and forget
2765        /// its capability revision as one serialized lifecycle transition.
2766        /// Individual carrier retirement APIs remain available for typed
2767        /// mechanism failures, which intentionally retain peer capabilities.
2768        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2769        #[wasm_bindgen(js_name = __retireIrohCarrierPeer)]
2770        pub async fn retire_iroh_carrier_peer(
2771            &self,
2772            connection_id: String,
2773            terminal_reason: Option<String>,
2774        ) {
2775            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2776            #[cfg(feature = "transport-webrtc")]
2777            self.retire_iroh_webrtc_carrier_locked(
2778                connection_id.clone(),
2779                terminal_reason.as_deref(),
2780            )
2781            .await;
2782            #[cfg(feature = "transport-moq")]
2783            self.retire_iroh_moq_carrier_locked(connection_id.clone(), terminal_reason.as_deref())
2784                .await;
2785            self.forget_iroh_carrier_peer_locked(&connection_id);
2786        }
2787
2788        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2789        fn has_matching_in_flight_wasm_carrier_attempt(
2790            &self,
2791            connection_id: &str,
2792            capabilities: WasmRemoteCarrierCapabilities,
2793        ) -> bool {
2794            #[cfg(feature = "transport-webrtc")]
2795            if capabilities.webrtc
2796                && self
2797                    .wasm_webrtc_carrier_attempts
2798                    .borrow()
2799                    .get(connection_id)
2800                    .is_some_and(|attempt| {
2801                        attempt.role == "responder" && !attempt.completion_started
2802                    })
2803            {
2804                return true;
2805            }
2806            #[cfg(feature = "transport-moq")]
2807            if capabilities.moq
2808                && self
2809                    .wasm_moq_carrier_attempts
2810                    .borrow()
2811                    .get(connection_id)
2812                    .is_some_and(|attempt| attempt.role == "responder")
2813            {
2814                return true;
2815            }
2816            false
2817        }
2818
2819        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2820        /// Start at most one mutually supported carrier according to the
2821        /// Rust-owned exact-route policy.
2822        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2823        #[wasm_bindgen(js_name = __beginPreferredIrohCarrier)]
2824        pub async fn begin_preferred_iroh_carrier(
2825            &self,
2826            connection_id: String,
2827            remote_endpoint_id: String,
2828            remote_supports_webrtc: bool,
2829            remote_supports_moq: bool,
2830        ) -> Result<bool, JsValue> {
2831            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2832            self.begin_preferred_iroh_carrier_locked(
2833                connection_id,
2834                remote_endpoint_id,
2835                remote_supports_webrtc,
2836                remote_supports_moq,
2837            )
2838            .await
2839        }
2840
2841        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2842        async fn begin_preferred_iroh_carrier_locked(
2843            &self,
2844            connection_id: String,
2845            remote_endpoint_id: String,
2846            remote_supports_webrtc: bool,
2847            remote_supports_moq: bool,
2848        ) -> Result<bool, JsValue> {
2849            let capabilities = WasmRemoteCarrierCapabilities {
2850                webrtc: remote_supports_webrtc,
2851                moq: remote_supports_moq,
2852            };
2853            let previous_capabilities = self
2854                .wasm_remote_carrier_capabilities
2855                .borrow()
2856                .get(&connection_id)
2857                .copied();
2858            if previous_capabilities != Some(capabilities) {
2859                // Remote capability knowledge belongs to this connection.
2860                // The global epoch is reserved for local route-policy/config
2861                // changes; otherwise a third peer joining can invalidate an
2862                // unrelated browser carrier proof.
2863                let matching_first_attempt = previous_capabilities.is_none()
2864                    && self
2865                        .has_matching_in_flight_wasm_carrier_attempt(&connection_id, capabilities);
2866                if crate::client::should_bump_remote_carrier_peer_policy_epoch(
2867                    previous_capabilities.is_some(),
2868                    true,
2869                    matching_first_attempt,
2870                ) {
2871                    self.inner
2872                        .bump_wasm_iroh_carrier_peer_policy_epoch(&connection_id)
2873                        .await;
2874                }
2875                self.wasm_remote_carrier_capabilities
2876                    .borrow_mut()
2877                    .insert(connection_id.clone(), capabilities);
2878            }
2879            if self
2880                .inner
2881                .reconcile_wasm_iroh_carrier_policy(
2882                    &connection_id,
2883                    remote_supports_webrtc,
2884                    remote_supports_moq,
2885                )
2886                .await
2887                .map_err(|error| JsValue::from_str(&error.to_string()))?
2888            {
2889                return Ok(true);
2890            }
2891            let ranked = self
2892                .inner
2893                .ranked_wasm_iroh_carriers(remote_supports_webrtc, remote_supports_moq)
2894                .await;
2895            match ranked.first() {
2896                #[cfg(feature = "transport-webrtc")]
2897                Some(crate::route_policy::KnownRoute::WebRtc) => {
2898                    self.begin_iroh_webrtc_carrier_attempt(connection_id, remote_endpoint_id, 0)
2899                        .await
2900                }
2901                #[cfg(feature = "transport-moq")]
2902                Some(crate::route_policy::KnownRoute::Moq) => {
2903                    self.begin_iroh_moq_carrier_attempt(connection_id, remote_endpoint_id, 0)
2904                        .await
2905                }
2906                _ => Ok(false),
2907            }
2908        }
2909
2910        /// Advance to the next configured carrier after Rust has retired a
2911        /// terminal attempt. Browser JavaScript only executes this decision.
2912        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2913        #[wasm_bindgen(js_name = __advancePreferredIrohCarrier)]
2914        pub async fn advance_preferred_iroh_carrier(
2915            &self,
2916            connection_id: String,
2917            remote_endpoint_id: String,
2918            failed_route: String,
2919        ) -> Result<bool, JsValue> {
2920            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2921            let Some(capabilities) = self
2922                .wasm_remote_carrier_capabilities
2923                .borrow()
2924                .get(&connection_id)
2925                .copied()
2926            else {
2927                return Ok(false);
2928            };
2929            let failed = crate::route_policy::normalize_route(&failed_route)
2930                .ok_or_else(|| JsValue::from_str("unknown failed carrier route"))?;
2931            let ranked = self
2932                .inner
2933                .ranked_wasm_iroh_carriers(capabilities.webrtc, capabilities.moq)
2934                .await;
2935            let Some(next_index) = ranked
2936                .iter()
2937                .position(|route| *route == failed)
2938                .map(|index| index + 1)
2939                .filter(|index| *index < ranked.len())
2940            else {
2941                return Ok(false);
2942            };
2943            let next = ranked[next_index];
2944            match next {
2945                #[cfg(feature = "transport-webrtc")]
2946                crate::route_policy::KnownRoute::WebRtc => {
2947                    self.begin_iroh_webrtc_carrier_attempt(connection_id, remote_endpoint_id, 0)
2948                        .await
2949                }
2950                #[cfg(feature = "transport-moq")]
2951                crate::route_policy::KnownRoute::Moq => {
2952                    self.begin_iroh_moq_carrier_attempt(connection_id, remote_endpoint_id, 0)
2953                        .await
2954                }
2955                _ => Ok(false),
2956            }
2957        }
2958
2959        #[cfg(feature = "transport-moq")]
2960        #[wasm_bindgen(js_name = __configureMoqCarrier)]
2961        pub async fn configure_iroh_moq_carrier(
2962            &self,
2963            enabled: bool,
2964            relay_url: Option<String>,
2965        ) -> Result<(), JsValue> {
2966            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2967            let relay_url = relay_url.map(|value| value.trim().to_string());
2968            if enabled && relay_url.as_deref().unwrap_or_default().is_empty() {
2969                return Err(JsValue::from_str(
2970                    "MoQ Iroh carrier requires an explicit relay URL",
2971                ));
2972            }
2973            self.inner.set_moq_carrier(enabled, relay_url).await;
2974            Ok(())
2975        }
2976
2977        /// Start a deterministic Draft 14 MoQ reliable object-stream carrier attempt
2978        /// after the admitted handshake reports peer support.
2979        #[cfg(feature = "transport-moq")]
2980        #[wasm_bindgen(js_name = __beginMoqCarrier)]
2981        pub async fn begin_iroh_moq_carrier(
2982            &self,
2983            connection_id: String,
2984            remote_endpoint_id: String,
2985        ) -> Result<bool, JsValue> {
2986            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2987            self.begin_iroh_moq_carrier_attempt(connection_id, remote_endpoint_id, 0)
2988                .await
2989        }
2990
2991        #[cfg(feature = "transport-moq")]
2992        async fn begin_iroh_moq_carrier_attempt(
2993            &self,
2994            connection_id: String,
2995            remote_endpoint_id: String,
2996            retry_count: u8,
2997        ) -> Result<bool, JsValue> {
2998            if !self.inner.is_moq_carrier_enabled().await {
2999                return Ok(false);
3000            }
3001            let local_endpoint_id = self
3002                .inner
3003                .current_node_id()
3004                .await
3005                .ok_or_else(|| JsValue::from_str("local Iroh endpoint id is unavailable"))?;
3006            if local_endpoint_id.as_str() <= remote_endpoint_id.as_str()
3007                || !crate::iroh_connection_policy::custom_carrier_base_allows(
3008                    self.inner.iroh_path_kind(&remote_endpoint_id).await,
3009                    crate::client::IrohPathKind::Moq,
3010                    false,
3011                )
3012            {
3013                return Ok(false);
3014            }
3015            let endpoint_id = remote_endpoint_id
3016                .parse::<iroh::EndpointId>()
3017                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3018            self.inner
3019                .get_connection(endpoint_id)
3020                .await
3021                .ok_or_else(|| JsValue::from_str("MoQ carrier base is unavailable"))?;
3022            let generation = self
3023                .inner
3024                .current_wasm_peer_data_generation(&connection_id, None)
3025                .await
3026                .ok_or_else(|| JsValue::from_str("MoQ carrier generation is unavailable"))?;
3027            let bootstrap = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::request(
3028                crate::iroh_carrier_bootstrap::CarrierBootstrapKind::MoqDraft14,
3029                crate::iroh_carrier_bootstrap::CarrierGenerationFence {
3030                    transport_stable_id: generation.transport_stable_id,
3031                    transport_generation: generation.transport_generation,
3032                    route_generation: generation.route_generation,
3033                },
3034                retry_count.saturating_add(1),
3035            )
3036            .map_err(|error| JsValue::from_str(&error.to_string()))?;
3037            let kind = crate::client::IrohPathKind::Moq;
3038            if !matches!(
3039                self.inner
3040                    .reserve_wasm_carrier_upgrade(
3041                        &connection_id,
3042                        kind,
3043                        &bootstrap.upgrade_id,
3044                        generation,
3045                    )
3046                    .await,
3047                crate::client::WasmCarrierUpgradeReservation::Reserved
3048            ) {
3049                let pending = self
3050                    .wasm_moq_carrier_attempts
3051                    .borrow_mut()
3052                    .get_mut(&connection_id)
3053                    .filter(|attempt| {
3054                        attempt.role == "initiator"
3055                            && attempt.generation == generation
3056                            && attempt.prepared
3057                            && !attempt.retry_sent
3058                    })
3059                    .map(|attempt| {
3060                        attempt.retry_sent = true;
3061                        attempt.clone()
3062                    });
3063                if let Some(pending) = pending {
3064                    // Admission can become current just before the replacement
3065                    // native-main writer is installed. A later trusted
3066                    // handshake is typed retry input for the same fenced
3067                    // attempt, so resend its idempotent bootstrap instead of
3068                    // waiting for a timer or creating another lifecycle owner.
3069                    emit_wasm_carrier_action(
3070                        &self.wasm_carrier_action_handler,
3071                        serde_json::json!({
3072                            "type": "send-control",
3073                            "connectionId": connection_id,
3074                            "remoteEndpointId": pending.remote_endpoint_id.to_string(),
3075                            "envelope": pending.bootstrap,
3076                        }),
3077                    );
3078                    return Ok(true);
3079                }
3080                return Ok(false);
3081            }
3082            let previous = self.wasm_moq_carrier_attempts.borrow_mut().insert(
3083                connection_id.clone(),
3084                WasmMoqCarrierAttempt {
3085                    connection_id: connection_id.clone(),
3086                    remote_endpoint_id: endpoint_id,
3087                    bootstrap: bootstrap.clone(),
3088                    generation,
3089                    role: "initiator",
3090                    prepared: false,
3091                    retry_sent: false,
3092                    retry_count,
3093                    inbound_authorization_expires_at_ms: None,
3094                },
3095            );
3096            if let Some(previous) = previous {
3097                self.wasm_moq_carrier_sessions
3098                    .borrow_mut()
3099                    .remove(&previous.bootstrap.upgrade_id);
3100            }
3101            let (publish_namespace, subscribe_namespace, track_name) = wasm_moq_carrier_namespaces(
3102                &local_endpoint_id,
3103                &remote_endpoint_id,
3104                &bootstrap.carrier_session_id,
3105            );
3106            emit_wasm_carrier_action(
3107                &self.wasm_carrier_action_handler,
3108                serde_json::json!({
3109                    "type": "prepare-moq",
3110                    "connectionId": connection_id,
3111                    "remoteEndpointId": remote_endpoint_id,
3112                    "role": "initiator",
3113                    "upgradeId": bootstrap.upgrade_id,
3114                    "carrierSessionId": bootstrap.carrier_session_id,
3115                    "transportGeneration": generation.transport_generation.saturating_add(1),
3116                    "publishNamespace": publish_namespace,
3117                    "subscribeNamespace": subscribe_namespace,
3118                    "trackName": track_name,
3119                }),
3120            );
3121            self.schedule_wasm_moq_carrier_watchdog(bootstrap);
3122            Ok(true)
3123        }
3124
3125        #[cfg(feature = "transport-moq")]
3126        #[wasm_bindgen(js_name = __moqCarrierPrepared)]
3127        pub async fn iroh_moq_carrier_prepared(
3128            &self,
3129            connection_id: String,
3130            upgrade_id: String,
3131        ) -> Result<(), JsValue> {
3132            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3133            let attempt = {
3134                let mut attempts = self.wasm_moq_carrier_attempts.borrow_mut();
3135                let attempt = attempts
3136                    .get_mut(&connection_id)
3137                    .filter(|attempt| attempt.bootstrap.upgrade_id == upgrade_id)
3138                    .ok_or_else(|| JsValue::from_str("MoQ carrier attempt is stale"))?;
3139                attempt.prepared = true;
3140                attempt.clone()
3141            };
3142            if !self
3143                .inner
3144                .wasm_carrier_upgrade_is_current(
3145                    &connection_id,
3146                    crate::client::IrohPathKind::Moq,
3147                    &upgrade_id,
3148                    attempt.generation,
3149                )
3150                .await
3151            {
3152                return Err(JsValue::from_str("MoQ carrier attempt was retired"));
3153            }
3154            if attempt.role == "initiator" {
3155                emit_wasm_carrier_action(
3156                    &self.wasm_carrier_action_handler,
3157                    serde_json::json!({
3158                        "type": "send-control",
3159                        "connectionId": connection_id,
3160                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
3161                        "envelope": attempt.bootstrap,
3162                    }),
3163                );
3164            } else {
3165                // Relay setup is not packet-carrier readiness. Activate the
3166                // directed duplex first; `attach_iroh_moq_carrier` emits the
3167                // protected Ready only after both browser pumps are installed.
3168                // Otherwise a native initiator can dial during this gap and
3169                // lose its first Iroh handshake packet before WASM is reading.
3170                emit_wasm_carrier_action(
3171                    &self.wasm_carrier_action_handler,
3172                    serde_json::json!({
3173                        "type": "activate-moq",
3174                        "connectionId": attempt.connection_id,
3175                        "upgradeId": upgrade_id,
3176                    }),
3177                );
3178            }
3179            Ok(())
3180        }
3181
3182        #[cfg(feature = "transport-moq")]
3183        #[wasm_bindgen(js_name = __moqCarrierFailed)]
3184        pub async fn iroh_moq_carrier_failed(
3185            &self,
3186            connection_id: String,
3187            upgrade_id: String,
3188            failure_code: String,
3189        ) {
3190            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3191            let attempt = self
3192                .wasm_moq_carrier_attempts
3193                .borrow()
3194                .get(&connection_id)
3195                .filter(|attempt| attempt.bootstrap.upgrade_id == upgrade_id)
3196                .cloned();
3197            if let Some(attempt) = attempt {
3198                let failure_code = match failure_code.as_str() {
3199                    "relay-failed" => "relay-failed",
3200                    "draft14-datagram-unavailable" => "draft14-datagram-unavailable",
3201                    "carrier-backpressure" => "carrier-backpressure",
3202                    _ => "browser-adapter-failed",
3203                };
3204                if retire_selected_wasm_moq_carrier(
3205                    self.inner.clone(),
3206                    self.wasm_moq_carrier_attempts.clone(),
3207                    self.wasm_moq_carrier_sessions.clone(),
3208                    self.wasm_carrier_action_handler.clone(),
3209                    &attempt,
3210                    failure_code,
3211                    crate::lifecycle_reason::REASON_IROH_CARRIER_FAILED,
3212                )
3213                .await
3214                {
3215                    return;
3216                }
3217                self.fail_wasm_moq_carrier_attempt(attempt, failure_code, true)
3218                    .await;
3219            }
3220        }
3221
3222        #[cfg(feature = "transport-moq")]
3223        #[wasm_bindgen(js_name = __retireMoqCarrier)]
3224        pub async fn retire_iroh_moq_carrier(
3225            &self,
3226            connection_id: String,
3227            terminal_reason: Option<String>,
3228        ) {
3229            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3230            self.retire_iroh_moq_carrier_locked(connection_id, terminal_reason.as_deref())
3231                .await;
3232        }
3233
3234        #[cfg(feature = "transport-moq")]
3235        async fn retire_iroh_moq_carrier_locked(
3236            &self,
3237            connection_id: String,
3238            terminal_reason: Option<&str>,
3239        ) {
3240            let attempt = self
3241                .wasm_moq_carrier_attempts
3242                .borrow_mut()
3243                .remove(&connection_id);
3244            if let Some(attempt) = attempt {
3245                self.inner
3246                    .retire_wasm_carrier_upgrade(
3247                        &connection_id,
3248                        crate::client::IrohPathKind::Moq,
3249                        &attempt.bootstrap.upgrade_id,
3250                        attempt.generation,
3251                    )
3252                    .await;
3253                let carrier = self
3254                    .wasm_moq_carrier_sessions
3255                    .borrow_mut()
3256                    .remove(&attempt.bootstrap.upgrade_id);
3257                if let (Some(reason), Some(session)) = (terminal_reason, carrier.as_ref()) {
3258                    let _ = session.send_terminal(reason).await;
3259                }
3260                emit_wasm_carrier_action(
3261                    &self.wasm_carrier_action_handler,
3262                    serde_json::json!({
3263                        "type": "retire-moq",
3264                        "connectionId": connection_id,
3265                        "upgradeId": attempt.bootstrap.upgrade_id,
3266                        "failureCode": "logical-connection-retired",
3267                    }),
3268                );
3269            }
3270        }
3271
3272        /// Start the deterministic browser-side WebRTC carrier attempt after
3273        /// an admitted handshake reports internal-carrier support.
3274        #[cfg(feature = "transport-webrtc")]
3275        #[wasm_bindgen(js_name = __beginWebRtcCarrier)]
3276        pub async fn begin_iroh_webrtc_carrier(
3277            &self,
3278            connection_id: String,
3279            remote_endpoint_id: String,
3280        ) -> Result<bool, JsValue> {
3281            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3282            self.begin_iroh_webrtc_carrier_attempt(connection_id, remote_endpoint_id, 0)
3283                .await
3284        }
3285
3286        #[cfg(feature = "transport-webrtc")]
3287        async fn begin_iroh_webrtc_carrier_attempt(
3288            &self,
3289            connection_id: String,
3290            remote_endpoint_id: String,
3291            retry_count: u8,
3292        ) -> Result<bool, JsValue> {
3293            if !self.inner.is_webrtc_carrier_enabled().await {
3294                return Ok(false);
3295            }
3296            let local_endpoint_id = self
3297                .inner
3298                .current_node_id()
3299                .await
3300                .ok_or_else(|| JsValue::from_str("local Iroh endpoint id is unavailable"))?;
3301            if local_endpoint_id.as_str() <= remote_endpoint_id.as_str()
3302                || !crate::iroh_connection_policy::custom_carrier_base_allows(
3303                    self.inner.iroh_path_kind(&remote_endpoint_id).await,
3304                    crate::client::IrohPathKind::WebRtc,
3305                    false,
3306                )
3307            {
3308                return Ok(false);
3309            }
3310            let endpoint_id = remote_endpoint_id
3311                .parse::<iroh::EndpointId>()
3312                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3313            self.inner
3314                .get_connection(endpoint_id)
3315                .await
3316                .ok_or_else(|| JsValue::from_str("WebRTC carrier base is unavailable"))?;
3317            let generation = self
3318                .inner
3319                .current_wasm_peer_data_generation(&connection_id, None)
3320                .await
3321                .ok_or_else(|| JsValue::from_str("WebRTC carrier generation is unavailable"))?;
3322            let bootstrap = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::request(
3323                crate::iroh_carrier_bootstrap::CarrierBootstrapKind::WebRtc,
3324                crate::iroh_carrier_bootstrap::CarrierGenerationFence {
3325                    transport_stable_id: generation.transport_stable_id,
3326                    transport_generation: generation.transport_generation,
3327                    route_generation: generation.route_generation,
3328                },
3329                1,
3330            )
3331            .map_err(|error| JsValue::from_str(&error.to_string()))?;
3332            let kind = crate::client::IrohPathKind::WebRtc;
3333            if !matches!(
3334                self.inner
3335                    .reserve_wasm_carrier_upgrade(
3336                        &connection_id,
3337                        kind,
3338                        &bootstrap.upgrade_id,
3339                        generation,
3340                    )
3341                    .await,
3342                crate::client::WasmCarrierUpgradeReservation::Reserved
3343            ) {
3344                let pending = self
3345                    .wasm_webrtc_carrier_attempts
3346                    .borrow_mut()
3347                    .get_mut(&connection_id)
3348                    .filter(|attempt| {
3349                        attempt.role == "initiator"
3350                            && attempt.generation == generation
3351                            && attempt.prepared
3352                            && !attempt.retry_sent
3353                            && !attempt.completion_started
3354                    })
3355                    .map(|attempt| {
3356                        attempt.retry_sent = true;
3357                        attempt.clone()
3358                    });
3359                if let Some(pending) = pending {
3360                    // See the MoQ path above. Reuse the exact pending upgrade
3361                    // ID and generation so duplicate trusted events cannot
3362                    // widen authority or create competing attempts.
3363                    emit_wasm_carrier_action(
3364                        &self.wasm_carrier_action_handler,
3365                        serde_json::json!({
3366                            "type": "send-control",
3367                            "connectionId": connection_id,
3368                            "remoteEndpointId": pending.remote_endpoint_id.to_string(),
3369                            "envelope": pending.bootstrap,
3370                        }),
3371                    );
3372                    return Ok(true);
3373                }
3374                return Ok(false);
3375            }
3376            let previous = self.wasm_webrtc_carrier_attempts.borrow_mut().insert(
3377                connection_id.clone(),
3378                WasmWebRtcCarrierAttempt {
3379                    connection_id: connection_id.clone(),
3380                    remote_endpoint_id: endpoint_id,
3381                    bootstrap: bootstrap.clone(),
3382                    generation,
3383                    role: "initiator",
3384                    prepared: false,
3385                    retry_sent: false,
3386                    offer_started: false,
3387                    remote_ready: false,
3388                    completion_started: false,
3389                    retry_count,
3390                    inbound_authorization_expires_at_ms: None,
3391                },
3392            );
3393            if let Some(previous) = previous {
3394                self.wasm_webrtc_carrier_sessions
3395                    .borrow_mut()
3396                    .remove(&previous.bootstrap.upgrade_id);
3397            }
3398            emit_wasm_carrier_action(
3399                &self.wasm_carrier_action_handler,
3400                serde_json::json!({
3401                    "type": "prepare-webrtc",
3402                    "connectionId": connection_id,
3403                    "remoteEndpointId": remote_endpoint_id,
3404                    "role": "initiator",
3405                    "upgradeId": bootstrap.upgrade_id,
3406                    "carrierSessionId": bootstrap.carrier_session_id,
3407                    "transportGeneration": generation.transport_generation.saturating_add(1),
3408                }),
3409            );
3410            self.schedule_wasm_webrtc_carrier_watchdog(bootstrap);
3411            Ok(true)
3412        }
3413
3414        /// Notify Rust that the main-thread peer connection and packet
3415        /// DataChannel exist, but negotiation has not started yet.
3416        #[cfg(feature = "transport-webrtc")]
3417        #[wasm_bindgen(js_name = __webRtcCarrierPrepared)]
3418        pub async fn iroh_webrtc_carrier_prepared(
3419            &self,
3420            connection_id: String,
3421            upgrade_id: String,
3422        ) -> Result<(), JsValue> {
3423            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3424            let attempt = {
3425                let mut attempts = self.wasm_webrtc_carrier_attempts.borrow_mut();
3426                let attempt = attempts
3427                    .get_mut(&connection_id)
3428                    .filter(|attempt| attempt.bootstrap.upgrade_id == upgrade_id)
3429                    .ok_or_else(|| JsValue::from_str("WebRTC carrier attempt is stale"))?;
3430                attempt.prepared = true;
3431                attempt.clone()
3432            };
3433            let kind = crate::client::IrohPathKind::WebRtc;
3434            if !self
3435                .inner
3436                .wasm_carrier_upgrade_is_current(
3437                    &connection_id,
3438                    kind,
3439                    &upgrade_id,
3440                    attempt.generation,
3441                )
3442                .await
3443            {
3444                return Err(JsValue::from_str("WebRTC carrier attempt was retired"));
3445            }
3446            if attempt.role == "initiator" {
3447                emit_wasm_carrier_action(
3448                    &self.wasm_carrier_action_handler,
3449                    serde_json::json!({
3450                        "type": "send-control",
3451                        "connectionId": connection_id,
3452                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
3453                        "envelope": attempt.bootstrap,
3454                    }),
3455                );
3456            } else {
3457                // The initiator must not send its offer until this responder
3458                // has installed the matching attempt. This explicit ready
3459                // signal also makes browser-to-browser carrier negotiation use
3460                // the same ordering contract as the native responder.
3461                emit_wasm_carrier_action(
3462                    &self.wasm_carrier_action_handler,
3463                    serde_json::json!({
3464                        "type": "send-control",
3465                        "connectionId": attempt.connection_id,
3466                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
3467                        "envelope": {
3468                            "type": "#pluto-signal",
3469                            "content": {
3470                                "transport": "webrtc",
3471                                "type": "renegotiate",
3472                                "negotiationId": upgrade_id,
3473                            }
3474                        },
3475                    }),
3476                );
3477            }
3478            Ok(())
3479        }
3480
3481        #[cfg(feature = "transport-webrtc")]
3482        #[wasm_bindgen(js_name = __webRtcCarrierFailed)]
3483        pub async fn iroh_webrtc_carrier_failed(
3484            &self,
3485            connection_id: String,
3486            upgrade_id: String,
3487            failure_code: String,
3488        ) {
3489            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3490            let attempt = self
3491                .wasm_webrtc_carrier_attempts
3492                .borrow()
3493                .get(&connection_id)
3494                .filter(|attempt| attempt.bootstrap.upgrade_id == upgrade_id)
3495                .cloned();
3496            if let Some(attempt) = attempt {
3497                let failure_code = match failure_code.as_str() {
3498                    "data-channel-failed" => "data-channel-failed",
3499                    "ice-failed" => "ice-failed",
3500                    "signaling-failed" => "signaling-failed",
3501                    "carrier-backpressure" => "carrier-backpressure",
3502                    "carrier-base-generation-stale" => "carrier-base-generation-stale",
3503                    _ => "browser-adapter-failed",
3504                };
3505                if retire_selected_wasm_webrtc_carrier(
3506                    self.inner.clone(),
3507                    self.wasm_webrtc_carrier_attempts.clone(),
3508                    self.wasm_webrtc_carrier_sessions.clone(),
3509                    self.wasm_carrier_action_handler.clone(),
3510                    &attempt,
3511                    failure_code,
3512                    crate::lifecycle_reason::REASON_IROH_CARRIER_FAILED,
3513                )
3514                .await
3515                {
3516                    return;
3517                }
3518                let should_retry = attempt.role == "initiator"
3519                    && attempt.retry_count == 0
3520                    && matches!(
3521                        failure_code,
3522                        "data-channel-failed"
3523                            | "ice-failed"
3524                            | "signaling-failed"
3525                            | "carrier-base-generation-stale"
3526                    );
3527                let remote_endpoint_id = attempt.remote_endpoint_id.to_string();
3528                self.fail_wasm_webrtc_carrier_attempt(attempt, failure_code, true)
3529                    .await;
3530                if should_retry {
3531                    // Browser ICE can report one terminal transition while
3532                    // the network monitor catches up after resume. Retry once
3533                    // on the current admitted base; this sends only peer
3534                    // control frames and cannot touch gateway coordination.
3535                    gloo_timers::future::sleep(std::time::Duration::from_millis(500)).await;
3536                    let _ = self
3537                        .begin_iroh_webrtc_carrier_attempt(connection_id, remote_endpoint_id, 1)
3538                        .await;
3539                }
3540            }
3541        }
3542
3543        #[cfg(feature = "transport-webrtc")]
3544        #[wasm_bindgen(js_name = __retireWebRtcCarrier)]
3545        pub async fn retire_iroh_webrtc_carrier(
3546            &self,
3547            connection_id: String,
3548            terminal_reason: Option<String>,
3549        ) {
3550            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3551            self.retire_iroh_webrtc_carrier_locked(connection_id, terminal_reason.as_deref())
3552                .await;
3553        }
3554
3555        #[cfg(feature = "transport-webrtc")]
3556        async fn retire_iroh_webrtc_carrier_locked(
3557            &self,
3558            connection_id: String,
3559            terminal_reason: Option<&str>,
3560        ) {
3561            let attempt = self
3562                .wasm_webrtc_carrier_attempts
3563                .borrow_mut()
3564                .remove(&connection_id);
3565            if let Some(attempt) = attempt {
3566                self.inner
3567                    .retire_wasm_carrier_upgrade(
3568                        &connection_id,
3569                        crate::client::IrohPathKind::WebRtc,
3570                        &attempt.bootstrap.upgrade_id,
3571                        attempt.generation,
3572                    )
3573                    .await;
3574                let carrier = self
3575                    .wasm_webrtc_carrier_sessions
3576                    .borrow_mut()
3577                    .remove(&attempt.bootstrap.upgrade_id);
3578                if let (Some(reason), Some(session)) = (terminal_reason, carrier.as_ref()) {
3579                    let _ = session.send_terminal(reason).await;
3580                }
3581                emit_wasm_carrier_action(
3582                    &self.wasm_carrier_action_handler,
3583                    serde_json::json!({
3584                        "type": "retire-webrtc",
3585                        "connectionId": connection_id,
3586                        "upgradeId": attempt.bootstrap.upgrade_id,
3587                        "failureCode": "logical-connection-retired",
3588                    }),
3589                );
3590            }
3591        }
3592
3593        /// Ingest one admitted carrier bootstrap or signaling frame. Returning
3594        /// `true` means the frame belongs exclusively to the internal carrier.
3595        #[cfg(feature = "transport-webrtc")]
3596        #[wasm_bindgen(js_name = __handleIrohCarrierControl)]
3597        pub async fn handle_iroh_carrier_control(
3598            &self,
3599            connection_id: String,
3600            remote_endpoint_id: String,
3601            frame: JsValue,
3602        ) -> Result<bool, JsValue> {
3603            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3604            let frame: serde_json::Value = serde_wasm_bindgen::from_value(frame)
3605                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3606            if frame.get("type").and_then(serde_json::Value::as_str) == Some("#pluto-signal")
3607                && frame
3608                    .get("content")
3609                    .and_then(|content| content.get("transport"))
3610                    .and_then(serde_json::Value::as_str)
3611                    == Some("webrtc")
3612            {
3613                let negotiation_id = frame
3614                    .get("content")
3615                    .and_then(|content| content.get("negotiationId"))
3616                    .and_then(serde_json::Value::as_str)
3617                    .unwrap_or_default();
3618                let current = self
3619                    .wasm_webrtc_carrier_attempts
3620                    .borrow()
3621                    .get(&connection_id)
3622                    .is_some_and(|attempt| {
3623                        attempt.bootstrap.upgrade_id == negotiation_id
3624                            && attempt.remote_endpoint_id.to_string() == remote_endpoint_id
3625                    });
3626                if current {
3627                    let signal_type = frame
3628                        .get("content")
3629                        .and_then(|content| content.get("type"))
3630                        .and_then(serde_json::Value::as_str);
3631                    let start_offer = if signal_type == Some("renegotiate") {
3632                        self.wasm_webrtc_carrier_attempts
3633                            .borrow_mut()
3634                            .get_mut(&connection_id)
3635                            .filter(|attempt| {
3636                                attempt.bootstrap.upgrade_id == negotiation_id
3637                                    && attempt.role == "initiator"
3638                                    && !attempt.offer_started
3639                            })
3640                            .map(|attempt| {
3641                                attempt.offer_started = true;
3642                            })
3643                            .is_some()
3644                    } else {
3645                        false
3646                    };
3647                    if start_offer {
3648                        emit_wasm_carrier_action(
3649                            &self.wasm_carrier_action_handler,
3650                            serde_json::json!({
3651                                "type": "start-webrtc",
3652                                "connectionId": connection_id,
3653                                "upgradeId": negotiation_id,
3654                            }),
3655                        );
3656                        return Ok(true);
3657                    }
3658                    emit_wasm_carrier_action(
3659                        &self.wasm_carrier_action_handler,
3660                        serde_json::json!({
3661                            "type": "apply-webrtc-signal",
3662                            "connectionId": connection_id,
3663                            "upgradeId": negotiation_id,
3664                            "signal": frame,
3665                        }),
3666                    );
3667                }
3668                return Ok(true);
3669            }
3670
3671            let Some(bootstrap) =
3672                crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::from_json(&frame)
3673            else {
3674                return Ok(false);
3675            };
3676            #[cfg(feature = "transport-moq")]
3677            if bootstrap.carrier == crate::iroh_carrier_bootstrap::CarrierBootstrapKind::MoqDraft14
3678            {
3679                return self
3680                    .handle_wasm_moq_bootstrap(connection_id, remote_endpoint_id, bootstrap)
3681                    .await;
3682            }
3683            if bootstrap.carrier != crate::iroh_carrier_bootstrap::CarrierBootstrapKind::WebRtc {
3684                return Ok(false);
3685            }
3686            let endpoint_id = remote_endpoint_id
3687                .parse::<iroh::EndpointId>()
3688                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3689            let kind = crate::client::IrohPathKind::WebRtc;
3690            match bootstrap.action {
3691                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Request => {
3692                    let local_endpoint_id = self
3693                        .inner
3694                        .current_node_id()
3695                        .await
3696                        .ok_or_else(|| JsValue::from_str("local endpoint id is unavailable"))?;
3697                    if local_endpoint_id.as_str() >= remote_endpoint_id.as_str()
3698                        || !self.inner.is_webrtc_carrier_enabled().await
3699                    {
3700                        return Ok(true);
3701                    }
3702                    if !crate::iroh_connection_policy::custom_carrier_base_allows(
3703                        self.inner.iroh_path_kind(&remote_endpoint_id).await,
3704                        crate::client::IrohPathKind::WebRtc,
3705                        false,
3706                    ) {
3707                        return Ok(true);
3708                    }
3709                    let base_connection = self
3710                        .inner
3711                        .get_connection(endpoint_id)
3712                        .await
3713                        .ok_or_else(|| JsValue::from_str("WebRTC carrier base is unavailable"))?;
3714                    let generation = self
3715                        .inner
3716                        .current_wasm_peer_data_generation(&connection_id, None)
3717                        .await
3718                        .ok_or_else(|| {
3719                            JsValue::from_str("WebRTC carrier generation is unavailable")
3720                        })?;
3721                    if crate::transport_generation::for_connection(&base_connection)
3722                        != generation.transport_stable_id
3723                    {
3724                        // The physical accept owner has a newer base than the
3725                        // logical record. Reject before starting ICE so the
3726                        // initiator's one stale-only retry targets the admitted
3727                        // generation instead of creating an asymmetric carrier.
3728                        let failed =
3729                            crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::failed_from(
3730                                &bootstrap,
3731                                "carrier-base-generation-stale",
3732                            )
3733                            .map_err(|error| JsValue::from_str(&error.to_string()))?;
3734                        emit_wasm_carrier_action(
3735                            &self.wasm_carrier_action_handler,
3736                            serde_json::json!({
3737                                "type": "send-control",
3738                                "connectionId": connection_id,
3739                                "remoteEndpointId": remote_endpoint_id,
3740                                "envelope": failed,
3741                            }),
3742                        );
3743                        return Ok(true);
3744                    }
3745                    if !matches!(
3746                        self.inner
3747                            .reserve_wasm_carrier_upgrade(
3748                                &connection_id,
3749                                kind,
3750                                &bootstrap.upgrade_id,
3751                                generation,
3752                            )
3753                            .await,
3754                        crate::client::WasmCarrierUpgradeReservation::Reserved
3755                    ) {
3756                        return Ok(true);
3757                    }
3758                    let node = self
3759                        .inner
3760                        .iroh_node
3761                        .read()
3762                        .await
3763                        .as_ref()
3764                        .cloned()
3765                        .ok_or_else(|| JsValue::from_str("Iroh node is unavailable"))?;
3766                    let authorization_expiry = node
3767                        .authorize_pending_inbound_replacement(
3768                            endpoint_id,
3769                            crate::iroh_carrier_kind::EXPERIMENTAL_WEBRTC_TRANSPORT_ID,
3770                            std::time::Duration::from_secs(45),
3771                        )
3772                        .await;
3773                    let previous = self.wasm_webrtc_carrier_attempts.borrow_mut().insert(
3774                        connection_id.clone(),
3775                        WasmWebRtcCarrierAttempt {
3776                            connection_id: connection_id.clone(),
3777                            remote_endpoint_id: endpoint_id,
3778                            bootstrap: bootstrap.clone(),
3779                            generation,
3780                            role: "responder",
3781                            prepared: false,
3782                            retry_sent: false,
3783                            offer_started: false,
3784                            remote_ready: false,
3785                            completion_started: false,
3786                            retry_count: bootstrap.attempt.saturating_sub(1),
3787                            inbound_authorization_expires_at_ms: Some(authorization_expiry),
3788                        },
3789                    );
3790                    if let Some(previous) = previous {
3791                        self.wasm_webrtc_carrier_sessions
3792                            .borrow_mut()
3793                            .remove(&previous.bootstrap.upgrade_id);
3794                    }
3795                    emit_wasm_carrier_action(
3796                        &self.wasm_carrier_action_handler,
3797                        serde_json::json!({
3798                            "type": "prepare-webrtc",
3799                            "connectionId": connection_id,
3800                            "remoteEndpointId": remote_endpoint_id,
3801                            "role": "responder",
3802                            "upgradeId": bootstrap.upgrade_id,
3803                            "carrierSessionId": bootstrap.carrier_session_id,
3804                            "transportGeneration": generation.transport_generation.saturating_add(1),
3805                        }),
3806                    );
3807                    self.schedule_wasm_webrtc_carrier_watchdog(bootstrap);
3808                }
3809                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Ready => {
3810                    let upgrade_id = {
3811                        let mut attempts = self.wasm_webrtc_carrier_attempts.borrow_mut();
3812                        attempts
3813                            .get_mut(&connection_id)
3814                            .filter(|attempt| bootstrap.is_response_to(&attempt.bootstrap))
3815                            .map(|attempt| {
3816                                attempt.remote_ready = true;
3817                                attempt.bootstrap.upgrade_id.clone()
3818                            })
3819                    };
3820                    let attempt = upgrade_id.as_deref().and_then(|upgrade_id| {
3821                        self.take_ready_outbound_wasm_webrtc_carrier_attempt(
3822                            &connection_id,
3823                            upgrade_id,
3824                        )
3825                    });
3826                    if let Some(attempt) = attempt {
3827                        self.spawn_outbound_wasm_webrtc_carrier_completion(attempt);
3828                    }
3829                }
3830                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Failed => {
3831                    let attempt = self
3832                        .wasm_webrtc_carrier_attempts
3833                        .borrow()
3834                        .get(&connection_id)
3835                        .filter(|attempt| bootstrap.is_response_to(&attempt.bootstrap))
3836                        .cloned();
3837                    if let Some(attempt) = attempt {
3838                        let failure_code = crate::client::wasm_peer_carrier_failure_code(
3839                            bootstrap.failure_code.as_deref(),
3840                        );
3841                        if crate::client::should_rearm_wasm_carrier_event_retry(
3842                            failure_code,
3843                            attempt.role == "initiator",
3844                            attempt.completion_started,
3845                        ) {
3846                            if let Some(current) = self
3847                                .wasm_webrtc_carrier_attempts
3848                                .borrow_mut()
3849                                .get_mut(&connection_id)
3850                                .filter(|current| {
3851                                    current.bootstrap.upgrade_id == attempt.bootstrap.upgrade_id
3852                                        && !current.completion_started
3853                                })
3854                            {
3855                                current.retry_sent = false;
3856                            }
3857                            return Ok(true);
3858                        }
3859                        let should_retry = attempt.role == "initiator"
3860                            && attempt.retry_count == 0
3861                            && failure_code == "carrier-base-generation-stale";
3862                        let remote_endpoint_id = attempt.remote_endpoint_id.to_string();
3863                        self.fail_wasm_webrtc_carrier_attempt(attempt, failure_code, false)
3864                            .await;
3865                        if should_retry {
3866                            gloo_timers::future::sleep(std::time::Duration::from_millis(500)).await;
3867                            let _ = self
3868                                .begin_iroh_webrtc_carrier_attempt(
3869                                    connection_id,
3870                                    remote_endpoint_id,
3871                                    1,
3872                                )
3873                                .await;
3874                        }
3875                    }
3876                }
3877            }
3878            Ok(true)
3879        }
3880
3881        #[cfg(all(feature = "transport-moq", not(feature = "transport-webrtc")))]
3882        #[wasm_bindgen(js_name = __handleIrohCarrierControl)]
3883        pub async fn handle_iroh_carrier_control_moq_only(
3884            &self,
3885            connection_id: String,
3886            remote_endpoint_id: String,
3887            frame: JsValue,
3888        ) -> Result<bool, JsValue> {
3889            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3890            let frame: serde_json::Value = serde_wasm_bindgen::from_value(frame)
3891                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3892            let Some(bootstrap) =
3893                crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::from_json(&frame)
3894            else {
3895                return Ok(false);
3896            };
3897            if bootstrap.carrier != crate::iroh_carrier_bootstrap::CarrierBootstrapKind::MoqDraft14
3898            {
3899                return Ok(false);
3900            }
3901            self.handle_wasm_moq_bootstrap(connection_id, remote_endpoint_id, bootstrap)
3902                .await
3903        }
3904
3905        /// Internal browser-adapter boundary. It attaches an already negotiated
3906        /// unreliable DataChannel to the pre-bind Iroh custom transport; it
3907        /// does not authorize, dial, select, or retry a replacement.
3908        #[cfg(feature = "transport-webrtc")]
3909        #[wasm_bindgen(js_name = __attachWebRtcCarrier)]
3910        pub async fn attach_iroh_webrtc_carrier(
3911            &self,
3912            connection_id: String,
3913            remote_endpoint_id: String,
3914            channel: web_sys::RtcDataChannel,
3915            upgrade_id: String,
3916        ) -> Result<(), JsValue> {
3917            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3918            let endpoint_id = remote_endpoint_id
3919                .parse::<iroh::EndpointId>()
3920                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3921            let attempt = self
3922                .wasm_webrtc_carrier_attempts
3923                .borrow()
3924                .get(&connection_id)
3925                .filter(|attempt| {
3926                    attempt.bootstrap.upgrade_id == upgrade_id
3927                        && attempt.remote_endpoint_id == endpoint_id
3928                })
3929                .cloned()
3930                .ok_or_else(|| JsValue::from_str("WebRTC carrier attempt is stale"))?;
3931            if !self
3932                .inner
3933                .wasm_carrier_upgrade_is_current(
3934                    &connection_id,
3935                    crate::client::IrohPathKind::WebRtc,
3936                    &upgrade_id,
3937                    attempt.generation,
3938                )
3939                .await
3940            {
3941                return Err(JsValue::from_str("WebRTC carrier attempt was retired"));
3942            }
3943            let packet_session = self
3944                .inner
3945                .activate_iroh_packet_carrier(
3946                    crate::iroh_carrier_kind::IrohCarrierKind::WebRtc,
3947                    endpoint_id,
3948                )
3949                .await
3950                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3951            let expected = attempt
3952                .bootstrap
3953                .frame_expectation()
3954                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3955            let application_key = self
3956                .inner
3957                .application_crypto_key_for_connection(Some(&connection_id))
3958                .ok_or_else(|| {
3959                    JsValue::from_str("WebRTC carrier requires the admitted application crypto key")
3960                })?;
3961            let terminal_client = self.inner.clone();
3962            let terminal_connection_id = connection_id.clone();
3963            let terminal_upgrade_id = upgrade_id.clone();
3964            let terminal_generation = attempt.generation.transport_generation.saturating_add(1);
3965            let terminal_attempts = self.wasm_webrtc_carrier_attempts.clone();
3966            let terminal_sessions = self.wasm_webrtc_carrier_sessions.clone();
3967            let terminal_handler = self.wasm_carrier_action_handler.clone();
3968            let terminal_lifecycle = self.wasm_carrier_peer_lifecycle.clone();
3969            let on_terminal: Rc<dyn Fn(&'static str)> = Rc::new(move |reason| {
3970                let client = terminal_client.clone();
3971                let connection_id = terminal_connection_id.clone();
3972                let upgrade_id = terminal_upgrade_id.clone();
3973                let attempts = terminal_attempts.clone();
3974                let sessions = terminal_sessions.clone();
3975                let handler = terminal_handler.clone();
3976                let lifecycle = terminal_lifecycle.clone();
3977                spawn_local(async move {
3978                    let _lifecycle = lifecycle.lock().await;
3979                    if !client
3980                        .close_current_iroh_carrier_generation_with_reason(
3981                            &connection_id,
3982                            endpoint_id,
3983                            crate::client::IrohPathKind::WebRtc,
3984                            terminal_generation,
3985                            reason,
3986                        )
3987                        .await
3988                    {
3989                        return;
3990                    }
3991                    // Selection retires the upgrade gate but deliberately
3992                    // retains the browser mechanism for the chosen physical
3993                    // generation. Its terminal callback is therefore the
3994                    // exact Rust-owned point that must release that retained
3995                    // attempt/session and name the upgrade-id-fenced browser
3996                    // object to close. A generic backend state projection is
3997                    // too late and is not generation-authoritative.
3998                    if attempts
3999                        .borrow()
4000                        .get(&connection_id)
4001                        .is_some_and(|current| current.bootstrap.upgrade_id == upgrade_id)
4002                    {
4003                        attempts.borrow_mut().remove(&connection_id);
4004                    }
4005                    sessions.borrow_mut().remove(&upgrade_id);
4006                    emit_wasm_carrier_action(
4007                        &handler,
4008                        serde_json::json!({
4009                            "type": "retire-webrtc",
4010                            "connectionId": connection_id,
4011                            "upgradeId": upgrade_id,
4012                            "failureCode": reason,
4013                        }),
4014                    );
4015                });
4016            });
4017            let carrier = crate::wasm_webrtc_carrier::WasmWebRtcCarrierSession::attach(
4018                channel,
4019                packet_session,
4020                expected,
4021                application_key,
4022                on_terminal,
4023            )?;
4024            self.wasm_webrtc_carrier_sessions
4025                .borrow_mut()
4026                .insert(upgrade_id.clone(), carrier);
4027            if attempt.role == "responder" {
4028                let ready = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::ready_from(
4029                    &attempt.bootstrap,
4030                )
4031                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4032                emit_wasm_carrier_action(
4033                    &self.wasm_carrier_action_handler,
4034                    serde_json::json!({
4035                        "type": "send-control",
4036                        "connectionId": connection_id,
4037                        "remoteEndpointId": remote_endpoint_id,
4038                        "envelope": ready,
4039                    }),
4040                );
4041                self.spawn_inbound_wasm_webrtc_carrier_completion(attempt);
4042            } else if let Some(attempt) =
4043                self.take_ready_outbound_wasm_webrtc_carrier_attempt(&connection_id, &upgrade_id)
4044            {
4045                self.spawn_outbound_wasm_webrtc_carrier_completion(attempt);
4046            }
4047            Ok(())
4048        }
4049
4050        /// Attach the exact directed Draft 14 MoQ reliable object-stream duplex for a
4051        /// generation-current Rust-owned attempt. This boundary cannot create
4052        /// an attempt or select a replacement.
4053        #[cfg(feature = "transport-moq")]
4054        #[wasm_bindgen(js_name = __attachMoqCarrier)]
4055        pub async fn attach_iroh_moq_carrier(
4056            &self,
4057            connection_id: String,
4058            remote_endpoint_id: String,
4059            datagrams: JsValue,
4060            upgrade_id: String,
4061        ) -> Result<(), JsValue> {
4062            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
4063            let endpoint_id = remote_endpoint_id
4064                .parse::<iroh::EndpointId>()
4065                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4066            let attempt = self
4067                .wasm_moq_carrier_attempts
4068                .borrow()
4069                .get(&connection_id)
4070                .filter(|attempt| {
4071                    attempt.bootstrap.upgrade_id == upgrade_id
4072                        && attempt.remote_endpoint_id == endpoint_id
4073                })
4074                .cloned()
4075                .ok_or_else(|| JsValue::from_str("MoQ carrier attempt is stale"))?;
4076            if !self
4077                .inner
4078                .wasm_carrier_upgrade_is_current(
4079                    &connection_id,
4080                    crate::client::IrohPathKind::Moq,
4081                    &upgrade_id,
4082                    attempt.generation,
4083                )
4084                .await
4085            {
4086                return Err(JsValue::from_str("MoQ carrier attempt was retired"));
4087            }
4088            let packet_session = self
4089                .inner
4090                .activate_iroh_packet_carrier(
4091                    crate::iroh_carrier_kind::IrohCarrierKind::Moq,
4092                    endpoint_id,
4093                )
4094                .await
4095                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4096            let expected = attempt
4097                .bootstrap
4098                .frame_expectation()
4099                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4100            let application_key = self
4101                .inner
4102                .application_crypto_key_for_connection(Some(&connection_id))
4103                .ok_or_else(|| {
4104                    JsValue::from_str("MoQ carrier requires an installed application key")
4105                })?;
4106            let terminal_client = self.inner.clone();
4107            let terminal_connection_id = connection_id.clone();
4108            let terminal_upgrade_id = upgrade_id.clone();
4109            let terminal_generation = attempt.generation.transport_generation.saturating_add(1);
4110            let terminal_attempts = self.wasm_moq_carrier_attempts.clone();
4111            let terminal_sessions = self.wasm_moq_carrier_sessions.clone();
4112            let terminal_handler = self.wasm_carrier_action_handler.clone();
4113            let terminal_lifecycle = self.wasm_carrier_peer_lifecycle.clone();
4114            let on_terminal: Rc<dyn Fn(&'static str)> = Rc::new(move |reason| {
4115                let client = terminal_client.clone();
4116                let connection_id = terminal_connection_id.clone();
4117                let upgrade_id = terminal_upgrade_id.clone();
4118                let attempts = terminal_attempts.clone();
4119                let sessions = terminal_sessions.clone();
4120                let handler = terminal_handler.clone();
4121                let lifecycle = terminal_lifecycle.clone();
4122                spawn_local(async move {
4123                    let _lifecycle = lifecycle.lock().await;
4124                    if !client
4125                        .close_current_iroh_carrier_generation_with_reason(
4126                            &connection_id,
4127                            endpoint_id,
4128                            crate::client::IrohPathKind::Moq,
4129                            terminal_generation,
4130                            reason,
4131                        )
4132                        .await
4133                    {
4134                        return;
4135                    }
4136                    if attempts
4137                        .borrow()
4138                        .get(&connection_id)
4139                        .is_some_and(|current| current.bootstrap.upgrade_id == upgrade_id)
4140                    {
4141                        attempts.borrow_mut().remove(&connection_id);
4142                    }
4143                    sessions.borrow_mut().remove(&upgrade_id);
4144                    emit_wasm_carrier_action(
4145                        &handler,
4146                        serde_json::json!({
4147                            "type": "retire-moq",
4148                            "connectionId": connection_id,
4149                            "upgradeId": upgrade_id,
4150                            "failureCode": reason,
4151                        }),
4152                    );
4153                });
4154            });
4155            let carrier = crate::wasm_moq_carrier::WasmMoqCarrierSession::attach(
4156                datagrams,
4157                packet_session,
4158                expected,
4159                application_key,
4160                on_terminal,
4161            )?;
4162            if attempt.role == "initiator" {
4163                carrier
4164                    .wait_for_peer_data_bidirectional_readiness(std::time::Duration::from_secs(15))
4165                    .await?;
4166            }
4167            self.wasm_moq_carrier_sessions
4168                .borrow_mut()
4169                .insert(upgrade_id.clone(), carrier);
4170            if attempt.role == "initiator" {
4171                self.spawn_outbound_wasm_moq_carrier_completion(attempt);
4172            } else {
4173                let ready = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::ready_from(
4174                    &attempt.bootstrap,
4175                )
4176                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4177                emit_wasm_carrier_action(
4178                    &self.wasm_carrier_action_handler,
4179                    serde_json::json!({
4180                        "type": "send-control",
4181                        "connectionId": connection_id,
4182                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
4183                        "envelope": ready,
4184                    }),
4185                );
4186                self.spawn_inbound_wasm_moq_carrier_completion(attempt);
4187            }
4188            Ok(())
4189        }
4190
4191        #[wasm_bindgen(js_name = setIdentityCredential)]
4192        pub fn set_identity_credential(&self, credential: Option<String>) {
4193            let has_credential = credential
4194                .as_ref()
4195                .map(|value| !value.is_empty())
4196                .unwrap_or(false);
4197            let credential_len = credential.as_ref().map(|value| value.len()).unwrap_or(0);
4198
4199            if let Ok(mut guard) = self.identity_credential.lock() {
4200                *guard = credential.filter(|value| !value.is_empty());
4201            }
4202
4203            let should_log = if let Ok(mut guard) = self.last_auth_log.lock() {
4204                let next = (has_credential, credential_len);
4205                if guard.as_ref() == Some(&next) {
4206                    false
4207                } else {
4208                    *guard = Some(next);
4209                    true
4210                }
4211            } else {
4212                true
4213            };
4214
4215            if should_log {
4216                web_sys::console::log_1(&JsValue::from_str(&format!(
4217                    "[OPENRTC][WASM-IDENTITY] credential updated present={} len={}",
4218                    has_credential, credential_len
4219                )));
4220            }
4221        }
4222
4223        /// Clear the provider-owned identity assertion without routing an
4224        /// optional string through the wasm-bindgen ABI. Some generated
4225        /// bindings retain the previous string length when `null` is passed
4226        /// for `Option<String>`, which can turn a legitimate logout into a
4227        /// null-pointer trap before Rust receives the call.
4228        #[wasm_bindgen(js_name = clearIdentityCredential)]
4229        pub fn clear_identity_credential(&self) {
4230            self.set_identity_credential(None);
4231        }
4232
4233        /// Normalize and rank eligible route labels using the Rust-owned pure
4234        /// policy. This does not dial, retry, promote, demote, or mutate state.
4235        #[wasm_bindgen(js_name = rankRoutes)]
4236        pub fn rank_routes(
4237            &self,
4238            configured_priority: Vec<String>,
4239            candidates: Vec<String>,
4240        ) -> Vec<String> {
4241            crate::route_policy::rank_routes(&configured_priority, &candidates)
4242        }
4243
4244        /// Apply relay eligibility before the Iroh endpoint is created.
4245        #[wasm_bindgen(js_name = setRelay)]
4246        pub async fn set_relay(&self, enabled: bool) -> Result<(), JsValue> {
4247            self.inner
4248                .set_relay(enabled)
4249                .await
4250                .map_err(|error| JsValue::from_str(&error.to_string()))
4251        }
4252
4253        pub async fn init_iroh(&self, secret_key: Option<Vec<u8>>) -> Result<String, JsValue> {
4254            let started_at = js_sys::Date::now();
4255            web_sys::console::log_1(&JsValue::from_str(&format!(
4256                "[OPENRTC][WASM-API] init_iroh called has_secret_key={} secret_key_len={}",
4257                secret_key.as_ref().is_some(),
4258                secret_key.as_ref().map(|k| k.len()).unwrap_or(0)
4259            )));
4260            match self.inner.init_iroh(secret_key, vec![]).await {
4261                Ok(node_id) => {
4262                    self.inner.clone().start_wasm_accept_bridge();
4263                    let elapsed = js_sys::Date::now() - started_at;
4264                    web_sys::console::log_1(&JsValue::from_str(&format!(
4265                        "[OPENRTC][WASM-API] init_iroh success elapsed_ms={:.0} node_id={}",
4266                        elapsed, node_id
4267                    )));
4268                    Ok(node_id)
4269                }
4270                Err(err) => {
4271                    let elapsed = js_sys::Date::now() - started_at;
4272                    web_sys::console::error_1(&JsValue::from_str(&format!(
4273                        "[OPENRTC][WASM-API] init_iroh failed elapsed_ms={:.0} error={}",
4274                        elapsed, err
4275                    )));
4276                    Err(JsValue::from_str(&err.to_string()))
4277                }
4278            }
4279        }
4280
4281        /// Local-harness-only endpoint initialization. The Rust runtime rejects
4282        /// every non-loopback or non-HTTPS relay URL before binding.
4283        #[wasm_bindgen(js_name = initIrohWithTestRelay)]
4284        pub async fn init_iroh_with_test_relay(
4285            &self,
4286            secret_key: Option<Vec<u8>>,
4287            test_relay_url: Option<String>,
4288        ) -> Result<String, JsValue> {
4289            let node_id = self
4290                .inner
4291                .init_iroh_with_test_relay(secret_key, vec![], test_relay_url.as_deref())
4292                .await
4293                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4294            // The local relay changes only relay selection. It must retain the
4295            // normal browser connection lifecycle, including the accepted
4296            // transport bridge that promotes an inbound replacement generation.
4297            self.inner.clone().start_wasm_accept_bridge();
4298            Ok(node_id)
4299        }
4300
4301        pub async fn iroh_secret_key(&self) -> Result<Vec<u8>, JsValue> {
4302            let node_guard = self.inner.iroh_node.read().await;
4303            if let Some(node) = node_guard.as_ref() {
4304                Ok(node.secret_key())
4305            } else {
4306                Err(JsValue::from_str("Iroh node not initialized"))
4307            }
4308        }
4309
4310        pub async fn node_addr(&self) -> Result<String, JsValue> {
4311            let node_guard = self.inner.iroh_node.read().await;
4312            if let Some(node) = node_guard.as_ref() {
4313                let addr = node
4314                    .node_addr()
4315                    .await
4316                    .map_err(|e| JsValue::from_str(&e.to_string()))?;
4317                serde_json::to_string(&addr).map_err(|e| JsValue::from_str(&e.to_string()))
4318            } else {
4319                Err(JsValue::from_str("Iroh node not initialized"))
4320            }
4321        }
4322
4323        pub async fn endpoint_ticket(&self) -> Result<String, JsValue> {
4324            self.inner
4325                .endpoint_ticket()
4326                .await
4327                .map_err(|e| JsValue::from_str(&e.to_string()))
4328        }
4329
4330        /// Build a compound ticket with an embedded session token.
4331        /// Registers the token on the canonical Rust client and returns the compound ticket string.
4332        /// `scope`: logical label (e.g. "share"). `max_connections`: 0 = unlimited.
4333        pub async fn endpoint_ticket_with_token(
4334            &self,
4335            grant_scope: String,
4336            max_connections: u32,
4337        ) -> Result<String, JsValue> {
4338            self.inner
4339                .endpoint_ticket_with_token(&grant_scope, max_connections)
4340                .await
4341                .map_err(|e| JsValue::from_str(&e.to_string()))
4342        }
4343
4344        /// Mint a transferable ticket invitation with Rust-owned participant
4345        /// capacity on this existing WASM peer runtime.
4346        pub async fn issue_ticket_invite(
4347            &self,
4348            id: String,
4349            max_peers: u32,
4350            mesh: bool,
4351        ) -> Result<String, JsValue> {
4352            let invite = if mesh {
4353                self.inner
4354                    .issue_ticket_invite(&id, crate::ticket_mesh::TicketMeshOptions { max_peers })
4355                    .await
4356            } else {
4357                self.inner.issue_direct_ticket_invite(&id, max_peers).await
4358            };
4359            invite
4360                .map(|invite| invite.encode())
4361                .map_err(|error| JsValue::from_str(&error.to_string()))
4362        }
4363
4364        /// Start one Rust-owned ticket mesh on this existing WASM endpoint.
4365        /// The callback only executes requested browser route adoption or
4366        /// protected frame delivery; Rust decides retries and membership.
4367        pub async fn issue_ticket_mesh(
4368            &self,
4369            id: String,
4370            max_peers: u32,
4371            action_handler: js_sys::Function,
4372        ) -> Result<String, JsValue> {
4373            if self.ticket_meshes.borrow().contains_key(&id)
4374                || !self.ticket_mesh_starts.borrow_mut().insert(id.clone())
4375            {
4376                return Err(JsValue::from_str("ticket mesh already has an owner"));
4377            }
4378            let _start = TicketMeshStartGuard {
4379                starts: &self.ticket_mesh_starts,
4380                id: id.clone(),
4381            };
4382            let mesh = crate::ticket_mesh::wasm_session::WasmTicketMesh::issue(
4383                self.inner.clone(),
4384                &id,
4385                crate::ticket_mesh::TicketMeshOptions { max_peers },
4386                action_handler,
4387            )
4388            .await
4389            .map_err(|error| JsValue::from_str(&error.to_string()))?;
4390            let invitation = mesh.invite();
4391            self.ticket_meshes.borrow_mut().insert(id, Rc::new(mesh));
4392            Ok(invitation)
4393        }
4394
4395        pub async fn join_ticket_mesh(
4396            &self,
4397            invitation: String,
4398            action_handler: js_sys::Function,
4399        ) -> Result<String, JsValue> {
4400            let invite = crate::ticket_mesh::TicketInvite::parse(&invitation)
4401                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4402            let id = invite.id().to_string();
4403            if self.ticket_meshes.borrow().contains_key(&id)
4404                || !self.ticket_mesh_starts.borrow_mut().insert(id.clone())
4405            {
4406                return Err(JsValue::from_str("ticket mesh already has an owner"));
4407            }
4408            let _start = TicketMeshStartGuard {
4409                starts: &self.ticket_mesh_starts,
4410                id: id.clone(),
4411            };
4412            let mesh = crate::ticket_mesh::wasm_session::WasmTicketMesh::join(
4413                self.inner.clone(),
4414                &invitation,
4415                action_handler,
4416            )
4417            .await
4418            .map_err(|error| JsValue::from_str(&error.to_string()))?;
4419            self.ticket_meshes
4420                .borrow_mut()
4421                .insert(id.clone(), Rc::new(mesh));
4422            Ok(id)
4423        }
4424
4425        pub async fn ticket_mesh_peers(&self, id: String) -> Result<JsValue, JsValue> {
4426            let mesh = self
4427                .ticket_meshes
4428                .borrow()
4429                .get(&id)
4430                .cloned()
4431                .ok_or_else(|| JsValue::from_str("ticket mesh is not active"))?;
4432            serde_wasm_bindgen::to_value(&mesh.peers().await)
4433                .map_err(|error| JsValue::from_str(&error.to_string()))
4434        }
4435
4436        pub fn ticket_mesh_issuer_node(&self, id: String) -> Result<String, JsValue> {
4437            let mesh = self
4438                .ticket_meshes
4439                .borrow()
4440                .get(&id)
4441                .cloned()
4442                .ok_or_else(|| JsValue::from_str("ticket mesh is not active"))?;
4443            mesh.issuer_node()
4444                .map_err(|error| JsValue::from_str(&error.to_string()))
4445        }
4446
4447        pub fn receive_ticket_mesh_frame(
4448            &self,
4449            id: String,
4450            connection_id: String,
4451            frame: String,
4452        ) -> Result<(), JsValue> {
4453            let mesh = self
4454                .ticket_meshes
4455                .borrow()
4456                .get(&id)
4457                .cloned()
4458                .ok_or_else(|| JsValue::from_str("ticket mesh is not active"))?;
4459            mesh.receive(connection_id, frame.into_bytes());
4460            Ok(())
4461        }
4462
4463        pub async fn close_ticket_mesh(&self, id: String) -> Result<(), JsValue> {
4464            if self.ticket_mesh_starts.borrow().contains(&id) {
4465                return Err(JsValue::from_str(
4466                    "ticket mesh is still starting; await startup before closing",
4467                ));
4468            }
4469            let mesh = self.ticket_meshes.borrow().get(&id).cloned();
4470            if let Some(mesh) = mesh {
4471                mesh.close().await;
4472                let mut meshes = self.ticket_meshes.borrow_mut();
4473                if meshes
4474                    .get(&id)
4475                    .is_some_and(|current| Rc::ptr_eq(current, &mesh))
4476                {
4477                    meshes.remove(&id);
4478                }
4479            }
4480            Ok(())
4481        }
4482
4483        /// Present the issuer's ticket once; the mesh session owner handles
4484        /// sustained recovery after the initial join.
4485        pub async fn connect_ticket_invite(&self, invitation: String) -> Result<JsValue, JsValue> {
4486            let result = self
4487                .inner
4488                .connect_ticket_invite(&invitation)
4489                .await
4490                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4491            serde_wasm_bindgen::to_value(&result)
4492                .map_err(|error| JsValue::from_str(&error.to_string()))
4493        }
4494
4495        /// Private gateway adapter input; this does not grant a peer permission.
4496        #[wasm_bindgen(js_name = __requireScopePeerAdmission)]
4497        pub fn require_scope_peer_admission(&self, scope: String) -> Result<(), JsValue> {
4498            self.inner
4499                .session_token_registry
4500                .require_scope_peer_admission(&scope)
4501                .map_err(|error| JsValue::from_str(&error))
4502        }
4503
4504        /// Complete receiver permissions from a verified gateway topology lease.
4505        /// The adapter owns socket/grant validation; Rust owns every peer verdict.
4506        #[wasm_bindgen(js_name = __updateScopePeerAdmission)]
4507        pub fn update_scope_peer_admission(
4508            &self,
4509            scope: String,
4510            revision: f64,
4511            expires_at_ms: f64,
4512            peers_json: String,
4513        ) -> Result<(), JsValue> {
4514            let valid_integer = |value: f64| {
4515                value.is_finite()
4516                    && value >= 0.0
4517                    && value <= 9_007_199_254_740_991.0
4518                    && value.fract() == 0.0
4519            };
4520            if !valid_integer(revision) || !valid_integer(expires_at_ms) || peers_json.len() > 4_000
4521            {
4522                return Err(JsValue::from_str("invalid peer admission lease"));
4523            }
4524            let peers = serde_json::from_str(&peers_json)
4525                .map_err(|_| JsValue::from_str("invalid peer admission peers"))?;
4526            self.inner
4527                .session_token_registry
4528                .update_scope_peer_admission(&scope, revision as u64, expires_at_ms as u64, peers)
4529                .map_err(|error| JsValue::from_str(&error))
4530        }
4531
4532        /// Register a session token on the canonical Rust client.
4533        pub fn register_session_token(
4534            &self,
4535            token: String,
4536            grant_scope: String,
4537            max_connections: u32,
4538        ) {
4539            self.inner
4540                .register_session_token(token, grant_scope, max_connections);
4541        }
4542
4543        /// Register a session token with an absolute Unix-millisecond expiry.
4544        pub fn register_token_until(
4545            &self,
4546            token: String,
4547            grant_scope: String,
4548            max_connections: u32,
4549            expires_at_ms: u64,
4550        ) {
4551            self.inner
4552                .register_token_until(token, grant_scope, max_connections, expires_at_ms);
4553        }
4554
4555        /// Mark a connection as requiring application crypto on native outbound paths.
4556        #[wasm_bindgen(js_name = setConnectionApplicationCryptoRequired)]
4557        pub fn set_connection_application_crypto_required(
4558            &self,
4559            connection_id: String,
4560        ) -> Result<(), JsValue> {
4561            self.inner
4562                .set_connection_application_crypto_required(&connection_id);
4563            Ok(())
4564        }
4565
4566        /// Install a negotiated per-connection application crypto key for native send paths.
4567        #[wasm_bindgen(js_name = setConnectionApplicationCryptoKey)]
4568        pub async fn set_connection_application_crypto_key(
4569            &self,
4570            connection_id: String,
4571            key: Vec<u8>,
4572        ) -> Result<(), JsValue> {
4573            if key.len() != crate::application_crypto::APPLICATION_KEY_BYTES {
4574                return Err(JsValue::from_str("application crypto key must be 32 bytes"));
4575            }
4576            let mut key_bytes = [0u8; crate::application_crypto::APPLICATION_KEY_BYTES];
4577            key_bytes.copy_from_slice(&key);
4578            self.inner
4579                .set_connection_application_crypto_key(&connection_id, key_bytes);
4580            self.inner
4581                .emit_current_wasm_connection_state(&connection_id)
4582                .await;
4583            Ok(())
4584        }
4585
4586        /// Retire the negotiated application key and ephemeral agreement for a
4587        /// logical connection. Connection ids may be reused after an ACL revoke
4588        /// and regrant, so lifecycle cleanup must clear the WASM runtime together
4589        /// with the TypeScript crypto indexes before a fresh handshake begins.
4590        #[wasm_bindgen(js_name = clearConnectionApplicationCryptoKey)]
4591        pub fn clear_connection_application_crypto_key(&self, connection_id: String) {
4592            self.inner
4593                .clear_connection_application_crypto_key(&connection_id);
4594        }
4595
4596        /// Negotiate and confirm the current generation's application key.
4597        /// TypeScript awaits this operation only to project readiness; Rust
4598        /// owns key material, framing, retries, and generation invalidation.
4599        #[wasm_bindgen(js_name = ensureConnectionApplicationCrypto)]
4600        pub async fn ensure_connection_application_crypto(
4601            &self,
4602            connection_id: String,
4603            remote_node_id: String,
4604            timeout_ms: u64,
4605            send_frame: js_sys::Function,
4606        ) -> Result<(), JsValue> {
4607            let connection_id = connection_id.trim().to_string();
4608            if connection_id.is_empty() {
4609                return Err(JsValue::from_str("connection id is required"));
4610            }
4611            let remote_node_id = remote_node_id.trim().to_string();
4612            if remote_node_id.is_empty() {
4613                return Err(JsValue::from_str("remote node id is required"));
4614            }
4615            let remote_endpoint_id: iroh::EndpointId = remote_node_id.parse().map_err(|error| {
4616                JsValue::from_str(&format!("invalid remote endpoint id: {error}"))
4617            })?;
4618            // The native-main opener and the key-agreement waiter must capture
4619            // the same Rust-owned generation. A legacy/early browser
4620            // projection can already be Connected while its stable ID is
4621            // absent or stale; repair that projection from the physical owner
4622            // before freezing the transcript generation below.
4623            self.inner
4624                .ensure_connection_manager_record_before_peer_stream(&remote_endpoint_id)
4625                .await
4626                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4627            let timeout_ms = timeout_ms.max(1);
4628            let initial = self
4629                .inner
4630                .connection_manager
4631                .get_by_connection_id(&connection_id)
4632                .await
4633                .ok_or_else(|| JsValue::from_str("connection is not registered"))?;
4634            let initial_endpoint = initial
4635                .endpoint_id
4636                .as_deref()
4637                .or(initial.node_id.as_deref())
4638                .map(ToOwned::to_owned)
4639                .ok_or_else(|| JsValue::from_str("connection has no endpoint"))?;
4640            let initial_transport_stable_id = initial.transport_stable_id;
4641            let initial_transport_generation = initial.transport_generation;
4642            let initial_route_generation = initial.route_generation;
4643            self.inner
4644                .set_connection_application_crypto_required(&connection_id);
4645
4646            let started_at_ms = js_sys::Date::now();
4647            let retry_delays_ms = [0_u64, 250, 750];
4648            for (attempt, delay_ms) in retry_delays_ms.into_iter().enumerate() {
4649                if delay_ms > 0 {
4650                    let remaining_ms = timeout_ms
4651                        .saturating_sub((js_sys::Date::now() - started_at_ms).max(0.0) as u64);
4652                    if remaining_ms == 0 {
4653                        break;
4654                    }
4655                    gloo_timers::future::sleep(std::time::Duration::from_millis(
4656                        delay_ms.min(remaining_ms),
4657                    ))
4658                    .await;
4659                }
4660                self.inner
4661                    .assert_application_crypto_generation(
4662                        &connection_id,
4663                        &initial_endpoint,
4664                        initial_transport_stable_id,
4665                        initial_transport_generation,
4666                        initial_route_generation,
4667                    )
4668                    .await
4669                    .map_err(|error| JsValue::from_str(&error.to_string()))?;
4670                if self
4671                    .inner
4672                    .connection_application_crypto_key(&connection_id)
4673                    .is_some()
4674                    && self.inner.connection_application_crypto_is_confirmed(
4675                        &connection_id,
4676                        initial_transport_stable_id,
4677                    )
4678                {
4679                    let confirmed_transport_stable_id =
4680                        initial_transport_stable_id.ok_or_else(|| {
4681                            JsValue::from_str("confirmed connection has no stable ID")
4682                        })?;
4683                    if !self
4684                        .inner
4685                        .confirm_managed_connection_readiness_from_transport_proof(
4686                            &connection_id,
4687                            confirmed_transport_stable_id,
4688                        )
4689                        .await
4690                    {
4691                        return Err(JsValue::from_str(
4692                            "confirmed application route belongs to a retired generation",
4693                        ));
4694                    }
4695                    self.inner
4696                        .emit_current_wasm_connection_state(&connection_id)
4697                        .await;
4698                    return Ok(());
4699                }
4700                let frame = self
4701                    .inner
4702                    .application_key_handshake_frame(&connection_id, "capability-update")
4703                    .map_err(|error| JsValue::from_str(&error.to_string()))?;
4704                let frame = serde::Serialize::serialize(
4705                    &frame,
4706                    &serde_wasm_bindgen::Serializer::json_compatible(),
4707                )
4708                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4709                let send_result = send_frame.call1(&JsValue::NULL, &frame)?;
4710                wasm_bindgen_futures::JsFuture::from(js_sys::Promise::resolve(&send_result))
4711                    .await?;
4712
4713                let attempt_deadline_ms = ((attempt + 1) as u64 * timeout_ms / 3).max(1);
4714                loop {
4715                    if self
4716                        .inner
4717                        .connection_application_crypto_key(&connection_id)
4718                        .is_some()
4719                        && self.inner.connection_application_crypto_is_confirmed(
4720                            &connection_id,
4721                            initial_transport_stable_id,
4722                        )
4723                    {
4724                        let confirmed_transport_stable_id = initial_transport_stable_id
4725                            .ok_or_else(|| {
4726                                JsValue::from_str("confirmed connection has no stable ID")
4727                            })?;
4728                        if !self
4729                            .inner
4730                            .confirm_managed_connection_readiness_from_transport_proof(
4731                                &connection_id,
4732                                confirmed_transport_stable_id,
4733                            )
4734                            .await
4735                        {
4736                            return Err(JsValue::from_str(
4737                                "confirmed application route belongs to a retired generation",
4738                            ));
4739                        }
4740                        self.inner
4741                            .emit_current_wasm_connection_state(&connection_id)
4742                            .await;
4743                        return Ok(());
4744                    }
4745                    let elapsed_ms = (js_sys::Date::now() - started_at_ms).max(0.0) as u64;
4746                    if elapsed_ms >= attempt_deadline_ms || elapsed_ms >= timeout_ms {
4747                        break;
4748                    }
4749                    self.inner
4750                        .assert_application_crypto_generation(
4751                            &connection_id,
4752                            &initial_endpoint,
4753                            initial_transport_stable_id,
4754                            initial_transport_generation,
4755                            initial_route_generation,
4756                        )
4757                        .await
4758                        .map_err(|error| JsValue::from_str(&error.to_string()))?;
4759                    gloo_timers::future::sleep(std::time::Duration::from_millis(25)).await;
4760                }
4761            }
4762            let current_manager_transport_stable_id = self
4763                .inner
4764                .connection_manager
4765                .get_by_connection_id(&connection_id)
4766                .await
4767                .and_then(|record| record.transport_stable_id);
4768            let current_physical_transport_stable_id = self
4769                .inner
4770                .get_connection(remote_endpoint_id)
4771                .await
4772                .map(|connection| crate::transport_generation::for_connection(&connection));
4773            let confirmation = self
4774                .inner
4775                .connection_application_crypto_confirmation_snapshot(&connection_id);
4776            return Err(JsValue::from_str(&format!(
4777                "connection {} reciprocal application key agreement timed out after {}ms: frozen_stable_id={:?} current_manager_stable_id={:?} current_physical_stable_id={:?} confirmation={:?}",
4778                connection_id,
4779                timeout_ms,
4780                initial_transport_stable_id,
4781                current_manager_transport_stable_id,
4782                current_physical_transport_stable_id,
4783                confirmation,
4784            )));
4785        }
4786
4787        #[wasm_bindgen(js_name = handleConnectionApplicationCryptoHandshake)]
4788        pub async fn handle_connection_application_crypto_handshake(
4789            &self,
4790            connection_id: String,
4791            remote_node_id: String,
4792            transport_stable_id: Option<u64>,
4793            action: Option<String>,
4794            remote_public_key: Vec<u8>,
4795        ) -> Result<JsValue, JsValue> {
4796            if remote_public_key.len() != crate::key_agreement::PUBLIC_KEY_BYTES {
4797                return Err(JsValue::from_str(
4798                    "application key agreement public key must be 32 bytes",
4799                ));
4800            }
4801            let mut remote_public = [0_u8; crate::key_agreement::PUBLIC_KEY_BYTES];
4802            remote_public.copy_from_slice(&remote_public_key);
4803            let outcome = self
4804                .inner
4805                .accept_application_key_handshake(
4806                    &connection_id,
4807                    &remote_node_id,
4808                    transport_stable_id,
4809                    action.as_deref(),
4810                    remote_public,
4811                )
4812                .await
4813                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4814            let reply_frame = outcome
4815                .reply_action
4816                .map(|reply_action| {
4817                    self.inner
4818                        .application_key_handshake_frame(&connection_id, reply_action)
4819                })
4820                .transpose()
4821                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4822            self.inner
4823                .emit_current_wasm_connection_state(&connection_id)
4824                .await;
4825            serde::Serialize::serialize(
4826                &reply_frame,
4827                &serde_wasm_bindgen::Serializer::json_compatible(),
4828            )
4829            .map_err(|error| JsValue::from_str(&error.to_string()))
4830        }
4831
4832        #[wasm_bindgen(js_name = connectionApplicationCryptoReady)]
4833        pub fn connection_application_crypto_ready(
4834            &self,
4835            connection_id: String,
4836            transport_stable_id: u64,
4837        ) -> bool {
4838            self.inner
4839                .connection_application_crypto_key(&connection_id)
4840                .is_some()
4841                && self.inner.connection_application_crypto_is_confirmed(
4842                    &connection_id,
4843                    Some(transport_stable_id),
4844                )
4845        }
4846
4847        #[wasm_bindgen(js_name = protectConnectionApplicationPayload)]
4848        pub fn protect_connection_application_payload(
4849            &self,
4850            connection_id: String,
4851            type_id: u8,
4852            payload: Vec<u8>,
4853        ) -> Result<Vec<u8>, JsValue> {
4854            self.inner
4855                .protect_outbound_application_payload_with_type(&connection_id, type_id, &payload)
4856                .map_err(|error| JsValue::from_str(&error.to_string()))
4857        }
4858
4859        #[wasm_bindgen(js_name = openConnectionApplicationPayload)]
4860        pub fn open_connection_application_payload(
4861            &self,
4862            connection_id: String,
4863            type_id: u8,
4864            payload: Vec<u8>,
4865        ) -> Result<Vec<u8>, JsValue> {
4866            self.inner
4867                .open_inbound_application_payload_with_type(&connection_id, type_id, &payload)
4868                .map_err(|error| JsValue::from_str(&error.to_string()))
4869        }
4870
4871        /// Validate (and consume one use of) a session token.
4872        /// Returns the scope string on success, throws on failure.
4873        /// If the registry is empty, always succeeds (backward-compat gate).
4874        pub fn validate_session_token(&self, token: String) -> Result<String, JsValue> {
4875            self.inner
4876                .validate_session_token(&token)
4877                .map_err(|e| JsValue::from_str(&e))
4878        }
4879
4880        /// Validate and record token admission for a specific connection.
4881        pub async fn validate_connection_token(
4882            &self,
4883            token: String,
4884            connection_id: String,
4885            token_payload: Option<String>,
4886        ) -> Result<String, JsValue> {
4887            self.inner
4888                .validate_connection_token(&token, &connection_id, token_payload.as_deref())
4889                .await
4890                .map_err(|e| JsValue::from_str(&e))
4891        }
4892
4893        /// Present a session token to the remote host over the SDK-owned
4894        /// native main stream before application traffic starts.
4895        /// Returns the approved scope string once the host acknowledges admission.
4896        ///
4897        /// Requires a managed transport record: call [`Self::connect_device`]
4898        /// (or another dial that runs `ensure_connected_addr`) before presenting.
4899        pub async fn present_session_token(
4900            &self,
4901            endpoint_id: String,
4902            token: String,
4903            token_payload: Option<String>,
4904            device_id: Option<String>,
4905        ) -> Result<String, JsValue> {
4906            crate::console_log!(
4907                "[OpenRTC][session-admission][wasm-present] endpoint_id={} claimed_local_device_id={}",
4908                endpoint_id,
4909                device_id.as_deref().unwrap_or("<none>")
4910            );
4911            let endpoint_id_parsed: iroh::EndpointId = endpoint_id
4912                .parse()
4913                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
4914            let local_node_id = self.inner.current_node_id().await.ok_or_else(|| {
4915                JsValue::from_str("missing local node id for session-token presentation")
4916            })?;
4917            let connection_id =
4918                crate::client::Client::deterministic_connection_id(&local_node_id, &endpoint_id);
4919            let approval_scope = self
4920                .inner
4921                .present_and_accept_session_token_with_local_claim(
4922                    endpoint_id_parsed,
4923                    &connection_id,
4924                    &token,
4925                    token_payload.as_deref(),
4926                    None,
4927                    device_id,
4928                )
4929                .await
4930                .map_err(|e| JsValue::from_str(&e))?;
4931            self.inner
4932                .emit_current_wasm_connection_state(&connection_id)
4933                .await;
4934
4935            Ok(approval_scope)
4936        }
4937
4938        /// Report whether this runtime has the current transport-generation
4939        /// outbound admission proof required by an endpoint ticket.
4940        pub async fn is_remote_admitted(&self, endpoint_ticket: String) -> Result<bool, JsValue> {
4941            self.inner
4942                .is_remote_admitted(&endpoint_ticket)
4943                .await
4944                .map_err(|error| JsValue::from_str(&error.to_string()))
4945        }
4946
4947        /// Fence one browser-relayed reciprocal presentation in the Rust
4948        /// admission owner before the adapter writes it to the stream.
4949        #[allow(clippy::too_many_arguments)]
4950        pub async fn prepare_reciprocal_admission(
4951            &self,
4952            endpoint_id: String,
4953            expected_transport_stable_id: u64,
4954            stream_instance_id: String,
4955            presentation_id: String,
4956            token: String,
4957            token_payload: String,
4958            device_id: String,
4959            stream_contract: String,
4960        ) -> Result<bool, JsValue> {
4961            let endpoint_id: iroh::EndpointId = endpoint_id
4962                .parse()
4963                .map_err(|error| JsValue::from_str(&format!("{error}")))?;
4964            let stream_contract = match stream_contract.trim() {
4965                "one-shot-admission" => {
4966                    crate::native_protocol::TokenStreamContract::OneShotAdmission
4967                }
4968                "persistent-control" => {
4969                    crate::native_protocol::TokenStreamContract::PersistentControl
4970                }
4971                other => {
4972                    return Err(JsValue::from_str(&format!(
4973                        "unsupported reciprocal stream contract: {other}"
4974                    )))
4975                }
4976            };
4977            self.inner
4978                .prepare_reciprocal_admission(
4979                    endpoint_id,
4980                    expected_transport_stable_id,
4981                    stream_instance_id.as_str(),
4982                    presentation_id.as_str(),
4983                    token.as_str(),
4984                    token_payload.as_str(),
4985                    device_id.as_str(),
4986                    stream_contract,
4987                )
4988                .await
4989                .map_err(|error| JsValue::from_str(&error))?;
4990            Ok(true)
4991        }
4992
4993        /// Commit an inline reciprocal session admission only when the ACK
4994        /// belongs to the exact Rust-owned transcript and physical generation.
4995        pub async fn confirm_reciprocal_admission(
4996            &self,
4997            endpoint_id: String,
4998            expected_transport_stable_id: u64,
4999            stream_instance_id: String,
5000            presentation_id: String,
5001            accepted: bool,
5002            approval_scope: Option<String>,
5003        ) -> Result<bool, JsValue> {
5004            let endpoint_id: iroh::EndpointId = endpoint_id
5005                .parse()
5006                .map_err(|error| JsValue::from_str(&format!("{error}")))?;
5007            self.inner
5008                .confirm_reciprocal_admission(
5009                    endpoint_id,
5010                    expected_transport_stable_id,
5011                    stream_instance_id.as_str(),
5012                    presentation_id.as_str(),
5013                    accepted,
5014                    approval_scope.as_deref(),
5015                )
5016                .await
5017                .map_err(|error| JsValue::from_str(&error))?;
5018            self.inner
5019                .emit_current_wasm_connection_state(
5020                    &crate::client::Client::deterministic_connection_id(
5021                        &self.inner.current_node_id().await.ok_or_else(|| {
5022                            JsValue::from_str(
5023                                "missing local node id after reciprocal admission ACK",
5024                            )
5025                        })?,
5026                        &endpoint_id.to_string(),
5027                    ),
5028                )
5029                .await;
5030            Ok(true)
5031        }
5032
5033        /// Revoke a single token by value.
5034        pub fn revoke_session_token(&self, token: String) -> Result<JsValue, JsValue> {
5035            serde_wasm_bindgen::to_value(&self.inner.revoke_session_token(&token))
5036                .map_err(|error| JsValue::from_str(&error.to_string()))
5037        }
5038
5039        /// Revoke all tokens that match the given scope and disconnect affected peers.
5040        pub async fn revoke_tokens_by_scope(
5041            &self,
5042            grant_scope: String,
5043        ) -> Result<JsValue, JsValue> {
5044            let affected = self.inner.begin_revoke_tokens_by_scope(&grant_scope);
5045            // `Client` has made the terminal authorization decision but keeps
5046            // the exact carrier generation alive long enough to send its
5047            // authenticated terminal frame. This is teardown only: it performs
5048            // no gateway operation and cannot create or retry a carrier.
5049            for connection_id in &affected {
5050                #[cfg(not(any(feature = "transport-webrtc", feature = "transport-moq")))]
5051                let _ = connection_id;
5052                #[cfg(feature = "transport-webrtc")]
5053                self.retire_iroh_webrtc_carrier(
5054                    connection_id.clone(),
5055                    Some(crate::lifecycle_reason::REASON_SESSION_TOKEN_REVOKED.to_string()),
5056                )
5057                .await;
5058                #[cfg(feature = "transport-moq")]
5059                self.retire_iroh_moq_carrier(
5060                    connection_id.clone(),
5061                    Some(crate::lifecycle_reason::REASON_SESSION_TOKEN_REVOKED.to_string()),
5062                )
5063                .await;
5064            }
5065            self.inner
5066                .finish_revoke_tokens_by_scope(&grant_scope, &affected)
5067                .await;
5068            serde_wasm_bindgen::to_value(&affected).map_err(|e| JsValue::from_str(&e.to_string()))
5069        }
5070
5071        /// Clear all short-lived session tokens and admission state.
5072        pub fn clear_session_tokens(&self) {
5073            self.inner.clear_session_tokens();
5074        }
5075
5076        pub fn endpoint_id_from_ticket(&self, ticket: String) -> Result<String, JsValue> {
5077            let (iroh_ticket, _token_suffix) = split_ticket(ticket.trim());
5078            let parsed = EndpointTicket::from_str(iroh_ticket)
5079                .map_err(|e| JsValue::from_str(&format!("Invalid endpoint ticket: {}", e)))?;
5080            Ok(parsed.endpoint_addr().id.to_string())
5081        }
5082
5083        pub async fn disconnect(&self, endpoint_id: String) -> Result<(), JsValue> {
5084            let endpoint_id: iroh::EndpointId = endpoint_id
5085                .parse()
5086                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
5087            let node_guard = self.inner.iroh_node.read().await;
5088            if let Some(node) = node_guard.as_ref() {
5089                node.disconnect(endpoint_id)
5090                    .await
5091                    .map_err(|e| JsValue::from_str(&e.to_string()))
5092            } else {
5093                Err(JsValue::from_str("Iroh node not initialized"))
5094            }
5095        }
5096
5097        /// Drop the iroh transport to a peer with a **transient** reason — a
5098        /// simulated network flap, as opposed to [`disconnect`] which signals a
5099        /// user/manual disconnect.
5100        ///
5101        /// `disconnect()` (and the generic close) reports `disconnected by user`,
5102        /// which `lifecycle_reason` classifies as `ManualDisconnect` — terminal and
5103        /// sticky: the remote will NOT auto-reconnect and WebRTC is retired
5104        /// immediately. That is correct for a real user action, but wrong for a
5105        /// transient transport drop. This variant uses a transient reason code
5106        /// (`network-change-forced-reconnect`, `is_transient()`), so both
5107        /// peers treat the drop as a recoverable transition and auto-reconnect —
5108        /// the browser equivalent of the native test harness's `irohDisconnect`.
5109        pub async fn disconnect_transient(&self, endpoint_id: String) -> Result<(), JsValue> {
5110            let endpoint_id: iroh::EndpointId = endpoint_id
5111                .parse()
5112                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
5113            self.inner
5114                .disconnect_with_reason(
5115                    endpoint_id,
5116                    crate::lifecycle_reason::REASON_NETWORK_CHANGE_RECONNECT,
5117                )
5118                .await
5119                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5120            // `disconnect_with_reason` has only an `&Client`; wake from this
5121            // WASM boundary, which owns the Arc and the single browser
5122            // desired-peer actor. This is a recoverable edge change, not a new
5123            // provider revision, so the actor would otherwise stay idle after
5124            // a previously healthy peer is marked replacement-pending.
5125            self.inner.wake_browser_auto_connect();
5126            Ok(())
5127        }
5128
5129        pub async fn is_connected(&self, endpoint_id: String) -> Result<bool, JsValue> {
5130            let endpoint_id: iroh::EndpointId = endpoint_id
5131                .parse()
5132                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
5133            Ok(self.inner.is_connected(endpoint_id).await)
5134        }
5135
5136        pub fn runtime_policy(&self) -> Result<JsValue, JsValue> {
5137            serde_wasm_bindgen::to_value(&self.inner.policy_snapshot())
5138                .map_err(|e| JsValue::from_str(&e.to_string()))
5139        }
5140
5141        pub async fn add_peer_scope(&self, id: String, scope: String) -> Result<JsValue, JsValue> {
5142            let scopes = self.inner.add_peer_scope(&id, &scope).await;
5143            serde_wasm_bindgen::to_value(&scopes).map_err(|e| JsValue::from_str(&e.to_string()))
5144        }
5145
5146        pub async fn release_peer_scope(
5147            &self,
5148            id: String,
5149            scope: Option<String>,
5150        ) -> Result<JsValue, JsValue> {
5151            let scopes = self.inner.release_peer_scope(&id, scope.as_deref()).await;
5152            serde_wasm_bindgen::to_value(&scopes).map_err(|e| JsValue::from_str(&e.to_string()))
5153        }
5154
5155        pub async fn peer_scopes(&self, id: String) -> Result<JsValue, JsValue> {
5156            let scopes = self.inner.peer_scopes(&id).await;
5157            serde_wasm_bindgen::to_value(&scopes).map_err(|e| JsValue::from_str(&e.to_string()))
5158        }
5159
5160        pub async fn same_peer(&self, left: String, right: String) -> Result<bool, JsValue> {
5161            Ok(self.inner.same_peer(&left, &right).await)
5162        }
5163
5164        pub async fn peer_snapshot(&self, id: String) -> Result<JsValue, JsValue> {
5165            let snapshot = self.inner.peer_snapshot(&id).await;
5166            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
5167        }
5168
5169        // U9: the `peer_snapshots()` wasm binding was retired; `peer_sessions()`
5170        // (below) is the single settled Rust projection exposed to TS.
5171
5172        pub async fn peer_session(&self, id: String) -> Result<JsValue, JsValue> {
5173            let snapshot = self.inner.peer_session(&id).await;
5174            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
5175        }
5176
5177        pub async fn peer_sessions(&self) -> Result<JsValue, JsValue> {
5178            let snapshots = self.inner.peer_sessions().await;
5179            serde_wasm_bindgen::to_value(&snapshots).map_err(|e| JsValue::from_str(&e.to_string()))
5180        }
5181
5182        pub async fn connection_state(&self, connection_id: String) -> Result<JsValue, JsValue> {
5183            let snapshot = self.inner.connection_state(&connection_id).await;
5184            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
5185        }
5186
5187        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
5188        async fn revisit_preferred_iroh_carrier_after_settlement(&self, connection_id: &str) {
5189            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
5190            let Some(record) = self
5191                .inner
5192                .connection_manager
5193                .get_by_connection_id(connection_id)
5194                .await
5195            else {
5196                return;
5197            };
5198            // A settled custom route already owns the preferred carrier. This
5199            // wake is only for a newly admitted base-Iroh replacement.
5200            if matches!(
5201                record.active_transport.as_str(),
5202                crate::transport_label::WEBRTC | crate::transport_label::MOQ
5203            ) {
5204                return;
5205            }
5206            let Some(capabilities) = self
5207                .wasm_remote_carrier_capabilities
5208                .borrow()
5209                .get(connection_id)
5210                .copied()
5211            else {
5212                return;
5213            };
5214            let Some(remote_endpoint_id) = self
5215                .inner
5216                .wasm_iroh_carrier_remote_endpoint_id(connection_id)
5217                .await
5218            else {
5219                return;
5220            };
5221            if let Err(error) = self
5222                .begin_preferred_iroh_carrier_locked(
5223                    connection_id.to_string(),
5224                    remote_endpoint_id,
5225                    capabilities.webrtc,
5226                    capabilities.moq,
5227                )
5228                .await
5229            {
5230                web_sys::console::warn_1(&JsValue::from_str(&format!(
5231                    "[OpenRTC][Iroh carrier] settled-generation revisit failed connection_id={} error={:?}",
5232                    connection_id, error,
5233                )));
5234            }
5235        }
5236
5237        pub async fn connection_states(&self) -> Result<JsValue, JsValue> {
5238            let snapshots = self.inner.connection_states().await;
5239            serde_wasm_bindgen::to_value(&snapshots).map_err(|e| JsValue::from_str(&e.to_string()))
5240        }
5241
5242        pub async fn wait_for_peer(
5243            &self,
5244            id: String,
5245            timeout_ms: Option<u32>,
5246        ) -> Result<JsValue, JsValue> {
5247            let snapshot = self
5248                .inner
5249                .wait_for_peer(&id, timeout_ms.map(|value| value as u64))
5250                .await;
5251            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
5252        }
5253
5254        pub async fn wait_for_settled_scope(
5255            &self,
5256            scope: String,
5257            timeout_ms: Option<u32>,
5258        ) -> Result<JsValue, JsValue> {
5259            let snapshot = self
5260                .inner
5261                .wait_for_settled_scope(&scope, timeout_ms.map(u64::from))
5262                .await;
5263            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
5264        }
5265
5266        pub async fn resolve_peer_connection_records(
5267            &self,
5268            id: String,
5269        ) -> Result<JsValue, JsValue> {
5270            let records = self.inner.resolve_peer_connection_records(&id).await;
5271            serde_wasm_bindgen::to_value(&records).map_err(|e| JsValue::from_str(&e.to_string()))
5272        }
5273
5274        pub async fn list_managed_connections(&self) -> Result<JsValue, JsValue> {
5275            let records = self.inner.list_managed_connections().await;
5276            serde_wasm_bindgen::to_value(&records).map_err(|e| JsValue::from_str(&e.to_string()))
5277        }
5278
5279        pub async fn bind_connection_device_id(
5280            &self,
5281            connection_id: String,
5282            device_id: String,
5283        ) -> Result<JsValue, JsValue> {
5284            let snapshot = self
5285                .inner
5286                .bind_connection_device_id(&connection_id, &device_id)
5287                .await;
5288            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
5289        }
5290
5291        pub async fn acknowledge_session_token_response(
5292            &self,
5293            connection_id: String,
5294            remote_node_id: String,
5295            transport_stable_id: u64,
5296            claimed_device_id: Option<String>,
5297        ) -> bool {
5298            self.inner
5299                .acknowledge_wasm_session_token_response(
5300                    &connection_id,
5301                    &remote_node_id,
5302                    transport_stable_id,
5303                    claimed_device_id.as_deref(),
5304                )
5305                .await
5306        }
5307
5308        pub async fn bind_node_device_id(
5309            &self,
5310            node_id: String,
5311            device_id: String,
5312        ) -> Result<(), JsValue> {
5313            self.inner.bind_node_device_id(&node_id, &device_id).await;
5314            Ok(())
5315        }
5316
5317        pub async fn reject_connection_admission(
5318            &self,
5319            connection_id: String,
5320            reason: String,
5321        ) -> Result<JsValue, JsValue> {
5322            self.inner
5323                .reject_session_connection(&connection_id, &reason);
5324            self.inner
5325                .emit_current_wasm_connection_state(&connection_id)
5326                .await;
5327            let snapshot = self.inner.connection_state(&connection_id).await;
5328            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
5329        }
5330
5331        pub async fn report_managed_connection_settled(
5332            &self,
5333            connection_id: String,
5334            settled: bool,
5335            device_id: Option<String>,
5336            transport_stable_id: Option<u64>,
5337            transport_generation: Option<u64>,
5338            route_generation: Option<u64>,
5339        ) -> Result<JsValue, JsValue> {
5340            let snapshot = match (transport_stable_id, transport_generation, route_generation) {
5341                (Some(transport_stable_id), Some(transport_generation), Some(route_generation)) => {
5342                    self.inner
5343                        .report_managed_connection_settled_for_transport(
5344                            &connection_id,
5345                            settled,
5346                            transport_stable_id,
5347                            transport_generation,
5348                            route_generation,
5349                        )
5350                        .await
5351                }
5352                _ => None,
5353            };
5354            let _ = device_id;
5355            #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
5356            if settled && snapshot.is_some() {
5357                // `set_settled_if_current` above is the generation and
5358                // application-route fence. Re-enter the existing Rust carrier
5359                // selector only after that proof commits, so a transient base
5360                // reconnect can restore the configured custom route without a
5361                // browser retry loop.
5362                self.revisit_preferred_iroh_carrier_after_settlement(&connection_id)
5363                    .await;
5364            }
5365            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
5366        }
5367
5368        pub async fn report_transport_status(
5369            &self,
5370            connection_id: String,
5371            active_transport: String,
5372            parallel_transport: Option<String>,
5373            transport_stable_id: Option<u64>,
5374            transport_generation: Option<u64>,
5375            route_generation: Option<u64>,
5376        ) -> Result<JsValue, JsValue> {
5377            let snapshot = match (transport_stable_id, transport_generation, route_generation) {
5378                (Some(transport_stable_id), Some(transport_generation), Some(route_generation)) => {
5379                    self.inner
5380                        .report_transport_status_for_generation(
5381                            &connection_id,
5382                            &active_transport,
5383                            parallel_transport.as_deref(),
5384                            transport_stable_id,
5385                            transport_generation,
5386                            route_generation,
5387                        )
5388                        .await
5389                }
5390                _ => None,
5391            };
5392            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
5393        }
5394
5395        pub async fn is_current_transport_stable_id(
5396            &self,
5397            endpoint_id: String,
5398            transport_stable_id: u64,
5399        ) -> Result<bool, JsValue> {
5400            let endpoint_id = endpoint_id
5401                .parse::<iroh::EndpointId>()
5402                .map_err(|error| JsValue::from_str(&error.to_string()))?;
5403            Ok(self
5404                .inner
5405                .is_current_transport_stable_id(endpoint_id, transport_stable_id)
5406                .await)
5407        }
5408
5409        /// Recheck a current physical generation before the browser reads a
5410        /// pre-admission stream. This permits only SDK credential traffic to
5411        /// reach the admission owner; it does not authorize application data.
5412        #[wasm_bindgen(js_name = isAuthenticatedReconnectCandidate)]
5413        pub async fn is_authenticated_reconnect_candidate(
5414            &self,
5415            endpoint_id: String,
5416            transport_stable_id: u64,
5417        ) -> Result<bool, JsValue> {
5418            let endpoint_id = endpoint_id
5419                .parse::<iroh::EndpointId>()
5420                .map_err(|error| JsValue::from_str(&error.to_string()))?;
5421            Ok(self
5422                .inner
5423                .incoming_stream_generation_is_authenticated_reconnect_candidate(
5424                    endpoint_id,
5425                    transport_stable_id,
5426                )
5427                .await)
5428        }
5429
5430        pub async fn open_bi(&self, endpoint_id: String) -> Result<BiStream, JsValue> {
5431            let endpoint_id: iroh::EndpointId = endpoint_id
5432                .parse()
5433                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
5434            self.inner
5435                .assert_raw_peer_stream_allowed(&endpoint_id)
5436                .await
5437                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5438            // Parity with the native `Client::open_bi`: register/finalize the
5439            // connection_manager record for the (already-alive) transport before
5440            // handing out a raw peer stream, so browser raw-stream opens
5441            // (explicit transfer, native-main signaling) participate in
5442            // connection lifecycle / close tracking. Idempotent, and a no-op when
5443            // the transport is not alive. `Client::open_bi` itself is native-only
5444            // (it returns native iroh stream types), so the wasm binding cannot
5445            // delegate to it and must mirror its guard + record + open sequence.
5446            self.inner
5447                .ensure_connection_manager_record_before_peer_stream(&endpoint_id)
5448                .await
5449                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5450            let (send, recv) = self
5451                .inner
5452                .open_bi_internal(endpoint_id)
5453                .await
5454                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5455            Ok(BiStream::from_parts(send, recv, endpoint_id.to_string()))
5456        }
5457
5458        /// Open a raw bi-stream for the SDK-owned native-main **control plane**
5459        /// (WebRTC signaling: SDP / ICE candidates / renegotiate, and the
5460        /// bootstrap application-route handshake).
5461        ///
5462        /// Unlike [`open_bi`], this is deliberately **exempt** from the
5463        /// application-crypto raw-open guard (`assert_raw_peer_stream_allowed`).
5464        /// That guard protects application *data* — but the control plane is not
5465        /// application data:
5466        ///   1. Signaling bootstraps the very application route (and key
5467        ///      agreement) it would otherwise depend on, so it cannot require app
5468        ///      crypto that has not been negotiated yet.
5469        ///   2. It is already authenticated by the iroh QUIC TLS that binds the
5470        ///      sender's node id.
5471        ///   3. The receiver classifies the stream by its `[0x00][len]["main"]`
5472        ///      native-main label and routes it to the signal handler; bytes sent
5473        ///      here can never be delivered as application data, so this cannot be
5474        ///      abused to smuggle unencrypted app payloads past the guard.
5475        ///
5476        /// This binding is intentionally named for native-main. It is not a
5477        /// general-purpose raw-stream escape hatch: JS callers must immediately
5478        /// write the `[0x00][len]["main"]` native-main label and then framed
5479        /// control payloads. Application data must continue to use `open_peer_bi`
5480        /// / `open_peer_uni` so the application-crypto guard stays fail-closed.
5481        ///
5482        /// Without this, a peer whose inbound native-main control writer was lost
5483        /// (e.g. after a rapid disconnect/reconnect flap) and that requires app
5484        /// crypto could not (re)open a signaling stream at all, stranding the
5485        /// edge on base Iroh because SDP offers/answers can never be exchanged.
5486        pub async fn open_native_main_control_bi(
5487            &self,
5488            endpoint_id: String,
5489        ) -> Result<BiStream, JsValue> {
5490            let endpoint_id: iroh::EndpointId = endpoint_id
5491                .parse()
5492                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
5493            self.inner
5494                .ensure_connection_manager_record_before_peer_stream(&endpoint_id)
5495                .await
5496                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5497            let (transport_stable_id, send, recv) = self
5498                .inner
5499                .open_bi_internal_with_transport_stable_id(endpoint_id)
5500                .await
5501                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5502            Ok(BiStream::from_parts_with_transport_stable_id(
5503                send,
5504                recv,
5505                endpoint_id.to_string(),
5506                transport_stable_id,
5507            ))
5508        }
5509
5510        /// Send one complete SDK-owned native-main control frame on a fresh
5511        /// signal stream. Rust owns the QUIC FIN and waits for the peer to
5512        /// acknowledge it so a successful JS promise means the frame reached
5513        /// the remote stream router, not merely the browser WritableStream.
5514        pub async fn send_native_main_control_frame(
5515            &self,
5516            endpoint_id: String,
5517            frame: Vec<u8>,
5518        ) -> Result<(), JsValue> {
5519            send_native_signal_control_frame(&self.inner, endpoint_id, frame).await
5520        }
5521
5522        pub async fn open_peer_bi(
5523            &self,
5524            id: String,
5525            timeout_ms: Option<u32>,
5526        ) -> Result<BiStream, JsValue> {
5527            let (_connection_id, remote_node_id, send, recv) = self
5528                .inner
5529                .open_peer_bi(&id, timeout_ms.map(|value| value as u64))
5530                .await
5531                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5532            Ok(BiStream::from_peer_parts(send, recv, remote_node_id))
5533        }
5534
5535        /// Send a complete protected application frame on a fresh peer stream.
5536        /// Rust owns the QUIC FIN so JS readable cancellation cannot reset the
5537        /// one-shot stream before the remote runtime admits it.
5538        pub async fn send_peer_application_frame(
5539            &self,
5540            id: String,
5541            frame: Vec<u8>,
5542            timeout_ms: Option<u32>,
5543        ) -> Result<(), JsValue> {
5544            self.inner
5545                .send_peer_application_frame(&id, &frame, timeout_ms.map(|value| value as u64))
5546                .await
5547                .map_err(|error| JsValue::from_str(&error.to_string()))
5548        }
5549
5550        /// Open a settled peer stream for explicit file transfer.
5551        ///
5552        /// The runtime writes the plaintext explicit-file protocol byte (`0x02`)
5553        /// before returning the send stream, then wraps only the transfer body
5554        /// with application crypto when a key is active for the peer.
5555        pub async fn open_peer_bi_explicit_file_sender(
5556            &self,
5557            id: String,
5558            timeout_ms: Option<u32>,
5559        ) -> Result<PeerUniStream, JsValue> {
5560            let (_connection_id, _remote_node_id, send) = self
5561                .inner
5562                .open_peer_bi_explicit_file_sender(&id, timeout_ms.map(|value| value as u64))
5563                .await
5564                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5565            Ok(peer_uni_stream_from_send(send))
5566        }
5567
5568        /// Open a bi-stream to a peer that is transport-connected but may not yet
5569        /// be settled (auth-ready). Use for latency probes and other transport-level
5570        /// diagnostics where `settled_ready` is not required.
5571        pub async fn open_peer_bi_transport_only(
5572            &self,
5573            id: String,
5574            timeout_ms: Option<u32>,
5575        ) -> Result<BiStream, JsValue> {
5576            let (_connection_id, remote_node_id, send, recv) = self
5577                .inner
5578                .open_peer_bi_transport_only(&id, timeout_ms.map(|value| value as u64))
5579                .await
5580                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5581            Ok(BiStream::from_parts(send, recv, remote_node_id))
5582        }
5583
5584        pub async fn open_uni(&self, endpoint_id: String) -> Result<PeerUniStream, JsValue> {
5585            let endpoint_id: iroh::EndpointId = endpoint_id
5586                .parse()
5587                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
5588            self.inner
5589                .assert_raw_peer_stream_allowed(&endpoint_id)
5590                .await
5591                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5592            // Parity with the native `Client::open_uni`, which also finalizes the
5593            // connection_manager record before opening a raw uni peer stream.
5594            self.inner
5595                .ensure_connection_manager_record_before_peer_stream(&endpoint_id)
5596                .await
5597                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5598            let node_guard = self.inner.iroh_node.read().await;
5599            if let Some(node) = node_guard.as_ref() {
5600                let send = node
5601                    .open_uni(endpoint_id.clone())
5602                    .await
5603                    .map_err(|e| JsValue::from_str(&e.to_string()))?;
5604                Ok(peer_uni_stream_from_send(
5605                    crate::application_crypto_streams::PeerSendStream::plain(send),
5606                ))
5607            } else {
5608                Err(JsValue::from_str("Iroh node not initialized"))
5609            }
5610        }
5611
5612        pub async fn open_peer_uni(
5613            &self,
5614            id: String,
5615            timeout_ms: Option<u32>,
5616        ) -> Result<PeerUniStream, JsValue> {
5617            let (_connection_id, _remote_node_id, send) = self
5618                .inner
5619                .open_peer_uni(&id, timeout_ms.map(|value| value as u64))
5620                .await
5621                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5622            Ok(peer_uni_stream_from_send(send))
5623        }
5624
5625        #[wasm_bindgen(js_name = sendPeerDatagram)]
5626        pub async fn send_peer_datagram(
5627            &self,
5628            id: String,
5629            payload: Vec<u8>,
5630            max_age_ms: Option<u32>,
5631        ) -> Result<bool, JsValue> {
5632            match max_age_ms {
5633                Some(max_age_ms) => {
5634                    self.inner
5635                        .send_peer_datagram_with_max_age(&id, &payload, u64::from(max_age_ms))
5636                        .await
5637                }
5638                None => self
5639                    .inner
5640                    .send_peer_datagram(&id, &payload)
5641                    .await
5642                    .map(|()| true),
5643            }
5644            .map_err(|error| JsValue::from_str(&error.to_string()))
5645        }
5646
5647        #[wasm_bindgen(js_name = receivePeerDatagram)]
5648        pub async fn receive_peer_datagram(&self, id: String) -> Result<Vec<u8>, JsValue> {
5649            self.inner
5650                .receive_peer_datagram(&id)
5651                .await
5652                .map_err(|error| JsValue::from_str(&error.to_string()))
5653        }
5654
5655        #[wasm_bindgen(js_name = peerDatagramMaxSize)]
5656        pub fn peer_datagram_max_size(&self) -> usize {
5657            Client::MAX_PEER_DATAGRAM_BYTES
5658        }
5659
5660        #[wasm_bindgen(js_name = createPeerDatagramPolicy)]
5661        pub fn create_peer_datagram_policy(&self) -> WasmPeerDatagramPolicy {
5662            WasmPeerDatagramPolicy {
5663                inner: RefCell::new(crate::datagrams::PeerDatagramPolicy::new(
5664                    Client::MAX_PEER_DATAGRAM_BYTES,
5665                )),
5666            }
5667        }
5668
5669        /// Send `data` to `peer_id` using the best available transport.
5670        ///
5671        /// Native route order is edge-aware: proven upgraded routes stay first,
5672        /// relay/unknown iroh can probe upgraded transports first, and direct
5673        /// iroh/LAN/BLE paths stay primary with upgraded transports as fallback.
5674        ///
5675        /// On WASM this always routes through iroh (relay or QUIC as iroh determines).
5676        /// Use the TypeScript `Connection.sendTyped()` for full transport priority on the
5677        /// browser side — this binding is primarily for symmetry and native callers.
5678        pub async fn send_peer(&self, id: String, data: Vec<u8>) -> Result<(), JsValue> {
5679            self.inner
5680                .send_peer(&id, &data)
5681                .await
5682                .map_err(|e| JsValue::from_str(&e.to_string()))
5683        }
5684
5685        /// Returns the current iroh path kind for a connected peer.
5686        ///
5687        /// Returns one of `"direct-quic"`, `"relay"`, or `"unknown"`.
5688        pub async fn iroh_path_kind(&self, peer_id: String) -> String {
5689            match self.inner.iroh_path_kind(&peer_id).await {
5690                crate::client::IrohPathKind::DirectQuic => "direct-quic".to_string(),
5691                crate::client::IrohPathKind::DirectLan => "direct-lan".to_string(),
5692                crate::client::IrohPathKind::Relay => "relay".to_string(),
5693                crate::client::IrohPathKind::Ble => "ble".to_string(),
5694                crate::client::IrohPathKind::WebRtc => "webrtc".to_string(),
5695                crate::client::IrohPathKind::Moq => "moq".to_string(),
5696                crate::client::IrohPathKind::Unknown => "unknown".to_string(),
5697            }
5698        }
5699
5700        /// Returns the current iroh transport RTT in milliseconds, when iroh has
5701        /// selected a live path and published path stats.
5702        pub async fn iroh_transport_rtt_ms(&self, peer_id: String) -> Option<u32> {
5703            self.inner
5704                .iroh_transport_rtt_ms(&peer_id)
5705                .await
5706                .map(|value| value.min(u32::MAX as u64) as u32)
5707        }
5708
5709        pub async fn incoming_streams(&self) -> Result<JsReadableStream, JsValue> {
5710            let (stream_owner, stream) = {
5711                let node_guard = self.inner.iroh_node.read().await;
5712                if let Some(node) = node_guard.as_ref() {
5713                    (self.inner.clone(), node.incoming_streams_stream())
5714                } else {
5715                    return Err(JsValue::from_str("Iroh node not initialized"));
5716                }
5717            };
5718
5719            use futures::StreamExt;
5720            let mapped_stream = stream.filter_map(move |incoming| {
5721                let stream_owner = stream_owner.clone();
5722                async move {
5723                    let owned = stream_owner
5724                        .incoming_stream_generation_is_owned(
5725                            incoming.endpoint_id,
5726                            incoming.transport_stable_id,
5727                        )
5728                        .await;
5729                    let admission_candidate = !owned
5730                        && matches!(
5731                            &incoming.stream,
5732                            crate::wasm_node::IncomingStreamType::Bi(_, _)
5733                        )
5734                        && stream_owner
5735                            .incoming_stream_generation_is_authenticated_reconnect_candidate(
5736                                incoming.endpoint_id,
5737                                incoming.transport_stable_id,
5738                            )
5739                            .await;
5740                    (owned || admission_candidate).then(|| {
5741                        crate::wasm_node::BiStream::incoming_to_js_value(
5742                            incoming,
5743                            admission_candidate,
5744                        )
5745                    })
5746                }
5747            });
5748
5749            Ok(wasm_streams::ReadableStream::from_stream(mapped_stream).into_raw())
5750        }
5751
5752        /// Classify a bounded plaintext stream prefix with the same Rust parser
5753        /// used by native OpenRTC. Browser code owns only incremental Web Stream
5754        /// reads and must not reproduce channel/protocol wire decisions.
5755        #[wasm_bindgen(js_name = inspectIncomingStreamPrefix)]
5756        pub fn inspect_incoming_stream_prefix(
5757            &self,
5758            bytes: Vec<u8>,
5759            reached_eof: bool,
5760        ) -> Result<JsValue, JsValue> {
5761            serde::Serialize::serialize(
5762                &crate::stream_metadata::inspect_prefix_for_binding(&bytes, reached_eof),
5763                &serde_wasm_bindgen::Serializer::json_compatible(),
5764            )
5765            .map_err(|error| JsValue::from_str(&error.to_string()))
5766        }
5767
5768        /// Decode only the optional channel envelope at the start of a stream.
5769        /// The returned consumed length lets the browser adapter rebuild the
5770        /// readable without parsing or retaining protocol state in TypeScript.
5771        #[wasm_bindgen(js_name = decodeIncomingChannelEnvelope)]
5772        pub fn decode_incoming_channel_envelope(&self, bytes: Vec<u8>) -> Result<JsValue, JsValue> {
5773            serde::Serialize::serialize(
5774                &crate::stream_metadata::decode_prefix_for_binding(&bytes),
5775                &serde_wasm_bindgen::Serializer::json_compatible(),
5776            )
5777            .map_err(|error| JsValue::from_str(&error.to_string()))
5778        }
5779
5780        pub async fn update_presence(
5781            &self,
5782            user_id: String,
5783            device_name: String,
5784            ticket: String,
5785            metadata: Option<String>,
5786            ttl_ms: Option<u64>,
5787        ) -> Result<(), JsValue> {
5788            self.inner
5789                .update_presence_with_ttl(
5790                    &user_id,
5791                    &device_name,
5792                    &ticket,
5793                    ttl_ms.unwrap_or(300_000),
5794                    metadata.as_deref(),
5795                )
5796                .await
5797                .map_err(|e| JsValue::from_str(&e.to_string()))
5798        }
5799
5800        pub async fn send_message(
5801            &self,
5802            target_id: String,
5803            payload: String,
5804            state: Option<String>,
5805            reply_payload: Option<String>,
5806        ) -> Result<String, JsValue> {
5807            self.inner
5808                .send_message(
5809                    &target_id,
5810                    &payload,
5811                    state.as_deref(),
5812                    reply_payload.as_deref(),
5813                )
5814                .await
5815                .map_err(|e| JsValue::from_str(&e.to_string()))
5816        }
5817
5818        pub async fn set_offline(&self, user_id: String) -> Result<(), JsValue> {
5819            self.inner
5820                .set_offline(&user_id)
5821                .await
5822                .map_err(|e| JsValue::from_str(&e.to_string()))
5823        }
5824
5825        pub async fn update_device(
5826            &self,
5827            user_id: String,
5828            device_id: String,
5829            device_name: Option<String>,
5830            capabilities: Option<JsValue>,
5831            metadata: Option<String>,
5832        ) -> Result<(), JsValue> {
5833            let parsed_capabilities = match capabilities {
5834                Some(value) if !value.is_null() && !value.is_undefined() => Some(
5835                    serde_wasm_bindgen::from_value::<crate::signaling::DeviceCapabilities>(value)
5836                        .map_err(|e| JsValue::from_str(&e.to_string()))?,
5837                ),
5838                _ => None,
5839            };
5840
5841            self.inner
5842                .update_device(
5843                    &user_id,
5844                    &device_id,
5845                    device_name.as_deref(),
5846                    parsed_capabilities,
5847                    metadata.as_deref(),
5848                )
5849                .await
5850                .map_err(|e| JsValue::from_str(&e.to_string()))
5851        }
5852
5853        pub async fn delete_device(
5854            &self,
5855            user_id: String,
5856            device_id: String,
5857        ) -> Result<(), JsValue> {
5858            self.inner
5859                .delete_device(&user_id, &device_id)
5860                .await
5861                .map_err(|e| JsValue::from_str(&e.to_string()))
5862        }
5863
5864        pub fn force_reconnect_snapshot(&self) {
5865            self.inner.clone().force_reconnect_snapshot();
5866        }
5867
5868        pub fn stop_presence_loop(&self) {
5869            self.inner.stop_presence_loop();
5870        }
5871
5872        pub fn stop_auto_connect(&self) {
5873            self.inner.stop_auto_connect();
5874            self.inner.stop_browser_auto_connect();
5875        }
5876
5877        pub fn start_auto_connect(
5878            &self,
5879            user_id: String,
5880            local_device_id: String,
5881        ) -> Result<(), JsValue> {
5882            self.inner
5883                .start_browser_auto_connect(user_id, local_device_id)
5884                .map_err(|error| JsValue::from_str(&error.to_string()))
5885        }
5886
5887        /// @deprecated Use `submit_browser_desired_peers_with_sparse_fanout` for sparse rooms.
5888        #[deprecated(note = "use submit_browser_desired_peers_with_sparse_fanout for sparse rooms")]
5889        pub fn submit_browser_desired_peers(
5890            &self,
5891            revision: u32,
5892            peers_json: String,
5893        ) -> Result<bool, JsValue> {
5894            self.inner
5895                .submit_browser_desired_peers(u64::from(revision), &peers_json)
5896                .map_err(|error| JsValue::from_str(&error.to_string()))
5897        }
5898
5899        pub async fn submit_browser_desired_peers_with_sparse_fanout(
5900            &self,
5901            revision: u32,
5902            peers_json: String,
5903            capability: Option<String>,
5904            local_device_id: Option<String>,
5905            local_device_key_x: Option<String>,
5906            sparse: Option<bool>,
5907        ) -> Result<bool, JsValue> {
5908            let peers_json = if sparse.unwrap_or(false) {
5909                let capability = capability
5910                    .as_deref()
5911                    .ok_or_else(|| JsValue::from_str("sparse fanout requires a capability"))?;
5912                let local_device_id = local_device_id
5913                    .as_deref()
5914                    .ok_or_else(|| JsValue::from_str("sparse fanout requires a local device id"))?;
5915                let local_device_key_x = local_device_key_x.as_deref().ok_or_else(|| {
5916                    JsValue::from_str("sparse fanout requires a local device key")
5917                })?;
5918                self.inner
5919                    .configure_sparse_fanout(
5920                        capability,
5921                        u64::from(revision),
5922                        local_device_id,
5923                        local_device_key_x,
5924                        &peers_json,
5925                        true,
5926                    )
5927                    .await
5928                    .map_err(|error| JsValue::from_str(&error.to_string()))?
5929                    .0
5930            } else {
5931                peers_json
5932            };
5933            self.inner
5934                .submit_browser_desired_peers(u64::from(revision), &peers_json)
5935                .map_err(|error| JsValue::from_str(&error.to_string()))
5936        }
5937
5938        #[wasm_bindgen(js_name = prepareSparseFanoutMessage)]
5939        pub async fn prepare_sparse_fanout_message(
5940            &self,
5941            capability: String,
5942            payload: Vec<u8>,
5943        ) -> Result<JsValue, JsValue> {
5944            let request = self
5945                .inner
5946                .prepare_sparse_fanout_message(&capability, &payload)
5947                .await
5948                .map_err(|error| JsValue::from_str(&error.to_string()))?;
5949            serde_wasm_bindgen::to_value(&request)
5950                .map_err(|error| JsValue::from_str(&error.to_string()))
5951        }
5952
5953        #[wasm_bindgen(js_name = finalizeSparseFanoutMessage)]
5954        pub async fn finalize_sparse_fanout_message(
5955            &self,
5956            request_id: String,
5957            signature: String,
5958        ) -> Result<Vec<u8>, JsValue> {
5959            self.inner
5960                .finalize_sparse_fanout_message(&request_id, &signature)
5961                .await
5962                .map_err(|error| JsValue::from_str(&error.to_string()))
5963        }
5964
5965        #[wasm_bindgen(js_name = acceptSparseFanoutMessage)]
5966        pub async fn accept_sparse_fanout_message(
5967            &self,
5968            capability: String,
5969            source_peer_id: String,
5970            encoded: Vec<u8>,
5971        ) -> Result<JsValue, JsValue> {
5972            let decision = self
5973                .inner
5974                .accept_sparse_fanout_message(&capability, &source_peer_id, &encoded)
5975                .await
5976                .map_err(|error| JsValue::from_str(&error.to_string()))?;
5977            serde_wasm_bindgen::to_value(&decision)
5978                .map_err(|error| JsValue::from_str(&error.to_string()))
5979        }
5980
5981        #[wasm_bindgen(js_name = sparseFanoutDiagnostics)]
5982        pub async fn sparse_fanout_diagnostics(
5983            &self,
5984            capability: String,
5985        ) -> Result<JsValue, JsValue> {
5986            serde_wasm_bindgen::to_value(&self.inner.sparse_fanout_diagnostics(&capability).await)
5987                .map_err(|error| JsValue::from_str(&error.to_string()))
5988        }
5989
5990        #[wasm_bindgen(js_name = recordSparseFanoutForwardQueueDrop)]
5991        pub async fn record_sparse_fanout_forward_queue_drop(
5992            &self,
5993            capability: String,
5994            count: u32,
5995        ) -> Result<(), JsValue> {
5996            self.inner
5997                .record_sparse_fanout_forward_queue_drop(&capability, u64::from(count))
5998                .await
5999                .map_err(|error| JsValue::from_str(&error.to_string()))
6000        }
6001
6002        pub fn wake_browser_auto_connect(&self) -> bool {
6003            self.inner.wake_browser_auto_connect()
6004        }
6005
6006        pub async fn set_auto_connect_excluded(&self, device_id: String, excluded: bool) {
6007            if excluded {
6008                self.inner.exclude_peer_and_publish(&device_id).await;
6009            } else {
6010                self.inner.unexclude_peer_and_publish(&device_id).await;
6011            }
6012            self.inner.wake_browser_auto_connect();
6013        }
6014
6015        pub fn is_auto_connect_excluded(&self, device_id: String) -> bool {
6016            self.inner.is_auto_connect_excluded(&device_id)
6017        }
6018
6019        pub async fn disconnect_device(
6020            &self,
6021            device_id: String,
6022            node_id_hint: Option<String>,
6023        ) -> Result<JsValue, JsValue> {
6024            let retired = self
6025                .inner
6026                .disconnect_device(&device_id, node_id_hint.as_deref())
6027                .await;
6028            serde_wasm_bindgen::to_value(&retired).map_err(|e| JsValue::from_str(&e.to_string()))
6029        }
6030
6031        pub fn stop_auth_scoped_activity(&self) {
6032            self.inner.stop_auth_scoped_activity();
6033            self.inner.stop_browser_auto_connect();
6034        }
6035
6036        pub fn start_presence_loop(
6037            &self,
6038            user_id: String,
6039            device_name: String,
6040            ticket: String,
6041            metadata: Option<String>,
6042        ) {
6043            self.inner
6044                .clone()
6045                .start_signaling_loop(user_id, device_name, ticket, metadata);
6046        }
6047
6048        pub async fn search_devices(&self, user_id: String) -> Result<JsValue, JsValue> {
6049            let devices = self
6050                .inner
6051                .search_devices(&user_id)
6052                .await
6053                .map_err(|e| JsValue::from_str(&e.to_string()))?;
6054            serde_wasm_bindgen::to_value(&devices).map_err(|e| JsValue::from_str(&e.to_string()))
6055        }
6056
6057        pub async fn devices_with_status(&self, user_id: String) -> Result<JsValue, JsValue> {
6058            let devices = self
6059                .inner
6060                .devices_with_status(&user_id)
6061                .await
6062                .map_err(|e| JsValue::from_str(&e.to_string()))?;
6063            serde_wasm_bindgen::to_value(&devices).map_err(|e| JsValue::from_str(&e.to_string()))
6064        }
6065
6066        pub async fn connect_device(
6067            &self,
6068            device_id: Option<String>,
6069            endpoint_ticket: String,
6070            timeout_ms: Option<u32>,
6071        ) -> Result<JsValue, JsValue> {
6072            let dial = self
6073                .inner
6074                .connect_device(device_id.as_deref(), &endpoint_ticket);
6075            let result = if let Some(timeout_ms) = timeout_ms {
6076                let deadline = gloo_timers::future::sleep(std::time::Duration::from_millis(
6077                    u64::from(timeout_ms.clamp(1, 120_000)),
6078                ));
6079                futures::pin_mut!(dial, deadline);
6080                match futures::future::select(dial, deadline).await {
6081                    futures::future::Either::Left((result, _)) => result,
6082                    futures::future::Either::Right(_) => {
6083                        return Err(JsValue::from_str("endpoint ticket dial timed out"));
6084                    }
6085                }
6086            } else {
6087                dial.await
6088            }
6089            .map_err(|e| JsValue::from_str(&e.to_string()))?;
6090            serde_wasm_bindgen::to_value(&result).map_err(|e| JsValue::from_str(&e.to_string()))
6091        }
6092
6093        pub async fn connect_known_device_with_token(
6094            &self,
6095            device_id: String,
6096            token: String,
6097            scope: String,
6098            max_connections: u32,
6099            expires_at_ms: Option<u64>,
6100            lookup_timeout_ms: Option<u32>,
6101        ) -> Result<JsValue, JsValue> {
6102            let result = self
6103                .inner
6104                .connect_known_device_with_token(
6105                    &device_id,
6106                    &token,
6107                    &scope,
6108                    max_connections,
6109                    expires_at_ms,
6110                    lookup_timeout_ms.map(u64::from),
6111                )
6112                .await
6113                .map_err(|e| JsValue::from_str(&e.to_string()))?;
6114            serde_wasm_bindgen::to_value(&result).map_err(|e| JsValue::from_str(&e.to_string()))
6115        }
6116
6117        pub async fn observe_known_device_endpoint(
6118            &self,
6119            device_id: String,
6120            endpoint_ticket: String,
6121        ) -> Result<(), JsValue> {
6122            self.inner
6123                .observe_known_device_endpoint(&device_id, &endpoint_ticket)
6124                .await
6125                .map_err(|e| JsValue::from_str(&e.to_string()))
6126        }
6127
6128        pub async fn create_session(&self, session_json: String) -> Result<(), JsValue> {
6129            let session: crate::signaling::SignalingSession =
6130                serde_json::from_str(&session_json)
6131                    .map_err(|e| JsValue::from_str(&e.to_string()))?;
6132            self.inner
6133                .create_session(session)
6134                .await
6135                .map_err(|e| JsValue::from_str(&e.to_string()))
6136        }
6137
6138        pub async fn update_session(
6139            &self,
6140            session_id: String,
6141            update_json: String,
6142        ) -> Result<(), JsValue> {
6143            let update_data: serde_json::Value = serde_json::from_str(&update_json)
6144                .map_err(|e| JsValue::from_str(&e.to_string()))?;
6145            self.inner
6146                .update_session(&session_id, update_data)
6147                .await
6148                .map_err(|e| JsValue::from_str(&e.to_string()))
6149        }
6150
6151        // --- Room Management API ---
6152
6153        pub async fn create_room(
6154            &self,
6155            room_id: String,
6156            user_id: String,
6157            ticket_str: String,
6158            my_node_id: String,
6159            tag: String,
6160            max_members: Option<u32>,
6161        ) -> Result<bool, JsValue> {
6162            self.inner
6163                .room
6164                .create_room(
6165                    &room_id,
6166                    &user_id,
6167                    &ticket_str,
6168                    &my_node_id,
6169                    &tag,
6170                    max_members,
6171                )
6172                .await
6173                .map_err(|e| JsValue::from_str(&e.to_string()))
6174        }
6175
6176        pub async fn join_room(
6177            &self,
6178            room_id: String,
6179            user_id: String,
6180            ticket_str: String,
6181            my_node_id: String,
6182            tag: String,
6183        ) -> Result<(), JsValue> {
6184            self.inner
6185                .room
6186                .join_room(&room_id, &user_id, &ticket_str, &my_node_id, &tag)
6187                .await
6188                .map_err(|e| JsValue::from_str(&e.to_string()))
6189        }
6190
6191        pub async fn get_members(
6192            &self,
6193            room_id: String,
6194            my_node_id: String,
6195            tag: String,
6196        ) -> Result<String, JsValue> {
6197            let members = self
6198                .inner
6199                .room
6200                .get_members(&room_id, &my_node_id, &tag)
6201                .await
6202                .map_err(|e| JsValue::from_str(&e.to_string()))?;
6203            serde_json::to_string(&members).map_err(|e| JsValue::from_str(&e.to_string()))
6204        }
6205
6206        pub async fn leave_room(
6207            &self,
6208            room_id: String,
6209            my_node_id: String,
6210            tag: String,
6211        ) -> Result<(), JsValue> {
6212            self.inner
6213                .room
6214                .leave_room(&room_id, &my_node_id, &tag)
6215                .await
6216                .map_err(|e| JsValue::from_str(&e.to_string()))
6217        }
6218    }
6219
6220    #[cfg(feature = "iroh-protocols-wasm")]
6221    #[wasm_bindgen]
6222    impl WasmClient {
6223        /// Hydrate the upstream iroh docs, blobs, and gossip protocols from a
6224        /// host persistence adapter. This reuses the already-bound OpenRTC
6225        /// endpoint and its single router.
6226        #[wasm_bindgen(js_name = __initPersistentIrohProtocols)]
6227        pub async fn init_persistent_iroh_protocols(
6228            &self,
6229            replica_store: JsValue,
6230        ) -> Result<(), JsValue> {
6231            let mut guard = self.persistent_protocols.lock().await;
6232            if guard.is_some() {
6233                return Ok(());
6234            }
6235            let node = self
6236                .inner
6237                .iroh_node
6238                .read()
6239                .await
6240                .as_ref()
6241                .cloned()
6242                .ok_or_else(|| {
6243                    JsValue::from_str("OpenRTC endpoint must be initialized before protocols")
6244                })?;
6245            let store = crate::wasm_docs_persistence::JsReplicaStore::new(replica_store)
6246                .map_err(|error| JsValue::from_str(&error.to_string()))?;
6247            let actor = crate::wasm_docs_persistence::WasmPersistentDocsActor::hydrate(
6248                store,
6249                node.endpoint().clone(),
6250            )
6251            .await
6252            .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
6253            node.install_standard_protocols(
6254                actor.docs_protocol(),
6255                actor.blobs_protocol(),
6256                actor.gossip_protocol(),
6257            )
6258            .await
6259            .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
6260            *guard = Some(actor);
6261            Ok(())
6262        }
6263
6264        /// Rejoin active upstream protocols after the browser reports that its
6265        /// existing OpenRTC endpoint may be reachable again. The actor reuses
6266        /// iroh-docs' stored useful peers and remains the only sync owner.
6267        #[wasm_bindgen(js_name = __reconcilePersistentIrohProtocols)]
6268        pub async fn reconcile_persistent_iroh_protocols(&self) -> Result<u32, JsValue> {
6269            let guard = self.persistent_protocols.lock().await;
6270            let Some(actor) = guard.as_ref() else {
6271                return Ok(0);
6272            };
6273            actor.reconcile_sync().await.map_err(js_protocol_error)
6274        }
6275
6276        #[wasm_bindgen(js_name = __importPersistentIrohAuthor)]
6277        pub async fn import_persistent_iroh_author(
6278            &self,
6279            author_secret: Vec<u8>,
6280        ) -> Result<String, JsValue> {
6281            let guard = self.persistent_protocols.lock().await;
6282            let actor = persistent_protocols(&guard)?;
6283            actor
6284                .import_author(author_secret)
6285                .await
6286                .map_err(js_protocol_error)
6287        }
6288
6289        #[wasm_bindgen(js_name = __importPersistentIrohNamespace)]
6290        pub async fn import_persistent_iroh_namespace(
6291            &self,
6292            capability_kind: String,
6293            capability: Vec<u8>,
6294            generation: u64,
6295            share_revision: u64,
6296        ) -> Result<String, JsValue> {
6297            let mut guard = self.persistent_protocols.lock().await;
6298            let actor = persistent_protocols_mut(&mut guard)?;
6299            actor
6300                .import_namespace(
6301                    capability_kind.as_str(),
6302                    capability,
6303                    generation,
6304                    share_revision,
6305                )
6306                .await
6307                .map_err(js_protocol_error)
6308        }
6309
6310        #[wasm_bindgen(js_name = __createPersistentIrohNamespace)]
6311        pub async fn create_persistent_iroh_namespace(
6312            &self,
6313            generation: u64,
6314            share_revision: u64,
6315        ) -> Result<JsValue, JsValue> {
6316            let mut guard = self.persistent_protocols.lock().await;
6317            let descriptor = persistent_protocols_mut(&mut guard)?
6318                .create_namespace(generation, share_revision)
6319                .await
6320                .map_err(js_protocol_error)?;
6321            serde_wasm_bindgen::to_value(&descriptor)
6322                .map_err(|error| JsValue::from_str(&error.to_string()))
6323        }
6324
6325        #[wasm_bindgen(js_name = __importPersistentIrohTicket)]
6326        pub async fn import_persistent_iroh_ticket(
6327            &self,
6328            ticket: String,
6329            generation: u64,
6330            share_revision: u64,
6331        ) -> Result<String, JsValue> {
6332            let mut guard = self.persistent_protocols.lock().await;
6333            persistent_protocols_mut(&mut guard)?
6334                .import_ticket(&ticket, generation, share_revision)
6335                .await
6336                .map_err(js_protocol_error)
6337        }
6338
6339        #[wasm_bindgen(js_name = __sharePersistentIrohNamespace)]
6340        pub async fn share_persistent_iroh_namespace(
6341            &self,
6342            namespace_id: String,
6343            writable: bool,
6344        ) -> Result<String, JsValue> {
6345            let guard = self.persistent_protocols.lock().await;
6346            persistent_protocols(&guard)?
6347                .share(&namespace_id, writable)
6348                .await
6349                .map_err(js_protocol_error)
6350        }
6351
6352        #[wasm_bindgen(js_name = __removePersistentIrohNamespace)]
6353        pub async fn remove_persistent_iroh_namespace(
6354            &self,
6355            namespace_id: String,
6356            generation: u64,
6357            share_revision: u64,
6358        ) -> Result<(), JsValue> {
6359            let mut guard = self.persistent_protocols.lock().await;
6360            persistent_protocols_mut(&mut guard)?
6361                .remove_namespace(&namespace_id, generation, share_revision)
6362                .await
6363                .map_err(js_protocol_error)
6364        }
6365
6366        #[wasm_bindgen(js_name = __putPersistentIrohBytes)]
6367        pub async fn put_persistent_iroh_bytes(
6368            &self,
6369            namespace_id: String,
6370            key: Vec<u8>,
6371            value: Vec<u8>,
6372        ) -> Result<JsValue, JsValue> {
6373            let guard = self.persistent_protocols.lock().await;
6374            let receipt = persistent_protocols(&guard)?
6375                .set_bytes(&namespace_id, key, value)
6376                .await
6377                .map_err(js_protocol_error)?;
6378            serde_wasm_bindgen::to_value(&receipt)
6379                .map_err(|error| JsValue::from_str(&error.to_string()))
6380        }
6381
6382        #[wasm_bindgen(js_name = __setPersistentIrohHash)]
6383        pub async fn set_persistent_iroh_hash(
6384            &self,
6385            namespace_id: String,
6386            key: Vec<u8>,
6387            content_hash: String,
6388            content_length: u64,
6389        ) -> Result<String, JsValue> {
6390            let guard = self.persistent_protocols.lock().await;
6391            persistent_protocols(&guard)?
6392                .set_hash(&namespace_id, key, &content_hash, content_length)
6393                .await
6394                .map_err(js_protocol_error)
6395        }
6396
6397        #[wasm_bindgen(js_name = __deletePersistentIrohPrefix)]
6398        pub async fn delete_persistent_iroh_prefix(
6399            &self,
6400            namespace_id: String,
6401            prefix: Vec<u8>,
6402        ) -> Result<JsValue, JsValue> {
6403            let guard = self.persistent_protocols.lock().await;
6404            let receipt = persistent_protocols(&guard)?
6405                .delete_prefix(&namespace_id, prefix)
6406                .await
6407                .map_err(js_protocol_error)?;
6408            serde_wasm_bindgen::to_value(&receipt)
6409                .map_err(|error| JsValue::from_str(&error.to_string()))
6410        }
6411
6412        #[wasm_bindgen(js_name = __queryPersistentIrohNamespace)]
6413        pub async fn query_persistent_iroh_namespace(
6414            &self,
6415            namespace_id: String,
6416            key_prefix: Vec<u8>,
6417        ) -> Result<JsValue, JsValue> {
6418            let guard = self.persistent_protocols.lock().await;
6419            let entries = persistent_protocols(&guard)?
6420                .query(&namespace_id, key_prefix)
6421                .await
6422                .map_err(js_protocol_error)?;
6423            serde_wasm_bindgen::to_value(&entries)
6424                .map_err(|error| JsValue::from_str(&error.to_string()))
6425        }
6426
6427        #[wasm_bindgen(js_name = __hydratePersistentIrohBlob)]
6428        pub async fn hydrate_persistent_iroh_blob(
6429            &self,
6430            content_hash: String,
6431        ) -> Result<(), JsValue> {
6432            let guard = self.persistent_protocols.lock().await;
6433            persistent_protocols(&guard)?
6434                .hydrate_blob(&content_hash)
6435                .await
6436                .map_err(js_protocol_error)
6437        }
6438
6439        #[wasm_bindgen(js_name = __acknowledgePersistentIrohOutbox)]
6440        pub async fn acknowledge_persistent_iroh_outbox(
6441            &self,
6442            operation_id: String,
6443        ) -> Result<(), JsValue> {
6444            let guard = self.persistent_protocols.lock().await;
6445            persistent_protocols(&guard)?
6446                .acknowledge_outbox(&operation_id)
6447                .await
6448                .map_err(js_protocol_error)
6449        }
6450
6451        #[wasm_bindgen(js_name = __flushPersistentIrohProtocols)]
6452        pub async fn flush_persistent_iroh_protocols(&self) -> Result<(), JsValue> {
6453            let guard = self.persistent_protocols.lock().await;
6454            persistent_protocols(&guard)?
6455                .flush()
6456                .await
6457                .map_err(js_protocol_error)
6458        }
6459
6460        #[wasm_bindgen(js_name = __shutdownPersistentIrohProtocols)]
6461        pub async fn shutdown_persistent_iroh_protocols(&self) -> Result<(), JsValue> {
6462            if let Some(actor) = self.persistent_protocols.lock().await.take() {
6463                actor.shutdown().await.map_err(js_protocol_error)?;
6464            }
6465            Ok(())
6466        }
6467    }
6468
6469    #[cfg(feature = "iroh-protocols-wasm")]
6470    fn persistent_protocols(
6471        guard: &Option<crate::wasm_docs_persistence::WasmPersistentDocsActor>,
6472    ) -> Result<&crate::wasm_docs_persistence::WasmPersistentDocsActor, JsValue> {
6473        guard
6474            .as_ref()
6475            .ok_or_else(|| JsValue::from_str("persistent Iroh protocols are not initialized"))
6476    }
6477
6478    #[cfg(feature = "iroh-protocols-wasm")]
6479    fn persistent_protocols_mut(
6480        guard: &mut Option<crate::wasm_docs_persistence::WasmPersistentDocsActor>,
6481    ) -> Result<&mut crate::wasm_docs_persistence::WasmPersistentDocsActor, JsValue> {
6482        guard
6483            .as_mut()
6484            .ok_or_else(|| JsValue::from_str("persistent Iroh protocols are not initialized"))
6485    }
6486
6487    #[cfg(feature = "iroh-protocols-wasm")]
6488    fn js_protocol_error(error: impl std::fmt::Display) -> JsValue {
6489        JsValue::from_str(&format!("{error:#}"))
6490    }
6491
6492    #[wasm_bindgen(start)]
6493    pub fn start() {
6494        console_error_panic_hook::set_once();
6495    }
6496}