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