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