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