Skip to main content

mcpmesh_node/
node.rs

1//! The supported embedding surface: build ([`NodeBuilder`]) and drive ([`Node`]) a full
2//! in-process mesh node. The node is its OWN mesh identity under its OWN root directory —
3//! it never touches the per-user daemon's state, socket, or singleton lock, so it coexists
4//! freely with a running `mcpmesh` daemon (and with other embedded nodes under other roots).
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use mcpmesh_local_api::client::ClientError;
9use mcpmesh_local_api::{ControlClient, connect_control_io};
10
11use crate::config::Config;
12use crate::control::serve_control_io;
13use crate::daemon::boot::{BootOverrides, BootedNode, start_node};
14use crate::paths::NodePaths;
15
16/// Everything that can refuse a [`NodeBuilder::start`]. Embedders branch on
17/// [`DataDirInUse`](StartError::DataDirInUse) (another node owns this root — one node per
18/// root, enforced by redb's exclusive database lock) and [`Config`](StartError::Config)
19/// (a malformed `config.toml` / programmatic config, worth showing to a human); everything
20/// else is opaque infrastructure failure.
21#[derive(Debug, thiserror::Error)]
22pub enum StartError {
23    #[error("config error: {0:#}")]
24    Config(#[source] anyhow::Error),
25    #[error("data dir already in use by another node: {path}")]
26    DataDirInUse { path: PathBuf },
27    #[error(transparent)]
28    Other(anyhow::Error),
29}
30
31impl StartError {
32    /// Classify a boot error by its CHAIN (the boot body stays plain-`anyhow`, so inner
33    /// `?` sites never re-wrap): a `redb` open refusal on the peer store → `DataDirInUse`
34    /// (its exact variant differs by platform/lock path, so any database-open error on the
35    /// store path counts); a `figment` error anywhere → `Config`; else `Other`.
36    pub(crate) fn classify(e: anyhow::Error, _config_path: &Path, db_path: &Path) -> StartError {
37        if e.chain()
38            .any(|c| c.downcast_ref::<redb::DatabaseError>().is_some())
39        {
40            return StartError::DataDirInUse {
41                path: db_path.to_path_buf(),
42            };
43        }
44        if e.chain()
45            .any(|c| c.downcast_ref::<figment::Error>().is_some())
46        {
47            return StartError::Config(e);
48        }
49        StartError::Other(e)
50    }
51}
52
53/// Build a [`Node`]: pick a root directory, optionally inject a [`Config`], then
54/// [`start`](NodeBuilder::start).
55pub struct NodeBuilder {
56    root: PathBuf,
57    config: Option<Config>,
58    identity_conflict: Option<std::sync::Arc<crate::diag::IdentityConflict>>,
59    overrides: BootOverrides,
60}
61
62impl NodeBuilder {
63    /// A node rooted at `root` — the ONE directory holding its whole world (`config/`,
64    /// `data/`, `state/`; layout-identical to a `mcpmesh --profile <root>` profile dir).
65    /// Missing pieces are created on start: the first start mints the device key, and an
66    /// absent `config/config.toml` boots the spec defaults.
67    pub fn new(root: impl Into<PathBuf>) -> Self {
68        Self {
69            root: root.into(),
70            config: None,
71            identity_conflict: None,
72            overrides: BootOverrides::default(),
73        }
74    }
75
76    /// Use this configuration instead of reading `<root>/config/config.toml`. The type IS
77    /// the config-file vocabulary (`docs/config.md`) — one schema, two front doors.
78    /// Config-persisting control verbs (a non-ephemeral `register_service`, pairing
79    /// grants) still write `<root>/config/config.toml`.
80    pub fn config(mut self, config: Config) -> Self {
81        self.config = Some(config);
82        self
83    }
84
85    /// Share the duplicate-identity observation with the host's `tracing` subscriber (#134).
86    ///
87    /// Two nodes booted from COPIES of one mesh root present the same endpoint id; the relay can
88    /// serve only one, and the displaced node's peers go unreachable with nothing saying why. iroh
89    /// 1.0.3 exposes that report **only as a log event**, so detecting it needs a layer in the
90    /// process's subscriber — and an embedded node cannot install one, because the subscriber is
91    /// global and your application owns it.
92    ///
93    /// Pass the SAME `Arc` you gave [`IdentityConflictLayer`](crate::diag::IdentityConflictLayer),
94    /// so that what the layer records is what this node's `status` reports:
95    ///
96    /// ```ignore
97    /// use std::sync::Arc;
98    /// use tracing_subscriber::prelude::*;
99    /// use mcpmesh_node::diag::{IdentityConflict, IdentityConflictLayer};
100    ///
101    /// let conflict = Arc::new(IdentityConflict::default());
102    /// tracing_subscriber::registry()
103    ///     .with(my_fmt_layer)
104    ///     .with(IdentityConflictLayer::new(conflict.clone()))
105    ///     .init();
106    ///
107    /// let node = NodeBuilder::new(root).identity_conflict(conflict).start().await?;
108    /// ```
109    ///
110    /// Without it, `status.self_network.identity_conflict_epoch` is always absent — which means
111    /// "not observable here", NOT "this identity is unique". Nothing else changes: the node boots,
112    /// serves, and behaves identically either way.
113    pub fn identity_conflict(
114        mut self,
115        shared: std::sync::Arc<crate::diag::IdentityConflict>,
116    ) -> Self {
117        self.identity_conflict = Some(shared);
118        self
119    }
120
121    /// Run as `key` instead of reading (or minting) `<root>/config/device.key` (#85).
122    ///
123    /// **What this is for.** The default posture is 32 raw ed25519 secret bytes at 0600, in a
124    /// directory the node owns — no passphrase, no keychain, no hardware seam. An embedder could
125    /// not fix that from outside: the file is inside the mesh root it is told not to hand-write,
126    /// and there was no way to supply a decrypted key at boot. This is that way — unwrap the key
127    /// from wherever your platform keeps secrets and hand it over.
128    ///
129    /// **When set, no DEVICE key file is read, minted, or written.** So that secret never lands on
130    /// disk. (The node still mints `<root>/config/user.key` — the pairing-identity key — which this
131    /// seam does not cover; #85 asks 2-3 are about that one and are not shipped.) The on-disk key
132    /// never exists to be
133    /// stolen — and a node whose embedder holds the key cannot silently fall back to a file one,
134    /// which would boot happily under a DIFFERENT identity and leave every peer unable to reach it.
135    ///
136    /// **Custody moves to you.** mcpmesh cannot recover this identity if you lose the key: there is
137    /// no escrow and no recovery path (#85 asks 2-3, not shipped). It is also the identity every
138    /// peer pinned at pairing, so replacing it makes this node a stranger to all of them.
139    ///
140    /// The key must stay STABLE across restarts of the same node — passing a fresh one each boot
141    /// mints a new identity every time.
142    pub fn device_key(mut self, key: mcpmesh_trust::ed25519_dalek::SigningKey) -> Self {
143        self.overrides.device_key = Some(key);
144        self
145    }
146
147    /// Boot the node: identity, stores, gates, the iroh endpoint, and every serving loop
148    /// the daemon runs. Requires a multi-thread tokio runtime (the node spawns its serving
149    /// loops onto the ambient runtime). Installs a process-default rustls `CryptoProvider`
150    /// (ring) if the host application has not installed one — idempotent, the host's wins.
151    pub async fn start(self) -> Result<Node, StartError> {
152        let paths = NodePaths::under_root(&self.root);
153        let booted = start_node(paths, self.config, self.overrides).await?;
154        // #134: adopt the host's shared observation, so the layer IN THEIR subscriber and this
155        // node's `status` read the same cell. Set after boot rather than threaded through it —
156        // the field is only ever read by the status projection, never during construction.
157        if let (Some(shared), Some(mesh)) = (self.identity_conflict, booted.state.mesh()) {
158            mesh.adopt_identity_conflict(shared);
159        }
160        Ok(Node { booted })
161    }
162}
163
164/// A running in-process node. Dropping it does NOT stop serving — call
165/// [`shutdown`](Node::shutdown).
166pub struct Node {
167    booted: BootedNode,
168}
169
170/// Hand-rolled: the boot internals are not `Debug`; the identity is the one diagnostic
171/// a `{:?}` needs.
172impl std::fmt::Debug for Node {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        f.debug_struct("Node")
175            .field("endpoint_id", &self.endpoint_id())
176            .finish_non_exhaustive()
177    }
178}
179
180impl Node {
181    /// A control connection to THIS node: the same typed `mcpmesh-local/1` client a
182    /// sidecar consumer gets from `connect_control_default`, over an in-memory pipe.
183    /// Cheap; open one per concurrent conversation — a session/stream upgrade
184    /// (`open_session`, `subscribe`) consumes its connection, exactly as on the socket.
185    pub async fn control(&self) -> Result<ControlClient, ClientError> {
186        let (client_io, server_io) = tokio::io::duplex(64 * 1024);
187        let (server_read, server_write) = tokio::io::split(server_io);
188        let state = self.booted.state.clone();
189        let track_state = state.clone();
190        let handle = tokio::spawn(async move {
191            if let Err(e) = serve_control_io(server_read, server_write, state).await {
192                tracing::debug!(%e, "in-process control connection ended");
193            }
194        });
195        // Tracked like a socket connection's serving task (`serve_control`'s per-connection
196        // spawn): without this, an attached `subscribe()` stream never notices `shutdown` (see
197        // `Node::shutdown`) and outlives the node, holding its `Arc<DaemonState>`/mesh/redb lock
198        // open.
199        track_state.track_control_task(handle);
200        let (client_read, client_write) = tokio::io::split(client_io);
201        connect_control_io(client_read, client_write).await
202    }
203
204    /// This node's mesh identity — what a peer's invite/pair flow binds to.
205    pub fn endpoint_id(&self) -> iroh::EndpointId {
206        self.mesh().endpoint.id()
207    }
208
209    /// Add an address-lookup service — a peer RESOLVER — to this node's endpoint (#68).
210    ///
211    /// **What this is for.** Peer resolution otherwise depends on external infrastructure: the
212    /// pkarr publisher/resolver a relay provides, or a dialable address someone already handed you
213    /// in an invite. Two machines on the same LAN with no internet cannot find each other, even
214    /// though the network path between them is fine — and offline-capable operation is the claim
215    /// that most clearly separates a P2P library from a self-hosted server, which is still a star
216    /// topology with a single point of failure.
217    ///
218    /// iroh 1.0.3 ships **no** mDNS or local-swarm lookup (that existed in 0.x and is not present
219    /// here), so mcpmesh cannot simply switch one on. What it can do is stop the resolver set being
220    /// closed: `iroh::address_lookup::AddressLookup` is a public, pluggable trait, so an mDNS
221    /// implementation — or any other — can live outside this crate and be handed in here.
222    ///
223    /// ```no_run
224    /// use mcpmesh_node::iroh::address_lookup::{AddressLookup, EndpointData};
225    ///
226    /// #[derive(Debug)]
227    /// struct MyMdns;
228    ///
229    /// impl AddressLookup for MyMdns {
230    ///     fn publish(&self, _data: &EndpointData) {
231    ///         // announce this endpoint's addresses on the LAN
232    ///     }
233    ///     // `resolve` defaults to `None`; implement it to answer queries.
234    /// }
235    ///
236    /// # fn f(node: &mcpmesh_node::Node) -> anyhow::Result<()> {
237    /// node.add_address_lookup(MyMdns)?;
238    /// # Ok(()) }
239    /// ```
240    ///
241    /// # It PUBLISHES too — read this before you add one
242    ///
243    /// `AddressLookup` is not only a resolver. iroh hands the service this node's own
244    /// [`EndpointData`] — **its direct IP addresses, LAN and public, v4 and v6** — synchronously
245    /// inside this call, and again on every address change afterwards. That happens whether or not
246    /// you implement `publish`, because the default is a no-op *you* control: a lookup that grows
247    /// a `publish` later, or a dependency's lookup you pass through, starts announcing them.
248    ///
249    /// This is outside `[network].discovery_urls`' scope. An operator who pinned that precisely so
250    /// publication never leaves their infrastructure gets no say over a lookup added here — you do.
251    /// Treat what you hand this seam as something you would let see the machine's addresses.
252    ///
253    /// The node's `[network]` address filter still applies: on a relay-only posture the service
254    /// receives relay-only data, because the filter runs centrally before any service sees it.
255    ///
256    /// # And its answers are attacker-controlled input
257    ///
258    /// A resolver on an untrusted LAN is fed by whoever is on that LAN. They cannot impersonate a
259    /// peer (see below), but they can steer a dial: make this node send QUIC handshakes to an
260    /// address of their choosing, revealing that it is looking for peer X, or hand back a relay URL
261    /// that routes metadata through them. Validate what your implementation accepts.
262    ///
263    /// # What it cannot do
264    ///
265    /// **Additive, never authoritative.** Lookups are consulted alongside whatever this node
266    /// already has — `add` appends, and every service is queried — so adding one cannot remove
267    /// relay-based resolution.
268    ///
269    /// **Resolution AUTHORIZES nothing.** A misdirected dial cannot complete against the wrong
270    /// peer: iroh's TLS verifier rejects any server whose key is not the `EndpointId` the dial
271    /// named, mcpmesh's pairing dial re-checks `conn.remote_id()` against the invite before
272    /// revealing the secret, and a stored dial hint whose embedded id disagrees is discarded. A
273    /// peer found this way then faces the trust gate exactly as one found any other way —
274    /// resolution answers "where", never "who may".
275    ///
276    /// **Takes effect for dials from now on.** A dial already in flight is not re-resolved (the
277    /// service list is snapshotted when resolution is triggered).
278    ///
279    /// # Errors and panics
280    ///
281    /// The `Result` is effectively infallible for an embedder: it errors only on a closed endpoint,
282    /// and `shutdown` consumes the `Node`, so you cannot hold one to call this on. It is kept as a
283    /// `Result` rather than swallowed because the underlying accessor can fail.
284    ///
285    /// **It can panic**, though: your `publish` is called synchronously inside this call, so a
286    /// panic in it propagates out of here.
287    pub fn add_address_lookup(
288        &self,
289        lookup: impl iroh::address_lookup::AddressLookup + 'static,
290    ) -> anyhow::Result<()> {
291        self.mesh()
292            .endpoint
293            .address_lookup()
294            .map_err(|e| anyhow::anyhow!("this node's endpoint is closed: {e}"))?
295            .add(lookup);
296        Ok(())
297    }
298
299    /// Serve a custom protocol on `alpn`, through this node's existing trust gate (#67).
300    ///
301    /// **What this is for.** mcpmesh has already built the hard parts of a P2P application
302    /// platform — identity, pairing, a trust gate, relay fallback, discovery, rate limiting, a
303    /// connection registry — and exposes one protocol shape on top: request/response MCP over
304    /// bi-streams. Anything that does not fit (realtime media wanting datagrams, efficient bulk
305    /// transfer, an app-level overlay) was out of reach however well the identity layer suited it.
306    /// The alternative was a SECOND endpoint with a second identity, which discards the gate, the
307    /// pairing relationship and the relay config — and makes your users pair twice.
308    ///
309    /// **Your handler runs behind the same gate as every built-in protocol.** An unauthorized or
310    /// revoked peer is closed before `accept` is called; the connection is entered in the registry,
311    /// so revoking that peer SEVERS it mid-protocol rather than waiting for it to end. You get the
312    /// authenticated `EndpointId` from `connection.remote_id()`, and it is the same identity
313    /// `_meta["mcpmesh/peer"]` names on the MCP path.
314    ///
315    /// ```no_run
316    /// use std::sync::Arc;
317    /// use mcpmesh_node::iroh;
318    ///
319    /// #[derive(Debug)]
320    /// struct MyProto;
321    ///
322    /// impl iroh::protocol::ProtocolHandler for MyProto {
323    ///     async fn accept(
324    ///         &self,
325    ///         conn: iroh::endpoint::Connection,
326    ///     ) -> Result<(), iroh::protocol::AcceptError> {
327    ///         let _peer = conn.remote_id(); // the AUTHENTICATED caller
328    ///         Ok(())
329    ///     }
330    /// }
331    ///
332    /// # fn f(node: &mcpmesh_node::Node) -> anyhow::Result<()> {
333    /// node.accept_protocol(b"app/myproto/1", Arc::new(MyProto))?;
334    /// # Ok(()) }
335    /// ```
336    ///
337    /// **The `mcpmesh/` prefix is reserved** and registering under it is an error — the accept loop
338    /// dispatches its own protocols by exact ALPN before consulting this registry, so a handler
339    /// there would be silently dead, and one on a name mcpmesh adds later would flip from working
340    /// to dead on an upgrade. `app/…` is the suggested convention.
341    ///
342    /// **Takes effect for connections negotiated from now on.** ALPN is chosen at handshake, so a
343    /// peer already connected cannot use the new protocol. Register during startup, before you
344    /// announce the node as ready, unless that is genuinely what you want.
345    ///
346    /// Registering the same `alpn` twice replaces the handler; connections already running under
347    /// the old one continue on it.
348    pub fn accept_protocol(
349        &self,
350        alpn: &[u8],
351        handler: Arc<dyn iroh::protocol::DynProtocolHandler>,
352    ) -> anyhow::Result<()> {
353        self.mesh().register_app_protocol(alpn, handler)
354    }
355
356    /// This node's currently-dialable address (#67) — its endpoint id plus whatever direct
357    /// addresses and relay it has, exactly what a pairing invite embeds.
358    ///
359    /// For handing an address to a peer OUT-OF-BAND, when your application has its own channel for
360    /// that and does not want a pairing invite. Carries transport vocabulary by nature, which is
361    /// why it is a typed accessor rather than anything on the control surface.
362    ///
363    /// A snapshot: addresses change as the network does, and immediately after boot it may hold
364    /// only local ones. It authorizes nothing — a peer dialling this still faces the trust gate.
365    pub fn endpoint_addr(&self) -> iroh::EndpointAddr {
366        self.mesh().endpoint.addr()
367    }
368
369    /// Dial `peer` on a custom `alpn` — the client half of [`accept_protocol`](Self::accept_protocol)
370    /// (#67).
371    ///
372    /// `peer` may be a paired nickname, a `b64u:` user_id, an `eid:` device principal, or — in
373    /// roster mode — a rostered user_id. That resolution, plus the stored dial-address hint and
374    /// this node's relay configuration, is most of what makes this worth using over a raw endpoint:
375    /// an embedder holding only "alice" has no way to turn that into an address, and one that stood
376    /// up its own endpoint would not have the pairing that produced it.
377    ///
378    /// For a person with several devices the candidates are tried IN ORDER — roster candidates
379    /// first, primary before mirror — and the first that connects wins. That is weaker than
380    /// `open_session`'s staggered race, which this deliberately does not reproduce: racing means
381    /// opening connections you then abandon, and an embedder's protocol may not be safe to
382    /// half-open. Each attempt is bounded by the same `DIAL_TIMEOUT` the service dial uses, so an
383    /// unreachable first device costs that timeout rather than hanging.
384    ///
385    /// ```no_run
386    /// # async fn f(node: &mcpmesh_node::Node) -> anyhow::Result<()> {
387    /// let conn = node.connect_protocol("alice", b"app/myproto/1").await?;
388    /// let (send, recv) = conn.open_bi().await?;
389    /// # Ok(()) }
390    /// ```
391    ///
392    ///
393    /// **This does not authorize anything.** It dials; the REMOTE side's gate decides whether to
394    /// admit you, and will close the connection if you are not paired with them. Symmetrically,
395    /// your own handler is protected by your gate — see `accept_protocol`.
396    ///
397    /// Errors when `peer` resolves to nobody, or when the dial fails. A peer that is simply offline
398    /// is a dial failure, not a distinct condition.
399    pub async fn connect_protocol(
400        &self,
401        peer: &str,
402        alpn: &[u8],
403    ) -> anyhow::Result<iroh::endpoint::Connection> {
404        let mesh = self.mesh();
405        let candidates = crate::daemon::dial::protocol_candidates(mesh, peer).await?;
406        anyhow::ensure!(
407            !candidates.is_empty(),
408            "no peer '{peer}' — 'status' lists your peers and roster members"
409        );
410        let mut last: Option<anyhow::Error> = None;
411        for endpoint_id in candidates {
412            let Ok(id) = iroh::EndpointId::from_bytes(&endpoint_id) else {
413                continue; // a corrupt stored id is skipped, not fatal — another device may work
414            };
415            // The stored last-addr hint, attached exactly as the service dial attaches it. It is
416            // what lets a hermetic/localhost mesh with no discovery reach a peer it has never
417            // dialled, and a hint recorded for a DIFFERENT id is discarded rather than dialled.
418            let store = mesh.store.clone();
419            let entry = crate::util::blocking("join connect_protocol store read", move || {
420                store.resolve(&endpoint_id)
421            })
422            .await??;
423            let addr = crate::daemon::dial::stored_dial_addr(
424                entry.and_then(|e| e.last_addr).as_deref(),
425                id,
426            );
427            // Bounded, like every other dial in the codebase: an unreachable candidate must cost a
428            // timeout, not the caller's future.
429            match tokio::time::timeout(
430                crate::daemon::dial::DIAL_TIMEOUT,
431                mesh.endpoint.connect(addr, alpn),
432            )
433            .await
434            {
435                Ok(Ok(conn)) => return Ok(conn),
436                Ok(Err(e)) => last = Some(anyhow::Error::new(e)),
437                Err(_) => last = Some(anyhow::anyhow!("dial timed out")),
438            }
439        }
440        Err(match last {
441            Some(e) => e.context(format!("dial '{peer}' on a custom protocol")),
442            None => anyhow::anyhow!("dial '{peer}' on a custom protocol: no usable candidate"),
443        })
444    }
445
446    /// Sign an application payload with this node's DEVICE key, under the embedder's own
447    /// `domain` (#59).
448    ///
449    /// **What this is for.** mcpmesh authenticates the transport: inside a session,
450    /// `_meta["mcpmesh/peer"]` says who is calling. That answers nothing about a payload which
451    /// outlives its connection — anything store-and-forward (offline delivery, a relay, a mailbox,
452    /// an app-level overlay) handles bytes whose author is not the peer that delivered them, and
453    /// the transport authenticated the FORWARDER. This attributes the ORIGIN, against the same
454    /// identity the transport already proves, so an embedder needs no second key, no second
455    /// backup/revocation story, and no binding protocol tying the two together.
456    ///
457    /// **`domain` is yours; pick one per statement KIND** (`b"chat/message/1"`,
458    /// `b"mailbox/receipt/1"`). A signature is only as narrow as its domain, and sharing one across
459    /// two shapes lets a value from either be read as the other. mcpmesh's own domains are out of
460    /// reach whatever you choose — see [`mcpmesh_trust::app`] for why that is a property of the
461    /// preimage rather than of your discipline.
462    ///
463    /// Verify with [`verify_app`](Self::verify_app), which needs no node.
464    ///
465    /// **Not a control verb, deliberately.** Signing over the JSON-RPC socket would put the device
466    /// key's authority behind an IPC surface shared by every consumer of that socket. This is an
467    /// in-process seam for the embedder that owns the node.
468    pub fn sign_app(&self, domain: &[u8], msg: &[u8]) -> [u8; 64] {
469        // Derived from the endpoint rather than held as a field: the signing key is then the one
470        // whose public half IS `endpoint_id()`, by construction. A separately-stored copy could be
471        // absent or stale, and a signing API that fails open or signs under the wrong identity is
472        // worse than none.
473        //
474        // Hardening note, the same residual `DeviceKey::secret_bytes` documents: `to_bytes()` hands
475        // back a plain `[u8; 32]` that is not zeroized, and the `SigningKey` built from it is
476        // scrubbed on drop but the array is not. Per call rather than once — accepted, because the
477        // alternative is caching the key material for the node's whole life, which is a larger
478        // residual, not a smaller one.
479        let signing = mcpmesh_trust::ed25519_dalek::SigningKey::from_bytes(
480            &self.mesh().endpoint.secret_key().to_bytes(),
481        );
482        mcpmesh_trust::sign_app(&signing, domain, msg)
483    }
484
485    /// Verify an application payload signed by `endpoint_id` under `domain` (#59).
486    ///
487    /// An associated function: verification needs no node, which is the point — a consumer checking
488    /// a relayed payload has the peer's `EndpointId` and nothing else.
489    ///
490    /// Returns `false` for a bad signature, a mismatched domain/message, or malformed bytes. It
491    /// never panics: every input is attacker-supplied by construction.
492    ///
493    /// It answers "which device produced these bytes" and nothing else. Whether that device was
494    /// ENTITLED to make the statement is the embedder's authorization question, answered from the
495    /// embedder's own state.
496    pub fn verify_app(
497        endpoint_id: &iroh::EndpointId,
498        domain: &[u8],
499        msg: &[u8],
500        sig: &[u8; 64],
501    ) -> bool {
502        mcpmesh_trust::verify_app(endpoint_id.as_bytes(), domain, msg, sig)
503    }
504
505    /// Resolves once shutdown has been requested — by [`shutdown`](Node::shutdown) from
506    /// another handle, or by the control protocol's `shutdown` verb (e.g. an operator
507    /// driving this node's control connection).
508    pub async fn wait(&self) {
509        self.booted.state.shutdown_requested().await;
510    }
511
512    /// Stop serving: raise the shutdown signal, stop the accept/poll/background loops and
513    /// every live control connection (subscription streams end immediately; in-flight control
514    /// requests get a dropped connection — acceptable, shutdown means shutdown), and close the
515    /// endpoint (a graceful QUIC close — live sessions end cleanly).
516    pub async fn shutdown(self) {
517        // One teardown path, shared with the boot tests (#105) so neither can drift from the other.
518        crate::daemon::boot::shutdown_booted(self.booted).await;
519    }
520
521    fn mesh(&self) -> &Arc<crate::daemon::MeshState> {
522        self.booted
523            .state
524            .mesh()
525            .expect("a started Node always owns a mesh")
526    }
527
528    /// Present this device's binding to a peer that already pairs with this person (#85 ask 3).
529    ///
530    /// The recovery path's second half. `identity import` (0.42.0) restores the `b64u:` a person's
531    /// peers pinned; this is what gets the machine holding it ADMITTED, without the in-person SAS
532    /// ceremony with everyone they ever paired with that #85 filed against.
533    ///
534    /// `offer` is a `mcpmesh-attest:` line the ADMITTING node mints (`attest_offer`) — a restored
535    /// device holds no rows, so it has no other way to find anyone.
536    ///
537    /// The peer admits this device only if it already holds a row for this person's `user_id`, has
538    /// `[identity].admit_attested_devices` on, and does not have this endpoint revoked. It cannot
539    /// admit a stranger.
540    pub async fn attest_to(&self, offer: &str) -> anyhow::Result<mcpmesh_local_api::PairResult> {
541        let mesh = self.mesh();
542        crate::pairing::rendezvous::attest_to(
543            mesh.endpoint.clone(),
544            offer.to_string(),
545            mesh.store.clone(),
546            mesh.self_binding(),
547            None,
548        )
549        .await
550    }
551}