Skip to main content

openrtc/client/
state_signaling_impl.rs

1//! Delegated `Client` peer-state, managed-connection, signaling, and loop-control methods.
2//!
3//! This module keeps read/write state APIs separate from endpoint bootstrap internals.
4
5use super::*;
6
7pub(crate) fn should_preserve_proven_iroh_carrier_route(
8    current_transport: &str,
9    observed_transport: &str,
10) -> bool {
11    crate::transport_label::is_generation_bound_iroh_carrier(current_transport)
12        && crate::transport_label::is_iroh_base(observed_transport)
13}
14
15pub(crate) fn should_present_session_token_on_connected_transport(
16    has_extracted_token: bool,
17    has_current_remote_admission_proof: bool,
18) -> bool {
19    has_extracted_token && !has_current_remote_admission_proof
20}
21
22fn selected_iroh_latency_label<'a>(
23    path_kind: crate::client::IrohPathKind,
24    transport_labels: impl IntoIterator<Item = &'a str>,
25) -> Option<&'a str> {
26    let labels = transport_labels
27        .into_iter()
28        .filter_map(|label| crate::transport_label::normalize(label).map(|_| label))
29        .collect::<Vec<_>>();
30    let matches_kind = |label: &&str| {
31        let normalized = crate::transport_label::normalize(label);
32        match path_kind {
33            crate::client::IrohPathKind::DirectQuic => {
34                normalized == Some(crate::transport_label::IROH)
35                    || normalized == Some(crate::transport_label::IROH_QUIC)
36            }
37            crate::client::IrohPathKind::DirectLan => {
38                normalized == Some(crate::transport_label::IROH_LAN)
39            }
40            crate::client::IrohPathKind::Relay => {
41                normalized == Some(crate::transport_label::IROH_RELAY)
42            }
43            crate::client::IrohPathKind::Ble => normalized == Some(crate::transport_label::BLE),
44            crate::client::IrohPathKind::WebRtc => {
45                normalized == Some(crate::transport_label::WEBRTC)
46            }
47            crate::client::IrohPathKind::Moq => normalized == Some(crate::transport_label::MOQ),
48            crate::client::IrohPathKind::Unknown => {
49                normalized.is_some_and(crate::transport_label::is_iroh_base)
50            }
51        }
52    };
53    labels.into_iter().find(matches_kind)
54}
55
56#[cfg(not(target_arch = "wasm32"))]
57fn native_presence_retry_targets(durable_registered: bool, live_registered: bool) -> (bool, bool) {
58    (!durable_registered, !live_registered)
59}
60
61#[cfg(not(target_arch = "wasm32"))]
62fn native_presence_is_ready(durable_registered: bool, live_registered: bool) -> bool {
63    durable_registered && live_registered
64}
65
66#[cfg(not(target_arch = "wasm32"))]
67fn native_presence_retry_delay(consecutive_failures: u32) -> std::time::Duration {
68    const BASE_MS: u64 = 2_000;
69    const MAX_MS: u64 = 60_000;
70    let exponent = consecutive_failures.min(5);
71    std::time::Duration::from_millis(BASE_MS.saturating_mul(1_u64 << exponent).min(MAX_MS))
72}
73
74#[cfg(not(target_arch = "wasm32"))]
75#[derive(Clone, Debug)]
76enum NativePresenceTicketPolicy {
77    Fixed(String),
78    ManagedUserDevice,
79}
80
81#[cfg(not(target_arch = "wasm32"))]
82#[derive(Default)]
83pub(super) struct RetiredNativeCarrierAttempts {
84    #[cfg(feature = "transport-webrtc")]
85    webrtc: Option<Arc<crate::client::NativeWebRtcCarrierAttempt>>,
86    #[cfg(feature = "transport-moq")]
87    moq: Option<Arc<crate::client::NativeMoqCarrierAttempt>>,
88}
89
90#[cfg(not(target_arch = "wasm32"))]
91fn native_carrier_generation_matches_retirement(
92    generation: crate::client::NativePeerDataGeneration,
93    retiring_generation: Option<(Option<u64>, u64, u64)>,
94) -> bool {
95    retiring_generation.is_some_and(
96        |(transport_stable_id, transport_generation, route_generation)| {
97            transport_stable_id.is_none_or(|stable_id| stable_id == generation.transport_stable_id)
98                && transport_generation == generation.transport_generation
99                && route_generation == generation.route_generation
100        },
101    )
102}
103
104#[cfg(not(target_arch = "wasm32"))]
105pub(super) struct CapturedNativeRevokedResources {
106    control: Option<NativeControlStreamOwner>,
107    gates: Vec<((String, IrohPathKind), NativeTransportUpgradeGate)>,
108    ble: Option<NativeBleUpgradeAttemptState>,
109    #[cfg(feature = "transport-webrtc")]
110    webrtc: Option<Arc<NativeWebRtcCarrierAttempt>>,
111    #[cfg(feature = "transport-moq")]
112    moq: Option<Arc<NativeMoqCarrierAttempt>>,
113}
114
115#[cfg(not(target_arch = "wasm32"))]
116impl CapturedNativeRevokedResources {
117    pub(super) async fn send_terminal(&self) {
118        #[cfg(feature = "transport-webrtc")]
119        if let Some(attempt) = self.webrtc.as_ref() {
120            if let Some(pump) = attempt.pump.lock().await.as_ref() {
121                let _ = pump
122                    .send_terminal(crate::lifecycle_reason::REASON_SESSION_TOKEN_REVOKED)
123                    .await;
124            }
125        }
126        #[cfg(feature = "transport-moq")]
127        if let Some(attempt) = self.moq.as_ref() {
128            if let Some(pump) = attempt.pump.lock().await.as_ref() {
129                let _ = pump
130                    .send_terminal(crate::lifecycle_reason::REASON_SESSION_TOKEN_REVOKED)
131                    .await;
132            }
133        }
134    }
135}
136
137impl Client {
138    #[cfg(not(target_arch = "wasm32"))]
139    pub(super) async fn capture_native_revoked_resources(
140        &self,
141        retirement: &super::admission_impl::RevokedConnectionRetirement,
142    ) -> CapturedNativeRevokedResources {
143        // Last-grant revocation owns the logical session while its retirement
144        // guard is live. Capture its exact subordinate owners even when a close
145        // observation has already cleared the physical/route projection, or a
146        // carrier serving N+1 still retains its base-N attempt metadata.
147        let connection_id = &retirement.record.connection_id;
148        let control = self
149            .native_control_streams
150            .lock()
151            .await
152            .get(connection_id)
153            .map(|entry| entry.owner);
154        let gates = self
155            .native_transport_upgrade_gates
156            .lock()
157            .await
158            .iter()
159            .filter(|((id, _), _)| id == connection_id)
160            .map(|(key, gate)| (key.clone(), gate.clone()))
161            .collect();
162        let ble = self
163            .native_ble_upgrade_attempts
164            .lock()
165            .await
166            .get(connection_id)
167            .copied();
168        #[cfg(feature = "transport-webrtc")]
169        let webrtc = self
170            .native_webrtc_carrier_attempts
171            .read()
172            .await
173            .get(connection_id)
174            .cloned();
175        #[cfg(feature = "transport-moq")]
176        let moq = self
177            .native_moq_carrier_attempts
178            .read()
179            .await
180            .get(connection_id)
181            .cloned();
182        CapturedNativeRevokedResources {
183            control,
184            gates,
185            ble,
186            #[cfg(feature = "transport-webrtc")]
187            webrtc,
188            #[cfg(feature = "transport-moq")]
189            moq,
190        }
191    }
192
193    #[cfg(not(target_arch = "wasm32"))]
194    pub(super) async fn finish_native_revoked_resources(
195        &self,
196        connection_id: &str,
197        captured: CapturedNativeRevokedResources,
198    ) {
199        // No physical/manager lock is held here. Every removal compares the
200        // captured owner, so a successor created during cleanup survives.
201        {
202            let mut streams = self.native_control_streams.lock().await;
203            if captured.control.is_some_and(|owner| {
204                streams
205                    .get(connection_id)
206                    .is_some_and(|entry| entry.owner == owner)
207            }) {
208                streams.remove(connection_id);
209            }
210        }
211        {
212            let mut gates = self.native_transport_upgrade_gates.lock().await;
213            for (key, expected) in captured.gates {
214                if gates.get(&key) == Some(&expected) {
215                    gates.remove(&key);
216                }
217            }
218            let mut ble = self.native_ble_upgrade_attempts.lock().await;
219            if captured.ble.is_some() && ble.get(connection_id) == captured.ble.as_ref() {
220                ble.remove(connection_id);
221            }
222        }
223        #[cfg(feature = "transport-webrtc")]
224        if let Some(attempt) = captured.webrtc {
225            {
226                let mut attempts = self.native_webrtc_carrier_attempts.write().await;
227                if attempts
228                    .get(connection_id)
229                    .is_some_and(|current| Arc::ptr_eq(current, &attempt))
230                {
231                    attempts.remove(connection_id);
232                }
233            }
234            attempt.channel.close();
235            let _ = attempt.pump.lock().await.take();
236        }
237        #[cfg(feature = "transport-moq")]
238        if let Some(attempt) = captured.moq {
239            {
240                let mut attempts = self.native_moq_carrier_attempts.write().await;
241                if attempts
242                    .get(connection_id)
243                    .is_some_and(|current| Arc::ptr_eq(current, &attempt))
244                {
245                    attempts.remove(connection_id);
246                }
247            }
248            attempt.session.close();
249            let _ = attempt.pump.lock().await.take();
250        }
251    }
252
253    pub(crate) async fn managed_connect_gate(
254        &self,
255        connection_id: &str,
256    ) -> Arc<tokio::sync::Mutex<()>> {
257        let mut gates = self.managed_connect_gates.lock().await;
258        gates
259            .entry(connection_id.to_string())
260            .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
261            .clone()
262    }
263
264    async fn close_cancelled_connect(
265        &self,
266        endpoint_id: iroh::EndpointId,
267        transport_stable_id: Option<u64>,
268    ) {
269        let (Some(node), Some(transport_stable_id)) = (
270            self.iroh_node.read().await.as_ref().cloned(),
271            transport_stable_id,
272        ) else {
273            return;
274        };
275        let _ = node
276            .disconnect_with_reason_if_current(
277                endpoint_id,
278                transport_stable_id,
279                crate::lifecycle_reason::REASON_MANUAL_DISCONNECT,
280            )
281            .await;
282    }
283
284    #[cfg(not(target_arch = "wasm32"))]
285    pub(super) async fn retire_native_carrier_upgrade_state_if_current(
286        &self,
287        connection_id: &str,
288        retiring_generation: Option<(Option<u64>, u64, u64)>,
289        retiring_peer_policy_epoch: Option<u64>,
290    ) -> RetiredNativeCarrierAttempts {
291        #[cfg(not(feature = "iroh-carrier-core"))]
292        let _ = retiring_peer_policy_epoch;
293        let mut gates = self.native_transport_upgrade_gates.lock().await;
294
295        #[cfg(feature = "transport-webrtc")]
296        let webrtc = {
297            let mut attempts = self.native_webrtc_carrier_attempts.write().await;
298            if attempts.get(connection_id).is_some_and(|attempt| {
299                native_carrier_generation_matches_retirement(
300                    attempt.generation,
301                    retiring_generation,
302                )
303            }) {
304                attempts.remove(connection_id)
305            } else {
306                None
307            }
308        };
309
310        #[cfg(feature = "transport-moq")]
311        let moq = {
312            let mut attempts = self.native_moq_carrier_attempts.write().await;
313            if attempts.get(connection_id).is_some_and(|attempt| {
314                native_carrier_generation_matches_retirement(
315                    attempt.generation,
316                    retiring_generation,
317                )
318            }) {
319                attempts.remove(connection_id)
320            } else {
321                None
322            }
323        };
324
325        let mut attempts = self.native_ble_upgrade_attempts.lock().await;
326        if attempts.get(connection_id).is_some_and(|attempt| {
327            native_carrier_generation_matches_retirement(attempt.generation, retiring_generation)
328        }) {
329            attempts.remove(connection_id);
330        }
331        drop(attempts);
332
333        gates.retain(|(candidate_connection_id, _), gate| {
334            candidate_connection_id != connection_id
335                || !native_carrier_generation_matches_retirement(
336                    gate.generation,
337                    retiring_generation,
338                )
339        });
340
341        #[cfg(feature = "iroh-carrier-core")]
342        if !gates
343            .keys()
344            .any(|(candidate_connection_id, _)| candidate_connection_id == connection_id)
345        {
346            if retiring_peer_policy_epoch.is_some_and(|expected_epoch| {
347                self.current_iroh_carrier_peer_policy_epoch(connection_id) == expected_epoch
348            }) {
349                self.forget_iroh_carrier_peer_policy_epoch(connection_id);
350            }
351        }
352
353        RetiredNativeCarrierAttempts {
354            #[cfg(feature = "transport-webrtc")]
355            webrtc,
356            #[cfg(feature = "transport-moq")]
357            moq,
358        }
359    }
360
361    pub(crate) async fn retire_managed_connection_now(
362        &self,
363        connection_id: &str,
364        normalized_reason: Option<String>,
365    ) {
366        #[cfg(all(
367            not(target_arch = "wasm32"),
368            any(feature = "transport-webrtc", feature = "transport-moq")
369        ))]
370        let retirement_reason = normalized_reason.clone();
371        #[cfg(not(target_arch = "wasm32"))]
372        let retiring_record = self
373            .connection_manager
374            .get_by_connection_id(connection_id)
375            .await;
376        #[cfg(not(target_arch = "wasm32"))]
377        let retiring_generation = retiring_record.as_ref().map(|record| {
378            (
379                record.transport_stable_id,
380                record.transport_generation,
381                record.route_generation,
382            )
383        });
384        #[cfg(all(not(target_arch = "wasm32"), feature = "iroh-carrier-core"))]
385        let retiring_peer_policy_epoch =
386            Some(self.current_iroh_carrier_peer_policy_epoch(connection_id));
387        #[cfg(all(not(target_arch = "wasm32"), not(feature = "iroh-carrier-core")))]
388        let retiring_peer_policy_epoch = None;
389        let terminal_disconnect =
390            crate::lifecycle_reason::is_terminal(normalized_reason.as_deref());
391        #[cfg(not(target_arch = "wasm32"))]
392        let already_terminal = retiring_record.as_ref().is_some_and(|record| {
393            matches!(
394                record.state,
395                crate::connection_manager::ConnectionState::Closed
396                    | crate::connection_manager::ConnectionState::Failed
397            )
398        });
399        #[cfg(target_arch = "wasm32")]
400        let already_terminal = self
401            .connection_manager
402            .get_by_connection_id(connection_id)
403            .await
404            .is_some_and(|record| {
405                matches!(
406                    record.state,
407                    crate::connection_manager::ConnectionState::Closed
408                        | crate::connection_manager::ConnectionState::Failed
409                )
410            });
411        if !already_terminal {
412            self.connection_manager
413                .set_closing(connection_id, normalized_reason.clone())
414                .await;
415            self.connection_manager
416                .set_closed(connection_id, normalized_reason)
417                .await;
418        }
419        #[cfg(not(target_arch = "wasm32"))]
420        self.emit_current_native_connection_state(connection_id)
421            .await;
422        #[cfg(target_arch = "wasm32")]
423        self.emit_current_wasm_connection_state(connection_id).await;
424        if terminal_disconnect {
425            self.forget_session_connection(connection_id);
426        }
427        let _ = self.connection_manager.remove(connection_id).await;
428        self.managed_connect_gates
429            .lock()
430            .await
431            .remove(connection_id);
432        #[cfg(not(target_arch = "wasm32"))]
433        self.native_peer_transport_capabilities
434            .write()
435            .await
436            .remove(connection_id);
437        #[cfg(all(target_arch = "wasm32", feature = "iroh-carrier-core"))]
438        self.forget_iroh_carrier_peer_policy_epoch(connection_id);
439        #[cfg(not(target_arch = "wasm32"))]
440        self.native_control_streams
441            .lock()
442            .await
443            .remove(connection_id);
444        #[cfg(not(target_arch = "wasm32"))]
445        let retired_native_carrier_attempts = self
446            .retire_native_carrier_upgrade_state_if_current(
447                connection_id,
448                retiring_generation,
449                retiring_peer_policy_epoch,
450            )
451            .await;
452        #[cfg(all(
453            not(target_arch = "wasm32"),
454            not(any(feature = "transport-webrtc", feature = "transport-moq"))
455        ))]
456        let _ = retired_native_carrier_attempts;
457        #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
458        if let Some(attempt) = retired_native_carrier_attempts.webrtc {
459            if retirement_reason.as_deref()
460                == Some(crate::lifecycle_reason::REASON_SESSION_TOKEN_REVOKED)
461            {
462                if let Some(pump) = attempt.pump.lock().await.as_ref() {
463                    let _ = pump
464                        .send_terminal(crate::lifecycle_reason::REASON_SESSION_TOKEN_REVOKED)
465                        .await;
466                }
467            }
468            self.record_native_iroh_carrier_debug_event(
469                connection_id,
470                &attempt.upgrade_id,
471                "close-requested",
472                retirement_reason
473                    .as_deref()
474                    .or(Some("logical-connection-retired")),
475            );
476            attempt.channel.close();
477            let _ = attempt.pump.lock().await.take();
478        }
479
480        #[cfg(all(not(target_arch = "wasm32"), feature = "transport-moq"))]
481        if let Some(attempt) = retired_native_carrier_attempts.moq {
482            if retirement_reason.as_deref()
483                == Some(crate::lifecycle_reason::REASON_SESSION_TOKEN_REVOKED)
484            {
485                if let Some(pump) = attempt.pump.lock().await.as_ref() {
486                    let _ = pump
487                        .send_terminal(crate::lifecycle_reason::REASON_SESSION_TOKEN_REVOKED)
488                        .await;
489                }
490            }
491            attempt.session.close();
492            let _ = attempt.pump.lock().await.take();
493        }
494    }
495
496    // A short grace window prevents us from declaring a peer "settled" during
497    // duplicate-transport handoff churn, which is especially common after a
498    // browser reload reattaches to an existing identity.
499    //
500    // WASM needs a slightly longer quiet period because reload-triggered
501    // duplicate close/promote cycles can land noticeably after the first
502    // "connected" transition, even though the peer is not yet durable for
503    // protocol traffic.
504    #[cfg(not(target_arch = "wasm32"))]
505    const SETTLED_PEER_STABILITY_WINDOW_MS: u64 = 3_000;
506    #[cfg(target_arch = "wasm32")]
507    const SETTLED_PEER_STABILITY_WINDOW_MS: u64 = 3_000;
508    #[cfg(target_arch = "wasm32")]
509    const EMPTY_PEER_SESSIONS_WARNING_INTERVAL_MS: u64 = 5_000;
510
511    fn normalize_transport_name(value: &str) -> Option<String> {
512        crate::transport_label::normalize(value).map(str::to_string)
513    }
514
515    /// Test-only convenience that captures the current tuple before submitting
516    /// through the same generation-fenced production boundary.
517    #[cfg(test)]
518    pub async fn report_transport_status(
519        &self,
520        connection_id: &str,
521        active_transport: &str,
522        parallel_transport: Option<&str>,
523    ) -> Option<PeerSessionSnapshot> {
524        self.report_transport_status_for_current_generation(
525            connection_id,
526            active_transport,
527            parallel_transport,
528        )
529        .await
530    }
531
532    /// Submit an immediate owner-generated observation after a connection
533    /// transition. The tuple is captured first and then revalidated under the
534    /// connection-manager write lock, so a concurrent replacement wins.
535    #[cfg(any(test, target_arch = "wasm32", feature = "iroh-carrier-core"))]
536    pub(crate) async fn report_transport_status_for_current_generation(
537        &self,
538        connection_id: &str,
539        active_transport: &str,
540        parallel_transport: Option<&str>,
541    ) -> Option<PeerSessionSnapshot> {
542        let record = self
543            .connection_manager
544            .get_by_connection_id(connection_id)
545            .await?;
546        let stable_id = record.transport_stable_id?;
547        self.report_transport_status_for_generation(
548            connection_id,
549            active_transport,
550            parallel_transport,
551            stable_id,
552            record.transport_generation,
553            record.route_generation,
554        )
555        .await
556    }
557
558    pub(crate) async fn report_transport_status_for_generation(
559        &self,
560        connection_id: &str,
561        active_transport: &str,
562        parallel_transport: Option<&str>,
563        expected_transport_stable_id: u64,
564        expected_transport_generation: u64,
565        expected_route_generation: u64,
566    ) -> Option<PeerSessionSnapshot> {
567        self.report_transport_status_observation(
568            connection_id,
569            active_transport,
570            parallel_transport,
571            (
572                expected_transport_stable_id,
573                expected_transport_generation,
574                expected_route_generation,
575            ),
576        )
577        .await
578    }
579
580    async fn report_transport_status_observation(
581        &self,
582        connection_id: &str,
583        active_transport: &str,
584        parallel_transport: Option<&str>,
585        expected_generation: (u64, u64, u64),
586    ) -> Option<PeerSessionSnapshot> {
587        let normalized_active_transport = Self::normalize_transport_name(active_transport)?;
588        let normalized_parallel_transport = parallel_transport
589            .and_then(Self::normalize_transport_name)
590            .filter(|value| value != &normalized_active_transport);
591        let current_record = self
592            .connection_manager
593            .get_by_connection_id(connection_id)
594            .await;
595        let (normalized_active_transport, normalized_parallel_transport) = {
596            let mut active_transport = normalized_active_transport;
597            let mut parallel_transport = normalized_parallel_transport;
598            if let Some(record) = current_record.as_ref() {
599                if should_preserve_proven_iroh_carrier_route(
600                    record.active_transport.as_str(),
601                    active_transport.as_str(),
602                ) {
603                    // The protected carrier proof owns the route for this
604                    // physical generation. Generic Iroh path snapshots can
605                    // still look like ordinary relay, so they may not
606                    // downgrade the proven BLE/WebRTC/MoQ route. Carrier loss
607                    // closes or replaces the physical generation, which is
608                    // fenced by report_transport_status_if_current below.
609                    active_transport = record.active_transport.clone();
610                    parallel_transport = record.parallel_transport.clone();
611                }
612            }
613            (active_transport, parallel_transport)
614        };
615
616        let (active_transport, parallel_transport) =
617            (normalized_active_transport, normalized_parallel_transport);
618
619        let transport_changed = current_record
620            .as_ref()
621            .map(|record| {
622                record.active_transport != active_transport
623                    || record.parallel_transport != parallel_transport
624            })
625            .unwrap_or(true);
626        let (stable_id, transport_generation, route_generation) = expected_generation;
627        let snapshot = self
628            .connection_manager
629            .report_transport_status_if_current(
630                connection_id,
631                stable_id,
632                transport_generation,
633                route_generation,
634                active_transport,
635                parallel_transport,
636            )
637            .await
638            .map(peer_session_snapshot_from_peer)
639            .map(|snapshot| self.apply_peer_session_admission_guard(snapshot));
640        if snapshot.is_some() && transport_changed {
641            #[cfg(not(target_arch = "wasm32"))]
642            self.emit_current_native_connection_state(connection_id)
643                .await;
644            #[cfg(target_arch = "wasm32")]
645            self.emit_current_wasm_connection_state(connection_id).await;
646        }
647        snapshot
648    }
649
650    #[cfg(not(target_arch = "wasm32"))]
651    pub(crate) async fn current_native_peer_data_generation(
652        &self,
653        connection_id: &str,
654        expected_transport_stable_id: Option<u64>,
655    ) -> Option<crate::client::NativePeerDataGeneration> {
656        let record = self
657            .connection_manager
658            .get_by_connection_id(connection_id)
659            .await?;
660        if !matches!(
661            record.state,
662            crate::connection_manager::ConnectionState::Connecting
663                | crate::connection_manager::ConnectionState::Connected
664        ) {
665            return None;
666        }
667        let transport_stable_id = record.transport_stable_id?;
668        if expected_transport_stable_id.is_some_and(|expected| expected != transport_stable_id) {
669            return None;
670        }
671        Some(crate::client::NativePeerDataGeneration {
672            transport_stable_id,
673            transport_generation: record.transport_generation,
674            route_generation: record.route_generation,
675        })
676    }
677
678    #[cfg(all(
679        target_arch = "wasm32",
680        any(feature = "transport-webrtc", feature = "transport-moq")
681    ))]
682    pub(crate) async fn current_wasm_peer_data_generation(
683        &self,
684        connection_id: &str,
685        expected_transport_stable_id: Option<u64>,
686    ) -> Option<crate::client::WasmPeerDataGeneration> {
687        let record = self
688            .connection_manager
689            .get_by_connection_id(connection_id)
690            .await?;
691        if !matches!(
692            record.state,
693            crate::connection_manager::ConnectionState::Connecting
694                | crate::connection_manager::ConnectionState::Connected
695        ) {
696            return None;
697        }
698        let transport_stable_id = record.transport_stable_id?;
699        if expected_transport_stable_id.is_some_and(|expected| expected != transport_stable_id) {
700            return None;
701        }
702        Some(crate::client::WasmPeerDataGeneration {
703            transport_stable_id,
704            transport_generation: record.transport_generation,
705            route_generation: record.route_generation,
706        })
707    }
708
709    async fn best_peer_snapshot_for_lookup(
710        &self,
711        id: &str,
712        seed: Option<&crate::connection_manager::PeerSnapshot>,
713    ) -> Option<crate::connection_manager::PeerSnapshot> {
714        let mut aliases = std::collections::HashSet::new();
715        if let Some(alias) = normalize_lookup_id(Some(id)) {
716            aliases.insert(alias);
717        }
718        if let Some(seed) = seed {
719            aliases.extend(peer_snapshot_lookup_aliases(seed));
720        }
721        if aliases.is_empty() {
722            return None;
723        }
724
725        self.connection_manager
726            .list_peer_snapshots()
727            .await
728            .into_iter()
729            .filter(|snapshot| peer_snapshot_matches_any_alias(snapshot, &aliases))
730            .fold(None, |current, candidate| {
731                Some(preferred_peer_snapshot(current, candidate))
732            })
733    }
734
735    async fn resolved_peer_snapshot_for_lookup(
736        &self,
737        id: &str,
738    ) -> Option<crate::connection_manager::PeerSnapshot> {
739        let snapshot = self.connection_manager.peer_snapshot(id).await;
740        self.best_peer_snapshot_for_lookup(id, snapshot.as_ref())
741            .await
742            .or(snapshot)
743    }
744
745    #[cfg(test)]
746    pub(crate) fn session_admission_block_reason(
747        &self,
748        connection_id: &str,
749    ) -> Option<(bool, String)> {
750        let block = self.session_admission_block_reason_for_transport(connection_id, None);
751        if matches!(
752            block.as_ref(),
753            Some((false, reason)) if reason == "application-crypto-confirmation-pending"
754        ) && self.connection_application_crypto_has_bound_confirmation(connection_id)
755        {
756            None
757        } else {
758            block
759        }
760    }
761
762    pub(crate) fn session_admission_block_reason_for_transport(
763        &self,
764        connection_id: &str,
765        transport_stable_id: Option<u64>,
766    ) -> Option<(bool, String)> {
767        #[cfg(target_arch = "wasm32")]
768        let _ = transport_stable_id;
769
770        if self.session_registry_active() {
771            match self.session_admission(connection_id) {
772                crate::session_token::SessionAdmission::Accepted { .. } => {
773                    #[cfg(not(target_arch = "wasm32"))]
774                    if !self.native_admission_route_is_ready_for_transport(
775                        connection_id,
776                        transport_stable_id,
777                    ) {
778                        if Self::admission_trace_enabled() {
779                            let diagnostic = transport_stable_id
780                                .map(|stable_id| {
781                                    self.native_application_stream_pending_diagnostic(
782                                        connection_id,
783                                        stable_id,
784                                    )
785                                })
786                                .unwrap_or_else(|| "transport_stable_id=missing".to_string());
787                            eprintln!(
788                                "[OpenRTC][admission-readiness] connection_id={} reason=native-main-route-pending {}",
789                                connection_id, diagnostic,
790                            );
791                        }
792                        return Some((false, "native-main-route-pending".to_string()));
793                    }
794                }
795                crate::session_token::SessionAdmission::Pending => {
796                    return Some((false, "session-admission-pending".to_string()));
797                }
798                crate::session_token::SessionAdmission::Rejected { reason } => {
799                    return Some((true, reason));
800                }
801            }
802        }
803
804        if self.connection_requires_application_crypto(connection_id)
805            && self
806                .application_crypto_key_for_connection(Some(connection_id))
807                .is_none()
808        {
809            return Some((false, "application-crypto-key-pending".to_string()));
810        }
811
812        if self.connection_requires_application_crypto_confirmation(connection_id)
813            && !self.connection_application_crypto_is_confirmed(connection_id, transport_stable_id)
814        {
815            return Some((false, "application-crypto-confirmation-pending".to_string()));
816        }
817
818        None
819    }
820
821    fn apply_peer_session_admission_guard(
822        &self,
823        mut snapshot: PeerSessionSnapshot,
824    ) -> PeerSessionSnapshot {
825        // Admission is the authorization authority. The connection manager
826        // normally stores the same scope for projection, but an incoming token
827        // may be accepted just before its connection record is materialized.
828        // Rehydrate only from the active logical connection so candidate or
829        // retired generations cannot leak a capability into this snapshot.
830        if let Some(connection_id) = snapshot.active_connection_id.as_deref() {
831            let admitted = self.session_token_registry.admitted_scopes(connection_id);
832            snapshot
833                .scopes
834                .retain(|scope| !scope.starts_with("ticket:") || admitted.contains(scope));
835            for scope in admitted {
836                if !scope.trim().is_empty() && !snapshot.scopes.contains(&scope) {
837                    snapshot.scopes.push(scope);
838                    snapshot.scopes.sort();
839                    snapshot.scopes.dedup();
840                }
841            }
842        }
843
844        let connection_id = snapshot
845            .active_connection_id
846            .clone()
847            .or_else(|| snapshot.candidate_connection_ids.first().cloned());
848        let Some(connection_id) = connection_id else {
849            return snapshot;
850        };
851        let Some((rejected, reason)) = self.session_admission_block_reason_for_transport(
852            &connection_id,
853            snapshot.active_transport_stable_id,
854        ) else {
855            return snapshot;
856        };
857
858        snapshot.settled_ready = false;
859        snapshot.replacement_pending = false;
860        snapshot.readiness_reason = reason.clone();
861
862        if rejected {
863            snapshot.status = crate::connection_manager::ConnectionState::Failed;
864            snapshot.readiness_state = ReadinessState::Failed;
865            snapshot.health = crate::connection_manager::ConnectionHealth::Stale;
866            snapshot.error = Some(reason);
867        } else {
868            if matches!(
869                snapshot.status,
870                crate::connection_manager::ConnectionState::Connected
871            ) {
872                snapshot.status = crate::connection_manager::ConnectionState::Connecting;
873            }
874            snapshot.readiness_state = ReadinessState::Settling;
875        }
876
877        snapshot
878    }
879
880    fn apply_connection_state_admission_guard(&self, mut snapshot: StateSnapshot) -> StateSnapshot {
881        if matches!(
882            snapshot.state.as_str(),
883            "closed" | "failed" | "disconnected"
884        ) {
885            // Admission can restrict only an active logical session. Terminal
886            // lifecycle authority has already made this snapshot non-routable;
887            // projecting a now-missing crypto/admission proof as `connecting`
888            // would visibly resurrect a manually closed peer.
889            return snapshot;
890        }
891        let Some((rejected, reason)) = self.session_admission_block_reason_for_transport(
892            &snapshot.connection_id,
893            snapshot.active_transport_stable_id,
894        ) else {
895            return snapshot;
896        };
897
898        snapshot.routable = false;
899        snapshot.replacement_in_progress = false;
900        snapshot.readiness_reason = reason.clone();
901
902        if rejected {
903            snapshot.state = "failed".to_string();
904            snapshot.protocol_state = "admission-rejected".to_string();
905            snapshot.readiness_state = ReadinessState::Failed;
906            snapshot.error = Some(reason);
907        } else {
908            snapshot.state = "connecting".to_string();
909            snapshot.protocol_state = "awaiting-admission".to_string();
910            snapshot.readiness_state = ReadinessState::Settling;
911        }
912
913        snapshot
914    }
915
916    fn apply_device_status_admission_guard(
917        &self,
918        mut snapshot: DeviceStatusSnapshot,
919    ) -> DeviceStatusSnapshot {
920        if matches!(
921            snapshot.connection_status,
922            ConnectionStatus::Disconnected | ConnectionStatus::Failed | ConnectionStatus::Closed
923        ) {
924            // Keep the device projection aligned with the terminal Rust
925            // lifecycle record. Missing admission after teardown is expected,
926            // not evidence that a new connection attempt exists.
927            return snapshot;
928        }
929        let Some(connection_id) = snapshot.connection_id.clone() else {
930            return snapshot;
931        };
932        let Some((rejected, reason)) = self.session_admission_block_reason_for_transport(
933            &connection_id,
934            snapshot.active_transport_stable_id,
935        ) else {
936            return snapshot;
937        };
938
939        snapshot.settled_ready = false;
940        snapshot.readiness_reason = reason.clone();
941
942        if rejected {
943            snapshot.connection_status = ConnectionStatus::Failed;
944            snapshot.readiness_state = ReadinessState::Failed;
945            snapshot.peer_health = crate::connection_manager::ConnectionHealth::Stale;
946        } else {
947            snapshot.connection_status = ConnectionStatus::Connecting;
948            snapshot.readiness_state = ReadinessState::Settling;
949        }
950
951        snapshot
952    }
953
954    pub(crate) async fn resolve_settled_peer_endpoint(
955        &self,
956        id: &str,
957        timeout_ms: Option<u64>,
958    ) -> anyhow::Result<(PeerSessionSnapshot, iroh::EndpointId)> {
959        let timeout_ms = timeout_ms.unwrap_or(10_000);
960
961        loop {
962            let snapshot = self
963                .wait_for_peer(id, Some(timeout_ms))
964                .await
965                .ok_or_else(|| anyhow::anyhow!("peer {} not found", id))?;
966
967            if snapshot.settled_ready && !Self::peer_session_is_stably_settled(&snapshot) {
968                anyhow::bail!(
969                    "peer-stream-route-pending: peer {id} generation has not remained settled long enough for protocol traffic"
970                );
971            }
972
973            if !snapshot.settled_ready {
974                #[cfg(not(target_arch = "wasm32"))]
975                let native_route_diagnostic = snapshot
976                    .active_connection_id
977                    .as_deref()
978                    .zip(snapshot.active_transport_stable_id)
979                    .map(|(connection_id, transport_stable_id)| {
980                        self.native_application_stream_pending_diagnostic(
981                            connection_id,
982                            transport_stable_id,
983                        )
984                    })
985                    .unwrap_or_else(|| "unavailable".to_string());
986                #[cfg(target_arch = "wasm32")]
987                let native_route_diagnostic = "wasm-runtime".to_string();
988                return Err(anyhow::anyhow!(
989                    "peer {} is not settled for protocol traffic: status={:?} readiness={:?} reason={} transport_stable_id={:?} route=[{}]",
990                    id,
991                    snapshot.status,
992                    snapshot.readiness_state,
993                    snapshot.readiness_reason,
994                    snapshot.active_transport_stable_id,
995                    native_route_diagnostic,
996                ));
997            }
998
999            let node_id = snapshot
1000                .node_id
1001                .as_deref()
1002                .map(str::trim)
1003                .filter(|value| !value.is_empty())
1004                .ok_or_else(|| anyhow::anyhow!("peer {} is missing a remote node id", id))?;
1005
1006            let endpoint_id = node_id.parse::<iroh::EndpointId>().map_err(|error| {
1007                anyhow::anyhow!("peer {} has invalid remote node id: {}", id, error)
1008            })?;
1009
1010            if !self.is_connection_transport_alive(endpoint_id).await {
1011                return Err(anyhow::anyhow!(
1012                    "peer {} has no active transport for protocol traffic",
1013                    id
1014                ));
1015            }
1016
1017            return Ok((snapshot, endpoint_id));
1018        }
1019    }
1020
1021    pub(crate) async fn wait_for_peer_transport_replacement(
1022        &self,
1023        id: &str,
1024        prior_connection_id: &str,
1025        prior_endpoint_id: iroh::EndpointId,
1026        prior_transport_stable_id: u64,
1027        timeout_ms: u64,
1028    ) -> Option<(PeerSessionSnapshot, iroh::EndpointId)> {
1029        #[cfg(not(target_arch = "wasm32"))]
1030        let started = std::time::Instant::now();
1031        #[cfg(target_arch = "wasm32")]
1032        let started_ms = js_sys::Date::now();
1033
1034        loop {
1035            if let Some(snapshot) = self.peer_session(id).await {
1036                let endpoint_id = snapshot
1037                    .node_id
1038                    .as_deref()
1039                    .map(str::trim)
1040                    .filter(|value| !value.is_empty())
1041                    .and_then(|value| value.parse::<iroh::EndpointId>().ok());
1042                let generation_changed = snapshot.active_connection_id.as_deref()
1043                    != Some(prior_connection_id)
1044                    || snapshot.active_transport_stable_id != Some(prior_transport_stable_id)
1045                    || endpoint_id.is_some_and(|value| value != prior_endpoint_id);
1046
1047                if generation_changed && Self::peer_session_is_stably_settled(&snapshot) {
1048                    if let Some(endpoint_id) = endpoint_id {
1049                        if self.is_connection_transport_alive(endpoint_id).await {
1050                            return Some((snapshot, endpoint_id));
1051                        }
1052                    }
1053                }
1054            }
1055
1056            #[cfg(not(target_arch = "wasm32"))]
1057            let timed_out = started.elapsed().as_millis() >= timeout_ms as u128;
1058            #[cfg(target_arch = "wasm32")]
1059            let timed_out = (js_sys::Date::now() - started_ms) >= timeout_ms as f64;
1060            if timed_out {
1061                return None;
1062            }
1063
1064            #[cfg(not(target_arch = "wasm32"))]
1065            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1066            #[cfg(target_arch = "wasm32")]
1067            gloo_timers::future::sleep(std::time::Duration::from_millis(50)).await;
1068        }
1069    }
1070
1071    async fn resolve_diagnostic_peer_endpoint(
1072        &self,
1073        id: &str,
1074        timeout_ms: Option<u64>,
1075    ) -> anyhow::Result<(PeerSessionSnapshot, iroh::EndpointId)> {
1076        let timeout_ms = timeout_ms.unwrap_or(10_000);
1077        #[cfg(not(target_arch = "wasm32"))]
1078        let started = std::time::Instant::now();
1079        #[cfg(target_arch = "wasm32")]
1080        let started_ms = js_sys::Date::now();
1081
1082        loop {
1083            let snapshot = self
1084                .peer_session(id)
1085                .await
1086                .ok_or_else(|| anyhow::anyhow!("peer {} not found", id))?;
1087            let connection_id = snapshot
1088                .active_connection_id
1089                .as_deref()
1090                .map(str::trim)
1091                .filter(|value| !value.is_empty())
1092                .ok_or_else(|| anyhow::anyhow!("peer {} is missing an active connection", id))?;
1093
1094            let readiness_block = self.session_admission_block_reason_for_transport(
1095                connection_id,
1096                snapshot.active_transport_stable_id,
1097            );
1098            if let Some((true, reason)) = readiness_block.as_ref() {
1099                return Err(anyhow::anyhow!(
1100                    "peer {} is not admitted for diagnostic traffic: {}",
1101                    id,
1102                    reason
1103                ));
1104            }
1105
1106            let node_id = snapshot
1107                .node_id
1108                .as_deref()
1109                .map(str::trim)
1110                .filter(|value| !value.is_empty())
1111                .ok_or_else(|| anyhow::anyhow!("peer {} is missing a remote node id", id))?;
1112            let endpoint_id = node_id.parse::<iroh::EndpointId>().map_err(|error| {
1113                anyhow::anyhow!("peer {} has invalid remote node id: {}", id, error)
1114            })?;
1115
1116            if readiness_block.is_none() && self.is_connection_transport_alive(endpoint_id).await {
1117                return Ok((snapshot, endpoint_id));
1118            }
1119
1120            #[cfg(not(target_arch = "wasm32"))]
1121            let timed_out = started.elapsed().as_millis() >= timeout_ms as u128;
1122            #[cfg(target_arch = "wasm32")]
1123            let timed_out = (js_sys::Date::now() - started_ms) >= timeout_ms as f64;
1124            if timed_out {
1125                if let Some((_rejected, reason)) = readiness_block {
1126                    return Err(anyhow::anyhow!(
1127                        "peer {} did not become ready for diagnostic traffic: {}",
1128                        id,
1129                        reason
1130                    ));
1131                }
1132                return Err(anyhow::anyhow!(
1133                    "peer {} has no active transport for diagnostic traffic",
1134                    id
1135                ));
1136            }
1137
1138            #[cfg(not(target_arch = "wasm32"))]
1139            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1140            #[cfg(target_arch = "wasm32")]
1141            gloo_timers::future::sleep(std::time::Duration::from_millis(50)).await;
1142        }
1143    }
1144
1145    pub async fn add_peer_scope(&self, id: &str, scope: &str) -> Vec<String> {
1146        self.connection_manager.add_scope(id, scope).await
1147    }
1148
1149    pub async fn release_peer_scope(&self, id: &str, scope: Option<&str>) -> Vec<String> {
1150        self.connection_manager.release_scope(id, scope).await
1151    }
1152
1153    pub async fn peer_scopes(&self, id: &str) -> Vec<String> {
1154        self.connection_manager.get_scopes(id).await
1155    }
1156
1157    pub async fn same_peer(&self, left: &str, right: &str) -> bool {
1158        self.connection_manager.are_same_peer(left, right).await
1159    }
1160
1161    pub async fn peer_snapshot(&self, id: &str) -> Option<crate::connection_manager::PeerSnapshot> {
1162        self.resolved_peer_snapshot_for_lookup(id).await
1163    }
1164
1165    // U9: the public plural `peer_snapshots()` view was retired in favor of the
1166    // settled `peer_sessions()` view below. The underlying primitive
1167    // `connection_manager.list_peer_snapshots()` remains (it feeds `peer_sessions`
1168    // and the device-status merge); only the redundant pass-through is removed.
1169
1170    /// Peer-session view over the Rust-owned connection manager.
1171    ///
1172    /// Delegation rule:
1173    /// - Rust owns peer-session truth and settled readiness
1174    /// - host wrappers may forward or normalize this snapshot shape
1175    /// - app code and TS wrappers must not reinterpret transport records into a
1176    ///   second peer/session model
1177    pub async fn peer_session(&self, id: &str) -> Option<PeerSessionSnapshot> {
1178        self.resolved_peer_snapshot_for_lookup(id)
1179            .await
1180            .map(peer_session_snapshot_from_peer)
1181            .map(|snapshot| self.apply_peer_session_admission_guard(snapshot))
1182    }
1183
1184    pub async fn peer_sessions(&self) -> Vec<PeerSessionSnapshot> {
1185        let snapshots: Vec<PeerSessionSnapshot> = self
1186            .connection_manager
1187            .list_peer_snapshots()
1188            .await
1189            .into_iter()
1190            .map(peer_session_snapshot_from_peer)
1191            .map(|snapshot| self.apply_peer_session_admission_guard(snapshot))
1192            .collect();
1193
1194        #[cfg(target_arch = "wasm32")]
1195        if snapshots.is_empty() {
1196            let active_endpoints = {
1197                let node_guard = self.iroh_node.read().await;
1198                if let Some(node) = node_guard.as_ref() {
1199                    node.active_endpoint_ids().await.len()
1200                } else {
1201                    0
1202                }
1203            };
1204            if active_endpoints > 0 {
1205                let now_ms = js_sys::Date::now() as u64;
1206                let previous_ms = self
1207                    .last_empty_peer_sessions_warning_ms
1208                    .load(std::sync::atomic::Ordering::Relaxed);
1209                if now_ms.saturating_sub(previous_ms)
1210                    >= Self::EMPTY_PEER_SESSIONS_WARNING_INTERVAL_MS
1211                {
1212                    self.last_empty_peer_sessions_warning_ms
1213                        .store(now_ms, std::sync::atomic::Ordering::Relaxed);
1214                    let record_count = self.connection_manager.list_all().await.len();
1215                    web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
1216                        "[pluto-rtc][peer-sessions][wasm] empty snapshot list active_endpoints={} record_count={} note=expected briefly during token-first connects before the main stream is registered; live-transports-may-belong-to-another-client-instance-until-adopted",
1217                        active_endpoints, record_count
1218                    )));
1219                }
1220            }
1221        }
1222
1223        snapshots
1224    }
1225
1226    pub async fn connection_state(&self, connection_id: &str) -> Option<StateSnapshot> {
1227        let record = self
1228            .connection_manager
1229            .get_by_connection_id(connection_id)
1230            .await?;
1231        #[allow(unused_mut)]
1232        let mut peer_snapshot = self.connection_manager.peer_snapshot(connection_id).await;
1233        let mut snapshot = self.apply_connection_state_admission_guard(
1234            connection_state_snapshot_from_parts(&record, peer_snapshot.as_ref()),
1235        );
1236        self.enrich_connection_metrics(&mut snapshot).await;
1237        Some(snapshot)
1238    }
1239
1240    pub async fn connection_states(&self) -> Vec<StateSnapshot> {
1241        let records = self.connection_manager.list_active().await;
1242        let mut snapshots = Vec::with_capacity(records.len());
1243        for record in records {
1244            if let Some(snapshot) = self.connection_state(&record.connection_id).await {
1245                snapshots.push(snapshot);
1246            }
1247        }
1248        snapshots
1249    }
1250
1251    /// Local observation only: no signaling requests, heartbeat, or route mutation.
1252    /// Reuse Iroh's passive path RTT, rejecting pending/retired physical generations.
1253    pub(super) async fn enrich_connection_metrics(&self, snapshot: &mut StateSnapshot) {
1254        snapshot.latency_ms = None;
1255        snapshot.carrier = None;
1256        snapshot.metrics_sampled_at_ms = None;
1257        if !snapshot.routable || snapshot.replacement_in_progress { return; }
1258        let Some(endpoint_id) = self.resolve_iroh_endpoint_id_for_peer(&snapshot.connection_id).await else { return; };
1259        let Some(connection) = self.get_connection(endpoint_id).await else { return; };
1260        if snapshot.active_transport_stable_id.is_none() || snapshot.active_transport_stable_id != crate::transport_generation::current_for_connection(&connection) { return; }
1261        let paths = connection.paths();
1262        // Never substitute another path's sample when the selected path has none.
1263        let Some(selected) = paths.iter().find(|path| path.is_selected()) else { return; };
1264        let rtt = selected.rtt();
1265        let address = selected.remote_addr().clone();
1266        #[cfg(not(target_arch = "wasm32"))]
1267        let kind = self.classify_native_iroh_connection_path(&connection).await;
1268        #[cfg(target_arch = "wasm32")]
1269        let kind = Self::classify_wasm_iroh_connection_path(&connection);
1270        if kind == crate::client::IrohPathKind::Unknown { return; }
1271        let Some(current) = self.connection_manager.get_by_connection_id(&snapshot.connection_id).await else { return; };
1272        if crate::transport_generation::current_for_connection(&connection) != snapshot.active_transport_stable_id
1273            || !connection.paths().iter().any(|path| path.is_selected() && path.remote_addr() == &address)
1274            || current.transport_stable_id != snapshot.active_transport_stable_id
1275            || current.transport_generation != snapshot.transport_generation
1276            || current.route_generation != snapshot.route_generation
1277            || self.session_admission_block_reason_for_transport(&snapshot.connection_id, snapshot.active_transport_stable_id).is_some()
1278            || !self.connection_manager.peer_snapshot(&snapshot.connection_id).await
1279                .as_ref().is_some_and(peer_snapshot_settled_ready) { return; }
1280        snapshot.latency_ms = Some(rtt.as_millis().max(1) as u64);
1281        snapshot.carrier = Some(kind.transport_label().to_string());
1282        snapshot.metrics_sampled_at_ms = Some(now_millis_i64());
1283    }
1284
1285    #[cfg(target_arch = "wasm32")]
1286    pub async fn emit_current_wasm_connection_state(&self, connection_id: &str) {
1287        if let Some(snapshot) = self.connection_state(connection_id).await {
1288            let fingerprint = super::WasmConnectionStateFingerprint::from(&snapshot);
1289            let terminal = matches!(
1290                snapshot.state.as_str(),
1291                "closed" | "failed" | "disconnected"
1292            );
1293            let should_emit = {
1294                let mut emitted = self
1295                    .last_emitted_connection_states
1296                    .lock()
1297                    .expect("wasm connection state cache poisoned");
1298                // A deterministic connection id can survive physical recovery
1299                // and revisit a prior terminal fingerprint. Terminal authority
1300                // must still reach every browser runtime; reducers are
1301                // idempotent, while suppressing this event leaves a stale live
1302                // projection. Non-terminal refresh noise remains deduplicated.
1303                if terminal {
1304                    emitted.insert(connection_id.to_string(), fingerprint);
1305                    true
1306                } else {
1307                    match emitted.get(connection_id) {
1308                        Some(previous) if *previous == fingerprint => false,
1309                        _ => {
1310                            emitted.insert(connection_id.to_string(), fingerprint);
1311                            true
1312                        }
1313                    }
1314                }
1315            };
1316            if !should_emit {
1317                return;
1318            }
1319            emit_wasm_connection_state_event(&self.wasm_connection_state_owner_id, &snapshot);
1320        }
1321    }
1322
1323    pub async fn wait_for_peer(
1324        &self,
1325        id: &str,
1326        timeout_ms: Option<u64>,
1327    ) -> Option<PeerSessionSnapshot> {
1328        let timeout_ms = timeout_ms.unwrap_or(10_000);
1329        #[cfg(not(target_arch = "wasm32"))]
1330        let started = std::time::Instant::now();
1331        #[cfg(target_arch = "wasm32")]
1332        let started_ms = js_sys::Date::now();
1333        loop {
1334            let snapshot = self.peer_session(id).await;
1335            if let Some(snapshot_ref) = snapshot.as_ref() {
1336                if Self::peer_session_is_stably_settled(snapshot_ref) {
1337                    return snapshot;
1338                }
1339            }
1340            #[cfg(not(target_arch = "wasm32"))]
1341            let timed_out = started.elapsed().as_millis() >= timeout_ms as u128;
1342            #[cfg(target_arch = "wasm32")]
1343            let timed_out = (js_sys::Date::now() - started_ms) >= timeout_ms as f64;
1344            if timed_out {
1345                return snapshot;
1346            }
1347            #[cfg(not(target_arch = "wasm32"))]
1348            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1349            #[cfg(target_arch = "wasm32")]
1350            gloo_timers::future::sleep(std::time::Duration::from_millis(50)).await;
1351        }
1352    }
1353
1354    /// Wait for the single authoritative peer session admitted for an exact
1355    /// application scope.
1356    ///
1357    /// This is the scope-oriented counterpart to [`Self::wait_for_peer`]
1358    /// for transient capabilities that intentionally do not have a durable
1359    /// device alias (for example, a one-time drive grant). Matching is exact,
1360    /// and multiple live sessions carrying the same scope fail closed rather
1361    /// than choosing an arbitrary route.
1362    pub async fn wait_for_settled_scope(
1363        &self,
1364        scope: &str,
1365        timeout_ms: Option<u64>,
1366    ) -> Option<PeerSessionSnapshot> {
1367        let scope = scope.trim();
1368        if scope.is_empty() {
1369            return None;
1370        }
1371
1372        let timeout_ms = timeout_ms.unwrap_or(10_000);
1373        #[cfg(not(target_arch = "wasm32"))]
1374        let started = std::time::Instant::now();
1375        #[cfg(target_arch = "wasm32")]
1376        let started_ms = js_sys::Date::now();
1377        loop {
1378            let mut matches = self
1379                .peer_sessions()
1380                .await
1381                .into_iter()
1382                .filter(|snapshot| snapshot.scopes.iter().any(|candidate| candidate == scope));
1383            let snapshot = matches.next();
1384            let snapshot = if matches.next().is_none() {
1385                snapshot
1386            } else {
1387                None
1388            };
1389
1390            if let Some(snapshot_ref) = snapshot.as_ref() {
1391                if Self::peer_session_is_stably_settled(snapshot_ref) {
1392                    return snapshot;
1393                }
1394            }
1395
1396            #[cfg(not(target_arch = "wasm32"))]
1397            let timed_out = started.elapsed().as_millis() >= timeout_ms as u128;
1398            #[cfg(target_arch = "wasm32")]
1399            let timed_out = (js_sys::Date::now() - started_ms) >= timeout_ms as f64;
1400            if timed_out {
1401                return snapshot.filter(Self::peer_session_is_stably_settled);
1402            }
1403            #[cfg(not(target_arch = "wasm32"))]
1404            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1405            #[cfg(target_arch = "wasm32")]
1406            gloo_timers::future::sleep(std::time::Duration::from_millis(50)).await;
1407        }
1408    }
1409
1410    fn peer_session_is_stably_settled(snapshot: &PeerSessionSnapshot) -> bool {
1411        if !snapshot.settled_ready {
1412            return false;
1413        }
1414        if snapshot.candidate_connection_ids.is_empty()
1415            && snapshot.transport_generation <= 1
1416            && snapshot.route_generation <= 1
1417        {
1418            return true;
1419        }
1420
1421        let stable_deadline_ms = snapshot
1422            .last_lifecycle_transition_at_ms
1423            .saturating_add(Self::SETTLED_PEER_STABILITY_WINDOW_MS as i64);
1424        #[cfg(not(target_arch = "wasm32"))]
1425        {
1426            now_millis_i64() >= stable_deadline_ms
1427        }
1428        #[cfg(target_arch = "wasm32")]
1429        {
1430            js_sys::Date::now() as i64 >= stable_deadline_ms
1431        }
1432    }
1433
1434    pub(crate) fn peer_stream_generation_matches(
1435        expected: &PeerSessionSnapshot,
1436        current: &PeerSessionSnapshot,
1437        opened_transport_stable_id: u64,
1438    ) -> bool {
1439        Self::peer_session_is_stably_settled(current)
1440            && current.active_connection_id == expected.active_connection_id
1441            && current.node_id == expected.node_id
1442            && current.active_transport_stable_id == Some(opened_transport_stable_id)
1443            && current.transport_generation == expected.transport_generation
1444            && current.route_generation == expected.route_generation
1445    }
1446
1447    pub(crate) async fn confirm_managed_connection_readiness(&self, connection_id: &str) -> bool {
1448        let healthy = self.probe_peer_health(connection_id).await;
1449        #[cfg(not(target_arch = "wasm32"))]
1450        self.emit_current_native_connection_state(connection_id)
1451            .await;
1452        #[cfg(target_arch = "wasm32")]
1453        self.emit_current_wasm_connection_state(connection_id).await;
1454        healthy
1455    }
1456
1457    pub(crate) async fn confirm_managed_connection_readiness_from_transport_proof(
1458        &self,
1459        connection_id: &str,
1460        expected_transport_stable_id: u64,
1461    ) -> bool {
1462        let confirmed = self
1463            .connection_manager
1464            .set_health_if_current(
1465                connection_id,
1466                expected_transport_stable_id,
1467                crate::connection_manager::ConnectionHealth::Healthy,
1468            )
1469            .await
1470            .is_some();
1471        #[cfg(not(target_arch = "wasm32"))]
1472        self.emit_current_native_connection_state(connection_id)
1473            .await;
1474        #[cfg(target_arch = "wasm32")]
1475        self.emit_current_wasm_connection_state(connection_id).await;
1476        confirmed
1477    }
1478
1479    pub async fn open_peer_bi(
1480        &self,
1481        id: &str,
1482        timeout_ms: Option<u64>,
1483    ) -> anyhow::Result<(
1484        Option<String>,
1485        String,
1486        crate::application_crypto_streams::PeerSendStream,
1487        crate::application_crypto_streams::PeerRecvStream,
1488    )> {
1489        let (snapshot, endpoint_id, send, recv) = self.open_peer_bi_current(id, timeout_ms).await?;
1490        Ok((
1491            snapshot.active_connection_id,
1492            endpoint_id.to_string(),
1493            send,
1494            recv,
1495        ))
1496    }
1497
1498    pub(crate) async fn open_peer_bi_current(
1499        &self,
1500        id: &str,
1501        timeout_ms: Option<u64>,
1502    ) -> anyhow::Result<(
1503        PeerSessionSnapshot,
1504        iroh::EndpointId,
1505        crate::application_crypto_streams::PeerSendStream,
1506        crate::application_crypto_streams::PeerRecvStream,
1507    )> {
1508        let (snapshot, endpoint_id) = self.resolve_settled_peer_endpoint(id, timeout_ms).await?;
1509        self.ensure_connection_manager_record_before_peer_stream(&endpoint_id)
1510            .await?;
1511        let (send, recv) = self
1512            .open_generation_bound_peer_bi(
1513                id,
1514                &snapshot,
1515                endpoint_id,
1516                timeout_ms.map(std::time::Duration::from_millis),
1517                "outgoing bidirectional application stream",
1518            )
1519            .await?;
1520        Ok((snapshot, endpoint_id, send, recv))
1521    }
1522
1523    async fn open_generation_bound_peer_bi(
1524        &self,
1525        id: &str,
1526        expected: &PeerSessionSnapshot,
1527        endpoint_id: iroh::EndpointId,
1528        timeout: Option<std::time::Duration>,
1529        operation: &str,
1530    ) -> anyhow::Result<(
1531        crate::application_crypto_streams::PeerSendStream,
1532        crate::application_crypto_streams::PeerRecvStream,
1533    )> {
1534        let expected_connection_id = expected.active_connection_id.as_deref().ok_or_else(|| {
1535            anyhow::anyhow!("peer-stream-route-pending: peer {id} has no active connection")
1536        })?;
1537        let expected_transport_stable_id =
1538            expected.active_transport_stable_id.ok_or_else(|| {
1539                anyhow::anyhow!(
1540                    "peer-stream-route-pending: peer {id} has no active transport generation"
1541                )
1542            })?;
1543
1544        #[cfg(not(target_arch = "wasm32"))]
1545        let opened = if let Some(timeout) = timeout {
1546            tokio::time::timeout(
1547                timeout,
1548                self.open_current_bi_internal_with_transport_stable_id(endpoint_id),
1549            )
1550            .await
1551            .map_err(|_| {
1552                anyhow::anyhow!(
1553                    "peer-stream-route-pending: stream open timed out after {}ms for peer {id}",
1554                    timeout.as_millis(),
1555                )
1556            })?
1557        } else {
1558            self.open_current_bi_internal_with_transport_stable_id(endpoint_id)
1559                .await
1560        };
1561        #[cfg(target_arch = "wasm32")]
1562        let opened = {
1563            let _ = timeout;
1564            self.open_current_bi_internal_with_transport_stable_id(endpoint_id)
1565                .await
1566        };
1567        let (opened_transport_stable_id, send, recv) = opened.map_err(|error| {
1568            anyhow::anyhow!("peer-stream-route-pending: peer {id} has no current route: {error}")
1569        })?;
1570        if opened_transport_stable_id != expected_transport_stable_id {
1571            anyhow::bail!(
1572                "peer-stream-generation-stale: peer {id} physical generation changed while opening a protected stream"
1573            );
1574        }
1575
1576        let key = self
1577            .required_crypto_key(Some(expected_connection_id), &endpoint_id, operation)
1578            .await?;
1579        let wrapped =
1580            self.wrap_application_streams(expected_connection_id, key, send, recv, &[])?;
1581
1582        let current = self.peer_session(id).await;
1583        let current_physical_stable_id = self
1584            .get_connection(endpoint_id)
1585            .await
1586            .map(|connection| crate::transport_generation::for_connection(&connection));
1587        let generation_is_current = current.as_ref().is_some_and(|current| {
1588            Self::peer_stream_generation_matches(expected, current, opened_transport_stable_id)
1589        });
1590        if current_physical_stable_id != Some(opened_transport_stable_id) || !generation_is_current
1591        {
1592            anyhow::bail!(
1593                "peer-stream-generation-stale: peer {id} changed generation while opening a protected stream"
1594            );
1595        }
1596
1597        Ok(wrapped)
1598    }
1599
1600    /// Open an application-protected bidirectional stream to a settled peer.
1601    ///
1602    /// Unlike [`Self::open_peer_bi`], this method requires a reciprocal
1603    /// application-crypto handshake even when the admitted peer did not
1604    /// originally request application protection. The active OpenRTC
1605    /// connection generation owns the negotiation and confirmation; callers
1606    /// never install keys or retry control frames themselves.
1607    #[cfg(not(target_arch = "wasm32"))]
1608    pub async fn open_peer_protected_bi(
1609        &self,
1610        id: &str,
1611        timeout_ms: Option<u64>,
1612    ) -> anyhow::Result<(
1613        Option<String>,
1614        String,
1615        crate::application_crypto_streams::PeerSendStream,
1616        crate::application_crypto_streams::PeerRecvStream,
1617    )> {
1618        let timeout_ms = timeout_ms.unwrap_or(10_000);
1619        let (snapshot, endpoint_id) = self
1620            .resolve_settled_peer_endpoint(id, Some(timeout_ms))
1621            .await?;
1622        let connection_id = snapshot
1623            .active_connection_id
1624            .as_deref()
1625            .map(str::trim)
1626            .filter(|value| !value.is_empty())
1627            .ok_or_else(|| anyhow::anyhow!("peer {} is missing an active connection", id))?;
1628        let transport_stable_id = snapshot.active_transport_stable_id;
1629
1630        self.ensure_connection_manager_record_before_peer_stream(&endpoint_id)
1631            .await?;
1632        self.set_connection_application_crypto_required(connection_id);
1633        if self
1634            .application_crypto_key_for_connection(Some(connection_id))
1635            .is_none()
1636            || !self.connection_application_crypto_is_confirmed(connection_id, transport_stable_id)
1637        {
1638            let public_key = self
1639                .get_or_create_connection_key_agreement(connection_id)
1640                .map_err(|error| {
1641                    anyhow::anyhow!(
1642                        "application crypto key agreement initialization failed for peer {}: {:?}",
1643                        id,
1644                        error
1645                    )
1646                })?
1647                .public_key_bytes();
1648            self.send_typescript_capability_update(
1649                connection_id,
1650                "protected-application-stream",
1651                Some(public_key),
1652            )
1653            .await?;
1654        }
1655
1656        let started = std::time::Instant::now();
1657        let endpoint_id_text = endpoint_id.to_string();
1658        loop {
1659            let current = self
1660                .peer_session(id)
1661                .await
1662                .ok_or_else(|| anyhow::anyhow!("peer {} disappeared during key agreement", id))?;
1663            let generation_is_current = current.active_connection_id.as_deref()
1664                == Some(connection_id)
1665                && current.node_id.as_deref() == Some(endpoint_id_text.as_str());
1666            if !generation_is_current {
1667                anyhow::bail!(
1668                    "peer {} connection generation changed during application crypto key agreement",
1669                    id
1670                );
1671            }
1672            if self
1673                .application_crypto_key_for_connection(Some(connection_id))
1674                .is_some()
1675                && self.connection_application_crypto_is_confirmed(
1676                    connection_id,
1677                    current.active_transport_stable_id,
1678                )
1679            {
1680                break;
1681            }
1682            if started.elapsed().as_millis() >= timeout_ms as u128 {
1683                anyhow::bail!(
1684                    "peer {} application crypto key agreement timed out after {}ms",
1685                    id,
1686                    timeout_ms
1687                );
1688            }
1689            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1690        }
1691
1692        let (send, recv) = self
1693            .open_generation_bound_peer_bi(
1694                id,
1695                &snapshot,
1696                endpoint_id,
1697                Some(std::time::Duration::from_millis(
1698                    timeout_ms
1699                        .saturating_sub(
1700                            started
1701                                .elapsed()
1702                                .as_millis()
1703                                .try_into()
1704                                .unwrap_or(timeout_ms),
1705                        )
1706                        .max(1),
1707                )),
1708                "outgoing protected bidirectional application stream",
1709            )
1710            .await?;
1711        Ok((
1712            Some(connection_id.to_string()),
1713            endpoint_id.to_string(),
1714            send,
1715            recv,
1716        ))
1717    }
1718
1719    /// Send one complete application frame on a fresh settled peer stream.
1720    ///
1721    /// The runtime owns `finish()` so WASM/JS stream cancellation cannot reset
1722    /// the QUIC send half before the remote intake observes the payload.
1723    pub async fn send_peer_application_frame(
1724        &self,
1725        id: &str,
1726        frame: &[u8],
1727        timeout_ms: Option<u64>,
1728    ) -> anyhow::Result<()> {
1729        let (_connection_id, _remote_node_id, mut send, _recv) =
1730            self.open_peer_bi(id, timeout_ms).await?;
1731        let envelope = crate::stream_metadata::encode_envelope(
1732            crate::stream_metadata::DEFAULT_PEER_CHANNEL_ID,
1733            None,
1734        )?;
1735        send.write_all(&envelope).await?;
1736        send.write_all(frame).await?;
1737        send.finish_and_wait_for_peer(std::time::Duration::from_secs(2))
1738            .await?;
1739        Ok(())
1740    }
1741
1742    /// Open a bidirectional stream for the explicit-file protocol.
1743    ///
1744    /// Explicit transfers reserve protocol byte `0x02` inside the managed
1745    /// application-crypto stream. SDK admission control is the only plaintext
1746    /// framing allowed on an admitted Iroh connection; product protocol bytes
1747    /// must pass through the same protection boundary as their payload.
1748    pub async fn open_peer_bi_explicit_file_sender(
1749        &self,
1750        id: &str,
1751        timeout_ms: Option<u64>,
1752    ) -> anyhow::Result<(
1753        Option<String>,
1754        String,
1755        crate::application_crypto_streams::PeerSendStream,
1756    )> {
1757        let (connection_id, remote_node_id, send, _recv) = self
1758            .open_peer_bi_explicit_file_streams(id, timeout_ms)
1759            .await?;
1760        Ok((connection_id, remote_node_id, send))
1761    }
1762
1763    /// Open the native explicit-file stream with its receiver-acknowledgement
1764    /// half. Product protocols use this when completion means the remote peer
1765    /// has durably accepted the payload, not merely that local writes finished.
1766    #[cfg(not(target_arch = "wasm32"))]
1767    pub async fn open_peer_bi_explicit_file(
1768        &self,
1769        id: &str,
1770        timeout_ms: Option<u64>,
1771    ) -> anyhow::Result<(
1772        Option<String>,
1773        String,
1774        crate::application_crypto_streams::PeerSendStream,
1775        crate::application_crypto_streams::PeerRecvStream,
1776    )> {
1777        self.open_peer_bi_explicit_file_streams(id, timeout_ms)
1778            .await
1779    }
1780
1781    async fn open_peer_bi_explicit_file_streams(
1782        &self,
1783        id: &str,
1784        timeout_ms: Option<u64>,
1785    ) -> anyhow::Result<(
1786        Option<String>,
1787        String,
1788        crate::application_crypto_streams::PeerSendStream,
1789        crate::application_crypto_streams::PeerRecvStream,
1790    )> {
1791        let (snapshot, endpoint_id) = self.resolve_settled_peer_endpoint(id, timeout_ms).await?;
1792        let remote_node_id = endpoint_id.to_string();
1793        self.ensure_connection_manager_record_before_peer_stream(&endpoint_id)
1794            .await?;
1795        let (mut send, recv) = self
1796            .open_generation_bound_peer_bi(
1797                id,
1798                &snapshot,
1799                endpoint_id,
1800                timeout_ms.map(std::time::Duration::from_millis),
1801                "outgoing explicit-file application stream",
1802            )
1803            .await?;
1804        let connection_id = snapshot.active_connection_id.clone();
1805        send.write_all(&[crate::explicit_transfer_crypto::EXPLICIT_FILE_PROTOCOL_BYTE])
1806            .await?;
1807        Ok((connection_id, remote_node_id, send, recv))
1808    }
1809
1810    /// Open an encrypted-capable bidirectional stream for diagnostics that
1811    /// participate in proving peer readiness. Normal protocol traffic should use
1812    /// `open_peer_bi`, which requires `settled_ready`; diagnostics may run once
1813    /// the transport is alive, admission is not blocked, and required E2EE keys
1814    /// are installed.
1815    pub async fn open_peer_bi_diagnostic(
1816        &self,
1817        id: &str,
1818        timeout_ms: Option<u64>,
1819    ) -> anyhow::Result<(
1820        Option<String>,
1821        String,
1822        crate::application_crypto_streams::PeerSendStream,
1823        crate::application_crypto_streams::PeerRecvStream,
1824    )> {
1825        let (snapshot, endpoint_id) = self
1826            .resolve_diagnostic_peer_endpoint(id, timeout_ms)
1827            .await?;
1828        let remote_node_id = endpoint_id.to_string();
1829        self.ensure_connection_manager_record_before_peer_stream(&endpoint_id)
1830            .await?;
1831        let (send, recv) = self
1832            .open_bi_internal_with_timeout(
1833                endpoint_id,
1834                timeout_ms.map(std::time::Duration::from_millis),
1835            )
1836            .await?;
1837        let key = self
1838            .required_crypto_key(
1839                snapshot.active_connection_id.as_deref(),
1840                &endpoint_id,
1841                "outgoing diagnostic application stream",
1842            )
1843            .await?;
1844        let connection_id = snapshot
1845            .active_connection_id
1846            .as_deref()
1847            .ok_or_else(|| anyhow::anyhow!("diagnostic peer has no active connection"))?;
1848        self.wrap_application_streams(connection_id, key, send, recv, &[])
1849            .map(|wrapped| {
1850                (
1851                    snapshot.active_connection_id,
1852                    remote_node_id,
1853                    wrapped.0,
1854                    wrapped.1,
1855                )
1856            })
1857    }
1858
1859    /// Like `open_peer_bi` but skips the `settled_ready` check — usable as soon
1860    /// as the transport is alive (e.g. for latency probes on a trusted-device peer
1861    /// that is transport-connected but may not have completed auth settlement yet).
1862    pub async fn open_peer_bi_transport_only(
1863        &self,
1864        id: &str,
1865        timeout_ms: Option<u64>,
1866    ) -> anyhow::Result<(
1867        Option<String>,
1868        String,
1869        iroh::endpoint::SendStream,
1870        iroh::endpoint::RecvStream,
1871    )> {
1872        let timeout_ms = timeout_ms.unwrap_or(10_000);
1873        #[cfg(not(target_arch = "wasm32"))]
1874        let started = std::time::Instant::now();
1875        #[cfg(target_arch = "wasm32")]
1876        let started_ms = js_sys::Date::now();
1877
1878        loop {
1879            let snapshot = self
1880                .peer_session(id)
1881                .await
1882                .ok_or_else(|| anyhow::anyhow!("peer {} not found", id))?;
1883            let connection_id = snapshot
1884                .active_connection_id
1885                .as_deref()
1886                .map(str::trim)
1887                .filter(|value| !value.is_empty())
1888                .ok_or_else(|| anyhow::anyhow!("peer {} is missing an active connection", id))?;
1889
1890            if let Some((_rejected, reason)) = self.session_admission_block_reason_for_transport(
1891                connection_id,
1892                snapshot.active_transport_stable_id,
1893            ) {
1894                return Err(anyhow::anyhow!(
1895                    "peer {} is not admitted for transport-only traffic: {}",
1896                    id,
1897                    reason
1898                ));
1899            }
1900
1901            let node_id = snapshot
1902                .node_id
1903                .as_deref()
1904                .map(str::trim)
1905                .filter(|value| !value.is_empty())
1906                .ok_or_else(|| anyhow::anyhow!("peer {} is missing a remote node id", id))?;
1907
1908            let endpoint_id = node_id.parse::<iroh::EndpointId>().map_err(|error| {
1909                anyhow::anyhow!("peer {} has invalid remote node id: {}", id, error)
1910            })?;
1911
1912            if self
1913                .application_crypto_key_for_endpoint(&endpoint_id)
1914                .await
1915                .is_some()
1916            {
1917                anyhow::bail!(
1918                    "open_peer_bi_transport_only is not allowed while application crypto is active; use open_peer_bi instead"
1919                );
1920            }
1921
1922            self.ensure_connection_manager_record_before_peer_stream(&endpoint_id)
1923                .await?;
1924            match self.open_bi_internal(endpoint_id).await {
1925                Ok((send, recv)) => {
1926                    return Ok((
1927                        snapshot.active_connection_id,
1928                        endpoint_id.to_string(),
1929                        send,
1930                        recv,
1931                    ));
1932                }
1933                Err(error) => {
1934                    #[cfg(not(target_arch = "wasm32"))]
1935                    let timed_out = started.elapsed().as_millis() >= timeout_ms as u128;
1936                    #[cfg(target_arch = "wasm32")]
1937                    let timed_out = (js_sys::Date::now() - started_ms) >= timeout_ms as f64;
1938                    if timed_out {
1939                        return Err(anyhow::anyhow!(
1940                            "peer {} transport-only open timed out after redial attempts: {}",
1941                            id,
1942                            error
1943                        ));
1944                    }
1945                }
1946            }
1947
1948            #[cfg(not(target_arch = "wasm32"))]
1949            let timed_out = started.elapsed().as_millis() >= timeout_ms as u128;
1950            #[cfg(target_arch = "wasm32")]
1951            let timed_out = (js_sys::Date::now() - started_ms) >= timeout_ms as f64;
1952            if timed_out {
1953                return Err(anyhow::anyhow!(
1954                    "peer {} transport not alive (transport-only open timed out)",
1955                    id
1956                ));
1957            }
1958
1959            #[cfg(not(target_arch = "wasm32"))]
1960            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1961            #[cfg(target_arch = "wasm32")]
1962            gloo_timers::future::sleep(std::time::Duration::from_millis(50)).await;
1963        }
1964    }
1965
1966    pub async fn open_peer_uni(
1967        &self,
1968        id: &str,
1969        timeout_ms: Option<u64>,
1970    ) -> anyhow::Result<(
1971        Option<String>,
1972        String,
1973        crate::application_crypto_streams::PeerSendStream,
1974    )> {
1975        let (snapshot, endpoint_id) = self.resolve_settled_peer_endpoint(id, timeout_ms).await?;
1976        let remote_node_id = endpoint_id.to_string();
1977        let node_guard = self.iroh_node.read().await;
1978        let node = node_guard
1979            .as_ref()
1980            .ok_or_else(|| anyhow::anyhow!("Iroh node not initialized"))?;
1981        let send = node.open_uni(endpoint_id).await?;
1982        let connection_id = snapshot.active_connection_id.clone();
1983        let key = self
1984            .required_crypto_key(
1985                snapshot.active_connection_id.as_deref(),
1986                &endpoint_id,
1987                "outgoing unidirectional application stream",
1988            )
1989            .await?;
1990        let send = match key {
1991            Some(key) => {
1992                let id = connection_id
1993                    .as_deref()
1994                    .ok_or_else(|| anyhow::anyhow!("peer has no active connection"))?;
1995                let access = self.application_stream_access(id, key)?;
1996                crate::application_crypto_streams::PeerSendStream::encrypted(send, key)
1997                    .with_access(access)
1998            }
1999            None => crate::application_crypto_streams::PeerSendStream::plain(send),
2000        };
2001        Ok((connection_id, remote_node_id, send))
2002    }
2003
2004    /// Conservative public payload ceiling that remains valid across native
2005    /// QUIC and the WebRTC/MoQ Iroh packet carriers after application crypto.
2006    pub const MAX_PEER_DATAGRAM_BYTES: usize = 1_024;
2007    const PEER_DATAGRAM_MAGIC: [u8; 4] = *b"ORD1";
2008
2009    /// Send one encrypted, unreliable application datagram on the current
2010    /// admitted logical Iroh connection. This never falls back to a stream.
2011    pub async fn send_peer_datagram(&self, id: &str, payload: &[u8]) -> anyhow::Result<()> {
2012        if payload.len() > Self::MAX_PEER_DATAGRAM_BYTES {
2013            anyhow::bail!(
2014                "peer datagram payload exceeds {} bytes",
2015                Self::MAX_PEER_DATAGRAM_BYTES
2016            );
2017        }
2018        loop {
2019            let (snapshot, endpoint_id) =
2020                self.resolve_settled_peer_endpoint(id, Some(10_000)).await?;
2021            let connection_id = snapshot
2022                .active_connection_id
2023                .as_deref()
2024                .ok_or_else(|| anyhow::anyhow!("peer {id} has no active connection"))?;
2025            let expected_stable_id = snapshot
2026                .active_transport_stable_id
2027                .ok_or_else(|| anyhow::anyhow!("peer {id} has no active transport generation"))?;
2028            if self
2029                .application_crypto_key_for_connection(Some(connection_id))
2030                .is_none()
2031            {
2032                anyhow::bail!("protected peer datagrams require confirmed application crypto");
2033            }
2034            if self.connection_requires_application_crypto_confirmation(connection_id)
2035                && !self.connection_application_crypto_is_confirmed(
2036                    connection_id,
2037                    Some(expected_stable_id),
2038                )
2039            {
2040                anyhow::bail!("protected peer datagrams require confirmed application crypto");
2041            }
2042            let protected = self.protect_outbound_application_payload(connection_id, payload)?;
2043            let mut frame = Vec::with_capacity(Self::PEER_DATAGRAM_MAGIC.len() + protected.len());
2044            frame.extend_from_slice(&Self::PEER_DATAGRAM_MAGIC);
2045            frame.extend_from_slice(&protected);
2046            let Some(connection) = self.get_connection(endpoint_id).await else {
2047                if self
2048                    .wait_for_peer_transport_replacement(
2049                        id,
2050                        connection_id,
2051                        endpoint_id,
2052                        expected_stable_id,
2053                        10_000,
2054                    )
2055                    .await
2056                    .is_some()
2057                {
2058                    continue;
2059                }
2060                anyhow::bail!("peer {id} has no live logical Iroh connection");
2061            };
2062            let stable_id = crate::transport_generation::for_connection(&connection);
2063            if stable_id != expected_stable_id {
2064                continue;
2065            }
2066            match connection
2067                .send_datagram_wait(bytes::Bytes::from(frame))
2068                .await
2069            {
2070                Ok(()) => return Ok(()),
2071                Err(error) => {
2072                    if self
2073                        .wait_for_peer_transport_replacement(
2074                            id,
2075                            connection_id,
2076                            endpoint_id,
2077                            stable_id,
2078                            10_000,
2079                        )
2080                        .await
2081                        .is_some()
2082                    {
2083                        continue;
2084                    }
2085                    return Err(anyhow::anyhow!("peer datagram send failed: {error}"));
2086                }
2087            }
2088        }
2089    }
2090
2091    /// Send one datagram only while its application freshness budget remains.
2092    /// Dropping the send future at the deadline prevents route recovery from
2093    /// turning stale unreliable data into a late reliable-style delivery.
2094    pub async fn send_peer_datagram_with_max_age(
2095        &self,
2096        id: &str,
2097        payload: &[u8],
2098        max_age_ms: u64,
2099    ) -> anyhow::Result<bool> {
2100        if max_age_ms == 0 {
2101            return Ok(false);
2102        }
2103        use futures::FutureExt;
2104        let send = self.send_peer_datagram(id, payload).fuse();
2105        #[cfg(target_arch = "wasm32")]
2106        let deadline =
2107            gloo_timers::future::sleep(std::time::Duration::from_millis(max_age_ms)).fuse();
2108        #[cfg(not(target_arch = "wasm32"))]
2109        let deadline = tokio::time::sleep(std::time::Duration::from_millis(max_age_ms)).fuse();
2110        futures::pin_mut!(send, deadline);
2111        futures::select! {
2112            result = send => result.map(|()| true),
2113            _ = deadline => Ok(false),
2114        }
2115    }
2116
2117    /// Read one encrypted application datagram from the current admitted
2118    /// generation. Physical replacement is absorbed here so public readers
2119    /// retain one logical stream and never acquire a carrier retry owner.
2120    pub async fn receive_peer_datagram(&self, id: &str) -> anyhow::Result<Vec<u8>> {
2121        loop {
2122            let (snapshot, endpoint_id) =
2123                self.resolve_settled_peer_endpoint(id, Some(10_000)).await?;
2124            let connection_id = snapshot
2125                .active_connection_id
2126                .as_deref()
2127                .ok_or_else(|| anyhow::anyhow!("peer {id} has no active connection"))?;
2128            let expected_stable_id = snapshot
2129                .active_transport_stable_id
2130                .ok_or_else(|| anyhow::anyhow!("peer {id} has no active transport generation"))?;
2131            if self
2132                .application_crypto_key_for_connection(Some(connection_id))
2133                .is_none()
2134                || (self.connection_requires_application_crypto_confirmation(connection_id)
2135                    && !self.connection_application_crypto_is_confirmed(
2136                        connection_id,
2137                        Some(expected_stable_id),
2138                    ))
2139            {
2140                anyhow::bail!("protected peer datagrams require confirmed application crypto");
2141            }
2142            let Some(connection) = self.get_connection(endpoint_id).await else {
2143                if self
2144                    .wait_for_peer_transport_replacement(
2145                        id,
2146                        connection_id,
2147                        endpoint_id,
2148                        expected_stable_id,
2149                        10_000,
2150                    )
2151                    .await
2152                    .is_some()
2153                {
2154                    continue;
2155                }
2156                anyhow::bail!("peer {id} has no live logical Iroh connection");
2157            };
2158            let stable_id = crate::transport_generation::for_connection(&connection);
2159            if stable_id != expected_stable_id {
2160                continue;
2161            }
2162            let frame = match connection.read_datagram().await {
2163                Ok(frame) => frame,
2164                Err(error) => {
2165                    let replacement = self
2166                        .wait_for_peer_transport_replacement(
2167                            id,
2168                            connection_id,
2169                            endpoint_id,
2170                            stable_id,
2171                            10_000,
2172                        )
2173                        .await
2174                        .is_some();
2175                    if replacement {
2176                        continue;
2177                    }
2178                    return Err(anyhow::anyhow!("peer datagram receive failed: {error}"));
2179                }
2180            };
2181            let current_transport = self.get_connection(endpoint_id).await.filter(|current| {
2182                crate::transport_generation::for_connection(current) == stable_id
2183            });
2184            let Ok((current_snapshot, current_endpoint_id)) =
2185                self.resolve_settled_peer_endpoint(id, Some(0)).await
2186            else {
2187                continue;
2188            };
2189            if current_transport.is_none()
2190                || current_endpoint_id != endpoint_id
2191                || current_snapshot.active_connection_id.as_deref() != Some(connection_id)
2192                || current_snapshot.active_transport_stable_id != Some(stable_id)
2193            {
2194                continue;
2195            }
2196            let Some(protected) = frame.strip_prefix(&Self::PEER_DATAGRAM_MAGIC) else {
2197                continue;
2198            };
2199            if let Ok(payload) = self.open_inbound_application_payload(connection_id, protected) {
2200                return Ok(payload);
2201            }
2202        }
2203    }
2204
2205    /// Resolve the canonical managed connection ids for any peer-facing lookup
2206    /// token (device id, device-id hint, node id, connection id).
2207    ///
2208    /// This keeps peer-identity resolution inside the Rust core so host
2209    /// wrappers can adopt/query protocol connections without rebuilding their
2210    /// own aliasing rules.
2211    pub async fn resolve_peer_connection_ids(&self, id: &str) -> Vec<String> {
2212        let mut connection_ids = Vec::new();
2213
2214        if let Some(snapshot) = self.connection_manager.peer_snapshot(id).await {
2215            connection_ids.extend(snapshot.connection_ids);
2216        }
2217
2218        if let Some(record) = self.connection_manager.get_by_connection_id(id).await {
2219            connection_ids.push(record.connection_id);
2220        }
2221
2222        if let Some((left, right)) = id.split_once('-') {
2223            for node_id in [left.trim(), right.trim()] {
2224                if node_id.is_empty() {
2225                    continue;
2226                }
2227                for record in self.connection_manager.get_by_node_id(node_id).await {
2228                    if let Some(snapshot) = self
2229                        .connection_manager
2230                        .peer_snapshot(&record.connection_id)
2231                        .await
2232                    {
2233                        connection_ids.extend(snapshot.connection_ids);
2234                    } else {
2235                        connection_ids.push(record.connection_id);
2236                    }
2237                }
2238            }
2239
2240            let all_records = self.connection_manager.list_all().await;
2241            for node_id in [left.trim(), right.trim()] {
2242                if node_id.is_empty() {
2243                    continue;
2244                }
2245                for record in all_records.iter().filter(|record| {
2246                    record
2247                        .connection_id
2248                        .split('-')
2249                        .any(|part| part.trim() == node_id)
2250                }) {
2251                    if let Some(snapshot) = self
2252                        .connection_manager
2253                        .peer_snapshot(&record.connection_id)
2254                        .await
2255                    {
2256                        connection_ids.extend(snapshot.connection_ids);
2257                    } else {
2258                        connection_ids.push(record.connection_id.clone());
2259                    }
2260                }
2261            }
2262        }
2263
2264        if connection_ids.len() > 1 {
2265            let mut unique = std::collections::HashSet::with_capacity(connection_ids.len());
2266            connection_ids.retain(|connection_id| unique.insert(connection_id.clone()));
2267        }
2268
2269        connection_ids
2270    }
2271
2272    pub async fn resolve_peer_connection_records(
2273        &self,
2274        id: &str,
2275    ) -> Vec<crate::connection_manager::ConnectionRecord> {
2276        let mut records = Vec::new();
2277        for connection_id in self.resolve_peer_connection_ids(id).await {
2278            if let Some(record) = self
2279                .connection_manager
2280                .get_by_connection_id(&connection_id)
2281                .await
2282            {
2283                records.push(record);
2284            }
2285        }
2286        records.sort_by(|left, right| right.updated_at_ms.cmp(&left.updated_at_ms));
2287        records
2288    }
2289
2290    pub async fn best_connection_record_for_peer(
2291        &self,
2292        id: &str,
2293    ) -> Option<crate::connection_manager::ConnectionRecord> {
2294        self.connection_manager.best_connection_for_peer(id).await
2295    }
2296
2297    pub async fn list_managed_connections(
2298        &self,
2299    ) -> Vec<crate::connection_manager::ConnectionRecord> {
2300        self.connection_manager.list_all().await
2301    }
2302
2303    /// Resolve the best available device-facing hint for a managed connection.
2304    ///
2305    /// Delegation rule:
2306    /// - host wrappers must not rebuild device-id hint lookup from sibling
2307    ///   records, metadata stores, or node-id scans
2308    /// - the Rust core owns peer alias resolution and is the only place that
2309    ///   may interpret node ids as transport metadata for device recovery
2310    pub async fn managed_connection_device_hint(&self, id: &str) -> Option<String> {
2311        // A canonical connection id contains both node ids separated by `-`.
2312        // Resolve its own record first so alias expansion through the shared
2313        // local node cannot borrow a newer sibling peer's device identity.
2314        if let Some(record) = self.connection_manager.get_by_connection_id(id).await {
2315            if let Some(device_id) = record
2316                .device_id
2317                .as_deref()
2318                .or(record.device_id_hint.as_deref())
2319                .map(str::trim)
2320                .filter(|value| !value.is_empty())
2321            {
2322                return Some(device_id.to_string());
2323            }
2324
2325            // The exact logical connection exists but has not acquired an
2326            // authoritative device identity yet. Do not expand its
2327            // deterministic connection id through either node component:
2328            // every sibling peer shares the local node, so such expansion can
2329            // borrow an unrelated peer's device identity.
2330            return None;
2331        }
2332
2333        self.resolve_peer_connection_records(id)
2334            .await
2335            .into_iter()
2336            .find_map(|record| {
2337                record
2338                    .device_id
2339                    .as_deref()
2340                    .map(str::trim)
2341                    .filter(|value| !value.is_empty())
2342                    .map(ToOwned::to_owned)
2343                    .or_else(|| {
2344                        record
2345                            .device_id_hint
2346                            .as_deref()
2347                            .map(str::trim)
2348                            .filter(|value| !value.is_empty())
2349                            .map(ToOwned::to_owned)
2350                    })
2351            })
2352    }
2353
2354    #[cfg(not(target_arch = "wasm32"))]
2355    pub async fn managed_connection_adoption(
2356        &self,
2357        record: &crate::connection_manager::ConnectionRecord,
2358    ) -> Option<crate::client::ConnectionAdoption> {
2359        if !matches!(
2360            record.state,
2361            crate::connection_manager::ConnectionState::Connected
2362        ) {
2363            return None;
2364        }
2365
2366        // When the session token registry is active, do not allow adoption
2367        // until the connection has been admitted (token validated or trusted
2368        // native binding resolved).  This prevents unadmitted connections
2369        // from being adopted into the host application.
2370        let admitted_scope = if self.session_registry_active() {
2371            let admission = self.session_token_registry.admission(&record.connection_id);
2372            match &admission {
2373                crate::session_token::SessionAdmission::Accepted { scope, .. } => scope.clone(),
2374                crate::session_token::SessionAdmission::Pending => return None,
2375                crate::session_token::SessionAdmission::Rejected { reason } => {
2376                    eprintln!(
2377                        "[PlutoRTC][managed-adoption][blocked] connection_id={} reason={} node_id={} device_id_hint={}",
2378                        record.connection_id,
2379                        reason,
2380                        record.node_id.as_deref().unwrap_or("pending"),
2381                        record
2382                            .device_id
2383                            .as_deref()
2384                            .or(record.device_id_hint.as_deref())
2385                            .unwrap_or("pending")
2386                    );
2387                    return None;
2388                }
2389            }
2390        } else {
2391            None
2392        };
2393
2394        let node_id = record
2395            .node_id
2396            .as_deref()
2397            .map(str::trim)
2398            .filter(|value| !value.is_empty())?
2399            .to_string();
2400        let device_id = self
2401            .managed_connection_device_hint(&record.connection_id)
2402            .await;
2403
2404        // Transient grant sessions intentionally have no durable device identity.
2405        // Use the admitted node as their transport-facing peer identity so host
2406        // adapters can project a routable connection without promoting the grant
2407        // into the durable user-device avenue.
2408        let uses_transient_identity = admitted_scope.as_ref().is_some_and(|scope| {
2409            super::admission_impl::session_scope_uses_transient_peer_identity(scope.as_str())
2410        });
2411        let resolved_device_id = if uses_transient_identity {
2412            // A stale durable-device hint must never promote or rebind a
2413            // transient capability. Its peer identity is route-local by
2414            // definition, even when the same endpoint previously participated
2415            // in a user-device session.
2416            Some(node_id.clone())
2417        } else {
2418            device_id
2419                .as_deref()
2420                .map(str::trim)
2421                .filter(|value| !value.is_empty())
2422                .map(ToOwned::to_owned)
2423                .or_else(|| {
2424                    if matches!(
2425                        admitted_scope.as_ref().map(|scope| scope.as_str()),
2426                        Some("user-device")
2427                    ) {
2428                        record
2429                            .device_id
2430                            .as_deref()
2431                            .or(record.device_id_hint.as_deref())
2432                            .map(str::trim)
2433                            .filter(|value| !value.is_empty())
2434                            .map(ToOwned::to_owned)
2435                    } else {
2436                        None
2437                    }
2438                })
2439        };
2440
2441        if resolved_device_id.is_none()
2442            && matches!(
2443                admitted_scope.as_ref().map(|scope| scope.as_str()),
2444                Some("user-device")
2445            )
2446        {
2447            eprintln!(
2448                "[PlutoRTC][managed-adoption][wait] connection_id={} scope=user-device reason=missing-authoritative-device-id node_id={} device_id_hint={}",
2449                record.connection_id,
2450                node_id,
2451                record
2452                    .device_id
2453                    .as_deref()
2454                    .or(record.device_id_hint.as_deref())
2455                    .unwrap_or("pending")
2456            );
2457        }
2458
2459        Some(crate::client::ConnectionAdoption {
2460            connection_id: record.connection_id.clone(),
2461            node_id,
2462            device_id: resolved_device_id.clone(),
2463            transport_generation: record.transport_generation,
2464            status_reason: record.status_reason.clone(),
2465            main_stream_ready: resolved_device_id.is_some()
2466                && self.native_admission_route_is_ready_for_transport(
2467                    &record.connection_id,
2468                    record.transport_stable_id,
2469                ),
2470        })
2471    }
2472
2473    pub async fn retire_managed_connection(&self, connection_id: &str, reason: Option<String>) {
2474        let normalized_reason = reason.and_then(|value| {
2475            let trimmed = value.trim();
2476            if trimmed.is_empty() {
2477                None
2478            } else {
2479                Some(trimmed.to_string())
2480            }
2481        });
2482
2483        #[cfg(target_arch = "wasm32")]
2484        {
2485            web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
2486                "[pluto-rtc][managed-retire] connection_id={} reason={:?}",
2487                connection_id, normalized_reason
2488            )));
2489        }
2490        #[cfg(not(target_arch = "wasm32"))]
2491        {
2492            eprintln!(
2493                "[pluto-rtc][managed-retire] connection_id={} reason={:?}",
2494                connection_id, normalized_reason
2495            );
2496        }
2497
2498        self.retire_managed_connection_now(connection_id, normalized_reason)
2499            .await;
2500    }
2501
2502    /// Bind a device-id to an already-existing managed connection.
2503    ///
2504    /// This is the accept-side counterpart to the dialer's deviceId knowledge
2505    /// acquired via presence/tickets. When a handshake's transport-trust payload
2506    /// reveals the remote deviceId, the TS Client calls this to re-index the
2507    /// connection store by `device_id`, so subsequent `peer_session(deviceId)`
2508    /// lookups resolve to the live transport instead of returning `None`.
2509    pub async fn bind_connection_device_id(
2510        &self,
2511        connection_id: &str,
2512        device_id: &str,
2513    ) -> Option<crate::connection_manager::PeerSnapshot> {
2514        let trimmed = device_id.trim();
2515        if !trimmed.is_empty() {
2516            let admission = self.session_admission(connection_id);
2517            let durable_admission = matches!(
2518                &admission,
2519                crate::session_token::SessionAdmission::Accepted {
2520                    scope: Some(scope),
2521                    ..
2522                } if super::admission_impl::session_scope_allows_device_binding(scope.as_str())
2523            );
2524            let legacy_trusted_binding = !self.session_registry_active()
2525                && matches!(admission, crate::session_token::SessionAdmission::Pending);
2526
2527            if durable_admission || legacy_trusted_binding {
2528                let admitted = self
2529                    .mark_trusted_user_device_connection_admitted(connection_id, trimmed)
2530                    .await;
2531                // Evict earlier records for this peer device that belong to a
2532                // different (pre-reconnect) node id only after durable admission.
2533                if admitted {
2534                    if let Some(record) = self
2535                        .connection_manager
2536                        .get_by_connection_id(connection_id)
2537                        .await
2538                    {
2539                        if let Some(node_id) = record.node_id.as_deref() {
2540                            self.reconcile_authoritative_device_node(trimmed, node_id)
2541                                .await;
2542                        }
2543                    }
2544                }
2545            } else if matches!(admission, crate::session_token::SessionAdmission::Pending) {
2546                // The transport handshake can reveal an identity before its
2547                // session token arrives. Preserve that hint so a later durable
2548                // admission can bind it, but do not infer trusted-device scope.
2549                self.connection_manager
2550                    .set_device_id(connection_id, trimmed.to_string())
2551                    .await;
2552            }
2553        }
2554        self.connection_manager.peer_snapshot(connection_id).await
2555    }
2556
2557    /// Supply the durable device identity learned by the signaling directory
2558    /// for every physical connection currently owned by a remote node.
2559    ///
2560    /// Incoming transports can settle before browser discovery has populated
2561    /// its node-to-device cache. Keeping this reconciliation in the Rust owner
2562    /// makes that startup-order race idempotent without creating another retry
2563    /// or connection lifecycle in the host layer.
2564    pub async fn bind_node_device_id(&self, node_id: &str, device_id: &str) {
2565        let node_id = node_id.trim();
2566        let device_id = device_id.trim();
2567        if node_id.is_empty() || device_id.is_empty() {
2568            return;
2569        }
2570
2571        // Preserve the authoritative directory alias even when discovery wins
2572        // the race and no physical record exists yet. A later incoming accept
2573        // can then bind immediately without another signaling read.
2574        self.reconcile_authoritative_device_node(device_id, node_id)
2575            .await;
2576        let records = self.connection_manager.get_by_node_id(node_id).await;
2577        for record in records {
2578            let _ = self
2579                .bind_connection_device_id(&record.connection_id, device_id)
2580                .await;
2581        }
2582    }
2583
2584    pub async fn report_managed_connection_settled(
2585        &self,
2586        connection_id: &str,
2587        settled: bool,
2588        device_id: Option<&str>,
2589    ) -> Option<crate::connection_manager::PeerSnapshot> {
2590        if let Some(device_id) = device_id.map(str::trim).filter(|value| !value.is_empty()) {
2591            let _ = self
2592                .connection_manager
2593                .set_device_id(connection_id, device_id.to_string())
2594                .await;
2595        }
2596
2597        if settled {
2598            let _ = self
2599                .connection_manager
2600                .clear_status_reason(
2601                    connection_id,
2602                    Some(crate::lifecycle_reason::REASON_REPLACEMENT_IN_PROGRESS),
2603                )
2604                .await;
2605        }
2606
2607        self.connection_manager
2608            .set_health(
2609                connection_id,
2610                if settled {
2611                    crate::connection_manager::ConnectionHealth::Healthy
2612                } else {
2613                    crate::connection_manager::ConnectionHealth::Unknown
2614                },
2615            )
2616            .await
2617    }
2618
2619    #[cfg(target_arch = "wasm32")]
2620    pub(crate) async fn report_managed_connection_settled_for_transport(
2621        &self,
2622        connection_id: &str,
2623        settled: bool,
2624        expected_transport_stable_id: u64,
2625        expected_transport_generation: u64,
2626        expected_route_generation: u64,
2627    ) -> Option<crate::connection_manager::PeerSnapshot> {
2628        self.connection_manager
2629            .set_settled_if_current(
2630                connection_id,
2631                expected_transport_stable_id,
2632                expected_transport_generation,
2633                expected_route_generation,
2634                settled,
2635            )
2636            .await
2637    }
2638
2639    pub async fn probe_peer_health(&self, id: &str) -> bool {
2640        let snapshot = self.connection_manager.peer_snapshot(id).await;
2641        let Some(snapshot) = snapshot else {
2642            return false;
2643        };
2644
2645        let Some(node_id) = snapshot.node_id.clone() else {
2646            let _ = self
2647                .connection_manager
2648                .set_health(id, crate::connection_manager::ConnectionHealth::Stale)
2649                .await;
2650            return false;
2651        };
2652
2653        let Ok(endpoint_id) = node_id.parse::<iroh::EndpointId>() else {
2654            let _ = self
2655                .connection_manager
2656                .set_health(id, crate::connection_manager::ConnectionHealth::Stale)
2657                .await;
2658            return false;
2659        };
2660
2661        let Some(active_connection_id) = snapshot.connection_ids.first().cloned() else {
2662            return false;
2663        };
2664        let Some(expected_transport_stable_id) = snapshot.active_transport_stable_id else {
2665            return false;
2666        };
2667
2668        let node = self.iroh_node.read().await.as_ref().cloned();
2669        let probe = match node {
2670            Some(node) => {
2671                node.probe_connection(endpoint_id, std::time::Duration::from_secs(4))
2672                    .await
2673            }
2674            None => None,
2675        };
2676        let recent_protocol_activity = super::auto_connect_impl::generation_activity_is_recent(
2677            self.native_transport_protocol_activity_age(
2678                &active_connection_id,
2679                expected_transport_stable_id,
2680            ),
2681            5_000,
2682        );
2683
2684        let (health, healthy) = match probe {
2685            // A complete OpenRTC frame on this exact physical generation is
2686            // positive route proof even when Iroh's diagnostic ping returns
2687            // no object. Custom carriers may deliver application traffic
2688            // while that independent ping times out; the ping must not erase
2689            // the stronger generation-fenced observation.
2690            _ if recent_protocol_activity => {
2691                (crate::connection_manager::ConnectionHealth::Healthy, true)
2692            }
2693            Some(probe)
2694                if probe.transport_stable_id == expected_transport_stable_id
2695                    && (probe.responsive
2696                        || super::auto_connect_impl::generation_activity_is_recent(
2697                            probe.last_inbound_activity_age,
2698                            5_000,
2699                        )) =>
2700            {
2701                (crate::connection_manager::ConnectionHealth::Healthy, true)
2702            }
2703            Some(probe) if probe.transport_stable_id != expected_transport_stable_id => {
2704                // The physical generation changed while its pong was in flight.
2705                // That is not positive or negative evidence for the replacement.
2706                (crate::connection_manager::ConnectionHealth::Unknown, false)
2707            }
2708            _ => (crate::connection_manager::ConnectionHealth::Stale, false),
2709        };
2710        let updated = self
2711            .connection_manager
2712            .set_health_if_current(&active_connection_id, expected_transport_stable_id, health)
2713            .await;
2714        healthy && updated.is_some()
2715    }
2716
2717    // High level signaling methods that would be exposed to WASM or FFI
2718    pub async fn update_presence(
2719        &self,
2720        user_id: &str,
2721        device_name: &str,
2722        ticket: &str,
2723        metadata: Option<&str>,
2724    ) -> anyhow::Result<()> {
2725        self.update_presence_with_ttl(user_id, device_name, ticket, 300_000, metadata)
2726            .await
2727    }
2728
2729    pub async fn update_presence_with_ttl(
2730        &self,
2731        user_id: &str,
2732        device_name: &str,
2733        ticket: &str,
2734        ttl_ms: u64,
2735        metadata: Option<&str>,
2736    ) -> anyhow::Result<()> {
2737        self.publish_presence_record_with_ttl(user_id, device_name, ticket, true, ttl_ms, metadata)
2738            .await
2739    }
2740
2741    pub async fn update_durable_device_record_with_ttl(
2742        &self,
2743        user_id: &str,
2744        device_name: &str,
2745        ticket: &str,
2746        ttl_ms: u64,
2747        metadata: Option<&str>,
2748    ) -> anyhow::Result<()> {
2749        self.publish_presence_record_with_ttl(user_id, device_name, ticket, false, ttl_ms, metadata)
2750            .await
2751    }
2752
2753    pub async fn update_live_presence_record(
2754        &self,
2755        user_id: &str,
2756        device_name: &str,
2757        ticket: &str,
2758        metadata: Option<&str>,
2759    ) -> anyhow::Result<()> {
2760        let effective_ticket = self.refresh_presence_ticket_for_publication(ticket).await;
2761        let (iroh_ticket, _) = crate::session_token::split_ticket(&effective_ticket);
2762        let ticket_node_id = parse_endpoint_ticket(iroh_ticket)?.id.to_string();
2763
2764        #[cfg(not(target_arch = "wasm32"))]
2765        let metadata_owned = self.metadata_with_local_device_id(metadata).await;
2766
2767        #[cfg(not(target_arch = "wasm32"))]
2768        let metadata = metadata_owned.as_deref();
2769
2770        self.signaling
2771            .update_live_presence(
2772                user_id,
2773                &ticket_node_id,
2774                &effective_ticket,
2775                device_name,
2776                metadata,
2777            )
2778            .await
2779    }
2780
2781    #[cfg(not(target_arch = "wasm32"))]
2782    async fn publish_native_user_device_presence_once(
2783        &self,
2784        reason: &str,
2785        user_id: &str,
2786        device_name: &str,
2787        ticket: &str,
2788        durable_ttl_ms: u64,
2789        metadata: Option<&str>,
2790        publish_durable: bool,
2791        publish_live: bool,
2792    ) -> (bool, bool) {
2793        if self.is_app_execution_suspended() {
2794            return (false, false);
2795        }
2796
2797        let effective_ticket = self.refresh_presence_ticket_for_publication(ticket).await;
2798        let (iroh_fingerprint, scope, token_fingerprint) =
2799            super::core_impl::summarize_compound_ticket_for_logs(effective_ticket.as_str());
2800        // Durable device projection and live presence lease are independent
2801        // gateway operations. Publish them concurrently so a slow durable
2802        // mutation cannot delay replacement-node visibility or reconnection.
2803        let durable_publish = async {
2804            if !publish_durable {
2805                return false;
2806            }
2807            eprintln!(
2808                "[openrtc][presence][device-register] reason={} liveness_source=gateway-lease scope={} token_fp={} iroh_fp={}",
2809                reason,
2810                scope.unwrap_or_else(|| "unrestricted".to_string()),
2811                token_fingerprint.unwrap_or_else(|| "none".to_string()),
2812                iroh_fingerprint
2813            );
2814            match self
2815                .update_durable_device_record_with_ttl(
2816                    user_id,
2817                    device_name,
2818                    &effective_ticket,
2819                    durable_ttl_ms,
2820                    metadata,
2821                )
2822                .await
2823            {
2824                Ok(()) => {
2825                    eprintln!(
2826                        "[openrtc][presence][device-register][ok] reason={} liveness_source=gateway-lease",
2827                        reason
2828                    );
2829                    true
2830                }
2831                Err(e) => {
2832                    eprintln!(
2833                        "[openrtc][presence][device-register][retry] reason={} error={}",
2834                        reason, e
2835                    );
2836                    false
2837                }
2838            }
2839        };
2840        let live_publish = async {
2841            if !publish_live {
2842                return false;
2843            }
2844            match self
2845                .update_live_presence_record(user_id, device_name, &effective_ticket, metadata)
2846                .await
2847            {
2848                Ok(()) => {
2849                    eprintln!("[openrtc][presence][lease][ok] reason={}", reason);
2850                    true
2851                }
2852                Err(e) => {
2853                    eprintln!(
2854                        "[openrtc][presence][lease][retry] reason={} error={}",
2855                        reason, e
2856                    );
2857                    false
2858                }
2859            }
2860        };
2861        let (durable_registered, live_registered) = tokio::join!(durable_publish, live_publish);
2862
2863        (durable_registered, live_registered)
2864    }
2865
2866    #[cfg(not(target_arch = "wasm32"))]
2867    async fn resolve_native_presence_ticket(
2868        &self,
2869        policy: &NativePresenceTicketPolicy,
2870    ) -> anyhow::Result<String> {
2871        match policy {
2872            NativePresenceTicketPolicy::Fixed(ticket) => {
2873                Ok(self.refresh_presence_ticket_for_publication(ticket).await)
2874            }
2875            NativePresenceTicketPolicy::ManagedUserDevice => {
2876                let ticket = self.endpoint_ticket_with_token("user-device", 0).await?;
2877                let (iroh_ticket, suffix) = crate::session_token::split_ticket(&ticket);
2878                let payload = suffix
2879                    .and_then(|value| crate::session_token::decode_payload(iroh_ticket, value))
2880                    .ok_or_else(|| {
2881                        anyhow::anyhow!(
2882                            "managed user-device presence requires a compound admission ticket"
2883                        )
2884                    })?;
2885                if payload.scope.as_str() != "user-device" {
2886                    return Err(anyhow::anyhow!(
2887                        "managed user-device presence minted unexpected scope {}",
2888                        payload.scope
2889                    ));
2890                }
2891                Ok(ticket)
2892            }
2893        }
2894    }
2895
2896    #[cfg(not(target_arch = "wasm32"))]
2897    async fn publish_native_presence_with_policy_once(
2898        &self,
2899        reason: &str,
2900        user_id: &str,
2901        device_name: &str,
2902        policy: &NativePresenceTicketPolicy,
2903        durable_ttl_ms: u64,
2904        metadata: Option<&str>,
2905        publish_durable: bool,
2906        publish_live: bool,
2907    ) -> (bool, bool) {
2908        let ticket = match self.resolve_native_presence_ticket(policy).await {
2909            Ok(ticket) => ticket,
2910            Err(error) => {
2911                eprintln!(
2912                    "[pluto-rtc][presence][ticket-unavailable] reason={} policy={} error={}",
2913                    reason,
2914                    match policy {
2915                        NativePresenceTicketPolicy::Fixed(_) => "fixed",
2916                        NativePresenceTicketPolicy::ManagedUserDevice => "managed-user-device",
2917                    },
2918                    error
2919                );
2920                return (false, false);
2921            }
2922        };
2923
2924        self.publish_native_user_device_presence_once(
2925            reason,
2926            user_id,
2927            device_name,
2928            &ticket,
2929            durable_ttl_ms,
2930            metadata,
2931            publish_durable,
2932            publish_live,
2933        )
2934        .await
2935    }
2936
2937    async fn publish_presence_record_with_ttl(
2938        &self,
2939        user_id: &str,
2940        device_name: &str,
2941        ticket: &str,
2942        is_online: bool,
2943        ttl_ms: u64,
2944        metadata: Option<&str>,
2945    ) -> anyhow::Result<()> {
2946        let effective_ticket = self.refresh_presence_ticket_for_publication(ticket).await;
2947        // Presence signaling publishes the full compound ticket string, but
2948        // only the iroh portion should be parsed for local validation.
2949        let (iroh_ticket, _) = crate::session_token::split_ticket(&effective_ticket);
2950        let ticket_node_id = parse_endpoint_ticket(iroh_ticket)?.id.to_string();
2951
2952        #[cfg(not(target_arch = "wasm32"))]
2953        let metadata_owned = self.metadata_with_local_device_id(metadata).await;
2954
2955        #[cfg(not(target_arch = "wasm32"))]
2956        let metadata = metadata_owned.as_deref();
2957
2958        self.signaling
2959            .update_presence(
2960                user_id,
2961                &ticket_node_id,
2962                &effective_ticket,
2963                is_online,
2964                device_name,
2965                ttl_ms,
2966                metadata,
2967            )
2968            .await
2969    }
2970
2971    async fn refresh_presence_ticket_for_publication(&self, ticket: &str) -> String {
2972        let trimmed = ticket.trim();
2973        if trimmed.is_empty() {
2974            return ticket.to_string();
2975        }
2976
2977        let (iroh_ticket, suffix) = crate::session_token::split_ticket(trimmed);
2978        let Some(payload) =
2979            suffix.and_then(|value| crate::session_token::decode_payload(iroh_ticket, value))
2980        else {
2981            return trimmed.to_string();
2982        };
2983
2984        let latest_iroh_ticket = match self.endpoint_ticket().await {
2985            Ok(value) => value,
2986            Err(_) => return trimmed.to_string(),
2987        };
2988
2989        if latest_iroh_ticket == iroh_ticket {
2990            return trimmed.to_string();
2991        }
2992
2993        let rebuilt = crate::session_token::build_ticket(
2994            &latest_iroh_ticket,
2995            &payload.token,
2996            payload.scope.clone(),
2997            payload.max_connections,
2998        );
2999
3000        #[cfg(not(target_arch = "wasm32"))]
3001        {
3002            let (iroh_fingerprint, scope, token_fingerprint) =
3003                super::core_impl::summarize_compound_ticket_for_logs(rebuilt.as_str());
3004            eprintln!(
3005                "[pluto-rtc][presence][ticket-refresh] rebuilt compound ticket for publish scope={} endpoint_changed=true token_fp={} iroh_fp={}",
3006                scope.unwrap_or_else(|| payload.scope.to_string()),
3007                token_fingerprint.unwrap_or_else(|| "unknown".to_string()),
3008                iroh_fingerprint
3009            );
3010        }
3011
3012        #[cfg(target_arch = "wasm32")]
3013        web_sys::console::info_1(&wasm_bindgen::JsValue::from_str(&format!(
3014            "[pluto-rtc][presence] rebuilt compound ticket for publish scope={} endpoint_changed=true",
3015            payload.scope
3016        )));
3017
3018        rebuilt
3019    }
3020
3021    pub async fn set_offline(&self, user_id: &str) -> anyhow::Result<()> {
3022        // Terminal offline intent must stop the lease actor first; otherwise a
3023        // concurrent refresh can immediately republish the device online.
3024        self.stop_presence_loop();
3025        if let Some(node_id) = self.current_node_id().await {
3026            // The gateway lease is the liveness authority. The durable device
3027            // projection remains unchanged and naturally projects offline from
3028            // lease expiry; another durable mutation would duplicate cost.
3029            self.signaling
3030                .set_live_presence_offline(user_id, &node_id)
3031                .await?;
3032        }
3033        Ok(())
3034    }
3035
3036    pub async fn update_device(
3037        &self,
3038        user_id: &str,
3039        device_id: &str,
3040        device_name: Option<&str>,
3041        capabilities: Option<crate::signaling::DeviceCapabilities>,
3042        metadata: Option<&str>,
3043    ) -> anyhow::Result<()> {
3044        self.signaling
3045            .update_device(user_id, device_id, device_name, capabilities, metadata)
3046            .await
3047    }
3048
3049    pub async fn delete_device(&self, user_id: &str, device_id: &str) -> anyhow::Result<()> {
3050        let active_session_identity = self.active_session_identity();
3051        #[cfg(not(target_arch = "wasm32"))]
3052        let native_device_id = self
3053            .native_device_identity
3054            .read()
3055            .await
3056            .as_ref()
3057            .map(|identity| identity.device_id.clone());
3058        #[cfg(target_arch = "wasm32")]
3059        let native_device_id: Option<String> = None;
3060        let deleting_local_device = device_id_is_local(
3061            device_id,
3062            active_session_identity.as_ref(),
3063            native_device_id.as_deref(),
3064        );
3065        if deleting_local_device {
3066            // A terminal local delete must stop the presence owner before the
3067            // gateway mutation. Otherwise its next retry/refresh could
3068            // recreate the row that was just removed. If the provider delete
3069            // fails, staying offline is the fail-closed cost posture and the
3070            // existing lease expires naturally.
3071            self.stop_presence_loop();
3072        }
3073        self.signaling.delete_device(user_id, device_id).await
3074    }
3075
3076    /// Returns the `(user_id, local_device_id)` that were registered when the
3077    /// auto-connect loop was started, or `None` if the loop hasn't started yet.
3078    pub fn active_session_identity(&self) -> Option<(String, String)> {
3079        match self.auto_connect_loop_key.lock() {
3080            Ok(guard) => guard.clone(),
3081            Err(p) => p.into_inner().clone(),
3082        }
3083    }
3084
3085    /// Update `excludedPeers` on the local device's live gateway lease.
3086    /// Pass the full desired list (not a diff) - the backend overwrites the field.
3087    ///
3088    /// Browser/WASM presence is owned by `WasmPresenceManager`, which publishes
3089    /// this hot state through the coordination gateway. Calling a second
3090    /// signaling owner from WASM would duplicate billed mutations.
3091    pub async fn update_signaling_excluded_peers(
3092        &self,
3093        user_id: &str,
3094        excluded_peers: &[String],
3095    ) -> anyhow::Result<()> {
3096        if let Some(node_id) = self.current_node_id().await {
3097            self.signaling
3098                .set_excluded_peers(user_id, &node_id, excluded_peers)
3099                .await?;
3100        }
3101        Ok(())
3102    }
3103
3104    /// Convenience: read the current in-memory auto-connect exclusion set as a sorted Vec.
3105    ///
3106    /// This intentionally returns only canonical device IDs. Node-id aliases are
3107    /// local-only runtime hints and must not be published into signaling's
3108    /// `excludedPeers` field.
3109    pub fn current_excluded_peers_snapshot(&self) -> Vec<String> {
3110        let guard = match self.auto_connect_excluded.lock() {
3111            Ok(g) => g,
3112            Err(p) => p.into_inner(),
3113        };
3114        let mut peers: Vec<String> = guard.iter().cloned().collect();
3115        peers.sort();
3116        peers
3117    }
3118
3119    fn normalize_auto_connect_exclusion_key(value: &str) -> Option<String> {
3120        let value = value.trim().to_ascii_lowercase();
3121        if value.is_empty() {
3122            None
3123        } else {
3124            Some(value)
3125        }
3126    }
3127
3128    #[cfg(not(target_arch = "wasm32"))]
3129    pub(crate) fn should_publish_excluded_peers_to_signaling(
3130        external_coordination_active: bool,
3131    ) -> bool {
3132        !external_coordination_active
3133    }
3134
3135    /// Exclude a remote device from auto-connect for this app session and publish
3136    /// the current session exclusions so peers do not immediately dial us back.
3137    pub async fn exclude_peer_and_publish(&self, remote_device_id: &str) {
3138        self.set_auto_connect_excluded_peer(remote_device_id, None, true);
3139
3140        #[cfg(target_arch = "wasm32")]
3141        return;
3142
3143        #[cfg(not(target_arch = "wasm32"))]
3144        if !Self::should_publish_excluded_peers_to_signaling(
3145            self.external_auto_connect_is_active().await,
3146        ) {
3147            return;
3148        }
3149
3150        #[cfg(not(target_arch = "wasm32"))]
3151        let Some((user_id, _)) = self.active_session_identity() else {
3152            return;
3153        };
3154        #[cfg(not(target_arch = "wasm32"))]
3155        let excluded = self.current_excluded_peers_snapshot();
3156        #[cfg(not(target_arch = "wasm32"))]
3157        if let Err(error) = self
3158            .update_signaling_excluded_peers(&user_id, &excluded)
3159            .await
3160        {
3161            eprintln!(
3162                "[PlutoRTC] exclude_peer_and_publish failed to publish excluded_peers remote_device_id={} error={}",
3163                remote_device_id,
3164                error
3165            );
3166        }
3167    }
3168
3169    /// Clear a remote device from this app session's auto-connect exclusion and
3170    /// publish the remaining session exclusions.
3171    pub async fn unexclude_peer_and_publish(&self, remote_device_id: &str) {
3172        self.set_auto_connect_excluded_peer(remote_device_id, None, false);
3173
3174        #[cfg(target_arch = "wasm32")]
3175        return;
3176
3177        #[cfg(not(target_arch = "wasm32"))]
3178        if !Self::should_publish_excluded_peers_to_signaling(
3179            self.external_auto_connect_is_active().await,
3180        ) {
3181            return;
3182        }
3183
3184        #[cfg(not(target_arch = "wasm32"))]
3185        let Some((user_id, _)) = self.active_session_identity() else {
3186            return;
3187        };
3188        #[cfg(not(target_arch = "wasm32"))]
3189        let excluded = self.current_excluded_peers_snapshot();
3190        #[cfg(not(target_arch = "wasm32"))]
3191        if let Err(error) = self
3192            .update_signaling_excluded_peers(&user_id, &excluded)
3193            .await
3194        {
3195            eprintln!(
3196                "[PlutoRTC] unexclude_peer_and_publish failed to publish excluded_peers remote_device_id={} error={}",
3197                remote_device_id,
3198                error
3199            );
3200        }
3201    }
3202
3203    pub async fn search_devices(
3204        &self,
3205        user_id: &str,
3206    ) -> anyhow::Result<Vec<crate::signaling::Device>> {
3207        let node_id = self.current_node_id().await;
3208        self.signaling
3209            .search_devices(user_id, node_id.as_deref())
3210            .await
3211    }
3212
3213    pub async fn devices_with_status(
3214        &self,
3215        user_id: &str,
3216    ) -> anyhow::Result<Vec<DeviceStatusSnapshot>> {
3217        let node_id = self.current_node_id().await;
3218        let devices = self
3219            .signaling
3220            .list_devices(user_id, node_id.as_deref())
3221            .await?;
3222        let peers = self.connection_manager.list_peer_snapshots().await;
3223        self.backfill_peer_device_ids_from_devices(&devices, &peers)
3224            .await;
3225        let peers = self.connection_manager.list_peer_snapshots().await;
3226        let _discovered_count = devices.len();
3227        let _peer_count = peers.len();
3228        let merged = merge_device_status_snapshots(devices, peers);
3229        // eprintln!(
3230        //     "[pluto-rtc][devices-with-status] user_id={} app_tag={} discovered={} peers={} merged={}",
3231        //     user_id,
3232        //     self.app_tag(),
3233        //     discovered_count,
3234        //     peer_count,
3235        //     merged.len()
3236        // );
3237        let mut snapshots = Vec::with_capacity(merged.len());
3238        for snapshot in merged {
3239            let mut snapshot = self.apply_local_manual_disconnect_status(snapshot);
3240            snapshot = self.apply_device_status_admission_guard(snapshot);
3241            self.enrich_device_status_latency(&mut snapshot).await;
3242            snapshots.push(snapshot);
3243        }
3244        Ok(snapshots)
3245    }
3246
3247    pub(crate) fn apply_local_manual_disconnect_status(
3248        &self,
3249        mut snapshot: DeviceStatusSnapshot,
3250    ) -> DeviceStatusSnapshot {
3251        if !self.is_locally_auto_connect_excluded(&snapshot.device.device_id) {
3252            return snapshot;
3253        }
3254
3255        // Discovery remains provider-owned and may truthfully report that the
3256        // remote device is online. A local manual disconnect is nevertheless
3257        // the authoritative connection lifecycle state until the user
3258        // explicitly reconnects. Project that terminal state from the same
3259        // Rust exclusion owner that fences auto-connect instead of asking a
3260        // frontend cache to override provider discovery.
3261        snapshot.connection_status = ConnectionStatus::Closed;
3262        snapshot.settled_ready = false;
3263        snapshot.readiness_state = ReadinessState::Closed;
3264        snapshot.readiness_reason = crate::lifecycle_reason::REASON_MANUAL_DISCONNECT.to_string();
3265        snapshot.peer_health = crate::connection_manager::ConnectionHealth::Stale;
3266        snapshot.connection_id = None;
3267        snapshot.active_transport_stable_id = None;
3268        snapshot.parallel_transport = None;
3269        snapshot.latency_ms = None;
3270        snapshot.latency_by_transport = crate::client::LatencySnapshot::default();
3271        snapshot
3272    }
3273
3274    async fn enrich_device_status_latency(&self, snapshot: &mut DeviceStatusSnapshot) {
3275        snapshot.latency_ms = None;
3276        snapshot.latency_by_transport = crate::client::LatencySnapshot::default();
3277        if !snapshot.settled_ready {
3278            return;
3279        }
3280
3281        let expected_generation = match snapshot.connection_id.as_deref() {
3282            Some(connection_id) => {
3283                self.connection_manager
3284                    .get_by_connection_id(connection_id)
3285                    .await
3286            }
3287            None => None,
3288        };
3289        if expected_generation.as_ref().is_some_and(|record| {
3290            record.transport_stable_id != snapshot.active_transport_stable_id
3291                || record.transport_generation != snapshot.transport_generation
3292                || record.route_generation != snapshot.route_generation
3293        }) {
3294            return;
3295        }
3296
3297        let transport_labels = [
3298            Some(snapshot.active_transport.as_str()),
3299            snapshot.parallel_transport.as_deref(),
3300        ];
3301        let lookup_id = snapshot
3302            .connection_id
3303            .as_deref()
3304            .or(snapshot.peer_id.as_deref())
3305            .or(snapshot.device.node_id.as_deref())
3306            .unwrap_or(snapshot.device.device_id.as_str());
3307        let mut latency_by_transport = crate::client::LatencySnapshot::default();
3308
3309        if transport_labels.iter().flatten().any(|transport| {
3310            crate::transport_label::normalize(transport).is_some_and(|label| {
3311                crate::transport_label::is_iroh_base(label)
3312                    || crate::transport_label::is_iroh_packet_carrier(label)
3313            }) || crate::transport_label::normalize(transport) == Some(crate::transport_label::BLE)
3314        }) {
3315            if let Some(latency_ms) = self.iroh_transport_rtt_ms(lookup_id).await {
3316                let path_kind = self.iroh_path_kind(lookup_id).await;
3317                if let Some(transport) = selected_iroh_latency_label(
3318                    path_kind,
3319                    transport_labels.iter().flatten().copied(),
3320                ) {
3321                    latency_by_transport.set(transport, latency_ms);
3322                }
3323            }
3324        }
3325
3326        if let Some(expected) = expected_generation {
3327            let current = self
3328                .connection_manager
3329                .get_by_connection_id(&expected.connection_id)
3330                .await;
3331            if current.as_ref().is_none_or(|record| {
3332                record.transport_stable_id != expected.transport_stable_id
3333                    || record.transport_generation != expected.transport_generation
3334                    || record.route_generation != expected.route_generation
3335            }) {
3336                return;
3337            }
3338        }
3339        snapshot.latency_by_transport = latency_by_transport;
3340        snapshot.latency_ms = snapshot
3341            .latency_by_transport
3342            .get(&snapshot.active_transport)
3343            .or_else(|| {
3344                snapshot
3345                    .parallel_transport
3346                    .as_deref()
3347                    .and_then(|transport| snapshot.latency_by_transport.get(transport))
3348            });
3349    }
3350
3351    pub(super) async fn backfill_peer_device_ids_from_devices(
3352        &self,
3353        devices: &[crate::signaling::Device],
3354        peers: &[crate::connection_manager::PeerSnapshot],
3355    ) {
3356        let device_ids_by_node: std::collections::HashMap<String, String> = devices
3357            .iter()
3358            .filter_map(|device| {
3359                let node_id = device
3360                    .node_id
3361                    .as_deref()
3362                    .map(str::trim)
3363                    .filter(|value| !value.is_empty())?;
3364                let device_id = device.device_id.trim();
3365                if device_id.is_empty() {
3366                    return None;
3367                }
3368                Some((node_id.to_string(), device_id.to_string()))
3369            })
3370            .collect();
3371
3372        for peer in peers {
3373            let has_device_id = peer
3374                .device_id
3375                .as_deref()
3376                .map(str::trim)
3377                .filter(|value| !value.is_empty())
3378                .is_some();
3379            if has_device_id {
3380                continue;
3381            }
3382
3383            let Some(node_id) = peer
3384                .node_id
3385                .as_deref()
3386                .map(str::trim)
3387                .filter(|value| !value.is_empty())
3388            else {
3389                continue;
3390            };
3391
3392            let Some(device_id) = device_ids_by_node.get(node_id).cloned() else {
3393                continue;
3394            };
3395
3396            for connection_id in &peer.connection_ids {
3397                let current_device_id = self
3398                    .connection_manager
3399                    .get_by_connection_id(connection_id)
3400                    .await
3401                    .and_then(|record| {
3402                        record
3403                            .device_id
3404                            .as_deref()
3405                            .map(str::trim)
3406                            .filter(|value| !value.is_empty())
3407                            .map(ToOwned::to_owned)
3408                    });
3409                if current_device_id.as_deref() == Some(device_id.as_str()) {
3410                    continue;
3411                }
3412
3413                self.connection_manager
3414                    .set_device_id(connection_id, device_id.clone())
3415                    .await;
3416
3417                #[cfg(target_arch = "wasm32")]
3418                web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
3419                    "[pluto-rtc][devices-with-status] backfilled peer device_id connection_id={} node_id={} device_id={}",
3420                    connection_id, node_id, device_id
3421                )));
3422            }
3423        }
3424    }
3425
3426    pub async fn runtime_status(&self) -> RuntimeStatus {
3427        let node_id = self.node_id.read().await.clone();
3428        let transport_config = self.transport_config.read().await;
3429        #[cfg(not(target_arch = "wasm32"))]
3430        let ble_available = self
3431            .native_custom_transport_kinds
3432            .read()
3433            .await
3434            .values()
3435            .any(|kind| matches!(kind, IrohPathKind::Ble));
3436        #[cfg(target_arch = "wasm32")]
3437        let ble_available = false;
3438        let transport = TransportStatus::from_config_with_ble_available(
3439            &transport_config,
3440            ble_available,
3441            current_iroh_relay_provider(),
3442        );
3443        RuntimeStatus {
3444            ready: true,
3445            node_id,
3446            transport,
3447        }
3448    }
3449
3450    /// Customer-safe product maturity for this compiled Rust runtime.
3451    ///
3452    /// This is support metadata, not peer-session state. It remains a separate
3453    /// additive query so the public `RuntimeStatus` shape stays source
3454    /// compatible with OpenRTC 2.4.
3455    pub fn product_capability_maturity(&self) -> ProductCapabilityMaturity {
3456        ProductCapabilityMaturity::for_current_target()
3457    }
3458
3459    #[cfg(not(target_arch = "wasm32"))]
3460    pub async fn notify_network_change(&self) -> anyhow::Result<usize> {
3461        let endpoint = {
3462            let endpoint_guard = self.iroh_endpoint.read().await;
3463            endpoint_guard
3464                .clone()
3465                .ok_or_else(|| anyhow::anyhow!("Iroh endpoint not initialized"))?
3466        };
3467
3468        tokio::time::timeout(
3469            std::time::Duration::from_secs(10),
3470            endpoint.network_change(),
3471        )
3472        .await
3473        .map_err(|_| anyhow::anyhow!("Iroh endpoint network-change refresh timed out after 10s"))?;
3474
3475        let records = self.connection_manager.list_all().await;
3476        let mut retired = 0usize;
3477        for record in records {
3478            let Some(endpoint_id) = record
3479                .endpoint_id
3480                .as_deref()
3481                .or(record.node_id.as_deref())
3482                .and_then(|value| value.parse::<iroh::EndpointId>().ok())
3483            else {
3484                continue;
3485            };
3486
3487            let transport_alive = tokio::time::timeout(
3488                std::time::Duration::from_secs(5),
3489                self.is_connection_transport_alive(endpoint_id),
3490            )
3491            .await
3492            .map_err(|_| {
3493                anyhow::anyhow!(
3494                    "Iroh transport health check timed out after 5s endpoint_id={endpoint_id}"
3495                )
3496            })?;
3497
3498            if transport_alive {
3499                continue;
3500            }
3501
3502            self.connection_manager
3503                .set_closing(
3504                    &record.connection_id,
3505                    Some("network-change-retired-stale-record".to_string()),
3506                )
3507                .await;
3508            self.connection_manager
3509                .set_closed(
3510                    &record.connection_id,
3511                    Some("network-change-retired-stale-record".to_string()),
3512                )
3513                .await;
3514            let _ = self.connection_manager.remove(&record.connection_id).await;
3515            retired = retired.saturating_add(1);
3516        }
3517
3518        self.wake_native_ble_recovery("network-change").await;
3519
3520        Ok(retired)
3521    }
3522
3523    /// Search devices without excluding the local node. Useful for tests.
3524    pub async fn search_devices_raw(
3525        &self,
3526        user_id: &str,
3527    ) -> anyhow::Result<Vec<crate::signaling::Device>> {
3528        self.signaling.search_devices(user_id, None).await
3529    }
3530
3531    pub async fn connect_device(
3532        &self,
3533        device_id: Option<&str>,
3534        endpoint_ticket: &str,
3535    ) -> anyhow::Result<ManagedConnectResult> {
3536        self.connect_device_with_intent(device_id, endpoint_ticket, true, None)
3537            .await
3538    }
3539
3540    /// Present a product-issued admission grant to a peer already known by the
3541    /// Rust discovery/session owner. The application supplies capability intent
3542    /// only; endpoint lookup and compound-ticket binding stay inside Rust.
3543    pub async fn connect_known_device_with_token(
3544        &self,
3545        device_id: &str,
3546        token: &str,
3547        scope: &str,
3548        max_connections: u32,
3549        expires_at_ms: Option<u64>,
3550        lookup_timeout_ms: Option<u64>,
3551    ) -> anyhow::Result<ManagedConnectResult> {
3552        let device_id = device_id.trim();
3553        let token = token.trim();
3554        let scope = scope.trim();
3555        if device_id.is_empty() {
3556            return Err(anyhow::anyhow!(
3557                "known-device admission requires a device id"
3558            ));
3559        }
3560        if token.is_empty() {
3561            return Err(anyhow::anyhow!("known-device admission requires a token"));
3562        }
3563        if scope.is_empty() {
3564            return Err(anyhow::anyhow!("known-device admission requires a scope"));
3565        }
3566        if expires_at_ms.is_some_and(|expiry| expiry <= crate::session_token::now_unix_ms()) {
3567            return Err(anyhow::anyhow!(
3568                "known-device admission expiry must be in the future"
3569            ));
3570        }
3571
3572        let mut endpoint_revisions = self.known_device_endpoint_revision.subscribe();
3573        let lookup = async {
3574            loop {
3575                if let Some(remote_node_id) = self.authoritative_node_for_device(device_id) {
3576                    let endpoint_id =
3577                        remote_node_id
3578                            .parse::<iroh::EndpointId>()
3579                            .map_err(|error| {
3580                                anyhow::anyhow!(
3581                                    "known device {device_id} has an invalid authoritative endpoint: {error}"
3582                                )
3583                            })?;
3584                    if let Some(endpoint_addr) = self.cached_endpoint_addr(endpoint_id).await {
3585                        return Ok::<_, anyhow::Error>(endpoint_addr);
3586                    }
3587                }
3588                endpoint_revisions
3589                    .changed()
3590                    .await
3591                    .map_err(|_| anyhow::anyhow!("known-device endpoint directory closed"))?;
3592            }
3593        };
3594        let lookup_timeout_ms = lookup_timeout_ms.unwrap_or(10_000).max(1);
3595        #[cfg(not(target_arch = "wasm32"))]
3596        let endpoint_addr = tokio::time::timeout(
3597            std::time::Duration::from_millis(lookup_timeout_ms),
3598            lookup,
3599        )
3600        .await
3601        .map_err(|_| {
3602            anyhow::anyhow!(
3603                "timed out after {lookup_timeout_ms}ms waiting for Rust-owned endpoint state for known device {device_id}"
3604            )
3605        })??;
3606        #[cfg(target_arch = "wasm32")]
3607        let endpoint_addr = {
3608            use futures::FutureExt;
3609
3610            let lookup = lookup.fuse();
3611            let timeout =
3612                gloo_timers::future::sleep(std::time::Duration::from_millis(lookup_timeout_ms))
3613                    .fuse();
3614            futures::pin_mut!(lookup, timeout);
3615            futures::select! {
3616                endpoint_addr = lookup => endpoint_addr?,
3617                _ = timeout => {
3618                    return Err(anyhow::anyhow!(
3619                        "timed out after {lookup_timeout_ms}ms waiting for Rust-owned endpoint state for known device {device_id}"
3620                    ));
3621                }
3622            }
3623        };
3624        let remote_node_id = endpoint_addr.id.to_string();
3625        let endpoint_ticket =
3626            iroh_tickets::endpoint::EndpointTicket::new(endpoint_addr.clone()).to_string();
3627        let compound_ticket = crate::session_token::expiring_ticket(
3628            endpoint_ticket.as_str(),
3629            token,
3630            scope,
3631            max_connections,
3632            expires_at_ms,
3633        );
3634        let previous_repair_credential = self.cache_scoped_route_repair_credential(
3635            &remote_node_id,
3636            Some(crate::client::ScopedRouteRepairCredential {
3637                token: token.to_string(),
3638                scope: scope.to_string(),
3639                max_connections,
3640                expires_at_ms,
3641                authoritative_device_id: device_id.to_string(),
3642            }),
3643        );
3644        // The known device id selects a provider-authorized endpoint; it does
3645        // not itself grant same-user trust. Present the product capability on
3646        // an untrusted connection, then bind the already-authoritative device
3647        // alias only after the remote host accepts the requested scope.
3648        let first_connect = self.connect_device(None, compound_ticket.as_str()).await;
3649        let mut first_error = None;
3650        let mut result = match first_connect {
3651            Ok(result) => Some(result),
3652            Err(error) => {
3653                first_error = Some(error);
3654                None
3655            }
3656        };
3657        if result
3658            .as_ref()
3659            .is_none_or(|result| result.approved_scope.as_deref() != Some(scope))
3660        {
3661            // A browser can retain a locally-live Iroh generation while it is
3662            // suspended and the host revokes the preceding grant and closes
3663            // that route. Re-presenting a replacement grant on that zombie
3664            // generation only repeats the same timeout. Retire exactly the
3665            // generation we observed, then make one bounded fresh dial through
3666            // this Rust-owned admission boundary. The generation fence leaves
3667            // any replacement that arrived concurrently untouched.
3668            let stale_transport_stable_id = self
3669                .get_connection(endpoint_addr.id)
3670                .await
3671                .map(|connection| crate::transport_generation::for_connection(&connection));
3672            let retired_stale_transport = match stale_transport_stable_id {
3673                Some(stable_id) => self
3674                    .disconnect_transport_generation_with_reason(
3675                        endpoint_addr.id,
3676                        stable_id,
3677                        crate::lifecycle_reason::REASON_SCOPED_ADMISSION_FRESH_REDIAL,
3678                    )
3679                    .await
3680                    .unwrap_or(false),
3681                None => false,
3682            };
3683            eprintln!(
3684                "[pluto-rtc][known-device-admission] retrying replacement scope on fresh transport device_id={} remote_node_id={} scope={} stale_transport_stable_id={:?} retired_stale_transport={} first_error={}",
3685                device_id,
3686                remote_node_id,
3687                scope,
3688                stale_transport_stable_id,
3689                retired_stale_transport,
3690                first_error
3691                    .as_ref()
3692                    .map(ToString::to_string)
3693                    .unwrap_or_else(|| "scope-not-approved".to_string()),
3694            );
3695            result = match self.connect_device(None, compound_ticket.as_str()).await {
3696                Ok(result) => Some(result),
3697                Err(error) => {
3698                    self.cache_scoped_route_repair_credential(
3699                        &remote_node_id,
3700                        previous_repair_credential,
3701                    );
3702                    return Err(match first_error {
3703                        Some(first_error) => anyhow::anyhow!(
3704                            "known-device admission for {device_id} failed before and after one fresh transport retry: first={first_error}; retry={error}"
3705                        ),
3706                        None => error,
3707                    });
3708                }
3709            };
3710        }
3711        let mut result = result.expect("known-device admission retry produced a result");
3712        if result.approved_scope.as_deref() != Some(scope) {
3713            self.cache_scoped_route_repair_credential(&remote_node_id, previous_repair_credential);
3714            return Err(anyhow::anyhow!(
3715                "known-device admission for {device_id} did not receive approval for scope {scope}"
3716            ));
3717        }
3718        self.connection_manager
3719            .set_device_id(&result.connection_id, device_id.to_string())
3720            .await;
3721        result.device_id = Some(device_id.to_string());
3722        result.device_id_hint = Some(device_id.to_string());
3723        Ok(result)
3724    }
3725
3726    /// Accept a provider-authorized endpoint observation as typed discovery
3727    /// input. Rust validates and owns the mapping; callers cannot use this to
3728    /// bypass the separate admission token required for a scoped connection.
3729    pub async fn observe_known_device_endpoint(
3730        &self,
3731        device_id: &str,
3732        endpoint_ticket: &str,
3733    ) -> anyhow::Result<()> {
3734        let device_id = device_id.trim();
3735        if device_id.is_empty() {
3736            return Err(anyhow::anyhow!(
3737                "known-device endpoint observation requires a device id"
3738            ));
3739        }
3740        let endpoint_ticket = endpoint_ticket.trim();
3741        let (iroh_ticket, _) = crate::session_token::split_ticket(endpoint_ticket);
3742        let endpoint_addr = parse_endpoint_ticket(iroh_ticket)?;
3743        let endpoint_id = endpoint_addr.id;
3744        self.remember_endpoint_addr(&endpoint_addr).await;
3745        self.reconcile_authoritative_device_node(device_id, &endpoint_id.to_string())
3746            .await;
3747        Ok(())
3748    }
3749
3750    pub(crate) async fn connect_desired_device(
3751        &self,
3752        device_id: &str,
3753        endpoint_ticket: &str,
3754        desired_peer_evidence: &str,
3755    ) -> anyhow::Result<ManagedConnectResult> {
3756        let (iroh_ticket, _) = crate::session_token::split_ticket(endpoint_ticket.trim());
3757        let endpoint_addr = parse_endpoint_ticket(iroh_ticket)?;
3758        let preferred_ticket =
3759            self.preferred_scoped_route_ticket(endpoint_addr.id.to_string().as_str(), iroh_ticket);
3760        self.connect_device_with_intent(
3761            Some(device_id),
3762            preferred_ticket.as_deref().unwrap_or(endpoint_ticket),
3763            false,
3764            Some(desired_peer_evidence),
3765        )
3766        .await
3767    }
3768
3769    async fn connect_device_with_intent(
3770        &self,
3771        device_id: Option<&str>,
3772        endpoint_ticket: &str,
3773        clear_auto_connect_exclusion: bool,
3774        desired_peer_evidence: Option<&str>,
3775    ) -> anyhow::Result<ManagedConnectResult> {
3776        // Strip compound ticket suffix if present.  The token is extracted
3777        // and will be auto-presented to the host after connection.
3778        let (iroh_ticket, token_suffix) =
3779            crate::session_token::split_ticket(endpoint_ticket.trim());
3780        let extracted_token_payload = token_suffix
3781            .and_then(|suffix| crate::session_token::decode_payload(iroh_ticket, suffix));
3782        let extracted_token = extracted_token_payload
3783            .as_ref()
3784            .map(|payload| payload.token.clone());
3785        let extracted_token_suffix = token_suffix
3786            .filter(|_| extracted_token_payload.is_some())
3787            .map(ToOwned::to_owned);
3788        if token_suffix.is_some() && extracted_token_payload.is_none() {
3789            return Err(anyhow::anyhow!(
3790                "invalid compound ticket payload for endpoint ticket"
3791            ));
3792        }
3793        let endpoint_addr = parse_endpoint_ticket(iroh_ticket)?;
3794        self.remember_endpoint_addr(&endpoint_addr).await;
3795        let remote_node_id = endpoint_addr.id.to_string();
3796        let local_node_id = self
3797            .current_node_id()
3798            .await
3799            .ok_or_else(|| anyhow::anyhow!("Iroh node not initialized"))?;
3800        let connection_id = Self::deterministic_connection_id(&local_node_id, &remote_node_id);
3801        #[cfg(not(target_arch = "wasm32"))]
3802        let bilateral_user_device = extracted_token_payload
3803            .as_ref()
3804            .is_some_and(|payload| payload.scope.as_str() == "user-device");
3805        #[cfg(not(target_arch = "wasm32"))]
3806        let claimed_local_device_id = self
3807            .auto_connect_loop_key
3808            .lock()
3809            .unwrap_or_else(|poisoned| poisoned.into_inner())
3810            .as_ref()
3811            .map(|(_, device_id)| device_id.clone());
3812        #[cfg(target_arch = "wasm32")]
3813        self.set_connection_application_crypto_required(&connection_id);
3814        let trimmed_device_id = device_id
3815            .map(str::trim)
3816            .filter(|value| !value.is_empty())
3817            .map(ToOwned::to_owned);
3818        #[cfg(not(target_arch = "wasm32"))]
3819        if let Some(payload) = extracted_token_payload
3820            .as_ref()
3821            .filter(|payload| payload.scope.as_str() == "user-device")
3822        {
3823            self.update_native_route_repair_credential(
3824                &remote_node_id,
3825                trimmed_device_id.as_deref(),
3826                extracted_token.as_deref(),
3827                extracted_token_suffix.as_deref(),
3828                &payload.scope,
3829            );
3830        }
3831        let ensure_connect_intent_is_current = || -> anyhow::Result<()> {
3832            if clear_auto_connect_exclusion {
3833                if trimmed_device_id
3834                    .as_deref()
3835                    .is_some_and(|device_id| self.is_auto_connect_excluded(device_id))
3836                {
3837                    return Err(anyhow::anyhow!(
3838                        "explicit device connect was cancelled by manual disconnect"
3839                    ));
3840                }
3841                return Ok(());
3842            }
3843            let Some(device_id) = trimmed_device_id.as_deref() else {
3844                return Ok(());
3845            };
3846            let Some(authoritative_node_id) = self.authoritative_node_for_device(device_id) else {
3847                return Ok(());
3848            };
3849            if authoritative_node_id == remote_node_id {
3850                return Ok(());
3851            }
3852            Err(anyhow::anyhow!(
3853                "browser desired-peer generation is stale: device {device_id} now belongs to node {authoritative_node_id}, not {remote_node_id}"
3854            ))
3855        };
3856        if clear_auto_connect_exclusion {
3857            if let Some(device_id) = trimmed_device_id.as_deref() {
3858                // `connect_device` is the shared explicit-reconnect boundary for
3859                // native, Tauri, and WASM callers. Clear both local suppression and
3860                // the published remote-dial veto before attempting the new dial.
3861                self.unexclude_peer_and_publish(device_id).await;
3862            }
3863        }
3864        let connect_gate = self.managed_connect_gate(&connection_id).await;
3865        let _connect_guard = connect_gate.lock().await;
3866        ensure_connect_intent_is_current()?;
3867        let mut approved_scope: Option<String> = None;
3868        let log_phase = |phase: &str| {
3869            let message = format!(
3870                "[pluto-rtc][connect-device][phase] phase={} connection_id={} remote_node_id={} device_id={}",
3871                phase,
3872                connection_id,
3873                remote_node_id,
3874                trimmed_device_id.as_deref().unwrap_or("")
3875            );
3876            #[cfg(not(target_arch = "wasm32"))]
3877            eprintln!("{}", message);
3878            #[cfg(target_arch = "wasm32")]
3879            web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&message));
3880        };
3881        log_phase("gate-acquired");
3882
3883        // Short-circuit: if we already have an active, connected record for this
3884        // peer with a live transport, skip the Pending→Connecting→Connected cycle.
3885        if let Some(existing) = self
3886            .connection_manager
3887            .get_by_connection_id(&connection_id)
3888            .await
3889        {
3890            if matches!(
3891                existing.state,
3892                crate::connection_manager::ConnectionState::Connected
3893            ) && self.is_connection_transport_alive(endpoint_addr.id).await
3894            {
3895                // The transport is the source of truth. A delayed close event can
3896                // leave the managed record in replacement-pending with no stable
3897                // id even though Iroh has already installed a live successor.
3898                // Reconcile that record before returning from the idempotent
3899                // connect path so browser/Tauri callers cannot preserve stale
3900                // lifecycle state indefinitely.
3901                let live_transport_stable_id = self
3902                    .get_connection(endpoint_addr.id)
3903                    .await
3904                    .map(|connection| crate::transport_generation::for_connection(&connection));
3905                if let Some(live_transport_stable_id) = live_transport_stable_id {
3906                    // The physical Iroh owner may install a fresh inbound leg
3907                    // before the idempotent desired-peer actor observes it.
3908                    // Reconcile through the same generation-fenced commit used
3909                    // by initial accepts so stale admission proofs and
3910                    // application-key confirmation are retired before the
3911                    // replacement becomes visible as connected. Updating the
3912                    // record directly here can briefly expose product traffic
3913                    // with the preceding leg's key confirmation.
3914                    self.commit_current_native_transport_record(
3915                        &connection_id,
3916                        &remote_node_id,
3917                        trimmed_device_id.clone(),
3918                        Some(live_transport_stable_id),
3919                        Some("connect-device-existing-live-transport".to_string()),
3920                    )
3921                    .await;
3922                }
3923                if let Some(device_id) = trimmed_device_id.clone() {
3924                    self.mark_trusted_user_device_connection_admitted(&connection_id, &device_id)
3925                        .await;
3926                    self.reconcile_authoritative_device_node(&device_id, &remote_node_id)
3927                        .await;
3928                }
3929
3930                let current_remote_admission_scope = if let Some(token) = extracted_token.as_deref()
3931                {
3932                    self.current_remote_session_admission_scope(
3933                        &connection_id,
3934                        endpoint_addr.id,
3935                        token,
3936                    )
3937                    .await
3938                } else {
3939                    None
3940                };
3941                let has_remote_admission_proof = current_remote_admission_scope.is_some();
3942                if let Some(scope) = current_remote_admission_scope {
3943                    // The current physical generation already proved this exact
3944                    // ticket to the remote host. Preserve the host-authoritative
3945                    // scope in the idempotent result so a TypeScript connection
3946                    // adopted after the Rust dial can inherit the same admission
3947                    // without inventing trust from the ticket's local payload.
3948                    approved_scope = Some(scope);
3949                };
3950                // Scoped service peers may have only one assigned direction.
3951                // A changed bearer needs exact-token proof regardless of endpoint
3952                // ordering; only bilateral user-device repair elects a presenter.
3953                let needs_token_representation =
3954                    should_present_session_token_on_connected_transport(
3955                        extracted_token.is_some(),
3956                        has_remote_admission_proof,
3957                    );
3958                #[cfg(not(target_arch = "wasm32"))]
3959                let desired_presentation_decision = bilateral_user_device.then(|| {
3960                    let has_current_inbound_admission_proof =
3961                        live_transport_stable_id.is_some_and(|transport_stable_id| {
3962                            self.inbound_session_token_admitted_for_transport(
3963                                &connection_id,
3964                                transport_stable_id,
3965                            )
3966                        });
3967                    let peer_requested_reciprocal =
3968                        self.has_pending_reciprocal_session_admission_request(&connection_id);
3969                    super::auto_connect_impl::session_admission_presentation_decision(
3970                        extracted_token.is_some(),
3971                        has_remote_admission_proof,
3972                        has_current_inbound_admission_proof,
3973                        peer_requested_reciprocal,
3974                        clear_auto_connect_exclusion || local_node_id > remote_node_id,
3975                    )
3976                });
3977                #[cfg(not(target_arch = "wasm32"))]
3978                let needs_token_representation = desired_presentation_decision
3979                    .map(|decision| decision.should_present)
3980                    .unwrap_or(needs_token_representation);
3981
3982                if needs_token_representation {
3983                    if let Some(token) = extracted_token.as_ref() {
3984                        eprintln!(
3985                            "[PlutoRTC] Existing managed transport still awaiting session admission; re-presenting token connection_id={} remote_node_id={} token_fp={}",
3986                            connection_id,
3987                            remote_node_id,
3988                            super::core_impl::log_fingerprint(token.as_str())
3989                        );
3990                        #[cfg(not(target_arch = "wasm32"))]
3991                        let presentation_result =
3992                            if let Some(decision) = desired_presentation_decision {
3993                                self.present_and_accept_session_token_for_route_repair(
3994                                    endpoint_addr.id,
3995                                    &connection_id,
3996                                    token,
3997                                    extracted_token_suffix.as_deref(),
3998                                    trimmed_device_id.clone(),
3999                                    claimed_local_device_id.clone(),
4000                                    has_remote_admission_proof,
4001                                    decision.request_reciprocal,
4002                                )
4003                                .await
4004                            } else {
4005                                self.present_and_accept_session_token(
4006                                    endpoint_addr.id,
4007                                    &connection_id,
4008                                    token,
4009                                    extracted_token_suffix.as_deref(),
4010                                    trimmed_device_id.clone(),
4011                                )
4012                                .await
4013                            };
4014                        #[cfg(target_arch = "wasm32")]
4015                        let presentation_result = self
4016                            .present_and_accept_session_token(
4017                                endpoint_addr.id,
4018                                &connection_id,
4019                                token,
4020                                extracted_token_suffix.as_deref(),
4021                                trimmed_device_id.clone(),
4022                            )
4023                            .await;
4024                        match presentation_result {
4025                            Ok(approval_scope) => {
4026                                approved_scope = Some(approval_scope.clone());
4027                                eprintln!(
4028                                    "[PlutoRTC] Re-presented session-token on existing managed transport connection_id={} scope={} token_fp={} local_admission=accepted",
4029                                    connection_id,
4030                                    approval_scope,
4031                                    super::core_impl::log_fingerprint(token.as_str())
4032                                );
4033                            }
4034                            Err(error) => {
4035                                eprintln!(
4036                                    "[PlutoRTC] Failed to re-present session-token on existing managed transport connection_id={} error={}",
4037                                    connection_id,
4038                                    error
4039                                );
4040                            }
4041                        }
4042                        ensure_connect_intent_is_current()?;
4043                    }
4044                }
4045
4046                ensure_connect_intent_is_current()?;
4047                let _ = self
4048                    .confirm_managed_connection_readiness(&connection_id)
4049                    .await;
4050                ensure_connect_intent_is_current()?;
4051
4052                let current = self
4053                    .connection_manager
4054                    .get_by_connection_id(&connection_id)
4055                    .await
4056                    .ok_or_else(|| {
4057                        anyhow::anyhow!(
4058                            "managed connection was retired while existing transport settled"
4059                        )
4060                    })?;
4061                if !matches!(
4062                    current.state,
4063                    crate::connection_manager::ConnectionState::Connected
4064                ) || current.transport_stable_id != live_transport_stable_id
4065                    || !self.is_connection_transport_alive(endpoint_addr.id).await
4066                {
4067                    return Err(anyhow::anyhow!(
4068                        "managed connection was superseded while existing transport settled"
4069                    ));
4070                }
4071
4072                let snapshot = self.connection_manager.peer_snapshot(&connection_id).await;
4073                return Ok(ManagedConnectResult {
4074                    connection_id,
4075                    device_id: snapshot
4076                        .as_ref()
4077                        .and_then(|value| value.device_id.clone())
4078                        .or(trimmed_device_id.clone()),
4079                    device_id_hint: snapshot
4080                        .as_ref()
4081                        .and_then(|value| value.device_id_hint.clone())
4082                        .or(trimmed_device_id),
4083                    remote_node_id,
4084                    state: "connected".to_string(),
4085                    approved_scope,
4086                });
4087            }
4088        }
4089
4090        if clear_auto_connect_exclusion {
4091            self.connection_manager
4092                .upsert_pending(
4093                    connection_id.clone(),
4094                    Some(remote_node_id.clone()),
4095                    trimmed_device_id.clone(),
4096                    Some(remote_node_id.clone()),
4097                )
4098                .await;
4099            self.connection_manager.set_connecting(&connection_id).await;
4100        } else {
4101            let automatic_device_id = trimmed_device_id.clone();
4102            let current_user_device_assignment = if extracted_token_payload
4103                .as_ref()
4104                .is_some_and(|payload| payload.scope.as_str() == "user-device")
4105            {
4106                match (automatic_device_id.as_deref(), desired_peer_evidence) {
4107                    (Some(device_id), Some(evidence)) => {
4108                        self.current_desired_user_device_assignment_matches(
4109                            device_id,
4110                            &remote_node_id,
4111                            endpoint_ticket,
4112                            evidence,
4113                        )
4114                        .await
4115                    }
4116                    _ => false,
4117                }
4118            } else {
4119                false
4120            };
4121            let began = self
4122                .connection_manager
4123                .begin_automatic_connect(
4124                    connection_id.clone(),
4125                    Some(remote_node_id.clone()),
4126                    trimmed_device_id.clone(),
4127                    Some(remote_node_id.clone()),
4128                    |requires_fresh_assignment| {
4129                        automatic_device_id
4130                            .as_deref()
4131                            .is_none_or(|device_id| !self.is_auto_connect_excluded(device_id))
4132                            && (!requires_fresh_assignment
4133                                || extracted_token_payload.as_ref().is_some_and(|payload| {
4134                                    crate::session_token::matches_ticket(iroh_ticket, payload)
4135                                        && if payload.scope.as_str() == "user-device" {
4136                                            current_user_device_assignment
4137                                        } else {
4138                                            self.session_token_registry.is_peer_assigned_to_scope(
4139                                                payload.scope.as_str(),
4140                                                &remote_node_id,
4141                                            )
4142                                        }
4143                                }))
4144                    },
4145                )
4146                .await;
4147            if began.is_none() {
4148                return Err(anyhow::anyhow!(
4149                    "automatic managed connect was cancelled by terminal peer intent"
4150                ));
4151            }
4152            if current_user_device_assignment {
4153                let Some(device_id) = automatic_device_id.as_deref() else {
4154                    unreachable!("current user-device assignment requires a device id")
4155                };
4156                let Some(evidence) = desired_peer_evidence else {
4157                    unreachable!("current user-device assignment requires peer evidence")
4158                };
4159                if !self
4160                    .current_desired_user_device_assignment_matches(
4161                        device_id,
4162                        &remote_node_id,
4163                        endpoint_ticket,
4164                        evidence,
4165                    )
4166                    .await
4167                {
4168                    return Err(anyhow::anyhow!(
4169                        "automatic managed connect was superseded by newer desired-peer evidence"
4170                    ));
4171                }
4172            }
4173        }
4174        if let Some(device_id) = trimmed_device_id.as_deref() {
4175            self.reconcile_authoritative_device_node(device_id, &remote_node_id)
4176                .await;
4177        }
4178        log_phase("transport-dial-start");
4179
4180        self.ensure_connected_addr(endpoint_addr.id, endpoint_addr.clone())
4181            .await?;
4182        let transport_stable_id = self
4183            .get_connection(endpoint_addr.id)
4184            .await
4185            .map(|connection| crate::transport_generation::for_connection(&connection));
4186        if let Err(error) = ensure_connect_intent_is_current() {
4187            // Stop may arrive while the transport dial is awaiting the network,
4188            // before disconnect_device can observe a physical generation. Close
4189            // only the generation created by this attempt so a later explicit
4190            // reconnect cannot be retired by the older cancellation.
4191            self.close_cancelled_connect(endpoint_addr.id, transport_stable_id)
4192                .await;
4193            return Err(error);
4194        }
4195        log_phase("transport-dial-complete");
4196
4197        log_phase("transport-record-start");
4198        let transport_committed = self
4199            .commit_current_native_transport_record(
4200                &connection_id,
4201                &remote_node_id,
4202                trimmed_device_id.clone(),
4203                transport_stable_id,
4204                None,
4205            )
4206            .await;
4207        if !transport_committed {
4208            if let Err(error) = ensure_connect_intent_is_current() {
4209                self.close_cancelled_connect(endpoint_addr.id, transport_stable_id)
4210                    .await;
4211                return Err(error);
4212            }
4213        }
4214        log_phase("transport-record-complete");
4215        log_phase("transport-health-start");
4216        let _ = self
4217            .confirm_managed_connection_readiness(&connection_id)
4218            .await;
4219        ensure_connect_intent_is_current()?;
4220        log_phase("transport-health-complete");
4221        if let Some(device_id) = trimmed_device_id.clone() {
4222            log_phase("trusted-admission-start");
4223            self.mark_trusted_user_device_connection_admitted(&connection_id, &device_id)
4224                .await;
4225            ensure_connect_intent_is_current()?;
4226            log_phase("trusted-admission-complete");
4227            log_phase("stale-record-prune-start");
4228            self.reconcile_authoritative_device_node(&device_id, &remote_node_id)
4229                .await;
4230            log_phase("stale-record-prune-complete");
4231        }
4232
4233        if let Some(ref token) = extracted_token {
4234            #[cfg(not(target_arch = "wasm32"))]
4235            let desired_presentation_decision = bilateral_user_device.then(|| {
4236                let has_remote_admission_proof =
4237                    transport_stable_id.is_some_and(|transport_stable_id| {
4238                        self.remote_session_token_admitted_for_transport(
4239                            &connection_id,
4240                            transport_stable_id,
4241                        )
4242                    });
4243                let has_current_inbound_admission_proof =
4244                    transport_stable_id.is_some_and(|transport_stable_id| {
4245                        self.inbound_session_token_admitted_for_transport(
4246                            &connection_id,
4247                            transport_stable_id,
4248                        )
4249                    });
4250                let peer_requested_reciprocal =
4251                    self.has_pending_reciprocal_session_admission_request(&connection_id);
4252                super::auto_connect_impl::session_admission_presentation_decision(
4253                    true,
4254                    has_remote_admission_proof,
4255                    has_current_inbound_admission_proof,
4256                    peer_requested_reciprocal,
4257                    clear_auto_connect_exclusion || local_node_id > remote_node_id,
4258                )
4259            });
4260            #[cfg(not(target_arch = "wasm32"))]
4261            let should_present = desired_presentation_decision
4262                .map(|decision| decision.should_present)
4263                .unwrap_or(true);
4264            #[cfg(target_arch = "wasm32")]
4265            let should_present = true;
4266
4267            if should_present {
4268                #[cfg(not(target_arch = "wasm32"))]
4269                let presentation_result = if let Some(decision) = desired_presentation_decision {
4270                    self.present_and_accept_session_token_for_route_repair(
4271                        endpoint_addr.id,
4272                        &connection_id,
4273                        token,
4274                        extracted_token_suffix.as_deref(),
4275                        trimmed_device_id.clone(),
4276                        claimed_local_device_id.clone(),
4277                        false,
4278                        decision.request_reciprocal,
4279                    )
4280                    .await
4281                } else {
4282                    self.present_and_accept_session_token(
4283                        endpoint_addr.id,
4284                        &connection_id,
4285                        token,
4286                        extracted_token_suffix.as_deref(),
4287                        trimmed_device_id.clone(),
4288                    )
4289                    .await
4290                };
4291                #[cfg(target_arch = "wasm32")]
4292                let presentation_result = self
4293                    .present_and_accept_session_token(
4294                        endpoint_addr.id,
4295                        &connection_id,
4296                        token,
4297                        extracted_token_suffix.as_deref(),
4298                        trimmed_device_id.clone(),
4299                    )
4300                    .await;
4301                match presentation_result {
4302                    Ok(approval_scope) => {
4303                        approved_scope = Some(approval_scope.clone());
4304                        eprintln!(
4305                            "[PlutoRTC] Sent session-token presentation to host and received approval connection_id={} scope={} token_fp={} local_admission=accepted",
4306                            connection_id,
4307                            approval_scope,
4308                            super::core_impl::log_fingerprint(token.as_str())
4309                        );
4310                    }
4311                    Err(error) => {
4312                        eprintln!(
4313                            "[PlutoRTC] Failed to send session-token presentation connection_id={} token_fp={} error={}",
4314                            connection_id,
4315                            super::core_impl::log_fingerprint(token.as_str()),
4316                            error
4317                        );
4318                    }
4319                }
4320            } else {
4321                eprintln!(
4322                    "[pluto-rtc][connect-device][session-admission] awaiting elected presenter on desired transport connection_id={} remote_node_id={} local_node_id={}",
4323                        connection_id,
4324                    remote_node_id,
4325                    local_node_id,
4326                );
4327            }
4328            let _ = self
4329                .confirm_managed_connection_readiness(&connection_id)
4330                .await;
4331            ensure_connect_intent_is_current()?;
4332        }
4333
4334        ensure_connect_intent_is_current()?;
4335        let snapshot = self.connection_manager.peer_snapshot(&connection_id).await;
4336        let state = match snapshot.as_ref().map(|value| &value.status) {
4337            Some(crate::connection_manager::ConnectionState::Pending)
4338            | Some(crate::connection_manager::ConnectionState::Connecting) => "connecting",
4339            Some(crate::connection_manager::ConnectionState::Connected) => "connected",
4340            Some(crate::connection_manager::ConnectionState::Failed) => "failed",
4341            Some(crate::connection_manager::ConnectionState::Closed)
4342            | Some(crate::connection_manager::ConnectionState::Closing) => "closed",
4343            None => "connected",
4344        }
4345        .to_string();
4346        log_phase("return");
4347
4348        Ok(ManagedConnectResult {
4349            connection_id,
4350            device_id: snapshot
4351                .as_ref()
4352                .and_then(|value| value.device_id.clone())
4353                .or(trimmed_device_id.clone()),
4354            device_id_hint: snapshot
4355                .as_ref()
4356                .and_then(|value| value.device_id_hint.clone())
4357                .or(trimmed_device_id),
4358            remote_node_id,
4359            state,
4360            approved_scope,
4361        })
4362    }
4363
4364    pub async fn managed_connection_health(
4365        &self,
4366        connection_id: &str,
4367    ) -> Option<ConnectionHealthView> {
4368        let record = self
4369            .connection_manager
4370            .get_by_connection_id(connection_id)
4371            .await?;
4372        let peer_snapshot = self.connection_manager.peer_snapshot(connection_id).await;
4373
4374        let settled_ready = peer_snapshot
4375            .as_ref()
4376            .map(peer_snapshot_settled_ready)
4377            .unwrap_or(false);
4378        let admission_block = self.session_admission_block_reason_for_transport(
4379            connection_id,
4380            record.transport_stable_id,
4381        );
4382        let admitted_settled_ready = settled_ready && admission_block.is_none();
4383        let settle_deadline_elapsed = matches!(
4384            record.state,
4385            crate::connection_manager::ConnectionState::Connected
4386        ) && !admitted_settled_ready
4387            && now_millis_i64().saturating_sub(record.last_transport_change_at_ms)
4388                >= MANAGED_SETTLE_DEADLINE_MS;
4389
4390        let status = match record.state {
4391            crate::connection_manager::ConnectionState::Pending
4392            | crate::connection_manager::ConnectionState::Connecting => {
4393                ManagedHealth::AwaitingReplacement
4394            }
4395            crate::connection_manager::ConnectionState::Connected => {
4396                #[cfg(not(target_arch = "wasm32"))]
4397                {
4398                    if admitted_settled_ready {
4399                        ManagedHealth::Healthy
4400                    } else if settle_deadline_elapsed {
4401                        ManagedHealth::Dead
4402                    } else {
4403                        ManagedHealth::AwaitingReplacement
4404                    }
4405                }
4406                #[cfg(target_arch = "wasm32")]
4407                {
4408                    let peer_health = peer_snapshot
4409                        .as_ref()
4410                        .map(|snapshot| snapshot.health.clone());
4411                    match peer_health
4412                        .unwrap_or(crate::connection_manager::ConnectionHealth::Unknown)
4413                    {
4414                        crate::connection_manager::ConnectionHealth::Healthy
4415                            if admitted_settled_ready =>
4416                        {
4417                            ManagedHealth::Healthy
4418                        }
4419                        _ if settle_deadline_elapsed => ManagedHealth::Dead,
4420                        crate::connection_manager::ConnectionHealth::Unknown
4421                        | crate::connection_manager::ConnectionHealth::Suspect
4422                        | crate::connection_manager::ConnectionHealth::Stale
4423                        | crate::connection_manager::ConnectionHealth::Healthy => {
4424                            ManagedHealth::AwaitingReplacement
4425                        }
4426                    }
4427                }
4428            }
4429            crate::connection_manager::ConnectionState::Closing
4430            | crate::connection_manager::ConnectionState::Closed
4431            | crate::connection_manager::ConnectionState::Failed => ManagedHealth::Dead,
4432        };
4433
4434        let readiness_state = connection_readiness_state(&record, peer_snapshot.as_ref());
4435        let readiness_reason = connection_readiness_reason(&record, peer_snapshot.as_ref());
4436        let replacement_pending = matches!(readiness_state, ReadinessState::AwaitingReplacement);
4437        let last_lifecycle_transition_at_ms = record
4438            .last_state_change_at_ms
4439            .max(record.last_transport_change_at_ms)
4440            .max(record.last_route_change_at_ms);
4441
4442        let mut snapshot = ConnectionHealthView {
4443            connection_id: record.connection_id,
4444            device_id: record.device_id,
4445            device_id_hint: record.device_id_hint,
4446            node_id: record.node_id,
4447            active_transport_stable_id: record.transport_stable_id,
4448            transport_generation: record.transport_generation,
4449            route_generation: record.route_generation,
4450            status,
4451            settled_ready: admitted_settled_ready,
4452            readiness_state,
4453            replacement_pending,
4454            last_lifecycle_transition_at_ms,
4455            readiness_reason,
4456            transition_count: record.transition_count,
4457            connecting_transition_count: record.connecting_transition_count,
4458            replacement_count: record.replacement_count,
4459            retire_count: record.retire_count,
4460            last_disconnect_reason: record.last_disconnect_reason,
4461            last_reconnect_reason: record.last_reconnect_reason,
4462        };
4463
4464        if let Some((rejected, reason)) = admission_block {
4465            snapshot.settled_ready = false;
4466            snapshot.replacement_pending = false;
4467            snapshot.readiness_reason = reason.clone();
4468            if rejected {
4469                snapshot.status = ManagedHealth::Dead;
4470                snapshot.readiness_state = ReadinessState::Failed;
4471            } else {
4472                snapshot.status = ManagedHealth::AwaitingReplacement;
4473                snapshot.readiness_state = ReadinessState::Settling;
4474            }
4475        }
4476
4477        Some(snapshot)
4478    }
4479
4480    #[cfg(not(target_arch = "wasm32"))]
4481    pub async fn managed_connection_bridge_action(
4482        &self,
4483        connection_id: &str,
4484    ) -> Option<BridgeAction> {
4485        let snapshot = self.managed_connection_health(connection_id).await?;
4486        let record = self
4487            .connection_manager
4488            .get_by_connection_id(connection_id)
4489            .await?;
4490        let settle_deadline_elapsed = matches!(
4491            record.state,
4492            crate::connection_manager::ConnectionState::Connected
4493        ) && !snapshot.settled_ready
4494            && now_millis_i64().saturating_sub(record.last_transport_change_at_ms)
4495                >= MANAGED_SETTLE_DEADLINE_MS;
4496        let action = match snapshot.status {
4497            ManagedHealth::Healthy => BridgeAction::Healthy,
4498            ManagedHealth::AwaitingReplacement => {
4499                if settle_deadline_elapsed {
4500                    let timeout_reason = if matches!(
4501                        self.session_admission(connection_id),
4502                        crate::session_token::SessionAdmission::Pending
4503                    ) {
4504                        "session-admission-timeout"
4505                    } else {
4506                        "settle-timeout"
4507                    };
4508                    BridgeAction::Retire {
4509                        reason: timeout_reason.to_string(),
4510                    }
4511                } else if snapshot
4512                    .node_id
4513                    .as_deref()
4514                    .and_then(|value| value.parse::<iroh::EndpointId>().ok())
4515                    .is_some()
4516                {
4517                    BridgeAction::Rebind {
4518                        remote_node_id: snapshot.node_id,
4519                        transport_generation: snapshot.transport_generation,
4520                    }
4521                } else {
4522                    BridgeAction::AwaitReplacement {
4523                        reason: snapshot.readiness_reason.clone(),
4524                    }
4525                }
4526            }
4527            ManagedHealth::Dead => {
4528                if settle_deadline_elapsed {
4529                    BridgeAction::Retire {
4530                        reason: "settle-timeout".to_string(),
4531                    }
4532                } else {
4533                    BridgeAction::Retire {
4534                        reason: "managed transport no longer alive".to_string(),
4535                    }
4536                }
4537            }
4538        };
4539        Some(action)
4540    }
4541
4542    #[cfg(not(target_arch = "wasm32"))]
4543    pub async fn send_message(
4544        &self,
4545        target_id: &str,
4546        payload: &str,
4547        state: Option<&str>,
4548        reply_payload: Option<&str>,
4549    ) -> anyhow::Result<String> {
4550        let node_guard = self.node_id.read().await;
4551        if let Some(node_id) = node_guard.as_deref() {
4552            self.signaling
4553                .send_message(node_id, target_id, payload, state, reply_payload)
4554                .await
4555        } else {
4556            Err(anyhow::anyhow!("Node ID not set"))
4557        }
4558    }
4559
4560    #[cfg(target_arch = "wasm32")]
4561    pub async fn send_message(
4562        &self,
4563        _target_id: &str,
4564        _payload: &str,
4565        _state: Option<&str>,
4566        _reply_payload: Option<&str>,
4567    ) -> anyhow::Result<String> {
4568        Err(Self::deprecated_browser_signaling_error("send_message"))
4569    }
4570
4571    #[cfg(not(target_arch = "wasm32"))]
4572    pub async fn subscribe_devices(
4573        &self,
4574        user_id: &str,
4575    ) -> anyhow::Result<
4576        futures::stream::BoxStream<'static, anyhow::Result<Vec<crate::signaling::DeviceEvent>>>,
4577    > {
4578        self.signaling.subscribe_devices(user_id).await
4579    }
4580
4581    #[cfg(target_arch = "wasm32")]
4582    pub async fn subscribe_devices(
4583        &self,
4584        _user_id: &str,
4585    ) -> anyhow::Result<
4586        futures::stream::BoxStream<'static, anyhow::Result<Vec<crate::signaling::DeviceEvent>>>,
4587    > {
4588        Err(Self::deprecated_browser_signaling_error(
4589            "subscribe_devices",
4590        ))
4591    }
4592
4593    #[cfg(not(target_arch = "wasm32"))]
4594    pub async fn create_session(
4595        &self,
4596        mut session: crate::signaling::SignalingSession,
4597    ) -> anyhow::Result<()> {
4598        if session.app_tag.is_none() {
4599            session.app_tag = Some(self.app_tag.clone());
4600        }
4601        self.signaling.create_session(session).await
4602    }
4603
4604    #[cfg(target_arch = "wasm32")]
4605    pub async fn create_session(
4606        &self,
4607        _session: crate::signaling::SignalingSession,
4608    ) -> anyhow::Result<()> {
4609        Err(Self::deprecated_browser_signaling_error("create_session"))
4610    }
4611
4612    #[cfg(not(target_arch = "wasm32"))]
4613    pub async fn update_session(
4614        &self,
4615        session_id: &str,
4616        update: serde_json::Value,
4617    ) -> anyhow::Result<()> {
4618        self.signaling.update_session(session_id, update).await
4619    }
4620
4621    #[cfg(target_arch = "wasm32")]
4622    pub async fn update_session(
4623        &self,
4624        _session_id: &str,
4625        _update: serde_json::Value,
4626    ) -> anyhow::Result<()> {
4627        Err(Self::deprecated_browser_signaling_error("update_session"))
4628    }
4629
4630    #[cfg(not(target_arch = "wasm32"))]
4631    pub async fn subscribe_sessions(
4632        &self,
4633        local_device_id: &str,
4634    ) -> anyhow::Result<
4635        futures::stream::BoxStream<'static, anyhow::Result<Vec<crate::signaling::SessionEvent>>>,
4636    > {
4637        self.signaling.subscribe_sessions(local_device_id).await
4638    }
4639
4640    #[cfg(target_arch = "wasm32")]
4641    pub async fn subscribe_sessions(
4642        &self,
4643        _local_device_id: &str,
4644    ) -> anyhow::Result<
4645        futures::stream::BoxStream<'static, anyhow::Result<Vec<crate::signaling::SessionEvent>>>,
4646    > {
4647        Err(Self::deprecated_browser_signaling_error(
4648            "subscribe_sessions",
4649        ))
4650    }
4651
4652    #[cfg(not(target_arch = "wasm32"))]
4653    pub fn start_signaling_loop(
4654        self: std::sync::Arc<Self>,
4655        user_id: String,
4656        device_name: String,
4657        ticket: String,
4658        metadata: Option<String>,
4659    ) -> tokio::task::JoinHandle<()> {
4660        let (handle, _readiness) = self.start_native_presence_loop(
4661            user_id,
4662            device_name,
4663            NativePresenceTicketPolicy::Fixed(ticket),
4664            metadata,
4665        );
4666        handle
4667    }
4668
4669    #[cfg(not(target_arch = "wasm32"))]
4670    pub async fn start_user_presence(
4671        self: std::sync::Arc<Self>,
4672        user_id: String,
4673        device_name: String,
4674        metadata: Option<String>,
4675    ) -> anyhow::Result<tokio::task::JoinHandle<()>> {
4676        const INITIAL_PRESENCE_READY_TIMEOUT_SECS: u64 = 15;
4677
4678        let owner = self.clone();
4679        let (handle, readiness) = self.start_native_presence_loop(
4680            user_id,
4681            device_name,
4682            NativePresenceTicketPolicy::ManagedUserDevice,
4683            metadata,
4684        );
4685        match tokio::time::timeout(
4686            std::time::Duration::from_secs(INITIAL_PRESENCE_READY_TIMEOUT_SECS),
4687            readiness,
4688        )
4689        .await
4690        {
4691            Ok(Ok(())) => Ok(handle),
4692            Ok(Err(_)) => {
4693                owner.stop_presence_loop();
4694                handle.abort();
4695                Err(anyhow::anyhow!(
4696                    "native presence actor exited before initial publication completed"
4697                ))
4698            }
4699            Err(_) => {
4700                owner.stop_presence_loop();
4701                handle.abort();
4702                Err(anyhow::anyhow!(
4703                    "native presence did not publish device and lease records within {} seconds",
4704                    INITIAL_PRESENCE_READY_TIMEOUT_SECS
4705                ))
4706            }
4707        }
4708    }
4709
4710    #[cfg(not(target_arch = "wasm32"))]
4711    fn start_native_presence_loop(
4712        self: std::sync::Arc<Self>,
4713        user_id: String,
4714        device_name: String,
4715        ticket_policy: NativePresenceTicketPolicy,
4716        metadata: Option<String>,
4717    ) -> (
4718        tokio::task::JoinHandle<()>,
4719        tokio::sync::oneshot::Receiver<()>,
4720    ) {
4721        let (tx, mut rx) = tokio::sync::mpsc::channel(8);
4722        let actor_tx = tx.clone();
4723        let (readiness_tx, readiness_rx) = tokio::sync::oneshot::channel();
4724
4725        let replaced_sender = {
4726            let mut guard = self.presence_loop_tx.lock().unwrap();
4727            guard.replace(tx)
4728        };
4729        let replaced_existing_actor = replaced_sender.is_some();
4730        if let Some(replaced_sender) = replaced_sender {
4731            let _ = replaced_sender.try_send(crate::presence::PresenceCommand::Stop);
4732        }
4733
4734        let handle = tokio::spawn(async move {
4735            const DURABLE_DEVICE_RECORD_TTL_MS: u64 =
4736                crate::presence_policy::DEVICE_OFFLINE_RETENTION_MS as u64;
4737            let mut durable_registered = false;
4738            let mut live_registered = false;
4739            let mut consecutive_publish_failures = 0_u32;
4740            let mut readiness_tx = Some(readiness_tx);
4741            let policy_label = match &ticket_policy {
4742                NativePresenceTicketPolicy::Fixed(_) => "fixed",
4743                NativePresenceTicketPolicy::ManagedUserDevice => "managed-user-device",
4744            };
4745            eprintln!(
4746                "[pluto-rtc][presence][actor-start] user_id={} device_name={} policy={} replaced_existing_actor={}",
4747                user_id, device_name, policy_label, replaced_existing_actor
4748            );
4749            let mut retry_interval =
4750                tokio::time::interval(native_presence_retry_delay(consecutive_publish_failures));
4751            retry_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
4752            loop {
4753                tokio::select! {
4754                    _ = retry_interval.tick(), if !durable_registered || !live_registered => {
4755                        let (publish_durable, publish_live) =
4756                            native_presence_retry_targets(durable_registered, live_registered);
4757                        let (durable_ok, live_ok) = self
4758                            .publish_native_presence_with_policy_once(
4759                                if durable_registered || live_registered {
4760                                    "initial-retry"
4761                                } else {
4762                                    "initial"
4763                                },
4764                                &user_id,
4765                                &device_name,
4766                                &ticket_policy,
4767                                DURABLE_DEVICE_RECORD_TTL_MS,
4768                                metadata.as_deref(),
4769                                publish_durable,
4770                                publish_live,
4771                            )
4772                            .await;
4773                        durable_registered = durable_registered || durable_ok;
4774                        live_registered = live_registered || live_ok;
4775                        if native_presence_is_ready(durable_registered, live_registered) {
4776                            consecutive_publish_failures = 0;
4777                            if let Some(readiness_tx) = readiness_tx.take() {
4778                                let _ = readiness_tx.send(());
4779                                eprintln!(
4780                                    "[pluto-rtc][presence][actor-ready] user_id={} device_name={} policy={}",
4781                                    user_id, device_name, policy_label
4782                                );
4783                            }
4784                        } else {
4785                            let delay = native_presence_retry_delay(consecutive_publish_failures);
4786                            consecutive_publish_failures =
4787                                consecutive_publish_failures.saturating_add(1);
4788                            retry_interval.reset_after(delay);
4789                        }
4790                    }
4791                    cmd = rx.recv() => {
4792                        match cmd {
4793                            Some(crate::presence::PresenceCommand::RefreshLiveNow) => {
4794                                let (_, live_ok) = self
4795                                    .publish_native_presence_with_policy_once(
4796                                        "live-refresh-now",
4797                                        &user_id,
4798                                        &device_name,
4799                                        &ticket_policy,
4800                                        DURABLE_DEVICE_RECORD_TTL_MS,
4801                                        metadata.as_deref(),
4802                                        false,
4803                                        true,
4804                                )
4805                                .await;
4806                                live_registered = live_ok;
4807                                if !live_registered {
4808                                    consecutive_publish_failures = 0;
4809                                    retry_interval.reset_after(native_presence_retry_delay(0));
4810                                }
4811                            }
4812                            Some(crate::presence::PresenceCommand::RepublishDurableNow) => {
4813                                let (durable_ok, live_ok) = self
4814                                    .publish_native_presence_with_policy_once(
4815                                        "durable-republish-now",
4816                                        &user_id,
4817                                        &device_name,
4818                                        &ticket_policy,
4819                                        DURABLE_DEVICE_RECORD_TTL_MS,
4820                                        metadata.as_deref(),
4821                                        true,
4822                                        true,
4823                                )
4824                                .await;
4825                                durable_registered = durable_ok;
4826                                live_registered = live_ok;
4827                                if native_presence_is_ready(durable_registered, live_registered) {
4828                                    consecutive_publish_failures = 0;
4829                                } else {
4830                                    consecutive_publish_failures = 0;
4831                                    retry_interval.reset_after(native_presence_retry_delay(0));
4832                                }
4833                            }
4834                            Some(crate::presence::PresenceCommand::Stop) => {
4835                                eprintln!(
4836                                    "[pluto-rtc][presence][actor-stop] user_id={} device_name={} reason=command",
4837                                    user_id, device_name
4838                                );
4839                                break;
4840                            }
4841                            None => {
4842                                eprintln!(
4843                                    "[pluto-rtc][presence][actor-stop] user_id={} device_name={} reason=sender-replaced-or-dropped",
4844                                    user_id, device_name
4845                                );
4846                                break;
4847                            }
4848                        }
4849                    }
4850                }
4851            }
4852
4853            let mut guard = self.presence_loop_tx.lock().unwrap();
4854            if guard
4855                .as_ref()
4856                .is_some_and(|active_tx| active_tx.same_channel(&actor_tx))
4857            {
4858                guard.take();
4859            }
4860        });
4861
4862        (handle, readiness_rx)
4863    }
4864
4865    #[cfg(target_arch = "wasm32")]
4866    pub fn start_signaling_loop(
4867        self: std::sync::Arc<Self>,
4868        _user_id: String,
4869        _device_name: String,
4870        _ticket: String,
4871        _metadata: Option<String>,
4872    ) {
4873        let (tx, mut rx) = tokio::sync::mpsc::channel(8);
4874        {
4875            let mut guard = self.presence_loop_tx.lock().unwrap();
4876            *guard = Some(tx);
4877        }
4878
4879        wasm_bindgen_futures::spawn_local(async move {
4880            loop {
4881                tokio::select! {
4882                    cmd = rx.recv() => {
4883                        match cmd {
4884                            Some(crate::presence::PresenceCommand::RefreshLiveNow)
4885                            | Some(crate::presence::PresenceCommand::RepublishDurableNow) => {
4886                                web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(
4887                                    "[OpenRTC] Rust browser presence is disabled; the TypeScript coordination gateway owns browser leases and device projection.",
4888                                ));
4889                            }
4890                            Some(crate::presence::PresenceCommand::Stop) | None => {
4891                                break;
4892                            }
4893                        }
4894                    }
4895                }
4896            }
4897        });
4898    }
4899
4900    #[cfg(not(target_arch = "wasm32"))]
4901    pub fn start_auto_connect(self: Arc<Self>, user_id: String, local_device_id: String) {
4902        let key = (user_id.clone(), local_device_id.clone());
4903        let generation = {
4904            let mut guard = match self.auto_connect_loop_key.lock() {
4905                Ok(guard) => guard,
4906                Err(poisoned) => poisoned.into_inner(),
4907            };
4908
4909            if guard.as_ref() == Some(&key) {
4910                #[cfg(not(target_arch = "wasm32"))]
4911                eprintln!(
4912                    "[pluto-rtc][auto-connect] duplicate start ignored user_id={} local_device_id={}",
4913                    user_id, local_device_id
4914                );
4915                #[cfg(target_arch = "wasm32")]
4916                web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
4917                    "[pluto-rtc][auto-connect] duplicate start ignored user_id={} local_device_id={}",
4918                    user_id, local_device_id
4919                )));
4920                return;
4921            }
4922
4923            self.known_device_ids_by_node
4924                .write()
4925                .unwrap_or_else(|poisoned| poisoned.into_inner())
4926                .clear();
4927            self.native_route_repair_credentials
4928                .write()
4929                .unwrap_or_else(|poisoned| poisoned.into_inner())
4930                .clear();
4931            self.scoped_route_repair_credentials
4932                .write()
4933                .unwrap_or_else(|poisoned| poisoned.into_inner())
4934                .clear();
4935            *guard = Some(key);
4936            self.auto_connect_generation.fetch_add(1, Ordering::SeqCst) + 1
4937        };
4938
4939        let client_clone = self.clone();
4940
4941        #[cfg(not(target_arch = "wasm32"))]
4942        tokio::spawn(async move {
4943            client_clone
4944                .auto_connect_loop(user_id, local_device_id, generation)
4945                .await;
4946        });
4947
4948        #[cfg(target_arch = "wasm32")]
4949        wasm_bindgen_futures::spawn_local(async move {
4950            client_clone
4951                .auto_connect_loop(user_id, local_device_id, generation)
4952                .await;
4953        });
4954    }
4955
4956    #[cfg(target_arch = "wasm32")]
4957    pub fn start_auto_connect(self: Arc<Self>, user_id: String, local_device_id: String) {
4958        // The WASM runtime does not run the Rust auto-connect polling loop
4959        // (that job lives in the TypeScript coordination gateway adapter),
4960        // but we still need the (user_id, local_device_id) context recorded so
4961        // directory-backed lookups like
4962        // `known_remote_device_id_for_incoming_transport` can call
4963        // `search_devices` to resolve an incoming iroh node id to a device id
4964        // on inbound accept.
4965        let key = (user_id, local_device_id);
4966        let mut guard = match self.auto_connect_loop_key.lock() {
4967            Ok(guard) => guard,
4968            Err(poisoned) => poisoned.into_inner(),
4969        };
4970        if guard.as_ref() != Some(&key) {
4971            self.known_device_ids_by_node
4972                .write()
4973                .unwrap_or_else(|poisoned| poisoned.into_inner())
4974                .clear();
4975            self.scoped_route_repair_credentials
4976                .write()
4977                .unwrap_or_else(|poisoned| poisoned.into_inner())
4978                .clear();
4979        }
4980        *guard = Some(key);
4981        self.auto_connect_generation
4982            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4983    }
4984
4985    #[cfg(target_arch = "wasm32")]
4986    fn deprecated_browser_signaling_error(operation: &str) -> anyhow::Error {
4987        Self::warn_deprecated_browser_signaling(operation);
4988        anyhow::anyhow!(
4989            "The wasm {} direct signaling path has been retired. Use the TypeScript coordination gateway adapter.",
4990            operation
4991        )
4992    }
4993
4994    #[cfg(target_arch = "wasm32")]
4995    fn warn_deprecated_browser_signaling(operation: &str) {
4996        web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
4997            "[OpenRTC] {} is deprecated in browsers. Use the TypeScript coordination gateway path.",
4998            operation
4999        )));
5000    }
5001
5002    pub fn force_reconnect_snapshot(self: Arc<Self>) {
5003        if self.is_app_execution_suspended() {
5004            #[cfg(target_arch = "wasm32")]
5005            web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(
5006                "[Client] force_reconnect_snapshot skipped while app is backgrounded",
5007            ));
5008            #[cfg(not(target_arch = "wasm32"))]
5009            eprintln!("[Client] force_reconnect_snapshot skipped while app is backgrounded");
5010            return;
5011        }
5012
5013        if let Some(tx) = self.presence_loop_tx.lock().unwrap().clone() {
5014            let _ = tx.try_send(crate::presence::PresenceCommand::RepublishDurableNow);
5015            #[cfg(target_arch = "wasm32")]
5016            web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(
5017                "[Client] Forced presence update triggered",
5018            ));
5019            #[cfg(not(target_arch = "wasm32"))]
5020            eprintln!("[Client] Forced presence update triggered");
5021        }
5022
5023        #[cfg(target_arch = "wasm32")]
5024        {
5025            web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(
5026                "[Client] force_reconnect_snapshot skipped browser auto-connect loop; the TypeScript coordination gateway owns device snapshots",
5027            ));
5028            return;
5029        }
5030
5031        #[cfg(not(target_arch = "wasm32"))]
5032        {
5033            let (user_id, local_device_id) = {
5034                let guard = match self.auto_connect_loop_key.lock() {
5035                    Ok(guard) => guard,
5036                    Err(poisoned) => poisoned.into_inner(),
5037                };
5038                match guard.as_ref() {
5039                    Some((uid, did)) => (uid.clone(), did.clone()),
5040                    None => return, // No auto-connect loop running
5041                }
5042            };
5043
5044            let generation = self
5045                .auto_connect_generation
5046                .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
5047                + 1;
5048            let client_clone = self.clone();
5049
5050            tokio::spawn(async move {
5051                client_clone
5052                    .auto_connect_loop(user_id, local_device_id, generation)
5053                    .await;
5054            });
5055        }
5056    }
5057
5058    pub fn stop_presence_loop(&self) {
5059        let tx = {
5060            let mut guard = self.presence_loop_tx.lock().unwrap();
5061            guard.take()
5062        };
5063
5064        if let Some(tx) = tx {
5065            let _ = tx.try_send(crate::presence::PresenceCommand::Stop);
5066        }
5067    }
5068
5069    pub fn request_presence_update(&self) -> bool {
5070        self.presence_loop_tx
5071            .lock()
5072            .unwrap()
5073            .as_ref()
5074            .is_some_and(|tx| {
5075                tx.try_send(crate::presence::PresenceCommand::RefreshLiveNow)
5076                    .is_ok()
5077            })
5078    }
5079
5080    pub fn stop_auto_connect(&self) {
5081        let mut guard = match self.auto_connect_loop_key.lock() {
5082            Ok(guard) => guard,
5083            Err(poisoned) => poisoned.into_inner(),
5084        };
5085        *guard = None;
5086
5087        self.auto_connect_generation
5088            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5089        #[cfg(not(target_arch = "wasm32"))]
5090        self.native_auto_connect_wake.notify_waiters();
5091        #[cfg(not(target_arch = "wasm32"))]
5092        self.native_route_repair_credentials
5093            .write()
5094            .unwrap_or_else(|poisoned| poisoned.into_inner())
5095            .clear();
5096        self.scoped_route_repair_credentials
5097            .write()
5098            .unwrap_or_else(|poisoned| poisoned.into_inner())
5099            .clear();
5100    }
5101
5102    pub fn stop_auth_scoped_activity(&self) {
5103        self.stop_presence_loop();
5104        self.stop_auto_connect();
5105    }
5106
5107    #[cfg(not(target_arch = "wasm32"))]
5108    pub(crate) fn is_auto_connect_generation_current(&self, generation: u64) -> bool {
5109        self.auto_connect_generation.load(Ordering::SeqCst) == generation
5110    }
5111
5112    /// Exclude or re-include a device from the auto-connect loop.
5113    ///
5114    /// Exclusion is session-scoped: it lives only in memory and is reset on process restart.
5115    /// Calling with `excluded = false` clears any prior exclusion for that device.
5116    pub fn set_auto_connect_excluded(&self, device_id: &str, excluded: bool) {
5117        self.set_auto_connect_excluded_peer(device_id, None, excluded);
5118    }
5119
5120    /// Exclude or re-include a device plus an optional node-id alias.
5121    ///
5122    /// The device id is the canonical key and is the only value published to
5123    /// signaling. The node id is a local guard for in-flight/native records that
5124    /// may still be keyed by endpoint/node id when a manual disconnect races
5125    /// auto-connect.
5126    pub(crate) fn set_auto_connect_excluded_peer(
5127        &self,
5128        device_id: &str,
5129        node_id: Option<&str>,
5130        excluded: bool,
5131    ) {
5132        let _owner = match self.auto_connect_exclusion_owner.lock() {
5133            Ok(owner) => owner,
5134            Err(poisoned) => poisoned.into_inner(),
5135        };
5136        let Some(device_id) = Self::normalize_auto_connect_exclusion_key(device_id) else {
5137            return;
5138        };
5139
5140        let normalized_node_id = node_id.and_then(Self::normalize_auto_connect_exclusion_key);
5141
5142        let mut guard = match self.auto_connect_excluded.lock() {
5143            Ok(g) => g,
5144            Err(p) => p.into_inner(),
5145        };
5146        let mut aliases = match self.auto_connect_excluded_node_aliases.lock() {
5147            Ok(g) => g,
5148            Err(p) => p.into_inner(),
5149        };
5150
5151        if excluded {
5152            guard.insert(device_id.clone());
5153            match self.auto_connect_peer_requested_excluded.lock() {
5154                Ok(mut peer_requested) => {
5155                    peer_requested.remove(&device_id);
5156                }
5157                Err(poisoned) => {
5158                    poisoned.into_inner().remove(&device_id);
5159                }
5160            }
5161            if let Some(node_id) = normalized_node_id {
5162                aliases.entry(device_id).or_default().insert(node_id);
5163            }
5164        } else {
5165            guard.remove(&device_id);
5166            aliases.remove(&device_id);
5167            match self.auto_connect_peer_requested_excluded.lock() {
5168                Ok(mut peer_requested) => {
5169                    peer_requested.remove(&device_id);
5170                }
5171                Err(poisoned) => {
5172                    poisoned.into_inner().remove(&device_id);
5173                }
5174            }
5175        }
5176    }
5177
5178    pub(crate) fn set_peer_requested_auto_connect_excluded(
5179        &self,
5180        device_id: &str,
5181        node_id: Option<&str>,
5182    ) {
5183        let _owner = match self.auto_connect_exclusion_owner.lock() {
5184            Ok(owner) => owner,
5185            Err(poisoned) => poisoned.into_inner(),
5186        };
5187        let Some(device_id) = Self::normalize_auto_connect_exclusion_key(device_id) else {
5188            return;
5189        };
5190
5191        let normalized_node_id = node_id.and_then(Self::normalize_auto_connect_exclusion_key);
5192        let mut excluded = match self.auto_connect_excluded.lock() {
5193            Ok(guard) => guard,
5194            Err(poisoned) => poisoned.into_inner(),
5195        };
5196        let mut aliases = match self.auto_connect_excluded_node_aliases.lock() {
5197            Ok(guard) => guard,
5198            Err(poisoned) => poisoned.into_inner(),
5199        };
5200        let mut peer_requested = match self.auto_connect_peer_requested_excluded.lock() {
5201            Ok(guard) => guard,
5202            Err(poisoned) => poisoned.into_inner(),
5203        };
5204
5205        // A local manual disconnect is authoritative until the local user
5206        // explicitly reconnects. When both peers disconnect at nearly the same
5207        // time, the remote notice must not relabel that local exclusion as
5208        // peer-requested, because authenticated traffic may resume only the
5209        // latter.
5210        let locally_excluded =
5211            excluded.contains(&device_id) && !peer_requested.contains(&device_id);
5212        if let Some(node_id) = normalized_node_id {
5213            aliases
5214                .entry(device_id.clone())
5215                .or_default()
5216                .insert(node_id);
5217        }
5218        if locally_excluded {
5219            return;
5220        }
5221
5222        excluded.insert(device_id.clone());
5223        peer_requested.insert(device_id);
5224    }
5225
5226    #[cfg(test)]
5227    pub(crate) fn resume_peer_requested_auto_connect_if_authenticated(
5228        &self,
5229        device_id: &str,
5230    ) -> bool {
5231        self.commit_peer_requested_reconnect_if_authenticated(device_id, || true)
5232    }
5233
5234    /// Commit the one remote reconnect exception under the exclusion owner.
5235    ///
5236    /// The current peer-requested intent, application-security mutation, and
5237    /// intent consumption are one transaction. A concurrent local manual
5238    /// exclusion therefore wins before this callback and prevents the
5239    /// security commit, or runs after the admitted transport is published; it
5240    /// cannot interleave between security mutation and intent consumption.
5241    pub(crate) fn commit_peer_requested_reconnect_if_authenticated(
5242        &self,
5243        device_id: &str,
5244        commit_application_security: impl FnOnce() -> bool,
5245    ) -> bool {
5246        let _owner = match self.auto_connect_exclusion_owner.lock() {
5247            Ok(owner) => owner,
5248            Err(poisoned) => poisoned.into_inner(),
5249        };
5250        let Some(device_id) = Self::normalize_auto_connect_exclusion_key(device_id) else {
5251            return false;
5252        };
5253        let mut excluded = match self.auto_connect_excluded.lock() {
5254            Ok(excluded) => excluded,
5255            Err(poisoned) => poisoned.into_inner(),
5256        };
5257        let mut aliases = match self.auto_connect_excluded_node_aliases.lock() {
5258            Ok(aliases) => aliases,
5259            Err(poisoned) => poisoned.into_inner(),
5260        };
5261        let mut peer_requested = match self.auto_connect_peer_requested_excluded.lock() {
5262            Ok(peer_requested) => peer_requested,
5263            Err(poisoned) => poisoned.into_inner(),
5264        };
5265        if !excluded.contains(&device_id) || !peer_requested.contains(&device_id) {
5266            return false;
5267        }
5268        if !commit_application_security() {
5269            return false;
5270        }
5271        peer_requested.remove(&device_id);
5272        excluded.remove(&device_id);
5273        aliases.remove(&device_id);
5274        true
5275    }
5276
5277    pub(crate) fn auto_connect_exclusion_for_peer(
5278        &self,
5279        device_id: Option<&str>,
5280        node_id: Option<&str>,
5281    ) -> Option<bool> {
5282        let _owner = match self.auto_connect_exclusion_owner.lock() {
5283            Ok(owner) => owner,
5284            Err(poisoned) => poisoned.into_inner(),
5285        };
5286        let device_id = device_id.and_then(Self::normalize_auto_connect_exclusion_key);
5287        let node_id = node_id.and_then(Self::normalize_auto_connect_exclusion_key);
5288        let excluded = match self.auto_connect_excluded.lock() {
5289            Ok(excluded) => excluded,
5290            Err(poisoned) => poisoned.into_inner(),
5291        };
5292        let aliases = match self.auto_connect_excluded_node_aliases.lock() {
5293            Ok(aliases) => aliases,
5294            Err(poisoned) => poisoned.into_inner(),
5295        };
5296        let peer_requested = match self.auto_connect_peer_requested_excluded.lock() {
5297            Ok(peer_requested) => peer_requested,
5298            Err(poisoned) => poisoned.into_inner(),
5299        };
5300        let excluded_device_id = device_id
5301            .filter(|device_id| excluded.contains(device_id))
5302            .or_else(|| {
5303                node_id.as_deref().and_then(|node_id| {
5304                    aliases.iter().find_map(|(device_id, node_ids)| {
5305                        (excluded.contains(device_id) && node_ids.contains(node_id))
5306                            .then(|| device_id.clone())
5307                    })
5308                })
5309            })?;
5310        Some(peer_requested.contains(&excluded_device_id))
5311    }
5312
5313    pub fn is_auto_connect_excluded(&self, device_id: &str) -> bool {
5314        let Some(device_id) = Self::normalize_auto_connect_exclusion_key(device_id) else {
5315            return false;
5316        };
5317        match self.auto_connect_excluded.lock() {
5318            Ok(g) => g.contains(&device_id),
5319            Err(p) => p.into_inner().contains(&device_id),
5320        }
5321    }
5322
5323    fn is_locally_auto_connect_excluded(&self, device_id: &str) -> bool {
5324        let Some(device_id) = Self::normalize_auto_connect_exclusion_key(device_id) else {
5325            return false;
5326        };
5327        let excluded = match self.auto_connect_excluded.lock() {
5328            Ok(guard) => guard.contains(&device_id),
5329            Err(poisoned) => poisoned.into_inner().contains(&device_id),
5330        };
5331        if !excluded {
5332            return false;
5333        }
5334        match self.auto_connect_peer_requested_excluded.lock() {
5335            Ok(guard) => !guard.contains(&device_id),
5336            Err(poisoned) => !poisoned.into_inner().contains(&device_id),
5337        }
5338    }
5339
5340    #[cfg(not(target_arch = "wasm32"))]
5341    pub(crate) fn is_auto_connect_peer_excluded(
5342        &self,
5343        device_id: &str,
5344        node_id: Option<&str>,
5345    ) -> bool {
5346        if self.is_auto_connect_excluded(device_id) {
5347            return true;
5348        }
5349
5350        let Some(node_id) = node_id.and_then(Self::normalize_auto_connect_exclusion_key) else {
5351            return false;
5352        };
5353        let aliases = match self.auto_connect_excluded_node_aliases.lock() {
5354            Ok(g) => g,
5355            Err(p) => p.into_inner(),
5356        };
5357        aliases.values().any(|values| values.contains(&node_id))
5358    }
5359
5360    /// Signal whether the app is currently backgrounded.
5361    ///
5362    /// Visibility is independent of a host-reported background execution grant.
5363    /// Repair is suspended only when hidden without that grant; visibility
5364    /// alone never closes an admitted transport.
5365    pub fn set_app_backgrounded(self: &Arc<Self>, backgrounded: bool) {
5366        let was_backgrounded = self
5367            .app_backgrounded
5368            .swap(backgrounded, std::sync::atomic::Ordering::Relaxed);
5369
5370        if was_backgrounded != backgrounded {
5371            #[cfg(not(target_arch = "wasm32"))]
5372            eprintln!(
5373                "[AppState] set_app_backgrounded transition={} previous={} current={}",
5374                if backgrounded {
5375                    "foreground->background"
5376                } else {
5377                    "background->foreground"
5378                },
5379                was_backgrounded,
5380                backgrounded
5381            );
5382            #[cfg(target_arch = "wasm32")]
5383            web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
5384                "[AppState] set_app_backgrounded transition={} previous={} current={}",
5385                if backgrounded {
5386                    "foreground->background"
5387                } else {
5388                    "background->foreground"
5389                },
5390                was_backgrounded,
5391                backgrounded
5392            )));
5393        }
5394
5395        #[cfg(not(target_arch = "wasm32"))]
5396        if was_backgrounded && !backgrounded {
5397            self.native_auto_connect_wake.notify_one();
5398        }
5399
5400        // Returning to foreground after being backgrounded — kick off transport
5401        // upgrades for all connections that are currently on the relay path.
5402        #[cfg(not(target_arch = "wasm32"))]
5403        if was_backgrounded && !backgrounded {
5404            let client = self.clone();
5405            // set_app_backgrounded may be called from an iOS UIKit callback on
5406            // the main thread, outside the Tokio runtime.  Use try_current() so
5407            // we don't panic when there is no reactor; the upgrade will happen on
5408            // the next path-watcher tick instead.
5409            if let Ok(handle) = tokio::runtime::Handle::try_current() {
5410                handle.spawn(async move {
5411                    eprintln!("[AppState] Returned to foreground — revisiting packet carriers for relay-connected peers");
5412                    for record in client.connection_manager.list_active().await {
5413                        let Some(ref endpoint_id) = record.endpoint_id else {
5414                            continue;
5415                        };
5416                        if client.iroh_path_kind(endpoint_id).await
5417                            == crate::client::IrohPathKind::Relay
5418                        {
5419                            let capabilities = client
5420                                .native_peer_transport_capabilities
5421                                .read()
5422                                .await
5423                                .get(&record.connection_id)
5424                                .cloned()
5425                                .unwrap_or_default();
5426                            let _ = client
5427                                .maybe_start_preferred_native_iroh_carrier(
5428                                    &record.connection_id,
5429                                    Some(endpoint_id.as_str()),
5430                                    capabilities.contains(
5431                                        &crate::client::NativePeerTransportCapability::WebRtc,
5432                                    ),
5433                                    capabilities.contains(
5434                                        &crate::client::NativePeerTransportCapability::Moq,
5435                                    ),
5436                                )
5437                                .await;
5438                        }
5439                    }
5440                });
5441            }
5442        }
5443    }
5444
5445    /// Report a real OS execution grant (Android foreground service or finite
5446    /// iOS background task). This is a host fact, never presence or peer intent.
5447    /// Revoke it on completion, expiration, timeout, or service destruction.
5448    pub fn set_background_execution_allowed(&self, allowed: bool) {
5449        self.background_execution_allowed.store(allowed, std::sync::atomic::Ordering::Release);
5450        #[cfg(not(target_arch = "wasm32"))]
5451        self.native_auto_connect_wake.notify_one();
5452    }
5453
5454    /// Visibility alone cannot suspend repair while the OS grants execution.
5455    pub fn is_app_execution_suspended(&self) -> bool {
5456        self.is_app_backgrounded()
5457            && !self.background_execution_allowed.load(std::sync::atomic::Ordering::Acquire)
5458    }
5459
5460    pub fn is_app_backgrounded(&self) -> bool {
5461        self.app_backgrounded
5462            .load(std::sync::atomic::Ordering::Relaxed)
5463    }
5464}
5465
5466fn device_id_is_local(
5467    device_id: &str,
5468    active_session_identity: Option<&(String, String)>,
5469    native_device_id: Option<&str>,
5470) -> bool {
5471    active_session_identity.is_some_and(|(_, local_device_id)| local_device_id == device_id)
5472        || native_device_id.is_some_and(|local_device_id| local_device_id == device_id)
5473}
5474
5475#[cfg(test)]
5476mod tests {
5477    use super::{
5478        device_id_is_local, native_presence_is_ready, native_presence_retry_delay,
5479        native_presence_retry_targets, selected_iroh_latency_label, Client,
5480        NativePresenceTicketPolicy,
5481    };
5482
5483    #[test]
5484    fn host_execution_grant_preserves_repair_until_os_revokes_it() {
5485        let client = std::sync::Arc::new(Client::new_with_app_tag(
5486            crate::test_constants::TEST_PROJECT_ID.to_string(),
5487            "execution-test".to_string(), Box::new(|| None),
5488        ));
5489        assert!(!client.is_app_execution_suspended());
5490        client.set_app_backgrounded(true);
5491        assert!(client.is_app_execution_suspended());
5492        client.set_background_execution_allowed(true);
5493        assert!(client.is_app_backgrounded());
5494        assert!(!client.is_app_execution_suspended());
5495        client.set_background_execution_allowed(false);
5496        assert!(client.is_app_execution_suspended());
5497        client.set_app_backgrounded(false);
5498        assert!(!client.is_app_execution_suspended());
5499        client.set_background_execution_allowed(true);
5500        client.set_app_backgrounded(true);
5501        assert!(!client.is_app_execution_suspended());
5502        client.set_background_execution_allowed(false);
5503        assert!(client.is_app_execution_suspended());
5504    }
5505
5506    #[tokio::test]
5507    async fn pending_admission_cannot_project_terminal_connection_as_connecting() {
5508        let client = Client::new_with_app_tag(
5509            crate::test_constants::TEST_PROJECT_ID.to_string(),
5510            "test-app".to_string(),
5511            Box::new(|| None),
5512        );
5513        let connection_id = "terminal-admission-projection";
5514        client
5515            .connection_manager
5516            .upsert_pending(
5517                connection_id.to_string(),
5518                Some("terminal-node".to_string()),
5519                Some("terminal-device".to_string()),
5520                Some("terminal-node".to_string()),
5521            )
5522            .await;
5523        client
5524            .connection_manager
5525            .commit_current_transport(
5526                connection_id,
5527                Some("terminal-node".to_string()),
5528                71,
5529                Some("initial".to_string()),
5530                |_| {},
5531            )
5532            .await
5533            .expect("connected transport");
5534        client.set_connection_application_crypto_required(connection_id);
5535        client
5536            .connection_manager
5537            .set_closed_if_current(
5538                connection_id,
5539                71,
5540                Some(crate::lifecycle_reason::REASON_MANUAL_DISCONNECT.to_string()),
5541            )
5542            .await
5543            .expect("terminal close");
5544
5545        let snapshot = client
5546            .connection_state(connection_id)
5547            .await
5548            .expect("terminal snapshot");
5549        assert_eq!(snapshot.state, "closed");
5550        assert_eq!(snapshot.transport_state, "closed");
5551        assert_eq!(snapshot.protocol_state, "closed");
5552        assert_eq!(
5553            snapshot.readiness_state,
5554            crate::client::ReadinessState::Closed
5555        );
5556        assert!(!snapshot.routable);
5557    }
5558
5559    #[test]
5560    fn local_device_delete_detection_survives_missing_auto_connect_identity() {
5561        let active = ("user".to_string(), "active-device".to_string());
5562        assert!(device_id_is_local(
5563            "active-device",
5564            Some(&active),
5565            Some("native-device"),
5566        ));
5567        assert!(device_id_is_local(
5568            "native-device",
5569            None,
5570            Some("native-device"),
5571        ));
5572        assert!(!device_id_is_local(
5573            "remote-device",
5574            Some(&active),
5575            Some("native-device"),
5576        ));
5577    }
5578
5579    #[test]
5580    fn native_presence_retry_does_not_repeat_successful_firestore_registration() {
5581        assert_eq!(native_presence_retry_targets(false, false), (true, true));
5582        assert_eq!(native_presence_retry_targets(true, false), (false, true));
5583        assert_eq!(native_presence_retry_targets(false, true), (true, false));
5584        assert_eq!(native_presence_retry_targets(true, true), (false, false));
5585    }
5586
5587    #[test]
5588    fn native_presence_reports_ready_only_after_both_publications() {
5589        assert!(!native_presence_is_ready(false, false));
5590        assert!(!native_presence_is_ready(true, false));
5591        assert!(!native_presence_is_ready(false, true));
5592        assert!(native_presence_is_ready(true, true));
5593    }
5594
5595    #[test]
5596    fn native_presence_retry_backoff_is_bounded() {
5597        assert_eq!(
5598            native_presence_retry_delay(0),
5599            std::time::Duration::from_secs(2),
5600        );
5601        assert_eq!(
5602            native_presence_retry_delay(1),
5603            std::time::Duration::from_secs(4),
5604        );
5605        assert_eq!(
5606            native_presence_retry_delay(5),
5607            std::time::Duration::from_secs(60),
5608        );
5609        assert_eq!(
5610            native_presence_retry_delay(100),
5611            std::time::Duration::from_secs(60),
5612        );
5613    }
5614
5615    #[tokio::test]
5616    async fn managed_native_presence_policy_never_falls_back_to_a_plain_ticket() {
5617        let base_dir = std::env::temp_dir().join(format!(
5618            "openrtc-managed-presence-policy-{}",
5619            std::time::SystemTime::now()
5620                .duration_since(std::time::UNIX_EPOCH)
5621                .expect("unix epoch")
5622                .as_nanos()
5623        ));
5624        let client = Client::new_with_app_tag(
5625            crate::test_constants::TEST_PROJECT_ID.to_string(),
5626            "test-app".to_string(),
5627            Box::new(|| None),
5628        );
5629        client
5630            .init_native_device_identity(base_dir.clone(), Some("Managed Presence Device"))
5631            .await
5632            .expect("native identity");
5633        client.init_iroh(None, Vec::new()).await.expect("iroh");
5634
5635        let ticket = client
5636            .resolve_native_presence_ticket(&NativePresenceTicketPolicy::ManagedUserDevice)
5637            .await
5638            .expect("managed ticket");
5639        let (iroh_ticket, suffix) = crate::session_token::split_ticket(&ticket);
5640        let payload = suffix
5641            .and_then(|value| crate::session_token::decode_payload(iroh_ticket, value))
5642            .expect("compound admission payload");
5643
5644        assert_eq!(payload.scope.as_str(), "user-device");
5645        assert!(!payload.token.trim().is_empty());
5646        let _ = tokio::fs::remove_dir_all(base_dir).await;
5647    }
5648
5649    #[tokio::test]
5650    async fn fixed_native_presence_policy_preserves_explicit_ticket_semantics() {
5651        let client = Client::new_with_app_tag(
5652            crate::test_constants::TEST_PROJECT_ID.to_string(),
5653            "test-app".to_string(),
5654            Box::new(|| None),
5655        );
5656        let ticket = client
5657            .resolve_native_presence_ticket(&NativePresenceTicketPolicy::Fixed(
5658                "explicit-ticket".to_string(),
5659            ))
5660            .await
5661            .expect("fixed ticket");
5662
5663        assert_eq!(ticket, "explicit-ticket");
5664    }
5665
5666    #[test]
5667    fn normalize_transport_name_accepts_official_path_labels() {
5668        for label in [
5669            "iroh",
5670            "iroh-quic",
5671            "iroh-lan",
5672            "iroh-relay",
5673            "ble",
5674            "webrtc",
5675            "moq",
5676        ] {
5677            assert_eq!(
5678                Client::normalize_transport_name(label).as_deref(),
5679                Some(label),
5680                "label {label} should be accepted"
5681            );
5682        }
5683    }
5684
5685    #[test]
5686    fn iroh_latency_is_attributed_only_to_the_selected_physical_path() {
5687        let labels = ["iroh-lan", "iroh-relay"];
5688        assert_eq!(
5689            selected_iroh_latency_label(crate::client::IrohPathKind::DirectLan, labels),
5690            Some("iroh-lan"),
5691        );
5692        assert_eq!(
5693            selected_iroh_latency_label(crate::client::IrohPathKind::Relay, labels),
5694            Some("iroh-relay"),
5695        );
5696        assert_eq!(
5697            selected_iroh_latency_label(crate::client::IrohPathKind::Ble, ["ble", "iroh-relay"],),
5698            Some("ble"),
5699        );
5700        assert_eq!(
5701            selected_iroh_latency_label(
5702                crate::client::IrohPathKind::WebRtc,
5703                ["iroh-relay", "webrtc"],
5704            ),
5705            Some("webrtc"),
5706        );
5707        assert_eq!(
5708            selected_iroh_latency_label(crate::client::IrohPathKind::Moq, ["moq", "iroh-relay"],),
5709            Some("moq"),
5710        );
5711    }
5712}