Skip to main content

openrtc/
client.rs

1use super::coordination::room::{GatewayRequiredRoomBackend, RoomBackend};
2use crate::signaling::{GatewayRequiredSignalingBackend, SignalingBackend};
3use iroh::Endpoint;
4use iroh_tickets::endpoint::EndpointTicket;
5use std::collections::{HashMap, HashSet};
6use std::str::FromStr;
7#[cfg(any(
8    not(target_arch = "wasm32"),
9    feature = "transport-webrtc",
10    feature = "transport-moq"
11))]
12use std::sync::atomic::Ordering;
13use std::sync::atomic::{AtomicBool, AtomicU64};
14use std::sync::{Arc, Mutex, RwLock as StdRwLock};
15use tokio::sync::RwLock;
16
17#[cfg(not(target_arch = "wasm32"))]
18use crate::client::scope_classifier::{default_classifier, ScopeClassifier};
19
20#[cfg(target_arch = "wasm32")]
21fn wasm_init_log(stage: &str) {
22    let now = js_sys::Date::now();
23    web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
24        "[OPENRTC][WASM-INIT] ts_ms={:.0} stage={}",
25        now, stage
26    )));
27}
28
29#[cfg(target_arch = "wasm32")]
30use crate::wasm_node::{AcceptEvent, ConnectEvent, IrohWasmNode};
31
32#[cfg(not(target_arch = "wasm32"))]
33use crate::native_node::{AcceptEvent, ConnectEvent, IncomingStream, IrohNativeNode};
34
35#[cfg(not(target_arch = "wasm32"))]
36#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct EndpointHandle {
39    pub node_id: String,
40    pub node_addr: String,
41}
42
43#[cfg(not(target_arch = "wasm32"))]
44pub struct BiStream {
45    pub send: crate::application_crypto_streams::PeerSendStream,
46    pub recv: crate::application_crypto_streams::PeerRecvStream,
47    pub id: String,
48}
49
50/// Result of routing a newly accepted native bidirectional stream through the
51/// Rust admission authority. Pending connections must complete SDK-owned token
52/// admission before an application or webview can observe their streams.
53#[cfg(not(target_arch = "wasm32"))]
54pub enum BiStreamDisposition {
55    Consumed,
56    Forward {
57        send: iroh::endpoint::SendStream,
58        recv: iroh::endpoint::RecvStream,
59        /// Bytes consumed only to classify an out-of-order encrypted product
60        /// frame while reciprocal key confirmation was in flight. The native
61        /// host wrapper must feed them into the application-crypto decoder
62        /// before reading the remaining QUIC stream.
63        recv_prefix: Vec<u8>,
64    },
65}
66
67#[cfg(not(target_arch = "wasm32"))]
68#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct NativePeerDataEvent {
71    pub connection_id: String,
72    pub remote_node_id: Option<String>,
73    pub transport: String,
74    pub transport_stable_id: u64,
75    pub transport_generation: u64,
76    pub route_generation: u64,
77    pub payload: Vec<u8>,
78}
79
80#[cfg(not(target_arch = "wasm32"))]
81#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub(crate) struct NativePeerDataGeneration {
84    pub transport_stable_id: u64,
85    pub transport_generation: u64,
86    pub route_generation: u64,
87}
88
89#[cfg(target_arch = "wasm32")]
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub(crate) struct WasmPeerDataGeneration {
92    pub transport_stable_id: u64,
93    pub transport_generation: u64,
94    pub route_generation: u64,
95}
96
97#[cfg(not(target_arch = "wasm32"))]
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub(crate) struct NativeBleUpgradeAttemptState {
100    pub generation: NativePeerDataGeneration,
101    pub attempts: u8,
102}
103
104#[cfg(not(target_arch = "wasm32"))]
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub(crate) struct NativeTransportUpgradeGate {
107    pub upgrade_id: String,
108    pub generation: NativePeerDataGeneration,
109    pub policy_epoch: u64,
110    pub peer_policy_epoch: u64,
111}
112
113#[cfg(target_arch = "wasm32")]
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub(crate) struct WasmTransportUpgradeGate {
116    pub upgrade_id: String,
117    /// The logical base generation this browser-carrier attempt is permitted
118    /// to replace. A duplicate capability/ready frame must never supersede a
119    /// live proof for this same base.
120    pub generation: WasmPeerDataGeneration,
121    pub policy_epoch: u64,
122    pub peer_policy_epoch: u64,
123}
124
125/// Outcome of asking to own a browser-carrier attempt fence.
126///
127/// This is deliberately a pure decision boundary so a replay cannot be
128/// mistaken for a new lifecycle owner by the carrier control handlers.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130#[cfg(any(
131    test,
132    all(
133        target_arch = "wasm32",
134        any(feature = "transport-webrtc", feature = "transport-moq")
135    )
136))]
137pub(crate) enum WasmCarrierUpgradeReservation {
138    Reserved,
139    CurrentReplay,
140    Busy,
141}
142
143/// State of a previously reserved browser-carrier attempt at a proof boundary.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145#[cfg(any(
146    test,
147    all(
148        target_arch = "wasm32",
149        any(feature = "transport-webrtc", feature = "transport-moq")
150    )
151))]
152pub(crate) enum WasmCarrierUpgradeFence {
153    Current(u64),
154    Retired,
155    Superseded,
156    PolicyChanged,
157    BaseGenerationChanged,
158}
159
160/// Preserve the two recoverable peer responses that carry lifecycle meaning.
161/// All other peer failure strings collapse to one bounded public-internal
162/// category so adapter diagnostics cannot become transport policy.
163#[cfg(any(
164    test,
165    all(
166        target_arch = "wasm32",
167        any(feature = "transport-webrtc", feature = "transport-moq")
168    )
169))]
170pub(crate) fn wasm_peer_carrier_failure_code(failure_code: Option<&str>) -> &'static str {
171    match failure_code {
172        Some("carrier-base-generation-stale") => "carrier-base-generation-stale",
173        Some("carrier-base-not-ready") => "carrier-base-not-ready",
174        _ => "peer-rejected-carrier",
175    }
176}
177
178/// A transient peer response means the exact fenced browser attempt should be
179/// eligible for the next Rust settlement/capability wake. It must not create a
180/// new upgrade ID or schedule an independent retry owner.
181#[cfg(any(
182    test,
183    all(
184        target_arch = "wasm32",
185        any(feature = "transport-webrtc", feature = "transport-moq")
186    )
187))]
188pub(crate) fn should_rearm_wasm_carrier_event_retry(
189    failure_code: &str,
190    initiator: bool,
191    completion_started: bool,
192) -> bool {
193    initiator && !completion_started && failure_code == "carrier-base-not-ready"
194}
195
196/// Classify a reservation without mutating the gate. The three generation
197/// fields are kept as a tuple here so this policy remains native-testable even
198/// though browser carrier attempts themselves are WASM-only.
199#[cfg(any(
200    test,
201    all(
202        target_arch = "wasm32",
203        any(feature = "transport-webrtc", feature = "transport-moq")
204    )
205))]
206pub(crate) fn classify_wasm_carrier_upgrade_reservation(
207    current: Option<(&str, (u64, u64, u64), u64)>,
208    competing_kind_current: bool,
209    upgrade_id: &str,
210    generation: (u64, u64, u64),
211    policy_epoch: u64,
212) -> WasmCarrierUpgradeReservation {
213    if competing_kind_current {
214        return WasmCarrierUpgradeReservation::Busy;
215    }
216    match current {
217        Some((current_id, current_generation, current_policy_epoch))
218            if current_generation == generation
219                && current_id == upgrade_id
220                && current_policy_epoch == policy_epoch =>
221        {
222            WasmCarrierUpgradeReservation::CurrentReplay
223        }
224        Some((_, current_generation, _)) if current_generation == generation => {
225            WasmCarrierUpgradeReservation::Busy
226        }
227        _ => WasmCarrierUpgradeReservation::Reserved,
228    }
229}
230
231#[cfg(any(
232    test,
233    all(
234        target_arch = "wasm32",
235        any(feature = "transport-webrtc", feature = "transport-moq")
236    )
237))]
238pub(crate) fn classify_wasm_carrier_upgrade_fence(
239    current: Option<(&str, (u64, u64, u64), u64)>,
240    upgrade_id: &str,
241    generation: (u64, u64, u64),
242    policy_epoch: u64,
243) -> WasmCarrierUpgradeFence {
244    match current {
245        None => WasmCarrierUpgradeFence::Retired,
246        Some((_, current_generation, _)) if current_generation != generation => {
247            WasmCarrierUpgradeFence::BaseGenerationChanged
248        }
249        Some((current_id, _, _)) if current_id != upgrade_id => WasmCarrierUpgradeFence::Superseded,
250        Some((_, _, current_policy_epoch)) if current_policy_epoch != policy_epoch => {
251            WasmCarrierUpgradeFence::PolicyChanged
252        }
253        Some((_, _, current_policy_epoch)) => {
254            WasmCarrierUpgradeFence::Current(current_policy_epoch)
255        }
256    }
257}
258
259/// Remote capability authority is connection-scoped. Preserve a matching
260/// authenticated attempt when its first canonical advertisement arrives
261/// slightly later, but invalidate every later capability change for that peer.
262#[cfg(any(test, feature = "iroh-carrier-core"))]
263pub(crate) fn should_bump_remote_carrier_peer_policy_epoch(
264    capabilities_were_known: bool,
265    capabilities_changed: bool,
266    matching_first_attempt: bool,
267) -> bool {
268    capabilities_changed && (capabilities_were_known || !matching_first_attempt)
269}
270
271#[cfg(test)]
272mod wasm_carrier_upgrade_gate_tests {
273    use super::*;
274
275    const BASE: (u64, u64, u64) = (7, 11, 13);
276
277    #[test]
278    fn delayed_proof_duplicate_replay_keeps_the_original_attempt_fenced() {
279        let current = Some(("attempt-a", BASE, 23));
280
281        assert_eq!(
282            classify_wasm_carrier_upgrade_reservation(current, false, "attempt-a", BASE, 23),
283            WasmCarrierUpgradeReservation::CurrentReplay,
284        );
285        assert_eq!(
286            classify_wasm_carrier_upgrade_reservation(current, false, "attempt-b", BASE, 23),
287            WasmCarrierUpgradeReservation::Busy,
288        );
289        assert_eq!(
290            classify_wasm_carrier_upgrade_fence(current, "attempt-a", BASE, 23),
291            WasmCarrierUpgradeFence::Current(23),
292        );
293    }
294
295    #[test]
296    fn another_carrier_cannot_overtake_a_proving_browser_generation() {
297        assert_eq!(
298            classify_wasm_carrier_upgrade_reservation(None, true, "webrtc-attempt", BASE, 23,),
299            WasmCarrierUpgradeReservation::Busy,
300        );
301    }
302
303    #[test]
304    fn remote_capability_epoch_preserves_only_a_matching_first_observation() {
305        assert!(!should_bump_remote_carrier_peer_policy_epoch(
306            false, true, true,
307        ));
308        assert!(should_bump_remote_carrier_peer_policy_epoch(
309            false, true, false,
310        ));
311        assert!(should_bump_remote_carrier_peer_policy_epoch(
312            true, true, true,
313        ));
314        assert!(!should_bump_remote_carrier_peer_policy_epoch(
315            true, false, false,
316        ));
317    }
318
319    #[test]
320    fn peer_base_readiness_failure_remains_recoverable() {
321        assert_eq!(
322            wasm_peer_carrier_failure_code(Some("carrier-base-not-ready")),
323            "carrier-base-not-ready",
324        );
325        assert_eq!(
326            wasm_peer_carrier_failure_code(Some("carrier-base-generation-stale")),
327            "carrier-base-generation-stale",
328        );
329        assert_eq!(
330            wasm_peer_carrier_failure_code(Some("carrier-authorization-rejected")),
331            "peer-rejected-carrier",
332        );
333        assert!(should_rearm_wasm_carrier_event_retry(
334            "carrier-base-not-ready",
335            true,
336            false,
337        ));
338        assert!(!should_rearm_wasm_carrier_event_retry(
339            "peer-rejected-carrier",
340            true,
341            false,
342        ));
343        assert!(!should_rearm_wasm_carrier_event_retry(
344            "carrier-base-not-ready",
345            false,
346            false,
347        ));
348        assert!(!should_rearm_wasm_carrier_event_retry(
349            "carrier-base-not-ready",
350            true,
351            true,
352        ));
353    }
354
355    #[test]
356    fn carrier_fence_distinguishes_lifecycle_retirement_from_policy_change() {
357        assert_eq!(
358            classify_wasm_carrier_upgrade_fence(None, "attempt-a", BASE, 23),
359            WasmCarrierUpgradeFence::Retired,
360        );
361        assert_eq!(
362            classify_wasm_carrier_upgrade_fence(
363                Some(("attempt-a", BASE, 23)),
364                "attempt-a",
365                BASE,
366                24,
367            ),
368            WasmCarrierUpgradeFence::PolicyChanged,
369        );
370        assert_eq!(
371            classify_wasm_carrier_upgrade_fence(
372                Some(("attempt-b", BASE, 23)),
373                "attempt-a",
374                BASE,
375                23,
376            ),
377            WasmCarrierUpgradeFence::Superseded,
378        );
379        assert_eq!(
380            classify_wasm_carrier_upgrade_fence(
381                Some(("attempt-a", (17, 19, 23), 23)),
382                "attempt-a",
383                BASE,
384                23,
385            ),
386            WasmCarrierUpgradeFence::BaseGenerationChanged,
387        );
388    }
389}
390
391#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
392pub(crate) struct NativeWebRtcCarrierAttempt {
393    pub upgrade_id: String,
394    pub bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
395    pub generation: NativePeerDataGeneration,
396    pub remote_endpoint_id: iroh::EndpointId,
397    pub channel: Arc<crate::transport::WebRtcDataChannel>,
398    pub pump: tokio::sync::Mutex<Option<crate::native_webrtc_carrier::NativeWebRtcCarrierSession>>,
399}
400
401#[cfg(all(not(target_arch = "wasm32"), feature = "transport-moq"))]
402pub(crate) struct NativeMoqCarrierAttempt {
403    pub upgrade_id: String,
404    pub bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
405    pub generation: NativePeerDataGeneration,
406    pub remote_endpoint_id: iroh::EndpointId,
407    pub session: Arc<crate::transport::NativeMoQSession>,
408    pub pump: tokio::sync::Mutex<Option<crate::native_moq_carrier::NativeMoqCarrierSession>>,
409}
410
411#[cfg(all(not(target_arch = "wasm32"), feature = "transport-moq"))]
412impl std::fmt::Debug for NativeMoqCarrierAttempt {
413    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414        formatter
415            .debug_struct("NativeMoqCarrierAttempt")
416            .field("upgrade_id", &self.upgrade_id)
417            .field("generation", &self.generation)
418            .field("remote_endpoint_id", &self.remote_endpoint_id)
419            .finish_non_exhaustive()
420    }
421}
422
423#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
424impl std::fmt::Debug for NativeWebRtcCarrierAttempt {
425    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426        formatter
427            .debug_struct("NativeWebRtcCarrierAttempt")
428            .field("upgrade_id", &self.upgrade_id)
429            .field("generation", &self.generation)
430            .field("remote_endpoint_id", &self.remote_endpoint_id)
431            .finish_non_exhaustive()
432    }
433}
434
435/// Exact physical owner of one SDK native-main stream. The logical connection
436/// id intentionally remains the map key; this token prevents stale physical
437/// generations and duplicate QUIC streams from mutating the current entry.
438#[cfg(not(target_arch = "wasm32"))]
439#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440pub(crate) struct NativeControlStreamOwner {
441    pub transport_stable_id: u64,
442    pub stream_rank: u64,
443}
444
445#[cfg(not(target_arch = "wasm32"))]
446#[derive(Clone)]
447pub(crate) struct NativeControlStreamEntry {
448    pub owner: NativeControlStreamOwner,
449    pub endpoint_id: iroh::EndpointId,
450    pub send: Arc<tokio::sync::Mutex<iroh::endpoint::SendStream>>,
451}
452
453/// Last valid OpenRTC protocol activity observed from one physical Iroh
454/// generation.
455///
456/// QUIC health probes use a separate uni-stream. A mobile runtime can process
457/// admission or control frames on an already-open bi-stream while that
458/// diagnostic probe is delayed or dropped. Recording the protocol activity
459/// here lets the lifecycle owner use that stronger positive evidence without
460/// allowing an old physical generation to keep its replacement alive.
461#[cfg(not(target_arch = "wasm32"))]
462#[derive(Debug, Clone, Copy)]
463pub(crate) struct NativeTransportProtocolActivity {
464    pub transport_stable_id: u64,
465    pub observed_at: std::time::Instant,
466}
467
468#[cfg(not(target_arch = "wasm32"))]
469#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
470pub(crate) struct NativeAdmissionStreamContracts {
471    pub inbound: Option<crate::native_protocol::TokenStreamContract>,
472    pub outbound: Option<crate::native_protocol::TokenStreamContract>,
473}
474
475#[cfg(not(target_arch = "wasm32"))]
476#[derive(Debug, Clone)]
477pub(crate) struct CachedManagedScopeTicket {
478    pub scope: crate::session_token::GrantScope,
479    pub token: String,
480    pub max_connections: u32,
481    pub compound_ticket: String,
482    pub iroh_ticket: String,
483}
484
485#[cfg(not(target_arch = "wasm32"))]
486#[derive(Debug, Clone)]
487pub(crate) struct NativeRouteRepairCredential {
488    pub token: String,
489    pub token_payload: String,
490    pub authoritative_device_id: String,
491}
492
493/// Last explicit product capability accepted for a known remote device.
494///
495/// Unlike provider `user-device` credentials, this intent is shared by native
496/// and WASM runtimes. It lets the existing physical-transport owner restore
497/// the exact product scope after a network-generation replacement without
498/// waiting for an application or coordination callback to dial again.
499#[derive(Debug, Clone, PartialEq, Eq)]
500pub(crate) struct ScopedRouteRepairCredential {
501    pub token: String,
502    pub scope: String,
503    pub max_connections: u32,
504    pub expires_at_ms: Option<u64>,
505    pub authoritative_device_id: String,
506}
507
508/// Opaque, short-lived native trust evidence supplied by a developer backend.
509///
510/// OpenRTC does not interpret the token. The active control plane verifies and
511/// exchanges it while callers and host bridges remain provider-neutral.
512#[allow(dead_code)]
513#[derive(Clone)]
514pub struct Client {
515    app_tag: String,
516    broadcasts: crate::broadcast::Broadcasts,
517    signaling: Arc<dyn SignalingBackend>,
518    pub room: Arc<dyn RoomBackend>,
519    node_id: Arc<RwLock<Option<String>>>,
520    pub iroh_endpoint: Arc<RwLock<Option<Endpoint>>>,
521    #[cfg(target_arch = "wasm32")]
522    pub iroh_node: Arc<RwLock<Option<IrohWasmNode>>>,
523    #[cfg(not(target_arch = "wasm32"))]
524    pub iroh_node: Arc<RwLock<Option<IrohNativeNode>>>,
525    #[cfg(not(target_arch = "wasm32"))]
526    native_application_streams: async_channel::Sender<IncomingStream>,
527    #[cfg(not(target_arch = "wasm32"))]
528    native_application_streams_receiver: async_channel::Receiver<IncomingStream>,
529    pub connection_manager: Arc<crate::connection_manager::ConnectionManager>,
530    #[cfg(not(target_arch = "wasm32"))]
531    native_device_identity: Arc<RwLock<Option<crate::native_device::NativeDeviceIdentity>>>,
532    #[cfg(not(target_arch = "wasm32"))]
533    native_device_base_dir: Arc<RwLock<Option<std::path::PathBuf>>>,
534    #[cfg(not(target_arch = "wasm32"))]
535    native_device_identity_init_guard: Arc<tokio::sync::Mutex<()>>,
536    #[cfg(not(target_arch = "wasm32"))]
537    native_device_updates:
538        tokio::sync::broadcast::Sender<crate::native_device::NativeDeviceIdentity>,
539    #[cfg(not(target_arch = "wasm32"))]
540    native_connection_state_updates: tokio::sync::broadcast::Sender<StateSnapshot>,
541    #[cfg(not(target_arch = "wasm32"))]
542    native_peer_data_updates: tokio::sync::broadcast::Sender<NativePeerDataEvent>,
543    auto_connect_loop_key: Arc<Mutex<Option<(String, String)>>>,
544    auto_connect_generation: Arc<AtomicU64>,
545    /// Generation-local input for the native provider-backed auto-connect
546    /// actor. Admission and transport callbacks notify the existing owner;
547    /// they never start a second polling loop or perform a local redial.
548    #[cfg(not(target_arch = "wasm32"))]
549    native_auto_connect_wake: Arc<tokio::sync::Notify>,
550    /// Provider-neutral native desired-peer input. Hosted coordination adapters
551    /// submit revisioned snapshots; Rust remains the only dial/retry owner.
552    #[cfg(not(target_arch = "wasm32"))]
553    external_desired_peer_actor:
554        Arc<tokio::sync::Mutex<auto_connect_impl::NativeExternalAutoConnectActorState>>,
555    /// Device IDs excluded from auto-connect. Session-scoped: cleared on restart.
556    /// Serializes every exclusion mutation so an authenticated peer resume
557    /// cannot erase a concurrent local disconnect intent.
558    auto_connect_exclusion_owner: Arc<Mutex<()>>,
559    auto_connect_excluded: Arc<Mutex<HashSet<String>>>,
560    /// Subset of exclusions installed because the peer explicitly disconnected.
561    ///
562    /// A fresh authenticated user-device admission from that peer is an explicit
563    /// reconnect request and may clear this subset. Locally requested exclusions
564    /// are deliberately not recorded here, so a remote dial cannot override the
565    /// local user's disconnect choice.
566    auto_connect_peer_requested_excluded: Arc<Mutex<HashSet<String>>>,
567    /// Node-id aliases for session-scoped auto-connect exclusions, keyed by
568    /// canonical device id. These are local-only and must not be published into
569    /// the coordination roster's `excludedPeers`, which is a device-id contract.
570    auto_connect_excluded_node_aliases: Arc<Mutex<HashMap<String, HashSet<String>>>>,
571    /// Session token registry — gates incoming connections during short-lived
572    /// sessions (e.g. share page). When non-empty, incoming handshakes must
573    /// carry a valid token. Shared across WASM and native paths.
574    pub session_token_registry: Arc<crate::session_token::SessionTokenRegistry>,
575    /// Directional proof that this runtime validated the remote token on the
576    /// current physical Iroh generation. Logical admission survives reconnect,
577    /// but a replacement leg must establish a fresh SDK control route.
578    #[cfg(not(target_arch = "wasm32"))]
579    inbound_session_admission_transport_ids: Arc<StdRwLock<HashMap<String, u64>>>,
580    /// Trust-validated offline candidates that require a fresh proof before
581    /// any product stream on their deterministic connection can be routed.
582    #[cfg(not(target_arch = "wasm32"))]
583    offline_admission_requirements:
584        Arc<StdRwLock<HashMap<String, crate::offline::OfflineCandidateHandoff>>>,
585    /// Accepted offline proofs fenced to their exact physical generation.
586    #[cfg(not(target_arch = "wasm32"))]
587    offline_admission_transport_ids: Arc<StdRwLock<HashMap<String, u64>>>,
588    /// Host-installed offline trust/signing owner. Discovery reports local
589    /// addresses into this state; it never owns dialing or admission.
590    #[cfg(not(target_arch = "wasm32"))]
591    pub(crate) offline_runtime:
592        Arc<tokio::sync::Mutex<Option<crate::offline::InstalledOfflineRuntime>>>,
593    /// At most one bounded offline proof request per logical connection and
594    /// physical generation. Retry remains owned by the desired-peer actor.
595    #[cfg(not(target_arch = "wasm32"))]
596    pub(crate) offline_proof_attempts: Arc<tokio::sync::Mutex<HashMap<String, u64>>>,
597    /// Declared lifetime of the stream that established admission for this
598    /// logical connection. The contract determines which generation-bound
599    /// proofs gate readiness; it never owns reconnection or settlement.
600    #[cfg(not(target_arch = "wasm32"))]
601    native_admission_stream_contracts:
602        Arc<StdRwLock<HashMap<String, NativeAdmissionStreamContracts>>>,
603    /// Host approval observed by this dialer, fenced to the physical Iroh leg
604    /// and token that produced it. Local trusted-device admission is a separate
605    /// fact and must never suppress remote token presentation.
606    remote_session_admission_proofs: Arc<StdRwLock<HashMap<String, RemoteSessionAdmissionProof>>>,
607    /// Rust-owned transcript for a reciprocal admission response that is
608    /// awaiting an ACK on one exact stream and physical transport generation.
609    /// Host adapters may relay observations, but cannot supply the token or
610    /// scope at commit time.
611    pending_inline_reciprocal_admissions:
612        Arc<StdRwLock<HashMap<String, PendingInlineReciprocalAdmission>>>,
613    /// Last capability token approved by the remote host for each logical
614    /// connection. This is application-security state, not transport state:
615    /// route replacement preserves a same-token epoch, while token rotation
616    /// must retire the previous application key before product traffic resumes.
617    outbound_application_security_epoch_fingerprints: Arc<StdRwLock<HashMap<String, String>>>,
618    /// Valid token presentations may ask this runtime to restore the reverse
619    /// directional proof. The existing auto-connect actor consumes this typed
620    /// input; it does not introduce a second retry or lifecycle owner.
621    #[cfg(not(target_arch = "wasm32"))]
622    pending_reciprocal_session_admission_requests: Arc<StdRwLock<HashSet<String>>>,
623    /// Latest validated managed credential advertised by each remote native
624    /// node. The native admission responder uses this desired-state input to
625    /// answer an inline reciprocal request without opening a second stream.
626    #[cfg(not(target_arch = "wasm32"))]
627    native_route_repair_credentials: Arc<StdRwLock<HashMap<String, NativeRouteRepairCredential>>>,
628    /// Explicit product admission intent keyed by authoritative remote node.
629    /// Only non-`user-device` scopes are retained here; provider auto-connect
630    /// remains independently owned by its desired-peer actor.
631    scoped_route_repair_credentials: Arc<StdRwLock<HashMap<String, ScopedRouteRepairCredential>>>,
632    connection_application_crypto_keys:
633        Arc<StdRwLock<HashMap<String, [u8; crate::application_crypto::APPLICATION_KEY_BYTES]>>>,
634    /// Serializes key agreement, key, requirement, sequence, and generation-
635    /// bound confirmation as one application-security state. The maps remain
636    /// separate for their existing read shapes, but a handshake commit is
637    /// observed atomically across all five.
638    connection_application_crypto_state: Arc<StdRwLock<()>>,
639    connection_application_crypto_required: Arc<StdRwLock<HashSet<String>>>,
640    /// Product opt-in requiring every trusted native user-device route to
641    /// complete reciprocal application key agreement before product streams
642    /// become routable.
643    #[cfg(not(target_arch = "wasm32"))]
644    trusted_user_device_application_crypto_required: Arc<AtomicBool>,
645    /// Automatic key agreement is not routable until the remote peer has
646    /// acknowledged the exact connection key. Key presence alone is only a
647    /// local derivation fact and can race the reciprocal handshake.
648    connection_application_crypto_confirmed: Arc<
649        StdRwLock<
650            HashMap<
651                String,
652                crate::client::application_crypto_impl::ConnectionApplicationCryptoConfirmation,
653            >,
654        >,
655    >,
656    /// Wakes native ingress tasks when any fact in the current application
657    /// route changes. Independent QUIC streams have no cross-stream delivery
658    /// order, so an application stream may arrive immediately before either a
659    /// directional admission proof or the reciprocal key acknowledgement. The
660    /// Rust admission owner uses this typed wake to hold that stream behind the
661    /// exact transport and security epoch without polling.
662    #[cfg(not(target_arch = "wasm32"))]
663    connection_application_route_updates: Arc<tokio::sync::Notify>,
664    connection_application_crypto_outbound_sequences: Arc<StdRwLock<HashMap<String, u64>>>,
665    connection_application_key_agreements:
666        Arc<StdRwLock<HashMap<String, crate::key_agreement::KeyAgreement>>>,
667    /// Native-managed admission grants that must survive frontend refreshes
668    /// for the lifetime of the desktop app process. The user-device scope is
669    /// intentionally backend-owned so auto-connect presence can keep
670    /// publishing the same tokenized ticket until explicit revoke/app restart.
671    ///
672    /// This cache is read-heavy (ticket lookups/refreshes) with infrequent
673    /// writes (revoke/rehydrate), so a standard RwLock avoids unnecessary
674    /// exclusive locking overhead from Mutex.
675    #[cfg(not(target_arch = "wasm32"))]
676    managed_scope_tickets: Arc<StdRwLock<HashMap<String, CachedManagedScopeTicket>>>,
677    /// Latest ticket-derived Iroh address for each remote endpoint.
678    ///
679    /// The coordination gateway publishes fresh device tickets. Native stream
680    /// recovery uses this cache to redial Iroh directly when a cached
681    /// connection/stream has gone stale.
682    known_endpoint_addrs: Arc<RwLock<HashMap<String, iroh::EndpointAddr>>>,
683    /// Session-scoped, authoritative mapping from the current remote Iroh node
684    /// to its durable device id. Physical connection records are deliberately
685    /// ephemeral and may be retired before an inbound replacement leg arrives;
686    /// logical identity must survive that transport-generation boundary.
687    known_device_ids_by_node: Arc<StdRwLock<HashMap<String, String>>>,
688    /// Wakes bounded known-device admission calls when either half of the
689    /// Rust-owned device-to-endpoint lookup changes. Callers wait on this
690    /// revision instead of installing an application-layer roster watcher.
691    known_device_endpoint_revision: tokio::sync::watch::Sender<u64>,
692    /// IO-less sparse avenue owner shared by native and WASM. It reduces
693    /// provider rosters and verifies forwarded broadcast envelopes but never
694    /// opens a socket or schedules lifecycle work.
695    sparse_fanout: Arc<tokio::sync::Mutex<crate::sparse_fanout::SparseFanoutState>>,
696    /// When true, the auto-connect loop runs at reduced frequency and skips
697    /// expensive operations (health probing, presence republish, network change
698    /// recovery). Event-driven connects still fire immediately.
699    app_backgrounded: Arc<AtomicBool>,
700    background_execution_allowed: Arc<AtomicBool>,
701    #[cfg(target_arch = "wasm32")]
702    wasm_accept_bridge_started: Arc<std::sync::atomic::AtomicBool>,
703    #[cfg(target_arch = "wasm32")]
704    last_emitted_connection_states:
705        Arc<Mutex<std::collections::HashMap<String, WasmConnectionStateFingerprint>>>,
706    #[cfg(target_arch = "wasm32")]
707    last_empty_peer_sessions_warning_ms: Arc<AtomicU64>,
708    /// Serializes concurrent calls to `init_iroh_with_router_mode` so that only
709    /// one endpoint is ever created. Without this, the background init and a
710    /// frontend IPC `start_iroh_node` can race and bind two endpoints with the
711    /// same secret key, causing the relay to reject the duplicate.
712    iroh_init_guard: Arc<tokio::sync::Mutex<()>>,
713    /// Packet carriers installed on the one immutable endpoint before bind.
714    /// Session pumps may activate a peer address, but the peer-session actor
715    /// remains the only owner allowed to commit a replacement generation.
716    #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
717    iroh_packet_carriers: Arc<
718        RwLock<
719            HashMap<
720                crate::iroh_carrier_kind::IrohCarrierKind,
721                Arc<crate::packet_carrier_transport::PacketCarrierAddressProvider>,
722            >,
723        >,
724    >,
725    /// Coalesces concurrent managed dials for the same deterministic connection id.
726    ///
727    /// Without this, two near-simultaneous `connect_device` callers can both dial
728    /// the same endpoint before either observes the other's Pending record. The
729    /// native node then replaces one outbound transport with the other, killing the
730    /// session-token admission stream before the host can adopt the connection.
731    managed_connect_gates: Arc<tokio::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
732    /// Physical Iroh connection stable IDs with an active selected-path watcher.
733    /// Every install/adoption path may request a watcher; this registry keeps
734    /// ownership singular without assuming another path already spawned one.
735    #[cfg(not(target_arch = "wasm32"))]
736    iroh_path_watcher_stable_ids: Arc<Mutex<HashSet<u64>>>,
737    /// Maps Iroh custom-transport discriminators to OpenRTC path semantics.
738    ///
739    /// Companion crates register their transport id here. Core owns only path
740    /// classification and lifecycle projection; transport-specific sockets,
741    /// scanning, and retries remain in the companion.
742    #[cfg(not(target_arch = "wasm32"))]
743    native_custom_transport_kinds: Arc<RwLock<HashMap<u64, IrohPathKind>>>,
744    /// Runtime-installed native transport providers keyed by their OpenRTC path
745    /// kind. Providers own hardware discovery; OpenRTC owns peer negotiation,
746    /// connection replacement, and lifecycle projection.
747    #[cfg(not(target_arch = "wasm32"))]
748    native_transport_upgrade_providers:
749        Arc<RwLock<HashMap<IrohPathKind, Arc<dyn UpgradeProvider>>>>,
750    /// Rust-owned browser carrier attempt gates. JavaScript owns the
751    /// `RTCPeerConnection` objects, but it cannot authorize, supersede, retry,
752    /// or commit a physical Iroh generation.
753    #[cfg(target_arch = "wasm32")]
754    wasm_transport_upgrade_gates:
755        Arc<tokio::sync::Mutex<HashMap<(String, IrohPathKind), WasmTransportUpgradeGate>>>,
756    /// Coalesces capability handshakes and retries into one transport upgrade
757    /// attempt per logical peer/path pair.
758    #[cfg(not(target_arch = "wasm32"))]
759    native_transport_upgrade_gates:
760        Arc<tokio::sync::Mutex<HashMap<(String, IrohPathKind), NativeTransportUpgradeGate>>>,
761    /// Bounded BLE replacement attempts for the current logical/physical
762    /// generation. The Rust transport owner uses this to retry radio or route
763    /// failures without creating a second lifecycle loop in TypeScript.
764    #[cfg(not(target_arch = "wasm32"))]
765    native_ble_upgrade_attempts:
766        Arc<tokio::sync::Mutex<HashMap<String, NativeBleUpgradeAttemptState>>>,
767    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
768    native_webrtc_carrier_attempts: Arc<RwLock<HashMap<String, Arc<NativeWebRtcCarrierAttempt>>>>,
769    /// Bounded, test-only ownership trail for native Iroh carrier attempts.
770    /// Physical-device harnesses cannot rely on WebView or process stdout, so
771    /// the Rust owner retains the last decisions for generation-safe failure
772    /// evidence without exposing carrier details through the public API.
773    #[cfg(all(
774        not(target_arch = "wasm32"),
775        feature = "iroh-carrier-core",
776        any(feature = "test-harness", feature = "testing-endpoints")
777    ))]
778    native_iroh_carrier_debug_events: Arc<StdRwLock<std::collections::VecDeque<serde_json::Value>>>,
779    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-moq"))]
780    native_moq_carrier_attempts: Arc<RwLock<HashMap<String, Arc<NativeMoqCarrierAttempt>>>>,
781    /// Negotiated optional-transport facts for the current logical peer
782    /// session. Path watchers consult this before asking the single upgrade
783    /// owner to react to relay demotion.
784    #[cfg(not(target_arch = "wasm32"))]
785    native_peer_transport_capabilities:
786        Arc<RwLock<HashMap<String, HashSet<NativePeerTransportCapability>>>>,
787    pub(crate) presence_loop_tx:
788        Arc<Mutex<Option<tokio::sync::mpsc::Sender<crate::presence::PresenceCommand>>>>,
789    /// Platform project and token source used for authenticated gateway
790    /// admission. Logical usage policy is enforced by the control plane, not
791    /// cached in the transport runtime.
792    #[cfg(not(target_arch = "wasm32"))]
793    pub(crate) project_id: String,
794    #[cfg(not(target_arch = "wasm32"))]
795    pub(crate) token_provider: Arc<dyn Fn() -> Option<String> + Send + Sync>,
796    transport_config: Arc<RwLock<TransportConfig>>,
797    /// Monotonic fence for carrier selection inputs. Attempts capture this at
798    /// reservation and must match it immediately before atomic replacement.
799    iroh_carrier_policy_epoch: Arc<AtomicU64>,
800    /// Per-connection remote capability revisions. A third peer must never
801    /// invalidate an unrelated carrier proof, while a capability change for
802    /// the owning peer must still retire its stale reservation.
803    iroh_carrier_peer_policy_epochs: Arc<StdRwLock<HashMap<String, u64>>>,
804    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
805    pub(crate) local_discovery_registry: crate::local_discovery::LocalDiscoveryRegistry,
806    #[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
807    mdns_address_lookup: Arc<RwLock<Option<iroh_mdns_address_lookup::MdnsAddressLookup>>>,
808    /// Persistent SDK-owned native-main control stream. The admission request
809    /// establishes this channel before application streams are exposed; transport
810    /// capability, replacement, and signaling frames reuse it for the peer
811    /// session lifetime. The map is keyed by deterministic connection id, but
812    /// each value is owned by one physical Iroh generation and QUIC stream.
813    #[cfg(not(target_arch = "wasm32"))]
814    pub(crate) native_control_streams:
815        Arc<tokio::sync::Mutex<HashMap<String, NativeControlStreamEntry>>>,
816    /// Generation-bound positive liveness evidence produced by successfully
817    /// parsed OpenRTC protocol frames. This is an input to the single Rust
818    /// lifecycle owner, not a second timer or connection-state authority.
819    #[cfg(not(target_arch = "wasm32"))]
820    native_transport_protocol_activity:
821        Arc<StdRwLock<HashMap<String, NativeTransportProtocolActivity>>>,
822    /// Experimental, opt-in scoped actor registry. The default runtime has no
823    /// Rust-side actor: the TypeScript scoped actor is the single active
824    /// coalescer until a native actor owns real dial and channel routing.
825    #[cfg(all(not(target_arch = "wasm32"), feature = "experimental-scoped-actor"))]
826    scoped_connection_actor_registry:
827        Arc<RwLock<Option<Arc<crate::client::scoped_connection_actor::ConnectionActors>>>>,
828    /// Phase 5: shared auth-readiness gate. The TS auth bridge (or any
829    /// host that owns auth lifecycle) pushes leg state via `mark_*`;
830    /// the drive-grant connection actor and other readiness-aware
831    /// callers consult it via `wait_until_ready`. Always present so
832    /// `Default`-style construction does not need a feature flag.
833    #[cfg(not(target_arch = "wasm32"))]
834    auth_readiness: Arc<crate::client::auth_readiness::AuthReadinessStore>,
835    /// Scope classifier for mapping admitted scopes → correlation labels.
836    /// Default preserves legacy drive-grant/user-device behavior.
837    #[cfg(not(target_arch = "wasm32"))]
838    scope_classifier: Arc<RwLock<Arc<dyn ScopeClassifier>>>,
839}
840
841#[derive(Debug, Clone, PartialEq, Eq)]
842struct RemoteSessionAdmissionProof {
843    transport_stable_id: u64,
844    token_fingerprint: String,
845    approval_scope: String,
846}
847
848#[derive(Debug, Clone)]
849struct PendingInlineReciprocalAdmission {
850    connection_id: String,
851    remote_node_id: String,
852    local_device_id: String,
853    remote_device_id: String,
854    transport_stable_id: u64,
855    stream_instance_id: String,
856    presentation_id: String,
857    token: String,
858    token_fingerprint: String,
859    expected_scope: String,
860    inbound_admission_fingerprint: String,
861    inbound_admission_epoch: u64,
862}
863
864fn now_millis_i64() -> i64 {
865    crate::coordination::now_millis_u64().min(i64::MAX as u64) as i64
866}
867
868/// The current path kind of an iroh connection, used to gate transport upgrades.
869///
870/// Priority order for native data sending is edge-aware:
871///   proven WebRTC/MoQ routes stay first; `DirectQuic` / `DirectLan` / `Ble`
872///   remain primary base paths; relay or unknown paths can probe optional
873///   upgrades first.
874///
875/// WebRTC and MoQ upgrades are started when the path is `Relay` and suspended
876/// (but not torn down) when it transitions back to `DirectQuic`.
877#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
878pub enum IrohPathKind {
879    /// Direct UDP/QUIC hole-punched path — low latency, no intermediary.
880    DirectQuic,
881    /// Direct QUIC over a private/link-local LAN address.
882    DirectLan,
883    /// Traffic routed through an iroh relay server — higher latency, rate-limited for web.
884    Relay,
885    /// Selected path uses a BLE custom transport.
886    Ble,
887    /// Selected path carries Iroh packets through an unreliable WebRTC DataChannel.
888    WebRtc,
889    /// Selected path carries Iroh packets through a FIFO MoQ object track.
890    Moq,
891    /// No live connection or path information is not yet available.
892    Unknown,
893}
894
895#[cfg(not(target_arch = "wasm32"))]
896#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
897pub(crate) enum NativePeerTransportCapability {
898    WebRtc,
899    Moq,
900    Ble,
901}
902
903/// Hardware-specific preparation for a native Iroh transport upgrade.
904///
905/// Implementations may scan radios or resolve a platform route, but they do
906/// not mutate OpenRTC connection state. The returned address must contain only
907/// the prepared transport path so OpenRTC can prove the replacement did not
908/// silently fall back to relay or IP.
909#[cfg(not(target_arch = "wasm32"))]
910#[async_trait::async_trait]
911pub trait UpgradeProvider: std::fmt::Debug + Send + Sync {
912    fn kind(&self) -> IrohPathKind;
913    fn transport_id(&self) -> u64;
914    async fn prepare_endpoint_addr(
915        &self,
916        endpoint_id: iroh::EndpointId,
917    ) -> anyhow::Result<iroh::EndpointAddr>;
918}
919
920impl IrohPathKind {
921    pub fn is_relay_path(self) -> bool {
922        matches!(self, Self::Relay)
923    }
924
925    pub fn transport_label(self) -> &'static str {
926        match self {
927            Self::DirectQuic => crate::transport_label::IROH_QUIC,
928            Self::DirectLan => crate::transport_label::IROH_LAN,
929            Self::Relay => crate::transport_label::IROH_RELAY,
930            Self::Ble => crate::transport_label::BLE,
931            Self::WebRtc => crate::transport_label::WEBRTC,
932            Self::Moq => crate::transport_label::MOQ,
933            Self::Unknown => crate::transport_label::IROH,
934        }
935    }
936}
937
938/// Native iroh LAN discovery configuration.
939#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
940#[serde(rename_all = "camelCase")]
941pub struct IrohLanConfig {
942    #[serde(default = "default_lan_enabled")]
943    pub enabled: bool,
944    /// When false, listen for LAN peers without advertising this endpoint.
945    #[serde(default = "default_lan_advertise")]
946    pub advertise: bool,
947}
948
949fn default_lan_enabled() -> bool {
950    true
951}
952
953fn default_lan_advertise() -> bool {
954    true
955}
956
957impl Default for IrohLanConfig {
958    fn default() -> Self {
959        Self {
960            enabled: true,
961            advertise: true,
962        }
963    }
964}
965
966/// Native BLE discovery and iroh custom-transport configuration.
967///
968/// BLE is native-only and is intended as a nearby-device path for poor or
969/// unavailable internet conditions. Browser runtimes ignore this setting.
970#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
971#[serde(rename_all = "camelCase")]
972pub struct BleConfig {
973    #[serde(default)]
974    pub enabled: bool,
975    #[serde(default, skip_serializing_if = "Option::is_none")]
976    pub connect_timeout_ms: Option<u64>,
977}
978
979/// How long a newly-bound transport has to reach settled-ready state before the
980/// connection is considered dead and retired.  Must be longer than the
981/// duplicate-close grace window (8 s) so that auto-connect suppression covers
982/// the entire hold period.
983pub(crate) const MANAGED_SETTLE_DEADLINE_MS: i64 =
984    crate::runtime_policy::MANAGED_SETTLE_DEADLINE_MS;
985
986/// How long an incoming connection has to present a valid session token before
987/// it is closed with session-admission-timeout.  This is intentionally longer
988/// than MANAGED_SETTLE_DEADLINE_MS because share-ticket peers must complete
989/// full WASM initialization and iroh connection setup before they can send the
990/// token stream, which can take 20-40 s on a cold web load.  Matches
991/// NATIVE_WEBRTC_CONNECT_TIMEOUT_MS so WebRTC negotiation can complete in the
992/// same window.
993#[cfg(not(target_arch = "wasm32"))]
994pub(crate) const SESSION_ADMISSION_TIMEOUT_MS: u64 =
995    crate::runtime_policy::SESSION_ADMISSION_TIMEOUT_MS;
996
997#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
998#[serde(rename_all = "camelCase")]
999pub enum ConnectionStatus {
1000    Disconnected,
1001    Connecting,
1002    Connected,
1003    Failed,
1004    Closed,
1005    Online,
1006}
1007
1008#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1009#[serde(rename_all = "camelCase")]
1010pub enum DevicePresenceStatus {
1011    Online,
1012    Idle,
1013    Offline,
1014}
1015
1016#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1017#[serde(rename_all = "camelCase")]
1018pub enum ReadinessState {
1019    Connecting,
1020    TransportOnly,
1021    Settling,
1022    Routable,
1023    AwaitingReplacement,
1024    Closed,
1025    Failed,
1026}
1027
1028#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1029#[serde(rename_all = "camelCase")]
1030pub struct LatencySnapshot {
1031    pub webrtc: Option<u64>,
1032    pub iroh: Option<u64>,
1033    pub iroh_lan: Option<u64>,
1034    pub iroh_relay: Option<u64>,
1035    pub ble: Option<u64>,
1036    pub moq: Option<u64>,
1037}
1038
1039impl LatencySnapshot {
1040    fn set(&mut self, transport: &str, latency_ms: u64) {
1041        match transport.trim().to_ascii_lowercase().as_str() {
1042            "webrtc" => self.webrtc = Some(latency_ms),
1043            "iroh-lan" => self.iroh_lan = Some(latency_ms),
1044            "iroh-relay" => self.iroh_relay = Some(latency_ms),
1045            "iroh" | "iroh-quic" => self.iroh = Some(latency_ms),
1046            "ble" => self.ble = Some(latency_ms),
1047            "moq" => self.moq = Some(latency_ms),
1048            _ => {}
1049        }
1050    }
1051
1052    fn get(&self, transport: &str) -> Option<u64> {
1053        match transport.trim().to_ascii_lowercase().as_str() {
1054            "webrtc" => self.webrtc,
1055            "iroh-lan" => self.iroh_lan,
1056            "iroh-relay" => self.iroh_relay,
1057            "iroh" | "iroh-quic" => self.iroh,
1058            "ble" => self.ble,
1059            "moq" => self.moq,
1060            _ => None,
1061        }
1062    }
1063}
1064
1065#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1066#[serde(rename_all = "camelCase")]
1067pub struct DeviceStatusSnapshot {
1068    #[serde(flatten)]
1069    pub device: crate::signaling::Device,
1070    pub presence_status: DevicePresenceStatus,
1071    pub presence_updated_at: Option<i64>,
1072    pub presence_expires_at: Option<i64>,
1073    pub connectable: bool,
1074    pub connection_status: ConnectionStatus,
1075    pub settled_ready: bool,
1076    pub readiness_state: ReadinessState,
1077    pub readiness_reason: String,
1078    pub peer_health: crate::connection_manager::ConnectionHealth,
1079    pub peer_id: Option<String>,
1080    pub scopes: Vec<String>,
1081    pub connection_id: Option<String>,
1082    pub device_id_hint: Option<String>,
1083    pub active_transport_stable_id: Option<u64>,
1084    pub transport_generation: u64,
1085    pub route_generation: u64,
1086    pub active_transport: String,
1087    pub parallel_transport: Option<String>,
1088    #[serde(default)]
1089    pub latency_ms: Option<u64>,
1090    #[serde(default)]
1091    pub latency_by_transport: LatencySnapshot,
1092}
1093
1094#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1095#[serde(rename_all = "camelCase")]
1096pub struct PeerSessionSnapshot {
1097    pub peer_id: String,
1098    pub device_id: Option<String>,
1099    pub device_id_hint: Option<String>,
1100    pub node_id: Option<String>,
1101    pub active_connection_id: Option<String>,
1102    pub candidate_connection_ids: Vec<String>,
1103    pub status: crate::connection_manager::ConnectionState,
1104    pub health: crate::connection_manager::ConnectionHealth,
1105    pub settled_ready: bool,
1106    #[serde(default)]
1107    pub logical_session_terminal: bool,
1108    pub readiness_state: ReadinessState,
1109    pub active_transport_stable_id: Option<u64>,
1110    pub transport_generation: u64,
1111    pub route_generation: u64,
1112    pub active_transport: String,
1113    pub parallel_transport: Option<String>,
1114    pub replacement_pending: bool,
1115    pub last_lifecycle_transition_at_ms: i64,
1116    pub readiness_reason: String,
1117    pub transition_count: u64,
1118    pub connecting_transition_count: u64,
1119    pub replacement_count: u64,
1120    pub retire_count: u64,
1121    pub last_disconnect_reason: Option<String>,
1122    pub last_reconnect_reason: Option<String>,
1123    pub scopes: Vec<String>,
1124    pub last_seen_at_ms: i64,
1125    pub error: Option<String>,
1126}
1127
1128#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1129#[serde(rename_all = "camelCase")]
1130pub struct ManagedConnectResult {
1131    pub connection_id: String,
1132    pub device_id: Option<String>,
1133    pub device_id_hint: Option<String>,
1134    pub remote_node_id: String,
1135    pub state: String,
1136    #[serde(default, skip_serializing_if = "Option::is_none")]
1137    pub approved_scope: Option<String>,
1138}
1139
1140#[cfg(not(target_arch = "wasm32"))]
1141#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1142#[serde(rename_all = "camelCase")]
1143pub struct ConnectionAdoption {
1144    pub connection_id: String,
1145    pub node_id: String,
1146    pub device_id: Option<String>,
1147    pub transport_generation: u64,
1148    pub status_reason: Option<String>,
1149    pub main_stream_ready: bool,
1150}
1151
1152#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1153#[serde(rename_all = "camelCase")]
1154pub enum ManagedHealth {
1155    Healthy,
1156    AwaitingReplacement,
1157    Dead,
1158}
1159
1160#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1161#[serde(rename_all = "camelCase")]
1162pub struct ConnectionHealthView {
1163    pub connection_id: String,
1164    pub device_id: Option<String>,
1165    pub device_id_hint: Option<String>,
1166    pub node_id: Option<String>,
1167    pub active_transport_stable_id: Option<u64>,
1168    pub transport_generation: u64,
1169    pub route_generation: u64,
1170    pub status: ManagedHealth,
1171    pub settled_ready: bool,
1172    pub readiness_state: ReadinessState,
1173    pub replacement_pending: bool,
1174    pub last_lifecycle_transition_at_ms: i64,
1175    pub readiness_reason: String,
1176    pub transition_count: u64,
1177    pub connecting_transition_count: u64,
1178    pub replacement_count: u64,
1179    pub retire_count: u64,
1180    pub last_disconnect_reason: Option<String>,
1181    pub last_reconnect_reason: Option<String>,
1182}
1183
1184#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1185#[serde(rename_all = "camelCase")]
1186pub struct StateSnapshot {
1187    pub connection_id: String,
1188    pub device_id: Option<String>,
1189    pub device_id_hint: Option<String>,
1190    pub remote_node_id: Option<String>,
1191    pub state: String,
1192    pub transport_state: String,
1193    pub protocol_state: String,
1194    pub routable: bool,
1195    #[serde(default)]
1196    pub logical_session_terminal: bool,
1197    pub readiness_state: ReadinessState,
1198    pub readiness_reason: String,
1199    pub transport_generation: u64,
1200    pub route_generation: u64,
1201    pub active_transport_stable_id: Option<u64>,
1202    pub active_transport: String,
1203    pub parallel_transport: Option<String>,
1204    pub replacement_in_progress: bool,
1205    pub last_lifecycle_transition_at_ms: i64,
1206    pub transition_count: u64,
1207    pub connecting_transition_count: u64,
1208    pub replacement_count: u64,
1209    pub retire_count: u64,
1210    pub last_disconnect_reason: Option<String>,
1211    pub last_reconnect_reason: Option<String>,
1212    /// Authenticated application scopes owned by this logical peer.
1213    ///
1214    /// Native projection uses these exact Rust-owned scopes to route a
1215    /// connection into its registered capability without inventing a second
1216    /// roster or relying on gateway desired-peer identity.
1217    #[serde(default)]
1218    pub scopes: Vec<String>,
1219    pub error: Option<String>,
1220    pub created_at: i64,
1221    pub updated_at: i64,
1222}
1223
1224#[cfg(target_arch = "wasm32")]
1225#[derive(Debug, Clone, PartialEq, Eq)]
1226struct WasmConnectionStateFingerprint {
1227    connection_id: String,
1228    device_id: Option<String>,
1229    device_id_hint: Option<String>,
1230    remote_node_id: Option<String>,
1231    state: String,
1232    transport_state: String,
1233    protocol_state: String,
1234    routable: bool,
1235    logical_session_terminal: bool,
1236    transport_generation: u64,
1237    route_generation: u64,
1238    active_transport_stable_id: Option<u64>,
1239    active_transport: String,
1240    parallel_transport: Option<String>,
1241    replacement_in_progress: bool,
1242    transition_count: u64,
1243    connecting_transition_count: u64,
1244    replacement_count: u64,
1245    retire_count: u64,
1246    last_disconnect_reason: Option<String>,
1247    last_reconnect_reason: Option<String>,
1248    scopes: Vec<String>,
1249    error: Option<String>,
1250}
1251
1252#[cfg(target_arch = "wasm32")]
1253impl From<&StateSnapshot> for WasmConnectionStateFingerprint {
1254    fn from(snapshot: &StateSnapshot) -> Self {
1255        Self {
1256            connection_id: snapshot.connection_id.clone(),
1257            device_id: snapshot.device_id.clone(),
1258            device_id_hint: snapshot.device_id_hint.clone(),
1259            remote_node_id: snapshot.remote_node_id.clone(),
1260            state: snapshot.state.clone(),
1261            transport_state: snapshot.transport_state.clone(),
1262            protocol_state: snapshot.protocol_state.clone(),
1263            routable: snapshot.routable,
1264            logical_session_terminal: snapshot.logical_session_terminal,
1265            transport_generation: snapshot.transport_generation,
1266            route_generation: snapshot.route_generation,
1267            active_transport_stable_id: snapshot.active_transport_stable_id,
1268            active_transport: snapshot.active_transport.clone(),
1269            parallel_transport: snapshot.parallel_transport.clone(),
1270            replacement_in_progress: snapshot.replacement_in_progress,
1271            transition_count: snapshot.transition_count,
1272            connecting_transition_count: snapshot.connecting_transition_count,
1273            replacement_count: snapshot.replacement_count,
1274            retire_count: snapshot.retire_count,
1275            last_disconnect_reason: snapshot.last_disconnect_reason.clone(),
1276            last_reconnect_reason: snapshot.last_reconnect_reason.clone(),
1277            scopes: snapshot.scopes.clone(),
1278            error: snapshot.error.clone(),
1279        }
1280    }
1281}
1282
1283#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1284#[serde(rename_all = "camelCase", tag = "kind")]
1285pub enum BridgeAction {
1286    Healthy,
1287    AwaitReplacement {
1288        reason: String,
1289    },
1290    Rebind {
1291        remote_node_id: Option<String>,
1292        transport_generation: u64,
1293    },
1294    Retire {
1295        reason: String,
1296    },
1297}
1298
1299#[cfg(not(target_arch = "wasm32"))]
1300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1301enum DuplicateClosedHandling {
1302    RebindKeptTransport,
1303    RetireClosedTransport,
1304}
1305
1306#[cfg(not(target_arch = "wasm32"))]
1307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1308pub(crate) enum IncomingTransportCloseResolution {
1309    PreservedKeptTransport,
1310    RetiredClosedTransport,
1311}
1312
1313#[cfg(not(target_arch = "wasm32"))]
1314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1315enum AutoConnectTieBreakDecision {
1316    ObserveConnectedTransport,
1317    WaitForInitiator,
1318    ActAsInitiator,
1319}
1320
1321#[cfg(not(target_arch = "wasm32"))]
1322fn duplicate_closed_handling(
1323    close_reason_debug: &str,
1324    kept_transport_alive: bool,
1325    kept_transport_healthy: bool,
1326    kept_stable_id: u64,
1327    closed_stable_id: u64,
1328) -> DuplicateClosedHandling {
1329    let _ = close_reason_debug;
1330    let _ = kept_transport_healthy;
1331    if kept_stable_id != closed_stable_id && kept_transport_alive {
1332        DuplicateClosedHandling::RebindKeptTransport
1333    } else {
1334        DuplicateClosedHandling::RetireClosedTransport
1335    }
1336}
1337
1338#[cfg(not(target_arch = "wasm32"))]
1339fn auto_connect_tie_break_decision(
1340    local_node_id: &str,
1341    remote_node_id: &str,
1342    transport_connected: bool,
1343    waited_ms: i64,
1344    initiator_grace_ms: i64,
1345) -> AutoConnectTieBreakDecision {
1346    if transport_connected {
1347        return AutoConnectTieBreakDecision::ObserveConnectedTransport;
1348    }
1349    if local_node_id <= remote_node_id {
1350        // Non-initiator waits for the grace period, then acts as initiator
1351        // to avoid getting stuck when the true initiator is unavailable.
1352        if waited_ms >= initiator_grace_ms {
1353            AutoConnectTieBreakDecision::ActAsInitiator
1354        } else {
1355            AutoConnectTieBreakDecision::WaitForInitiator
1356        }
1357    } else {
1358        AutoConnectTieBreakDecision::ActAsInitiator
1359    }
1360}
1361
1362fn normalize_lookup_id(value: Option<&str>) -> Option<String> {
1363    let trimmed = value?.trim();
1364    if trimmed.is_empty() {
1365        None
1366    } else {
1367        Some(trimmed.to_ascii_lowercase())
1368    }
1369}
1370
1371fn peer_snapshot_lookup_aliases(peer: &crate::connection_manager::PeerSnapshot) -> Vec<String> {
1372    [
1373        normalize_lookup_id(peer.device_id.as_deref()),
1374        normalize_lookup_id(peer.device_id_hint.as_deref()),
1375        normalize_lookup_id(peer.node_id.as_deref()),
1376        normalize_lookup_id(Some(peer.peer_id.as_str())),
1377    ]
1378    .into_iter()
1379    .flatten()
1380    .collect()
1381}
1382
1383fn peer_snapshot_matches_any_alias(
1384    peer: &crate::connection_manager::PeerSnapshot,
1385    aliases: &std::collections::HashSet<String>,
1386) -> bool {
1387    peer_snapshot_lookup_aliases(peer)
1388        .into_iter()
1389        .any(|alias| aliases.contains(&alias))
1390}
1391
1392fn preferred_peer_snapshot(
1393    current: Option<crate::connection_manager::PeerSnapshot>,
1394    candidate: crate::connection_manager::PeerSnapshot,
1395) -> crate::connection_manager::PeerSnapshot {
1396    match current {
1397        Some(existing) if !prefer_device_status_peer_snapshot(&candidate, &existing) => existing,
1398        _ => candidate,
1399    }
1400}
1401
1402fn snapshot_connection_status(
1403    device: &crate::signaling::Device,
1404    peer: Option<&crate::connection_manager::PeerSnapshot>,
1405) -> ConnectionStatus {
1406    match peer.map(|value| &value.status) {
1407        Some(crate::connection_manager::ConnectionState::Pending)
1408        | Some(crate::connection_manager::ConnectionState::Connecting) => {
1409            ConnectionStatus::Connecting
1410        }
1411        Some(crate::connection_manager::ConnectionState::Connected)
1412            if peer.map(peer_snapshot_settled_ready).unwrap_or(false) =>
1413        {
1414            ConnectionStatus::Connected
1415        }
1416        Some(crate::connection_manager::ConnectionState::Connected) => ConnectionStatus::Connecting,
1417        Some(crate::connection_manager::ConnectionState::Failed) => ConnectionStatus::Failed,
1418        Some(crate::connection_manager::ConnectionState::Closed)
1419        | Some(crate::connection_manager::ConnectionState::Closing) => ConnectionStatus::Closed,
1420        None if device.online => ConnectionStatus::Online,
1421        None => ConnectionStatus::Disconnected,
1422    }
1423}
1424
1425fn snapshot_settled_ready(
1426    peer: Option<&crate::connection_manager::PeerSnapshot>,
1427    connection_status: &ConnectionStatus,
1428) -> bool {
1429    matches!(connection_status, ConnectionStatus::Connected)
1430        && peer.map(peer_snapshot_settled_ready).unwrap_or(false)
1431}
1432
1433fn device_presence_updated_at(device: &crate::signaling::Device) -> Option<i64> {
1434    [
1435        device.last_seen_at.as_ref(),
1436        device.updated_at.as_ref(),
1437        device.created_at.as_ref(),
1438    ]
1439    .into_iter()
1440    .flatten()
1441    .filter_map(|value| crate::presence_policy::parse_millis(Some(value)))
1442    .max()
1443}
1444
1445fn device_presence_expires_at(device: &crate::signaling::Device) -> Option<i64> {
1446    crate::presence_policy::parse_millis(device.expires_at.as_ref())
1447}
1448
1449fn device_presence_status(
1450    device: &crate::signaling::Device,
1451    presence_updated_at: Option<i64>,
1452    now_ms: i64,
1453) -> DevicePresenceStatus {
1454    if !device.online {
1455        return DevicePresenceStatus::Offline;
1456    }
1457
1458    if presence_updated_at
1459        .map(|updated_at| {
1460            updated_at.saturating_add(crate::presence_policy::DEVICE_STALE_HEARTBEAT_MS) <= now_ms
1461        })
1462        .unwrap_or(false)
1463    {
1464        DevicePresenceStatus::Idle
1465    } else {
1466        DevicePresenceStatus::Online
1467    }
1468}
1469
1470fn device_connectable(
1471    device: &crate::signaling::Device,
1472    presence_status: &DevicePresenceStatus,
1473) -> bool {
1474    matches!(presence_status, DevicePresenceStatus::Online)
1475        && device
1476            .ticket
1477            .as_deref()
1478            .map(str::trim)
1479            .is_some_and(|value| !value.is_empty())
1480}
1481
1482fn peer_snapshot_settled_ready(peer: &crate::connection_manager::PeerSnapshot) -> bool {
1483    matches!(peer_readiness_state(peer), ReadinessState::Routable)
1484}
1485
1486fn peer_has_live_transport(peer: &crate::connection_manager::PeerSnapshot) -> bool {
1487    !peer.connection_ids.is_empty() && peer.active_transport_stable_id.is_some()
1488}
1489
1490fn peer_is_replacement_handoff(peer: &crate::connection_manager::PeerSnapshot) -> bool {
1491    peer.active_transport_stable_id.is_none()
1492        && peer.error.as_deref() == Some(crate::lifecycle_reason::REASON_REPLACEMENT_IN_PROGRESS)
1493}
1494
1495fn peer_readiness_state(peer: &crate::connection_manager::PeerSnapshot) -> ReadinessState {
1496    match peer.status {
1497        crate::connection_manager::ConnectionState::Pending
1498        | crate::connection_manager::ConnectionState::Connecting => ReadinessState::Connecting,
1499        crate::connection_manager::ConnectionState::Connected => {
1500            // A transport object or socket-ready carrier is not readiness
1501            // proof. Only a current-generation pong or generation-bound
1502            // application-route observation may set Healthy.
1503            let connection_ids_present = !peer.connection_ids.is_empty();
1504            let healthy_with_connections = connection_ids_present
1505                && matches!(
1506                    peer.health,
1507                    crate::connection_manager::ConnectionHealth::Healthy
1508                );
1509            if healthy_with_connections {
1510                ReadinessState::Routable
1511            } else if peer_is_replacement_handoff(peer) {
1512                ReadinessState::AwaitingReplacement
1513            } else if !peer_has_live_transport(peer) {
1514                ReadinessState::TransportOnly
1515            } else if now_millis_i64().saturating_sub(peer.last_lifecycle_transition_at_ms)
1516                >= MANAGED_SETTLE_DEADLINE_MS
1517            {
1518                ReadinessState::AwaitingReplacement
1519            } else {
1520                ReadinessState::Settling
1521            }
1522        }
1523        crate::connection_manager::ConnectionState::Closing
1524        | crate::connection_manager::ConnectionState::Closed => ReadinessState::Closed,
1525        crate::connection_manager::ConnectionState::Failed => ReadinessState::Failed,
1526    }
1527}
1528
1529fn peer_readiness_reason(peer: &crate::connection_manager::PeerSnapshot) -> String {
1530    match peer_readiness_state(peer) {
1531        ReadinessState::Connecting => "waiting-for-transport".to_string(),
1532        ReadinessState::TransportOnly => {
1533            "transport-attached-awaiting-readiness-confirmation".to_string()
1534        }
1535        ReadinessState::Settling => "transport-connected-health-check-pending".to_string(),
1536        ReadinessState::Routable => "transport-healthy".to_string(),
1537        ReadinessState::AwaitingReplacement => {
1538            if peer.active_transport_stable_id.is_none() {
1539                "missing-active-transport".to_string()
1540            } else {
1541                "readiness-confirmation-timed-out".to_string()
1542            }
1543        }
1544        ReadinessState::Closed => "transport-closed".to_string(),
1545        ReadinessState::Failed => "connection-failed".to_string(),
1546    }
1547}
1548
1549fn connection_readiness_state(
1550    record: &crate::connection_manager::ConnectionRecord,
1551    peer_snapshot: Option<&crate::connection_manager::PeerSnapshot>,
1552) -> ReadinessState {
1553    match record.state {
1554        crate::connection_manager::ConnectionState::Pending
1555        | crate::connection_manager::ConnectionState::Connecting => ReadinessState::Connecting,
1556        crate::connection_manager::ConnectionState::Connected => {
1557            if peer_snapshot
1558                .map(peer_snapshot_settled_ready)
1559                .unwrap_or(false)
1560            {
1561                ReadinessState::Routable
1562            } else if record.transport_stable_id.is_none() {
1563                ReadinessState::AwaitingReplacement
1564            } else if now_millis_i64().saturating_sub(record.last_transport_change_at_ms)
1565                >= MANAGED_SETTLE_DEADLINE_MS
1566            {
1567                ReadinessState::AwaitingReplacement
1568            } else {
1569                ReadinessState::Settling
1570            }
1571        }
1572        crate::connection_manager::ConnectionState::Closing
1573        | crate::connection_manager::ConnectionState::Closed => ReadinessState::Closed,
1574        crate::connection_manager::ConnectionState::Failed => ReadinessState::Failed,
1575    }
1576}
1577
1578fn connection_readiness_reason(
1579    record: &crate::connection_manager::ConnectionRecord,
1580    peer_snapshot: Option<&crate::connection_manager::PeerSnapshot>,
1581) -> String {
1582    match connection_readiness_state(record, peer_snapshot) {
1583        ReadinessState::Connecting => "waiting-for-transport".to_string(),
1584        ReadinessState::TransportOnly => {
1585            "transport-attached-awaiting-readiness-confirmation".to_string()
1586        }
1587        ReadinessState::Settling => "bounded-health-confirmation-pending".to_string(),
1588        ReadinessState::Routable => "transport-healthy".to_string(),
1589        ReadinessState::AwaitingReplacement => {
1590            if record.transport_stable_id.is_none() {
1591                "missing-active-transport".to_string()
1592            } else {
1593                "readiness-confirmation-timed-out".to_string()
1594            }
1595        }
1596        ReadinessState::Closed => "transport-closed".to_string(),
1597        ReadinessState::Failed => "connection-failed".to_string(),
1598    }
1599}
1600
1601fn public_peer_state(
1602    peer: &crate::connection_manager::PeerSnapshot,
1603) -> crate::connection_manager::ConnectionState {
1604    if matches!(
1605        peer.status,
1606        crate::connection_manager::ConnectionState::Connected
1607    ) && !peer_snapshot_settled_ready(peer)
1608    {
1609        crate::connection_manager::ConnectionState::Connecting
1610    } else {
1611        peer.status.clone()
1612    }
1613}
1614
1615fn peer_session_snapshot_from_peer(
1616    peer: crate::connection_manager::PeerSnapshot,
1617) -> PeerSessionSnapshot {
1618    let status = public_peer_state(&peer);
1619    let active_connection_id = peer.connection_ids.first().cloned();
1620    let candidate_connection_ids = if peer.connection_ids.len() > 1 {
1621        peer.connection_ids[1..].to_vec()
1622    } else {
1623        Vec::new()
1624    };
1625    let settled_ready = peer_snapshot_settled_ready(&peer);
1626    let readiness_state = peer_readiness_state(&peer);
1627    let readiness_reason = peer_readiness_reason(&peer);
1628    let crate::connection_manager::PeerSnapshot {
1629        peer_id,
1630        device_id,
1631        device_id_hint,
1632        node_id,
1633        active_transport_stable_id,
1634        active_transport_generation,
1635        active_route_generation,
1636        active_transport,
1637        parallel_transport,
1638        last_lifecycle_transition_at_ms,
1639        health,
1640        transition_count,
1641        connecting_transition_count,
1642        replacement_count,
1643        retire_count,
1644        last_disconnect_reason,
1645        last_reconnect_reason,
1646        logical_session_terminal,
1647        scopes,
1648        last_seen_at_ms,
1649        error,
1650        ..
1651    } = peer;
1652
1653    PeerSessionSnapshot {
1654        peer_id,
1655        device_id,
1656        device_id_hint,
1657        node_id,
1658        active_connection_id,
1659        candidate_connection_ids,
1660        status,
1661        health,
1662        settled_ready,
1663        logical_session_terminal,
1664        readiness_state: readiness_state.clone(),
1665        active_transport_stable_id,
1666        transport_generation: active_transport_generation,
1667        route_generation: active_route_generation,
1668        active_transport,
1669        parallel_transport,
1670        replacement_pending: matches!(readiness_state, ReadinessState::AwaitingReplacement),
1671        last_lifecycle_transition_at_ms,
1672        readiness_reason,
1673        transition_count,
1674        connecting_transition_count,
1675        replacement_count,
1676        retire_count,
1677        last_disconnect_reason,
1678        last_reconnect_reason,
1679        scopes,
1680        last_seen_at_ms,
1681        error,
1682    }
1683}
1684
1685fn connection_state_snapshot_from_parts(
1686    record: &crate::connection_manager::ConnectionRecord,
1687    peer_snapshot: Option<&crate::connection_manager::PeerSnapshot>,
1688) -> StateSnapshot {
1689    let settled_ready = peer_snapshot
1690        .map(peer_snapshot_settled_ready)
1691        .unwrap_or(false);
1692    let readiness_state = connection_readiness_state(record, peer_snapshot);
1693    // An in-place transport reconnect (the base transport closes with a
1694    // replacement/transient reason, then re-binds on the SAME connection — e.g.
1695    // physical reconnect, bumping transport_generation) is not a
1696    // disconnect. The record momentarily transitions Closing/Closed before the
1697    // successor transport lands; surfacing that intermediate frame as "closed" makes
1698    // every consumer flicker connected -> disconnected -> connected on each
1699    // generation bump. When the close reason is an in-place reconnect, report the
1700    // reconnecting family ("connecting") instead.
1701    //
1702    // Scoped to ReplacementInProgress + is_transient (same-connection
1703    // reconnects). It deliberately excludes is_replacement_churn (replaced-by-new-*),
1704    // which marks a DIFFERENT connection that was genuinely superseded — that record
1705    // is correctly closed, and the peer is live on the successor connection. A
1706    // genuine close (manual / graceful / failed / revoked) keeps a non-reconnect
1707    // reason and still surfaces as closed; each transition overwrites status_reason,
1708    // so a later terminal close clears this.
1709    let in_place_reconnect_close = matches!(
1710        record.state,
1711        crate::connection_manager::ConnectionState::Closing
1712            | crate::connection_manager::ConnectionState::Closed
1713    ) && crate::lifecycle_reason::LifecycleReasonCode::from_text(
1714        record.status_reason.as_deref(),
1715    )
1716    .map(|code| {
1717        matches!(
1718            code,
1719            crate::lifecycle_reason::LifecycleReasonCode::ReplacementInProgress
1720        ) || code.is_transient()
1721    })
1722    .unwrap_or(false);
1723    let state = match record.state {
1724        crate::connection_manager::ConnectionState::Pending => "connecting",
1725        crate::connection_manager::ConnectionState::Connecting => "connecting",
1726        crate::connection_manager::ConnectionState::Connected if settled_ready => "connected",
1727        crate::connection_manager::ConnectionState::Connected => "connecting",
1728        crate::connection_manager::ConnectionState::Failed => "failed",
1729        crate::connection_manager::ConnectionState::Closing
1730        | crate::connection_manager::ConnectionState::Closed
1731            if in_place_reconnect_close =>
1732        {
1733            "connecting"
1734        }
1735        crate::connection_manager::ConnectionState::Closing
1736        | crate::connection_manager::ConnectionState::Closed => "closed",
1737    };
1738    let transport_state = match record.state {
1739        crate::connection_manager::ConnectionState::Pending
1740        | crate::connection_manager::ConnectionState::Connecting => "connecting",
1741        crate::connection_manager::ConnectionState::Connected => "connected",
1742        crate::connection_manager::ConnectionState::Closing
1743        | crate::connection_manager::ConnectionState::Closed
1744            if in_place_reconnect_close =>
1745        {
1746            "connecting"
1747        }
1748        crate::connection_manager::ConnectionState::Failed
1749        | crate::connection_manager::ConnectionState::Closing
1750        | crate::connection_manager::ConnectionState::Closed => "closed",
1751    };
1752    let protocol_state = match record.state {
1753        crate::connection_manager::ConnectionState::Connected if settled_ready => "routable",
1754        crate::connection_manager::ConnectionState::Connected => "transport-only",
1755        crate::connection_manager::ConnectionState::Pending
1756        | crate::connection_manager::ConnectionState::Connecting => "connecting",
1757        crate::connection_manager::ConnectionState::Closing
1758        | crate::connection_manager::ConnectionState::Closed
1759            if in_place_reconnect_close =>
1760        {
1761            "connecting"
1762        }
1763        crate::connection_manager::ConnectionState::Failed
1764        | crate::connection_manager::ConnectionState::Closing
1765        | crate::connection_manager::ConnectionState::Closed => "closed",
1766    };
1767
1768    StateSnapshot {
1769        connection_id: record.connection_id.clone(),
1770        device_id: record
1771            .device_id
1772            .clone()
1773            .or_else(|| peer_snapshot.and_then(|snapshot| snapshot.device_id.clone())),
1774        device_id_hint: record
1775            .device_id_hint
1776            .clone()
1777            .or_else(|| peer_snapshot.and_then(|snapshot| snapshot.device_id_hint.clone())),
1778        remote_node_id: record.node_id.clone(),
1779        state: state.to_string(),
1780        transport_state: transport_state.to_string(),
1781        protocol_state: protocol_state.to_string(),
1782        routable: settled_ready,
1783        // A different physical record may already own the logical peer. A late
1784        // close of its predecessor is not a terminal verdict for that successor.
1785        logical_session_terminal: record.logical_session_terminal()
1786            && peer_snapshot
1787                .map(|peer| {
1788                    peer.logical_session_terminal
1789                        && peer.connection_ids.first() == Some(&record.connection_id)
1790                        && peer.active_transport_generation == record.transport_generation
1791                        && peer.active_route_generation == record.route_generation
1792                        && peer.active_transport_stable_id == record.transport_stable_id
1793                })
1794                .unwrap_or(false),
1795        readiness_state: readiness_state.clone(),
1796        readiness_reason: connection_readiness_reason(record, peer_snapshot),
1797        transport_generation: record.transport_generation,
1798        route_generation: record.route_generation,
1799        active_transport_stable_id: record.transport_stable_id,
1800        active_transport: record.active_transport.clone(),
1801        parallel_transport: record.parallel_transport.clone(),
1802        replacement_in_progress: matches!(readiness_state, ReadinessState::AwaitingReplacement),
1803        last_lifecycle_transition_at_ms: record
1804            .last_state_change_at_ms
1805            .max(record.last_transport_change_at_ms)
1806            .max(record.last_route_change_at_ms),
1807        transition_count: record.transition_count,
1808        connecting_transition_count: record.connecting_transition_count,
1809        replacement_count: record.replacement_count,
1810        retire_count: record.retire_count,
1811        last_disconnect_reason: record.last_disconnect_reason.clone(),
1812        last_reconnect_reason: record.last_reconnect_reason.clone(),
1813        scopes: peer_snapshot
1814            .map(|snapshot| snapshot.scopes.clone())
1815            .unwrap_or_default(),
1816        error: record.status_reason.clone(),
1817        created_at: record.created_at_ms,
1818        updated_at: record.updated_at_ms,
1819    }
1820}
1821
1822fn merge_device_status_snapshots(
1823    devices: Vec<crate::signaling::Device>,
1824    peers: Vec<crate::connection_manager::PeerSnapshot>,
1825) -> Vec<DeviceStatusSnapshot> {
1826    let mut peer_by_device: std::collections::HashMap<
1827        String,
1828        crate::connection_manager::PeerSnapshot,
1829    > = std::collections::HashMap::new();
1830
1831    for peer in peers {
1832        for alias in peer_snapshot_lookup_aliases(&peer) {
1833            let replace = match peer_by_device.get(&alias) {
1834                Some(existing) => prefer_device_status_peer_snapshot(&peer, existing),
1835                None => true,
1836            };
1837            if replace {
1838                peer_by_device.insert(alias, peer.clone());
1839            }
1840        }
1841    }
1842
1843    devices
1844        .into_iter()
1845        .map(|device| {
1846            let authoritative_node_id = normalize_lookup_id(device.node_id.as_deref());
1847            let lookup_aliases: Vec<String> = [
1848                normalize_lookup_id(Some(device.device_id.as_str())),
1849                authoritative_node_id.clone(),
1850            ]
1851            .into_iter()
1852            .flatten()
1853            .collect();
1854            let peer = lookup_aliases
1855                .iter()
1856                .filter_map(|key| peer_by_device.get(key).cloned())
1857                .filter(|candidate| {
1858                    let candidate_node_id = normalize_lookup_id(candidate.node_id.as_deref());
1859                    match (&authoritative_node_id, candidate_node_id) {
1860                        (Some(expected), Some(actual)) => expected == &actual,
1861                        _ => true,
1862                    }
1863                })
1864                .fold(None, |current, candidate| {
1865                    Some(preferred_peer_snapshot(current, candidate))
1866                });
1867            let connection_status = snapshot_connection_status(&device, peer.as_ref());
1868            let presence_updated_at = device_presence_updated_at(&device);
1869            let presence_expires_at = device_presence_expires_at(&device);
1870            let presence_status =
1871                device_presence_status(&device, presence_updated_at, now_millis_i64());
1872            let connectable = device_connectable(&device, &presence_status);
1873            let readiness_state = peer.as_ref().map(peer_readiness_state).unwrap_or_else(|| {
1874                if device.online {
1875                    ReadinessState::Connecting
1876                } else {
1877                    ReadinessState::Closed
1878                }
1879            });
1880            DeviceStatusSnapshot {
1881                presence_status,
1882                presence_updated_at,
1883                presence_expires_at,
1884                connectable,
1885                settled_ready: snapshot_settled_ready(peer.as_ref(), &connection_status),
1886                readiness_state: readiness_state.clone(),
1887                readiness_reason: peer.as_ref().map(peer_readiness_reason).unwrap_or_else(|| {
1888                    if device.online {
1889                        "device-online-awaiting-runtime-session".to_string()
1890                    } else {
1891                        "device-offline".to_string()
1892                    }
1893                }),
1894                connection_status,
1895                peer_health: peer
1896                    .as_ref()
1897                    .map(|value| value.health.clone())
1898                    .unwrap_or_else(|| {
1899                        if device.online {
1900                            crate::connection_manager::ConnectionHealth::Healthy
1901                        } else {
1902                            crate::connection_manager::ConnectionHealth::Unknown
1903                        }
1904                    }),
1905                peer_id: peer.as_ref().map(|value| value.peer_id.clone()),
1906                scopes: peer
1907                    .as_ref()
1908                    .map(|value| value.scopes.clone())
1909                    .unwrap_or_default(),
1910                connection_id: peer
1911                    .as_ref()
1912                    .and_then(|value| value.connection_ids.first().cloned()),
1913                device_id_hint: peer.as_ref().and_then(|value| value.device_id_hint.clone()),
1914                active_transport_stable_id: peer
1915                    .as_ref()
1916                    .and_then(|value| value.active_transport_stable_id),
1917                transport_generation: peer
1918                    .as_ref()
1919                    .map(|value| value.active_transport_generation)
1920                    .unwrap_or(0),
1921                route_generation: peer
1922                    .as_ref()
1923                    .map(|value| value.active_route_generation)
1924                    .unwrap_or(0),
1925                active_transport: peer
1926                    .as_ref()
1927                    .map(|value| value.active_transport.clone())
1928                    .unwrap_or_else(|| "iroh".to_string()),
1929                parallel_transport: peer
1930                    .as_ref()
1931                    .and_then(|value| value.parallel_transport.clone()),
1932                latency_ms: None,
1933                latency_by_transport: LatencySnapshot::default(),
1934                device,
1935            }
1936        })
1937        .collect()
1938}
1939
1940fn peer_snapshot_recency_ms(peer: &crate::connection_manager::PeerSnapshot) -> i64 {
1941    peer.last_seen_at_ms
1942        .max(peer.last_lifecycle_transition_at_ms)
1943}
1944
1945/// Picks the winning [`PeerSnapshot`] when the same device/node alias maps to more than one
1946/// aggregate (different `peer_id` keys / backfill + dial rows). `device_status_peer_priority`
1947/// ranks *readiness* (Routable) ahead of *status* (Failed), which lets a stale
1948/// "connected + routable" snapshot replace a newer failed dial; mirror `strongest_state` by
1949/// comparing `Connected` vs `Failed` with recency first.
1950fn prefer_device_status_peer_snapshot(
1951    candidate: &crate::connection_manager::PeerSnapshot,
1952    existing: &crate::connection_manager::PeerSnapshot,
1953) -> bool {
1954    use crate::connection_manager::ConnectionState;
1955    let c = &candidate.status;
1956    let e = &existing.status;
1957    let c_fail = matches!(c, ConnectionState::Failed);
1958    let c_conn = matches!(c, ConnectionState::Connected);
1959    let e_fail = matches!(e, ConnectionState::Failed);
1960    let e_conn = matches!(e, ConnectionState::Connected);
1961
1962    // Data-plane truth: a Connected snapshot with a live physical transport
1963    // reflects an actual working data path and beats a Failed snapshot
1964    // regardless of recency. Recency only matters when
1965    // neither side has a live transport, in which case we fall back to the
1966    // previous tie-break.
1967    let c_live = c_conn && peer_has_live_transport(candidate);
1968    let e_live = e_conn && peer_has_live_transport(existing);
1969    if c_live && !e_live {
1970        return true;
1971    }
1972    if !c_live && e_live {
1973        return false;
1974    }
1975
1976    if c_fail && e_conn {
1977        return peer_snapshot_recency_ms(candidate) >= peer_snapshot_recency_ms(existing);
1978    }
1979    if c_conn && e_fail {
1980        return peer_snapshot_recency_ms(candidate) > peer_snapshot_recency_ms(existing);
1981    }
1982    device_status_peer_priority(candidate) > device_status_peer_priority(existing)
1983}
1984
1985fn device_status_peer_priority(
1986    peer: &crate::connection_manager::PeerSnapshot,
1987) -> (u8, u8, u8, u8, i64, u64, u64) {
1988    let readiness_rank = match peer_readiness_state(peer) {
1989        ReadinessState::Routable => 5,
1990        ReadinessState::Settling => 4,
1991        ReadinessState::TransportOnly => 3,
1992        ReadinessState::Connecting => 2,
1993        ReadinessState::AwaitingReplacement => 1,
1994        ReadinessState::Closed | ReadinessState::Failed => 0,
1995    };
1996
1997    let status_rank = match peer.status {
1998        crate::connection_manager::ConnectionState::Connected => 3,
1999        crate::connection_manager::ConnectionState::Connecting
2000        | crate::connection_manager::ConnectionState::Pending => 2,
2001        crate::connection_manager::ConnectionState::Closing
2002        | crate::connection_manager::ConnectionState::Closed => 1,
2003        crate::connection_manager::ConnectionState::Failed => 0,
2004    };
2005
2006    let health_rank = match peer.health {
2007        crate::connection_manager::ConnectionHealth::Healthy => 3,
2008        crate::connection_manager::ConnectionHealth::Suspect => 2,
2009        crate::connection_manager::ConnectionHealth::Unknown => 1,
2010        crate::connection_manager::ConnectionHealth::Stale => 0,
2011    };
2012
2013    (
2014        readiness_rank,
2015        status_rank,
2016        health_rank,
2017        u8::from(peer_has_live_transport(peer)),
2018        peer.last_seen_at_ms,
2019        peer.transition_count,
2020        peer.replacement_count,
2021    )
2022}
2023
2024#[cfg(not(target_arch = "wasm32"))]
2025fn auto_connect_verbose() -> bool {
2026    std::env::var("OPENRTC_AUTOCONNECT_VERBOSE")
2027        .map(|value| {
2028            let normalized = value.trim().to_ascii_lowercase();
2029            normalized == "1" || normalized == "true" || normalized == "yes"
2030        })
2031        .unwrap_or(false)
2032}
2033
2034#[cfg(not(target_arch = "wasm32"))]
2035fn parse_env_bool(value: &str) -> Option<bool> {
2036    match value.trim().to_ascii_lowercase().as_str() {
2037        "1" | "true" | "yes" | "on" => Some(true),
2038        "0" | "false" | "no" | "off" => Some(false),
2039        _ => None,
2040    }
2041}
2042
2043#[cfg(not(target_arch = "wasm32"))]
2044fn env_bool(name: &str) -> Option<bool> {
2045    std::env::var(name)
2046        .ok()
2047        .and_then(|value| parse_env_bool(&value))
2048}
2049
2050#[cfg(not(target_arch = "wasm32"))]
2051fn should_use_relay_only_mode() -> bool {
2052    env_bool("PLUTO_IROH_RELAY_ONLY").unwrap_or(false)
2053}
2054
2055#[cfg(not(target_arch = "wasm32"))]
2056fn should_disable_ipv6() -> bool {
2057    resolve_disable_ipv6(
2058        env_bool("PLUTO_IROH_DISABLE_IPV6"),
2059        env_bool("PLUTO_IROH_ENABLE_IPV6"),
2060    )
2061}
2062
2063#[cfg(not(target_arch = "wasm32"))]
2064fn resolve_disable_ipv6(disable: Option<bool>, enable: Option<bool>) -> bool {
2065    disable
2066        .or_else(|| enable.map(|value| !value))
2067        .unwrap_or(false)
2068}
2069
2070#[cfg(not(target_arch = "wasm32"))]
2071fn apply_native_network_preferences(
2072    mut builder: iroh::endpoint::Builder,
2073    relay_only: bool,
2074    relay_transport_policy: RelayPolicy,
2075) -> anyhow::Result<iroh::endpoint::Builder> {
2076    #[cfg(openrtc_iroh_relay_transport_policy_api)]
2077    {
2078        let policy = match relay_transport_policy {
2079            RelayPolicy::Auto => iroh::RelayTransportPolicy::Auto,
2080            RelayPolicy::QuicRequired => iroh::RelayTransportPolicy::QuicRequired,
2081            RelayPolicy::WebsocketRequired => iroh::RelayTransportPolicy::WebsocketRequired,
2082        };
2083        builder = builder.relay_transport_policy(policy);
2084    }
2085    #[cfg(not(openrtc_iroh_relay_transport_policy_api))]
2086    {
2087        match relay_transport_policy {
2088            RelayPolicy::QuicRequired => {
2089                anyhow::bail!(
2090                    "requested relay carriage quicRequired is unsupported by this upstream-Iroh build; refusing to initialize"
2091                );
2092            }
2093            RelayPolicy::Auto => {
2094                eprintln!(
2095                    "[pluto-rtc][native] requested relay carriage auto; effective carriage is websocket fallback because this upstream-Iroh build has no QUIC relay policy API"
2096                );
2097            }
2098            RelayPolicy::WebsocketRequired => {}
2099        }
2100    }
2101
2102    // Increase QUIC idle timeout to survive iOS background suspension.
2103    // The default is 6.5s (5s heartbeat + 1.5s grace), which is too aggressive:
2104    // when iOS suspends the process, PING frames stop and the connection is torn
2105    // down before the OS resumes us. 120s allows the app to be backgrounded,
2106    // complete its background task window (~30s), and still reconnect gracefully
2107    // without the connection being declared dead mid-transfer.
2108    // Keep-alive interval defaults to 25s on desktop. On mobile targets use a
2109    // slower cadence to reduce background wake pressure.
2110    //
2111    // Flow-control window tuning for relay throughput:
2112    // When using the iroh relay (WebSocket-based for WASM↔native), the round-trip
2113    // time is ~100–300 ms. Quinn's defaults (stream_receive_window=256KB,
2114    // receive_window=1MB, send_window=2MB) cap throughput at roughly 1–10 MB/s on
2115    // these high-latency paths because the sender stalls waiting for WINDOW_UPDATE
2116    // frames. Setting windows large enough to cover the bandwidth-delay product
2117    // (target 40 MB/s × 300 ms RTT = 12 MB) avoids flow-control stalls entirely.
2118    builder = builder.transport_config(crate::native_node::iroh_config());
2119
2120    if relay_only {
2121        // Relay-only mode avoids NAT traversal + local candidate churn on constrained networks.
2122        return Ok(builder.clear_ip_transports());
2123    }
2124
2125    if should_disable_ipv6() {
2126        // IPv4-only is an explicit compatibility override. Native endpoints are
2127        // dual-stack by default so iOS remains routable on IPv6-only/NAT64 networks.
2128        builder = builder.clear_ip_transports();
2129        builder = builder
2130            .bind_addr("0.0.0.0:0")
2131            .map_err(|e| anyhow::anyhow!("failed to bind IPv4 transport: {}", e))?;
2132    }
2133
2134    Ok(builder)
2135}
2136
2137#[cfg(not(target_arch = "wasm32"))]
2138fn auto_connect_failure_backoff_ms(failure_count: u8) -> i64 {
2139    match failure_count {
2140        0 => 0,
2141        _ => {
2142            // Foreground transport failures should recover quickly. Slow or
2143            // offline peers are still bounded, but one startup race must not
2144            // turn an already-discovered peer into a minute-long wait.
2145            let exp = 500_i64.saturating_mul(1_i64 << (failure_count as u32).min(4));
2146            exp.min(8_000)
2147        }
2148    }
2149}
2150
2151#[cfg(not(target_arch = "wasm32"))]
2152fn auto_connect_admission_rejection_backoff_ms(failure_count: u8) -> i64 {
2153    match failure_count {
2154        0 => 0,
2155        _ => {
2156            // Invalid or unauthorized credentials are not a reachability
2157            // failure. Keep the conservative schedule until coordination
2158            // publishes materially new ticket data.
2159            let exp = 1_000_i64.saturating_mul(1_i64 << (failure_count as u32).min(5));
2160            exp.min(30_000)
2161        }
2162    }
2163}
2164
2165#[cfg(not(target_arch = "wasm32"))]
2166fn auto_connect_network_change_failure_threshold() -> u8 {
2167    std::env::var("OPENRTC_NETWORK_CHANGE_FAILURE_THRESHOLD")
2168        .ok()
2169        .and_then(|value| value.parse::<u8>().ok())
2170        .map(|value| value.clamp(1, 6))
2171        .unwrap_or(2)
2172}
2173
2174/// Grace period for the non-initiator before it escalates to dial.
2175/// Uses exponential backoff with 20 % jitter to avoid duplicate-storm collisions.
2176/// Progression: 2 s → 4 s → 8 s → 16 s (cap) with ±20 % jitter.
2177#[cfg(not(target_arch = "wasm32"))]
2178fn non_initiator_escalation_grace_ms(escalation_count: u8) -> i64 {
2179    let base_ms = 2_000_i64;
2180    let exp_ms = base_ms.saturating_mul(1_i64 << (escalation_count as u32).min(3));
2181    let capped_ms = exp_ms.min(16_000);
2182    // Add ±20 % jitter using the escalation count as a cheap deterministic seed.
2183    // A real random source would be nicer but we avoid pulling in a dependency.
2184    let jitter_pct = ((escalation_count as i64 * 37 + 7) % 41) - 20; // -20..+20
2185    let jitter_ms = capped_ms * jitter_pct / 100;
2186    (capped_ms + jitter_ms).max(1_000)
2187}
2188
2189#[cfg(not(target_arch = "wasm32"))]
2190fn next_non_initiator_wakeup_delay_ms(
2191    wait_started_at: &std::collections::HashMap<String, i64>,
2192    escalation_count: &std::collections::HashMap<String, u8>,
2193    now_ms: i64,
2194) -> Option<u64> {
2195    wait_started_at
2196        .iter()
2197        .map(|(device_id, started_at)| {
2198            let grace_ms = non_initiator_escalation_grace_ms(
2199                escalation_count.get(device_id).copied().unwrap_or(0),
2200            );
2201            started_at
2202                .saturating_add(grace_ms)
2203                .saturating_sub(now_ms)
2204                .max(1) as u64
2205        })
2206        .min()
2207}
2208
2209#[cfg(not(target_arch = "wasm32"))]
2210fn register_auto_connect_failure(
2211    remote_device_id: &str,
2212    now_ms: i64,
2213    failure_count: &mut std::collections::HashMap<String, u8>,
2214    failure_backoff_until: &mut std::collections::HashMap<String, i64>,
2215) {
2216    let count = failure_count
2217        .entry(remote_device_id.to_string())
2218        .or_insert(0);
2219    *count = count.saturating_add(1).min(10);
2220    let backoff_ms = auto_connect_failure_backoff_ms(*count);
2221    failure_backoff_until.insert(
2222        remote_device_id.to_string(),
2223        now_ms.saturating_add(backoff_ms),
2224    );
2225}
2226
2227#[cfg(not(target_arch = "wasm32"))]
2228fn register_auto_connect_admission_rejection(
2229    remote_device_id: &str,
2230    now_ms: i64,
2231    failure_count: &mut std::collections::HashMap<String, u8>,
2232    failure_backoff_until: &mut std::collections::HashMap<String, i64>,
2233) {
2234    let count = failure_count
2235        .entry(remote_device_id.to_string())
2236        .or_insert(0);
2237    *count = count.saturating_add(1).min(10);
2238    let backoff_ms = auto_connect_admission_rejection_backoff_ms(*count);
2239    failure_backoff_until.insert(
2240        remote_device_id.to_string(),
2241        now_ms.saturating_add(backoff_ms),
2242    );
2243}
2244
2245#[cfg(not(target_arch = "wasm32"))]
2246fn clear_auto_connect_failure_state(
2247    remote_device_id: &str,
2248    failure_count: &mut std::collections::HashMap<String, u8>,
2249    failure_backoff_until: &mut std::collections::HashMap<String, i64>,
2250) {
2251    failure_count.remove(remote_device_id);
2252    failure_backoff_until.remove(remote_device_id);
2253}
2254
2255#[cfg(target_arch = "wasm32")]
2256fn emit_wasm_connection_state_event(snapshot: &StateSnapshot) {
2257    use wasm_bindgen::JsValue;
2258
2259    let Some(window) = web_sys::window() else {
2260        return;
2261    };
2262
2263    let detail = js_sys::Object::new();
2264    let _ = js_sys::Reflect::set(
2265        &detail,
2266        &JsValue::from_str("connectionId"),
2267        &JsValue::from_str(snapshot.connection_id.as_str()),
2268    );
2269    let _ = js_sys::Reflect::set(
2270        &detail,
2271        &JsValue::from_str("state"),
2272        &JsValue::from_str(snapshot.state.as_str()),
2273    );
2274    let _ = js_sys::Reflect::set(
2275        &detail,
2276        &JsValue::from_str("createdAt"),
2277        &JsValue::from_f64(snapshot.created_at as f64),
2278    );
2279    let _ = js_sys::Reflect::set(
2280        &detail,
2281        &JsValue::from_str("updatedAt"),
2282        &JsValue::from_f64(snapshot.updated_at as f64),
2283    );
2284    let _ = js_sys::Reflect::set(
2285        &detail,
2286        &JsValue::from_str("transportState"),
2287        &JsValue::from_str(snapshot.transport_state.as_str()),
2288    );
2289    let _ = js_sys::Reflect::set(
2290        &detail,
2291        &JsValue::from_str("protocolState"),
2292        &JsValue::from_str(snapshot.protocol_state.as_str()),
2293    );
2294    let _ = js_sys::Reflect::set(
2295        &detail,
2296        &JsValue::from_str("routable"),
2297        &JsValue::from_bool(snapshot.routable),
2298    );
2299    let _ = js_sys::Reflect::set(
2300        &detail,
2301        &JsValue::from_str("logicalSessionTerminal"),
2302        &JsValue::from_bool(snapshot.logical_session_terminal),
2303    );
2304    let _ = js_sys::Reflect::set(
2305        &detail,
2306        &JsValue::from_str("transportGeneration"),
2307        &JsValue::from_f64(snapshot.transport_generation as f64),
2308    );
2309    let _ = js_sys::Reflect::set(
2310        &detail,
2311        &JsValue::from_str("routeGeneration"),
2312        &JsValue::from_f64(snapshot.route_generation as f64),
2313    );
2314    let _ = js_sys::Reflect::set(
2315        &detail,
2316        &JsValue::from_str("activeTransportStableId"),
2317        &snapshot
2318            .active_transport_stable_id
2319            .map(|value| JsValue::from_f64(value as f64))
2320            .unwrap_or(JsValue::NULL),
2321    );
2322    let _ = js_sys::Reflect::set(
2323        &detail,
2324        &JsValue::from_str("activeTransport"),
2325        &JsValue::from_str(snapshot.active_transport.as_str()),
2326    );
2327    let _ = js_sys::Reflect::set(
2328        &detail,
2329        &JsValue::from_str("parallelTransport"),
2330        &snapshot
2331            .parallel_transport
2332            .as_deref()
2333            .map(JsValue::from_str)
2334            .unwrap_or(JsValue::NULL),
2335    );
2336    let _ = js_sys::Reflect::set(
2337        &detail,
2338        &JsValue::from_str("replacementInProgress"),
2339        &JsValue::from_bool(snapshot.replacement_in_progress),
2340    );
2341    let _ = js_sys::Reflect::set(
2342        &detail,
2343        &JsValue::from_str("transitionCount"),
2344        &JsValue::from_f64(snapshot.transition_count as f64),
2345    );
2346    let _ = js_sys::Reflect::set(
2347        &detail,
2348        &JsValue::from_str("connectingTransitionCount"),
2349        &JsValue::from_f64(snapshot.connecting_transition_count as f64),
2350    );
2351    let _ = js_sys::Reflect::set(
2352        &detail,
2353        &JsValue::from_str("replacementCount"),
2354        &JsValue::from_f64(snapshot.replacement_count as f64),
2355    );
2356    let _ = js_sys::Reflect::set(
2357        &detail,
2358        &JsValue::from_str("retireCount"),
2359        &JsValue::from_f64(snapshot.retire_count as f64),
2360    );
2361
2362    if let Some(device_id) = snapshot.device_id.as_deref() {
2363        let _ = js_sys::Reflect::set(
2364            &detail,
2365            &JsValue::from_str("deviceId"),
2366            &JsValue::from_str(device_id),
2367        );
2368    }
2369    if let Some(device_id_hint) = snapshot.device_id_hint.as_deref() {
2370        let _ = js_sys::Reflect::set(
2371            &detail,
2372            &JsValue::from_str("deviceIdHint"),
2373            &JsValue::from_str(device_id_hint),
2374        );
2375    }
2376    if let Some(remote_node_id) = snapshot.remote_node_id.as_deref() {
2377        let _ = js_sys::Reflect::set(
2378            &detail,
2379            &JsValue::from_str("remoteNodeId"),
2380            &JsValue::from_str(remote_node_id),
2381        );
2382    }
2383    if let Some(error) = snapshot.error.as_deref() {
2384        let _ = js_sys::Reflect::set(
2385            &detail,
2386            &JsValue::from_str("error"),
2387            &JsValue::from_str(error),
2388        );
2389    }
2390    if let Some(reason) = snapshot.last_disconnect_reason.as_deref() {
2391        let _ = js_sys::Reflect::set(
2392            &detail,
2393            &JsValue::from_str("lastDisconnectReason"),
2394            &JsValue::from_str(reason),
2395        );
2396    }
2397    if let Some(reason) = snapshot.last_reconnect_reason.as_deref() {
2398        let _ = js_sys::Reflect::set(
2399            &detail,
2400            &JsValue::from_str("lastReconnectReason"),
2401            &JsValue::from_str(reason),
2402        );
2403    }
2404
2405    let init = web_sys::CustomEventInit::new();
2406    init.set_detail(&detail.into());
2407    if let Ok(event) =
2408        web_sys::CustomEvent::new_with_event_init_dict("connection-state-changed", &init)
2409    {
2410        let _ = window.dispatch_event(&event);
2411    }
2412}
2413
2414fn parse_endpoint_ticket(ticket: &str) -> anyhow::Result<iroh::EndpointAddr> {
2415    let trimmed = ticket.trim();
2416    if trimmed.is_empty() {
2417        return Err(anyhow::anyhow!("endpoint ticket is required"));
2418    }
2419
2420    let parsed = EndpointTicket::from_str(trimmed)
2421        .map_err(|e| anyhow::anyhow!("invalid endpoint ticket: {}", e))?;
2422    Ok(parsed.endpoint_addr().clone())
2423}
2424
2425#[derive(Debug, Clone, serde::Serialize)]
2426#[serde(rename_all = "camelCase")]
2427pub struct RuntimeStatus {
2428    pub ready: bool,
2429    pub node_id: Option<String>,
2430    pub transport: TransportStatus,
2431}
2432
2433#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
2434#[serde(rename_all = "kebab-case")]
2435pub enum CapabilityMaturity {
2436    Stable,
2437    Preview,
2438    SupportOnly,
2439    Unavailable,
2440}
2441
2442#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
2443#[serde(rename_all = "camelCase")]
2444pub struct ProductCapabilityMaturity {
2445    pub bounded_rooms: CapabilityMaturity,
2446    pub service_nodes: CapabilityMaturity,
2447    pub offline_edge: CapabilityMaturity,
2448    pub broadcast: CapabilityMaturity,
2449}
2450
2451impl ProductCapabilityMaturity {
2452    pub(crate) fn for_current_target() -> Self {
2453        Self {
2454            bounded_rooms: CapabilityMaturity::Stable,
2455            service_nodes: CapabilityMaturity::Preview,
2456            offline_edge: if cfg!(all(not(target_arch = "wasm32"), feature = "transport-lan")) {
2457                CapabilityMaturity::Preview
2458            } else {
2459                CapabilityMaturity::SupportOnly
2460            },
2461            broadcast: CapabilityMaturity::Preview,
2462        }
2463    }
2464}
2465
2466#[derive(Debug, Clone, serde::Serialize)]
2467#[serde(rename_all = "camelCase")]
2468pub struct FeatureStatus {
2469    pub compiled: bool,
2470    pub enabled: bool,
2471}
2472
2473impl FeatureStatus {
2474    pub(crate) fn new(compiled: bool, enabled: bool) -> Self {
2475        Self {
2476            compiled,
2477            enabled: compiled && enabled,
2478        }
2479    }
2480}
2481
2482#[derive(Debug, Clone, serde::Serialize)]
2483#[serde(rename_all = "camelCase")]
2484pub struct TransportStatus {
2485    pub iroh_quic: FeatureStatus,
2486    pub iroh_lan: FeatureStatus,
2487    pub web_rtc: FeatureStatus,
2488    pub moq: FeatureStatus,
2489    pub ble: FeatureStatus,
2490    pub iroh_relay_only: bool,
2491    #[serde(skip_serializing_if = "Option::is_none")]
2492    pub iroh_relay_transport_policy: Option<RelayPolicy>,
2493    pub iroh_relay: RelayStatus,
2494    pub route_priority: Vec<crate::route_policy::KnownRoute>,
2495    pub optimize_for: crate::route_policy::RouteOptimization,
2496    pub network_policy: NetworkPolicy,
2497}
2498
2499#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
2500#[serde(rename_all = "camelCase")]
2501pub enum IrohRelayProvider {
2502    /// The vendored OpenRTC Iroh fork exposes QUIC relay carriage selection.
2503    VendoredIroh,
2504    /// An upstream Iroh build exposes the WebSocket relay carriage only.
2505    UpstreamIroh,
2506}
2507
2508#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
2509#[serde(rename_all = "camelCase")]
2510pub enum RelayCarriage {
2511    Quic,
2512    Websocket,
2513    QuicWithWebsocketFallback,
2514    WebsocketFallback,
2515    Unsupported,
2516}
2517
2518#[derive(Debug, Clone, serde::Serialize)]
2519#[serde(rename_all = "camelCase")]
2520pub struct RelayStatus {
2521    pub provider: IrohRelayProvider,
2522    pub requested_carriage: RelayPolicy,
2523    pub effective_carriage: RelayCarriage,
2524}
2525
2526impl RelayStatus {
2527    pub(crate) fn from_policy(
2528        requested_carriage: RelayPolicy,
2529        provider: IrohRelayProvider,
2530    ) -> Self {
2531        let effective_carriage = match requested_carriage {
2532            RelayPolicy::WebsocketRequired => RelayCarriage::Websocket,
2533            RelayPolicy::Auto => {
2534                #[cfg(target_arch = "wasm32")]
2535                {
2536                    RelayCarriage::Websocket
2537                }
2538                #[cfg(all(not(target_arch = "wasm32"), openrtc_iroh_relay_transport_policy_api))]
2539                {
2540                    RelayCarriage::QuicWithWebsocketFallback
2541                }
2542                #[cfg(all(
2543                    not(target_arch = "wasm32"),
2544                    not(openrtc_iroh_relay_transport_policy_api)
2545                ))]
2546                {
2547                    RelayCarriage::WebsocketFallback
2548                }
2549            }
2550            RelayPolicy::QuicRequired => {
2551                #[cfg(target_arch = "wasm32")]
2552                {
2553                    RelayCarriage::Unsupported
2554                }
2555                #[cfg(all(not(target_arch = "wasm32"), openrtc_iroh_relay_transport_policy_api))]
2556                {
2557                    RelayCarriage::Quic
2558                }
2559                #[cfg(all(
2560                    not(target_arch = "wasm32"),
2561                    not(openrtc_iroh_relay_transport_policy_api)
2562                ))]
2563                {
2564                    RelayCarriage::Unsupported
2565                }
2566            }
2567        };
2568
2569        Self {
2570            provider,
2571            requested_carriage,
2572            effective_carriage,
2573        }
2574    }
2575}
2576
2577#[cfg(openrtc_iroh_relay_transport_policy_api)]
2578pub(crate) const fn current_iroh_relay_provider() -> IrohRelayProvider {
2579    IrohRelayProvider::VendoredIroh
2580}
2581
2582#[cfg(not(openrtc_iroh_relay_transport_policy_api))]
2583pub(crate) const fn current_iroh_relay_provider() -> IrohRelayProvider {
2584    IrohRelayProvider::UpstreamIroh
2585}
2586
2587impl TransportStatus {
2588    pub(crate) fn from_config_with_ble_available(
2589        config: &TransportConfig,
2590        ble_available: bool,
2591        relay_provider: IrohRelayProvider,
2592    ) -> Self {
2593        let lan_compiled = cfg!(feature = "transport-lan");
2594        let lan_enabled = config
2595            .iroh_lan
2596            .as_ref()
2597            .map(|lan| lan.enabled)
2598            .unwrap_or(false);
2599        let webrtc_compiled = cfg!(feature = "transport-webrtc");
2600        let webrtc_enabled = config.webrtc.is_some();
2601        let moq_compiled = cfg!(feature = "transport-moq");
2602        let moq_enabled = config.moq.is_some();
2603        let ble_compiled = ble_available;
2604        let ble_enabled = config.ble.as_ref().map(|ble| ble.enabled).unwrap_or(false);
2605
2606        Self {
2607            iroh_quic: FeatureStatus::new(true, true),
2608            iroh_lan: FeatureStatus::new(lan_compiled, lan_enabled),
2609            web_rtc: FeatureStatus::new(webrtc_compiled, webrtc_enabled),
2610            moq: FeatureStatus::new(moq_compiled, moq_enabled),
2611            ble: FeatureStatus::new(ble_compiled, ble_enabled),
2612            iroh_relay_only: config.iroh_relay_only,
2613            iroh_relay_transport_policy: config.iroh_relay_transport_policy.clone(),
2614            iroh_relay: RelayStatus::from_policy(
2615                config
2616                    .iroh_relay_transport_policy
2617                    .clone()
2618                    .unwrap_or(RelayPolicy::WebsocketRequired),
2619                relay_provider,
2620            ),
2621            route_priority: config.route_priority.clone(),
2622            optimize_for: config.optimize_for,
2623            network_policy: config.network_policy,
2624        }
2625    }
2626}
2627
2628#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
2629#[serde(rename_all = "camelCase")]
2630pub struct IceServerConfig {
2631    #[serde(default)]
2632    pub urls: Vec<String>,
2633    #[serde(default, skip_serializing_if = "Option::is_none")]
2634    pub username: Option<String>,
2635    #[serde(default, skip_serializing_if = "Option::is_none")]
2636    pub credential: Option<String>,
2637}
2638
2639#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
2640#[serde(rename_all = "camelCase")]
2641pub struct WebRTCConfig {
2642    #[serde(default)]
2643    pub ice_servers: Vec<IceServerConfig>,
2644    #[serde(default)]
2645    pub privacy_mode: bool,
2646    #[serde(default)]
2647    pub lan_mode: bool,
2648}
2649
2650impl Default for WebRTCConfig {
2651    fn default() -> Self {
2652        Self {
2653            ice_servers: Vec::new(),
2654            privacy_mode: false,
2655            lan_mode: false,
2656        }
2657    }
2658}
2659
2660#[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
2661#[serde(rename_all = "camelCase")]
2662pub struct MoQConfig {
2663    #[serde(default)]
2664    pub relay_url: String,
2665    #[serde(default, skip_serializing)]
2666    pub access_token: Option<String>,
2667}
2668
2669impl std::fmt::Debug for MoQConfig {
2670    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2671        let relay_label = self
2672            .relay_url
2673            .split(['?', '#'])
2674            .next()
2675            .unwrap_or("invalid-moq-relay-url");
2676        formatter
2677            .debug_struct("MoQConfig")
2678            .field("relay_url", &relay_label)
2679            .field(
2680                "access_token",
2681                &self.access_token.as_ref().map(|_| "[redacted]"),
2682            )
2683            .finish()
2684    }
2685}
2686
2687impl Default for MoQConfig {
2688    fn default() -> Self {
2689        Self {
2690            relay_url: String::new(),
2691            access_token: None,
2692        }
2693    }
2694}
2695
2696#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
2697#[serde(rename_all = "camelCase")]
2698pub enum RelayPolicy {
2699    Auto,
2700    QuicRequired,
2701    WebsocketRequired,
2702}
2703
2704/// Endpoint-wide network boundary.
2705///
2706/// `LocalOnly` is a hard construction-time policy: the endpoint omits public
2707/// DNS discovery and relays, and only locally scoped IP candidates (plus an
2708/// explicitly installed native BLE mechanism) are eligible. It is not a
2709/// best-effort route preference.
2710#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
2711#[serde(rename_all = "kebab-case")]
2712pub enum NetworkPolicy {
2713    #[default]
2714    Managed,
2715    LocalOnly,
2716}
2717
2718#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
2719#[serde(rename_all = "camelCase")]
2720pub struct TransportConfig {
2721    #[serde(default)]
2722    pub network_policy: NetworkPolicy,
2723    /// Allow relay mechanisms selected by their independently configured
2724    /// runtime capabilities. Defaults to true.
2725    #[serde(default = "default_true")]
2726    pub relay: bool,
2727    /// Hard peer-address privacy policy across every configured route.
2728    #[serde(default)]
2729    pub privacy_mode: bool,
2730    /// Fine-grained Iroh endpoint policy. Unlike `privacy_mode`, this does not
2731    /// constrain WebRTC, BLE, MoQ, or exact route priority.
2732    #[serde(default)]
2733    pub iroh_relay_only: bool,
2734    #[serde(default, skip_serializing_if = "Option::is_none")]
2735    pub iroh_relay_transport_policy: Option<RelayPolicy>,
2736    #[serde(default, skip_serializing_if = "Option::is_none")]
2737    pub iroh_lan: Option<IrohLanConfig>,
2738    #[serde(default, skip_serializing_if = "Option::is_none")]
2739    pub webrtc: Option<WebRTCConfig>,
2740    #[serde(default, skip_serializing_if = "Option::is_none")]
2741    pub moq: Option<MoQConfig>,
2742    #[serde(default, skip_serializing_if = "Option::is_none")]
2743    pub ble: Option<BleConfig>,
2744    /// Exact-route preference used by the Rust peer-session selector. Routes
2745    /// unavailable on the current runtime or peer are skipped.
2746    #[serde(default = "default_route_priority")]
2747    pub route_priority: Vec<crate::route_policy::KnownRoute>,
2748    #[serde(default)]
2749    pub optimize_for: crate::route_policy::RouteOptimization,
2750}
2751
2752fn default_route_priority() -> Vec<crate::route_policy::KnownRoute> {
2753    crate::route_policy::DEFAULT_ROUTE_PRIORITY.to_vec()
2754}
2755
2756fn default_true() -> bool {
2757    true
2758}
2759
2760impl Default for TransportConfig {
2761    fn default() -> Self {
2762        Self {
2763            network_policy: NetworkPolicy::Managed,
2764            relay: true,
2765            privacy_mode: false,
2766            iroh_relay_only: false,
2767            iroh_relay_transport_policy: None,
2768            iroh_lan: Some(IrohLanConfig::default()),
2769            webrtc: None,
2770            moq: None,
2771            ble: None,
2772            route_priority: default_route_priority(),
2773            optimize_for: crate::route_policy::RouteOptimization::Balanced,
2774        }
2775    }
2776}
2777
2778impl TransportConfig {
2779    /// Rank mutually supported packet carriers under the global privacy
2780    /// policy. `iroh_relay_only` constrains only Iroh path selection and must
2781    /// not make an otherwise eligible direct WebRTC carrier disappear.
2782    #[cfg(any(test, feature = "transport-webrtc", feature = "transport-moq"))]
2783    pub(crate) fn ranked_iroh_carriers(
2784        &self,
2785        supports_webrtc: bool,
2786        supports_moq: bool,
2787    ) -> Vec<crate::route_policy::KnownRoute> {
2788        crate::route_policy::rank_iroh_carriers(
2789            &self.route_priority,
2790            supports_webrtc,
2791            supports_moq,
2792            self.privacy_mode,
2793            self.webrtc
2794                .as_ref()
2795                .is_some_and(|webrtc| webrtc.privacy_mode),
2796        )
2797    }
2798
2799    /// Returns true when applying `next` requires constructing a new Iroh
2800    /// endpoint. These options control socket binding or address discovery and
2801    /// cannot be truthfully changed on an endpoint that is already running.
2802    #[cfg(not(target_arch = "wasm32"))]
2803    pub(crate) fn endpoint_rebind_required(&self, next: &Self) -> bool {
2804        self.network_policy != next.network_policy
2805            || self.relay != next.relay
2806            || self.iroh_relay_only != next.iroh_relay_only
2807            || self.iroh_relay_transport_policy != next.iroh_relay_transport_policy
2808            || self.iroh_lan != next.iroh_lan
2809    }
2810
2811    pub fn sanitize_for_runtime(self) -> Self {
2812        self.sanitize_for_runtime_with_ble_available(false)
2813    }
2814
2815    /// Apply static transport policy before a native host has had a chance to
2816    /// register its deferred hardware providers. Keeping requested BLE intent
2817    /// here does not advertise capability: runtime status and handshakes still
2818    /// require the provider to be registered. WASM has no native provider
2819    /// installation phase, so it continues to remove BLE immediately.
2820    fn sanitize_for_client_construction(self) -> Self {
2821        #[cfg(not(target_arch = "wasm32"))]
2822        let deferred_ble_requested = self.ble.as_ref().is_some_and(|config| config.enabled);
2823        #[cfg(target_arch = "wasm32")]
2824        let deferred_ble_requested = false;
2825        self.sanitize_for_runtime_with_ble_available(deferred_ble_requested)
2826    }
2827
2828    pub(crate) fn sanitize_for_runtime_with_ble_available(mut self, ble_available: bool) -> Self {
2829        let mut seen_routes = std::collections::HashSet::new();
2830        self.route_priority
2831            .retain(|route| seen_routes.insert(*route));
2832        if self.route_priority.is_empty() {
2833            self.route_priority = default_route_priority();
2834        }
2835
2836        if self.network_policy == NetworkPolicy::LocalOnly {
2837            self.relay = false;
2838            self.privacy_mode = false;
2839            self.iroh_relay_only = false;
2840            self.iroh_relay_transport_policy = None;
2841            self.iroh_lan = Some(IrohLanConfig {
2842                enabled: true,
2843                advertise: self
2844                    .iroh_lan
2845                    .as_ref()
2846                    .map(|lan| lan.advertise)
2847                    .unwrap_or(true),
2848            });
2849            self.webrtc = None;
2850            self.moq = None;
2851            self.route_priority.retain(|route| {
2852                matches!(
2853                    route,
2854                    crate::route_policy::KnownRoute::IrohLan
2855                        | crate::route_policy::KnownRoute::IrohQuic
2856                        | crate::route_policy::KnownRoute::Ble
2857                )
2858            });
2859            if self.route_priority.is_empty() {
2860                self.route_priority = vec![
2861                    crate::route_policy::KnownRoute::IrohLan,
2862                    crate::route_policy::KnownRoute::IrohQuic,
2863                    crate::route_policy::KnownRoute::Ble,
2864                ];
2865            }
2866        }
2867
2868        if self.privacy_mode && self.relay {
2869            self.iroh_relay_only = true;
2870            self.iroh_lan = None;
2871            self.ble = None;
2872            self.route_priority.retain(|route| {
2873                !matches!(
2874                    route,
2875                    crate::route_policy::KnownRoute::IrohLan
2876                        | crate::route_policy::KnownRoute::IrohQuic
2877                        | crate::route_policy::KnownRoute::Ble
2878                )
2879            });
2880            if let Some(webrtc) = self.webrtc.as_mut() {
2881                webrtc.privacy_mode = true;
2882                webrtc.lan_mode = false;
2883                for server in &mut webrtc.ice_servers {
2884                    server.urls.retain(|url| {
2885                        let url = url.trim().to_ascii_lowercase();
2886                        url.starts_with("turn:") || url.starts_with("turns:")
2887                    });
2888                }
2889                webrtc.ice_servers.retain(|server| !server.urls.is_empty());
2890            }
2891        }
2892
2893        if !self.relay {
2894            self.privacy_mode = false;
2895            self.iroh_relay_only = false;
2896            self.iroh_relay_transport_policy = None;
2897            self.moq = None;
2898            self.route_priority.retain(|route| {
2899                !matches!(
2900                    route,
2901                    crate::route_policy::KnownRoute::IrohRelay
2902                        | crate::route_policy::KnownRoute::Moq
2903                )
2904            });
2905            if self.route_priority.is_empty() {
2906                self.route_priority = vec![
2907                    crate::route_policy::KnownRoute::IrohLan,
2908                    crate::route_policy::KnownRoute::IrohQuic,
2909                    crate::route_policy::KnownRoute::WebRtc,
2910                    crate::route_policy::KnownRoute::Ble,
2911                ];
2912            }
2913            if let Some(webrtc) = self.webrtc.as_mut() {
2914                webrtc.privacy_mode = false;
2915                for server in &mut webrtc.ice_servers {
2916                    server.urls.retain(|url| {
2917                        let url = url.trim().to_ascii_lowercase();
2918                        !url.starts_with("turn:") && !url.starts_with("turns:")
2919                    });
2920                }
2921                webrtc.ice_servers.retain(|server| !server.urls.is_empty());
2922            }
2923        }
2924
2925        if self.ble.as_ref().map(|ble| ble.enabled).unwrap_or(false) && !ble_available {
2926            self.ble = None;
2927        }
2928
2929        self
2930    }
2931}
2932
2933pub struct ClientBuilder {
2934    #[cfg(not(target_arch = "wasm32"))]
2935    project_id: String,
2936    app_tag: String,
2937    transport_config: TransportConfig,
2938    #[cfg(not(target_arch = "wasm32"))]
2939    token_provider: Arc<dyn Fn() -> Option<String> + Send + Sync>,
2940    signaling: Option<Arc<dyn SignalingBackend>>,
2941    room: Option<Arc<dyn RoomBackend>>,
2942}
2943
2944impl ClientBuilder {
2945    pub fn new_provider_neutral(
2946        app_tag: String,
2947        identity_credential_provider: Box<dyn Fn() -> Option<String> + Send + Sync>,
2948    ) -> Self {
2949        #[cfg(target_arch = "wasm32")]
2950        let _ = identity_credential_provider;
2951        Self {
2952            // OpenRTC 2.0 live coordination is installed explicitly through a
2953            // provider-neutral SignalingBackend. An empty legacy project ID is
2954            // deliberate and prevents an accidental Firebase control path.
2955            #[cfg(not(target_arch = "wasm32"))]
2956            project_id: String::new(),
2957            app_tag,
2958            transport_config: TransportConfig::default(),
2959            #[cfg(not(target_arch = "wasm32"))]
2960            token_provider: Arc::from(identity_credential_provider),
2961            signaling: None,
2962            room: None,
2963        }
2964    }
2965
2966    // Test-only compatibility for the existing transport corpus. Production
2967    // crates expose this only when the rollback feature is explicitly enabled.
2968    #[cfg(test)]
2969    pub(crate) fn new_for_test(
2970        project_id: String,
2971        api_key: String,
2972        token_provider: Box<dyn Fn() -> Option<String> + Send + Sync>,
2973    ) -> Self {
2974        Self::new_with_app_tag(
2975            project_id,
2976            crate::app_tag_from_api_key(&api_key),
2977            token_provider,
2978        )
2979    }
2980
2981    #[cfg(test)]
2982    pub fn new_with_app_tag(
2983        project_id: String,
2984        app_tag: String,
2985        token_provider: Box<dyn Fn() -> Option<String> + Send + Sync>,
2986    ) -> Self {
2987        Self {
2988            #[cfg(not(target_arch = "wasm32"))]
2989            project_id,
2990            app_tag,
2991            transport_config: TransportConfig::default(),
2992            #[cfg(not(target_arch = "wasm32"))]
2993            token_provider: Arc::from(token_provider),
2994            signaling: None,
2995            room: None,
2996        }
2997    }
2998
2999    pub fn transport_config(mut self, transport_config: TransportConfig) -> Self {
3000        self.transport_config = transport_config.sanitize_for_client_construction();
3001        self
3002    }
3003
3004    pub fn signaling_backend(mut self, signaling: Arc<dyn SignalingBackend>) -> Self {
3005        self.signaling = Some(signaling);
3006        self
3007    }
3008
3009    pub fn room_backend(mut self, room: Arc<dyn RoomBackend>) -> Self {
3010        self.room = Some(room);
3011        self
3012    }
3013
3014    pub fn build(self) -> Client {
3015        let app_backgrounded = Arc::new(AtomicBool::new(false));
3016
3017        let room = self
3018            .room
3019            .unwrap_or_else(|| Arc::new(GatewayRequiredRoomBackend) as Arc<dyn RoomBackend>);
3020
3021        let signaling = self.signaling.unwrap_or_else(|| {
3022            Arc::new(GatewayRequiredSignalingBackend) as Arc<dyn SignalingBackend>
3023        });
3024
3025        #[cfg(not(target_arch = "wasm32"))]
3026        let (native_device_updates, _) = tokio::sync::broadcast::channel(32);
3027        #[cfg(not(target_arch = "wasm32"))]
3028        let (native_connection_state_updates, _) = tokio::sync::broadcast::channel(64);
3029        #[cfg(not(target_arch = "wasm32"))]
3030        let (native_peer_data_updates, _) = tokio::sync::broadcast::channel(256);
3031        #[cfg(not(target_arch = "wasm32"))]
3032        let (native_application_streams, native_application_streams_receiver) =
3033            async_channel::bounded(256);
3034
3035        Client {
3036            app_tag: self.app_tag,
3037            broadcasts: crate::broadcast::Broadcasts::default(),
3038            signaling,
3039            room,
3040            node_id: Arc::new(RwLock::new(None)),
3041            iroh_endpoint: Arc::new(RwLock::new(None)),
3042            #[cfg(target_arch = "wasm32")]
3043            iroh_node: Arc::new(RwLock::new(None)),
3044            #[cfg(not(target_arch = "wasm32"))]
3045            iroh_node: Arc::new(RwLock::new(None)),
3046            #[cfg(not(target_arch = "wasm32"))]
3047            native_application_streams,
3048            #[cfg(not(target_arch = "wasm32"))]
3049            native_application_streams_receiver,
3050            connection_manager: Arc::new(crate::connection_manager::ConnectionManager::new()),
3051            session_token_registry: Arc::new(crate::session_token::SessionTokenRegistry::new()),
3052            #[cfg(not(target_arch = "wasm32"))]
3053            inbound_session_admission_transport_ids: Arc::new(StdRwLock::new(HashMap::new())),
3054            #[cfg(not(target_arch = "wasm32"))]
3055            offline_admission_requirements: Arc::new(StdRwLock::new(HashMap::new())),
3056            #[cfg(not(target_arch = "wasm32"))]
3057            offline_admission_transport_ids: Arc::new(StdRwLock::new(HashMap::new())),
3058            #[cfg(not(target_arch = "wasm32"))]
3059            offline_runtime: Arc::new(tokio::sync::Mutex::new(None)),
3060            #[cfg(not(target_arch = "wasm32"))]
3061            offline_proof_attempts: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
3062            #[cfg(not(target_arch = "wasm32"))]
3063            native_admission_stream_contracts: Arc::new(StdRwLock::new(HashMap::new())),
3064            remote_session_admission_proofs: Arc::new(StdRwLock::new(HashMap::new())),
3065            pending_inline_reciprocal_admissions: Arc::new(StdRwLock::new(HashMap::new())),
3066            outbound_application_security_epoch_fingerprints: Arc::new(StdRwLock::new(
3067                HashMap::new(),
3068            )),
3069            #[cfg(not(target_arch = "wasm32"))]
3070            pending_reciprocal_session_admission_requests: Arc::new(StdRwLock::new(HashSet::new())),
3071            #[cfg(not(target_arch = "wasm32"))]
3072            native_route_repair_credentials: Arc::new(StdRwLock::new(HashMap::new())),
3073            scoped_route_repair_credentials: Arc::new(StdRwLock::new(HashMap::new())),
3074            connection_application_crypto_keys:
3075                crate::client::application_crypto_impl::new_connection_application_crypto_key_map(),
3076            connection_application_crypto_state: Arc::new(StdRwLock::new(())),
3077            connection_application_crypto_required:
3078                crate::client::application_crypto_impl::new_connection_application_crypto_required_set(),
3079            #[cfg(not(target_arch = "wasm32"))]
3080            trusted_user_device_application_crypto_required: Arc::new(AtomicBool::new(false)),
3081            connection_application_crypto_confirmed:
3082                crate::client::application_crypto_impl::new_connection_application_crypto_confirmed_map(),
3083            #[cfg(not(target_arch = "wasm32"))]
3084            connection_application_route_updates: Arc::new(tokio::sync::Notify::new()),
3085            connection_application_crypto_outbound_sequences:
3086                crate::client::application_crypto_impl::new_connection_application_crypto_outbound_sequences(),
3087            connection_application_key_agreements:
3088                crate::client::application_crypto_impl::new_connection_application_key_agreement_map(),
3089            #[cfg(not(target_arch = "wasm32"))]
3090            managed_scope_tickets: Arc::new(StdRwLock::new(HashMap::new())),
3091            known_endpoint_addrs: Arc::new(RwLock::new(HashMap::new())),
3092            known_device_ids_by_node: Arc::new(StdRwLock::new(HashMap::new())),
3093            known_device_endpoint_revision: tokio::sync::watch::channel(0).0,
3094            sparse_fanout: Arc::new(tokio::sync::Mutex::new(
3095                crate::sparse_fanout::SparseFanoutState::default(),
3096            )),
3097            #[cfg(not(target_arch = "wasm32"))]
3098            native_device_identity: Arc::new(RwLock::new(None)),
3099            #[cfg(not(target_arch = "wasm32"))]
3100            native_device_base_dir: Arc::new(RwLock::new(None)),
3101            #[cfg(not(target_arch = "wasm32"))]
3102            native_device_identity_init_guard: Arc::new(tokio::sync::Mutex::new(())),
3103            #[cfg(not(target_arch = "wasm32"))]
3104            native_device_updates,
3105            #[cfg(not(target_arch = "wasm32"))]
3106            native_connection_state_updates,
3107            #[cfg(not(target_arch = "wasm32"))]
3108            native_peer_data_updates,
3109            auto_connect_loop_key: Arc::new(Mutex::new(None)),
3110            auto_connect_generation: Arc::new(AtomicU64::new(0)),
3111            #[cfg(not(target_arch = "wasm32"))]
3112            native_auto_connect_wake: Arc::new(tokio::sync::Notify::new()),
3113            #[cfg(not(target_arch = "wasm32"))]
3114            external_desired_peer_actor: Arc::new(tokio::sync::Mutex::new(
3115                auto_connect_impl::NativeExternalAutoConnectActorState::default(),
3116            )),
3117            auto_connect_exclusion_owner: Arc::new(Mutex::new(())),
3118            auto_connect_excluded: Arc::new(Mutex::new(HashSet::new())),
3119            auto_connect_peer_requested_excluded: Arc::new(Mutex::new(HashSet::new())),
3120            auto_connect_excluded_node_aliases: Arc::new(Mutex::new(HashMap::new())),
3121            app_backgrounded,
3122            background_execution_allowed: Arc::new(AtomicBool::new(false)),
3123            #[cfg(target_arch = "wasm32")]
3124            wasm_accept_bridge_started: Arc::new(std::sync::atomic::AtomicBool::new(false)),
3125            #[cfg(target_arch = "wasm32")]
3126            last_emitted_connection_states: Arc::new(Mutex::new(std::collections::HashMap::new())),
3127            #[cfg(target_arch = "wasm32")]
3128            last_empty_peer_sessions_warning_ms: Arc::new(AtomicU64::new(0)),
3129            iroh_init_guard: Arc::new(tokio::sync::Mutex::new(())),
3130            #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
3131            iroh_packet_carriers: Arc::new(RwLock::new(HashMap::new())),
3132            managed_connect_gates: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
3133            #[cfg(not(target_arch = "wasm32"))]
3134            iroh_path_watcher_stable_ids: Arc::new(Mutex::new(HashSet::new())),
3135            #[cfg(not(target_arch = "wasm32"))]
3136            native_custom_transport_kinds: Arc::new(RwLock::new(HashMap::new())),
3137            #[cfg(not(target_arch = "wasm32"))]
3138            native_transport_upgrade_providers: Arc::new(RwLock::new(HashMap::new())),
3139            #[cfg(target_arch = "wasm32")]
3140            wasm_transport_upgrade_gates: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
3141            #[cfg(not(target_arch = "wasm32"))]
3142            native_transport_upgrade_gates: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
3143            #[cfg(not(target_arch = "wasm32"))]
3144            native_ble_upgrade_attempts: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
3145            #[cfg(not(target_arch = "wasm32"))]
3146            native_peer_transport_capabilities: Arc::new(RwLock::new(HashMap::new())),
3147            presence_loop_tx: Arc::new(Mutex::new(None)),
3148            #[cfg(not(target_arch = "wasm32"))]
3149            project_id: self.project_id,
3150            #[cfg(not(target_arch = "wasm32"))]
3151            token_provider: self.token_provider,
3152            transport_config: Arc::new(RwLock::new(
3153                self.transport_config.sanitize_for_client_construction(),
3154            )),
3155            iroh_carrier_policy_epoch: Arc::new(AtomicU64::new(0)),
3156            iroh_carrier_peer_policy_epochs: Arc::new(StdRwLock::new(HashMap::new())),
3157            #[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
3158            local_discovery_registry: crate::local_discovery::LocalDiscoveryRegistry::new(),
3159            #[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
3160            mdns_address_lookup: Arc::new(RwLock::new(None)),
3161            #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
3162            native_webrtc_carrier_attempts: Arc::new(RwLock::new(HashMap::new())),
3163            #[cfg(all(
3164                not(target_arch = "wasm32"),
3165                feature = "iroh-carrier-core",
3166                any(feature = "test-harness", feature = "testing-endpoints")
3167            ))]
3168            native_iroh_carrier_debug_events: Arc::new(StdRwLock::new(
3169                std::collections::VecDeque::new(),
3170            )),
3171            #[cfg(all(not(target_arch = "wasm32"), feature = "transport-moq"))]
3172            native_moq_carrier_attempts: Arc::new(RwLock::new(HashMap::new())),
3173            #[cfg(not(target_arch = "wasm32"))]
3174            native_control_streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
3175            #[cfg(not(target_arch = "wasm32"))]
3176            native_transport_protocol_activity: Arc::new(StdRwLock::new(HashMap::new())),
3177            #[cfg(all(not(target_arch = "wasm32"), feature = "experimental-scoped-actor"))]
3178            scoped_connection_actor_registry: Arc::new(RwLock::new(None)),
3179            #[cfg(not(target_arch = "wasm32"))]
3180            auth_readiness: Arc::new(crate::client::auth_readiness::AuthReadinessStore::new()),
3181            #[cfg(not(target_arch = "wasm32"))]
3182            scope_classifier: Arc::new(RwLock::new(default_classifier())),
3183        }
3184    }
3185}
3186
3187// Delegated implementation modules for readability and ownership boundaries.
3188// - `core_impl`: constructors, endpoint lifecycle, transport primitives, accept bridges.
3189// - `admission_impl`: session-token admission and managed-scope persistence.
3190// - `state_signaling_impl`: peer state APIs, managed connection snapshots, signaling loops.
3191// - `auto_connect_impl`: transport liveness checks and auto-connect policy loop internals.
3192// - `scoped_connection_actor`: optional native experiment, never a default
3193//   lifecycle authority until it owns real dial and channel routing end-to-end.
3194mod admission_impl;
3195mod application_crypto_impl;
3196#[cfg(not(target_arch = "wasm32"))]
3197pub mod auth_readiness;
3198mod auto_connect_impl;
3199mod core_impl;
3200#[cfg(not(target_arch = "wasm32"))]
3201pub mod correlation;
3202#[cfg(all(not(target_arch = "wasm32"), feature = "experimental-scoped-actor"))]
3203mod drive_grant_actor;
3204#[cfg(all(
3205    test,
3206    not(target_arch = "wasm32"),
3207    feature = "experimental-scoped-actor"
3208))]
3209mod drive_grant_actor_tests;
3210#[cfg(not(target_arch = "wasm32"))]
3211pub mod scope_classifier;
3212#[cfg(all(not(target_arch = "wasm32"), feature = "experimental-scoped-actor"))]
3213pub mod scoped_connection_actor;
3214mod sparse_fanout_impl;
3215mod state_signaling_impl;
3216#[cfg(not(target_arch = "wasm32"))]
3217mod transport_upgrade_impl;
3218#[cfg(all(
3219    target_arch = "wasm32",
3220    any(feature = "transport-webrtc", feature = "transport-moq")
3221))]
3222mod wasm_carrier_impl;
3223#[cfg(all(not(target_arch = "wasm32"), test))]
3224pub(crate) use transport_upgrade_impl::{
3225    retire_losing_native_control_candidate, retire_replaced_native_control_send,
3226};
3227
3228impl Client {
3229    /// Opens non-peer live broadcasts through the shared Rust lifecycle owner.
3230    /// Native applications use this directly; WASM/Tauri only adapt commands.
3231    pub fn broadcasts(&self) -> crate::broadcast::Broadcasts {
3232        self.broadcasts.clone()
3233    }
3234}
3235
3236#[cfg(any(
3237    not(target_arch = "wasm32"),
3238    feature = "transport-webrtc",
3239    feature = "transport-moq"
3240))]
3241impl Client {
3242    pub(crate) fn current_iroh_carrier_policy_epoch(&self) -> u64 {
3243        self.iroh_carrier_policy_epoch.load(Ordering::Acquire)
3244    }
3245
3246    #[cfg(feature = "iroh-carrier-core")]
3247    pub(crate) fn bump_iroh_carrier_policy_epoch(&self) -> u64 {
3248        self.iroh_carrier_policy_epoch
3249            .fetch_add(1, Ordering::AcqRel)
3250            .saturating_add(1)
3251    }
3252
3253    #[cfg(feature = "iroh-carrier-core")]
3254    pub(crate) fn iroh_carrier_policy_epoch_is_current(&self, policy_epoch: u64) -> bool {
3255        self.current_iroh_carrier_policy_epoch() == policy_epoch
3256    }
3257
3258    pub(crate) fn current_iroh_carrier_peer_policy_epoch(&self, connection_id: &str) -> u64 {
3259        self.iroh_carrier_peer_policy_epochs
3260            .read()
3261            .ok()
3262            .and_then(|epochs| epochs.get(connection_id).copied())
3263            .unwrap_or_default()
3264    }
3265
3266    #[cfg(feature = "iroh-carrier-core")]
3267    pub(crate) fn bump_iroh_carrier_peer_policy_epoch(&self, connection_id: &str) -> u64 {
3268        let Ok(mut epochs) = self.iroh_carrier_peer_policy_epochs.write() else {
3269            return self.current_iroh_carrier_peer_policy_epoch(connection_id);
3270        };
3271        let next = epochs
3272            .get(connection_id)
3273            .copied()
3274            .unwrap_or_default()
3275            .saturating_add(1);
3276        epochs.insert(connection_id.to_string(), next);
3277        next
3278    }
3279
3280    #[cfg(feature = "iroh-carrier-core")]
3281    pub(crate) fn forget_iroh_carrier_peer_policy_epoch(&self, connection_id: &str) {
3282        if let Ok(mut epochs) = self.iroh_carrier_peer_policy_epochs.write() {
3283            epochs.remove(connection_id);
3284        }
3285    }
3286}
3287
3288/// WASM stub for `send_peer`.
3289///
3290/// On native this is provided by `transport_upgrade_impl`. On WASM the TypeScript
3291/// layer owns transport selection; this stub exists for API symmetry.
3292#[cfg(target_arch = "wasm32")]
3293impl Client {
3294    pub(crate) async fn set_relay(&self, enabled: bool) -> anyhow::Result<()> {
3295        anyhow::ensure!(
3296            self.iroh_endpoint.read().await.is_none(),
3297            "relay must be configured before Iroh endpoint initialization",
3298        );
3299        let mut config = self.transport_config.read().await.clone();
3300        config.relay = enabled;
3301        *self.transport_config.write().await = config.sanitize_for_runtime();
3302        Ok(())
3303    }
3304
3305    pub async fn send_peer(&self, _id: &str, _data: &[u8]) -> anyhow::Result<()> {
3306        Err(anyhow::anyhow!(
3307            "send_peer: use TypeScript Connection.sendTyped() for transport-aware sending in browser environments"
3308        ))
3309    }
3310}
3311
3312#[cfg(not(target_arch = "wasm32"))]
3313impl Client {
3314    pub fn subscribe_native_peer_data(
3315        &self,
3316    ) -> tokio::sync::broadcast::Receiver<NativePeerDataEvent> {
3317        self.native_peer_data_updates.subscribe()
3318    }
3319
3320    #[cfg_attr(
3321        not(any(feature = "transport-webrtc", feature = "transport-moq")),
3322        allow(dead_code)
3323    )]
3324    pub(crate) fn emit_native_peer_data(&self, event: NativePeerDataEvent) {
3325        let _ = self.native_peer_data_updates.send(event);
3326    }
3327}
3328
3329#[cfg(test)]
3330mod tests;
3331
3332#[cfg(all(test, not(target_arch = "wasm32")))]
3333mod sparse_iroh_lane_tests;
3334
3335#[cfg(all(test, not(target_arch = "wasm32")))]
3336mod relay_diagnostic_truth_tests {
3337    use super::*;
3338
3339    #[test]
3340    fn auto_reports_requested_and_effective_relay_carriage() {
3341        let diagnostic = RelayStatus::from_policy(RelayPolicy::Auto, current_iroh_relay_provider());
3342
3343        assert_eq!(diagnostic.requested_carriage, RelayPolicy::Auto);
3344        let serialized = serde_json::to_value(&diagnostic).expect("relay diagnostic serializes");
3345        assert_eq!(serialized["requestedCarriage"], "auto");
3346        #[cfg(openrtc_iroh_relay_transport_policy_api)]
3347        assert_eq!(
3348            diagnostic.effective_carriage,
3349            RelayCarriage::QuicWithWebsocketFallback
3350        );
3351        #[cfg(not(openrtc_iroh_relay_transport_policy_api))]
3352        assert_eq!(
3353            diagnostic.effective_carriage,
3354            RelayCarriage::WebsocketFallback
3355        );
3356    }
3357
3358    #[cfg(not(openrtc_iroh_relay_transport_policy_api))]
3359    #[test]
3360    fn upstream_quic_required_fails_closed_before_endpoint_bind() {
3361        let result = apply_native_network_preferences(
3362            iroh::Endpoint::builder(iroh::endpoint::presets::N0),
3363            false,
3364            RelayPolicy::QuicRequired,
3365        );
3366
3367        assert!(result.is_err());
3368        assert!(result
3369            .err()
3370            .expect("unsupported QUIC policy should return an error")
3371            .to_string()
3372            .contains("unsupported by this upstream-Iroh build"));
3373    }
3374
3375    #[cfg(openrtc_iroh_relay_transport_policy_api)]
3376    #[test]
3377    fn vendored_quic_required_reports_quic_and_keeps_endpoint_policy_available() {
3378        let diagnostic =
3379            RelayStatus::from_policy(RelayPolicy::QuicRequired, current_iroh_relay_provider());
3380
3381        assert_eq!(diagnostic.effective_carriage, RelayCarriage::Quic);
3382        assert_eq!(diagnostic.provider, IrohRelayProvider::VendoredIroh);
3383    }
3384}