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) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-05-19 16:58:14 -04:00
co-authored by Dawn
parent 1dbc89cc11
commit 000c78c203
2 changed files with 110 additions and 0 deletions
+2
View File
@@ -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};
+108
View File
@@ -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<String, Nip11Error> {
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<Option<String>, 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(_))
));
}
}