Skip to main content

truffle_core/
node.rs

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