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::{HealthInfo, NetworkProvider, NetworkUdpSocket, NodeIdentity, PingResult};
59use crate::session::{PeerEvent, PeerRegistry, PeerState};
60use crate::transport::websocket::WebSocketTransport;
61use crate::transport::{RawListener, WsConfig};
62
63// ---------------------------------------------------------------------------
64// NamespacedMessage — public message type for subscribers
65// ---------------------------------------------------------------------------
66
67/// A message received on a specific namespace.
68///
69/// This is the public type that [`Node::subscribe`] delivers to application
70/// code. It contains the deserialized envelope fields plus the sender's peer ID.
71#[derive(Debug, Clone)]
72pub struct NamespacedMessage {
73    /// Stable node ID of the sender.
74    pub from: String,
75    /// Namespace the message was sent on.
76    pub namespace: String,
77    /// Application-defined message type within the namespace.
78    pub msg_type: String,
79    /// Opaque JSON payload.
80    pub payload: serde_json::Value,
81    /// Millisecond Unix timestamp from the sender, if set.
82    pub timestamp: Option<u64>,
83}
84
85// ---------------------------------------------------------------------------
86// Peer — simplified view for application code
87// ---------------------------------------------------------------------------
88
89/// A peer as seen by application code.
90///
91/// This is a simplified projection of the internal [`PeerState`] that hides
92/// session-layer internals. Applications use this to display peer lists and
93/// resolve peer IDs for `send()` / `open_tcp()`.
94///
95/// RFC 017 introduced `device_id` and `device_name` derived from the hello
96/// envelope — these are populated once the WebSocket link comes up. Before
97/// that, they fall back to the legacy Tailscale ID / hostname pair so
98/// application code has something to display even for not-yet-connected
99/// peers.
100#[derive(Debug, Clone)]
101pub struct Peer {
102    /// Legacy per-peer ID. Kept for back-compat with call sites that
103    /// destructure `peer.id`; new code should prefer [`device_id`](Self::device_id)
104    /// directly. Equal to `device_id` once the hello has landed; equal to
105    /// the Tailscale stable ID beforehand.
106    pub id: String,
107    /// Legacy name (the Layer 3 Tailscale hostname — the *slug*). New code
108    /// should prefer [`device_name`](Self::device_name).
109    pub name: String,
110    /// Stable per-device ULID (RFC 017 §5.4) from the hello envelope.
111    /// Falls back to the Tailscale stable ID until the hello handshake
112    /// completes.
113    pub device_id: String,
114    /// Human-readable device name from the hello envelope (original
115    /// Unicode form, NOT the slug). Falls back to the hostname until the
116    /// hello completes.
117    pub device_name: String,
118    /// Tailscale stable node ID — escape hatch for diagnostics and the
119    /// transport routing key.
120    pub tailscale_id: String,
121    /// Network IP address.
122    pub ip: IpAddr,
123    /// Whether the peer is online (from Layer 3).
124    pub online: bool,
125    /// Whether there is an active WebSocket connection.
126    pub ws_connected: bool,
127    /// Connection type description (e.g., `"direct"` or `"relay:ord"`).
128    pub connection_type: String,
129    /// Operating system, if known. Prefers the hello envelope's value
130    /// and falls back to Layer 3.
131    pub os: Option<String>,
132    /// Last time the peer was seen online (RFC 3339 string).
133    pub last_seen: Option<String>,
134}
135
136impl From<PeerState> for Peer {
137    fn from(s: PeerState) -> Self {
138        let (device_id, device_name, os) = match s.identity.as_ref() {
139            Some(identity) => (
140                identity.device_id.clone(),
141                identity.device_name.clone(),
142                Some(identity.os.clone()),
143            ),
144            None => (s.id.clone(), s.name.clone(), s.os.clone()),
145        };
146        let legacy_id = s
147            .identity
148            .as_ref()
149            .map(|i| i.device_id.clone())
150            .unwrap_or_else(|| s.id.clone());
151        Self {
152            id: legacy_id,
153            name: s.name,
154            device_id,
155            device_name,
156            tailscale_id: s.id,
157            ip: s.ip,
158            online: s.online,
159            ws_connected: s.ws_connected,
160            connection_type: s.connection_type,
161            os,
162            last_seen: s.last_seen,
163        }
164    }
165}
166
167// ---------------------------------------------------------------------------
168// NodeError
169// ---------------------------------------------------------------------------
170
171/// Errors from the Node API.
172#[derive(Debug, thiserror::Error)]
173pub enum NodeError {
174    /// The requested peer is not known.
175    #[error("peer not found: {0}")]
176    PeerNotFound(String),
177
178    /// Failed to establish a connection.
179    #[error("connection failed: {0}")]
180    ConnectionFailed(String),
181
182    /// Failed to send a message.
183    #[error("send failed: {0}")]
184    SendFailed(String),
185
186    /// Envelope encoding/decoding error.
187    #[error("envelope error: {0}")]
188    Envelope(#[from] EnvelopeError),
189
190    /// Session layer error.
191    #[error("session error: {0}")]
192    Session(#[from] crate::session::SessionError),
193
194    /// Network layer error.
195    #[error("network error: {0}")]
196    Network(#[from] crate::network::NetworkError),
197
198    /// Transport layer error.
199    #[error("transport error: {0}")]
200    Transport(#[from] crate::transport::TransportError),
201
202    /// The requested feature is not yet implemented.
203    #[error("not implemented: {0}")]
204    NotImplemented(String),
205
206    /// The node has been stopped.
207    #[error("node stopped")]
208    Stopped,
209
210    /// Builder configuration error.
211    #[error("build error: {0}")]
212    BuildError(String),
213
214    /// I/O error from the builder (state dir creation, device-id persistence).
215    #[error("io error: {0}")]
216    Io(#[from] std::io::Error),
217}
218
219// ---------------------------------------------------------------------------
220// Node
221// ---------------------------------------------------------------------------
222
223/// The main truffle node — single public entry point for all functionality.
224///
225/// Generic over `N: NetworkProvider` so that tests can inject a mock provider
226/// without Tailscale. In production, use the concrete type
227/// `Node<TailscaleProvider>` (created via [`NodeBuilder`]).
228///
229/// # Lifecycle
230///
231/// 1. Create via [`Node::builder()`] + `.build().await`
232/// 2. Use `peers()`, `send()`, `subscribe()`, `open_tcp()`, etc.
233/// 3. Call `stop()` to shut down
234pub struct Node<N: NetworkProvider + 'static> {
235    /// Layer 3 network provider.
236    network: Arc<N>,
237    /// Layer 5 session / peer registry.
238    session: Arc<PeerRegistry<N>>,
239    /// Layer 6 envelope codec.
240    codec: Arc<dyn EnvelopeCodec>,
241    /// Broadcast sender for all incoming namespaced messages.
242    /// Kept alive to prevent the channel from closing. The router task holds a clone.
243    #[allow(dead_code)]
244    incoming_tx: broadcast::Sender<NamespacedMessage>,
245    /// Per-namespace subscription channels.
246    ///
247    /// Guarded by a `std::sync::RwLock` (not tokio) because the lock is
248    /// held only for quick HashMap operations with no `.await` points
249    /// underneath, and the `subscribe()` API has to be callable from
250    /// synchronous contexts (the NAPI bridge, which runs on Node.js's
251    /// main thread with no tokio runtime in scope).
252    namespace_filters: Arc<StdRwLock<HashMap<String, broadcast::Sender<NamespacedMessage>>>>,
253    /// File transfer subsystem state.
254    pub(crate) file_transfer_state: FileTransferState,
255}
256
257impl<N: NetworkProvider + 'static> Node<N> {
258    /// Create a `Node` from pre-built components (used by builder and tests).
259    ///
260    /// This constructor wires together the layers and spawns the envelope
261    /// router task that reads from the session layer, deserializes envelopes,
262    /// and dispatches to namespace subscribers.
263    pub(crate) fn from_parts(
264        network: Arc<N>,
265        session: Arc<PeerRegistry<N>>,
266        codec: Arc<dyn EnvelopeCodec>,
267    ) -> Self {
268        let (incoming_tx, _) = broadcast::channel(1024);
269        let namespace_filters: Arc<
270            StdRwLock<HashMap<String, broadcast::Sender<NamespacedMessage>>>,
271        > = Arc::new(StdRwLock::new(HashMap::new()));
272
273        let node = Self {
274            network,
275            session: session.clone(),
276            codec: codec.clone(),
277            incoming_tx: incoming_tx.clone(),
278            namespace_filters: namespace_filters.clone(),
279            file_transfer_state: FileTransferState::new(),
280        };
281
282        // Spawn the envelope router task.
283        node.spawn_envelope_router(session, codec, incoming_tx, namespace_filters);
284
285        node
286    }
287
288    /// Spawn a background task that reads incoming raw messages from the
289    /// session layer, deserializes them as envelopes, and routes them to
290    /// the global channel and per-namespace subscribers.
291    fn spawn_envelope_router(
292        &self,
293        session: Arc<PeerRegistry<N>>,
294        codec: Arc<dyn EnvelopeCodec>,
295        incoming_tx: broadcast::Sender<NamespacedMessage>,
296        namespace_filters: Arc<StdRwLock<HashMap<String, broadcast::Sender<NamespacedMessage>>>>,
297    ) {
298        let mut rx = session.subscribe();
299
300        tokio::spawn(async move {
301            loop {
302                match rx.recv().await {
303                    Ok(msg) => {
304                        if let Ok(envelope) = codec.decode(&msg.data) {
305                            let namespaced = NamespacedMessage {
306                                from: msg.from,
307                                namespace: envelope.namespace.clone(),
308                                msg_type: envelope.msg_type,
309                                payload: envelope.payload,
310                                timestamp: envelope.timestamp,
311                            };
312
313                            tracing::debug!(
314                                from = %namespaced.from,
315                                namespace = %namespaced.namespace,
316                                msg_type = %namespaced.msg_type,
317                                "envelope router: dispatching message"
318                            );
319
320                            // Send to global channel (best-effort).
321                            let _ = incoming_tx.send(namespaced.clone());
322
323                            // Route to namespace-specific subscriber if present.
324                            // Sync read on the std RwLock: the critical section
325                            // is a HashMap lookup with no `.await` points.
326                            let filters = namespace_filters
327                                .read()
328                                .expect("namespace_filters std RwLock poisoned");
329                            let _has_subscriber = filters.contains_key(&namespaced.namespace);
330                            if let Some(tx) = filters.get(&namespaced.namespace) {
331                                let send_result = tx.send(namespaced);
332                                tracing::debug!(
333                                    namespace = %envelope.namespace,
334                                    subscriber_count = tx.receiver_count(),
335                                    sent = send_result.is_ok(),
336                                    "envelope router: sent to namespace subscriber"
337                                );
338                            } else {
339                                tracing::debug!(
340                                    namespace = %envelope.namespace,
341                                    "envelope router: no subscriber for namespace"
342                                );
343                            }
344                        } else {
345                            tracing::warn!(
346                                from = %msg.from,
347                                data_len = msg.data.len(),
348                                "node: failed to decode envelope from incoming message"
349                            );
350                        }
351                    }
352                    Err(broadcast::error::RecvError::Lagged(n)) => {
353                        tracing::warn!(
354                            missed = n,
355                            "node: envelope router lagged, missed {n} messages"
356                        );
357                        continue;
358                    }
359                    Err(broadcast::error::RecvError::Closed) => {
360                        tracing::debug!("node: session incoming channel closed, router exiting");
361                        break;
362                    }
363                }
364            }
365        });
366    }
367
368    // ── Builder ──────────────────────────────────────────────────────────
369
370    /// Create a new [`NodeBuilder`] for configuring and constructing a node.
371    pub fn builder() -> NodeBuilder {
372        NodeBuilder::default()
373    }
374
375    // ── File Transfer ────────────────────────────────────────────────────
376
377    /// Access the file transfer subsystem.
378    ///
379    /// Returns a [`FileTransfer`](file_transfer::FileTransfer) handle
380    /// that provides methods for sending, receiving, and pulling files.
381    pub fn file_transfer(&self) -> file_transfer::FileTransfer<'_, N> {
382        file_transfer::FileTransfer::new(self)
383    }
384
385    /// Create a synchronized store for device-owned state.
386    ///
387    /// Returns an `Arc<SyncedStore<T>>` that syncs data across the mesh on
388    /// namespace `"ss:{store_id}"`. The caller owns the returned Arc;
389    /// the background sync task also holds one.
390    ///
391    /// Requires `self` to be wrapped in an `Arc` because the sync task
392    /// needs to outlive this call.
393    pub fn synced_store<T>(
394        self: &Arc<Self>,
395        store_id: &str,
396    ) -> Arc<crate::synced_store::SyncedStore<T>>
397    where
398        T: serde::Serialize + serde::de::DeserializeOwned + Clone + Send + Sync + 'static,
399    {
400        crate::synced_store::SyncedStore::new(self.clone(), store_id)
401    }
402
403    /// Create a synchronized store with a custom persistence backend.
404    ///
405    /// Same as [`synced_store`](Self::synced_store) but restores persisted
406    /// data on startup and writes through to the backend on every change.
407    pub fn synced_store_with_backend<T>(
408        self: &Arc<Self>,
409        store_id: &str,
410        backend: std::sync::Arc<dyn crate::synced_store::StoreBackend>,
411    ) -> Arc<crate::synced_store::SyncedStore<T>>
412    where
413        T: serde::Serialize + serde::de::DeserializeOwned + Clone + Send + Sync + 'static,
414    {
415        crate::synced_store::SyncedStore::new_with_backend(self.clone(), store_id, backend)
416    }
417
418    // ── Lifecycle ────────────────────────────────────────────────────────
419
420    /// Stop the node and all underlying layers.
421    ///
422    /// After calling `stop()`, the node should not be used for further
423    /// operations. Peer connections are closed and the network provider
424    /// is shut down.
425    pub async fn stop(&self) {
426        tracing::info!("node: stopping");
427        // The session and network layers will be cleaned up when the last
428        // Arc reference is dropped. For now, we signal intent to stop.
429        // Future enhancement: add explicit shutdown signals to each layer.
430    }
431
432    // ── Identity ─────────────────────────────────────────────────────────
433
434    /// Return the local node's identity (stable ID, hostname, name).
435    pub fn local_info(&self) -> NodeIdentity {
436        self.network.local_identity()
437    }
438
439    // ── Discovery (from Layer 3, no transport needed) ────────────────────
440
441    /// Return all known peers.
442    ///
443    /// Includes peers that are online but not yet connected (no active WS).
444    /// This information comes from Layer 3 peer discovery.
445    pub async fn peers(&self) -> Vec<Peer> {
446        self.session
447            .peers()
448            .await
449            .into_iter()
450            .map(Peer::from)
451            .collect()
452    }
453
454    /// Subscribe to peer change events (joined, left, connected, etc.).
455    pub fn on_peer_change(&self) -> broadcast::Receiver<PeerEvent> {
456        self.session.on_peer_change()
457    }
458
459    /// Resolve a peer identifier to the canonical per-device ULID
460    /// (`device_id`) from the RFC 017 hello envelope.
461    ///
462    /// Accepts any of:
463    /// - the stable `device_id` (full ULID)
464    /// - a unique prefix of the `device_id` (at least 4 characters; must
465    ///   match exactly one known peer)
466    /// - the human-readable `device_name` from the hello
467    /// - the Layer 3 Tailscale hostname (the sanitised slug) — legacy
468    /// - the Tailscale stable ID — escape hatch for diagnostics
469    ///
470    /// The returned string is always a `device_id`, so it is safe to feed
471    /// back into `Node::send` or any other method that takes a peer
472    /// identifier.
473    pub async fn resolve_peer_id(&self, peer_id: &str) -> Result<String, NodeError> {
474        let peers = self.session.peers().await;
475
476        // Direct matches first — device_id, device_name, hostname,
477        // tailscale_id — so deterministic inputs always take the fast path.
478        for p in &peers {
479            if let Some(identity) = p.identity.as_ref() {
480                if identity.device_id == peer_id || identity.device_name == peer_id {
481                    return Ok(identity.device_id.clone());
482                }
483            }
484            if p.id == peer_id || p.name == peer_id {
485                // `p.id` is the tailscale_id. If we have a richer identity,
486                // return its device_id; otherwise fall back to the routing
487                // key so the caller still has something usable.
488                return Ok(p
489                    .identity
490                    .as_ref()
491                    .map(|i| i.device_id.clone())
492                    .unwrap_or_else(|| p.id.clone()));
493            }
494        }
495
496        // Prefix match on device_id (require at least 4 chars and a single
497        // unambiguous hit). This matches the CLI affordance of typing just
498        // the first few characters of a ULID.
499        if peer_id.len() >= 4 {
500            let prefix_hits: Vec<&PeerState> = peers
501                .iter()
502                .filter(|p| {
503                    p.identity
504                        .as_ref()
505                        .map(|i| i.device_id.starts_with(peer_id))
506                        .unwrap_or(false)
507                })
508                .collect();
509            if prefix_hits.len() == 1 {
510                if let Some(identity) = prefix_hits[0].identity.as_ref() {
511                    return Ok(identity.device_id.clone());
512                }
513            }
514        }
515
516        Err(NodeError::PeerNotFound(peer_id.to_string()))
517    }
518
519    // ── Diagnostics ──────────────────────────────────────────────────────
520
521    /// Ping a peer via the network layer.
522    ///
523    /// Resolves the peer ID to an IP address and pings via Layer 3. Accepts
524    /// the same identifier forms as [`resolve_peer_id`](Self::resolve_peer_id).
525    pub async fn ping(&self, peer_id: &str) -> Result<PingResult, NodeError> {
526        let peers = self.session.peers().await;
527        let peer = peers
528            .iter()
529            .find(|p| {
530                p.id == peer_id
531                    || p.name == peer_id
532                    || p.identity
533                        .as_ref()
534                        .map(|i| i.device_id == peer_id || i.device_name == peer_id)
535                        .unwrap_or(false)
536            })
537            .ok_or_else(|| NodeError::PeerNotFound(peer_id.to_string()))?;
538
539        let addr = peer.ip.to_string();
540        self.network.ping(&addr).await.map_err(NodeError::Network)
541    }
542
543    /// Return health information from the network layer.
544    pub async fn health(&self) -> HealthInfo {
545        self.network.health().await
546    }
547
548    // ── Messaging (Layer 6 envelope over Layer 4 WS) ─────────────────────
549
550    /// Send a namespaced message to a specific peer.
551    ///
552    /// The data is wrapped in a Layer 6 [`Envelope`] with the given namespace
553    /// and a `"message"` type, then serialized and sent via the session layer.
554    /// If no WebSocket connection exists, one is lazily established.
555    pub async fn send(&self, peer_id: &str, namespace: &str, data: &[u8]) -> Result<(), NodeError> {
556        // If the data is valid UTF-8 JSON, parse it into a proper JSON value
557        // so the receiver gets a structured object rather than an array of
558        // byte values.  This is critical for the file transfer protocol and
559        // any other protocol that serializes structs to JSON bytes before
560        // calling send().
561        let payload = std::str::from_utf8(data)
562            .ok()
563            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
564            .unwrap_or_else(|| serde_json::Value::from(data.to_vec()));
565
566        let envelope = Envelope::new(namespace, "message", payload).with_timestamp();
567
568        let encoded = self.codec.encode(&envelope)?;
569        self.session.send(peer_id, &encoded).await?;
570        Ok(())
571    }
572
573    /// Send a namespaced message with an explicit `msg_type` and JSON payload.
574    ///
575    /// Unlike [`send`](Self::send), this method takes a pre-built
576    /// [`serde_json::Value`] payload and a caller-chosen `msg_type` instead
577    /// of raw bytes with a hardcoded `"message"` type. Used by subsystems
578    /// (file transfer, synced store, request/reply) that define their own
579    /// wire protocol message types.
580    pub async fn send_typed(
581        &self,
582        peer_id: &str,
583        namespace: &str,
584        msg_type: &str,
585        payload: &serde_json::Value,
586    ) -> Result<(), NodeError> {
587        let envelope = Envelope::new(namespace, msg_type, payload.clone()).with_timestamp();
588        let encoded = self.codec.encode(&envelope)?;
589        self.session.send(peer_id, &encoded).await?;
590        Ok(())
591    }
592
593    /// Broadcast a namespaced message with an explicit `msg_type` and JSON
594    /// payload to all connected peers.
595    pub async fn broadcast_typed(
596        &self,
597        namespace: &str,
598        msg_type: &str,
599        payload: &serde_json::Value,
600    ) {
601        let envelope = Envelope::new(namespace, msg_type, payload.clone()).with_timestamp();
602        match self.codec.encode(&envelope) {
603            Ok(encoded) => {
604                self.session.broadcast(&encoded).await;
605            }
606            Err(e) => {
607                tracing::error!("node: failed to encode broadcast envelope: {e}");
608            }
609        }
610    }
611
612    /// Broadcast a namespaced message to all connected peers.
613    ///
614    /// Only peers with active WebSocket connections receive the broadcast.
615    /// No lazy connections are established.
616    pub async fn broadcast(&self, namespace: &str, data: &[u8]) {
617        let payload = std::str::from_utf8(data)
618            .ok()
619            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
620            .unwrap_or_else(|| serde_json::Value::from(data.to_vec()));
621
622        let envelope = Envelope::new(namespace, "message", payload).with_timestamp();
623
624        match self.codec.encode(&envelope) {
625            Ok(encoded) => {
626                self.session.broadcast(&encoded).await;
627            }
628            Err(e) => {
629                tracing::error!("node: failed to encode broadcast envelope: {e}");
630            }
631        }
632    }
633
634    /// Subscribe to messages in a specific namespace.
635    ///
636    /// Returns a broadcast receiver that yields [`NamespacedMessage`]s
637    /// matching the given namespace. Multiple subscribers to the same
638    /// namespace share the same underlying channel.
639    pub fn subscribe(&self, namespace: &str) -> broadcast::Receiver<NamespacedMessage> {
640        // Fast path: check if subscriber already exists (read lock).
641        {
642            let filters = self
643                .namespace_filters
644                .read()
645                .expect("namespace_filters std RwLock poisoned");
646            if let Some(tx) = filters.get(namespace) {
647                return tx.subscribe();
648            }
649        }
650
651        // Slow path: create a new channel for this namespace (write lock).
652        let mut filters = self
653            .namespace_filters
654            .write()
655            .expect("namespace_filters std RwLock poisoned");
656        // Double-check after acquiring write lock.
657        if let Some(tx) = filters.get(namespace) {
658            return tx.subscribe();
659        }
660        let (tx, rx) = broadcast::channel(256);
661        filters.insert(namespace.to_string(), tx);
662        rx
663    }
664
665    // ── Raw streams (Layer 4 direct) ─────────────────────────────────────
666
667    /// Open a raw TCP stream to a peer on the given port.
668    ///
669    /// Resolves the peer ID to an IP address via the session's peer list,
670    /// then dials via the network layer. Accepts the same identifier
671    /// forms as [`resolve_peer_id`](Self::resolve_peer_id). Returns a
672    /// plain `TcpStream` for byte-oriented I/O.
673    pub async fn open_tcp(&self, peer_id: &str, port: u16) -> Result<TcpStream, NodeError> {
674        let peers = self.session.peers().await;
675        let peer = peers
676            .iter()
677            .find(|p| {
678                p.id == peer_id
679                    || p.name == peer_id
680                    || p.identity
681                        .as_ref()
682                        .map(|i| i.device_id == peer_id || i.device_name == peer_id)
683                        .unwrap_or(false)
684            })
685            .ok_or_else(|| NodeError::PeerNotFound(peer_id.to_string()))?;
686
687        let addr = peer.ip.to_string();
688        self.network
689            .dial_tcp(&addr, port)
690            .await
691            .map_err(|e| NodeError::ConnectionFailed(e.to_string()))
692    }
693
694    /// Listen for incoming TCP connections on a port.
695    ///
696    /// Returns a [`RawListener`] that yields raw `TcpStream`s. The caller
697    /// is responsible for accepting connections in a loop.
698    pub async fn listen_tcp(&self, port: u16) -> Result<RawListener, NodeError> {
699        use crate::transport::tcp::TcpTransport;
700        use crate::transport::RawTransport;
701
702        let tcp = TcpTransport::new(self.network.clone());
703        tcp.listen(port).await.map_err(NodeError::Transport)
704    }
705
706    /// Open a QUIC connection to a peer.
707    ///
708    /// **Stub** — returns `NotImplemented` until Phase 8.
709    pub async fn open_quic(&self, _peer_id: &str) -> Result<(), NodeError> {
710        Err(NodeError::NotImplemented(
711            "QUIC connections are not yet implemented".to_string(),
712        ))
713    }
714
715    /// Open a UDP datagram socket to a peer.
716    ///
717    /// **Stub** — returns `NotImplemented` until Phase 8.
718    pub async fn open_udp(&self, _peer_id: &str) -> Result<NetworkUdpSocket, NodeError> {
719        Err(NodeError::NotImplemented(
720            "UDP sockets are not yet implemented".to_string(),
721        ))
722    }
723}
724
725// ---------------------------------------------------------------------------
726// NodeBuilder
727// ---------------------------------------------------------------------------
728
729/// Builder for constructing a [`Node<TailscaleProvider>`].
730///
731/// Configures the Tailscale sidecar, RFC 017 identity, and transport
732/// parameters before wiring all layers together.
733///
734/// # Example
735///
736/// ```ignore
737/// let node = Node::builder()
738///     .app_id("playground")?
739///     .device_name("alice-mbp")
740///     .sidecar_path("/opt/truffle/sidecar")
741///     .ws_port(9417)
742///     .build()
743///     .await?;
744/// ```
745#[derive(Debug, Clone)]
746pub struct NodeBuilder {
747    app_id: Option<AppId>,
748    device_name: Option<DeviceName>,
749    device_id: Option<DeviceId>,
750    sidecar_path: Option<PathBuf>,
751    state_dir: Option<String>,
752    auth_key: Option<String>,
753    ephemeral: bool,
754    ws_port: u16,
755}
756
757/// Atomically write a string to `path` by writing to a sibling `.tmp`
758/// file first and then renaming it over the destination.
759///
760/// On POSIX, `rename(2)` is atomic and replaces an existing destination
761/// in-place. On Windows, `std::fs::rename` refuses to overwrite, so we
762/// explicitly remove the destination first — the window between remove
763/// and rename is acceptable here because the caller (device-id.txt
764/// persistence) runs once at startup and is not racing itself.
765fn atomic_write_string(path: &Path, content: &str) -> std::io::Result<()> {
766    let _parent = path.parent().ok_or_else(|| {
767        std::io::Error::new(
768            std::io::ErrorKind::InvalidInput,
769            "path has no parent directory",
770        )
771    })?;
772    let mut tmp = path.to_path_buf();
773    tmp.set_extension("tmp");
774    std::fs::write(&tmp, content)?;
775    #[cfg(unix)]
776    {
777        std::fs::rename(&tmp, path)?;
778    }
779    #[cfg(windows)]
780    {
781        // Windows: std::fs::rename errors if the destination exists, so
782        // remove the old file first. Best-effort — missing file is fine.
783        let _ = std::fs::remove_file(path);
784        std::fs::rename(&tmp, path)?;
785    }
786    Ok(())
787}
788
789impl Default for NodeBuilder {
790    fn default() -> Self {
791        Self {
792            app_id: None,
793            device_name: None,
794            device_id: None,
795            sidecar_path: None,
796            state_dir: None,
797            auth_key: None,
798            ephemeral: false,
799            ws_port: 9417,
800        }
801    }
802}
803
804impl NodeBuilder {
805    /// Set the application namespace identifier (RFC 017 §5.1).
806    ///
807    /// The input is validated against `^[a-z][a-z0-9-]{1,31}$`. Invalid
808    /// values are rejected with `NodeError::BuildError`.
809    pub fn app_id(mut self, s: impl Into<String>) -> Result<Self, NodeError> {
810        let raw: String = s.into();
811        let app_id = AppId::parse(&raw)
812            .map_err(|e| NodeError::BuildError(format!("invalid app_id: {e}")))?;
813        self.app_id = Some(app_id);
814        Ok(self)
815    }
816
817    /// Set the human-readable device name.
818    ///
819    /// Accepts any Unicode input; soft-truncated to 256 graphemes. When
820    /// unset, the builder falls back to `hostname::get()` at `build()` time.
821    pub fn device_name(mut self, s: impl Into<String>) -> Self {
822        self.device_name = Some(DeviceName::parse(s));
823        self
824    }
825
826    /// Override the auto-generated device ID.
827    ///
828    /// Validates that `s` is a well-formed ULID. When provided, the value
829    /// is persisted to `{state_dir}/device-id.txt` during `build()` so
830    /// subsequent starts without an explicit `device_id` see it.
831    pub fn device_id(mut self, s: impl Into<String>) -> Result<Self, NodeError> {
832        let raw: String = s.into();
833        let device_id = DeviceId::parse(&raw)
834            .map_err(|e| NodeError::BuildError(format!("invalid device_id: {e}")))?;
835        self.device_id = Some(device_id);
836        Ok(self)
837    }
838
839    /// Set the path to the Go sidecar binary.
840    pub fn sidecar_path(mut self, path: impl Into<PathBuf>) -> Self {
841        self.sidecar_path = Some(path.into());
842        self
843    }
844
845    /// Set the Tailscale state directory.
846    pub fn state_dir(mut self, dir: &str) -> Self {
847        self.state_dir = Some(dir.to_string());
848        self
849    }
850
851    /// Set the Tailscale auth key for headless authentication.
852    pub fn auth_key(mut self, key: &str) -> Self {
853        self.auth_key = Some(key.to_string());
854        self
855    }
856
857    /// Set whether the node is ephemeral (auto-removed from tailnet on shutdown).
858    pub fn ephemeral(mut self, val: bool) -> Self {
859        self.ephemeral = val;
860        self
861    }
862
863    /// Set the WebSocket listen port.
864    pub fn ws_port(mut self, port: u16) -> Self {
865        self.ws_port = port;
866        self
867    }
868
869    /// Resolve RFC 017 identity values and the Tailscale config.
870    ///
871    /// Shared between [`build()`](Self::build) and
872    /// [`build_with_auth_handler()`](Self::build_with_auth_handler). Returns
873    /// the ready-to-start `TailscaleConfig` along with the parsed identity
874    /// triple.
875    fn prepare_config(&self) -> Result<TailscaleConfig, NodeError> {
876        // 1. sidecar binary is required.
877        let binary_path = self
878            .sidecar_path
879            .clone()
880            .ok_or_else(|| NodeError::BuildError("sidecar_path is required".into()))?;
881
882        // 2. app_id is required.
883        let app_id = self
884            .app_id
885            .clone()
886            .ok_or_else(|| NodeError::BuildError("app_id is required".into()))?;
887
888        // 3. device_name falls back to the OS hostname.
889        let device_name = match self.device_name.clone() {
890            Some(name) => name,
891            None => {
892                let os_hostname = hostname::get()
893                    .map_err(|e| {
894                        NodeError::BuildError(format!(
895                            "device_name is unset and hostname::get() failed: {e}"
896                        ))
897                    })?
898                    .to_string_lossy()
899                    .into_owned();
900                DeviceName::parse(os_hostname)
901            }
902        };
903
904        // 4. Compose the Tailscale hostname once, here. Downstream code
905        //    MUST NOT rebuild it — the provider config stores this verbatim.
906        let tailscale_host = identity::tailscale_hostname(&app_id, &device_name);
907
908        // 5. Resolve state_dir. Default:
909        //    `{dirs::data_dir}/truffle/{app_id}/{slug(device_name)}`.
910        let state_dir = self.state_dir.clone().unwrap_or_else(|| {
911            let base = dirs::data_dir().unwrap_or_else(|| {
912                tracing::warn!(
913                    "dirs::data_dir() returned None, falling back to std::env::temp_dir()"
914                );
915                std::env::temp_dir()
916            });
917            base.join("truffle")
918                .join(app_id.as_str())
919                .join(identity::slug(device_name.as_str(), 255))
920                .to_string_lossy()
921                .into_owned()
922        });
923
924        // 6. Ensure the state directory exists before Tailscale starts.
925        std::fs::create_dir_all(&state_dir)?;
926
927        // 7. Resolve device_id. Priority:
928        //    a) explicit builder override → validate + persist
929        //    b) existing `device-id.txt` → read + validate
930        //    c) generate + persist
931        let device_id_file = Path::new(&state_dir).join("device-id.txt");
932        let device_id = match self.device_id.clone() {
933            Some(id) => {
934                // Persist the override so later auto-generated calls see it.
935                atomic_write_string(&device_id_file, id.as_str())?;
936                id
937            }
938            None => {
939                if device_id_file.exists() {
940                    let s = std::fs::read_to_string(&device_id_file)?.trim().to_string();
941                    DeviceId::parse(&s).map_err(|e| {
942                        NodeError::BuildError(format!(
943                            "device-id.txt at {device_id_file:?} contains an invalid ULID: {e}"
944                        ))
945                    })?
946                } else {
947                    let id = DeviceId::generate();
948                    atomic_write_string(&device_id_file, id.as_str())?;
949                    id
950                }
951            }
952        };
953
954        Ok(TailscaleConfig {
955            binary_path,
956            app_id: app_id.as_str().to_string(),
957            device_id: device_id.as_str().to_string(),
958            device_name: device_name.as_str().to_string(),
959            hostname: tailscale_host,
960            state_dir,
961            auth_key: self.auth_key.clone(),
962            ephemeral: if self.ephemeral { Some(true) } else { None },
963            tags: None,
964        })
965    }
966
967    /// Build and start the node.
968    ///
969    /// This creates the TailscaleProvider, starts it, creates the WebSocket
970    /// transport and PeerRegistry, starts the session, and spawns the
971    /// envelope router.
972    ///
973    /// # Errors
974    ///
975    /// Returns [`NodeError::BuildError`] if required configuration is missing,
976    /// or propagates errors from the network provider startup.
977    pub async fn build(self) -> Result<Node<TailscaleProvider>, NodeError> {
978        let ws_port = self.ws_port;
979        let config = self.prepare_config()?;
980
981        let mut provider = TailscaleProvider::new(config);
982        provider.start().await.map_err(NodeError::Network)?;
983
984        let network = Arc::new(provider);
985
986        // 2. Create WebSocket transport.
987        let ws_config = WsConfig {
988            port: ws_port,
989            ..Default::default()
990        };
991        let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config));
992
993        // 3. Create PeerRegistry and start session.
994        let session = Arc::new(PeerRegistry::new(network.clone(), ws_transport));
995        session.start().await;
996
997        // 4. Create the node with the envelope router.
998        let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
999        let node = Node::from_parts(network, session, codec);
1000
1001        tracing::info!("node: started successfully");
1002        Ok(node)
1003    }
1004
1005    /// Build and start the node, calling `on_auth` if authentication is needed.
1006    ///
1007    /// This is identical to [`build()`](Self::build) except it subscribes to
1008    /// provider events *before* `provider.start()` blocks, forwarding
1009    /// `AuthRequired` events to the callback while waiting for authentication
1010    /// to complete.
1011    ///
1012    /// # Errors
1013    ///
1014    /// Returns [`NodeError::BuildError`] if required configuration is missing,
1015    /// or propagates errors from the network provider startup.
1016    pub async fn build_with_auth_handler(
1017        self,
1018        on_auth: impl Fn(String) + Send + 'static,
1019    ) -> Result<Node<TailscaleProvider>, NodeError> {
1020        let ws_port = self.ws_port;
1021        let config = self.prepare_config()?;
1022
1023        let mut provider = TailscaleProvider::new(config);
1024
1025        // 2. Subscribe to peer events BEFORE start() so we capture auth URLs.
1026        let mut auth_rx = provider.peer_events();
1027
1028        // 3. Spawn a task that forwards AuthRequired events to the callback.
1029        let auth_task = tokio::spawn(async move {
1030            use crate::network::NetworkPeerEvent;
1031            loop {
1032                match auth_rx.recv().await {
1033                    Ok(NetworkPeerEvent::AuthRequired { url }) => {
1034                        on_auth(url);
1035                    }
1036                    Err(broadcast::error::RecvError::Closed) => break,
1037                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
1038                    _ => {} // Ignore other events
1039                }
1040            }
1041        });
1042
1043        // 4. Start the provider (blocks until auth completes).
1044        let start_result = provider.start().await.map_err(NodeError::Network);
1045
1046        // 5. Cancel the auth forwarding task — auth is done.
1047        auth_task.abort();
1048
1049        start_result?;
1050
1051        let network = Arc::new(provider);
1052
1053        // 6. Create WebSocket transport.
1054        let ws_config = WsConfig {
1055            port: ws_port,
1056            ..Default::default()
1057        };
1058        let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config));
1059
1060        // 7. Create PeerRegistry and start session.
1061        let session = Arc::new(PeerRegistry::new(network.clone(), ws_transport));
1062        session.start().await;
1063
1064        // 8. Create the node with the envelope router.
1065        let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
1066        let node = Node::from_parts(network, session, codec);
1067
1068        tracing::info!("node: started successfully (with auth handler)");
1069        Ok(node)
1070    }
1071}
1072
1073// ---------------------------------------------------------------------------
1074// Tests
1075// ---------------------------------------------------------------------------
1076
1077#[cfg(test)]
1078mod tests {
1079    use super::*;
1080    use crate::network::{
1081        HealthInfo, IncomingConnection, NetworkError, NetworkPeer, NetworkPeerEvent,
1082        NetworkTcpListener, NetworkUdpSocket, PeerAddr,
1083    };
1084    use crate::transport::WsConfig;
1085    use serde_json::json;
1086    use std::time::Duration;
1087    use tokio::sync::{broadcast, mpsc};
1088
1089    // ── Mock NetworkProvider ──────────────────────────────────────────
1090
1091    struct MockNetworkProvider {
1092        identity: NodeIdentity,
1093        local_addr: PeerAddr,
1094        peer_event_tx: broadcast::Sender<NetworkPeerEvent>,
1095        /// Pre-loaded peer list for `peers()`.
1096        mock_peers: Arc<RwLock<Vec<NetworkPeer>>>,
1097    }
1098
1099    impl MockNetworkProvider {
1100        fn new(id: &str) -> Self {
1101            let (peer_event_tx, _) = broadcast::channel(64);
1102            Self {
1103                identity: NodeIdentity {
1104                    app_id: "test".to_string(),
1105                    // RFC 017: align `device_id` with fixture input so
1106                    // tests can reason about a single identifier.
1107                    device_id: id.to_string(),
1108                    device_name: format!("Test Node {id}"),
1109                    tailscale_hostname: format!("truffle-test-{id}"),
1110                    tailscale_id: id.to_string(),
1111                    dns_name: None,
1112                    ip: Some("127.0.0.1".parse().unwrap()),
1113                },
1114                local_addr: PeerAddr {
1115                    ip: Some("127.0.0.1".parse().unwrap()),
1116                    hostname: format!("truffle-test-{id}"),
1117                    dns_name: None,
1118                },
1119                peer_event_tx,
1120                mock_peers: Arc::new(RwLock::new(Vec::new())),
1121            }
1122        }
1123
1124        fn event_sender(&self) -> broadcast::Sender<NetworkPeerEvent> {
1125            self.peer_event_tx.clone()
1126        }
1127    }
1128
1129    impl NetworkProvider for MockNetworkProvider {
1130        async fn start(&mut self) -> Result<(), NetworkError> {
1131            Ok(())
1132        }
1133
1134        async fn stop(&mut self) -> Result<(), NetworkError> {
1135            Ok(())
1136        }
1137
1138        fn local_identity(&self) -> NodeIdentity {
1139            self.identity.clone()
1140        }
1141
1142        fn local_addr(&self) -> PeerAddr {
1143            self.local_addr.clone()
1144        }
1145
1146        fn peer_events(&self) -> broadcast::Receiver<NetworkPeerEvent> {
1147            self.peer_event_tx.subscribe()
1148        }
1149
1150        async fn peers(&self) -> Vec<NetworkPeer> {
1151            self.mock_peers.read().await.clone()
1152        }
1153
1154        async fn dial_tcp(&self, addr: &str, port: u16) -> Result<TcpStream, NetworkError> {
1155            let target = format!("{addr}:{port}");
1156            TcpStream::connect(&target)
1157                .await
1158                .map_err(|e| NetworkError::DialFailed(format!("mock dial {target}: {e}")))
1159        }
1160
1161        async fn listen_tcp(&self, port: u16) -> Result<NetworkTcpListener, NetworkError> {
1162            let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
1163                .await
1164                .map_err(|e| NetworkError::ListenFailed(format!("mock listen :{port}: {e}")))?;
1165
1166            let actual_port = listener.local_addr().unwrap().port();
1167            let (tx, rx) = mpsc::channel::<IncomingConnection>(64);
1168
1169            tokio::spawn(async move {
1170                loop {
1171                    match listener.accept().await {
1172                        Ok((stream, addr)) => {
1173                            let conn = IncomingConnection {
1174                                stream,
1175                                remote_addr: addr.to_string(),
1176                                remote_identity: String::new(),
1177                                port: actual_port,
1178                            };
1179                            if tx.send(conn).await.is_err() {
1180                                break;
1181                            }
1182                        }
1183                        Err(e) => {
1184                            tracing::debug!("mock listener error: {e}");
1185                            break;
1186                        }
1187                    }
1188                }
1189            });
1190
1191            Ok(NetworkTcpListener {
1192                port: actual_port,
1193                incoming: rx,
1194            })
1195        }
1196
1197        async fn unlisten_tcp(&self, _port: u16) -> Result<(), NetworkError> {
1198            Ok(())
1199        }
1200
1201        async fn bind_udp(&self, _port: u16) -> Result<NetworkUdpSocket, NetworkError> {
1202            Err(NetworkError::Internal("mock: UDP not supported".into()))
1203        }
1204
1205        async fn ping(&self, _addr: &str) -> Result<PingResult, NetworkError> {
1206            Ok(PingResult {
1207                latency: Duration::from_millis(1),
1208                connection: "direct".to_string(),
1209                peer_addr: None,
1210            })
1211        }
1212
1213        async fn health(&self) -> HealthInfo {
1214            HealthInfo {
1215                state: "running".to_string(),
1216                healthy: true,
1217                ..Default::default()
1218            }
1219        }
1220    }
1221
1222    // ── Helpers ──────────────────────────────────────────────────────
1223
1224    fn make_loopback_peer(id: &str) -> NetworkPeer {
1225        NetworkPeer {
1226            id: id.to_string(),
1227            hostname: format!("truffle-test-{id}"),
1228            ip: "127.0.0.1".parse().unwrap(),
1229            online: true,
1230            cur_addr: Some("127.0.0.1:41641".to_string()),
1231            relay: None,
1232            os: Some("linux".to_string()),
1233            last_seen: Some("2026-03-25T12:00:00Z".to_string()),
1234            key_expiry: None,
1235            dns_name: None,
1236        }
1237    }
1238
1239    fn ws_config(port: u16) -> WsConfig {
1240        WsConfig {
1241            port,
1242            ping_interval: Duration::from_secs(300),
1243            pong_timeout: Duration::from_secs(300),
1244            ..Default::default()
1245        }
1246    }
1247
1248    async fn random_port() -> u16 {
1249        let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1250        l.local_addr().unwrap().port()
1251    }
1252
1253    /// Create a Node backed by a mock provider for testing.
1254    async fn make_test_node(
1255        id: &str,
1256        ws_port: u16,
1257    ) -> (
1258        Node<MockNetworkProvider>,
1259        broadcast::Sender<NetworkPeerEvent>,
1260        Arc<MockNetworkProvider>,
1261    ) {
1262        let provider = MockNetworkProvider::new(id);
1263        let event_tx = provider.event_sender();
1264        let network = Arc::new(provider);
1265        let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config(ws_port)));
1266        let session = Arc::new(PeerRegistry::new(network.clone(), ws_transport));
1267        session.start().await;
1268
1269        let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
1270        let node = Node::from_parts(network.clone(), session, codec);
1271
1272        (node, event_tx, network)
1273    }
1274
1275    // ── Tests ────────────────────────────────────────────────────────
1276
1277    #[tokio::test]
1278    async fn test_node_builder_creates_node() {
1279        let ws_port = random_port().await;
1280        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1281
1282        let identity = node.local_info();
1283        assert_eq!(identity.tailscale_id, "node-1");
1284        assert_eq!(identity.device_id, "node-1");
1285        assert!(identity.tailscale_hostname.contains("node-1"));
1286    }
1287
1288    #[tokio::test]
1289    async fn test_node_peers_from_network() {
1290        let ws_port = random_port().await;
1291        let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
1292
1293        // Initially no peers.
1294        let peers = node.peers().await;
1295        assert!(peers.is_empty());
1296
1297        // Inject a peer via Layer 3.
1298        let peer = make_loopback_peer("peer-a");
1299        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
1300        tokio::time::sleep(Duration::from_millis(50)).await;
1301
1302        let peers = node.peers().await;
1303        assert_eq!(peers.len(), 1);
1304        assert_eq!(peers[0].id, "peer-a");
1305        assert!(peers[0].online);
1306        assert!(!peers[0].ws_connected);
1307    }
1308
1309    #[tokio::test]
1310    async fn test_node_send_to_unknown_peer_errors() {
1311        let ws_port = random_port().await;
1312        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1313
1314        let result = node.send("nonexistent", "test", b"hello").await;
1315        assert!(result.is_err());
1316        let err_str = result.unwrap_err().to_string();
1317        assert!(
1318            err_str.contains("unknown peer") || err_str.contains("not found"),
1319            "expected unknown peer error, got: {err_str}"
1320        );
1321    }
1322
1323    #[tokio::test]
1324    async fn test_node_send_wraps_in_envelope() {
1325        // Test that send() properly creates an envelope.
1326        // We test the codec directly since a full send requires two connected nodes.
1327        let codec = JsonCodec;
1328        let data = b"hello world";
1329        let envelope = Envelope::new("test-ns", "message", serde_json::Value::from(data.to_vec()))
1330            .with_timestamp();
1331
1332        let encoded = codec.encode(&envelope).unwrap();
1333        let decoded = codec.decode(&encoded).unwrap();
1334
1335        assert_eq!(decoded.namespace, "test-ns");
1336        assert_eq!(decoded.msg_type, "message");
1337        assert!(decoded.timestamp.is_some());
1338    }
1339
1340    #[tokio::test]
1341    async fn test_node_subscribe_filters_by_namespace() {
1342        let ws_port = random_port().await;
1343        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1344
1345        // Create subscribers for two different namespaces.
1346        let _rx_chat = node.subscribe("chat");
1347        let _rx_ft = node.subscribe("ft");
1348
1349        // Subscribing to the same namespace again should work.
1350        let _rx_chat2 = node.subscribe("chat");
1351    }
1352
1353    #[tokio::test]
1354    async fn test_node_broadcast() {
1355        let ws_port = random_port().await;
1356        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1357
1358        // Broadcast with no connected peers should not panic.
1359        node.broadcast("test", b"hello everyone").await;
1360    }
1361
1362    #[tokio::test]
1363    async fn test_node_open_tcp_resolves_peer() {
1364        let ws_port = random_port().await;
1365        let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
1366
1367        // Start a TCP server for the test.
1368        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1369        let tcp_port = listener.local_addr().unwrap().port();
1370
1371        // Inject a loopback peer.
1372        let peer = make_loopback_peer("peer-tcp");
1373        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
1374        tokio::time::sleep(Duration::from_millis(50)).await;
1375
1376        // Accept a connection in the background.
1377        let accept_handle = tokio::spawn(async move {
1378            let (stream, _) = listener.accept().await.unwrap();
1379            stream
1380        });
1381
1382        // open_tcp should resolve peer-tcp to 127.0.0.1 and connect.
1383        let stream = node.open_tcp("peer-tcp", tcp_port).await;
1384        assert!(stream.is_ok(), "open_tcp failed: {:?}", stream.err());
1385
1386        let _ = accept_handle.await;
1387    }
1388
1389    #[tokio::test]
1390    async fn test_node_open_tcp_unknown_peer_errors() {
1391        let ws_port = random_port().await;
1392        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1393
1394        let result = node.open_tcp("nonexistent", 8080).await;
1395        assert!(result.is_err());
1396        let err_str = result.unwrap_err().to_string();
1397        assert!(
1398            err_str.contains("not found"),
1399            "expected peer not found error, got: {err_str}"
1400        );
1401    }
1402
1403    #[tokio::test]
1404    async fn test_node_ping_resolves_peer() {
1405        let ws_port = random_port().await;
1406        let (node, event_tx, _network) = make_test_node("node-1", ws_port).await;
1407
1408        // No peer yet.
1409        let result = node.ping("peer-ping").await;
1410        assert!(result.is_err());
1411
1412        // Inject peer.
1413        let peer = make_loopback_peer("peer-ping");
1414        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
1415        tokio::time::sleep(Duration::from_millis(50)).await;
1416
1417        // Should succeed (mock returns 1ms latency).
1418        let result = node.ping("peer-ping").await;
1419        assert!(result.is_ok());
1420        assert_eq!(result.unwrap().latency, Duration::from_millis(1));
1421    }
1422
1423    #[tokio::test]
1424    async fn test_node_health() {
1425        let ws_port = random_port().await;
1426        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1427
1428        let health = node.health().await;
1429        assert!(health.healthy);
1430        assert_eq!(health.state, "running");
1431    }
1432
1433    #[tokio::test]
1434    async fn test_node_open_quic_not_implemented() {
1435        let ws_port = random_port().await;
1436        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1437
1438        let result = node.open_quic("peer").await;
1439        assert!(matches!(result, Err(NodeError::NotImplemented(_))));
1440    }
1441
1442    #[tokio::test]
1443    async fn test_node_open_udp_not_implemented() {
1444        let ws_port = random_port().await;
1445        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1446
1447        let result = node.open_udp("peer").await;
1448        assert!(matches!(result, Err(NodeError::NotImplemented(_))));
1449    }
1450
1451    #[tokio::test]
1452    async fn test_node_listen_tcp() {
1453        let ws_port = random_port().await;
1454        let (node, _event_tx, _network) = make_test_node("node-1", ws_port).await;
1455
1456        // listen_tcp(0) should bind to an ephemeral port.
1457        let listener = node.listen_tcp(0).await;
1458        assert!(listener.is_ok(), "listen_tcp failed: {:?}", listener.err());
1459    }
1460
1461    #[tokio::test]
1462    async fn test_envelope_serialize_deserialize() {
1463        let envelope = Envelope::new("chat", "message", json!({"text": "hello"})).with_timestamp();
1464
1465        let bytes = envelope.serialize().unwrap();
1466        let decoded = Envelope::deserialize(&bytes).unwrap();
1467
1468        assert_eq!(decoded.namespace, "chat");
1469        assert_eq!(decoded.msg_type, "message");
1470        assert_eq!(decoded.payload["text"], "hello");
1471        assert!(decoded.timestamp.is_some());
1472    }
1473
1474    #[tokio::test]
1475    async fn test_envelope_codec_json() {
1476        let codec = JsonCodec;
1477        let envelope = Envelope::new("ft", "offer", json!({"file": "test.bin"}));
1478
1479        let encoded = codec.encode(&envelope).unwrap();
1480        let decoded = codec.decode(&encoded).unwrap();
1481
1482        assert_eq!(decoded.namespace, "ft");
1483        assert_eq!(decoded.payload["file"], "test.bin");
1484    }
1485
1486    #[tokio::test]
1487    async fn test_envelope_unknown_fields_ignored() {
1488        let json_bytes = br#"{
1489            "namespace": "v2",
1490            "msg_type": "new",
1491            "payload": {},
1492            "future_field": "ignored"
1493        }"#;
1494
1495        let codec = JsonCodec;
1496        let decoded = codec.decode(json_bytes).unwrap();
1497        assert_eq!(decoded.namespace, "v2");
1498        assert_eq!(decoded.msg_type, "new");
1499    }
1500
1501    #[tokio::test]
1502    async fn test_node_send_and_receive_roundtrip() {
1503        // Set up two nodes that communicate via loopback WS.
1504        let port_a = random_port().await;
1505        let port_b = random_port().await;
1506
1507        let (node_a, event_tx_a, _net_a) = make_test_node("node-a", port_a).await;
1508        let (node_b, event_tx_b, _net_b) = make_test_node("node-b", port_b).await;
1509
1510        // Inject each node as a peer of the other.
1511        let peer_b = NetworkPeer {
1512            id: "node-b".to_string(),
1513            hostname: "truffle-test-node-b".to_string(),
1514            ip: "127.0.0.1".parse().unwrap(),
1515            online: true,
1516            cur_addr: Some("127.0.0.1:41641".to_string()),
1517            relay: None,
1518            os: None,
1519            last_seen: None,
1520            key_expiry: None,
1521            dns_name: None,
1522        };
1523        let peer_a = NetworkPeer {
1524            id: "node-a".to_string(),
1525            hostname: "truffle-test-node-a".to_string(),
1526            ip: "127.0.0.1".parse().unwrap(),
1527            online: true,
1528            cur_addr: Some("127.0.0.1:41641".to_string()),
1529            relay: None,
1530            os: None,
1531            last_seen: None,
1532            key_expiry: None,
1533            dns_name: None,
1534        };
1535
1536        let _ = event_tx_a.send(NetworkPeerEvent::Joined(peer_b));
1537        let _ = event_tx_b.send(NetworkPeerEvent::Joined(peer_a));
1538        tokio::time::sleep(Duration::from_millis(100)).await;
1539
1540        // Subscribe to namespace on node_b.
1541        let mut rx = node_b.subscribe("test");
1542
1543        // Send from node_a to node_b. This triggers lazy WS connect.
1544        // Note: this will connect to node_b's WS listener on port_b.
1545        let send_result = node_a.send("node-b", "test", b"hello from a").await;
1546
1547        // The send may fail in loopback mock because the WS port for node-b
1548        // is the listener port, and the mock's dial connects to 127.0.0.1:port_b.
1549        // In a real scenario with Tailscale, this works because each node
1550        // listens on its own Tailscale IP.
1551        //
1552        // For unit tests, we verify the envelope codec roundtrip works.
1553        // Full integration tests require two separate processes.
1554        if send_result.is_ok() {
1555            // If send succeeded, verify the message arrives.
1556            let msg = tokio::time::timeout(Duration::from_secs(2), rx.recv()).await;
1557            if let Ok(Ok(msg)) = msg {
1558                assert_eq!(msg.namespace, "test");
1559            }
1560        }
1561        // If send fails due to loopback WS peer-id mismatch, that's expected
1562        // in unit tests. The important thing is no panics.
1563    }
1564
1565    // ── RFC 017 Phase 2: resolve_peer_id ─────────────────────────────
1566
1567    /// Helper: inject a peer entry into the session registry and then
1568    /// stamp a synthetic RFC 017 identity on it so `resolve_peer_id`
1569    /// has something to look up. This skips the real hello exchange.
1570    async fn inject_peer_with_identity(
1571        node: &Node<MockNetworkProvider>,
1572        event_tx: &broadcast::Sender<NetworkPeerEvent>,
1573        tailscale_id: &str,
1574        device_id: &str,
1575        device_name: &str,
1576    ) {
1577        let peer = make_loopback_peer(tailscale_id);
1578        let _ = event_tx.send(NetworkPeerEvent::Joined(peer));
1579        tokio::time::sleep(Duration::from_millis(30)).await;
1580
1581        let identity = crate::session::PeerIdentity {
1582            app_id: "test".into(),
1583            device_id: device_id.into(),
1584            device_name: device_name.into(),
1585            os: "linux".into(),
1586            tailscale_id: tailscale_id.into(),
1587        };
1588        assert!(
1589            node.session
1590                .test_stamp_identity(tailscale_id, identity)
1591                .await,
1592            "peer {tailscale_id} should exist in session registry before stamping identity"
1593        );
1594    }
1595
1596    #[tokio::test]
1597    async fn test_resolve_peer_id_by_device_id() {
1598        let ws_port = random_port().await;
1599        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
1600
1601        inject_peer_with_identity(
1602            &node,
1603            &event_tx,
1604            "tailscale-abc",
1605            "01HZZZZZZZZZZZZZZZZZZZZZZZ",
1606            "Alice MacBook",
1607        )
1608        .await;
1609
1610        let resolved = node
1611            .resolve_peer_id("01HZZZZZZZZZZZZZZZZZZZZZZZ")
1612            .await
1613            .unwrap();
1614        assert_eq!(resolved, "01HZZZZZZZZZZZZZZZZZZZZZZZ");
1615    }
1616
1617    #[tokio::test]
1618    async fn test_resolve_peer_id_by_device_name() {
1619        let ws_port = random_port().await;
1620        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
1621
1622        inject_peer_with_identity(
1623            &node,
1624            &event_tx,
1625            "tailscale-abc",
1626            "01HXYZXYZXYZXYZXYZXYZXYZXY",
1627            "Bob's Mac",
1628        )
1629        .await;
1630
1631        let resolved = node.resolve_peer_id("Bob's Mac").await.unwrap();
1632        assert_eq!(resolved, "01HXYZXYZXYZXYZXYZXYZXYZXY");
1633    }
1634
1635    #[tokio::test]
1636    async fn test_resolve_peer_id_by_device_id_prefix() {
1637        let ws_port = random_port().await;
1638        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
1639
1640        inject_peer_with_identity(
1641            &node,
1642            &event_tx,
1643            "tailscale-abc",
1644            "01HXYZXYZXYZXYZXYZXYZXYZXY",
1645            "laptop",
1646        )
1647        .await;
1648
1649        // Prefix match — 4 chars is the minimum the implementation
1650        // accepts.
1651        let resolved = node.resolve_peer_id("01HX").await.unwrap();
1652        assert_eq!(resolved, "01HXYZXYZXYZXYZXYZXYZXYZXY");
1653    }
1654
1655    #[tokio::test]
1656    async fn test_resolve_peer_id_by_tailscale_id_legacy() {
1657        // Escape hatch: resolving by the Tailscale stable ID should
1658        // still work and return the device_id (or the tailscale_id as
1659        // fallback when no identity is populated).
1660        let ws_port = random_port().await;
1661        let (node, event_tx, _net) = make_test_node("node-1", ws_port).await;
1662
1663        inject_peer_with_identity(
1664            &node,
1665            &event_tx,
1666            "tailscale-legacy",
1667            "01HLEGACY0000000000000000X",
1668            "legacy box",
1669        )
1670        .await;
1671
1672        let resolved = node.resolve_peer_id("tailscale-legacy").await.unwrap();
1673        assert_eq!(resolved, "01HLEGACY0000000000000000X");
1674    }
1675
1676    #[tokio::test]
1677    async fn test_resolve_peer_id_unknown() {
1678        let ws_port = random_port().await;
1679        let (node, _event_tx, _net) = make_test_node("node-1", ws_port).await;
1680        let result = node.resolve_peer_id("nope").await;
1681        assert!(matches!(result, Err(NodeError::PeerNotFound(_))));
1682    }
1683}