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