From 000c78c203c40262cd3f33843a3fc5c21dc78efd Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 16:58:14 -0400 Subject: [PATCH] mesh-llm: B1 NIP-11 fetcher for iroh_relay_url desktop/src-tauri/src/mesh_llm/nip11.rs: fetch_iroh_relay_url(ws_url) converts ws:// -> http://, GETs / with Accept: application/nostr+json, extracts the iroh_relay_url field from the NIP-11 JSON. Returns Ok(None) on unreachable relays / malformed responses / missing field so mesh-LLM silently disables itself rather than producing a deploy mystery; only returns Err for un-fixable caller mistakes (non-ws URL). Mirrors the probe_relay_supports_nip43 helper already in commands/pairing.rs; deliberately doesn't share code since this is a different decode shape with different graceful-failure semantics. desktop mesh_llm tests: 9 -> 11 (+2 nip11 helper). Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- desktop/src-tauri/src/mesh_llm/mod.rs | 2 + desktop/src-tauri/src/mesh_llm/nip11.rs | 108 ++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 desktop/src-tauri/src/mesh_llm/nip11.rs diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index e6a4491ed..1790bb5cd 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -16,9 +16,11 @@ //! (VRAM/RAM/concurrency caps, models). pub mod endpoint; +pub mod nip11; pub mod nip98; pub mod offer; pub use endpoint::{load_or_create_endpoint_key, EndpointKeyError}; +pub use nip11::{fetch_iroh_relay_url, Nip11Error}; pub use nip98::{build_nip98_bearer, Nip98BearerError}; pub use offer::{ComputeSharingPrefs, OfferPrefsError}; diff --git a/desktop/src-tauri/src/mesh_llm/nip11.rs b/desktop/src-tauri/src/mesh_llm/nip11.rs new file mode 100644 index 000000000..feaa6835e --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/nip11.rs @@ -0,0 +1,108 @@ +//! NIP-11 fetch helper specialised for mesh-LLM bootstrapping. +//! +//! At session start the desktop queries the connected Sprout relay's +//! NIP-11 document and reads the `iroh_relay_url` field +//! ([`sprout_relay::nip11::RelayInfo::iroh_relay_url`]). When set, this is +//! the URL the desktop signs NIP-98 bearer tokens against and dials iroh +//! connections through. +//! +//! Unreachable relays / malformed responses / missing field all return +//! `Ok(None)` — the desktop falls back to "mesh-LLM disabled for this +//! relay", same UX as a relay that simply doesn't host iroh. + +use std::time::Duration; + +const NIP11_TIMEOUT: Duration = Duration::from_secs(5); + +/// Errors that bubble up only for *infrastructural* failures the caller +/// should surface (e.g. a malformed user-provided URL). Network and +/// "field-not-present" cases collapse to `Ok(None)` because mesh-LLM is +/// optional. +#[derive(Debug, thiserror::Error)] +pub enum Nip11Error { + /// The provided relay URL doesn't start with `ws://` or `wss://`. + #[error("not a ws:// or wss:// URL: {0}")] + NotWebsocketUrl(String), + /// The `reqwest` client failed to construct. + #[error("http client init: {0}")] + ClientBuild(String), +} + +/// Convert a Nostr `ws(s)://` relay URL to its `http(s)://` NIP-11 base. +fn to_http_base(relay_url: &str) -> Result { + if let Some(rest) = relay_url.strip_prefix("wss://") { + Ok(format!("https://{rest}")) + } else if let Some(rest) = relay_url.strip_prefix("ws://") { + Ok(format!("http://{rest}")) + } else { + Err(Nip11Error::NotWebsocketUrl(relay_url.to_string())) + } +} + +/// Fetch the relay's NIP-11 document and extract `iroh_relay_url`. +/// +/// Returns: +/// - `Ok(Some(url))` — the relay advertises an iroh-relay endpoint. +/// - `Ok(None)` — relay is reachable but does not advertise `iroh_relay_url`, +/// OR the relay is unreachable / returned a malformed doc. Mesh-LLM is +/// silently disabled in both cases (same UX). +/// - `Err(_)` — the *caller* gave us something un-fixable (e.g. a non-ws +/// URL). Should be surfaced as a developer error, not a runtime fallback. +pub async fn fetch_iroh_relay_url(relay_url: &str) -> Result, Nip11Error> { + let http_url = to_http_base(relay_url)?; + + let client = reqwest::Client::builder() + .timeout(NIP11_TIMEOUT) + .build() + .map_err(|e| Nip11Error::ClientBuild(e.to_string()))?; + + let resp = match client + .get(&http_url) + .header("Accept", "application/nostr+json") + .send() + .await + { + Ok(r) => r, + Err(_) => return Ok(None), // unreachable — graceful no-mesh + }; + + let json: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(_) => return Ok(None), // malformed — graceful no-mesh + }; + + Ok(json + .get("iroh_relay_url") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn http_base_strips_ws_prefix() { + assert_eq!( + to_http_base("wss://relay.example.com/iroh").unwrap(), + "https://relay.example.com/iroh", + ); + assert_eq!( + to_http_base("ws://localhost:3000").unwrap(), + "http://localhost:3000", + ); + } + + #[test] + fn http_base_rejects_non_ws() { + assert!(matches!( + to_http_base("https://relay.example.com"), + Err(Nip11Error::NotWebsocketUrl(_)) + )); + assert!(matches!( + to_http_base("relay.example.com"), + Err(Nip11Error::NotWebsocketUrl(_)) + )); + } +}