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