Skip to main content

truffle_core/
node.rs

1//! Node API — the single public entry point for all truffle functionality.
2//!
3//! The [`Node`] struct wires together Layers 3-6 and exposes a clean ~12-method
4//! API that Layer 7 applications consume. Applications should **never** import
5//! from lower layers directly; everything they need is accessible through `Node`.
6//!
7//! # Quick start
8//!
9//! ```ignore
10//! use truffle_core::Node;
11//!
12//! let node = Node::builder()
13//!     .name("my-app")
14//!     .sidecar_path("/usr/local/bin/truffle-sidecar")
15//!     .build()
16//!     .await?;
17//!
18//! // Discover peers (Layer 3 — no transport needed)
19//! let peers = node.peers().await;
20//!
21//! // Send a namespaced message (Layer 6 envelope over Layer 4 WS).
22//! // String args remain queries; RFC 022 Phase B adds Peer handles.
23//! node.send(&peers[0].tailscale_id, "chat", b"hello!").await?;
24//!
25//! // Subscribe to a namespace
26//! let mut rx = node.subscribe("chat");
27//! let msg = rx.recv().await?;
28//!
29//! // Open a raw TCP stream (Layer 4 direct)
30//! let stream = node.open_tcp(&peers[0].tailscale_id, 8080).await?;
31//! ```
32
33use std::collections::HashMap;
34use std::net::IpAddr;
35use std::path::{Path, PathBuf};
36use std::sync::Arc;
37use std::time::Duration;
38// `std::sync::RwLock` is aliased to `StdRwLock` so the namespace_filters
39// lock — which is held only briefly for pure HashMap ops with no `.await`
40// points underneath — can stay sync. Keeping it sync lets
41// `Node::subscribe` remain non-async (required by the NAPI bridge, which
42// is called from Node.js's sync main thread with no tokio runtime in
43// scope) without the `try_write`-panic footgun that the previous
44// `tokio::sync::RwLock` implementation suffered from (see RFC 017 fix K).
45use std::sync::RwLock as StdRwLock;
46
47use tokio::net::TcpStream;
48use tokio::sync::broadcast;
49// The tokio RwLock is only used by the in-file test `MockNetworkProvider`
50// (see `#[cfg(test)] mod tests`); production code now uses the std
51// RwLock alias above for `namespace_filters`.
52#[cfg(test)]
53use tokio::sync::RwLock;
54
55use crate::envelope::codec::{EnvelopeCodec, JsonCodec};
56use crate::envelope::{Envelope, EnvelopeError};
57use crate::file_transfer::{self, FileTransferState};
58use crate::identity::{self, AppId, DeviceId, DeviceName};
59use crate::network::tailscale::{TailscaleConfig, TailscaleProvider};
60use crate::network::{DialOpts, HealthInfo, NetworkProvider, NodeIdentity, PingResult};
61use crate::session::{PeerEvent, PeerRegistry, PeerState};
62use crate::transport::quic::{QuicConnection, QuicListener};
63use crate::transport::websocket::WebSocketTransport;
64use crate::transport::{DatagramSocket, RawListener, WsConfig};
65
66// ---------------------------------------------------------------------------
67// NamespacedMessage — public message type for subscribers
68// ---------------------------------------------------------------------------
69
70/// A message received on a specific namespace.
71///
72/// This is the public type that [`Node::subscribe`] delivers to application
73/// code. It contains the deserialized envelope fields plus the sender's peer ID.
74#[derive(Debug, Clone)]
75pub struct NamespacedMessage {
76    /// Stable node ID of the sender.
77    pub from: String,
78    /// Namespace the message was sent on.
79    pub namespace: String,
80    /// Application-defined message type within the namespace.
81    pub msg_type: String,
82    /// Opaque JSON payload.
83    pub payload: serde_json::Value,
84    /// Millisecond Unix timestamp from the sender, if set.
85    pub timestamp: Option<u64>,
86}
87
88// ---------------------------------------------------------------------------
89// Peer — simplified view for application code
90// ---------------------------------------------------------------------------
91
92/// A peer as seen by application code (RFC 022 projection).
93///
94/// This is a simplified projection of the internal [`PeerState`] that hides
95/// session-layer internals. Networking still accepts string queries today;
96/// Phase B of RFC 022 will take handle-first parameters. Fields are already
97/// honest: `device_id` is never a Tailscale-id fallback.
98#[derive(Debug, Clone)]
99pub struct Peer {
100    /// Durable ULID once known and published; `None` until identity is learned
101    /// (or while suppressed under first-wins). Never equals `tailscale_id`.
102    pub device_id: Option<String>,
103    /// Human-readable device name from the hello identity block, if known.
104    pub device_name: Option<String>,
105    /// Best label for UI: identity name → hostname with `truffle-{appId}-`
106    /// stripped when possible → short tailscale id.
107    pub display_name: String,
108    /// Layer 3 Tailscale hostname (`truffle-{appId}-{slug}`).
109    pub hostname: String,
110    /// Tailscale stable node ID — routing key and advanced diagnostics.
111    pub tailscale_id: String,
112    /// Process-local ref `{tailscale_id}:{generation}` (RFC 022).
113    pub peer_ref: String,
114    /// Generation of this registry entry (bumped on re-join).
115    pub generation: u64,
116    /// Network IP address.
117    pub ip: IpAddr,
118    /// Whether the peer is online (from Layer 3).
119    pub online: bool,
120    /// Whether there is an active envelope-bus WebSocket connection.
121    pub ws_connected: bool,
122    /// Connection type description (e.g., `"direct"` or `"relay:ord"`).
123    pub connection_type: String,
124    /// Operating system, if known. Prefers the hello envelope's value
125    /// and falls back to Layer 3.
126    pub os: Option<String>,
127    /// Last time the peer was seen online (RFC 3339 string).
128    pub last_seen: Option<String>,
129}
130
131impl From<PeerState> for Peer {
132    fn from(s: PeerState) -> Self {
133        let (device_id, device_name, os_from_hello) = if s.identity_suppressed {
134            (None, None, None)
135        } else {
136            match s.identity.as_ref() {
137                Some(identity) => (
138                    Some(identity.device_id.clone()),
139                    Some(identity.device_name.clone()),
140                    Some(identity.os.clone()),
141                ),
142                None => (None, None, None),
143            }
144        };
145
146        // Invariant I1: published device_id must never equal the Tailscale id.
147        debug_assert!(
148            device_id
149                .as_ref()
150                .map(|d| d.as_str() != s.id.as_str())
151                .unwrap_or(true),
152            "RFC 022 I1 violated: device_id must not equal tailscale_id"
153        );
154
155        let display_name = device_name
156            .clone()
157            .filter(|n| !n.is_empty())
158            .unwrap_or_else(|| display_name_from_hostname(&s.name, &s.id));
159
160        let peer_ref = s.peer_ref();
161        let generation = s.generation;
162        let os = os_from_hello.or(s.os.clone());
163
164        Self {
165            device_id,
166            device_name,
167            display_name,
168            hostname: s.name,
169            tailscale_id: s.id,
170            peer_ref,
171            generation,
172            ip: s.ip,
173            online: s.online,
174            ws_connected: s.ws_connected,
175            connection_type: s.connection_type,
176            os,
177            last_seen: s.last_seen,
178        }
179    }
180}
181
182/// Derive a human-ish display name from a Tailscale hostname when identity
183/// is not yet known: strip a leading `truffle-…-` app prefix when present.
184fn display_name_from_hostname(hostname: &str, tailscale_id: &str) -> String {
185    const PREFIX: &str = "truffle-";
186    if let Some(rest) = hostname.strip_prefix(PREFIX) {
187        // rest = "{appId}-{slug…}"; drop the first label (appId).
188        if let Some((_, slug)) = rest.split_once('-') {
189            if !slug.is_empty() {
190                return slug.to_string();
191            }
192        }
193    }
194    if !hostname.is_empty() {
195        return hostname.to_string();
196    }
197    // Last resort: short tailscale id.
198    let short: String = tailscale_id.chars().take(8).collect();
199    if short.is_empty() {
200        "peer".to_string()
201    } else {
202        short
203    }
204}
205
206// ---------------------------------------------------------------------------
207// NodeError
208// ---------------------------------------------------------------------------
209
210/// Errors from the Node API.
211#[derive(Debug, thiserror::Error)]
212pub enum NodeError {
213    /// The requested peer is not known.
214    #[error("peer not found: {0}")]
215    PeerNotFound(String),
216
217    /// The query matched more than one peer (RFC 022 `mesh.peer`).
218    #[error("ambiguous peer query '{query}': {} candidates", candidates.len())]
219    AmbiguousPeer {
220        /// Original query string.
221        query: String,
222        /// Candidate display labels / routing keys for the UI.
223        candidates: Vec<String>,
224    },
225
226    /// The peer handle is no longer usable (left the mesh).
227    #[error("peer gone: {0}")]
228    PeerGone(String),
229
230    /// Failed to establish a connection.
231    #[error("connection failed: {0}")]
232    ConnectionFailed(String),
233
234    /// Failed to send a message.
235    #[error("send failed: {0}")]
236    SendFailed(String),
237
238    /// Envelope encoding/decoding error.
239    #[error("envelope error: {0}")]
240    Envelope(#[from] EnvelopeError),
241
242    /// Session layer error.
243    #[error("session error: {0}")]
244    Session(#[from] crate::session::SessionError),
245
246    /// Network layer error.
247    #[error("network error: {0}")]
248    Network(#[from] crate::network::NetworkError),
249
250    /// Transport layer error.
251    #[error("transport error: {0}")]
252    Transport(#[from] crate::transport::TransportError),
253
254    /// The requested feature is not yet implemented.
255    #[error("not implemented: {0}")]
256    NotImplemented(String),
257
258    /// The port is reserved by truffle's own listeners.
259    #[error("port {0} is reserved by truffle (443 = sidecar TLS, or the session WebSocket port)")]
260    ReservedPort(u16),
261
262    /// The node has been stopped.
263    #[error("node stopped")]
264    Stopped,
265
266    /// Builder configuration error.
267    #[error("build error: {0}")]
268    BuildError(String),
269
270    /// I/O error from the builder (state dir creation, device-id persistence).
271    #[error("io error: {0}")]
272    Io(#[from] std::io::Error),
273}
274
275// ---------------------------------------------------------------------------
276// Node
277// ---------------------------------------------------------------------------
278
279/// The main truffle node — single public entry point for all functionality.
280///
281/// Generic over `N: NetworkProvider` so that tests can inject a mock provider
282/// without Tailscale. In production, use the concrete type
283/// `Node<TailscaleProvider>` (created via [`NodeBuilder`]).
284///
285/// # Lifecycle
286///
287/// 1. Create via [`Node::builder()`] + `.build().await`
288/// 2. Use `peers()`, `send()`, `subscribe()`, `open_tcp()`, etc.
289/// 3. Call `stop()` to shut down
290pub struct Node<N: NetworkProvider + 'static> {
291    /// Layer 3 network provider.
292    pub(crate) network: Arc<N>,
293    /// Layer 5 session / peer registry.
294    session: Arc<PeerRegistry<N>>,
295    /// Layer 6 envelope codec.
296    codec: Arc<dyn EnvelopeCodec>,
297    /// Broadcast sender for all incoming namespaced messages.
298    /// Kept alive to prevent the channel from closing. The router task holds a clone.
299    #[allow(dead_code)]
300    incoming_tx: broadcast::Sender<NamespacedMessage>,
301    /// Per-namespace subscription channels.
302    ///
303    /// Guarded by a `std::sync::RwLock` (not tokio) because the lock is
304    /// held only for quick HashMap operations with no `.await` points
305    /// underneath, and the `subscribe()` API has to be callable from
306    /// synchronous contexts (the NAPI bridge, which runs on Node.js's
307    /// main thread with no tokio runtime in scope).
308    namespace_filters: Arc<StdRwLock<HashMap<String, broadcast::Sender<NamespacedMessage>>>>,
309    /// File transfer subsystem state.
310    pub(crate) file_transfer_state: FileTransferState,
311    /// State directory for persistence (e.g., synced store backends). Empty
312    /// path when constructed via `from_parts` (tests); set by the builder.
313    state_dir: PathBuf,
314    /// The session WebSocket listen port (reserved against raw listeners).
315    /// Defaults to 9417 in `from_parts`; set by the builder.
316    ws_port: u16,
317    /// Reverse proxy subsystem state.
318    pub(crate) proxy_state: crate::proxy::ProxyState,
319    /// Set once [`stop`](Self::stop) has completed teardown. Makes further
320    /// `stop()` calls no-ops and causes the send paths to fail fast with
321    /// [`NodeError::Stopped`].
322    stopped: std::sync::atomic::AtomicBool,
323}
324
325impl<N: NetworkProvider + 'static> Node<N> {
326    /// Create a `Node` from pre-built components (used by builder and tests).
327    ///
328    /// This constructor wires together the layers and spawns the envelope
329    /// router task that reads from the session layer, deserializes envelopes,
330    /// and dispatches to namespace subscribers.
331    pub(crate) fn from_parts(
332        network: Arc<N>,
333        session: Arc<PeerRegistry<N>>,
334        codec: Arc<dyn EnvelopeCodec>,
335    ) -> Self {
336        let (incoming_tx, _) = broadcast::channel(1024);
337        let namespace_filters: Arc<
338            StdRwLock<HashMap<String, broadcast::Sender<NamespacedMessage>>>,
339        > = Arc::new(StdRwLock::new(HashMap::new()));
340
341        let node = Self {
342            network,
343            session: session.clone(),
344            codec: codec.clone(),
345            incoming_tx: incoming_tx.clone(),
346            namespace_filters: namespace_filters.clone(),
347            file_transfer_state: FileTransferState::new(),
348            state_dir: PathBuf::new(),
349            ws_port: 9417,
350            proxy_state: crate::proxy::ProxyState::new(),
351            stopped: std::sync::atomic::AtomicBool::new(false),
352        };
353
354        // Spawn the envelope router task.
355        node.spawn_envelope_router(session, codec, incoming_tx, namespace_filters);
356
357        node
358    }
359
360    /// Spawn a background task that reads incoming raw messages from the
361    /// session layer, deserializes them as envelopes, and routes them to
362    /// the global channel and per-namespace subscribers.
363    fn spawn_envelope_router(
364        &self,
365        session: Arc<PeerRegistry<N>>,
366        codec: Arc<dyn EnvelopeCodec>,
367        incoming_tx: broadcast::Sender<NamespacedMessage>,
368        namespace_filters: Arc<StdRwLock<HashMap<String, broadcast::Sender<NamespacedMessage>>>>,
369    ) {
370        let mut rx = session.subscribe();
371
372        tokio::spawn(async move {
373            loop {
374                match rx.recv().await {
375                    Ok(msg) => {
376                        if let Ok(envelope) = codec.decode(&msg.data) {
377                            let namespaced = NamespacedMessage {
378                                from: msg.from,
379                                namespace: envelope.namespace.clone(),
380                                msg_type: envelope.msg_type,
381                                payload: envelope.payload,
382                                timestamp: envelope.timestamp,
383                            };
384
385                            tracing::debug!(
386                                from = %namespaced.from,
387                                namespace = %namespaced.namespace,
388                                msg_type = %namespaced.msg_type,
389                                "envelope router: dispatching message"
390                            );
391
392                            // Send to global channel (best-effort).
393                            let _ = incoming_tx.send(namespaced.clone());
394
395                            // Route to namespace-specific subscriber if present.
396                            // Sync read on the std RwLock: the critical section
397                            // is a HashMap lookup with no `.await` points.
398                            let filters = namespace_filters
399                                .read()
400                                .expect("namespace_filters std RwLock poisoned");
401                            let _has_subscriber = filters.contains_key(&namespaced.namespace);
402                            if let Some(tx) = filters.get(&namespaced.namespace) {
403                                let send_result = tx.send(namespaced);
404                                tracing::debug!(
405                                    namespace = %envelope.namespace,
406                                    subscriber_count = tx.receiver_count(),
407                                    sent = send_result.is_ok(),
408                                    "envelope router: sent to namespace subscriber"
409                                );
410                            } else {
411                                tracing::debug!(
412                                    namespace = %envelope.namespace,
413                                    "envelope router: no subscriber for namespace"
414                                );
415                            }
416                        } else {
417                            tracing::warn!(
418                                from = %msg.from,
419                                data_len = msg.data.len(),
420                                "node: failed to decode envelope from incoming message"
421                            );
422                        }
423                    }
424                    Err(broadcast::error::RecvError::Lagged(n)) => {
425                        tracing::warn!(
426                            missed = n,
427                            "node: envelope router lagged, missed {n} messages"
428                        );
429                        continue;
430                    }
431                    Err(broadcast::error::RecvError::Closed) => {
432                        tracing::debug!("node: session incoming channel closed, router exiting");
433                        break;
434                    }
435                }
436            }
437        });
438    }
439
440    // ── Builder ──────────────────────────────────────────────────────────
441
442    /// Create a new [`NodeBuilder`] for configuring and constructing a node.
443    pub fn builder() -> NodeBuilder {
444        NodeBuilder::default()
445    }
446
447    // ── File Transfer ────────────────────────────────────────────────────
448
449    /// Access the file transfer subsystem.
450    ///
451    /// Returns a [`FileTransfer`](file_transfer::FileTransfer) handle
452    /// that provides methods for sending, receiving, and pulling files.
453    pub fn file_transfer(&self) -> file_transfer::FileTransfer<'_, N> {
454        file_transfer::FileTransfer::new(self)
455    }
456
457    // ── Reverse Proxy ──────────────────────────────────────────────────
458
459    /// Access the reverse proxy subsystem.
460    ///
461    /// Returns a [`Proxy`](crate::proxy::Proxy) handle that provides
462    /// methods for adding, removing, and listing reverse proxies.
463    pub fn proxy(&self) -> crate::proxy::Proxy<'_, N> {
464        crate::proxy::Proxy::new(self)
465    }
466
467    /// Create a synchronized store for device-owned state.
468    ///
469    /// Returns an `Arc<SyncedStore<T>>` that syncs data across the mesh on
470    /// namespace `"ss:{store_id}"`. The caller owns the returned Arc;
471    /// the background sync task also holds one.
472    ///
473    /// Requires `self` to be wrapped in an `Arc` because the sync task
474    /// needs to outlive this call.
475    pub fn synced_store<T>(
476        self: &Arc<Self>,
477        store_id: &str,
478    ) -> Arc<crate::synced_store::SyncedStore<T>>
479    where
480        T: serde::Serialize + serde::de::DeserializeOwned + Clone + Send + Sync + 'static,
481    {
482        crate::synced_store::SyncedStore::new(self.clone(), store_id)
483    }
484
485    /// Create a synchronized store with a custom persistence backend.
486    ///
487    /// Same as [`synced_store`](Self::synced_store) but restores persisted
488    /// data on startup and writes through to the backend on every change.
489    pub fn synced_store_with_backend<T>(
490        self: &Arc<Self>,
491        store_id: &str,
492        backend: std::sync::Arc<dyn crate::synced_store::StoreBackend>,
493    ) -> Arc<crate::synced_store::SyncedStore<T>>
494    where
495        T: serde::Serialize + serde::de::DeserializeOwned + Clone + Send + Sync + 'static,
496    {
497        crate::synced_store::SyncedStore::new_with_backend(self.clone(), store_id, backend)
498    }
499
500    // ── State directory ───────────────────��────────────────────────────��
501
502    /// Set the state directory (called by the builder after construction).
503    pub(crate) fn with_state_dir(mut self, dir: PathBuf) -> Self {
504        self.state_dir = dir;
505        self
506    }
507
508    /// Record the session WebSocket port (called by the builder) so the
509    /// reserved-port guard tracks a customized `ws_port`.
510    pub(crate) fn with_ws_port(mut self, port: u16) -> Self {
511        self.ws_port = port;
512        self
513    }
514
515    /// The state directory for persistence backends.
516    ///
517    /// Returns an empty path for test nodes created via `from_parts`.
518    pub fn state_dir(&self) -> &Path {
519        &self.state_dir
520    }
521
522    // ── Lifecycle ────────────────────────────────────────────────────────
523
524    /// Stop the node and all underlying layers.
525    ///
526    /// Closes every active WebSocket connection (Layer 5) and shuts down the
527    /// network provider (Layer 3 — sidecar + bridge). After `stop()` returns,
528    /// [`send`](Self::send) and [`send_typed`](Self::send_typed) fail with
529    /// [`NodeError::Stopped`], and [`broadcast`](Self::broadcast) /
530    /// [`broadcast_typed`](Self::broadcast_typed) become no-ops.
531    ///
532    /// Idempotent: calling `stop()` more than once is safe — subsequent calls
533    /// return immediately without repeating teardown.
534    ///
535    /// The envelope-router and peer-event background tasks are not aborted
536    /// here; they exit on their own once the `Node` is dropped and their
537    /// broadcast channels close. They sit idle after `stop()`.
538    pub async fn stop(&self) {
539        if self.stopped.swap(true, std::sync::atomic::Ordering::SeqCst) {
540            tracing::debug!("node: stop() called on already-stopped node");
541            return;
542        }
543        tracing::info!("node: stopping");
544        // Layer 5: close all active WebSocket connections.
545        self.session.shutdown().await;
546        // Layer 3: shut down the network provider (sidecar + bridge).
547        if let Err(e) = self.network.stop().await {
548            tracing::warn!(error = %e, "node: network provider stop failed");
549        }
550        tracing::info!("node: stopped");
551    }
552
553    // ── Identity ─────────────────────────────────────────────────────────
554
555    /// Return the local node's identity (stable ID, hostname, name).
556    pub fn local_info(&self) -> NodeIdentity {
557        self.network.local_identity()
558    }
559
560    // ── Discovery (from Layer 3, no transport needed) ────────────────────
561
562    /// Return all known peers.
563    ///
564    /// Includes peers that are online but not yet connected (no active WS).
565    /// This information comes from Layer 3 peer discovery.
566    pub async fn peers(&self) -> Vec<Peer> {
567        let app_id = self.network.local_identity().app_id;
568        self.session
569            .peers()
570            .await
571            .into_iter()
572            .map(|s| Self::project_peer(s, &app_id))
573            .collect()
574    }
575
576    /// Project a session [`PeerState`] to the public [`Peer`] view.
577    fn project_peer(s: PeerState, app_id: &str) -> Peer {
578        // RFC 022: `device_name` stays None until identity is known.
579        // Prefer a stripped hostname slug for `display_name` when we
580        // have no hello name yet (raw-transport / pre-identity peers).
581        let bare = s
582            .identity
583            .is_none()
584            .then(|| hostname_slug(&s.name, app_id).map(str::to_string))
585            .flatten();
586        let mut peer = Peer::from(s);
587        if let Some(bare) = bare {
588            peer.display_name = bare;
589        }
590        peer
591    }
592
593    /// Subscribe to peer change events (joined, left, connected, etc.).
594    pub fn on_peer_change(&self) -> broadcast::Receiver<PeerEvent> {
595        self.session.on_peer_change()
596    }
597
598    /// Resolve a query to a public [`Peer`] handle view (RFC 022).
599    ///
600    /// - Not found → `Ok(None)` after optional wait
601    /// - Ambiguous (multiple name / short-prefix hits) → [`NodeError::AmbiguousPeer`]
602    ///
603    /// `wait_ms`: when set, block until the query becomes resolvable or the
604    /// timeout elapses (then `Ok(None)`). Useful for rehydrating a saved ULID
605    /// whose owner has not completed hello yet.
606    pub async fn peer(&self, query: &str, wait_ms: Option<u64>) -> Result<Option<Peer>, NodeError> {
607        match self.resolve_peer(query).await {
608            Ok(state) => {
609                let app_id = self.network.local_identity().app_id;
610                return Ok(Some(Self::project_peer(state, &app_id)));
611            }
612            Err(NodeError::PeerNotFound(_)) => {}
613            Err(e) => return Err(e),
614        }
615
616        let Some(ms) = wait_ms.filter(|m| *m > 0) else {
617            return Ok(None);
618        };
619
620        let mut rx = self.session.on_peer_change();
621
622        // Re-check immediately after subscribing: an event landing between
623        // the initial miss above and the subscription would otherwise be
624        // lost, and the query would only re-resolve on the NEXT event (or
625        // never — burning the whole timeout on an already-resolvable peer).
626        match self.resolve_peer(query).await {
627            Ok(state) => {
628                let app_id = self.network.local_identity().app_id;
629                return Ok(Some(Self::project_peer(state, &app_id)));
630            }
631            Err(NodeError::PeerNotFound(_)) => {}
632            Err(e) => return Err(e),
633        }
634
635        let deadline = tokio::time::Instant::now() + Duration::from_millis(ms);
636        loop {
637            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
638            if remaining.is_zero() {
639                return Ok(None);
640            }
641            match tokio::time::timeout(remaining, rx.recv()).await {
642                Ok(Ok(_)) => match self.resolve_peer(query).await {
643                    Ok(state) => {
644                        let app_id = self.network.local_identity().app_id;
645                        return Ok(Some(Self::project_peer(state, &app_id)));
646                    }
647                    Err(NodeError::PeerNotFound(_)) => continue,
648                    Err(e) => return Err(e),
649                },
650                Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
651                Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => return Ok(None),
652                Err(_) => return Ok(None), // timeout
653            }
654        }
655    }
656
657    /// Resolve any accepted peer identifier form to the peer's current
658    /// session state.
659    ///
660    /// The single resolution path shared by
661    /// [`resolve_peer_id`](Self::resolve_peer_id), [`ping`](Self::ping),
662    /// and the raw transport methods. Accepts the identifier forms
663    /// documented on `resolve_peer_id`, plus the peer's Tailscale IP.
664    pub(crate) async fn resolve_peer(&self, peer_ref: &str) -> Result<PeerState, NodeError> {
665        let peers = self.session.peers().await;
666        let app_id = self.network.local_identity().app_id;
667
668        // Exact matches that are always unique.
669        for p in &peers {
670            if let Some(uid) = p.published_device_id() {
671                if uid == peer_ref {
672                    return Ok(p.clone());
673                }
674            }
675            if p.id == peer_ref || p.name == peer_ref || p.ip.to_string() == peer_ref {
676                return Ok(p.clone());
677            }
678            if p.peer_ref() == peer_ref {
679                return Ok(p.clone());
680            }
681        }
682
683        // Device-name matches (published identity names) — may be ambiguous.
684        let name_hits: Vec<&PeerState> = peers
685            .iter()
686            .filter(|p| {
687                p.identity
688                    .as_ref()
689                    .map(|i| !p.identity_suppressed && i.device_name.eq_ignore_ascii_case(peer_ref))
690                    .unwrap_or(false)
691            })
692            .collect();
693        if name_hits.len() > 1 {
694            return Err(NodeError::AmbiguousPeer {
695                query: peer_ref.to_string(),
696                candidates: name_hits
697                    .iter()
698                    .map(|p| p.published_device_id().unwrap_or(p.id.as_str()).to_string())
699                    .collect(),
700            });
701        }
702        if let Some(hit) = name_hits.first() {
703            return Ok((*hit).clone());
704        }
705
706        // Bare device name via hostname slug (pre-identity peers).
707        let ref_slug = identity::slug(peer_ref, 255);
708        if !ref_slug.is_empty() {
709            let slug_hits: Vec<&PeerState> = peers
710                .iter()
711                .filter(|p| hostname_slug(&p.name, &app_id) == Some(ref_slug.as_str()))
712                .collect();
713            if slug_hits.len() > 1 {
714                return Err(NodeError::AmbiguousPeer {
715                    query: peer_ref.to_string(),
716                    candidates: slug_hits.iter().map(|p| p.id.clone()).collect(),
717                });
718            }
719            if let Some(hit) = slug_hits.first() {
720                return Ok((*hit).clone());
721            }
722        }
723
724        // Prefix match on published device_id (require ≥4 chars, unique).
725        if peer_ref.len() >= 4 {
726            let hits: Vec<&PeerState> = peers
727                .iter()
728                .filter(|p| {
729                    p.published_device_id()
730                        .map(|uid| uid.starts_with(peer_ref))
731                        .unwrap_or(false)
732                })
733                .collect();
734            if hits.len() > 1 {
735                return Err(NodeError::AmbiguousPeer {
736                    query: peer_ref.to_string(),
737                    candidates: hits
738                        .iter()
739                        .filter_map(|p| p.published_device_id().map(|s| s.to_string()))
740                        .collect(),
741                });
742            }
743            if let Some(hit) = hits.first() {
744                return Ok((*hit).clone());
745            }
746        }
747
748        // A well-formed peer ref that resolved nothing above refers to a
749        // departed or superseded generation (live refs exact-matched
750        // `p.peer_ref()` earlier) — surface PeerGone, not PeerNotFound, so
751        // stale handles fail loudly instead of looking like typos
752        // (RFC 022 I5). Checked last so colon-containing names still match.
753        if crate::session::parse_peer_ref(peer_ref).is_some() {
754            return Err(NodeError::PeerGone(peer_ref.to_string()));
755        }
756
757        Err(NodeError::PeerNotFound(peer_ref.to_string()))
758    }
759
760    /// Resolve a peer identifier to the peer's Tailscale IP address.
761    ///
762    /// Accepts the same identifier forms as
763    /// [`resolve_peer_id`](Self::resolve_peer_id). Used by the FFI layers
764    /// to address datagram sends by peer name.
765    pub async fn resolve_peer_ip(&self, peer_ref: &str) -> Result<IpAddr, NodeError> {
766        Ok(self.resolve_peer(peer_ref).await?.ip)
767    }
768
769    /// Resolve a peer query to a string safe to pass to [`send`](Self::send)
770    /// and other peer-addressed methods.
771    ///
772    /// Prefer the published durable ULID when known and not suppressed;
773    /// otherwise return the Tailscale stable id (routing key). This is the
774    /// legacy string path — RFC 022 Phase B replaces it with `peer()` handles.
775    ///
776    /// Accepts any of:
777    /// - the stable `device_id` (full ULID)
778    /// - a unique prefix of the `device_id` (at least 4 characters; must
779    ///   match exactly one known peer)
780    /// - the human-readable `device_name` from the hello
781    /// - the bare device name of a peer that has not helloed yet (matched
782    ///   via the `truffle-{app_id}-{slug}` hostname convention)
783    /// - the Layer 3 Tailscale hostname (the sanitised slug)
784    /// - the Tailscale stable ID
785    /// - the Tailscale IP address (e.g. `100.x.x.x`)
786    pub async fn resolve_peer_id(&self, peer_id: &str) -> Result<String, NodeError> {
787        let p = self.resolve_peer(peer_id).await?;
788        Ok(p.published_device_id()
789            .map(|s| s.to_string())
790            .unwrap_or_else(|| p.id.clone()))
791    }
792
793    // ── Diagnostics ──────────────────────────────────────────────────────
794
795    /// Ping a peer via the network layer.
796    ///
797    /// Resolves the peer ID to an IP address and pings via Layer 3. Accepts
798    /// the same identifier forms as [`resolve_peer_id`](Self::resolve_peer_id).
799    pub async fn ping(&self, peer_id: &str) -> Result<PingResult, NodeError> {
800        let peer = self.resolve_peer(peer_id).await?;
801        let addr = peer.ip.to_string();
802        self.network.ping(&addr).await.map_err(NodeError::Network)
803    }
804
805    /// Return health information from the network layer.
806    pub async fn health(&self) -> HealthInfo {
807        self.network.health().await
808    }
809
810    // ── Messaging (Layer 6 envelope over Layer 4 WS) ─────────────────────
811
812    /// Return [`NodeError::Stopped`] if [`stop`](Self::stop) has already run.
813    ///
814    /// Used by the send paths to fail fast instead of attempting a doomed
815    /// session send after teardown.
816    fn ensure_not_stopped(&self) -> Result<(), NodeError> {
817        if self.stopped.load(std::sync::atomic::Ordering::SeqCst) {
818            return Err(NodeError::Stopped);
819        }
820        Ok(())
821    }
822
823    /// Map session send errors: a stale peer-ref selector surfaces as
824    /// [`NodeError::PeerGone`] (RFC 022 I5); everything else wraps as-is.
825    fn map_session_send_err(e: crate::session::SessionError) -> NodeError {
826        match e {
827            crate::session::SessionError::PeerGone(r) => NodeError::PeerGone(r),
828            e => NodeError::Session(e),
829        }
830    }
831
832    /// Send a namespaced message to a specific peer.
833    ///
834    /// The data is wrapped in a Layer 6 [`Envelope`] with the given namespace
835    /// and a `"message"` type, then serialized and sent via the session layer.
836    /// If no WebSocket connection exists, one is lazily established.
837    ///
838    /// Returns [`NodeError::Stopped`] if [`stop`](Self::stop) has been called.
839    pub async fn send(&self, peer_id: &str, namespace: &str, data: &[u8]) -> Result<(), NodeError> {
840        self.ensure_not_stopped()?;
841        // If the data is valid UTF-8 JSON, parse it into a proper JSON value
842        // so the receiver gets a structured object rather than an array of
843        // byte values.  This is critical for the file transfer protocol and
844        // any other protocol that serializes structs to JSON bytes before
845        // calling send().
846        let payload = std::str::from_utf8(data)
847            .ok()
848            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
849            .unwrap_or_else(|| serde_json::Value::from(data.to_vec()));
850
851        let envelope = Envelope::new(namespace, "message", payload).with_timestamp();
852
853        let encoded = self.codec.encode(&envelope)?;
854        self.session
855            .send(peer_id, &encoded)
856            .await
857            .map_err(Self::map_session_send_err)?;
858        Ok(())
859    }
860
861    /// Send a namespaced message with an explicit `msg_type` and JSON payload.
862    ///
863    /// Unlike [`send`](Self::send), this method takes a pre-built
864    /// [`serde_json::Value`] payload and a caller-chosen `msg_type` instead
865    /// of raw bytes with a hardcoded `"message"` type. Used by subsystems
866    /// (file transfer, synced store, request/reply) that define their own
867    /// wire protocol message types.
868    pub async fn send_typed(
869        &self,
870        peer_id: &str,
871        namespace: &str,
872        msg_type: &str,
873        payload: &serde_json::Value,
874    ) -> Result<(), NodeError> {
875        self.ensure_not_stopped()?;
876        let envelope = Envelope::new(namespace, msg_type, payload.clone()).with_timestamp();
877        let encoded = self.codec.encode(&envelope)?;
878        self.session
879            .send(peer_id, &encoded)
880            .await
881            .map_err(Self::map_session_send_err)?;
882        Ok(())
883    }
884
885    /// Broadcast a namespaced message with an explicit `msg_type` and JSON
886    /// payload to all connected peers.
887    pub async fn broadcast_typed(
888        &self,
889        namespace: &str,
890        msg_type: &str,
891        payload: &serde_json::Value,
892    ) {
893        if self.stopped.load(std::sync::atomic::Ordering::SeqCst) {
894            tracing::debug!("node: broadcast after stop ignored");
895            return;
896        }
897        let envelope = Envelope::new(namespace, msg_type, payload.clone()).with_timestamp();
898        match self.codec.encode(&envelope) {
899            Ok(encoded) => {
900                self.session.broadcast(&encoded).await;
901            }
902            Err(e) => {
903                tracing::error!("node: failed to encode broadcast envelope: {e}");
904            }
905        }
906    }
907
908    /// Broadcast a namespaced message to all connected peers.
909    ///
910    /// Only peers with active WebSocket connections receive the broadcast.
911    /// No lazy connections are established.
912    pub async fn broadcast(&self, namespace: &str, data: &[u8]) {
913        if self.stopped.load(std::sync::atomic::Ordering::SeqCst) {
914            tracing::debug!("node: broadcast after stop ignored");
915            return;
916        }
917        let payload = std::str::from_utf8(data)
918            .ok()
919            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
920            .unwrap_or_else(|| serde_json::Value::from(data.to_vec()));
921
922        let envelope = Envelope::new(namespace, "message", payload).with_timestamp();
923
924        match self.codec.encode(&envelope) {
925            Ok(encoded) => {
926                self.session.broadcast(&encoded).await;
927            }
928            Err(e) => {
929                tracing::error!("node: failed to encode broadcast envelope: {e}");
930            }
931        }
932    }
933
934    /// Subscribe to messages in a specific namespace.
935    ///
936    /// Returns a broadcast receiver that yields [`NamespacedMessage`]s
937    /// matching the given namespace. Multiple subscribers to the same
938    /// namespace share the same underlying channel.
939    pub fn subscribe(&self, namespace: &str) -> broadcast::Receiver<NamespacedMessage> {
940        // Fast path: check if subscriber already exists (read lock).
941        {
942            let filters = self
943                .namespace_filters
944                .read()
945                .expect("namespace_filters std RwLock poisoned");
946            if let Some(tx) = filters.get(namespace) {
947                return tx.subscribe();
948            }
949        }
950
951        // Slow path: create a new channel for this namespace (write lock).
952        let mut filters = self
953            .namespace_filters
954            .write()
955            .expect("namespace_filters std RwLock poisoned");
956        // Double-check after acquiring write lock.
957        if let Some(tx) = filters.get(namespace) {
958            return tx.subscribe();
959        }
960        let (tx, rx) = broadcast::channel(256);
961        filters.insert(namespace.to_string(), tx);
962        rx
963    }
964
965    // ── Raw streams (Layer 4 direct) ─────────────────────────────────────
966
967    /// Open a raw TCP stream to a peer on the given port.
968    ///
969    /// Resolves the peer ID to an IP address via the session's peer list,
970    /// then dials via the network layer. Accepts the same identifier
971    /// forms as [`resolve_peer_id`](Self::resolve_peer_id). Returns a
972    /// plain `TcpStream` for byte-oriented I/O.
973    ///
974    /// The stream is raw even on port 443 — the sidecar's legacy auto-TLS
975    /// wrap for 443 dials is disabled on this path.
976    pub async fn open_tcp(&self, peer_id: &str, port: u16) -> Result<TcpStream, NodeError> {
977        let peer = self.resolve_peer(peer_id).await?;
978        let addr = peer.ip.to_string();
979        self.network
980            .dial_tcp_opts(&addr, port, DialOpts { tls: Some(false) })
981            .await
982            .map_err(|e| NodeError::ConnectionFailed(e.to_string()))
983    }
984
985    /// Listen for incoming TCP connections on a port.
986    ///
987    /// Returns a [`RawListener`] that yields raw `TcpStream`s. The caller
988    /// is responsible for accepting connections in a loop. Port 0 binds an
989    /// ephemeral port (advertise the resolved `RawListener::port` in-band,
990    /// like the file transfer subsystem does). Ports 443 and 9417 are
991    /// reserved by truffle's own listeners.
992    pub async fn listen_tcp(&self, port: u16) -> Result<RawListener, NodeError> {
993        use crate::transport::tcp::TcpTransport;
994        use crate::transport::RawTransport;
995
996        ensure_port_unreserved(port, self.ws_port)?;
997        let tcp = TcpTransport::new(self.network.clone());
998        tcp.listen(port).await.map_err(NodeError::Transport)
999    }
1000
1001    /// Stop listening on a previously opened TCP port.
1002    ///
1003    /// Dropping the [`RawListener`] alone stops local delivery but leaves
1004    /// the tsnet port bound in the sidecar; this releases it.
1005    pub async fn unlisten_tcp(&self, port: u16) -> Result<(), NodeError> {
1006        self.network
1007            .unlisten_tcp(port)
1008            .await
1009            .map_err(NodeError::Network)
1010    }
1011
1012    /// Open a raw QUIC connection to a peer on the given port.
1013    ///
1014    /// The connection carries multiple concurrent bidirectional byte
1015    /// streams ([`QuicConnection::open_stream`]) with no head-of-line
1016    /// blocking between them. App scoping is enforced at the TLS layer via
1017    /// ALPN (`truffle-raw.{app_id}`) — peers from a different app fail the
1018    /// handshake. Accepts the same identifier forms as
1019    /// [`resolve_peer_id`](Self::resolve_peer_id).
1020    pub async fn connect_quic(
1021        &self,
1022        peer_id: &str,
1023        port: u16,
1024    ) -> Result<QuicConnection, NodeError> {
1025        let peer = self.resolve_peer(peer_id).await?;
1026        let alpn = crate::transport::quic::raw_alpn(&self.network.local_identity().app_id);
1027        crate::transport::quic::connect_raw(&self.network, &peer.ip.to_string(), port, &alpn)
1028            .await
1029            .map_err(NodeError::Transport)
1030    }
1031
1032    /// Listen for raw QUIC connections on a port.
1033    ///
1034    /// Returns a [`QuicListener`] that yields [`QuicConnection`]s. Only
1035    /// same-app peers can complete the handshake (ALPN scoping). Ports 443
1036    /// and 9417 are reserved, and port 0 is not yet supported over the
1037    /// tsnet relay (the relay cannot report the actual ephemeral port
1038    /// back).
1039    pub async fn listen_quic(&self, port: u16) -> Result<QuicListener, NodeError> {
1040        ensure_port_unreserved(port, self.ws_port)?;
1041        if port == 0 {
1042            return Err(NodeError::NotImplemented(
1043                "ephemeral (port 0) QUIC listeners are not supported over the tsnet relay yet — choose an explicit port"
1044                    .to_string(),
1045            ));
1046        }
1047        let alpn = crate::transport::quic::raw_alpn(&self.network.local_identity().app_id);
1048        crate::transport::quic::listen_raw(&self.network, port, &alpn)
1049            .await
1050            .map_err(NodeError::Transport)
1051    }
1052
1053    /// Bind a UDP datagram socket on a port.
1054    ///
1055    /// Datagrams are relayed through the network provider (tsnet) with
1056    /// boundaries preserved; the transport falls back to a direct host
1057    /// socket only when the provider has no UDP support (tests). Returns a
1058    /// [`DatagramSocket`] supporting `send_to` / `recv_from` with tailnet
1059    /// addresses. IPv4 (`100.x`) peers only; keep payloads ≤ ~1200 bytes
1060    /// to stay under the tailnet MTU. Port 0 binds an ephemeral relay
1061    /// port — suitable for client-style sockets that send first.
1062    pub async fn bind_udp(&self, port: u16) -> Result<DatagramSocket, NodeError> {
1063        use crate::transport::udp::{UdpConfig, UdpTransport};
1064        use crate::transport::DatagramTransport;
1065
1066        let udp = UdpTransport::new(self.network.clone(), UdpConfig::default());
1067        udp.bind(port).await.map_err(NodeError::Transport)
1068    }
1069}
1070
1071/// The bare device-name slug from a Layer 3 hostname, when it follows this
1072/// app's `truffle-{app_id}-{slug}` convention (RFC 017). `None` otherwise.
1073fn hostname_slug<'a>(hostname: &'a str, app_id: &str) -> Option<&'a str> {
1074    hostname
1075        .strip_prefix("truffle-")?
1076        .strip_prefix(app_id)?
1077        .strip_prefix('-')
1078}
1079
1080/// Reject ports reserved by truffle's own listeners: 443 (sidecar TLS) and
1081/// the node's configured session WebSocket port (default 9417).
1082fn ensure_port_unreserved(port: u16, ws_port: u16) -> Result<(), NodeError> {
1083    if port == 443 || port == ws_port {
1084        Err(NodeError::ReservedPort(port))
1085    } else {
1086        Ok(())
1087    }
1088}
1089
1090// ---------------------------------------------------------------------------
1091// NodeBuilder
1092// ---------------------------------------------------------------------------
1093
1094/// Builder for constructing a [`Node<TailscaleProvider>`].
1095///
1096/// Configures the Tailscale sidecar, RFC 017 identity, and transport
1097/// parameters before wiring all layers together.
1098///
1099/// # Example
1100///
1101/// ```ignore
1102/// let node = Node::builder()
1103///     .app_id("playground")?
1104///     .device_name("alice-mbp")
1105///     .sidecar_path("/opt/truffle/sidecar")
1106///     .ws_port(9417)
1107///     .build()
1108///     .await?;
1109/// ```
1110#[derive(Debug, Clone)]
1111pub struct NodeBuilder {
1112    app_id: Option<AppId>,
1113    device_name: Option<DeviceName>,
1114    device_id: Option<DeviceId>,
1115    sidecar_path: Option<PathBuf>,
1116    state_dir: Option<String>,
1117    auth_key: Option<String>,
1118    ephemeral: bool,
1119    ws_port: u16,
1120    idle_timeout_secs: Option<u64>,
1121    /// RFC 022 Phase C: proactively exchange hello with online peers.
1122    eager_identity: bool,
1123}
1124
1125/// Atomically write a string to `path` by writing to a sibling `.tmp`
1126/// file first and then renaming it over the destination.
1127///
1128/// On POSIX, `rename(2)` is atomic and replaces an existing destination
1129/// in-place. On Windows, `std::fs::rename` refuses to overwrite, so we
1130/// explicitly remove the destination first — the window between remove
1131/// and rename is acceptable here because the caller (device-id.txt
1132/// persistence) runs once at startup and is not racing itself.
1133fn atomic_write_string(path: &Path, content: &str) -> std::io::Result<()> {
1134    let _parent = path.parent().ok_or_else(|| {
1135        std::io::Error::new(
1136            std::io::ErrorKind::InvalidInput,
1137            "path has no parent directory",
1138        )
1139    })?;
1140    let mut tmp = path.to_path_buf();
1141    tmp.set_extension("tmp");
1142    std::fs::write(&tmp, content)?;
1143    #[cfg(unix)]
1144    {
1145        std::fs::rename(&tmp, path)?;
1146    }
1147    #[cfg(windows)]
1148    {
1149        // Windows: std::fs::rename errors if the destination exists, so
1150        // remove the old file first. Best-effort — missing file is fine.
1151        let _ = std::fs::remove_file(path);
1152        std::fs::rename(&tmp, path)?;
1153    }
1154    Ok(())
1155}
1156
1157impl Default for NodeBuilder {
1158    fn default() -> Self {
1159        Self {
1160            app_id: None,
1161            device_name: None,
1162            device_id: None,
1163            sidecar_path: None,
1164            state_dir: None,
1165            auth_key: None,
1166            ephemeral: false,
1167            ws_port: 9417,
1168            idle_timeout_secs: None,
1169            eager_identity: true,
1170        }
1171    }
1172}
1173
1174impl NodeBuilder {
1175    /// Set the application namespace identifier (RFC 017 §5.1).
1176    ///
1177    /// The input is validated against `^[a-z][a-z0-9-]{1,31}$`. Invalid
1178    /// values are rejected with `NodeError::BuildError`.
1179    pub fn app_id(mut self, s: impl Into<String>) -> Result<Self, NodeError> {
1180        let raw: String = s.into();
1181        let app_id = AppId::parse(&raw)
1182            .map_err(|e| NodeError::BuildError(format!("invalid app_id: {e}")))?;
1183        self.app_id = Some(app_id);
1184        Ok(self)
1185    }
1186
1187    /// Set the human-readable device name.
1188    ///
1189    /// Accepts any Unicode input; soft-truncated to 256 graphemes. When
1190    /// unset, the builder falls back to `hostname::get()` at `build()` time.
1191    pub fn device_name(mut self, s: impl Into<String>) -> Self {
1192        self.device_name = Some(DeviceName::parse(s));
1193        self
1194    }
1195
1196    /// Override the auto-generated device ID.
1197    ///
1198    /// Validates that `s` is a well-formed ULID. When provided, the value
1199    /// is persisted to `{state_dir}/device-id.txt` during `build()` so
1200    /// subsequent starts without an explicit `device_id` see it.
1201    pub fn device_id(mut self, s: impl Into<String>) -> Result<Self, NodeError> {
1202        let raw: String = s.into();
1203        let device_id = DeviceId::parse(&raw)
1204            .map_err(|e| NodeError::BuildError(format!("invalid device_id: {e}")))?;
1205        self.device_id = Some(device_id);
1206        Ok(self)
1207    }
1208
1209    /// Set the path to the Go sidecar binary.
1210    pub fn sidecar_path(mut self, path: impl Into<PathBuf>) -> Self {
1211        self.sidecar_path = Some(path.into());
1212        self
1213    }
1214
1215    /// Set the Tailscale state directory.
1216    pub fn state_dir(mut self, dir: &str) -> Self {
1217        self.state_dir = Some(dir.to_string());
1218        self
1219    }
1220
1221    /// Set the Tailscale auth key for headless authentication.
1222    pub fn auth_key(mut self, key: &str) -> Self {
1223        self.auth_key = Some(key.to_string());
1224        self
1225    }
1226
1227    /// Set whether the node is ephemeral (auto-removed from tailnet on shutdown).
1228    pub fn ephemeral(mut self, val: bool) -> Self {
1229        self.ephemeral = val;
1230        self
1231    }
1232
1233    /// Set the WebSocket listen port.
1234    /// RFC 022 Phase C: when true (default), proactively exchange hello with
1235    /// online peers so durable `device_id` is learned without app `send`.
1236    pub fn eager_identity(mut self, enabled: bool) -> Self {
1237        self.eager_identity = enabled;
1238        self
1239    }
1240
1241    pub fn ws_port(mut self, port: u16) -> Self {
1242        self.ws_port = port;
1243        self
1244    }
1245
1246    /// Set the idle timeout (in seconds) for bridged raw TCP connections.
1247    ///
1248    /// The sidecar reaps quiet bridged connections after this long
1249    /// (default: 600 s). Apps holding long-lived quiet sockets should
1250    /// raise this or send application-level keepalives.
1251    pub fn idle_timeout_secs(mut self, secs: u64) -> Self {
1252        self.idle_timeout_secs = Some(secs);
1253        self
1254    }
1255
1256    /// Resolve RFC 017 identity values and the Tailscale config.
1257    ///
1258    /// Shared between [`build()`](Self::build) and
1259    /// [`build_with_auth_handler()`](Self::build_with_auth_handler). Returns
1260    /// the ready-to-start `TailscaleConfig` along with the parsed identity
1261    /// triple.
1262    fn prepare_config(&self) -> Result<TailscaleConfig, NodeError> {
1263        // 1. sidecar binary is required.
1264        let binary_path = self
1265            .sidecar_path
1266            .clone()
1267            .ok_or_else(|| NodeError::BuildError("sidecar_path is required".into()))?;
1268
1269        // 2. app_id is required.
1270        let app_id = self
1271            .app_id
1272            .clone()
1273            .ok_or_else(|| NodeError::BuildError("app_id is required".into()))?;
1274
1275        // 3. device_name falls back to the OS hostname.
1276        let device_name = match self.device_name.clone() {
1277            Some(name) => name,
1278            None => {
1279                let os_hostname = hostname::get()
1280                    .map_err(|e| {
1281                        NodeError::BuildError(format!(
1282                            "device_name is unset and hostname::get() failed: {e}"
1283                        ))
1284                    })?
1285                    .to_string_lossy()
1286                    .into_owned();
1287                DeviceName::parse(os_hostname)
1288            }
1289        };
1290
1291        // 4. Compose the Tailscale hostname once, here. Downstream code
1292        //    MUST NOT rebuild it — the provider config stores this verbatim.
1293        let tailscale_host = identity::tailscale_hostname(&app_id, &device_name);
1294
1295        // 5. Resolve state_dir. Default:
1296        //    `{dirs::data_dir}/truffle/{app_id}/{slug(device_name)}`.
1297        let state_dir = self.state_dir.clone().unwrap_or_else(|| {
1298            let base = dirs::data_dir().unwrap_or_else(|| {
1299                tracing::warn!(
1300                    "dirs::data_dir() returned None, falling back to std::env::temp_dir()"
1301                );
1302                std::env::temp_dir()
1303            });
1304            base.join("truffle")
1305                .join(app_id.as_str())
1306                .join(identity::slug(device_name.as_str(), 255))
1307                .to_string_lossy()
1308                .into_owned()
1309        });
1310
1311        // 6. Ensure the state directory exists before Tailscale starts.
1312        std::fs::create_dir_all(&state_dir)?;
1313
1314        // 7. Resolve device_id. Priority:
1315        //    a) explicit builder override → validate + persist
1316        //    b) existing `device-id.txt` → read + validate
1317        //    c) generate + persist
1318        let device_id_file = Path::new(&state_dir).join("device-id.txt");
1319        let device_id = match self.device_id.clone() {
1320            Some(id) => {
1321                // Persist the override so later auto-generated calls see it.
1322                atomic_write_string(&device_id_file, id.as_str())?;
1323                id
1324            }
1325            None => {
1326                if device_id_file.exists() {
1327                    let s = std::fs::read_to_string(&device_id_file)?.trim().to_string();
1328                    DeviceId::parse(&s).map_err(|e| {
1329                        NodeError::BuildError(format!(
1330                            "device-id.txt at {device_id_file:?} contains an invalid ULID: {e}"
1331                        ))
1332                    })?
1333                } else {
1334                    let id = DeviceId::generate();
1335                    atomic_write_string(&device_id_file, id.as_str())?;
1336                    id
1337                }
1338            }
1339        };
1340
1341        Ok(TailscaleConfig {
1342            binary_path,
1343            app_id: app_id.as_str().to_string(),
1344            device_id: device_id.as_str().to_string(),
1345            device_name: device_name.as_str().to_string(),
1346            hostname: tailscale_host,
1347            state_dir,
1348            auth_key: self.auth_key.clone(),
1349            ephemeral: if self.ephemeral { Some(true) } else { None },
1350            tags: None,
1351            idle_timeout_secs: self.idle_timeout_secs,
1352        })
1353    }
1354
1355    /// Build and start the node.
1356    ///
1357    /// This creates the TailscaleProvider, starts it, creates the WebSocket
1358    /// transport and PeerRegistry, starts the session, and spawns the
1359    /// envelope router.
1360    ///
1361    /// # Errors
1362    ///
1363    /// Returns [`NodeError::BuildError`] if required configuration is missing,
1364    /// or propagates errors from the network provider startup.
1365    pub async fn build(self) -> Result<Node<TailscaleProvider>, NodeError> {
1366        let ws_port = self.ws_port;
1367        let eager_identity = self.eager_identity;
1368        let config = self.prepare_config()?;
1369        let state_dir = PathBuf::from(&config.state_dir);
1370
1371        let mut provider = TailscaleProvider::new(config);
1372        provider.start().await.map_err(NodeError::Network)?;
1373
1374        let network = Arc::new(provider);
1375
1376        // 2. Create WebSocket transport.
1377        let ws_config = WsConfig {
1378            port: ws_port,
1379            ..Default::default()
1380        };
1381        let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config));
1382
1383        // 3. Create PeerRegistry and start session.
1384        let session = Arc::new(PeerRegistry::with_options(
1385            network.clone(),
1386            ws_transport,
1387            crate::session::PeerRegistryOptions {
1388                eager_identity,
1389                ..Default::default()
1390            },
1391        ));
1392        session.start().await;
1393
1394        // 4. Create the node with the envelope router.
1395        let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
1396        let node = Node::from_parts(network, session, codec)
1397            .with_state_dir(state_dir)
1398            .with_ws_port(ws_port);
1399
1400        tracing::info!("node: started successfully");
1401        Ok(node)
1402    }
1403
1404    /// Build and start the node, calling `on_auth` if authentication is needed.
1405    ///
1406    /// This is identical to [`build()`](Self::build) except it subscribes to
1407    /// provider events *before* `provider.start()` blocks, forwarding
1408    /// `AuthRequired` events to the callback while waiting for authentication
1409    /// to complete.
1410    ///
1411    /// # Errors
1412    ///
1413    /// Returns [`NodeError::BuildError`] if required configuration is missing,
1414    /// or propagates errors from the network provider startup.
1415    pub async fn build_with_auth_handler(
1416        self,
1417        on_auth: impl Fn(String) + Send + 'static,
1418    ) -> Result<Node<TailscaleProvider>, NodeError> {
1419        let ws_port = self.ws_port;
1420        let eager_identity = self.eager_identity;
1421        let config = self.prepare_config()?;
1422        let state_dir = PathBuf::from(&config.state_dir);
1423
1424        let mut provider = TailscaleProvider::new(config);
1425
1426        // 2. Subscribe to peer events BEFORE start() so we capture auth URLs.
1427        let mut auth_rx = provider.peer_events();
1428
1429        // 3. Spawn a task that forwards AuthRequired events to the callback.
1430        let auth_task = tokio::spawn(async move {
1431            use crate::network::NetworkPeerEvent;
1432            loop {
1433                match auth_rx.recv().await {
1434                    Ok(NetworkPeerEvent::AuthRequired { url }) => {
1435                        on_auth(url);
1436                    }
1437                    Err(broadcast::error::RecvError::Closed) => break,
1438                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
1439                    _ => {} // Ignore other events
1440                }
1441            }
1442        });
1443
1444        // 4. Start the provider (blocks until auth completes).
1445        let start_result = provider.start().await.map_err(NodeError::Network);
1446
1447        // 5. Cancel the auth forwarding task — auth is done.
1448        auth_task.abort();
1449
1450        start_result?;
1451
1452        let network = Arc::new(provider);
1453
1454        // 6. Create WebSocket transport.
1455        let ws_config = WsConfig {
1456            port: ws_port,
1457            ..Default::default()
1458        };
1459        let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config));
1460
1461        // 7. Create PeerRegistry and start session.
1462        let session = Arc::new(PeerRegistry::with_options(
1463            network.clone(),
1464            ws_transport,
1465            crate::session::PeerRegistryOptions {
1466                eager_identity,
1467                ..Default::default()
1468            },
1469        ));
1470        session.start().await;
1471
1472        // 8. Create the node with the envelope router.
1473        let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
1474        let node = Node::from_parts(network, session, codec)
1475            .with_state_dir(state_dir)
1476            .with_ws_port(ws_port);
1477
1478        tracing::info!("node: started successfully (with auth handler)");
1479        Ok(node)
1480    }
1481}
1482
1483// ---------------------------------------------------------------------------
1484// Tests
1485// ---------------------------------------------------------------------------
1486
1487#[cfg(test)]
1488mod tests {
1489    use super::*;
1490    use crate::network::{
1491        HealthInfo, IncomingConnection, NetworkError, NetworkPeer, NetworkPeerEvent,
1492        NetworkTcpListener, NetworkUdpSocket, PeerAddr,
1493    };
1494    use crate::transport::WsConfig;
1495    use serde_json::json;
1496    use std::sync::atomic::{AtomicUsize, Ordering};
1497    use std::time::Duration;
1498    use tokio::sync::{broadcast, mpsc};
1499
1500    // ── Mock NetworkProvider ──────────────────────────────────────────
1501
1502    struct MockNetworkProvider {
1503        identity: NodeIdentity,
1504        local_addr: PeerAddr,
1505        peer_event_tx: broadcast::Sender<NetworkPeerEvent>,
1506        /// Pre-loaded peer list for `peers()`.
1507        mock_peers: Arc<RwLock<Vec<NetworkPeer>>>,
1508        /// Count of `stop()` invocations — lets tests assert the Node
1509        /// actually shut the provider down during teardown.
1510        stop_calls: Arc<AtomicUsize>,
1511    }
1512
1513    impl MockNetworkProvider {
1514        fn new(id: &str) -> Self {
1515            // RFC 017: align `device_id` with fixture input so tests can
1516            // reason about a single identifier.
1517            Self::new_with_device_id(id, id)
1518        }
1519
1520        /// `device_id` distinct from the tailscale id — required when a test
1521        /// lets the node hello with itself (loopback self-dial): publishing
1522        /// an identity whose device_id equals the entry's tailscale_id would
1523        /// violate RFC 022 invariant I1.
1524        fn new_with_device_id(id: &str, device_id: &str) -> Self {
1525            let (peer_event_tx, _) = broadcast::channel(64);
1526            Self {
1527                identity: NodeIdentity {
1528                    app_id: "test".to_string(),
1529                    device_id: device_id.to_string(),
1530                    device_name: format!("Test Node {id}"),
1531                    tailscale_hostname: format!("truffle-test-{id}"),
1532                    tailscale_id: id.to_string(),
1533                    dns_name: None,
1534                    ip: Some("127.0.0.1".parse().unwrap()),
1535                },
1536                local_addr: PeerAddr {
1537                    ip: Some("127.0.0.1".parse().unwrap()),
1538                    hostname: format!("truffle-test-{id}"),
1539                    dns_name: None,
1540                },
1541                peer_event_tx,
1542                mock_peers: Arc::new(RwLock::new(Vec::new())),
1543                stop_calls: Arc::new(AtomicUsize::new(0)),
1544            }
1545        }
1546
1547        fn event_sender(&self) -> broadcast::Sender<NetworkPeerEvent> {
1548            self.peer_event_tx.clone()
1549        }
1550
1551        /// Number of times `stop()` has been called on this provider.
1552        fn stop_call_count(&self) -> usize {
1553            self.stop_calls.load(Ordering::SeqCst)
1554        }
1555    }
1556
1557    impl NetworkProvider for MockNetworkProvider {
1558        async fn start(&mut self) -> Result<(), NetworkError> {
1559            Ok(())
1560        }
1561
1562        async fn stop(&self) -> Result<(), NetworkError> {
1563            self.stop_calls.fetch_add(1, Ordering::SeqCst);
1564            Ok(())
1565        }
1566
1567        fn local_identity(&self) -> NodeIdentity {
1568            self.identity.clone()
1569        }
1570
1571        fn local_addr(&self) -> PeerAddr {
1572            self.local_addr.clone()
1573        }
1574
1575        fn peer_events(&self) -> broadcast::Receiver<NetworkPeerEvent> {
1576            self.peer_event_tx.subscribe()
1577        }
1578
1579        async fn peers(&self) -> Vec<NetworkPeer> {
1580            self.mock_peers.read().await.clone()
1581        }
1582
1583        async fn dial_tcp(&self, addr: &str, port: u16) -> Result<TcpStream, NetworkError> {
1584            let target = format!("{addr}:{port}");
1585            TcpStream::connect(&target)
1586                .await
1587                .map_err(|e| NetworkError::DialFailed(format!("mock dial {target}: {e}")))
1588        }
1589
1590        async fn listen_tcp(&self, port: u16) -> Result<NetworkTcpListener, NetworkError> {
1591            let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
1592                .await
1593                .map_err(|e| NetworkError::ListenFailed(format!("mock listen :{port}: {e}")))?;
1594
1595            let actual_port = listener.local_addr().unwrap().port();
1596            let (tx, rx) = mpsc::channel::<IncomingConnection>(64);
1597
1598            tokio::spawn(async move {
1599                loop {
1600                    match listener.accept().await {
1601                        Ok((stream, addr)) => {
1602                            let conn = IncomingConnection {
1603                                stream,
1604                                remote_addr: addr.to_string(),
1605                                remote_identity: String::new(),
1606                                port: actual_port,
1607                            };
1608                            if tx.send(conn).await.is_err() {
1609                                break;
1610                            }
1611                        }
1612                        Err(e) => {
1613                            tracing::debug!("mock listener error: {e}");
1614                            break;
1615                        }
1616                    }
1617                }
1618            });
1619
1620            Ok(NetworkTcpListener {
1621                port: actual_port,
1622                incoming: rx,
1623            })
1624        }
1625
1626        async fn unlisten_tcp(&self, _port: u16) -> Result<(), NetworkError> {
1627            Ok(())
1628        }
1629
1630        async fn bind_udp(&self, _port: u16) -> Result<NetworkUdpSocket, NetworkError> {
1631            Err(NetworkError::Internal("mock: UDP not supported".into()))
1632        }
1633
1634        async fn ping(&self, _addr: &str) -> Result<PingResult, NetworkError> {
1635            Ok(PingResult {
1636                latency: Duration::from_millis(1),
1637                connection: "direct".to_string(),
1638                peer_addr: None,
1639            })
1640        }
1641
1642        async fn health(&self) -> HealthInfo {
1643            HealthInfo {
1644                state: "running".to_string(),
1645                healthy: true,
1646                ..Default::default()
1647            }
1648        }
1649    }
1650
1651    // ── Helpers ──────────────────────────────────────────────────────
1652
1653    fn make_loopback_peer(id: &str) -> NetworkPeer {
1654        NetworkPeer {
1655            id: id.to_string(),
1656            hostname: format!("truffle-test-{id}"),
1657            ip: "127.0.0.1".parse().unwrap(),
1658            online: true,
1659            cur_addr: Some("127.0.0.1:41641".to_string()),
1660            relay: None,
1661            os: Some("linux".to_string()),
1662            last_seen: Some("2026-03-25T12:00:00Z".to_string()),
1663            key_expiry: None,
1664            dns_name: None,
1665        }
1666    }
1667
1668    fn ws_config(port: u16) -> WsConfig {
1669        WsConfig {
1670            port,
1671            ping_interval: Duration::from_secs(300),
1672            pong_timeout: Duration::from_secs(300),
1673            ..Default::default()
1674        }
1675    }
1676
1677    async fn random_port() -> u16 {
1678        let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1679        l.local_addr().unwrap().port()
1680    }
1681
1682    /// Create a Node backed by a mock provider for testing.
1683    async fn make_test_node(
1684        id: &str,
1685        ws_port: u16,
1686    ) -> (
1687        Node<MockNetworkProvider>,
1688        broadcast::Sender<NetworkPeerEvent>,
1689        Arc<MockNetworkProvider>,
1690    ) {
1691        make_test_node_with_device_id(id, id, ws_port).await
1692    }
1693
1694    /// Like [`make_test_node`] but with a `device_id` distinct from the
1695    /// tailscale id — see [`MockNetworkProvider::new_with_device_id`].
1696    async fn make_test_node_with_device_id(
1697        id: &str,
1698        device_id: &str,
1699        ws_port: u16,
1700    ) -> (
1701        Node<MockNetworkProvider>,
1702        broadcast::Sender<NetworkPeerEvent>,
1703        Arc<MockNetworkProvider>,
1704    ) {
1705        let provider = MockNetworkProvider::new_with_device_id(id, device_id);
1706        let event_tx = provider.event_sender();
1707        let network = Arc::new(provider);
1708        let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config(ws_port)));
1709        let session = Arc::new(PeerRegistry::new(network.clone(), ws_transport));
1710        session.start().await;
1711
1712        let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
1713        let node = Node::from_parts(network.clone(), session, codec);
1714
1715        (node, event_tx, network)
1716    }
1717
1718    // ── Tests ────────────────────────────────────────────────────────
1719
1720    #[tokio::test]
1721    async fn test_node_builder_creates_node() {
1722        let ws_port = random_port().await;
1723        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1724
1725        let identity = node.local_info();
1726        assert_eq!(identity.tailscale_id, "node-1");
1727        assert_eq!(identity.device_id, "node-1");
1728        assert!(identity.tailscale_hostname.contains("node-1"));
1729    }
1730
1731    #[tokio::test]
1732    async fn test_node_peers_from_network() {
1733        let ws_port = random_port().await;
1734        let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
1735
1736        // Initially no peers.
1737        let peers = node.peers().await;
1738        assert!(peers.is_empty());
1739
1740        // Inject a peer via Layer 3.
1741        let peer = make_loopback_peer("peer-a");
1742        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
1743        tokio::time::sleep(Duration::from_millis(50)).await;
1744
1745        let peers = node.peers().await;
1746        assert_eq!(peers.len(), 1);
1747        assert_eq!(peers[0].tailscale_id, "peer-a");
1748        assert!(
1749            peers[0].device_id.is_none(),
1750            "RFC 022: no ULID before identity"
1751        );
1752        assert!(peers[0].online);
1753        assert!(!peers[0].ws_connected);
1754    }
1755
1756    #[tokio::test]
1757    async fn test_node_send_to_unknown_peer_errors() {
1758        let ws_port = random_port().await;
1759        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1760
1761        let result = node.send("nonexistent", "test", b"hello").await;
1762        assert!(result.is_err());
1763        let err_str = result.unwrap_err().to_string();
1764        assert!(
1765            err_str.contains("unknown peer") || err_str.contains("not found"),
1766            "expected unknown peer error, got: {err_str}"
1767        );
1768    }
1769
1770    #[tokio::test]
1771    async fn test_node_send_wraps_in_envelope() {
1772        // Test that send() properly creates an envelope.
1773        // We test the codec directly since a full send requires two connected nodes.
1774        let codec = JsonCodec;
1775        let data = b"hello world";
1776        let envelope = Envelope::new("test-ns", "message", serde_json::Value::from(data.to_vec()))
1777            .with_timestamp();
1778
1779        let encoded = codec.encode(&envelope).unwrap();
1780        let decoded = codec.decode(&encoded).unwrap();
1781
1782        assert_eq!(decoded.namespace, "test-ns");
1783        assert_eq!(decoded.msg_type, "message");
1784        assert!(decoded.timestamp.is_some());
1785    }
1786
1787    #[tokio::test]
1788    async fn test_node_subscribe_filters_by_namespace() {
1789        let ws_port = random_port().await;
1790        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1791
1792        // Create subscribers for two different namespaces.
1793        let _rx_chat = node.subscribe("chat");
1794        let _rx_ft = node.subscribe("ft");
1795
1796        // Subscribing to the same namespace again should work.
1797        let _rx_chat2 = node.subscribe("chat");
1798    }
1799
1800    #[tokio::test]
1801    async fn test_node_broadcast() {
1802        let ws_port = random_port().await;
1803        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1804
1805        // Broadcast with no connected peers should not panic.
1806        node.broadcast("test", b"hello everyone").await;
1807    }
1808
1809    #[tokio::test]
1810    async fn test_node_open_tcp_resolves_peer() {
1811        let ws_port = random_port().await;
1812        let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
1813
1814        // Start a TCP server for the test.
1815        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1816        let tcp_port = listener.local_addr().unwrap().port();
1817
1818        // Inject a loopback peer.
1819        let peer = make_loopback_peer("peer-tcp");
1820        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
1821        tokio::time::sleep(Duration::from_millis(50)).await;
1822
1823        // Accept a connection in the background.
1824        let accept_handle = tokio::spawn(async move {
1825            let (stream, _) = listener.accept().await.unwrap();
1826            stream
1827        });
1828
1829        // open_tcp should resolve peer-tcp to 127.0.0.1 and connect.
1830        let stream = node.open_tcp("peer-tcp", tcp_port).await;
1831        assert!(stream.is_ok(), "open_tcp failed: {:?}", stream.err());
1832
1833        let _ = accept_handle.await;
1834    }
1835
1836    #[tokio::test]
1837    async fn test_node_open_tcp_unknown_peer_errors() {
1838        let ws_port = random_port().await;
1839        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1840
1841        let result = node.open_tcp("nonexistent", 8080).await;
1842        assert!(result.is_err());
1843        let err_str = result.unwrap_err().to_string();
1844        assert!(
1845            err_str.contains("not found"),
1846            "expected peer not found error, got: {err_str}"
1847        );
1848    }
1849
1850    #[tokio::test]
1851    async fn test_node_ping_resolves_peer() {
1852        let ws_port = random_port().await;
1853        let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
1854
1855        // No peer yet.
1856        let result = node.ping("peer-ping").await;
1857        assert!(result.is_err());
1858
1859        // Inject peer.
1860        let peer = make_loopback_peer("peer-ping");
1861        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
1862        tokio::time::sleep(Duration::from_millis(50)).await;
1863
1864        // Should succeed (mock returns 1ms latency).
1865        let result = node.ping("peer-ping").await;
1866        assert!(result.is_ok());
1867        assert_eq!(result.unwrap().latency, Duration::from_millis(1));
1868    }
1869
1870    #[tokio::test]
1871    async fn test_node_health() {
1872        let ws_port = random_port().await;
1873        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1874
1875        let health = node.health().await;
1876        assert!(health.healthy);
1877        assert_eq!(health.state, "running");
1878    }
1879
1880    #[tokio::test]
1881    async fn test_node_connect_quic_unknown_peer_errors() {
1882        let ws_port = random_port().await;
1883        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1884
1885        let result = node.connect_quic("peer", 4433).await;
1886        assert!(matches!(result, Err(NodeError::PeerNotFound(_))));
1887    }
1888
1889    #[tokio::test]
1890    async fn test_node_listen_tcp() {
1891        let ws_port = random_port().await;
1892        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1893
1894        // listen_tcp(0) should bind to an ephemeral port.
1895        let listener = node.listen_tcp(0).await;
1896        assert!(listener.is_ok(), "listen_tcp failed: {:?}", listener.err());
1897    }
1898
1899    #[tokio::test]
1900    async fn test_envelope_serialize_deserialize() {
1901        let envelope = Envelope::new("chat", "message", json!({"text": "hello"})).with_timestamp();
1902
1903        let bytes = envelope.serialize().unwrap();
1904        let decoded = Envelope::deserialize(&bytes).unwrap();
1905
1906        assert_eq!(decoded.namespace, "chat");
1907        assert_eq!(decoded.msg_type, "message");
1908        assert_eq!(decoded.payload["text"], "hello");
1909        assert!(decoded.timestamp.is_some());
1910    }
1911
1912    #[tokio::test]
1913    async fn test_envelope_codec_json() {
1914        let codec = JsonCodec;
1915        let envelope = Envelope::new("ft", "offer", json!({"file": "test.bin"}));
1916
1917        let encoded = codec.encode(&envelope).unwrap();
1918        let decoded = codec.decode(&encoded).unwrap();
1919
1920        assert_eq!(decoded.namespace, "ft");
1921        assert_eq!(decoded.payload["file"], "test.bin");
1922    }
1923
1924    #[tokio::test]
1925    async fn test_envelope_unknown_fields_ignored() {
1926        let json_bytes = br#"{
1927            "namespace": "v2",
1928            "msg_type": "new",
1929            "payload": {},
1930            "future_field": "ignored"
1931        }"#;
1932
1933        let codec = JsonCodec;
1934        let decoded = codec.decode(json_bytes).unwrap();
1935        assert_eq!(decoded.namespace, "v2");
1936        assert_eq!(decoded.msg_type, "new");
1937    }
1938
1939    #[tokio::test]
1940    async fn test_node_send_and_receive_roundtrip() {
1941        // Set up two nodes that communicate via loopback WS.
1942        let port_a = random_port().await;
1943        let port_b = random_port().await;
1944
1945        let (node_a, event_tx_a, _net_a) = make_test_node("node-a", port_a).await;
1946        let (node_b, event_tx_b, _net_b) = make_test_node("node-b", port_b).await;
1947
1948        // Inject each node as a peer of the other.
1949        let peer_b = NetworkPeer {
1950            id: "node-b".to_string(),
1951            hostname: "truffle-test-node-b".to_string(),
1952            ip: "127.0.0.1".parse().unwrap(),
1953            online: true,
1954            cur_addr: Some("127.0.0.1:41641".to_string()),
1955            relay: None,
1956            os: None,
1957            last_seen: None,
1958            key_expiry: None,
1959            dns_name: None,
1960        };
1961        let peer_a = NetworkPeer {
1962            id: "node-a".to_string(),
1963            hostname: "truffle-test-node-a".to_string(),
1964            ip: "127.0.0.1".parse().unwrap(),
1965            online: true,
1966            cur_addr: Some("127.0.0.1:41641".to_string()),
1967            relay: None,
1968            os: None,
1969            last_seen: None,
1970            key_expiry: None,
1971            dns_name: None,
1972        };
1973
1974        let _ = event_tx_a.send(NetworkPeerEvent::Joined(peer_b));
1975        let _ = event_tx_b.send(NetworkPeerEvent::Joined(peer_a));
1976        tokio::time::sleep(Duration::from_millis(100)).await;
1977
1978        // Subscribe to namespace on node_b.
1979        let mut rx = node_b.subscribe("test");
1980
1981        // Send from node_a to node_b. This triggers lazy WS connect.
1982        // Note: this will connect to node_b's WS listener on port_b.
1983        let send_result = node_a.send("node-b", "test", b"hello from a").await;
1984
1985        // The send may fail in loopback mock because the WS port for node-b
1986        // is the listener port, and the mock's dial connects to 127.0.0.1:port_b.
1987        // In a real scenario with Tailscale, this works because each node
1988        // listens on its own Tailscale IP.
1989        //
1990        // For unit tests, we verify the envelope codec roundtrip works.
1991        // Full integration tests require two separate processes.
1992        if send_result.is_ok() {
1993            // If send succeeded, verify the message arrives.
1994            let msg = tokio::time::timeout(Duration::from_secs(2), rx.recv()).await;
1995            if let Ok(Ok(msg)) = msg {
1996                assert_eq!(msg.namespace, "test");
1997            }
1998        }
1999        // If send fails due to loopback WS peer-id mismatch, that's expected
2000        // in unit tests. The important thing is no panics.
2001    }
2002
2003    // ── Lifecycle: stop() teardown ───────────────────────────────────
2004
2005    #[tokio::test]
2006    async fn test_node_stop_shuts_down_provider_and_is_idempotent() {
2007        let ws_port = random_port().await;
2008        let (node, event_tx, network) = make_test_node("node-1", ws_port).await;
2009
2010        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-1")));
2011        tokio::time::sleep(Duration::from_millis(50)).await;
2012
2013        assert_eq!(network.stop_call_count(), 0);
2014        node.stop().await;
2015        assert_eq!(
2016            network.stop_call_count(),
2017            1,
2018            "stop() must shut down the provider"
2019        );
2020
2021        // Idempotent: second stop must not stop the provider again.
2022        node.stop().await;
2023        assert_eq!(network.stop_call_count(), 1);
2024
2025        // Post-stop sends fail with NodeError::Stopped.
2026        let err = node.send("peer-1", "test", b"hi").await.unwrap_err();
2027        assert!(matches!(err, NodeError::Stopped));
2028    }
2029
2030    #[tokio::test]
2031    async fn test_node_stop_closes_ws_connections() {
2032        // A single node that discovers itself as a loopback peer. Under the
2033        // loopback mock every dial lands on the node's own listener, and the
2034        // RFC 022 dial-side identity check drops any connection whose
2035        // answerer is not the dialed peer — so the only WS a mock node can
2036        // legitimately establish is to an entry carrying its own
2037        // tailscale_id. That is all this test needs: a live WS connection
2038        // for stop() to tear down. The distinct device_id keeps the
2039        // self-hello from violating invariant I1 on projection.
2040        let port_a = random_port().await;
2041        let (node_a, event_tx_a, _net_a) =
2042            make_test_node_with_device_id("node-a", "dev-node-a", port_a).await;
2043
2044        let self_peer = NetworkPeer {
2045            id: "node-a".to_string(),
2046            hostname: "truffle-test-node-a".to_string(),
2047            ip: "127.0.0.1".parse().unwrap(),
2048            online: true,
2049            cur_addr: Some("127.0.0.1:41641".to_string()),
2050            relay: None,
2051            os: None,
2052            last_seen: None,
2053            key_expiry: None,
2054            dns_name: None,
2055        };
2056        let _ = event_tx_a.send(NetworkPeerEvent::Joined(self_peer));
2057        tokio::time::sleep(Duration::from_millis(100)).await;
2058
2059        node_a
2060            .send("node-a", "test", b"hello from a")
2061            .await
2062            .expect("loopback self-dial should establish a WS connection");
2063
2064        let peers = node_a.peers().await;
2065        let entry = peers
2066            .iter()
2067            .find(|p| p.tailscale_id == "node-a")
2068            .expect("self peer discovered");
2069        assert!(
2070            entry.ws_connected,
2071            "send() should have established a WS connection"
2072        );
2073
2074        node_a.stop().await;
2075        let peers = node_a.peers().await;
2076        let entry = peers
2077            .iter()
2078            .find(|p| p.tailscale_id == "node-a")
2079            .expect("self peer still discovered");
2080        assert!(
2081            !entry.ws_connected,
2082            "stop() must close and un-mark WS connections"
2083        );
2084    }
2085
2086    // ── RFC 017 Phase 2: resolve_peer_id ─────────────────────────────
2087
2088    /// Helper: inject a peer entry into the session registry and then
2089    /// stamp a synthetic RFC 017 identity on it so `resolve_peer_id`
2090    /// has something to look up. This skips the real hello exchange.
2091    async fn inject_peer_with_identity(
2092        node: &Node<MockNetworkProvider>,
2093        event_tx: &broadcast::Sender<NetworkPeerEvent>,
2094        tailscale_id: &str,
2095        device_id: &str,
2096        device_name: &str,
2097    ) {
2098        let peer = make_loopback_peer(tailscale_id);
2099        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
2100        tokio::time::sleep(Duration::from_millis(30)).await;
2101
2102        let identity = crate::session::PeerIdentity {
2103            app_id: "test".into(),
2104            device_id: device_id.into(),
2105            device_name: device_name.into(),
2106            os: "linux".into(),
2107            tailscale_id: tailscale_id.into(),
2108        };
2109        assert!(
2110            node.session
2111                .test_stamp_identity(tailscale_id, identity)
2112                .await,
2113            "peer {tailscale_id} should exist in session registry before stamping identity"
2114        );
2115    }
2116
2117    #[tokio::test]
2118    async fn test_resolve_peer_id_by_device_id() {
2119        let ws_port = random_port().await;
2120        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2121
2122        inject_peer_with_identity(
2123            &node,
2124            &event_tx,
2125            "tailscale-abc",
2126            "01HZZZZZZZZZZZZZZZZZZZZZZZ",
2127            "Alice MacBook",
2128        )
2129        .await;
2130
2131        let resolved = node
2132            .resolve_peer_id("01HZZZZZZZZZZZZZZZZZZZZZZZ")
2133            .await
2134            .unwrap();
2135        assert_eq!(resolved, "01HZZZZZZZZZZZZZZZZZZZZZZZ");
2136    }
2137
2138    #[tokio::test]
2139    async fn test_resolve_peer_id_by_device_name() {
2140        let ws_port = random_port().await;
2141        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2142
2143        inject_peer_with_identity(
2144            &node,
2145            &event_tx,
2146            "tailscale-abc",
2147            "01HXYZXYZXYZXYZXYZXYZXYZXY",
2148            "Bob's Mac",
2149        )
2150        .await;
2151
2152        let resolved = node.resolve_peer_id("Bob's Mac").await.unwrap();
2153        assert_eq!(resolved, "01HXYZXYZXYZXYZXYZXYZXYZXY");
2154    }
2155
2156    #[tokio::test]
2157    async fn test_resolve_peer_id_by_device_id_prefix() {
2158        let ws_port = random_port().await;
2159        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2160
2161        inject_peer_with_identity(
2162            &node,
2163            &event_tx,
2164            "tailscale-abc",
2165            "01HXYZXYZXYZXYZXYZXYZXYZXY",
2166            "laptop",
2167        )
2168        .await;
2169
2170        // Prefix match — 4 chars is the minimum the implementation
2171        // accepts.
2172        let resolved = node.resolve_peer_id("01HX").await.unwrap();
2173        assert_eq!(resolved, "01HXYZXYZXYZXYZXYZXYZXYZXY");
2174    }
2175
2176    #[tokio::test]
2177    async fn test_resolve_peer_id_by_tailscale_id_legacy() {
2178        // Escape hatch: resolving by the Tailscale stable ID should
2179        // still work and return the device_id (or the tailscale_id as
2180        // fallback when no identity is populated).
2181        let ws_port = random_port().await;
2182        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2183
2184        inject_peer_with_identity(
2185            &node,
2186            &event_tx,
2187            "tailscale-legacy",
2188            "01HLEGACY0000000000000000X",
2189            "legacy box",
2190        )
2191        .await;
2192
2193        let resolved = node.resolve_peer_id("tailscale-legacy").await.unwrap();
2194        assert_eq!(resolved, "01HLEGACY0000000000000000X");
2195    }
2196
2197    #[tokio::test]
2198    async fn test_resolve_peer_id_unknown() {
2199        let ws_port = random_port().await;
2200        let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
2201        let result = node.resolve_peer_id("nope").await;
2202        assert!(matches!(result, Err(NodeError::PeerNotFound(_))));
2203    }
2204
2205    // ── RFC 021: raw transport surface ────────────────────────────────
2206
2207    #[tokio::test]
2208    async fn test_resolve_peer_by_bare_name_before_hello() {
2209        // Raw-transport-only peers have no hello identity; the bare device
2210        // name must still resolve via the hostname slug (RFC 021 smoke-test
2211        // finding: only the full `truffle-{app}-{slug}` hostname matched).
2212        let ws_port = random_port().await;
2213        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2214
2215        let mut peer = make_loopback_peer("nodeid-9");
2216        peer.hostname = "truffle-test-ec2-smoke".to_string();
2217        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
2218        tokio::time::sleep(Duration::from_millis(50)).await;
2219
2220        // Bare slug, and an unslugged variant that normalizes to it.
2221        assert_eq!(node.resolve_peer("ec2-smoke").await.unwrap().id, "nodeid-9");
2222        assert_eq!(node.resolve_peer("EC2 Smoke").await.unwrap().id, "nodeid-9");
2223        // Unknown bare names still miss.
2224        assert!(node.resolve_peer("other-box").await.is_err());
2225    }
2226
2227    #[tokio::test]
2228    async fn test_peers_device_name_strips_hostname_before_hello() {
2229        let ws_port = random_port().await;
2230        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2231
2232        let mut peer = make_loopback_peer("nodeid-9");
2233        peer.hostname = "truffle-test-ec2-smoke".to_string();
2234        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
2235        tokio::time::sleep(Duration::from_millis(50)).await;
2236
2237        let peers = node.peers().await;
2238        assert_eq!(peers.len(), 1);
2239        // Pre-identity: device_name is None; display_name uses stripped slug.
2240        assert!(peers[0].device_name.is_none());
2241        assert_eq!(peers[0].display_name, "ec2-smoke");
2242        assert_eq!(peers[0].hostname, "truffle-test-ec2-smoke");
2243        assert!(peers[0].device_id.is_none());
2244    }
2245
2246    #[tokio::test]
2247    async fn test_resolve_peer_stale_ref_is_peer_gone() {
2248        let ws_port = random_port().await;
2249        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2250
2251        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-9")));
2252        tokio::time::sleep(Duration::from_millis(50)).await;
2253
2254        let live_ref = node.peers().await[0].peer_ref.clone();
2255        assert_eq!(node.resolve_peer(&live_ref).await.unwrap().id, "peer-9");
2256
2257        // Left + rejoin bumps the generation: the old handle's ref must fail
2258        // with PeerGone (RFC 022 I5), never silently reach the new
2259        // generation.
2260        let _ = event_tx.send(NetworkPeerEvent::Left("peer-9".to_string()));
2261        tokio::time::sleep(Duration::from_millis(50)).await;
2262        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-9")));
2263        tokio::time::sleep(Duration::from_millis(50)).await;
2264
2265        assert!(matches!(
2266            node.resolve_peer(&live_ref).await,
2267            Err(NodeError::PeerGone(_))
2268        ));
2269        // A ref for a fully departed peer is PeerGone too — not a typo-shaped
2270        // PeerNotFound.
2271        assert!(matches!(
2272            node.resolve_peer("peer-9:99").await,
2273            Err(NodeError::PeerGone(_))
2274        ));
2275        // peer() propagates PeerGone immediately instead of waiting out the
2276        // timeout.
2277        assert!(matches!(
2278            node.peer(&live_ref, Some(2_000)).await,
2279            Err(NodeError::PeerGone(_))
2280        ));
2281    }
2282
2283    #[tokio::test]
2284    async fn test_resolve_peer_accepts_ip() {
2285        let ws_port = random_port().await;
2286        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2287
2288        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-a")));
2289        tokio::time::sleep(Duration::from_millis(50)).await;
2290
2291        let resolved = node.resolve_peer("127.0.0.1").await.unwrap();
2292        assert_eq!(resolved.id, "peer-a");
2293        // resolve_peer_id falls back to the tailscale id when no hello
2294        // identity is populated yet.
2295        assert_eq!(node.resolve_peer_id("127.0.0.1").await.unwrap(), "peer-a");
2296    }
2297
2298    #[tokio::test]
2299    async fn test_listen_tcp_rejects_reserved_ports() {
2300        let ws_port = random_port().await;
2301        let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
2302
2303        for port in [443u16, 9417] {
2304            let err = node.listen_tcp(port).await.unwrap_err();
2305            assert!(
2306                matches!(err, NodeError::ReservedPort(p) if p == port),
2307                "expected ReservedPort({port}), got: {err}"
2308            );
2309        }
2310    }
2311
2312    #[tokio::test]
2313    async fn test_listen_quic_rejects_reserved_and_ephemeral_ports() {
2314        let ws_port = random_port().await;
2315        let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
2316
2317        let err = node.listen_quic(443).await.unwrap_err();
2318        assert!(matches!(err, NodeError::ReservedPort(443)));
2319
2320        let err = node.listen_quic(0).await.unwrap_err();
2321        assert!(matches!(err, NodeError::NotImplemented(_)));
2322    }
2323
2324    #[tokio::test]
2325    async fn test_bind_udp_falls_back_to_direct_socket() {
2326        // The mock provider has no UDP support, so bind_udp exercises the
2327        // direct-socket fallback and must return a usable socket.
2328        let ws_port = random_port().await;
2329        let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
2330
2331        let socket = node.bind_udp(0).await.unwrap();
2332        let addr = socket.local_addr().unwrap();
2333        assert_ne!(addr.port(), 0);
2334    }
2335
2336    #[test]
2337    fn test_raw_alpn_scoping() {
2338        assert_eq!(
2339            crate::transport::quic::raw_alpn("demo"),
2340            b"truffle-raw.demo".to_vec()
2341        );
2342        assert_eq!(
2343            crate::transport::quic::raw_alpn(""),
2344            b"truffle-raw".to_vec()
2345        );
2346    }
2347
2348    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2349    async fn test_connect_quic_roundtrip_via_raw_listener() {
2350        let ws_port = random_port().await;
2351        let (client_node, event_tx, _net) = make_test_node("cli", ws_port).await;
2352
2353        // Raw listener on port 0 (direct-socket fallback path) with the
2354        // ALPN that connect_quic derives from the mock app_id ("test").
2355        let server_network = Arc::new(MockNetworkProvider::new("srv"));
2356        let alpn = crate::transport::quic::raw_alpn("test");
2357        let listener = crate::transport::quic::listen_raw(&server_network, 0, &alpn)
2358            .await
2359            .unwrap();
2360        let port = listener.port();
2361        assert_ne!(port, 0);
2362
2363        let echo_task = tokio::spawn(async move {
2364            let conn = listener.accept().await.expect("no incoming connection");
2365            let mut stream = conn
2366                .accept_stream()
2367                .await
2368                .unwrap()
2369                .expect("connection closed before stream");
2370            let mut received = Vec::new();
2371            while let Some(chunk) = stream.read(1024).await.unwrap() {
2372                received.extend_from_slice(&chunk);
2373            }
2374            stream.write(&received).await.unwrap();
2375            stream.finish();
2376            // Keep the connection alive until the peer has read the echo —
2377            // closing immediately could drop in-flight stream data.
2378            tokio::time::sleep(Duration::from_millis(500)).await;
2379            conn.close();
2380        });
2381
2382        // Register the "server" as a peer at 127.0.0.1 and connect by name.
2383        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("srv-peer")));
2384        tokio::time::sleep(Duration::from_millis(50)).await;
2385
2386        let conn = client_node.connect_quic("srv-peer", port).await.unwrap();
2387        let mut stream = conn.open_stream().await.unwrap();
2388        stream.write(b"hello quic").await.unwrap();
2389        stream.finish();
2390
2391        let mut echoed = Vec::new();
2392        while let Some(chunk) = stream.read(1024).await.unwrap() {
2393            echoed.extend_from_slice(&chunk);
2394        }
2395        assert_eq!(echoed, b"hello quic");
2396
2397        echo_task.await.unwrap();
2398        conn.close();
2399    }
2400
2401    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2402    async fn test_quic_raw_alpn_mismatch_rejected() {
2403        // Cross-app connections must fail the TLS handshake outright —
2404        // ALPN is the QUIC analog of the WS hello's app_id check.
2405        let server_network = Arc::new(MockNetworkProvider::new("srv"));
2406        let listener = crate::transport::quic::listen_raw(
2407            &server_network,
2408            0,
2409            &crate::transport::quic::raw_alpn("app-a"),
2410        )
2411        .await
2412        .unwrap();
2413        let port = listener.port();
2414
2415        let client_network = Arc::new(MockNetworkProvider::new("cli"));
2416        let result = crate::transport::quic::connect_raw(
2417            &client_network,
2418            "127.0.0.1",
2419            port,
2420            &crate::transport::quic::raw_alpn("app-b"),
2421        )
2422        .await;
2423
2424        assert!(
2425            result.is_err(),
2426            "cross-app QUIC connect must fail (ALPN mismatch)"
2427        );
2428    }
2429
2430    // ── RFC 022 honest projection ─────────────────────────────────────
2431
2432    #[tokio::test]
2433    async fn test_rfc022_node_peer_resolves_and_wait_miss_returns_none() {
2434        let ws_port = random_port().await;
2435        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
2436
2437        // Miss without wait
2438        assert!(node.peer("nope", None).await.unwrap().is_none());
2439
2440        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-a")));
2441        tokio::time::sleep(Duration::from_millis(50)).await;
2442
2443        let p = node.peer("peer-a", None).await.unwrap().expect("found");
2444        assert_eq!(p.tailscale_id, "peer-a");
2445        assert!(p.device_id.is_none());
2446
2447        // waitMs on unknown times out to None
2448        let start = std::time::Instant::now();
2449        let miss = node.peer("still-missing", Some(80)).await.unwrap();
2450        assert!(miss.is_none());
2451        assert!(start.elapsed() >= Duration::from_millis(70));
2452    }
2453
2454    #[test]
2455    fn test_rfc022_peer_projection_never_uses_tailscale_as_device_id() {
2456        use crate::session::PeerState;
2457        use std::net::Ipv4Addr;
2458
2459        // Pre-identity
2460        let pre = PeerState {
2461            id: "ts-abc".into(),
2462            generation: 1,
2463            name: "truffle-app-laptop".into(),
2464            ip: Ipv4Addr::new(100, 64, 0, 1).into(),
2465            online: true,
2466            ws_connected: false,
2467            connection_type: "direct".into(),
2468            os: None,
2469            last_seen: None,
2470            identity: None,
2471            identity_suppressed: false,
2472        };
2473        let p = Peer::from(pre);
2474        assert!(p.device_id.is_none());
2475        assert_eq!(p.tailscale_id, "ts-abc");
2476        assert_eq!(p.peer_ref, "ts-abc:1");
2477        assert_eq!(p.display_name, "laptop");
2478        assert!(p
2479            .device_id
2480            .as_ref()
2481            .map(|d| d.as_str() != p.tailscale_id)
2482            .unwrap_or(true));
2483
2484        // Post-identity
2485        let post = PeerState {
2486            id: "ts-abc".into(),
2487            generation: 1,
2488            name: "truffle-app-laptop".into(),
2489            ip: Ipv4Addr::new(100, 64, 0, 1).into(),
2490            online: true,
2491            ws_connected: true,
2492            connection_type: "direct".into(),
2493            os: Some("darwin".into()),
2494            last_seen: None,
2495            identity: Some(crate::session::PeerIdentity {
2496                app_id: "app".into(),
2497                device_id: "01J4K9M2Z8AB3RNYQPW6H5TC0X".into(),
2498                device_name: "Alice's Mac".into(),
2499                os: "darwin".into(),
2500                tailscale_id: "ts-abc".into(),
2501            }),
2502            identity_suppressed: false,
2503        };
2504        let p = Peer::from(post);
2505        assert_eq!(p.device_id.as_deref(), Some("01J4K9M2Z8AB3RNYQPW6H5TC0X"));
2506        assert_ne!(p.device_id.as_deref().unwrap(), p.tailscale_id);
2507        assert_eq!(p.display_name, "Alice's Mac");
2508
2509        // Suppressed (first-wins loser)
2510        let suppressed = PeerState {
2511            id: "ts-xyz".into(),
2512            generation: 1,
2513            name: "truffle-app-other".into(),
2514            ip: Ipv4Addr::new(100, 64, 0, 2).into(),
2515            online: true,
2516            ws_connected: true,
2517            connection_type: "direct".into(),
2518            os: None,
2519            last_seen: None,
2520            identity: Some(crate::session::PeerIdentity {
2521                app_id: "app".into(),
2522                device_id: "01J4K9M2Z8AB3RNYQPW6H5TC0X".into(),
2523                device_name: "Clone".into(),
2524                os: "linux".into(),
2525                tailscale_id: "ts-xyz".into(),
2526            }),
2527            identity_suppressed: true,
2528        };
2529        let p = Peer::from(suppressed);
2530        assert!(
2531            p.device_id.is_none(),
2532            "suppressed claim must project null device_id"
2533        );
2534    }
2535}