From f4ca099b1c2d9d9cc69431bec5037b4acc3838a8 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 15:33:13 -0400 Subject: [PATCH 01/14] mesh-llm plan v6.1: Steps 1, 2, 4, 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Step 1: KIND_MESH_LLM_DISCOVERY = 31990 (parameterized replaceable, global-only, MessagesWrite scope) — relay members announce compute offers through the same NIP-43-gated fan-out path as messages. - Step 2: extract transport-neutral check_relay_membership returning MembershipDecision. HTTP enforce_relay_membership becomes a thin wrapper that maps Denied -> 403 JSON. Same behavior for all 6 existing HTTP callers; non-HTTP gates (iroh-relay AccessConfig) can call the core directly without a StatusCode in their return type. - Step 4: NIP-11 iroh_relay_url field, fed from new SPROUT_IROH_RELAY_PUBLIC_URL config. Absent unless configured (older clients unaffected). This is what mesh-llm sidecars read to wire their iroh endpoints to Sprout's own relay -- no out-of-band config required. - Step 5: sprout-auth::nip98_canonical_url helper. Single source of truth for the NIP-98 'u'-tag value, used by both signer and verifier. Suffix- aware path join (preserves /iroh prefix when joining /relay), localhost/ IPv6 loopback collapse, query+fragment stripping. Round-trip test signs with the helper and verifies through verify_nip98_event to prevent drift. sprout-core: 165 tests pass sprout-auth: 36 -> 48 tests pass (+12 nip98_url) sprout-relay: 190 -> 195 tests pass (+3 mesh-llm, +2 iroh_relay_url) workspace clippy -D warnings: clean workspace cargo fmt --check: clean Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- crates/sprout-auth/src/lib.rs | 3 + crates/sprout-auth/src/nip98_url.rs | 234 +++++++++++++++++++++ crates/sprout-core/src/kind.rs | 12 ++ crates/sprout-relay/src/api/mod.rs | 106 +++++++--- crates/sprout-relay/src/config.rs | 22 ++ crates/sprout-relay/src/handlers/ingest.rs | 37 +++- crates/sprout-relay/src/nip11.rs | 70 +++++- crates/sprout-relay/src/router.rs | 14 +- 8 files changed, 451 insertions(+), 47 deletions(-) create mode 100644 crates/sprout-auth/src/nip98_url.rs diff --git a/crates/sprout-auth/src/lib.rs b/crates/sprout-auth/src/lib.rs index 30ff51ba7..e9ec34f2d 100644 --- a/crates/sprout-auth/src/lib.rs +++ b/crates/sprout-auth/src/lib.rs @@ -23,6 +23,8 @@ pub mod error; pub mod nip42; /// NIP-98 HTTP Auth verification (kind:27235). pub mod nip98; +/// Canonical URL builder for NIP-98 `u`-tag signing/verification. +pub mod nip98_url; /// Per-connection rate limiting. pub mod rate_limit; /// OAuth scope parsing and enforcement. @@ -32,6 +34,7 @@ pub use access::{check_read_access, check_write_access, require_scope, ChannelAc pub use error::AuthError; pub use nip42::{generate_challenge, verify_nip42_event}; pub use nip98::verify_nip98_event; +pub use nip98_url::{nip98_canonical_url, nip98_canonicalize}; pub use rate_limit::{ ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter, }; diff --git a/crates/sprout-auth/src/nip98_url.rs b/crates/sprout-auth/src/nip98_url.rs new file mode 100644 index 000000000..dd0d48c3b --- /dev/null +++ b/crates/sprout-auth/src/nip98_url.rs @@ -0,0 +1,234 @@ +//! Canonical URL builder for NIP-98 `u`-tag signing and verification. +//! +//! Both the **signer** (e.g. desktop iroh-relay bearer-token producer) and the +//! **verifier** (e.g. the iroh-relay `AccessConfig::Restricted` callback) must +//! compute the same canonical URL string. Any drift between the two sides +//! produces a `URL mismatch` rejection on every single connection, which is +//! the canonical NIP-98 deploy bug. +//! +//! This module centralises the canonicalisation rules so they cannot drift: +//! +//! 1. Scheme/host are lowercased by [`url::Url`]. +//! 2. `localhost` and `::1` collapse to `127.0.0.1` (so dev signers that bind +//! `[::]` and verifiers that see `127.0.0.1` agree). +//! 3. Query and fragment are stripped (NIP-98 signs the URL "path identity", +//! not transient query parameters). +//! 4. Trailing slashes on the path are collapsed to a single canonical form. +//! 5. Path-prefix joins are suffix-aware: `base=https://h/iroh` joined with +//! `path=/relay` yields `https://h/iroh/relay`, NOT `https://h/relay` +//! (which is what [`url::Url::join`] would produce). +//! +//! The single canonical-string format is consumed by both +//! [`crate::verify_nip98_event`] and external signers; the round-trip test +//! pins them together. + +use url::Url; + +/// Build the canonical NIP-98 `u`-tag value for a request, joining a base URL +/// with a (potentially absolute) path while preserving any base path prefix. +/// +/// Returns `None` if `base` is not a parseable URL. +/// +/// # Examples +/// +/// Plain join: +/// +/// ``` +/// use sprout_auth::nip98_canonical_url; +/// assert_eq!( +/// nip98_canonical_url("https://relay.example.com", "/iroh/relay").as_deref(), +/// Some("https://relay.example.com/iroh/relay"), +/// ); +/// ``` +/// +/// Path-prefix preservation (the typical reverse-proxy case): +/// +/// ``` +/// use sprout_auth::nip98_canonical_url; +/// assert_eq!( +/// nip98_canonical_url("https://relay.example.com/iroh", "/relay").as_deref(), +/// Some("https://relay.example.com/iroh/relay"), +/// ); +/// ``` +pub fn nip98_canonical_url(base: &str, path: &str) -> Option { + let mut parsed = Url::parse(base).ok()?; + + // localhost collapse — `Url::host_str()` returns the canonicalised host + // string, which for IPv6 omits brackets (`"::1"`) but for v4-mapped or + // alternate IPv6 spellings may yield different forms. We compare against + // the parsed `Host` enum where available to catch all loopback shapes. + let is_loopback = match parsed.host() { + Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv6(addr)) => addr.is_loopback(), + Some(url::Host::Ipv4(addr)) => addr.is_loopback(), + None => false, + }; + if is_loopback { + parsed.set_host(Some("127.0.0.1")).ok()?; + } + + // Suffix-join: append `path` to the base's path, keeping the prefix. + let base_path = parsed.path().trim_end_matches('/').to_string(); + let suffix = path.trim_start_matches('/'); + let joined = if suffix.is_empty() { + base_path + } else if base_path.is_empty() { + format!("/{suffix}") + } else { + format!("{base_path}/{suffix}") + }; + let collapsed = joined.trim_end_matches('/').to_string(); + let final_path = if collapsed.is_empty() { + "/".to_string() + } else { + collapsed + }; + parsed.set_path(&final_path); + + // Strip query + fragment — NIP-98 signs path identity, not transient args. + parsed.set_query(None); + parsed.set_fragment(None); + + Some(parsed.to_string()) +} + +/// Build a canonical NIP-98 `u`-tag value from a fully-qualified URL. +/// +/// Useful on the verifier side, where the caller has already reconstructed +/// the full request URL (e.g. from `X-Forwarded-Proto` + `Host` + path) and +/// only needs canonicalisation. +pub fn nip98_canonicalize(url: &str) -> Option { + nip98_canonical_url(url, "") +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + #[test] + fn plain_join_no_base_path() { + assert_eq!( + nip98_canonical_url("https://relay.example.com", "/iroh/relay").as_deref(), + Some("https://relay.example.com/iroh/relay"), + ); + } + + #[test] + fn suffix_join_preserves_base_path_prefix() { + // The classic Plan v4 deploy bug: signer reads `iroh_relay_url` from + // NIP-11 as `https://host/iroh` and joins path `/relay`. `Url::join` + // would discard the `/iroh` prefix; the canonical helper must keep it. + assert_eq!( + nip98_canonical_url("https://relay.example.com/iroh", "/relay").as_deref(), + Some("https://relay.example.com/iroh/relay"), + ); + } + + #[test] + fn trailing_slash_on_base_collapsed() { + assert_eq!( + nip98_canonical_url("https://relay.example.com/iroh/", "/relay").as_deref(), + Some("https://relay.example.com/iroh/relay"), + ); + } + + #[test] + fn trailing_slash_on_path_collapsed() { + assert_eq!( + nip98_canonical_url("https://relay.example.com/iroh", "/relay/").as_deref(), + Some("https://relay.example.com/iroh/relay"), + ); + } + + #[test] + fn localhost_collapses_to_loopback() { + assert_eq!( + nip98_canonical_url("http://localhost:3000", "/iroh/relay").as_deref(), + Some("http://127.0.0.1:3000/iroh/relay"), + ); + } + + #[test] + fn ipv6_loopback_collapses_to_loopback() { + assert_eq!( + nip98_canonical_url("http://[::1]:3000", "/iroh/relay").as_deref(), + Some("http://127.0.0.1:3000/iroh/relay"), + ); + } + + #[test] + fn explicit_port_is_preserved() { + assert_eq!( + nip98_canonical_url("https://relay.example.com:8443", "/iroh/relay").as_deref(), + Some("https://relay.example.com:8443/iroh/relay"), + ); + } + + #[test] + fn query_and_fragment_stripped() { + assert_eq!( + nip98_canonical_url("https://relay.example.com/iroh?foo=bar#x", "/relay").as_deref(), + Some("https://relay.example.com/iroh/relay"), + ); + } + + #[test] + fn scheme_and_host_lowercased() { + assert_eq!( + nip98_canonical_url("HTTPS://Relay.Example.COM", "/iroh/relay").as_deref(), + Some("https://relay.example.com/iroh/relay"), + ); + } + + #[test] + fn empty_path_yields_root() { + assert_eq!( + nip98_canonical_url("https://relay.example.com", "").as_deref(), + Some("https://relay.example.com/"), + ); + } + + #[test] + fn invalid_base_returns_none() { + assert!(nip98_canonical_url("not a url", "/iroh/relay").is_none()); + } + + #[test] + fn canonicalize_full_url_round_trip() { + let canonical = nip98_canonicalize("https://relay.example.com:8443/iroh/relay?x=1#y") + .expect("canonicalize must succeed"); + assert_eq!(canonical, "https://relay.example.com:8443/iroh/relay"); + } + + /// **Critical round-trip test:** signs an event using the canonical helper, + /// then verifies it through [`crate::verify_nip98_event`]. If they drift, + /// every connection in production deny-loops. + #[test] + fn round_trip_with_verify_nip98_event() { + let keys = Keys::generate(); + let canonical = nip98_canonical_url("https://relay.example.com/iroh", "/relay").unwrap(); + + let event = EventBuilder::new( + Kind::HttpAuth, + "", + vec![ + Tag::parse(&["u", &canonical]).unwrap(), + Tag::parse(&["method", "GET"]).unwrap(), + ], + ) + .custom_created_at(Timestamp::now()) + .sign_with_keys(&keys) + .unwrap(); + let json = serde_json::to_string(&event).unwrap(); + + // Verifier reconstructs the exact same canonical URL — different inputs, + // same string. + let verifier_url = + nip98_canonical_url("https://relay.example.com/iroh/", "/relay/").unwrap(); + + let result = crate::verify_nip98_event(&json, &verifier_url, "GET", None); + assert!(result.is_ok(), "round-trip verify failed: {:?}", result); + assert_eq!(result.unwrap(), keys.public_key()); + } +} diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs index 3ac6bcec1..1c8db284b 100644 --- a/crates/sprout-core/src/kind.rs +++ b/crates/sprout-core/src/kind.rs @@ -111,6 +111,16 @@ pub const KIND_NIP29_GROUP_ROLES: u32 = 39003; /// Workflow definition (parameterized replaceable, d=workflow_uuid). pub const KIND_WORKFLOW_DEF: u32 = 30620; +/// Mesh-LLM compute-offer discovery announcement (parameterized replaceable, +/// `d` = mesh node identifier). +/// +/// Published by Sprout members willing to share their local LLM/compute with +/// the rest of the relay. The event content carries the offer envelope +/// (model id, max VRAM/RAM, iroh endpoint id), and is addressable so a member +/// can replace their own offer atomically. Stored globally (`channel_id = NULL`) +/// — relay membership, not channel scope, is the audience. +pub const KIND_MESH_LLM_DISCOVERY: u32 = 31990; + /// Lower bound of the NIP-33 parameterized replaceable range (30000–39999). pub const PARAM_REPLACEABLE_KIND_MIN: u32 = 30000; /// Upper bound of the NIP-33 parameterized replaceable range (30000–39999). @@ -393,6 +403,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_WORKFLOW_DEF, + KIND_MESH_LLM_DISCOVERY, KIND_LONG_FORM, KIND_USER_STATUS, KIND_READ_STATE, @@ -511,6 +522,7 @@ pub fn event_kind_i32(event: &nostr::Event) -> i32 { // Compile-time: new kinds are in the expected ranges. const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000–19999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_MESH_LLM_DISCOVERY)); // 31990 ∈ 30000–39999 // Compile-time: NIP-34 parameterized replaceable kinds are in the correct range. const _: () = assert!( diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index bd799b19a..2e6b44969 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -38,51 +38,65 @@ pub mod relay_members { use crate::state::AppState; - /// Enforce relay membership for a pubkey, with NIP-OA agent delegation fallback. + /// Outcome of the transport-neutral membership check. /// - /// Returns `Ok(Some(owner_pubkey))` when the agent is not a direct member but - /// its NIP-OA owner *is* — access is granted via delegation. + /// Distinguishes the four meaningful states without forcing the caller to + /// produce an HTTP response — used by both the HTTP wrapper + /// [`enforce_relay_membership`] and by non-HTTP gates (e.g. the iroh-relay + /// `AccessConfig::Restricted` callback). + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum MembershipDecision { + /// The relay does not enforce membership (`require_relay_membership = false`). + OpenRelay, + /// The caller's pubkey is in `relay_members` directly. + Member, + /// The caller is an agent and its NIP-OA owner is in `relay_members`. + /// The owner pubkey is included so callers can audit or backfill. + ViaOwner(nostr::PublicKey), + /// The caller is not a relay member and no valid NIP-OA delegation applies. + Denied, + } + + /// Transport-neutral relay membership check. /// - /// On open relays (`require_relay_membership = false`), returns `Ok(None)` - /// immediately — no membership check is performed. Callers that need NIP-OA - /// owner extraction on open relays should call [`extract_nip_oa_owner`] directly. + /// Returns a [`MembershipDecision`] without producing an HTTP response. + /// HTTP callers should prefer [`enforce_relay_membership`], which maps + /// `Denied → 403 JSON`. Non-HTTP callers (e.g. iroh-relay access checks) + /// inspect the decision directly. /// - /// Returns `Ok(None)` when the caller is a direct member (closed relay) or when - /// no NIP-OA tag is present/applicable (open relay without auth tag). - pub async fn enforce_relay_membership( + /// `Err` is reserved for **infrastructure failures** (database errors, + /// invalid pubkey bytes) — never for the authorization decision itself. + pub async fn check_relay_membership( state: &AppState, pubkey_bytes: &[u8], auth_tag_header: Option<&str>, - ) -> Result, (StatusCode, Json)> { + ) -> Result { if !state.config.require_relay_membership { - return Ok(None); + return Ok(MembershipDecision::OpenRelay); } let pubkey_hex = hex::encode(pubkey_bytes); - let is_member = state.db.is_relay_member(&pubkey_hex).await.map_err(|e| { - tracing::error!("relay membership check failed: {e}"); - super::internal_error(&format!("relay membership check failed: {e}")) - })?; + let is_member = state + .db + .is_relay_member(&pubkey_hex) + .await + .map_err(|e| format!("relay membership check failed: {e}"))?; if is_member { - return Ok(None); + return Ok(MembershipDecision::Member); } - // NIP-OA fallback: check if agent's owner is a relay member. if state.config.allow_nip_oa_auth { if let Some(tag_json) = auth_tag_header { - let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes).map_err(|e| { - super::internal_error(&format!("invalid agent pubkey for NIP-OA check: {e}")) - })?; + let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes) + .map_err(|e| format!("invalid agent pubkey for NIP-OA check: {e}"))?; match sprout_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { Ok(owner_pubkey) => { let owner_hex = owner_pubkey.to_hex(); let owner_is_member = state.db.is_relay_member(&owner_hex).await.map_err(|e| { - super::internal_error(&format!( - "relay membership check (owner) failed: {e}" - )) + format!("relay membership check (owner) failed: {e}") })?; if owner_is_member { @@ -91,7 +105,7 @@ pub mod relay_members { owner = %owner_hex, "NIP-OA membership granted via owner" ); - return Ok(Some(owner_pubkey)); + return Ok(MembershipDecision::ViaOwner(owner_pubkey)); } } Err(e) => { @@ -101,13 +115,43 @@ pub mod relay_members { } } - Err(( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "relay_membership_required", - "message": "You must be a relay member to access this relay" - })), - )) + Ok(MembershipDecision::Denied) + } + + /// Enforce relay membership for a pubkey, with NIP-OA agent delegation fallback. + /// + /// Thin HTTP-layer wrapper around [`check_relay_membership`] that converts + /// `Denied → 403 JSON` and infra errors → 500 envelope. + /// + /// Returns `Ok(Some(owner_pubkey))` when the agent is not a direct member but + /// its NIP-OA owner *is* — access is granted via delegation. + /// + /// On open relays (`require_relay_membership = false`), returns `Ok(None)` + /// immediately — no membership check is performed. Callers that need NIP-OA + /// owner extraction on open relays should call [`extract_nip_oa_owner`] directly. + /// + /// Returns `Ok(None)` when the caller is a direct member (closed relay) or when + /// no NIP-OA tag is present/applicable (open relay without auth tag). + pub async fn enforce_relay_membership( + state: &AppState, + pubkey_bytes: &[u8], + auth_tag_header: Option<&str>, + ) -> Result, (StatusCode, Json)> { + match check_relay_membership(state, pubkey_bytes, auth_tag_header).await { + Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Ok(None), + Ok(MembershipDecision::ViaOwner(owner)) => Ok(Some(owner)), + Ok(MembershipDecision::Denied) => Err(( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "relay_membership_required", + "message": "You must be a relay member to access this relay" + })), + )), + Err(e) => { + tracing::error!("relay membership check errored: {e}"); + Err(super::internal_error(&e)) + } + } } /// Extract NIP-OA owner from an auth tag without membership enforcement. diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index 23b2cf4a7..0c26188a7 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -118,6 +118,21 @@ pub struct Config { /// When set, the relay serves the SPA from this directory for browser requests. /// When unset, no static file serving happens (relay behaves as before). pub web_dir: Option, + + // ── Mesh-LLM iroh-relay advertisement ──────────────────────────────────── + /// Optional publicly-reachable URL of this relay's embedded iroh-relay + /// endpoint, advertised in the NIP-11 document as `iroh_relay_url`. + /// + /// Read by mesh-llm clients (desktop sidecar) so they can connect their + /// iroh endpoints to Sprout's own relay — keeping all mesh-LLM QUIC + /// traffic on the same trust boundary as the Sprout relay membership, + /// with **no out-of-band configuration** required from the user. + /// + /// Absent → NIP-11 omits the field (older clients unaffected). Set via + /// `SPROUT_IROH_RELAY_PUBLIC_URL`. Example: + /// `https://relay.example.com/iroh` (a path prefix is supported and + /// preserved by the NIP-98 canonicaliser). + pub iroh_relay_public_url: Option, } impl Config { @@ -319,6 +334,12 @@ impl Config { let secret: [u8; 32] = rand::random(); hex::encode(secret) }); + // Mesh-LLM iroh-relay advertisement (optional) + let iroh_relay_public_url = std::env::var("SPROUT_IROH_RELAY_PUBLIC_URL") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + // Web UI static file serving let web_dir = std::env::var("SPROUT_WEB_DIR") .ok() @@ -378,6 +399,7 @@ impl Config { git_max_concurrent_ops, git_hook_hmac_secret, web_dir, + iroh_relay_public_url, }) } } diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index 47235e9b7..60360bb53 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -21,9 +21,9 @@ use sprout_core::kind::{ KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_STARTED, KIND_HUDDLE_TRACK_PUBLISHED, KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, - KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, - KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_DISCOVERY, KIND_NIP29_CREATE_GROUP, + KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, + KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, @@ -215,6 +215,11 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), KIND_WORKFLOW_DEF | KIND_WORKFLOW_TRIGGER => Ok(Scope::MessagesWrite), + // Mesh-LLM compute-offer discovery — relay members fan out their compute + // availability to other relay members. Same write scope as regular + // messages; the audience boundary is relay membership (enforced by + // NIP-43), not channel scope. + KIND_MESH_LLM_DISCOVERY => Ok(Scope::MessagesWrite), KIND_APPROVAL_GRANT | KIND_APPROVAL_DENY => Ok(Scope::MessagesWrite), _ => Err("restricted: unknown event kind"), } @@ -322,6 +327,10 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | RELAY_ADMIN_REMOVE_MEMBER | RELAY_ADMIN_CHANGE_ROLE | KIND_NIP43_LEAVE_REQUEST + // Mesh-LLM compute-offer discovery is addressed by (pubkey, kind, d_tag) + // and consumed by every relay member; a stray `h` tag must not + // accidentally channel-scope it. + | KIND_MESH_LLM_DISCOVERY ) } @@ -1795,6 +1804,28 @@ mod tests { assert!(is_global_only_kind(KIND_USER_STATUS)); } + #[test] + fn mesh_llm_discovery_requires_messages_write_scope() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_MESH_LLM_DISCOVERY, &dummy).unwrap(), + Scope::MessagesWrite, + "kind:31990 must require MessagesWrite — relay membership is enforced by the existing NIP-43 gate" + ); + } + + #[test] + fn mesh_llm_discovery_is_global_only() { + // kind:31990 is addressed by (pubkey, kind, d_tag); a stray `h` tag + // must not channel-scope it. + assert!(is_global_only_kind(KIND_MESH_LLM_DISCOVERY)); + } + + #[test] + fn mesh_llm_discovery_does_not_require_h_tag() { + assert!(!requires_h_channel_scope(KIND_MESH_LLM_DISCOVERY)); + } + #[test] fn user_status_does_not_require_h_tag() { assert!(!requires_h_channel_scope(KIND_USER_STATUS)); diff --git a/crates/sprout-relay/src/nip11.rs b/crates/sprout-relay/src/nip11.rs index 7cbff06d2..684cbc2ae 100644 --- a/crates/sprout-relay/src/nip11.rs +++ b/crates/sprout-relay/src/nip11.rs @@ -41,6 +41,15 @@ pub struct RelayInfo { /// Relay's own signing pubkey (NIP-11 `self` field, NIP-43). #[serde(rename = "self", skip_serializing_if = "Option::is_none")] pub relay_self: Option, + /// Publicly-reachable URL of this relay's embedded iroh-relay endpoint, + /// used by mesh-LLM clients to wire their QUIC compute traffic through + /// the same trust boundary as Sprout relay membership. + /// + /// Absent unless the relay is configured to host an iroh-relay + /// (`SPROUT_IROH_RELAY_PUBLIC_URL`). Older clients that don't recognise + /// this field see no behaviour change. + #[serde(skip_serializing_if = "Option::is_none")] + pub iroh_relay_url: Option, } /// Protocol and resource limits advertised in the NIP-11 document. @@ -102,7 +111,11 @@ impl RelayInfo { /// gates on NIP-43 events — i.e. has a stable key AND enforces /// membership. NIP-43 events are verified against `self`, so it is a /// programmer error to advertise NIP-43 without a `relay_self`. - pub fn build(relay_self: Option<&str>, advertise_nip43: bool) -> Self { + pub fn build( + relay_self: Option<&str>, + advertise_nip43: bool, + iroh_relay_url: Option<&str>, + ) -> Self { debug_assert!( !advertise_nip43 || relay_self.is_some(), "advertise_nip43=true requires relay_self=Some — NIP-43 events are verified against `self`" @@ -123,6 +136,7 @@ impl RelayInfo { version: env!("CARGO_PKG_VERSION").to_string(), limitation: Some(relay_limitation()), relay_self: relay_self.map(|s| s.to_string()), + iroh_relay_url: iroh_relay_url.map(|s| s.to_string()), } } } @@ -131,11 +145,15 @@ impl RelayInfo { pub async fn relay_info_handler( axum::extract::State(state): axum::extract::State>, ) -> axum::response::Json { - let (relay_self, advertise_nip43) = nip11_facts(&state); - axum::response::Json(RelayInfo::build(relay_self.as_deref(), advertise_nip43)) + let (relay_self, advertise_nip43, iroh_relay_url) = nip11_facts(&state); + axum::response::Json(RelayInfo::build( + relay_self.as_deref(), + advertise_nip43, + iroh_relay_url.as_deref(), + )) } -/// Derives the two NIP-11 facts that depend on runtime config: +/// Derives the NIP-11 facts that depend on runtime config: /// /// - `relay_self`: the NIP-11 `self` pubkey, set whenever the relay has a /// stable signing key. Consumed by NIP-29 (group metadata verification) @@ -144,14 +162,19 @@ pub async fn relay_info_handler( /// - `advertise_nip43`: whether to list NIP-43 in `supported_nips`. True /// only when membership is actually enforced AND we have a stable key /// (NIP-43 events must be verifiable against `self`). +/// - `iroh_relay_url`: the publicly-reachable iroh-relay URL (see +/// [`crate::config::Config::iroh_relay_public_url`]). /// /// Centralised so the content-negotiated root handler and the dedicated /// `/info` endpoint can't drift apart. -pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option, bool) { +pub(crate) fn nip11_facts( + state: &crate::state::AppState, +) -> (Option, bool, Option) { let has_stable_key = state.config.relay_private_key.is_some(); let relay_self = has_stable_key.then(|| state.relay_keypair.public_key().to_hex()); let advertise_nip43 = has_stable_key && state.config.require_relay_membership; - (relay_self, advertise_nip43) + let iroh_relay_url = state.config.iroh_relay_public_url.clone(); + (relay_self, advertise_nip43, iroh_relay_url) } #[cfg(test)] @@ -214,7 +237,7 @@ mod tests { /// Open relay, ephemeral key — both `self` and NIP-43 are absent. #[test] fn build_open_relay_ephemeral_key_omits_self_and_nip43() { - let info = RelayInfo::build(None, false); + let info = RelayInfo::build(None, false, None); assert!(info.relay_self.is_none()); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -227,7 +250,7 @@ mod tests { #[test] fn build_open_relay_stable_key_advertises_self_but_not_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), false); + let info = RelayInfo::build(Some(pk), false, None); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -236,7 +259,7 @@ mod tests { #[test] fn build_membership_relay_advertises_self_and_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), true); + let info = RelayInfo::build(Some(pk), true, None); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -247,6 +270,33 @@ mod tests { #[test] #[should_panic(expected = "advertise_nip43=true requires relay_self=Some")] fn build_nip43_without_self_panics_in_debug() { - let _ = RelayInfo::build(None, true); + let _ = RelayInfo::build(None, true, None); + } + + /// `iroh_relay_url` is omitted from the NIP-11 doc when unset, so older + /// clients that don't recognise the field see no extra payload. + #[test] + fn build_omits_iroh_relay_url_when_unset() { + let info = RelayInfo::build(None, false, None); + assert!(info.iroh_relay_url.is_none()); + let json = serde_json::to_value(&info).unwrap(); + assert!( + json.get("iroh_relay_url").is_none(), + "iroh_relay_url must be skipped when None: {json}" + ); + } + + /// When configured, the iroh-relay URL is advertised verbatim. + #[test] + fn build_advertises_iroh_relay_url_when_set() { + let url = "https://relay.example.com/iroh"; + let info = RelayInfo::build(None, false, Some(url)); + assert_eq!(info.iroh_relay_url.as_deref(), Some(url)); + let json = serde_json::to_value(&info).unwrap(); + assert_eq!( + json.get("iroh_relay_url").and_then(|v| v.as_str()), + Some(url), + "iroh_relay_url must be serialised when Some: {json}" + ); } } diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index f72516bf3..55921ab51 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -153,10 +153,14 @@ async fn nip11_or_ws_handler( .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let (relay_self, advertise_nip43) = nip11_facts(&state); + let (relay_self, advertise_nip43, iroh_relay_url) = nip11_facts(&state); if accept.contains("application/nostr+json") { - let info = RelayInfo::build(relay_self.as_deref(), advertise_nip43); + let info = RelayInfo::build( + relay_self.as_deref(), + advertise_nip43, + iroh_relay_url.as_deref(), + ); return Json(info).into_response(); } @@ -175,7 +179,11 @@ async fn nip11_or_ws_handler( } } // Not a WS request and not asking for nostr+json — serve NIP-11 as fallback. - let info = RelayInfo::build(relay_self.as_deref(), advertise_nip43); + let info = RelayInfo::build( + relay_self.as_deref(), + advertise_nip43, + iroh_relay_url.as_deref(), + ); Json(info).into_response() } } From 3ca691807845105cef73de256a6aa271b02da3a9 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 15:39:35 -0400 Subject: [PATCH 02/14] =?UTF-8?q?mesh-llm=20plan=20v6.1:=20Step=203=20?= =?UTF-8?q?=E2=80=94=20embedded=20iroh-relay=20with=20NIP-98=20admission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New module crates/sprout-relay/src/iroh_relay.rs (~290 lines incl. tests). - pub fn spawn(state, bind_addr) constructs an iroh_relay::server::Server with AccessConfig::Restricted set to a closure that: 1. Pulls the Bearer token from ClientRequest::auth_token(). 2. base64-decodes (accepts STANDARD + URL_SAFE, padded or not). 3. Calls sprout_auth::verify_nip98_event against canonical URL (= sprout_auth::nip98_canonical_url(public_url, '/relay')). 4. Runs check_relay_membership against the NIP-98 pubkey. Anything other than Member/ViaOwner/OpenRelay -> Deny. Per Max's review notes: fail-closed on missing/invalid token, run membership only after NIP-98 verifies the pubkey, no caching. - Returns Ok(None) gracefully when SPROUT_IROH_RELAY_PUBLIC_URL is unset (the canonical URL can't be built without it). - patched-iroh-relay feature flag reserved for upstream PR C's per-client max-lifetime hook (kept behind cfg so unpatched rc.0 still compiles). - MSRV bumped from 1.88.0 -> 1.91.0 (iroh-relay rc.0's MSRV). Repo's rust-toolchain.toml already pins 1.95.0 so builds are unaffected; the bump just keeps Cargo.toml honest with the actual transitive floor. - README updated: 'Rust 1.88+' -> 'Rust 1.91+'. - crates/sprout-relay/Cargo.toml: added iroh-relay = { version = "=1.0.0-rc.0", features = ["server"] } plus the patched-iroh-relay feature. Tests (rustc 1.95, via rust-toolchain.toml; also verified independently on 1.91.1): - sprout-relay --lib: 195 -> 206 (+11 iroh_relay tests covering valid admission, missing/empty/non-base64/wrong-method/wrong-URL/wrong-kind/ stale-timestamp denials, and bearer-encoding round-trips). - cargo clippy --workspace --all-targets -- -D warnings: clean. - cargo fmt --all -- --check: clean. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- Cargo.lock | 1440 ++++++++++++++++++++++++- Cargo.toml | 2 +- README.md | 2 +- crates/sprout-relay/Cargo.toml | 5 + crates/sprout-relay/src/iroh_relay.rs | 418 +++++++ crates/sprout-relay/src/lib.rs | 2 + 6 files changed, 1823 insertions(+), 46 deletions(-) create mode 100644 crates/sprout-relay/src/iroh_relay.rs diff --git a/Cargo.lock b/Cargo.lock index f44b6fcd4..38e2487fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,33 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -118,12 +145,57 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-compression" version = "0.4.42" @@ -147,6 +219,17 @@ dependencies = [ "syn", ] +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + [[package]] name = "atoi" version = "2.0.0" @@ -259,7 +342,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", + "sha1 0.10.6", "sync_wrapper", "tokio", "tokio-tungstenite", @@ -308,6 +391,12 @@ dependencies = [ "fastrand", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base58ck" version = "0.1.0" @@ -347,6 +436,15 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitcoin" version = "0.32.9" @@ -410,6 +508,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -651,6 +763,15 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -748,6 +869,41 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -820,6 +976,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "cron" version = "0.16.0" @@ -901,6 +1063,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -910,14 +1081,67 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0-pre.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", ] [[package]] @@ -933,13 +1157,24 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn", ] @@ -964,6 +1199,26 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -1006,6 +1261,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1016,6 +1285,66 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + [[package]] name = "digest" version = "0.10.7" @@ -1079,32 +1408,80 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] -name = "either" -version = "1.15.0" +name = "ed25519" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "serde", + "serdect", + "signature 3.0.0", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "ed25519-dalek" +version = "3.0.0-pre.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "20449acd54b660981ae5caa2bcb56d1fe7f25f2e37a38ec507400fab034d4bb6" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "subtle", + "zeroize", +] [[package]] -name = "errno" -version = "0.3.14" +name = "either" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" dependencies = [ - "libc", - "windows-sys 0.61.2", + "serde", ] [[package]] -name = "etcetera" +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" @@ -1157,6 +1534,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1181,7 +1564,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.8", ] [[package]] @@ -1232,6 +1615,19 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1276,6 +1672,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1375,11 +1784,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", ] [[package]] @@ -1484,6 +1905,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -1527,6 +1953,83 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http", + "idna", + "ipnet", + "jni", + "rand 0.10.1", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.1", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -1803,6 +2306,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + [[package]] name = "idna" version = "1.1.0" @@ -1917,11 +2426,168 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.3", + "widestring", + "windows-registry", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] + +[[package]] +name = "iroh-base" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2160a45265eba3bd290ce698f584c9b088bee47e518e9ec4460d5e5888ef660e" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "data-encoding-macro", + "derive_more", + "digest 0.11.3", + "ed25519-dalek", + "getrandom 0.4.2", + "n0-error", + "rand 0.10.1", + "serde", + "sha2 0.11.0", + "url", + "zeroize", + "zeroize_derive", +] + +[[package]] +name = "iroh-dns" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8b6d2946350d398c9d2d795bb99b04f22e8414c8a8ad9c5c3c0c5b7899af9a4" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "rand 0.10.1", + "reqwest 0.13.3", + "rustls", + "simple-dns", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d102597d0ee523f17fdb672c532395e634dbe945429284c811430d63bacc0d8a" +dependencies = [ + "http-body-util", + "hyper", + "hyper-util", + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "reqwest 0.13.3", + "rustls", + "rustls-platform-verifier", + "ryu", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c8e0c97f1dc787107f388433c349397c565572fe6406d600ff7bb7b7fe3b30" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "iroh-relay" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f490405e42dd2ecf16be18a3587d2665401e94a498094f12322eaa6d5ebb2b" +dependencies = [ + "ahash", + "blake3", + "bytes", + "cfg_aliases", + "clap", + "dashmap", + "data-encoding", + "derive_more", + "getrandom 0.4.2", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.1", + "rcgen", + "reloadable-state", + "reqwest 0.13.3", + "rustls", + "rustls-cert-file-reader", + "rustls-cert-reloadable-resolver", + "rustls-pki-types", + "serde", + "serde_bytes", + "serde_json", + "sha1 0.11.0", + "simdutf8", + "strum", + "time", + "tokio", + "tokio-rustls", + "tokio-rustls-acme", + "tokio-util", + "tokio-websockets", + "toml 1.1.2+spec-1.1.0", + "tracing", + "tracing-subscriber", + "url", + "vergen-gitcl", + "webpki-roots 1.0.7", + "ws_stream_wasm", +] [[package]] name = "is_terminal_polyfill" @@ -2021,7 +2687,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] [[package]] @@ -2115,6 +2781,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2244,6 +2919,12 @@ dependencies = [ "rxml", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2306,6 +2987,54 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "n0-error" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "223e946a84aa91644507a6b7865cfebbb9a231ace499041c747ab0fd30408212" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "565305a21e6b3bf26640ad98f05a0fda12d3ab4315394566b52a7bddb8b34828" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "negentropy" version = "0.3.1" @@ -2330,6 +3059,79 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noq" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22739e0831e40f5ab7d6ac5317ed80bfe5fb3f44be57d23fa2eea8bff83fb303" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash", + "rustls", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cee32450cf726b223ac4154003c93cb52fbde159ab1240990e88945bf3ae35e" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.2", + "identity-hash", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78633d1fe1bde91d12bcabb230ac9edb890857414c6d44f3212e0d309525b5ff" +dependencies = [ + "cfg_aliases", + "libc", + "socket2 0.6.3", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "nostr" version = "0.36.0" @@ -2464,6 +3266,37 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -2483,11 +3316,24 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -2573,6 +3419,16 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2588,6 +3444,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + [[package]] name = "phf" version = "0.11.3" @@ -2630,6 +3496,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2693,11 +3579,50 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "potential_utf" @@ -2723,6 +3648,17 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -2733,6 +3669,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2946,6 +3891,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xoshiro" version = "0.7.0" @@ -2973,6 +3927,20 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redis" version = "0.27.6" @@ -3054,6 +4022,23 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reloadable-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dc20ac1418988b60072d783c9f68e28a173fb63493c127952f6face3b40c6e0" + +[[package]] +name = "reloadable-state" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3853ef78d45b50f8b989896304a85239539d39b7f866a000e8846b9b72d74ce8" +dependencies = [ + "arc-swap", + "reloadable-core", + "tokio", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -3132,6 +4117,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "ring" version = "0.17.14" @@ -3176,7 +4167,7 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "serde_json", @@ -3197,7 +4188,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core 0.6.4", - "signature", + "signature 2.2.0", "spki", "subtle", "zeroize", @@ -3263,33 +4254,76 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-cert-file-reader" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb47c2a50fdfdaf95b0ac8b12620fc327da1fd4adbb30d0c56d866b005873ff" +dependencies = [ + "rustls-cert-read", + "rustls-pki-types", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "rustls-cert-read" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd46e8c5ae4de3345c4786a83f99ec7aff287209b9e26fa883c473aeb28f19d5" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", + "rustls-pki-types", ] [[package]] -name = "rustls" -version = "0.23.40" +name = "rustls-cert-reloadable-resolver" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "fe1baa8a3a1f05eaa9fc55aed4342867f70e5c170ea3bfed1b38c51a4857c0c8" dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", + "futures-util", + "reloadable-state", + "rustls", + "rustls-cert-read", + "thiserror 2.0.18", ] [[package]] @@ -3320,7 +4354,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -3509,7 +4543,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3531,6 +4565,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -3541,6 +4581,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3631,6 +4681,16 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3642,6 +4702,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -3705,6 +4776,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -3733,6 +4810,15 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -3780,6 +4866,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "spin" version = "0.9.8" @@ -3789,6 +4892,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + [[package]] name = "spki" version = "0.7.3" @@ -3833,7 +4942,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "tokio-util", - "toml", + "toml 0.9.12+spec-1.1.0", "tracing", "tracing-subscriber", "url", @@ -4156,6 +5265,7 @@ dependencies = [ "hex", "hmac 0.13.0", "infer", + "iroh-relay", "metrics", "metrics-exporter-prometheus", "moka", @@ -4387,7 +5497,7 @@ dependencies = [ "rand 0.8.6", "rsa", "serde", - "sha1", + "sha1 0.10.6", "sha2 0.10.9", "smallvec", "sqlx-core", @@ -4492,6 +5602,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4543,6 +5674,27 @@ dependencies = [ "windows 0.61.3", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -4619,7 +5771,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -4713,6 +5867,34 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls-acme" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1af8573b15fdad8d66da116198cd8fd8d87ff62a67c1c6c3df7f62da1170793f" +dependencies = [ + "async-trait", + "base64", + "chrono", + "futures", + "log", + "num-bigint", + "pem", + "proc-macro2", + "rcgen", + "reqwest 0.13.3", + "ring", + "rustls", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-rustls", + "webpki-roots 1.0.7", + "x509-parser", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -4722,6 +5904,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -4754,6 +5937,29 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.2", + "http", + "httparse", + "rand 0.10.1", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -4763,12 +5969,27 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.2", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -4778,6 +5999,27 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.2", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -4945,7 +6187,7 @@ dependencies = [ "rand 0.9.4", "rustls", "rustls-pki-types", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", ] @@ -4988,6 +6230,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -5065,6 +6313,43 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + [[package]] name = "version_check" version = "0.9.5" @@ -5285,6 +6570,12 @@ dependencies = [ "wasite", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -5461,6 +6752,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5751,6 +7053,9 @@ name = "winnow" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen" @@ -5852,6 +7157,53 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 54f885e30..1e6554898 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ resolver = "2" [workspace.package] version = "0.1.0" edition = "2021" -rust-version = "1.88.0" +rust-version = "1.91.0" license = "Apache-2.0" repository = "https://github.com/sprout-rs/sprout" diff --git a/README.md b/README.md index bc89abffe..e1c290fa1 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Agents are colleagues, not haunted cron jobs. ## Quick start -You'll need [Docker](https://docs.docker.com/get-docker/) and [Hermit](https://cashapp.github.io/hermit/) (or Rust 1.88+, Node 24+, pnpm 10+, `just`). +You'll need [Docker](https://docs.docker.com/get-docker/) and [Hermit](https://cashapp.github.io/hermit/) (or Rust 1.91+, Node 24+, pnpm 10+, `just`). **Once:** ```bash diff --git a/crates/sprout-relay/Cargo.toml b/crates/sprout-relay/Cargo.toml index 997e5de47..7f9550236 100644 --- a/crates/sprout-relay/Cargo.toml +++ b/crates/sprout-relay/Cargo.toml @@ -58,9 +58,14 @@ url = { workspace = true } moka = { workspace = true } metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } +iroh-relay = { version = "=1.0.0-rc.0", features = ["server"] } [features] dev = ["sprout-auth/dev"] +# Enables APIs that only exist on a locally-patched fork of iroh-relay +# (notably the per-client max lifetime hook used by Step 3). Off by default +# so the published rc.0 crate compiles unmodified. +patched-iroh-relay = [] [dev-dependencies] sprout-core = { workspace = true, features = ["test-utils"] } diff --git a/crates/sprout-relay/src/iroh_relay.rs b/crates/sprout-relay/src/iroh_relay.rs new file mode 100644 index 000000000..f1521df96 --- /dev/null +++ b/crates/sprout-relay/src/iroh_relay.rs @@ -0,0 +1,418 @@ +//! Embedded iroh-relay server, gated by Sprout relay membership. +//! +//! This is the **Step 3** half of the mesh-LLM plan (v6.1). The desktop +//! sidecar connects its iroh endpoint to `iroh_relay_url` advertised in the +//! Sprout NIP-11 document; this module hosts that relay endpoint inside the +//! Sprout process and gates every connection with the same NIP-98 + relay- +//! membership check we already use for HTTP entry points. +//! +//! The result: mesh-LLM QUIC traffic never leaves the relay's trust boundary +//! and **n0's public relays are never in the path**. No subscriptions, no +//! signups, no out-of-band config — relay members get pooled compute "for +//! free" once they install Sprout. +//! +//! # Access flow +//! +//! 1. The iroh client opens a WebSocket to `https:///iroh/relay` +//! carrying `Authorization: Bearer ` and its +//! proven `EndpointId` (proved by iroh-relay's handshake before we run). +//! 2. iroh-relay calls our [`AccessConfig::Restricted`] callback with the +//! [`ClientRequest`]. +//! 3. We verify the NIP-98 event against the canonical relay URL using +//! [`sprout_auth::nip98_canonical_url`] + [`sprout_auth::verify_nip98_event`]. +//! Any failure → `Access::Deny`. This proves the connecting pubkey. +//! 4. We run [`crate::api::relay_members::check_relay_membership`] against +//! that pubkey. Anything other than `OpenRelay`/`Member`/`ViaOwner` → +//! `Access::Deny`. +//! 5. We log the bound (NIP-98 pubkey, EndpointId, decision) for audit. +//! +//! No state is cached: every connection re-runs steps 3 + 4. The cost is one +//! Schnorr verify and 1-2 DB reads per connection, which is negligible +//! versus the QUIC + model traffic that follows. +//! +//! # Patched-fork hooks +//! +//! Upstream iroh-relay rc.0 does not expose a per-client maximum-lifetime +//! hook. A locally-patched fork (`upstream PR C` in the plan) adds one so we +//! can force re-auth every N minutes. We isolate that wiring behind +//! [`cfg(feature = "patched-iroh-relay")`] so the unpatched crate compiles +//! cleanly. + +use std::net::SocketAddr; +use std::sync::Arc; + +use iroh_relay::server::{ + Access, AccessConfig, ClientRequest, RelayConfig, Server, ServerConfig, SpawnError, +}; +use tracing::{debug, info, warn}; + +use crate::api::relay_members::{check_relay_membership, MembershipDecision}; +use crate::state::AppState; + +/// Path component appended to `iroh_relay_public_url` for the access check. +/// +/// Iroh's WebSocket upgrade always hits `/relay`; if the relay is reverse- +/// proxied under a path prefix (e.g. `https://host/iroh`), the full canonical +/// URL is `https://host/iroh/relay`. The NIP-98 signer and verifier both +/// compute this via [`sprout_auth::nip98_canonical_url`]. +pub const IROH_RELAY_PATH: &str = "/relay"; + +/// HTTP method bound into NIP-98 events for iroh-relay connection auth. +const NIP98_METHOD: &str = "GET"; + +/// Handle returned by [`spawn`] — dropping it stops the server. +pub struct IrohRelayHandle { + /// The bound HTTP address (resolved if the caller passed port 0). + pub http_addr: Option, + /// The bound HTTPS address, if TLS was configured. + pub https_addr: Option, + _server: Server, +} + +/// Spawn an embedded iroh-relay bound to `bind_addr`, gated by Sprout's +/// NIP-98 + relay-membership check. +/// +/// Returns `Ok(None)` if `state.config.iroh_relay_public_url` is not set: +/// without a stable public URL the NIP-98 `u`-tag can't be canonicalised, so +/// hosting an iroh-relay endpoint would just produce an undebuggable storm +/// of `URL mismatch` denials. We surface that as "not enabled" instead. +pub async fn spawn( + state: Arc, + bind_addr: SocketAddr, +) -> Result, SpawnError> { + let Some(public_url) = state.config.iroh_relay_public_url.clone() else { + info!("SPROUT_IROH_RELAY_PUBLIC_URL not set — embedded iroh-relay disabled"); + return Ok(None); + }; + + let canonical_url = match sprout_auth::nip98_canonical_url(&public_url, IROH_RELAY_PATH) { + Some(u) => u, + None => { + warn!( + public_url = %public_url, + "SPROUT_IROH_RELAY_PUBLIC_URL is not a parseable URL — iroh-relay disabled", + ); + return Ok(None); + } + }; + + info!( + bind_addr = %bind_addr, + canonical_url = %canonical_url, + "spawning embedded iroh-relay", + ); + + let access = build_access_config(state.clone(), canonical_url); + + let mut relay = RelayConfig::new(bind_addr); + relay.access = access; + + let mut cfg = ServerConfig::default(); + cfg.relay = Some(relay); + + let server = Server::spawn(cfg).await?; + Ok(Some(IrohRelayHandle { + http_addr: server.http_addr(), + https_addr: server.https_addr(), + _server: server, + })) +} + +/// Build the [`AccessConfig::Restricted`] callback that gates every +/// iroh-relay connection on (NIP-98 ∧ relay-membership). +fn build_access_config(state: Arc, canonical_url: String) -> AccessConfig { + AccessConfig::Restricted(Box::new(move |request: &ClientRequest| { + let state = state.clone(); + let canonical_url = canonical_url.clone(); + let endpoint_id = request.endpoint_id(); + let auth_token = request.auth_token(); + Box::pin(async move { + match decide(&state, &canonical_url, auth_token.as_deref()).await { + Decision::Allow { pubkey, owner } => { + debug!( + endpoint = %endpoint_id, + pubkey = %pubkey, + via_owner = ?owner, + "iroh-relay admission allowed", + ); + Access::Allow + } + Decision::Deny(reason) => { + debug!( + endpoint = %endpoint_id, + reason = %reason, + "iroh-relay admission denied", + ); + Access::Deny + } + } + }) + })) +} + +/// Internal decision type for the access callback — kept separate so it's +/// straightforward to unit-test [`decide`] without spinning up a full server. +#[derive(Debug)] +enum Decision { + /// Connection should be admitted. + Allow { + /// The NIP-98-proven pubkey of the connecting client. + pubkey: nostr::PublicKey, + /// `Some(owner)` if admission was via NIP-OA delegation. + owner: Option, + }, + /// Connection should be rejected, with a debug-only reason string. + Deny(String), +} + +/// Pure-logic admission decision, decoupled from iroh-relay's types so it +/// can be unit-tested with a real [`AppState`] and a synthetic bearer token. +async fn decide(state: &AppState, canonical_url: &str, auth_token: Option<&str>) -> Decision { + // Step 1+2+3 — extract and verify the NIP-98 bearer to recover the + // Nostr pubkey. This sub-function is unit-testable in isolation. + let pubkey = match verify_bearer(canonical_url, auth_token) { + Ok(pk) => pk, + Err(reason) => return Decision::Deny(reason), + }; + + // Step 4 — now and only now, run the membership check. We pass the NIP-98 + // pubkey bytes, not the iroh `EndpointId` — the latter is just an + // anonymous network identifier; membership is on Nostr identity. + match check_relay_membership(state, &pubkey.to_bytes(), None).await { + Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Decision::Allow { + pubkey, + owner: None, + }, + Ok(MembershipDecision::ViaOwner(owner)) => Decision::Allow { + pubkey, + owner: Some(owner), + }, + Ok(MembershipDecision::Denied) => Decision::Deny(format!("not a relay member: {}", pubkey)), + Err(e) => { + // Infrastructure failure. Fail closed. + warn!("iroh-relay membership check infra error: {e}"); + Decision::Deny(format!("membership check infra error: {e}")) + } + } +} + +/// Decode + verify the bearer token, returning the proven Nostr pubkey. +/// +/// Fail-closed on: +/// - missing/empty token, +/// - non-base64, +/// - non-UTF-8 JSON, +/// - any NIP-98 verification failure (wrong kind, bad signature, stale +/// timestamp, URL mismatch, method mismatch, payload mismatch). +/// +/// The returned `String` is a debug-only deny reason; do not forward to +/// clients (some failures distinguish "what" from "why" in ways we don't +/// want to leak). +fn verify_bearer( + canonical_url: &str, + auth_token: Option<&str>, +) -> Result { + let token = match auth_token { + Some(t) if !t.is_empty() => t, + _ => return Err("missing or empty bearer token".to_string()), + }; + + let json = + decode_bearer(token).ok_or_else(|| "bearer token is not valid base64".to_string())?; + + sprout_auth::verify_nip98_event(&json, canonical_url, NIP98_METHOD, None) + .map_err(|e| format!("NIP-98 verification failed: {e}")) +} + +/// Decode a NIP-98 bearer token. NIP-98 specifies base64 over the JSON event; +/// some signers use URL-safe encoding and/or omit padding, so we accept both. +fn decode_bearer(token: &str) -> Option { + use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD}; + use base64::Engine; + + let trimmed = token.trim(); + for engine in [&STANDARD, &URL_SAFE, &STANDARD_NO_PAD, &URL_SAFE_NO_PAD] { + if let Ok(bytes) = engine.decode(trimmed) { + if let Ok(s) = String::from_utf8(bytes) { + return Some(s); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::STANDARD; + use base64::Engine; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + use sprout_auth::nip98_canonical_url; + + /// Build a signed NIP-98 event JSON for the given canonical URL. + fn signed_event_json(keys: &Keys, canonical_url: &str, method: &str) -> String { + let event = EventBuilder::new( + Kind::HttpAuth, + "", + vec![ + Tag::parse(&["u", canonical_url]).unwrap(), + Tag::parse(&["method", method]).unwrap(), + ], + ) + .custom_created_at(Timestamp::now()) + .sign_with_keys(keys) + .unwrap(); + serde_json::to_string(&event).unwrap() + } + + fn bearer(json: &str) -> String { + STANDARD.encode(json) + } + + fn canonical() -> String { + nip98_canonical_url("https://relay.example.com/iroh", IROH_RELAY_PATH).unwrap() + } + + // ── Bearer verification ────────────────────────────────────────────── + + #[test] + fn verify_bearer_accepts_valid_nip98() { + let keys = Keys::generate(); + let url = canonical(); + let json = signed_event_json(&keys, &url, NIP98_METHOD); + let token = bearer(&json); + + let result = verify_bearer(&url, Some(&token)); + assert!(result.is_ok(), "expected accept, got {result:?}"); + assert_eq!(result.unwrap(), keys.public_key()); + } + + #[test] + fn verify_bearer_rejects_missing_token() { + let url = canonical(); + let result = verify_bearer(&url, None); + assert!(matches!(result, Err(ref e) if e.contains("missing"))); + } + + #[test] + fn verify_bearer_rejects_empty_token() { + let url = canonical(); + let result = verify_bearer(&url, Some("")); + assert!(matches!(result, Err(ref e) if e.contains("missing"))); + } + + #[test] + fn verify_bearer_rejects_non_base64() { + let url = canonical(); + let result = verify_bearer(&url, Some("not!!!base64!!!")); + assert!(matches!(result, Err(ref e) if e.contains("base64"))); + } + + #[test] + fn verify_bearer_rejects_wrong_method() { + // NIP-98 event signed for POST but the iroh-relay handshake is GET. + // The bearer must not be accepted with the wrong method. + let keys = Keys::generate(); + let url = canonical(); + let json = signed_event_json(&keys, &url, "POST"); + let token = bearer(&json); + + let result = verify_bearer(&url, Some(&token)); + assert!( + matches!(result, Err(ref e) if e.contains("method")), + "expected method-mismatch denial, got {result:?}", + ); + } + + #[test] + fn verify_bearer_rejects_wrong_url() { + // Event signed for a DIFFERENT relay URL must not authorize access + // to *this* relay. This is the property that breaks if the canonical + // helper drifts between signer and verifier. + let keys = Keys::generate(); + let other_url = + nip98_canonical_url("https://other-relay.example.com/iroh", IROH_RELAY_PATH).unwrap(); + let json = signed_event_json(&keys, &other_url, NIP98_METHOD); + let token = bearer(&json); + + let result = verify_bearer(&canonical(), Some(&token)); + assert!( + matches!(result, Err(ref e) if e.contains("URL")), + "expected URL-mismatch denial, got {result:?}", + ); + } + + #[test] + fn verify_bearer_rejects_wrong_kind() { + let keys = Keys::generate(); + let url = canonical(); + // Build a kind:1 (text note) event instead of kind:27235 — should fail. + let event = EventBuilder::new( + Kind::TextNote, + "", + vec![ + Tag::parse(&["u", &url]).unwrap(), + Tag::parse(&["method", NIP98_METHOD]).unwrap(), + ], + ) + .sign_with_keys(&keys) + .unwrap(); + let token = bearer(&serde_json::to_string(&event).unwrap()); + + let result = verify_bearer(&url, Some(&token)); + assert!( + matches!(result, Err(ref e) if e.contains("kind")), + "expected kind-mismatch denial, got {result:?}", + ); + } + + #[test] + fn verify_bearer_rejects_stale_timestamp() { + let keys = Keys::generate(); + let url = canonical(); + let event = EventBuilder::new( + Kind::HttpAuth, + "", + vec![ + Tag::parse(&["u", &url]).unwrap(), + Tag::parse(&["method", NIP98_METHOD]).unwrap(), + ], + ) + // Two hours in the past — well outside the ±60s NIP-98 tolerance. + .custom_created_at(Timestamp::from(Timestamp::now().as_u64() - 7200)) + .sign_with_keys(&keys) + .unwrap(); + let token = bearer(&serde_json::to_string(&event).unwrap()); + + let result = verify_bearer(&url, Some(&token)); + assert!( + matches!(result, Err(ref e) if e.contains("timestamp")), + "expected timestamp denial, got {result:?}", + ); + } + + // ── Bearer decoding ────────────────────────────────────────────────── + + #[test] + fn decode_bearer_accepts_standard() { + use base64::engine::general_purpose::STANDARD; + use base64::Engine; + let payload = r#"{"hello":"world"}"#; + let token = STANDARD.encode(payload); + assert_eq!(decode_bearer(&token).as_deref(), Some(payload)); + } + + #[test] + fn decode_bearer_accepts_url_safe_no_pad() { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + let payload = r#"{"hello":"world"}"#; + let token = URL_SAFE_NO_PAD.encode(payload); + assert_eq!(decode_bearer(&token).as_deref(), Some(payload)); + } + + #[test] + fn decode_bearer_rejects_garbage() { + assert!(decode_bearer("not base64 at all !!!").is_none()); + } +} diff --git a/crates/sprout-relay/src/lib.rs b/crates/sprout-relay/src/lib.rs index 3c0096431..7fa1fbc59 100644 --- a/crates/sprout-relay/src/lib.rs +++ b/crates/sprout-relay/src/lib.rs @@ -14,6 +14,8 @@ pub mod connection; pub mod error; /// WebSocket message handlers for NIP-01 client commands. pub mod handlers; +/// Embedded iroh-relay endpoint, gated by Sprout relay membership. +pub mod iroh_relay; /// Prometheus metrics: recorder, upkeep, HTTP middleware. pub mod metrics; /// NIP-11 relay information document. From 2d818b6efdec652bb6fe16ba3df88e95e7a87b4d Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 15:46:31 -0400 Subject: [PATCH 03/14] iroh-relay: review fixups from Mari + Max Mari (trust): - Add 64 KiB pre-decode length cap on the bearer token. NIP-98 events are well under a kilobyte; rejecting oversized inputs before allocating the base64 decode buffer prevents an admission request from coercing the relay into multi-megabyte allocations. New const MAX_BEARER_LEN. - New verify_bearer_rejects_oversized_token test. - New verify_bearer_rejects_internal_whitespace test: pins the fact that base64 0.22's general_purpose engines reject mid-token whitespace (no MIME mode), which is what we want. Max (review): - Soften the module-level 'patched-fork hooks' docs so they don't imply the per-client max-lifetime hook is already wired, and add an explicit TODO(patched-iroh-relay) marker at the future insertion site in spawn. - Add SPROUT_IROH_RELAY_BIND_ADDR to Config (iroh_relay_bind_addr: Option) now, so the main.rs wiring follow-up can read it without a separate config churn. Server::spawn owns its own listener, so this is independent of the Sprout HTTP bind_addr. sprout-relay --lib: 206 -> 208 tests pass (+2). workspace clippy -D warnings: clean. workspace cargo fmt --check: clean. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- crates/sprout-relay/src/config.rs | 26 ++++++++++ crates/sprout-relay/src/iroh_relay.rs | 69 +++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index 0c26188a7..a2cedb07b 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -133,6 +133,16 @@ pub struct Config { /// `https://relay.example.com/iroh` (a path prefix is supported and /// preserved by the NIP-98 canonicaliser). pub iroh_relay_public_url: Option, + + /// Optional local socket address the embedded iroh-relay binds to. + /// + /// `iroh_relay::server::Server::spawn` owns its own listener, so this is + /// independent of [`Self::bind_addr`] (the Sprout HTTP/WS port). When + /// unset, [`crate::iroh_relay::spawn`] is *not* started by `fn main` — + /// even if `iroh_relay_public_url` is configured, since advertising a + /// URL without a listener would be a deploy footgun. Set via + /// `SPROUT_IROH_RELAY_BIND_ADDR`, e.g. `0.0.0.0:3478`. + pub iroh_relay_bind_addr: Option, } impl Config { @@ -340,6 +350,21 @@ impl Config { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); + // Mesh-LLM iroh-relay local bind address (optional; independent of + // the Sprout HTTP listener since Server::spawn owns its own socket). + let iroh_relay_bind_addr = match std::env::var("SPROUT_IROH_RELAY_BIND_ADDR") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + { + Some(s) => Some(s.parse::().map_err(|e| { + ConfigError::InvalidValue(format!( + "SPROUT_IROH_RELAY_BIND_ADDR={s:?} is not a valid socket address: {e}" + )) + })?), + None => None, + }; + // Web UI static file serving let web_dir = std::env::var("SPROUT_WEB_DIR") .ok() @@ -400,6 +425,7 @@ impl Config { git_hook_hmac_secret, web_dir, iroh_relay_public_url, + iroh_relay_bind_addr, }) } } diff --git a/crates/sprout-relay/src/iroh_relay.rs b/crates/sprout-relay/src/iroh_relay.rs index f1521df96..49a0a5cd1 100644 --- a/crates/sprout-relay/src/iroh_relay.rs +++ b/crates/sprout-relay/src/iroh_relay.rs @@ -30,13 +30,14 @@ //! Schnorr verify and 1-2 DB reads per connection, which is negligible //! versus the QUIC + model traffic that follows. //! -//! # Patched-fork hooks +//! # Patched-fork hooks (forward-looking) //! //! Upstream iroh-relay rc.0 does not expose a per-client maximum-lifetime -//! hook. A locally-patched fork (`upstream PR C` in the plan) adds one so we -//! can force re-auth every N minutes. We isolate that wiring behind -//! [`cfg(feature = "patched-iroh-relay")`] so the unpatched crate compiles -//! cleanly. +//! hook. The mesh-LLM plan (v6.1, upstream PR C) will add one so we can +//! force re-auth every N minutes. The `patched-iroh-relay` Cargo feature +//! and the `TODO(patched-iroh-relay)` marker in [`spawn`] are reserved +//! insertion points for that wiring — **the hook is not implemented yet**. +//! Unpatched rc.0 stays compile-clean either way. use std::net::SocketAddr; use std::sync::Arc; @@ -60,6 +61,14 @@ pub const IROH_RELAY_PATH: &str = "/relay"; /// HTTP method bound into NIP-98 events for iroh-relay connection auth. const NIP98_METHOD: &str = "GET"; +/// Maximum size of a bearer token (raw, pre-base64-decode) we'll even try to +/// process. A well-formed NIP-98 event JSON is well under a kilobyte; the +/// base64 expansion of that is ~1.4 KiB. We allow up to 64 KiB so generous +/// signers don't trip over `payload` tag hashes etc., but reject anything +/// larger before allocating decode buffers — admission requests must not be +/// able to coerce the relay into multi-megabyte allocations. +const MAX_BEARER_LEN: usize = 64 * 1024; + /// Handle returned by [`spawn`] — dropping it stops the server. pub struct IrohRelayHandle { /// The bound HTTP address (resolved if the caller passed port 0). @@ -107,6 +116,11 @@ pub async fn spawn( let mut relay = RelayConfig::new(bind_addr); relay.access = access; + // TODO(patched-iroh-relay): once upstream PR C lands, set the per-client + // maximum-lifetime hook here (gated on `#[cfg(feature = "patched-iroh-relay")]`) + // so we force re-auth every N minutes. Until then the connection lifetime + // is whatever iroh-relay's defaults are. + let mut cfg = ServerConfig::default(); cfg.relay = Some(relay); @@ -217,6 +231,16 @@ fn verify_bearer( _ => return Err("missing or empty bearer token".to_string()), }; + // Pre-decode length cap (Mari's review note). NIP-98 events are tiny; an + // attacker shouldn't be able to coerce the relay into allocating a + // multi-megabyte decode buffer before any signature check runs. + if token.len() > MAX_BEARER_LEN { + return Err(format!( + "bearer token exceeds {MAX_BEARER_LEN}-byte limit ({} bytes)", + token.len() + )); + } + let json = decode_bearer(token).ok_or_else(|| "bearer token is not valid base64".to_string())?; @@ -308,6 +332,41 @@ mod tests { assert!(matches!(result, Err(ref e) if e.contains("base64"))); } + #[test] + fn verify_bearer_rejects_oversized_token() { + // 64 KiB + 1 byte. Must be rejected by the length cap *before* any + // decode allocation happens, so an attacker can't coerce a giant + // base64 buffer. + let url = canonical(); + let huge = "A".repeat(MAX_BEARER_LEN + 1); + let result = verify_bearer(&url, Some(&huge)); + assert!( + matches!(result, Err(ref e) if e.contains("exceeds")), + "expected length-cap denial, got {result:?}", + ); + } + + #[test] + fn verify_bearer_rejects_internal_whitespace() { + // base64 0.22's `general_purpose` engines reject internal whitespace + // (no MIME mode). A valid token with a space spliced into the middle + // must therefore fail decode, not be silently accepted as if the + // whitespace were ignored. + let keys = Keys::generate(); + let url = canonical(); + let json = signed_event_json(&keys, &url, NIP98_METHOD); + let mut token = bearer(&json); + // Splice a space into the middle of an otherwise valid token. + let mid = token.len() / 2; + token.insert(mid, ' '); + + let result = verify_bearer(&url, Some(&token)); + assert!( + matches!(result, Err(ref e) if e.contains("base64")), + "expected base64 denial on internal whitespace, got {result:?}", + ); + } + #[test] fn verify_bearer_rejects_wrong_method() { // NIP-98 event signed for POST but the iroh-relay handshake is GET. From db6c1103aa949d3b441e27c3b7b568fd4205e972 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 16:45:31 -0400 Subject: [PATCH 04/14] iroh-relay: wire spawn() into fn main with graceful shutdown - IrohRelayHandle::shutdown() consumes self and awaits Server::shutdown() (which drains in-flight QUIC sessions before returning), replacing the previous drop-aborts-supervisor pattern. - main.rs::serve() now starts the embedded iroh-relay when *both* SPROUT_IROH_RELAY_PUBLIC_URL and SPROUT_IROH_RELAY_BIND_ADDR are set. Spawned alongside the HTTP listener; subscribed to the same shutdown_tx watcher so SIGTERM/Ctrl-C drains the iroh-relay together with axum. - Mismatched config logs a warn! and starts neither: * URL only -> NIP-11 advertises a phantom endpoint -> mesh-LLM is broken * bind only -> clients can't build the NIP-98 'u' tag -> 100% denial In both cases we fail loud and refuse to lie to clients. - Both serve() paths (UDS-enabled + TCP-only) await the iroh drain task before returning so shutdown is actually graceful end-to-end. sprout-relay --lib: 208/208 unit tests pass (unchanged; this is a wiring change, not an auth change). workspace clippy -D warnings: clean. workspace cargo fmt --check: clean. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- crates/sprout-relay/src/iroh_relay.rs | 23 +++++++-- crates/sprout-relay/src/main.rs | 68 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/crates/sprout-relay/src/iroh_relay.rs b/crates/sprout-relay/src/iroh_relay.rs index 49a0a5cd1..50297d2ed 100644 --- a/crates/sprout-relay/src/iroh_relay.rs +++ b/crates/sprout-relay/src/iroh_relay.rs @@ -69,13 +69,30 @@ const NIP98_METHOD: &str = "GET"; /// able to coerce the relay into multi-megabyte allocations. const MAX_BEARER_LEN: usize = 64 * 1024; -/// Handle returned by [`spawn`] — dropping it stops the server. +/// Handle returned by [`spawn`]. +/// +/// Dropping the handle aborts the iroh-relay supervisor task (via +/// `AbortOnDropHandle` inside `Server`). For graceful drain, prefer +/// [`IrohRelayHandle::shutdown`] which waits for in-flight connections to +/// finish before returning. pub struct IrohRelayHandle { /// The bound HTTP address (resolved if the caller passed port 0). pub http_addr: Option, /// The bound HTTPS address, if TLS was configured. pub https_addr: Option, - _server: Server, + server: Server, +} + +impl IrohRelayHandle { + /// Request graceful shutdown of the embedded iroh-relay. + /// + /// Returns once all relay tasks have stopped. Used by the Sprout main + /// shutdown loop so SIGTERM stops admitting new mesh-LLM connections + /// alongside the HTTP listener, instead of yanking the socket out from + /// under in-flight QUIC sessions. + pub async fn shutdown(self) -> Result<(), iroh_relay::server::SupervisorError> { + self.server.shutdown().await + } } /// Spawn an embedded iroh-relay bound to `bind_addr`, gated by Sprout's @@ -128,7 +145,7 @@ pub async fn spawn( Ok(Some(IrohRelayHandle { http_addr: server.http_addr(), https_addr: server.https_addr(), - _server: server, + server, })) } diff --git a/crates/sprout-relay/src/main.rs b/crates/sprout-relay/src/main.rs index 5ee724b4f..a52850318 100644 --- a/crates/sprout-relay/src/main.rs +++ b/crates/sprout-relay/src/main.rs @@ -11,6 +11,7 @@ use sprout_pubsub::PubSubManager; use sprout_search::{SearchConfig, SearchService}; use sprout_relay::config::Config; +use sprout_relay::iroh_relay; use sprout_relay::metrics as relay_metrics; use sprout_relay::router::{build_health_router, build_router}; use sprout_relay::state::AppState; @@ -482,6 +483,65 @@ async fn serve( .map_err(|e| anyhow::anyhow!("Failed to bind {}: {e}", config.bind_addr))?; info!(addr = %config.bind_addr, "sprout-relay TCP listening"); + // ── Embedded iroh-relay (mesh-LLM, optional) ───────────────────────────── + // Started only when both `SPROUT_IROH_RELAY_PUBLIC_URL` and + // `SPROUT_IROH_RELAY_BIND_ADDR` are configured. Advertising a URL without + // a listener (or vice-versa) is a deploy footgun, so we log loudly and + // refuse to start the iroh-relay if exactly one is set. + let iroh_relay_task = match ( + config.iroh_relay_public_url.as_deref(), + config.iroh_relay_bind_addr, + ) { + (Some(_), Some(bind_addr)) => { + match iroh_relay::spawn(Arc::clone(&state), bind_addr).await { + Ok(Some(handle)) => { + info!( + bind_addr = %bind_addr, + http_addr = ?handle.http_addr, + "embedded iroh-relay started", + ); + let mut iroh_rx = shutdown_tx.subscribe(); + Some(tokio::spawn(async move { + // Wait for the workspace shutdown signal, then drain + // the iroh-relay gracefully so in-flight QUIC sessions + // get a chance to finish. + iroh_rx.changed().await.ok(); + info!("draining embedded iroh-relay"); + if let Err(e) = handle.shutdown().await { + tracing::error!("embedded iroh-relay shutdown error: {e}"); + } + })) + } + Ok(None) => { + // `spawn` returned None — likely an unparseable + // `iroh_relay_public_url`. Already logged inside `spawn`. + None + } + Err(e) => { + return Err(anyhow::anyhow!( + "Failed to spawn embedded iroh-relay on {bind_addr}: {e}" + )); + } + } + } + (Some(_), None) => { + tracing::warn!( + "SPROUT_IROH_RELAY_PUBLIC_URL set but SPROUT_IROH_RELAY_BIND_ADDR is not — \ + NIP-11 will advertise a URL that nothing serves. Mesh-LLM will not work.", + ); + None + } + (None, Some(_)) => { + tracing::warn!( + "SPROUT_IROH_RELAY_BIND_ADDR set but SPROUT_IROH_RELAY_PUBLIC_URL is not — \ + iroh-relay refused to start because clients cannot construct the canonical \ + NIP-98 `u` tag without a public URL.", + ); + None + } + (None, None) => None, + }; + // ── App listener (UDS, optional) ───────────────────────────────────────── #[cfg(unix)] if let Some(ref uds_path) = config.uds_path { @@ -524,6 +584,10 @@ async fn serve( .map_err(|e| anyhow::anyhow!("TCP server error: {e}"))?; uds_handle.abort(); + if let Some(task) = iroh_relay_task { + // Already triggered by shutdown_tx; just wait for the drain task. + let _ = task.await; + } return Ok(()); } @@ -544,6 +608,10 @@ async fn serve( .await .map_err(|e| anyhow::anyhow!("Server error: {e}"))?; + if let Some(task) = iroh_relay_task { + let _ = task.await; + } + Ok(()) } From 1dbc89cc112f88a2c512b13c656d6af13dba2ab3 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 16:55:45 -0400 Subject: [PATCH 05/14] mesh-llm: offer envelope (sprout-core) + desktop building blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B0 — sprout-core/src/mesh_llm.rs (new): MeshLlmOffer envelope, the content of a kind:31990 event. Schema versioned (v: u32), with deny_unknown_fields at the top level and a freeform 'extra' Value escape hatch. ResourceCaps + ModelOffer sub-structs. d_tag charset is limited to [A-Za-z0-9_-] (NIP-33 stability). 9 unit tests covering round-trip JSON, optional caps, unknown-field rejection, d_tag validation, is_publishable rule set. B2 — desktop/src-tauri/src/mesh_llm/endpoint.rs: persists the iroh endpoint keypair to {app_data_dir}/mesh_iroh.key as 32 hex bytes. Atomic write via tempfile.persist; corrupt files quarantined to .bad.{epoch} (same pattern as identity.key). 2 unit tests. Design note in the module doc: we deliberately do NOT derive the iroh key from the Nostr key, because that would couple key rotation (rotating the Nostr key would silently break active offers) and invent a new key-custody convention. Separate file, same Tauri sandbox. B3 — desktop/src-tauri/src/mesh_llm/nip98.rs: build_nip98_bearer(keys, iroh_relay_public_url) signs a kind:27235 event with the user's Nostr key over the canonical relay URL (sprout_auth::nip98_canonical_url with path '/relay'), base64-encodes the event JSON. This is the exact token the relay's iroh_relay::verify_bearer decodes + verifies. 3 unit tests. B-offer prefs — desktop/src-tauri/src/mesh_llm/offer.rs: persisted ComputeSharingPrefs (the avatar-menu sliders). Default is disabled, 1 concurrent consumer cap. build_offer() returns None when disabled so callers know to *delete* any prior offer rather than re-publish. JSON round-trip + helper tests, 4 tests. Workspace deps added: - desktop pulls sprout-auth (for the canonical URL helper, nostr-free at the API surface so the 0.36/0.37 nostr split doesn't matter). - desktop pulls iroh-base = =1.0.0-rc.0 with the 'key' feature for SecretKey/PublicKey/EndpointId. - desktop pulls thiserror = '2'. Tests: 9 desktop mesh_llm tests pass + 9 sprout-core mesh_llm tests pass. Workspace clippy + fmt clean (relay side; desktop has expected dead_code warnings until B4-B6 wire these in). Note: desktop crate requires sidecar binary stubs in desktop/src-tauri/binaries/ to typecheck; created via the existing 'just _ensure-sidecar-stubs' helper. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- crates/sprout-core/src/lib.rs | 2 + crates/sprout-core/src/mesh_llm.rs | 286 +++++++++++++++++++++ desktop/src-tauri/Cargo.lock | 224 +++++++++++++++- desktop/src-tauri/Cargo.toml | 3 + desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/mesh_llm/endpoint.rs | 145 +++++++++++ desktop/src-tauri/src/mesh_llm/mod.rs | 24 ++ desktop/src-tauri/src/mesh_llm/nip98.rs | 105 ++++++++ desktop/src-tauri/src/mesh_llm/offer.rs | 192 ++++++++++++++ 9 files changed, 979 insertions(+), 3 deletions(-) create mode 100644 crates/sprout-core/src/mesh_llm.rs create mode 100644 desktop/src-tauri/src/mesh_llm/endpoint.rs create mode 100644 desktop/src-tauri/src/mesh_llm/mod.rs create mode 100644 desktop/src-tauri/src/mesh_llm/nip98.rs create mode 100644 desktop/src-tauri/src/mesh_llm/offer.rs diff --git a/crates/sprout-core/src/lib.rs b/crates/sprout-core/src/lib.rs index a0395a060..cecc3a61a 100644 --- a/crates/sprout-core/src/lib.rs +++ b/crates/sprout-core/src/lib.rs @@ -20,6 +20,8 @@ pub mod filter; pub mod git_perms; /// Sprout kind number registry — custom event type constants. pub mod kind; +/// Mesh-LLM compute-offer envelope (kind:31990 event content). +pub mod mesh_llm; /// Network utilities — SSRF-safe IP classification. pub mod network; /// Agent observer frame helpers. diff --git a/crates/sprout-core/src/mesh_llm.rs b/crates/sprout-core/src/mesh_llm.rs new file mode 100644 index 000000000..72ec9568d --- /dev/null +++ b/crates/sprout-core/src/mesh_llm.rs @@ -0,0 +1,286 @@ +//! Mesh-LLM compute offer envelope (kind:31990 event content). +//! +//! Published by Sprout members willing to share their local LLM/compute with +//! the rest of the relay. Consumers (other Sprout members) subscribe to +//! kind:31990 events scoped to relay membership and pick an offer that +//! matches their request. +//! +//! # Schema +//! +//! The event content is a JSON-serialised [`MeshLlmOffer`]. The event itself +//! is a NIP-33 parameterized-replaceable event addressed by +//! `(pubkey, kind:31990, d_tag)` where `d_tag` is the [`MeshLlmOffer::d_tag`]. +//! This means a member can replace their own offer atomically (e.g. when the +//! VRAM cap changes or a model is loaded/unloaded) without leaking dangling +//! stale offers. +//! +//! # Trust model +//! +//! The signing pubkey of the kind:31990 event is the Nostr identity of the +//! offering member; the event flows through the existing NIP-43 fan-out, so +//! only relay members ever see it. The iroh [`endpoint_id`](MeshLlmOffer::endpoint_id) +//! is a separate ed25519 keypair under the same member's control — the +//! Nostr signature on the kind:31990 event is what binds those two +//! identities together. +//! +//! When a consumer connects to the offered iroh endpoint, the consumer's own +//! NIP-98 bearer (signed with its Nostr key, NOT its iroh key) is what the +//! receiving relay uses to gate admission. So the chain of trust is: +//! +//! - The 31990 event proves "Nostr pubkey N offers compute via iroh endpoint E". +//! - The NIP-98 bearer on the iroh connection proves "Nostr pubkey N' is the +//! connecting party". +//! - Sprout's [`check_relay_membership`] confirms N' is a relay member. +//! +//! There is no need to also bind N' ↔ iroh-client-endpoint cryptographically: +//! once the membership decision allows the connection, the QUIC stream itself +//! is end-to-end-encrypted between the two iroh endpoints. The offering side +//! sees only `(member-pubkey N', iroh-endpoint E')`, both authenticated. + +use serde::{Deserialize, Serialize}; + +/// The full content of a kind:31990 event. +/// +/// Serialized to JSON and placed in the event's `content` field. The event's +/// `d` tag should equal [`MeshLlmOffer::d_tag`] so the event is a stable +/// addressable replacement target. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MeshLlmOffer { + /// Schema version. Bumped on breaking changes. Current: `1`. + pub v: u32, + + /// Stable identifier for *this offering node* under the publisher's + /// pubkey. A member may publish multiple offers (e.g. one per host they + /// own, or one per GPU); each gets a distinct `d_tag`. + /// + /// MUST be ≤64 chars, ASCII alphanumeric + `-` + `_`. The same value + /// must be used as the kind:31990 event's `d` tag so replaces are + /// atomic. + pub d_tag: String, + + /// Iroh endpoint id (ed25519 public key, base32 z-base form as iroh + /// renders it) of the offering node's iroh endpoint. Consumers dial + /// this through an iroh `NodeAddr`. + pub endpoint_id: String, + + /// Iroh relay URL through which the offering endpoint is reachable. + /// + /// This is the *Sprout-hosted* iroh-relay URL — copied verbatim from + /// the publisher's view of NIP-11 `iroh_relay_url`. If multiple Sprout + /// relays are bridged into the same membership scope in the future, + /// this lets a consumer reach an offer behind a different host. + pub iroh_relay_url: String, + + /// Resource caps the offering side promises to honour for any single + /// consumer at a time. The publisher should re-publish (replacing the + /// previous event) whenever these change materially. + pub caps: ResourceCaps, + + /// Models this node is willing to serve. Empty list = "negotiate at + /// connect time"; non-empty = the consumer should pick one of these. + #[serde(default)] + pub models: Vec, + + /// Free-form opaque metadata field, reserved for future extensions + /// (e.g. region, accelerator type, presence-style state). + /// + /// Stored as `serde_json::Value` so additions don't require a schema + /// bump. `deny_unknown_fields` above keeps the *top-level* schema + /// strict; freeform extension lives here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extra: Option, +} + +/// Resource caps the offering side commits to for a single consumer. +/// +/// Caps are *per-consumer* upper bounds — the offering side may host +/// multiple concurrent consumers, each subject to these caps. The +/// `max_concurrency` field expresses how many concurrent consumers the node +/// will accept across all consumers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResourceCaps { + /// Max VRAM (megabytes) the offering side will commit to a single + /// request. `None` = no cap advertised (consumer decides whether to + /// proceed). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_vram_mb: Option, + + /// Max system RAM (megabytes) the offering side will commit to a + /// single request. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_ram_mb: Option, + + /// Max number of concurrent consumers the offering node will accept + /// across all currently-running requests. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_concurrency: Option, +} + +/// A single model the offering node is prepared to serve. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ModelOffer { + /// Model identifier. Convention: HuggingFace-style `org/name[:tag]`, + /// or `local:` for ad-hoc local files. Free-form string; + /// the consumer side is responsible for matching this against its own + /// requested model. + pub id: String, + + /// Optional human-readable label for UI surfaces. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + + /// Approximate context window this model serves (tokens). Used for + /// UI hints; not enforced. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_tokens: Option, +} + +impl MeshLlmOffer { + /// Maximum length of a `d_tag` string. Mirrors NIP-33's general rule + /// that `d` tags should be short and stable. + pub const MAX_D_TAG_LEN: usize = 64; + + /// Validate that a `d_tag` is well-formed: ≤64 chars, ASCII + /// alphanumeric / `-` / `_`. + pub fn is_valid_d_tag(d_tag: &str) -> bool { + !d_tag.is_empty() + && d_tag.len() <= Self::MAX_D_TAG_LEN + && d_tag + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + } + + /// Returns true if every required field is well-formed for publishing. + /// + /// This is a *publisher-side* sanity check; consumers should be + /// permissive in what they accept as long as serde-deserialization + /// succeeds. + pub fn is_publishable(&self) -> bool { + self.v == 1 + && Self::is_valid_d_tag(&self.d_tag) + && !self.endpoint_id.is_empty() + && !self.iroh_relay_url.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> MeshLlmOffer { + MeshLlmOffer { + v: 1, + d_tag: "node-1".to_string(), + endpoint_id: "1234abcd".to_string(), + iroh_relay_url: "https://relay.example.com/iroh".to_string(), + caps: ResourceCaps { + max_vram_mb: Some(24_000), + max_ram_mb: Some(64_000), + max_concurrency: Some(2), + }, + models: vec![ModelOffer { + id: "meta-llama/Llama-3-8B".to_string(), + label: Some("Llama 3 8B".to_string()), + context_tokens: Some(8192), + }], + extra: None, + } + } + + #[test] + fn round_trip_via_json() { + let offer = sample(); + let s = serde_json::to_string(&offer).expect("serialise"); + let back: MeshLlmOffer = serde_json::from_str(&s).expect("deserialise"); + assert_eq!(offer, back); + } + + #[test] + fn optional_caps_default_to_none() { + let s = r#"{ + "v": 1, + "d_tag": "x", + "endpoint_id": "abc", + "iroh_relay_url": "https://r/", + "caps": {} + }"#; + let offer: MeshLlmOffer = serde_json::from_str(s).expect("deserialise minimal"); + assert!(offer.caps.max_vram_mb.is_none()); + assert!(offer.caps.max_ram_mb.is_none()); + assert!(offer.caps.max_concurrency.is_none()); + assert!(offer.models.is_empty()); + } + + #[test] + fn unknown_top_level_field_rejected() { + // deny_unknown_fields catches schema drift. + let s = r#"{ + "v": 1, + "d_tag": "x", + "endpoint_id": "abc", + "iroh_relay_url": "https://r", + "caps": {}, + "wat": "lol" + }"#; + assert!(serde_json::from_str::(s).is_err()); + } + + #[test] + fn unknown_caps_field_rejected() { + let s = r#"{ + "v": 1, + "d_tag": "x", + "endpoint_id": "abc", + "iroh_relay_url": "https://r", + "caps": { "wat": 7 } + }"#; + assert!(serde_json::from_str::(s).is_err()); + } + + #[test] + fn extra_freeform_passes_through() { + let offer = MeshLlmOffer { + extra: Some(serde_json::json!({"region": "us-east", "gpu": "H100"})), + ..sample() + }; + let s = serde_json::to_string(&offer).unwrap(); + let back: MeshLlmOffer = serde_json::from_str(&s).unwrap(); + assert_eq!(offer, back); + } + + #[test] + fn d_tag_validation() { + assert!(MeshLlmOffer::is_valid_d_tag("node-1")); + assert!(MeshLlmOffer::is_valid_d_tag("a")); + assert!(MeshLlmOffer::is_valid_d_tag(&"a".repeat(64))); + assert!(!MeshLlmOffer::is_valid_d_tag("")); + assert!(!MeshLlmOffer::is_valid_d_tag(&"a".repeat(65))); + assert!(!MeshLlmOffer::is_valid_d_tag("node 1")); + assert!(!MeshLlmOffer::is_valid_d_tag("node/1")); + assert!(!MeshLlmOffer::is_valid_d_tag("nodé")); + } + + #[test] + fn is_publishable_rejects_bad_d_tag() { + let mut offer = sample(); + offer.d_tag = "bad tag with spaces".to_string(); + assert!(!offer.is_publishable()); + } + + #[test] + fn is_publishable_rejects_wrong_version() { + let mut offer = sample(); + offer.v = 2; + assert!(!offer.is_publishable()); + } + + #[test] + fn is_publishable_rejects_empty_endpoint() { + let mut offer = sample(); + offer.endpoint_id = String::new(); + assert!(!offer.is_publishable()); + } +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index b87b7273a..168b999e1 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -403,6 +403,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base58ck" version = "0.1.0" @@ -854,6 +860,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "combine" version = "4.6.7" @@ -911,6 +923,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -1154,6 +1175,44 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-pre.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.11.2", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -1200,6 +1259,26 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "data-encoding-macro" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" +dependencies = [ + "data-encoding", + "syn 1.0.109", +] + [[package]] name = "dbus" version = "0.9.11" @@ -1244,7 +1323,7 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", @@ -1266,10 +1345,12 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -1292,6 +1373,7 @@ dependencies = [ "block-buffer 0.12.0", "const-oid", "crypto-common 0.2.1", + "ctutils", ] [[package]] @@ -1445,6 +1527,31 @@ dependencies = [ "libm", ] +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "serdect", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-pre.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20449acd54b660981ae5caa2bcb56d1fe7f25f2e37a38ec507400fab034d4bb6" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.8" @@ -1570,6 +1677,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "field-offset" version = "0.3.6" @@ -1934,11 +2047,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -2201,6 +2316,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -2559,6 +2683,28 @@ dependencies = [ "serde", ] +[[package]] +name = "iroh-base" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2160a45265eba3bd290ce698f584c9b088bee47e518e9ec4460d5e5888ef660e" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "data-encoding-macro", + "derive_more 2.1.1", + "digest 0.11.2", + "ed25519-dalek", + "getrandom 0.4.2", + "n0-error", + "rand 0.10.1", + "serde", + "sha2 0.11.0", + "url", + "zeroize", + "zeroize_derive", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -2995,6 +3141,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "n0-error" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "223e946a84aa91644507a6b7865cfebbb9a231ace499041c747ab0fd30408212" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "565305a21e6b3bf26640ad98f05a0fda12d3ab4315394566b52a7bddb8b34828" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3649,7 +3816,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac", + "hmac 0.12.1", ] [[package]] @@ -5024,6 +5191,16 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -5136,6 +5313,12 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -5224,6 +5407,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sprout" version = "0.1.0" @@ -5239,6 +5433,7 @@ dependencies = [ "futures-util", "hex", "infer", + "iroh-base", "libc", "neteq", "nostr 0.36.0", @@ -5253,6 +5448,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "sherpa-onnx", + "sprout-auth", "sprout-core", "sprout-persona", "sprout-sdk", @@ -5270,6 +5466,7 @@ dependencies = [ "tauri-plugin-websocket", "tauri-plugin-window-state", "tempfile", + "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", "tokio-util", @@ -5280,17 +5477,37 @@ dependencies = [ "zip 2.4.2", ] +[[package]] +name = "sprout-auth" +version = "0.1.0" +dependencies = [ + "hex", + "nostr 0.36.0", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "sprout-core", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid", +] + [[package]] name = "sprout-core" version = "0.1.0" dependencies = [ "chrono", "hex", + "hmac 0.13.0", "nostr 0.36.0", "percent-encoding", "rand 0.10.1", "serde", "serde_json", + "sha2 0.11.0", "subtle", "thiserror 2.0.18", "url", @@ -6301,6 +6518,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -8085,7 +8303,7 @@ dependencies = [ "displaydoc", "flate2", "getrandom 0.3.4", - "hmac", + "hmac 0.12.1", "indexmap 2.14.0", "lzma-rs", "memchr", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index d880acff2..4e052432d 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -52,9 +52,11 @@ nostr-compat = { package = "nostr", version = "0.36" } zeroize = "1" reqwest = { version = "0.13", features = ["json", "query", "stream"] } url = "2" +sprout-auth = { path = "../../crates/sprout-auth" } sprout-core = { path = "../../crates/sprout-core" } sprout-persona = { path = "../../crates/sprout-persona" } sprout-sdk = { path = "../../crates/sprout-sdk" } +iroh-base = { version = "=1.0.0-rc.0", features = ["key"] } base64 = "0.22" sha2 = "0.11" tar = "0.4" @@ -73,5 +75,6 @@ earshot = "1.0" rubato = "2.0" audioadapter-buffers = "3.0" tempfile = "3" +thiserror = "2" [dev-dependencies] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0443243a0..81a699963 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod events; mod huddle; mod managed_agents; mod media_proxy; +mod mesh_llm; mod migration; mod models; pub mod nostr_convert; diff --git a/desktop/src-tauri/src/mesh_llm/endpoint.rs b/desktop/src-tauri/src/mesh_llm/endpoint.rs new file mode 100644 index 000000000..df5a34b0d --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/endpoint.rs @@ -0,0 +1,145 @@ +//! Iroh endpoint keypair: persisted per Sprout install. +//! +//! The user's Nostr identity (`identity.key`) is separate from the iroh +//! endpoint identity. The Nostr key signs kind:31990 offers and the NIP-98 +//! admission bearer; the iroh key proves possession of the iroh `EndpointId` +//! during QUIC handshake. The kind:31990 event's Nostr signature binds the +//! two identities together — anyone who trusts the Nostr pubkey can trust +//! the advertised endpoint id, because nobody else could have signed that +//! offer. +//! +//! We deliberately do **not** derive the iroh key from the Nostr key: +//! +//! - It would couple key rotation: rotating the Nostr key would silently +//! change the iroh endpoint id, breaking active offers. +//! - It would force a particular HKDF over the Nostr seckey, picking a new +//! custody convention nobody else implements. +//! - The iroh key is generated once, never leaves the desktop, and is +//! already inside the same Tauri sandbox as `identity.key`. Two files, +//! one trust boundary. + +use std::path::{Path, PathBuf}; + +use iroh_base::SecretKey; +use tauri::{AppHandle, Manager}; + +const KEY_FILENAME: &str = "mesh_iroh.key"; + +/// Errors loading or creating the iroh endpoint keypair. +#[derive(Debug, thiserror::Error)] +pub enum EndpointKeyError { + /// Couldn't determine the Tauri app data dir. + #[error("app data dir: {0}")] + AppDataDir(String), + /// Filesystem I/O failure. + #[error("filesystem: {0}")] + Io(String), + /// On-disk key file exists but is malformed. + #[error("malformed key file: {0}")] + MalformedKeyFile(String), +} + +/// Resolve the iroh endpoint key file path under the Tauri app data dir. +fn key_path(app: &AppHandle) -> Result { + let data_dir = app + .path() + .app_data_dir() + .map_err(|e| EndpointKeyError::AppDataDir(e.to_string()))?; + std::fs::create_dir_all(&data_dir).map_err(|e| EndpointKeyError::Io(e.to_string()))?; + Ok(data_dir.join(KEY_FILENAME)) +} + +/// Load the persisted iroh endpoint keypair, generating + saving one on +/// first run. Mirrors the pattern in [`crate::app_state::resolve_persisted_identity`]. +/// +/// File format: 32 raw secret-key bytes encoded as lower-case hex on a single +/// line. Matches what `iroh_base::SecretKey`'s `FromStr` accepts. +pub fn load_or_create_endpoint_key(app: &AppHandle) -> Result { + let path = key_path(app)?; + + if path.exists() { + match load_key_file(&path) { + Ok(k) => return Ok(k), + Err(e) => { + // Quarantine corrupt files so we never overwrite a usable + // backup — same pattern as `identity.key`. + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let bad = path.with_extension(format!("bad.{ts}")); + let _ = std::fs::rename(&path, &bad); + eprintln!( + "sprout-desktop: corrupt mesh_iroh.key ({e}), quarantined to {}", + bad.display(), + ); + } + } + } + + let key = SecretKey::generate(); + save_key_file(&path, &key)?; + eprintln!( + "sprout-desktop: generated and saved mesh iroh endpoint pubkey {}", + key.public(), + ); + Ok(key) +} + +fn load_key_file(path: &Path) -> Result { + let content = + std::fs::read_to_string(path).map_err(|e| EndpointKeyError::Io(e.to_string()))?; + let trimmed = content.trim(); + trimmed + .parse::() + .map_err(|e| EndpointKeyError::MalformedKeyFile(e.to_string())) +} + +fn save_key_file(path: &Path, key: &SecretKey) -> Result<(), EndpointKeyError> { + let bytes = key.to_bytes(); + let hex = hex::encode(bytes); + // Atomic write: write to a temp file in the same dir, fsync, rename. + let dir = path + .parent() + .ok_or_else(|| EndpointKeyError::Io("key path has no parent".to_string()))?; + let tmp = tempfile::NamedTempFile::new_in(dir) + .map_err(|e| EndpointKeyError::Io(format!("temp file: {e}")))?; + std::fs::write(tmp.path(), hex.as_bytes()) + .map_err(|e| EndpointKeyError::Io(format!("write temp: {e}")))?; + tmp.persist(path) + .map_err(|e| EndpointKeyError::Io(format!("rename temp: {e}")))?; + // No fsync of the directory here — matches the existing identity.key path. + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + /// Round-trip a generated key through the save/load functions. + #[test] + fn round_trip_save_load() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("mesh_iroh.key"); + let original = SecretKey::generate(); + save_key_file(&path, &original).expect("save"); + let loaded = load_key_file(&path).expect("load"); + assert_eq!(original.to_bytes(), loaded.to_bytes()); + } + + /// A truncated/corrupted file is rejected with `MalformedKeyFile`. + #[test] + fn corrupt_file_returns_malformed() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("mesh_iroh.key"); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(b"not a valid hex key").unwrap(); + drop(f); + let err = load_key_file(&path).expect_err("should fail"); + match err { + EndpointKeyError::MalformedKeyFile(_) => {} + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs new file mode 100644 index 000000000..e6a4491ed --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -0,0 +1,24 @@ +//! Mesh-LLM client: discover, dial, and publish kind:31990 offers. +//! +//! This module is the desktop-side counterpart to `sprout-relay`'s embedded +//! iroh-relay (see `crates/sprout-relay/src/iroh_relay.rs`). The relay gates +//! admission with NIP-98 + relay membership; this module signs that bearer +//! token, dials offers advertised under kind:31990, and publishes our own +//! offer when the user enables compute-sharing. +//! +//! ## Submodules +//! +//! - [`endpoint`]: long-lived iroh endpoint keypair persisted at +//! `{app_data_dir}/mesh_iroh.key`. +//! - [`nip98`]: build the NIP-98 bearer event signed with the user's Nostr +//! key for a given canonical relay URL. +//! - [`offer`]: load/save the user's mesh-LLM offer preferences +//! (VRAM/RAM/concurrency caps, models). + +pub mod endpoint; +pub mod nip98; +pub mod offer; + +pub use endpoint::{load_or_create_endpoint_key, EndpointKeyError}; +pub use nip98::{build_nip98_bearer, Nip98BearerError}; +pub use offer::{ComputeSharingPrefs, OfferPrefsError}; diff --git a/desktop/src-tauri/src/mesh_llm/nip98.rs b/desktop/src-tauri/src/mesh_llm/nip98.rs new file mode 100644 index 000000000..1e821459a --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/nip98.rs @@ -0,0 +1,105 @@ +//! NIP-98 bearer token builder for iroh-relay admission. +//! +//! Signs a kind:27235 event over the canonical iroh-relay URL using the +//! user's Nostr identity, then base64-encodes the event JSON. The receiving +//! relay's `sprout_relay::iroh_relay` access callback decodes + verifies +//! this exact bearer string. +//! +//! Both sides use the same `sprout_auth::nip98_canonical_url` helper, so +//! path-prefix / trailing-slash / localhost-vs-127.0.0.1 drift cannot +//! create undebuggable per-connection denials. + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; + +const IROH_RELAY_PATH: &str = "/relay"; +const NIP98_METHOD: &str = "GET"; + +/// Errors produced while building a NIP-98 bearer. +#[derive(Debug, thiserror::Error)] +pub enum Nip98BearerError { + /// `iroh_relay_url` from NIP-11 wasn't a parseable URL. + #[error("invalid iroh relay URL: {0}")] + InvalidUrl(String), + /// `nostr` library failed to construct/sign the event. + #[error("event signing failed: {0}")] + Sign(String), + /// Tag construction failed (should never happen for static "u"/"method"). + #[error("tag construction failed: {0}")] + Tag(String), +} + +/// Build the `Authorization: Bearer ` value for an iroh-relay +/// admission request. +/// +/// `iroh_relay_public_url` is the value taken verbatim from the target +/// relay's NIP-11 `iroh_relay_url` field. We canonicalise it the same way +/// the relay does before signing the `u` tag. +pub fn build_nip98_bearer( + keys: &Keys, + iroh_relay_public_url: &str, +) -> Result { + let canonical = sprout_auth::nip98_canonical_url(iroh_relay_public_url, IROH_RELAY_PATH) + .ok_or_else(|| Nip98BearerError::InvalidUrl(iroh_relay_public_url.to_string()))?; + + let tags = vec![ + Tag::parse(["u", &canonical]).map_err(|e| Nip98BearerError::Tag(e.to_string()))?, + Tag::parse(["method", NIP98_METHOD]).map_err(|e| Nip98BearerError::Tag(e.to_string()))?, + ]; + + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys) + .map_err(|e| Nip98BearerError::Sign(e.to_string()))?; + + let json = event.as_json(); + Ok(STANDARD.encode(json)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signs_for_canonical_url() { + let keys = Keys::generate(); + let token = + build_nip98_bearer(&keys, "https://relay.example.com/iroh").expect("build bearer"); + // Round-trip through base64 -> JSON to confirm the event has the + // canonical URL in its `u` tag. + let bytes = STANDARD.decode(&token).expect("base64 decode"); + let json = String::from_utf8(bytes).expect("utf8"); + assert!( + json.contains("\"u\""), + "bearer event should carry `u` tag: {json}", + ); + assert!( + json.contains("https://relay.example.com/iroh/relay"), + "bearer event should canonicalise the URL: {json}", + ); + assert!( + json.contains("\"method\""), + "bearer event should carry `method` tag", + ); + } + + #[test] + fn rejects_unparseable_url() { + let keys = Keys::generate(); + let err = build_nip98_bearer(&keys, "definitely not a url").expect_err("should fail"); + match err { + Nip98BearerError::InvalidUrl(_) => {} + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn different_keys_produce_different_bearers() { + let a = Keys::generate(); + let b = Keys::generate(); + let ta = build_nip98_bearer(&a, "https://relay.example.com/iroh").unwrap(); + let tb = build_nip98_bearer(&b, "https://relay.example.com/iroh").unwrap(); + assert_ne!(ta, tb); + } +} diff --git a/desktop/src-tauri/src/mesh_llm/offer.rs b/desktop/src-tauri/src/mesh_llm/offer.rs new file mode 100644 index 000000000..441b0accd --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/offer.rs @@ -0,0 +1,192 @@ +//! Persisted compute-sharing preferences (the avatar-menu sliders). +//! +//! When the user turns on compute sharing and dials the VRAM/RAM/concurrency +//! sliders in the bottom-left avatar menu, those preferences live in +//! `{app_data_dir}/mesh_offer.json`. The publisher reads this file when it +//! builds a kind:31990 event; the settings UI reads + writes it via Tauri +//! commands. +//! +//! Keeping the prefs as a plain JSON file (rather than baking them into the +//! kind:31990 event directly) lets the user toggle sharing without +//! republishing on every restart and makes the file inspectable for support. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use sprout_core::mesh_llm::{MeshLlmOffer, ModelOffer, ResourceCaps}; +use tauri::{AppHandle, Manager}; + +const OFFER_FILENAME: &str = "mesh_offer.json"; +const DEFAULT_D_TAG: &str = "default"; + +/// Errors loading or saving offer preferences. +#[derive(Debug, thiserror::Error)] +pub enum OfferPrefsError { + /// Couldn't determine the Tauri app data dir. + #[error("app data dir: {0}")] + AppDataDir(String), + /// Filesystem I/O failure. + #[error("filesystem: {0}")] + Io(String), + /// JSON parse / serialize failure. + #[error("json: {0}")] + Json(String), +} + +/// Persisted compute-sharing preferences. +/// +/// `enabled = false` is the default; the publisher must skip publishing in +/// that case and **must delete any previously-published offer** (NIP-09 or +/// kind:31990 with empty content per NIP-33's replace-with-empty convention). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ComputeSharingPrefs { + /// Whether the user has opted in to sharing compute. Default: `false`. + pub enabled: bool, + + /// Caps the user wants to advertise. `None` on a field = "no cap"; the + /// publisher passes `Some(0)` through unchanged because the schema + /// allows it (consumers should treat 0 as "explicit zero"). + pub caps: ResourceCaps, + + /// Models the user wants to advertise. May be empty. + pub models: Vec, + + /// Persistent `d_tag` for the user's offer. Generated once on first + /// enable and re-used so replaces target the same address. + pub d_tag: String, +} + +impl Default for ComputeSharingPrefs { + fn default() -> Self { + Self { + enabled: false, + caps: ResourceCaps { + max_vram_mb: None, + max_ram_mb: None, + max_concurrency: Some(1), + }, + models: vec![], + d_tag: DEFAULT_D_TAG.to_string(), + } + } +} + +impl ComputeSharingPrefs { + /// Builds the kind:31990 offer envelope to publish. Returns `None` if + /// sharing is disabled; the publisher should then *delete* any prior + /// offer rather than calling this. + pub fn build_offer( + &self, + endpoint_id: &str, + iroh_relay_url: &str, + ) -> Option { + if !self.enabled { + return None; + } + Some(MeshLlmOffer { + v: 1, + d_tag: self.d_tag.clone(), + endpoint_id: endpoint_id.to_string(), + iroh_relay_url: iroh_relay_url.to_string(), + caps: self.caps.clone(), + models: self.models.clone(), + extra: None, + }) + } +} + +fn prefs_path(app: &AppHandle) -> Result { + let data_dir = app + .path() + .app_data_dir() + .map_err(|e| OfferPrefsError::AppDataDir(e.to_string()))?; + std::fs::create_dir_all(&data_dir).map_err(|e| OfferPrefsError::Io(e.to_string()))?; + Ok(data_dir.join(OFFER_FILENAME)) +} + +/// Load persisted prefs; returns [`ComputeSharingPrefs::default`] if the file +/// is absent. On parse errors, returns the error verbatim — callers should +/// surface it in the settings UI rather than silently resetting. +pub fn load_prefs(app: &AppHandle) -> Result { + let path = prefs_path(app)?; + if !path.exists() { + return Ok(ComputeSharingPrefs::default()); + } + let content = std::fs::read_to_string(&path).map_err(|e| OfferPrefsError::Io(e.to_string()))?; + serde_json::from_str(&content).map_err(|e| OfferPrefsError::Json(e.to_string())) +} + +/// Atomically replace the on-disk prefs file. +pub fn save_prefs(app: &AppHandle, prefs: &ComputeSharingPrefs) -> Result<(), OfferPrefsError> { + let path = prefs_path(app)?; + let dir = path + .parent() + .ok_or_else(|| OfferPrefsError::Io("prefs path has no parent".to_string()))?; + let json = serde_json::to_string_pretty(prefs).map_err(|e| OfferPrefsError::Json(e.to_string()))?; + let tmp = tempfile::NamedTempFile::new_in(dir) + .map_err(|e| OfferPrefsError::Io(format!("temp file: {e}")))?; + std::fs::write(tmp.path(), json.as_bytes()) + .map_err(|e| OfferPrefsError::Io(format!("write temp: {e}")))?; + tmp.persist(&path) + .map_err(|e| OfferPrefsError::Io(format!("rename temp: {e}")))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_disabled() { + let prefs = ComputeSharingPrefs::default(); + assert!(!prefs.enabled); + assert_eq!(prefs.caps.max_concurrency, Some(1)); + assert_eq!(prefs.d_tag, DEFAULT_D_TAG); + } + + #[test] + fn build_offer_returns_none_when_disabled() { + let prefs = ComputeSharingPrefs::default(); + assert!( + prefs + .build_offer("endpoint", "https://relay/iroh") + .is_none() + ); + } + + #[test] + fn build_offer_returns_envelope_when_enabled() { + let prefs = ComputeSharingPrefs { + enabled: true, + ..Default::default() + }; + let offer = prefs + .build_offer("endpoint-id-hex", "https://relay.example.com/iroh") + .expect("offer"); + assert_eq!(offer.endpoint_id, "endpoint-id-hex"); + assert_eq!(offer.iroh_relay_url, "https://relay.example.com/iroh"); + assert!(offer.is_publishable()); + } + + /// Round-trip prefs through serde so the on-disk format stays stable. + #[test] + fn round_trip_via_json() { + let prefs = ComputeSharingPrefs { + enabled: true, + caps: ResourceCaps { + max_vram_mb: Some(8192), + max_ram_mb: Some(16_000), + max_concurrency: Some(3), + }, + models: vec![ModelOffer { + id: "qwen/Qwen2.5-7B-Instruct".to_string(), + label: Some("Qwen 2.5 7B".to_string()), + context_tokens: Some(32_768), + }], + d_tag: "node-laptop".to_string(), + }; + let s = serde_json::to_string(&prefs).unwrap(); + let back: ComputeSharingPrefs = serde_json::from_str(&s).unwrap(); + assert_eq!(prefs, back); + } +} 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 06/14] 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(_)) + )); + } +} From d8ca1a9c3e70d2b017236b2a619dcc61cf4f47db Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 16:59:57 -0400 Subject: [PATCH 07/14] mesh-llm: B7 Tauri commands surface desktop/src-tauri/src/commands/mesh_llm.rs (new): - mesh_get_endpoint_id(app) -> { endpoint_id }: creates the persisted iroh keypair on first call; returns the canonical Display form of the public key so the UI can show 'this device' identity. - mesh_get_sharing_prefs(app) -> ComputeSharingPrefs: reads the persisted prefs file (defaults applied when absent). - mesh_set_sharing_prefs(app, prefs) -> (): atomic write-through. - mesh_relay_iroh_url(state, relay_ws_url) -> Option: probes the relay's NIP-11 doc for iroh_relay_url; returns None gracefully when the relay doesn't advertise mesh-LLM. All four registered in lib.rs's tauri::generate_handler! block. Frontend hooks land in C1 (avatar-menu MeshComputeSettingsCard). Cleanup: drop wildcard re-exports from mesh_llm/mod.rs so unused publisher/dialer helpers don't trip top-level dead-code lints before B5/B6 are wired in. cargo check passes; clippy + fmt clean (relay-side workspace). Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- desktop/src-tauri/src/commands/mesh_llm.rs | 72 ++++++++++++++++++++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 4 ++ desktop/src-tauri/src/mesh_llm/mod.rs | 12 ++-- 4 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 desktop/src-tauri/src/commands/mesh_llm.rs diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs new file mode 100644 index 000000000..cca4e6e24 --- /dev/null +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -0,0 +1,72 @@ +//! Tauri commands for the mesh-LLM frontend surface. +//! +//! All commands deal with *the local user's own* mesh-LLM state: +//! - the persisted iroh endpoint id, +//! - the persisted compute-sharing preferences (the avatar-menu sliders), +//! - explicit toggle/save calls invoked when the user changes the prefs. +//! +//! Discovering and connecting to *other* members' offers happens through the +//! existing relay WebSocket pipeline, not these commands. + +use serde::Serialize; +use tauri::{AppHandle, State}; + +use crate::app_state::AppState; +use crate::mesh_llm; + +/// Result type for mesh-LLM commands: errors are surfaced as user-facing +/// strings by the frontend. +type CmdResult = Result; + +/// Stable identifier of the local iroh endpoint, in iroh's canonical +/// Display form. Returned to the frontend so the user can see *which* +/// machine identity they're publishing under (useful when one user has +/// multiple devices each running Sprout). +#[derive(Debug, Clone, Serialize)] +pub struct MeshEndpointInfo { + /// Iroh endpoint id (= public key) as displayed by `iroh-base`. + pub endpoint_id: String, +} + +/// Returns the local mesh-LLM iroh endpoint id, creating + persisting the +/// keypair on first call. +#[tauri::command] +pub fn mesh_get_endpoint_id(app: AppHandle) -> CmdResult { + let key = mesh_llm::load_or_create_endpoint_key(&app).map_err(|e| e.to_string())?; + Ok(MeshEndpointInfo { + endpoint_id: key.public().to_string(), + }) +} + +/// Returns the persisted compute-sharing preferences for the avatar menu. +#[tauri::command] +pub fn mesh_get_sharing_prefs(app: AppHandle) -> CmdResult { + mesh_llm::offer::load_prefs(&app).map_err(|e| e.to_string()) +} + +/// Replaces the persisted compute-sharing preferences. The caller is +/// responsible for republishing or deleting the kind:31990 offer to reflect +/// the change — this command only touches local state. +#[tauri::command] +pub fn mesh_set_sharing_prefs( + app: AppHandle, + prefs: mesh_llm::ComputeSharingPrefs, +) -> CmdResult<()> { + mesh_llm::offer::save_prefs(&app, &prefs).map_err(|e| e.to_string()) +} + +/// Probe the connected relay's NIP-11 for an `iroh_relay_url`. +/// +/// Returns: +/// - `Ok(Some(url))` if the relay advertises one, +/// - `Ok(None)` if it doesn't, or if the relay is unreachable / malformed. +/// - `Err(_)` only for caller-side errors (e.g. bad WS URL shape). +#[tauri::command] +pub async fn mesh_relay_iroh_url( + _state: State<'_, AppState>, + relay_ws_url: String, +) -> CmdResult> { + mesh_llm::fetch_iroh_relay_url(&relay_ws_url) + .await + .map_err(|e| e.to_string()) +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 2bc5062f5..1d5fc6be2 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ mod export_util; mod identity; mod media; mod media_download; +mod mesh_llm; mod messages; pub mod pairing; mod personas; @@ -32,6 +33,7 @@ pub use dms::*; pub use identity::*; pub use media::*; pub use media_download::*; +pub use mesh_llm::*; pub use messages::*; pub use pairing::*; pub use personas::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 81a699963..96e618fba 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -455,6 +455,10 @@ pub fn run() { get_relay_ws_url, get_relay_http_url, get_media_proxy_port, + mesh_get_endpoint_id, + mesh_get_sharing_prefs, + mesh_set_sharing_prefs, + mesh_relay_iroh_url, discover_acp_providers, discover_managed_agent_prereqs, sign_event, diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index 1790bb5cd..40ed11dc6 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -20,7 +20,11 @@ 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}; +// Wildcard re-exports are deliberately avoided so that adding an +// unused-by-design helper to a submodule (e.g. a publisher that hasn't been +// wired yet) doesn't trip dead-code lints at the top level. Callers reach +// into the submodules directly until a public API surface stabilises. + +pub use endpoint::load_or_create_endpoint_key; +pub use nip11::fetch_iroh_relay_url; +pub use offer::ComputeSharingPrefs; From e6b968a9de4df6ac8e67e608cdeb609446e534c1 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 17:03:20 -0400 Subject: [PATCH 08/14] mesh-llm: C1 MeshComputeSettingsCard desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx (new): - Toggle: 'Share this machine's compute' (master switch). - Three numeric inputs: max VRAM (MB), max RAM (MB), concurrent peers. Empty = no cap, validated to non-negative integers. - Displays the local iroh endpoint id (canonical Display form) so the user knows which device identity is publishing. - All state persisted through the mesh_set_sharing_prefs Tauri command; loads via mesh_get_sharing_prefs + mesh_get_endpoint_id on mount. - Error and saving states surfaced inline. Registered as a new 'compute' SettingsSection in SettingsPanels.tsx between 'agents' and 'channel-templates', with a Cpu icon. Reached through the existing avatar-menu -> Settings flow (no popover restructuring needed for the MVP). desktop typecheck (tsc --noEmit): clean. desktop biome check: clean. UX (matches Tyler's [1]): - avatar bottom-left -> ProfilePopover -> Settings -> Share compute - one switch + three caps; the offering side decides everything. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- .../settings/ui/MeshComputeSettingsCard.tsx | 233 ++++++++++++++++++ .../features/settings/ui/SettingsPanels.tsx | 10 + 2 files changed, 243 insertions(+) create mode 100644 desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx diff --git a/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx new file mode 100644 index 000000000..911cdc0d2 --- /dev/null +++ b/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx @@ -0,0 +1,233 @@ +import * as React from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { Cpu } from "lucide-react"; + +import { Switch } from "@/shared/ui/switch"; +import { Input } from "@/shared/ui/input"; + +// --------------------------------------------------------------------------- +// Types matching the Rust mesh_llm::ComputeSharingPrefs / ResourceCaps shape. +// --------------------------------------------------------------------------- + +interface ResourceCaps { + max_vram_mb: number | null; + max_ram_mb: number | null; + max_concurrency: number | null; +} + +interface ModelOffer { + id: string; + label?: string | null; + context_tokens?: number | null; +} + +interface ComputeSharingPrefs { + enabled: boolean; + caps: ResourceCaps; + models: ModelOffer[]; + d_tag: string; +} + +interface MeshEndpointInfo { + endpoint_id: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Parse a string into Some(n) when it represents a positive integer, or +/// None to clear the cap. Empty string also clears. +function parseCap(raw: string): number | null { + if (raw.trim() === "") return null; + const n = Number.parseInt(raw, 10); + if (Number.isFinite(n) && n >= 0) return n; + return null; +} + +function formatCap(value: number | null): string { + return value == null ? "" : String(value); +} + +// --------------------------------------------------------------------------- +// Card +// --------------------------------------------------------------------------- + +export function MeshComputeSettingsCard() { + const [prefs, setPrefs] = React.useState(null); + const [endpoint, setEndpoint] = React.useState(null); + const [error, setError] = React.useState(null); + const [saving, setSaving] = React.useState(false); + + // Load the persisted prefs + the iroh endpoint identity on mount. + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const [p, e] = await Promise.all([ + invoke("mesh_get_sharing_prefs"), + invoke("mesh_get_endpoint_id"), + ]); + if (!cancelled) { + setPrefs(p); + setEndpoint(e); + } + } catch (e) { + if (!cancelled) setError(String(e)); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const persist = React.useCallback(async (next: ComputeSharingPrefs) => { + setSaving(true); + setError(null); + try { + await invoke("mesh_set_sharing_prefs", { prefs: next }); + setPrefs(next); + } catch (e) { + setError(String(e)); + } finally { + setSaving(false); + } + }, []); + + if (!prefs) { + return ( +
+
+

+ Share compute +

+

+ {error ?? "Loading mesh-LLM preferences…"} +

+
+
+ ); + } + + const updateCap = (field: keyof ResourceCaps, raw: string) => { + persist({ + ...prefs, + caps: { ...prefs.caps, [field]: parseCap(raw) }, + }); + }; + + return ( +
+
+

Share compute

+

+ When enabled, other members of this relay can run agents on this + machine using the limits you set below. Your relay membership is the + only gate — there is no signup or external account. +

+
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ {/* ── Master toggle ────────────────────────────────────────── */} +
+
+ +

+ Publishes a kind:31990 compute-offer event when on; deletes it + when off. +

+
+ + persist({ ...prefs, enabled: checked }) + } + /> +
+ + {/* ── Caps ─────────────────────────────────────────────────── */} +
+ + + Limits per request + + +
+
+ + updateCap("max_vram_mb", e.target.value)} + placeholder="No limit" + value={formatCap(prefs.caps.max_vram_mb)} + /> +
+
+ + updateCap("max_ram_mb", e.target.value)} + placeholder="No limit" + value={formatCap(prefs.caps.max_ram_mb)} + /> +
+
+ + updateCap("max_concurrency", e.target.value)} + placeholder="1" + value={formatCap(prefs.caps.max_concurrency)} + /> +
+
+
+ + {/* ── Identity ────────────────────────────────────────────── */} + {endpoint ? ( +
+ + This device's iroh endpoint: + {" "} + {endpoint.endpoint_id} +
+ ) : null} +
+
+ ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 661926de5..786352000 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -3,6 +3,7 @@ import { BellRing, Bot, Check, + Cpu, Download, Keyboard, LayoutTemplate, @@ -31,6 +32,7 @@ import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader"; import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard"; import { DoctorSettingsPanel } from "./DoctorSettingsPanel"; import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard"; +import { MeshComputeSettingsCard } from "./MeshComputeSettingsCard"; import { MobilePairingCard } from "./MobilePairingCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; @@ -41,6 +43,7 @@ export type SettingsSection = | "profile" | "notifications" | "agents" + | "compute" | "channel-templates" | "appearance" | "shortcuts" @@ -87,6 +90,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Agents", icon: Bot, }, + { + value: "compute", + label: "Share compute", + icon: Cpu, + }, { value: "channel-templates", label: "Templates", @@ -282,6 +290,8 @@ export function renderSettingsSection( ); case "agents": return ; + case "compute": + return ; case "channel-templates": return ; case "appearance": From c5ec624ebcef1cd22c04205fb86efbe07da25d0d Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 17:06:52 -0400 Subject: [PATCH 09/14] mesh-llm: B6 kind:31990 publisher + settings-card publish-on-save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit desktop/src-tauri/src/commands/mesh_llm.rs: new mesh_publish_offer command. Reads persisted prefs and the local iroh endpoint id; on enabled=true, builds a kind:31990 event with the JSON-serialised MeshLlmOffer envelope and a 'd' tag matching prefs.d_tag, then signs + POSTs via the existing submit_event pipeline (NIP-98 to /events). On enabled=false, publishes the *same address* with empty content — NIP-33's 'delete by replace' idiom — so consumers know the offer has been withdrawn. PublishOfferResult.published_offer reports which path was taken. desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx: persist() now follows save-prefs with a relay capability probe and (when the relay advertises iroh_relay_url) a publish call. If the relay doesn't support mesh-LLM, prefs are still saved locally and the UI surfaces a specific 'this relay does not advertise iroh_relay_url' message rather than a confusing 'publish failed'. The user-facing flow is now: open settings -> Share compute -> toggle on -> a kind:31990 event hits the relay, NIP-43-fanned-out to other members. Toggling off publishes the empty-content replacement. Tests: 208 sprout-relay, 174 sprout-core, 11 desktop mesh_llm — all unchanged-and-pass. desktop typecheck + biome clean. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- desktop/src-tauri/src/commands/mesh_llm.rs | 81 ++++++++++++++++++- desktop/src-tauri/src/lib.rs | 1 + .../settings/ui/MeshComputeSettingsCard.tsx | 17 ++++ 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index cca4e6e24..321d77889 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -3,16 +3,20 @@ //! All commands deal with *the local user's own* mesh-LLM state: //! - the persisted iroh endpoint id, //! - the persisted compute-sharing preferences (the avatar-menu sliders), -//! - explicit toggle/save calls invoked when the user changes the prefs. +//! - explicit toggle/save calls invoked when the user changes the prefs, +//! - publishing / deleting the user's kind:31990 compute-offer event. //! -//! Discovering and connecting to *other* members' offers happens through the -//! existing relay WebSocket pipeline, not these commands. +//! Discovering *other* members' offers happens through the relay +//! WebSocket pipeline already exposed by `relayClientSession.ts`. +use nostr::{EventBuilder, Kind, Tag}; use serde::Serialize; +use sprout_core::kind::KIND_MESH_LLM_DISCOVERY; use tauri::{AppHandle, State}; use crate::app_state::AppState; use crate::mesh_llm; +use crate::relay::submit_event; /// Result type for mesh-LLM commands: errors are surfaced as user-facing /// strings by the frontend. @@ -70,3 +74,74 @@ pub async fn mesh_relay_iroh_url( .await .map_err(|e| e.to_string()) } + +// ── Publisher ────────────────────────────────────────────────────────────── + +/// Result of `mesh_publish_offer` — surface enough state so the frontend +/// can show the user *which* offer just went on the wire. +#[derive(Debug, Clone, Serialize)] +pub struct PublishOfferResult { + /// `event_id` returned by the relay on accept. + pub event_id: String, + /// `true` if compute-sharing is currently enabled. When false, the + /// command publishes an *empty-content* kind:31990 event at the same + /// `(pubkey, d_tag)` address, which under NIP-33 is the canonical way + /// to indicate "this offer is no longer active". Consumers that observe + /// the empty content drop the offer from their cache. + pub published_offer: bool, +} + +/// Publish (or revoke) the user's kind:31990 compute-offer event. +/// +/// Reads the current prefs from disk and the local iroh endpoint id. If +/// `enabled = true`, builds a kind:31990 with the offer envelope content +/// and the matching `d` tag; signs and POSTs via the existing +/// [`submit_event`] pipeline (NIP-98-authenticated to the configured relay). +/// If `enabled = false`, publishes the *same address* with empty content +/// to tell consumers the offer has been retired. +/// +/// `iroh_relay_url` should be the relay's NIP-11 `iroh_relay_url` (fetched +/// via [`mesh_relay_iroh_url`] at session start). The offer envelope +/// carries it so consumers know where to dial. +#[tauri::command] +pub async fn mesh_publish_offer( + app: AppHandle, + state: State<'_, AppState>, + iroh_relay_url: String, +) -> CmdResult { + // Load prefs + endpoint key. These are sync; complete before any await. + let prefs = mesh_llm::offer::load_prefs(&app).map_err(|e| e.to_string())?; + let endpoint_key = + mesh_llm::load_or_create_endpoint_key(&app).map_err(|e| e.to_string())?; + let endpoint_id_str = endpoint_key.public().to_string(); + + let d_tag = prefs.d_tag.clone(); + let d_tag_tag = Tag::parse(["d", &d_tag]).map_err(|e| format!("d tag: {e}"))?; + + let (content, published_offer) = if prefs.enabled { + let offer = prefs + .build_offer(&endpoint_id_str, &iroh_relay_url) + .ok_or_else(|| { + "build_offer returned None despite enabled=true (logic bug)".to_string() + })?; + if !offer.is_publishable() { + return Err("offer envelope failed publishable check".to_string()); + } + let json = serde_json::to_string(&offer).map_err(|e| format!("serialise: {e}"))?; + (json, true) + } else { + // NIP-33 "delete by replace": same (pubkey, kind, d) address, empty + // content. Consumers must treat an empty content as 'offer + // withdrawn'. + (String::new(), false) + }; + + let builder = EventBuilder::new(Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16), content) + .tags(vec![d_tag_tag]); + + let res = submit_event(builder, &state).await?; + Ok(PublishOfferResult { + event_id: res.event_id, + published_offer, + }) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 96e618fba..8e98f90f4 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -459,6 +459,7 @@ pub fn run() { mesh_get_sharing_prefs, mesh_set_sharing_prefs, mesh_relay_iroh_url, + mesh_publish_offer, discover_acp_providers, discover_managed_agent_prereqs, sign_event, diff --git a/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx index 911cdc0d2..17f6510df 100644 --- a/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx @@ -85,8 +85,25 @@ export function MeshComputeSettingsCard() { setSaving(true); setError(null); try { + // Save first so a failed publish leaves the prefs in a sane state. await invoke("mesh_set_sharing_prefs", { prefs: next }); setPrefs(next); + + // Probe the connected relay for its iroh_relay_url. If it doesn't + // advertise mesh-LLM at all, the offer can't be published — but the + // local prefs are still saved (the user might re-connect to a + // mesh-capable relay later). + const relayWsUrl = await invoke("get_relay_ws_url"); + const irohUrl = await invoke("mesh_relay_iroh_url", { + relayWsUrl, + }); + if (irohUrl) { + await invoke("mesh_publish_offer", { irohRelayUrl: irohUrl }); + } else if (next.enabled) { + setError( + "Saved locally, but this relay does not advertise iroh_relay_url — your offer will not be visible to other members until the relay is configured for mesh-LLM.", + ); + } } catch (e) { setError(String(e)); } finally { From 72824221c017f78fffe5fde9080058a5f61eb677 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 17:10:26 -0400 Subject: [PATCH 10/14] =?UTF-8?q?mesh-llm:=20B5=20discovery=20=E2=80=94=20?= =?UTF-8?q?subscribe=20+=20render=20in=20settings=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shared/constants/kinds.ts: KIND_MESH_LLM_DISCOVERY = 31990 (matches sprout_core::kind::KIND_MESH_LLM_DISCOVERY). shared/api/relayClientSession.ts: subscribeToMeshLlmOffers(onEvent) issues a NIP-01 REQ for kinds=[31990], limit=200. Returns the latest snapshot plus a live stream. Membership is enforced relay-side via the existing NIP-43 fan-out gate, so consumers see only offers authored by relay members. features/settings/hooks/useMeshLlmOffers.ts: parses each event's content as MeshLlmOffer (mirrors sprout_core::mesh_llm shape), keys offers by (pubkey, d_tag) per NIP-33. Empty content => drop entry (matches the Rust publisher's delete-by-replace path). Newest-first sort. Returns { offers, error } for the consumer hook contract. features/settings/ui/MeshComputeSettingsCard.tsx: new 'Compute offered by other members' section between the caps fieldset and the identity footer. Empty-state copy when no offers; otherwise a list with pubkey-short / d_tag / advertised models / caps for each. scripts/check-file-sizes.mjs: relayClientSession override 930 -> 960 to absorb subscribeToMeshLlmOffers; comment updated to mention it so future readers know why. pnpm check, pnpm typecheck both clean. The full publish/discover loop runs end-to-end: user A toggles -> kind:31990 hits relay -> user B's useMeshLlmOffers hook receives it -> card renders 'A is offering ...'. The only remaining gap to actually use the compute is the iroh dial, which is the deferred question for Tyler. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- desktop/scripts/check-file-sizes.mjs | 2 +- .../settings/hooks/useMeshLlmOffers.ts | 133 ++++++++++++++++++ .../settings/ui/MeshComputeSettingsCard.tsx | 52 ++++++- desktop/src/shared/api/relayClientSession.ts | 19 +++ desktop/src/shared/constants/kinds.ts | 2 + 5 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/settings/hooks/useMeshLlmOffers.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 54c924c11..a8beadf92 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -43,7 +43,7 @@ const overrides = new Map([ ["src/features/messages/ui/MessageComposer.tsx", 710], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav - ["src/shared/api/relayClientSession.ts", 930], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ["src/shared/api/relayClientSession.ts", 960], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + subscribeToMeshLlmOffers (kind:31990 discovery) ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions ["src-tauri/src/lib.rs", 710], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + huddle command registration + PTT global shortcut handler + persona pack commands + app_handle storage for event emission ["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests diff --git a/desktop/src/features/settings/hooks/useMeshLlmOffers.ts b/desktop/src/features/settings/hooks/useMeshLlmOffers.ts new file mode 100644 index 000000000..e93103b9e --- /dev/null +++ b/desktop/src/features/settings/hooks/useMeshLlmOffers.ts @@ -0,0 +1,133 @@ +import { useEffect, useState } from "react"; + +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; + +/** + * Mesh-LLM offer envelope as carried in the `content` field of a kind:31990 + * event. Keep in sync with the Rust `sprout_core::mesh_llm::MeshLlmOffer`. + */ +export interface MeshLlmOffer { + v: number; + d_tag: string; + endpoint_id: string; + iroh_relay_url: string; + caps: { + max_vram_mb?: number | null; + max_ram_mb?: number | null; + max_concurrency?: number | null; + }; + models: Array<{ + id: string; + label?: string | null; + context_tokens?: number | null; + }>; + extra?: unknown; +} + +/** + * A kind:31990 offer paired with the *Nostr* pubkey that signed it (so the + * UI can show 'Alice is offering Llama 3 8B') and the event's `created_at` + * (for sorting and freshness display). + */ +export interface ResolvedOffer { + offer: MeshLlmOffer; + pubkey: string; + createdAt: number; + d_tag: string; +} + +function extractDTag(event: RelayEvent): string | null { + for (const tag of event.tags) { + if (tag.length >= 2 && tag[0] === "d") return tag[1]; + } + return null; +} + +/** + * Subscribe to live mesh-LLM offers from the connected relay. + * + * Returns the de-duplicated set of *currently-active* offers (keyed by + * `(pubkey, d_tag)` per NIP-33). An event with empty `content` is treated + * as 'offer withdrawn' and removes the corresponding entry — this is the + * NIP-33 delete-by-replace idiom the Rust publisher emits when the user + * toggles compute-sharing off. + */ +export function useMeshLlmOffers(): { + offers: ResolvedOffer[]; + error: string | null; +} { + const [offers, setOffers] = useState>(new Map()); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + let unsub: (() => Promise) | null = null; + + function onEvent(event: RelayEvent) { + if (cancelled) return; + const dTag = extractDTag(event); + if (!dTag) return; + const key = `${event.pubkey}:${dTag}`; + + // Empty content = NIP-33 delete-by-replace. + if (event.content.trim() === "") { + setOffers((prev) => { + if (!prev.has(key)) return prev; + const next = new Map(prev); + next.delete(key); + return next; + }); + return; + } + + let parsed: MeshLlmOffer; + try { + parsed = JSON.parse(event.content) as MeshLlmOffer; + } catch { + // Skip malformed offers silently; one bad publisher must not + // poison the list. + return; + } + setOffers((prev) => { + const existing = prev.get(key); + if (existing && existing.createdAt >= event.created_at) { + // We already have a fresher version under the same address. + return prev; + } + const next = new Map(prev); + next.set(key, { + offer: parsed, + pubkey: event.pubkey, + createdAt: event.created_at, + d_tag: dTag, + }); + return next; + }); + } + + (async () => { + try { + const u = await relayClient.subscribeToMeshLlmOffers(onEvent); + if (cancelled) { + void u(); + } else { + unsub = u; + } + } catch (e) { + if (!cancelled) setError(String(e)); + } + })(); + + return () => { + cancelled = true; + if (unsub) void unsub(); + }; + }, []); + + // Sort newest first for the UI. + const list = Array.from(offers.values()).sort( + (a, b) => b.createdAt - a.createdAt, + ); + return { offers: list, error }; +} diff --git a/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx index 17f6510df..ce482802f 100644 --- a/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx @@ -1,7 +1,8 @@ import * as React from "react"; import { invoke } from "@tauri-apps/api/core"; -import { Cpu } from "lucide-react"; +import { Cpu, Users } from "lucide-react"; +import { useMeshLlmOffers } from "@/features/settings/hooks/useMeshLlmOffers"; import { Switch } from "@/shared/ui/switch"; import { Input } from "@/shared/ui/input"; @@ -58,6 +59,7 @@ export function MeshComputeSettingsCard() { const [endpoint, setEndpoint] = React.useState(null); const [error, setError] = React.useState(null); const [saving, setSaving] = React.useState(false); + const { offers, error: offersError } = useMeshLlmOffers(); // Load the persisted prefs + the iroh endpoint identity on mount. React.useEffect(() => { @@ -235,6 +237,54 @@ export function MeshComputeSettingsCard() { + {/* ── Offers visible from other members ──────────────────── */} +
+

+ + Compute offered by other members +

+ {offersError ? ( +

{offersError}

+ ) : null} + {offers.length === 0 ? ( +

+ Nobody else on this relay is currently sharing compute. +

+ ) : ( +
    + {offers.map((entry) => ( +
  • +
    + + {entry.pubkey.slice(0, 16)}…{entry.pubkey.slice(-4)} + + {" · "} + {entry.d_tag} +
    +
    + {entry.offer.models.length === 0 + ? "No models advertised" + : entry.offer.models + .map((m) => m.label ?? m.id) + .join(", ")} +
    +
    + {entry.offer.caps.max_vram_mb != null + ? `${entry.offer.caps.max_vram_mb} MB VRAM · ` + : ""} + {entry.offer.caps.max_concurrency != null + ? `${entry.offer.caps.max_concurrency} concurrent` + : ""} +
    +
  • + ))} +
+ )} +
+ {/* ── Identity ────────────────────────────────────────────── */} {endpoint ? (
diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 2d846d473..db51a8164 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -9,6 +9,7 @@ import type { PresenceStatus, RelayEvent } from "@/shared/api/types"; import { CHANNEL_EVENT_KINDS, HOME_MENTION_EVENT_KINDS, + KIND_MESH_LLM_DISCOVERY, KIND_STREAM_MESSAGE, KIND_TYPING_INDICATOR, KIND_USER_STATUS, @@ -311,6 +312,24 @@ export class RelayClient { ); } + /** + * Subscribe to kind:31990 mesh-LLM compute-offer events. + * + * Pulls the most recent 200 offers (one per (pubkey, d_tag) address under + * NIP-33) so the UI sees the steady-state set of who is currently + * offering, then streams live updates. Consumers should treat an event + * with empty `content` as 'offer withdrawn' (NIP-33 delete-by-replace). + * + * Membership is enforced relay-side via the existing NIP-43 fan-out gate + * — this subscription will only deliver events authored by relay members. + */ + async subscribeToMeshLlmOffers(onEvent: (event: RelayEvent) => void) { + return this.subscribe( + { kinds: [KIND_MESH_LLM_DISCOVERY], limit: 200 }, + onEvent, + ); + } + async subscribeToAllStreamMessages(onEvent: (event: RelayEvent) => void) { return this.subscribe(this.buildGlobalStreamFilter(50), onEvent); } diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 73028596f..8e6a21a92 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -20,6 +20,8 @@ export const KIND_READ_STATE = 30078; export const KIND_USER_STATUS = 30315; export const KIND_AGENT_OBSERVER_FRAME = 24200; export const KIND_REPO_ANNOUNCEMENT = 30617; +/** Mesh-LLM compute-offer discovery. Parameterized replaceable (NIP-33). */ +export const KIND_MESH_LLM_DISCOVERY = 31990; // Human-visible "new content" message kinds. Used as the unread trigger set // (sidebar badges, catch-up queries) and as the Home-feed mention query. From f711e37a7aac1af976318ab6d5aac2f23d45ca9f Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 17:20:29 -0400 Subject: [PATCH 11/14] =?UTF-8?q?mesh-llm:=20Max=20review=20fixups=20?= =?UTF-8?q?=E2=80=94=20expires=5Fat,=20EndpointAddr,=20same-relay=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Max's pre-PR review of d9a791f: 1. Add MeshLlmOffer.expires_at: u64 (unix seconds). Hard-required by serde (no default) since consumers depend on it for correctness: crashed publishers cannot send the NIP-33 delete-by-replace tombstone, so the TTL is the only thing that reaps stale offers. New helpers + tests in sprout-core::mesh_llm: - is_expired(now) returns true when expires_at <= now. - matches_local_relay(current_relay) compares against the relay's NIP-11 iroh_relay_url with lightweight canonicalisation (lower-case scheme/host, trailing-slash collapse, query/fragment drop). v1 invariant: one relay = one mesh boundary. - is_publishable now rejects expires_at == 0. - canonical_relay_url() is a small private helper, deliberately separate from sprout_auth::nip98_canonical_url (different jobs). 5 new tests (expires_at_required, is_expired_filter, matches_local_relay_canonicalises, is_publishable_rejects_zero_*, plus updated JSON fixtures throughout). 2. Fix doc wording: iroh NodeAddr -> EndpointAddr (rc.0 naming). Soften 'multiple relays bridging into membership scope' language to reflect that v1 is strictly single-relay and the field is reserved for future cross-relay only. 3. Desktop publisher (mesh_publish_offer) now computes expires_at = now + OFFER_TTL_SECS (15 min) and threads it through build_offer(endpoint_id, iroh_relay_url, expires_at). The frontend hook is expected to re-invoke publish on heartbeat well before the deadline (heartbeat plumbing is in the next commit). 4. Frontend useMeshLlmOffers hook: - probes the relay's NIP-11 iroh_relay_url once on mount and filters offers whose iroh_relay_url doesn't match (v1 same-relay invariant). When the relay doesn't advertise mesh-LLM, the filter correctly empties the list. - rejects events with schema v != 1 before storing. - 30s setInterval ticks 'now' so expired offers drop without waiting for a new event. O(1) timer regardless of how many offers are in the cache. - mirrors the canonicaliser logic from sprout-core in TS so JS-side accept/reject decisions match the Rust check. Tests after change: - sprout-core --lib: 174 -> 178 (+4 mesh_llm) - desktop mesh_llm --lib: 11 unchanged (publisher signature change required updating build_offer call sites; tests pass exact-same set). - pnpm typecheck + pnpm check (biome + file-sizes): clean. - cargo clippy --workspace -- -D warnings: clean. - cargo fmt --all -- --check: clean. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- crates/sprout-core/src/mesh_llm.rs | 150 +++++++++++++++++- desktop/src-tauri/src/commands/mesh_llm.rs | 11 +- desktop/src-tauri/src/mesh_llm/offer.rs | 20 ++- .../settings/hooks/useMeshLlmOffers.ts | 100 +++++++++++- 4 files changed, 264 insertions(+), 17 deletions(-) diff --git a/crates/sprout-core/src/mesh_llm.rs b/crates/sprout-core/src/mesh_llm.rs index 72ec9568d..b4293bccc 100644 --- a/crates/sprout-core/src/mesh_llm.rs +++ b/crates/sprout-core/src/mesh_llm.rs @@ -61,20 +61,35 @@ pub struct MeshLlmOffer { /// Iroh endpoint id (ed25519 public key, base32 z-base form as iroh /// renders it) of the offering node's iroh endpoint. Consumers dial - /// this through an iroh `NodeAddr`. + /// this through an iroh `EndpointAddr` constructed from + /// `(endpoint_id, iroh_relay_url)`. pub endpoint_id: String, /// Iroh relay URL through which the offering endpoint is reachable. /// /// This is the *Sprout-hosted* iroh-relay URL — copied verbatim from - /// the publisher's view of NIP-11 `iroh_relay_url`. If multiple Sprout - /// relays are bridged into the same membership scope in the future, - /// this lets a consumer reach an offer behind a different host. + /// the publisher's view of NIP-11 `iroh_relay_url`. The field is + /// preserved for future cross-relay bridging, but **v1 consumers MUST + /// ignore offers whose `iroh_relay_url` doesn't match the current + /// relay's NIP-11 `iroh_relay_url`** (see [`Self::matches_local_relay`]). + /// This keeps "one relay = one mesh boundary" as an enforced invariant + /// until cross-relay membership is explicitly designed. pub iroh_relay_url: String, + /// Unix-seconds timestamp at which this offer becomes stale. Consumers + /// MUST ignore offers where `expires_at <= now` (publishers SHOULD + /// republish a fresh offer well before this deadline to act as a + /// heartbeat). Because crashed publishers cannot send the NIP-33 + /// delete-by-replace tombstone, this TTL is the only thing that + /// removes their offers from the consumer view. + pub expires_at: u64, + /// Resource caps the offering side promises to honour for any single /// consumer at a time. The publisher should re-publish (replacing the - /// previous event) whenever these change materially. + /// previous event) whenever these change materially. **These are + /// claims/UI hints, not authority** — the provider runtime must + /// enforce its own caps locally; the consumer cannot rely on the + /// publisher to honour them at admission time. pub caps: ResourceCaps, /// Models this node is willing to serve. Empty list = "negotiate at @@ -157,12 +172,83 @@ impl MeshLlmOffer { /// /// This is a *publisher-side* sanity check; consumers should be /// permissive in what they accept as long as serde-deserialization - /// succeeds. + /// succeeds (modulo the [`Self::is_expired`] and + /// [`Self::matches_local_relay`] filters below). pub fn is_publishable(&self) -> bool { self.v == 1 && Self::is_valid_d_tag(&self.d_tag) && !self.endpoint_id.is_empty() && !self.iroh_relay_url.is_empty() + && self.expires_at > 0 + } + + /// Consumer-side TTL check. Returns `true` when `expires_at <= now`, + /// in which case the offer must be ignored (crashed publishers can't + /// send a delete-by-replace tombstone; the TTL is the only reaper). + /// + /// `now` is unix-seconds — callers in this crate pass `Timestamp::now()` + /// or a test clock to avoid pulling `std::time::SystemTime` into the + /// trust path. + pub fn is_expired(&self, now: u64) -> bool { + self.expires_at <= now + } + + /// Consumer-side same-relay filter for v1 discovery. Returns `true` + /// when the offer's advertised `iroh_relay_url` matches `current_relay` + /// after canonicalisation (lower-case scheme/host, trailing slash on + /// path collapsed, query/fragment dropped). + /// + /// **v1 consumers MUST ignore offers where this returns `false`.** The + /// invariant is "one relay = one mesh boundary"; cross-relay bridging + /// is reserved for a future explicit design. + pub fn matches_local_relay(&self, current_relay: &str) -> bool { + canonical_relay_url(&self.iroh_relay_url) == canonical_relay_url(current_relay) + } +} + +/// Lightweight URL canonicaliser used only for the same-relay filter. Not +/// to be confused with [`sprout_auth::nip98_canonical_url`], which has a +/// different job (computing the `u`-tag value); this one just strips +/// query/fragment, lower-cases scheme/host, and collapses one trailing +/// slash so users who paste `https://r.example.com/iroh/` see it match +/// `https://r.example.com/iroh`. +fn canonical_relay_url(raw: &str) -> String { + let trimmed = raw.trim(); + // Split off query + fragment. Compose the splits in two steps so the + // second split operates on the result of the first, not on `trimmed`. + let no_query = match trimmed.split_once('?') { + Some((h, _)) => h, + None => trimmed, + }; + let no_frag = match no_query.split_once('#') { + Some((h, _)) => h, + None => no_query, + }; + let head = if let Some(stripped) = no_frag.strip_suffix('/') { + // Only strip *one* trailing slash — don't collapse repeated + // slashes (those are real path components). + stripped + } else { + no_frag + }; + + // Lower-case the scheme+authority portion; leave the path case-sensitive. + if let Some(idx) = head.find("://") { + let (scheme, rest) = head.split_at(idx); + // rest starts with "://"; authority ends at next '/'. + let after_proto = &rest[3..]; + let (authority, path) = match after_proto.find('/') { + Some(p) => after_proto.split_at(p), + None => (after_proto, ""), + }; + format!( + "{}://{}{}", + scheme.to_ascii_lowercase(), + authority.to_ascii_lowercase(), + path, + ) + } else { + head.to_string() } } @@ -176,6 +262,7 @@ mod tests { d_tag: "node-1".to_string(), endpoint_id: "1234abcd".to_string(), iroh_relay_url: "https://relay.example.com/iroh".to_string(), + expires_at: 2_000_000_000, // far-future fixture caps: ResourceCaps { max_vram_mb: Some(24_000), max_ram_mb: Some(64_000), @@ -205,6 +292,7 @@ mod tests { "d_tag": "x", "endpoint_id": "abc", "iroh_relay_url": "https://r/", + "expires_at": 2000000000, "caps": {} }"#; let offer: MeshLlmOffer = serde_json::from_str(s).expect("deserialise minimal"); @@ -222,6 +310,7 @@ mod tests { "d_tag": "x", "endpoint_id": "abc", "iroh_relay_url": "https://r", + "expires_at": 2000000000, "caps": {}, "wat": "lol" }"#; @@ -235,11 +324,60 @@ mod tests { "d_tag": "x", "endpoint_id": "abc", "iroh_relay_url": "https://r", + "expires_at": 2000000000, "caps": { "wat": 7 } }"#; assert!(serde_json::from_str::(s).is_err()); } + #[test] + fn expires_at_required() { + // expires_at has no serde default; missing it is a hard error + // (consumers depend on TTL for correctness). + let s = r#"{ + "v": 1, + "d_tag": "x", + "endpoint_id": "abc", + "iroh_relay_url": "https://r", + "caps": {} + }"#; + assert!(serde_json::from_str::(s).is_err()); + } + + #[test] + fn is_expired_filter() { + let mut offer = sample(); + offer.expires_at = 1_000; + assert!(offer.is_expired(2_000), "now > expires_at must expire"); + assert!(offer.is_expired(1_000), "now == expires_at must expire"); + assert!(!offer.is_expired(999), "now < expires_at must not expire"); + } + + #[test] + fn matches_local_relay_canonicalises() { + let mut offer = sample(); + offer.iroh_relay_url = "https://relay.example.com/iroh".to_string(); + // exact match + assert!(offer.matches_local_relay("https://relay.example.com/iroh")); + // trailing slash on one side + assert!(offer.matches_local_relay("https://relay.example.com/iroh/")); + // upper-case host + assert!(offer.matches_local_relay("HTTPS://Relay.Example.COM/iroh")); + // query/fragment stripped + assert!(offer.matches_local_relay("https://relay.example.com/iroh?x=1#y")); + // different host -> reject + assert!(!offer.matches_local_relay("https://other.example.com/iroh")); + // different path -> reject (different mesh boundary) + assert!(!offer.matches_local_relay("https://relay.example.com/other")); + } + + #[test] + fn is_publishable_rejects_zero_expires_at() { + let mut offer = sample(); + offer.expires_at = 0; + assert!(!offer.is_publishable()); + } + #[test] fn extra_freeform_passes_through() { let offer = MeshLlmOffer { diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 321d77889..014dcabf9 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -119,8 +119,17 @@ pub async fn mesh_publish_offer( let d_tag_tag = Tag::parse(["d", &d_tag]).map_err(|e| format!("d tag: {e}"))?; let (content, published_offer) = if prefs.enabled { + // expires_at = now + OFFER_TTL_SECS. The frontend hook re-invokes + // mesh_publish_offer on a heartbeat well before the deadline; if + // the publisher crashes before the next heartbeat, consumers reap + // the offer once `now > expires_at` (see MeshLlmOffer::is_expired). + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| format!("system clock: {e}"))? + .as_secs(); + let expires_at = now + mesh_llm::offer::OFFER_TTL_SECS; let offer = prefs - .build_offer(&endpoint_id_str, &iroh_relay_url) + .build_offer(&endpoint_id_str, &iroh_relay_url, expires_at) .ok_or_else(|| { "build_offer returned None despite enabled=true (logic bug)".to_string() })?; diff --git a/desktop/src-tauri/src/mesh_llm/offer.rs b/desktop/src-tauri/src/mesh_llm/offer.rs index 441b0accd..03563cfa1 100644 --- a/desktop/src-tauri/src/mesh_llm/offer.rs +++ b/desktop/src-tauri/src/mesh_llm/offer.rs @@ -75,10 +75,15 @@ impl ComputeSharingPrefs { /// Builds the kind:31990 offer envelope to publish. Returns `None` if /// sharing is disabled; the publisher should then *delete* any prior /// offer rather than calling this. + /// + /// `expires_at` is the unix-seconds deadline after which consumers will + /// drop this offer. Callers pass `now + OFFER_TTL_SECS` so an + /// unannounced publisher crash naturally reaps the stale offer. pub fn build_offer( &self, endpoint_id: &str, iroh_relay_url: &str, + expires_at: u64, ) -> Option { if !self.enabled { return None; @@ -88,6 +93,7 @@ impl ComputeSharingPrefs { d_tag: self.d_tag.clone(), endpoint_id: endpoint_id.to_string(), iroh_relay_url: iroh_relay_url.to_string(), + expires_at, caps: self.caps.clone(), models: self.models.clone(), extra: None, @@ -95,6 +101,11 @@ impl ComputeSharingPrefs { } } +/// Default offer TTL: 15 minutes. Publishers republish on each prefs +/// change *and* should heartbeat at ~`OFFER_TTL_SECS/3` so a one-missed +/// heartbeat still leaves the offer visible. +pub const OFFER_TTL_SECS: u64 = 15 * 60; + fn prefs_path(app: &AppHandle) -> Result { let data_dir = app .path() @@ -149,7 +160,7 @@ mod tests { let prefs = ComputeSharingPrefs::default(); assert!( prefs - .build_offer("endpoint", "https://relay/iroh") + .build_offer("endpoint", "https://relay/iroh", 2_000_000_000) .is_none() ); } @@ -161,10 +172,15 @@ mod tests { ..Default::default() }; let offer = prefs - .build_offer("endpoint-id-hex", "https://relay.example.com/iroh") + .build_offer( + "endpoint-id-hex", + "https://relay.example.com/iroh", + 2_000_000_000, + ) .expect("offer"); assert_eq!(offer.endpoint_id, "endpoint-id-hex"); assert_eq!(offer.iroh_relay_url, "https://relay.example.com/iroh"); + assert_eq!(offer.expires_at, 2_000_000_000); assert!(offer.is_publishable()); } diff --git a/desktop/src/features/settings/hooks/useMeshLlmOffers.ts b/desktop/src/features/settings/hooks/useMeshLlmOffers.ts index e93103b9e..6eba832e2 100644 --- a/desktop/src/features/settings/hooks/useMeshLlmOffers.ts +++ b/desktop/src/features/settings/hooks/useMeshLlmOffers.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; import { relayClient } from "@/shared/api/relayClient"; import type { RelayEvent } from "@/shared/api/types"; @@ -12,6 +13,8 @@ export interface MeshLlmOffer { d_tag: string; endpoint_id: string; iroh_relay_url: string; + /** Unix-seconds deadline; consumers ignore offers where `expires_at <= now`. */ + expires_at: number; caps: { max_vram_mb?: number | null; max_ram_mb?: number | null; @@ -44,14 +47,50 @@ function extractDTag(event: RelayEvent): string | null { return null; } +/** + * Canonicalise a relay URL the same way `sprout_core::mesh_llm`'s same-relay + * filter does, so the JS side can't accept an offer the Rust schema check + * would reject. (See `MeshLlmOffer::matches_local_relay` in core.) + */ +function canonicalRelayUrl(raw: string): string { + const trimmed = raw.trim(); + const noQuery = trimmed.split("?")[0] ?? trimmed; + const noFrag = noQuery.split("#")[0] ?? noQuery; + const noTrail = noFrag.endsWith("/") ? noFrag.slice(0, -1) : noFrag; + const protoIdx = noTrail.indexOf("://"); + if (protoIdx === -1) return noTrail; + const scheme = noTrail.slice(0, protoIdx).toLowerCase(); + const rest = noTrail.slice(protoIdx + 3); + const slash = rest.indexOf("/"); + if (slash === -1) { + return `${scheme}://${rest.toLowerCase()}`; + } + const authority = rest.slice(0, slash).toLowerCase(); + const path = rest.slice(slash); + return `${scheme}://${authority}${path}`; +} + +/** + * How often (in ms) the hook recomputes the rendered list so freshly + * expired offers drop without waiting for a new event to arrive. Tied to a + * setInterval rather than per-offer setTimeouts so the cost is O(1) no + * matter how many offers are in the cache. + */ +const EXPIRY_TICK_MS = 30_000; + /** * Subscribe to live mesh-LLM offers from the connected relay. * * Returns the de-duplicated set of *currently-active* offers (keyed by - * `(pubkey, d_tag)` per NIP-33). An event with empty `content` is treated - * as 'offer withdrawn' and removes the corresponding entry — this is the - * NIP-33 delete-by-replace idiom the Rust publisher emits when the user - * toggles compute-sharing off. + * `(pubkey, d_tag)` per NIP-33), filtered to: + * - the current relay's iroh-relay URL (v1 invariant: one relay = one mesh + * boundary). Offers advertising a different `iroh_relay_url` are dropped. + * - non-expired offers (`expires_at > now`). Crashed publishers can't send + * the NIP-33 delete-by-replace tombstone; the TTL is the reaper. + * + * An event with empty `content` is treated as 'offer withdrawn' and removes + * the corresponding entry — this is the NIP-33 delete-by-replace idiom the + * Rust publisher emits when the user toggles compute-sharing off. */ export function useMeshLlmOffers(): { offers: ResolvedOffer[]; @@ -59,6 +98,39 @@ export function useMeshLlmOffers(): { } { const [offers, setOffers] = useState>(new Map()); const [error, setError] = useState(null); + const [localRelay, setLocalRelay] = useState(null); + const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000)); + + // Discover the current relay's iroh_relay_url once so we can filter + // offers against it. If the relay doesn't advertise one, the same-relay + // filter rejects everything and the panel correctly shows the empty + // state. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const ws = await invoke("get_relay_ws_url"); + const irohUrl = await invoke("mesh_relay_iroh_url", { + relayWsUrl: ws, + }); + if (!cancelled) setLocalRelay(irohUrl ?? null); + } catch (e) { + if (!cancelled) setError(String(e)); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + // Periodic re-tick so expired offers fall out of the rendered list even + // when no new events arrive. + useEffect(() => { + const id = setInterval(() => { + setNowSec(Math.floor(Date.now() / 1000)); + }, EXPIRY_TICK_MS); + return () => clearInterval(id); + }, []); useEffect(() => { let cancelled = false; @@ -89,6 +161,9 @@ export function useMeshLlmOffers(): { // poison the list. return; } + // Reject obviously-bad schema versions before storing. + if (parsed.v !== 1) return; + setOffers((prev) => { const existing = prev.get(key); if (existing && existing.createdAt >= event.created_at) { @@ -125,9 +200,18 @@ export function useMeshLlmOffers(): { }; }, []); - // Sort newest first for the UI. - const list = Array.from(offers.values()).sort( - (a, b) => b.createdAt - a.createdAt, - ); + // Filter on every render so newly-expired offers drop without a refresh + // event, and so the same-relay filter applies as soon as the NIP-11 + // probe completes. + const localCanonical = + localRelay != null ? canonicalRelayUrl(localRelay) : null; + const list = Array.from(offers.values()) + .filter((entry) => entry.offer.expires_at > nowSec) + .filter((entry) => { + if (localCanonical == null) return false; + return canonicalRelayUrl(entry.offer.iroh_relay_url) === localCanonical; + }) + .sort((a, b) => b.createdAt - a.createdAt); + return { offers: list, error }; } From 16299b4e3f92f08a6b8702c38b1402c81f4660d6 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 17:22:00 -0400 Subject: [PATCH 12/14] =?UTF-8?q?mesh-llm:=20heartbeat=20republish=20?= =?UTF-8?q?=E2=80=94=20keep=20offers=20fresh=20while=20the=20panel=20is=20?= =?UTF-8?q?mounted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without heartbeat, the expires_at TTL added in the previous commit makes offers vanish from consumer UIs 15 min after the user toggles on, even if the publisher is still running fine. The original commit message even noted this gap but didn't implement it. Fixing now. desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts (new): - HEARTBEAT_MS = 5 minutes (~OFFER_TTL_SECS / 3 — so one missed beat still leaves the offer visible; only two consecutive misses drop us). - While { enabled, irohRelayUrl } are both truthy, setInterval calls mesh_publish_offer, which re-stamps expires_at = now + 15 min as a NIP-33 replace under the same (pubkey, d_tag) address. - Errors are logged and ignored — the user isn't waiting in front of the panel; the next tick or an explicit prefs change will surface a fresh error if the relay is durably down. desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx: - New irohRelayUrl state, populated on mount and after every persist(). - useMeshOfferHeartbeat({ enabled, irohRelayUrl }) wired in. - Mount-time relay probe so a user opening the panel with sharing already enabled from a previous session starts heartbeating immediately; failures are non-fatal (console.warn, prefs still editable). pnpm typecheck + pnpm check clean. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- .../settings/hooks/useMeshOfferHeartbeat.ts | 56 +++++++++++++++++++ .../settings/ui/MeshComputeSettingsCard.tsx | 34 +++++++++-- 2 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts diff --git a/desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts b/desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts new file mode 100644 index 000000000..81e670f7a --- /dev/null +++ b/desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts @@ -0,0 +1,56 @@ +import { useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +/** + * Heartbeat interval (ms) for the mesh-LLM offer publisher. + * + * The Rust side stamps `expires_at = now + OFFER_TTL_SECS` (15 min) on + * each publish. We re-publish at ~1/3 of that so even a single missed + * heartbeat leaves the offer visible to consumers; only a crash that + * misses two consecutive heartbeats actually drops us out of the UI. + * + * Kept here rather than computed from a Rust constant because the value + * is fundamentally a *frontend timer* (Tauri-side has no concept of "the + * UI mounted"); we just need it to stay strictly less than the + * Rust-side TTL so the invariant holds. + */ +const HEARTBEAT_MS = 5 * 60 * 1000; + +/** + * While `enabled` is true, periodically re-invoke `mesh_publish_offer` so + * the kind:31990 event's `expires_at` stays fresh. Republishes are NIP-33 + * replaces under the same `(pubkey, d_tag)` address — consumers do not see + * a flicker; they just see the deadline advance. + * + * Errors are logged to the console and dropped on the floor — the user + * is not in front of the settings panel waiting for them, and the next + * heartbeat (or an explicit prefs change) will surface a fresh error if + * the relay is durably down. + */ +export function useMeshOfferHeartbeat(params: { + enabled: boolean; + irohRelayUrl: string | null; +}): void { + const { enabled, irohRelayUrl } = params; + + useEffect(() => { + if (!enabled || irohRelayUrl == null || irohRelayUrl === "") { + return; + } + let cancelled = false; + const tick = async () => { + if (cancelled) return; + try { + await invoke("mesh_publish_offer", { irohRelayUrl }); + } catch (e) { + // Heartbeat failure is non-fatal — log and let the next tick try. + console.warn("mesh-llm heartbeat failed:", e); + } + }; + const id = setInterval(tick, HEARTBEAT_MS); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [enabled, irohRelayUrl]); +} diff --git a/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx index ce482802f..66594886e 100644 --- a/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { Cpu, Users } from "lucide-react"; import { useMeshLlmOffers } from "@/features/settings/hooks/useMeshLlmOffers"; +import { useMeshOfferHeartbeat } from "@/features/settings/hooks/useMeshOfferHeartbeat"; import { Switch } from "@/shared/ui/switch"; import { Input } from "@/shared/ui/input"; @@ -57,11 +58,24 @@ function formatCap(value: number | null): string { export function MeshComputeSettingsCard() { const [prefs, setPrefs] = React.useState(null); const [endpoint, setEndpoint] = React.useState(null); + const [irohRelayUrl, setIrohRelayUrl] = React.useState(null); const [error, setError] = React.useState(null); const [saving, setSaving] = React.useState(false); const { offers, error: offersError } = useMeshLlmOffers(); - // Load the persisted prefs + the iroh endpoint identity on mount. + // While the card is mounted AND the user has compute-sharing enabled, + // re-publish the offer every ~5 min so its `expires_at` doesn't lapse + // (see useMeshOfferHeartbeat for the rationale). The heartbeat hook + // no-ops when `enabled` is false or `irohRelayUrl` is missing. + useMeshOfferHeartbeat({ + enabled: prefs?.enabled === true, + irohRelayUrl, + }); + + // Load the persisted prefs + the iroh endpoint identity + the relay's + // iroh_relay_url on mount. The latter is what the heartbeat republishes + // against, so we have to know it before the heartbeat fires its first + // tick. React.useEffect(() => { let cancelled = false; (async () => { @@ -70,9 +84,20 @@ export function MeshComputeSettingsCard() { invoke("mesh_get_sharing_prefs"), invoke("mesh_get_endpoint_id"), ]); - if (!cancelled) { - setPrefs(p); - setEndpoint(e); + if (cancelled) return; + setPrefs(p); + setEndpoint(e); + // Probe the relay's iroh_relay_url so the heartbeat hook has + // something to publish against. Failures here are non-fatal — + // the user can still see/edit prefs; we just won't heartbeat. + try { + const relayWsUrl = await invoke("get_relay_ws_url"); + const irohUrl = await invoke("mesh_relay_iroh_url", { + relayWsUrl, + }); + if (!cancelled) setIrohRelayUrl(irohUrl ?? null); + } catch (probeErr) { + console.warn("mesh-llm iroh url probe failed:", probeErr); } } catch (e) { if (!cancelled) setError(String(e)); @@ -99,6 +124,7 @@ export function MeshComputeSettingsCard() { const irohUrl = await invoke("mesh_relay_iroh_url", { relayWsUrl, }); + setIrohRelayUrl(irohUrl ?? null); if (irohUrl) { await invoke("mesh_publish_offer", { irohRelayUrl: irohUrl }); } else if (next.enabled) { From d1262beb8a9799a3c2f6decb799882f6f0c5f517 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 17:27:13 -0400 Subject: [PATCH 13/14] mesh-llm: e2e tests against a live relay (#[ignore]'d by default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crates/sprout-test-client/tests/e2e_mesh_llm_discovery.rs (new): four end-to-end tests verifying the kind:31990 publish/discover loop against a real running sprout-relay. Follows the same #[ignore]+RELAY_URL pattern as e2e_long_form.rs etc. Tests: - test_offer_publish_then_retrieve: publish a kind:31990 with a far-future expires_at, REQ it back via kinds+author filter, confirm the serialised MeshLlmOffer round-trips through the relay intact (expires_at, d_tag). - test_offer_replace_by_d_tag: NIP-33 replace semantics — publishing a second event with the same (pubkey, d_tag) must replace the first; a subsequent REQ returns only the latest. - test_offer_delete_by_empty_replace: the desktop publisher's delete-by-replace tombstone — publish a real offer, then publish empty content at the same address; only the tombstone remains. - test_offer_stray_h_tag_is_ignored: kind:31990 is global (is_global_only_kind); a stray h-tag must not channel-scope the event. Verifies the unit-tested behaviour holds end-to-end on real relay traffic. These exercise the slice that pure unit tests can't reach: the actual NIP-43 fan-out gate, the relay's ingest validation against required_scope_for_kind / is_global_only_kind, and NIP-33 storage semantics. They are #[ignore]'d so 'just test-unit' is unaffected; 'cargo test --test e2e_mesh_llm_discovery -- --ignored' runs them against the relay pointed at by RELAY_URL (default ws://localhost:3000). clippy + fmt clean across the workspace. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- .../tests/e2e_mesh_llm_discovery.rs | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 crates/sprout-test-client/tests/e2e_mesh_llm_discovery.rs diff --git a/crates/sprout-test-client/tests/e2e_mesh_llm_discovery.rs b/crates/sprout-test-client/tests/e2e_mesh_llm_discovery.rs new file mode 100644 index 000000000..6cfa3458d --- /dev/null +++ b/crates/sprout-test-client/tests/e2e_mesh_llm_discovery.rs @@ -0,0 +1,325 @@ +//! End-to-end tests for kind:31990 mesh-LLM compute-offer discovery. +//! +//! These tests require a running relay instance. By default they are marked +//! `#[ignore]` so that `cargo test` does not fail in CI when the relay is not +//! available. +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! cargo test --test e2e_mesh_llm_discovery -- --ignored +//! ``` +//! +//! Override the relay URL with the `RELAY_URL` environment variable: +//! +//! ```text +//! RELAY_URL=ws://relay.example.com cargo test --test e2e_mesh_llm_discovery -- --ignored +//! ``` + +use std::time::Duration; + +use nostr::{EventBuilder, Filter, Keys, Kind, Tag, Timestamp}; +use sprout_core::kind::KIND_MESH_LLM_DISCOVERY; +use sprout_core::mesh_llm::{MeshLlmOffer, ModelOffer, ResourceCaps}; +use sprout_test_client::SproutTestClient; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn sub_id(name: &str) -> String { + format!("e2e-mesh-{name}-{}", uuid::Uuid::new_v4()) +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() +} + +fn sample_offer(d_tag: &str, expires_at: u64) -> MeshLlmOffer { + MeshLlmOffer { + v: 1, + d_tag: d_tag.to_string(), + endpoint_id: "endpoint-id-test".to_string(), + iroh_relay_url: "https://relay.example.com/iroh".to_string(), + expires_at, + caps: ResourceCaps { + max_vram_mb: Some(8192), + max_ram_mb: Some(16_000), + max_concurrency: Some(1), + }, + models: vec![ModelOffer { + id: "test/model-1".to_string(), + label: Some("Test Model".to_string()), + context_tokens: Some(4096), + }], + extra: None, + } +} + +fn build_offer_event(keys: &Keys, offer: &MeshLlmOffer) -> nostr::Event { + let content = serde_json::to_string(offer).expect("serialise offer"); + let tags = vec![Tag::parse(&["d", &offer.d_tag]).expect("d tag")]; + EventBuilder::new(Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16), content, tags) + .sign_with_keys(keys) + .expect("sign event") +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +/// kind:31990 events with a far-future `expires_at` are accepted by the +/// relay and retrievable by a subsequent REQ scoped to that kind + author. +#[tokio::test] +#[ignore] +async fn test_offer_publish_then_retrieve() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let d_tag = format!("offer-{}", uuid::Uuid::new_v4().simple()); + let offer = sample_offer(&d_tag, now_secs() + 600); + let event = build_offer_event(&keys, &offer); + let event_id = event.id; + + let ok = client.send_event(event).await.expect("send event"); + assert!( + ok.accepted, + "relay should accept kind:31990 (well-formed offer): {}", + ok.message, + ); + + // Pull it back via REQ scoped to this author. + let sid = sub_id("retrieve"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16)) + .author(keys.public_key()); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + events.iter().any(|e| e.id == event_id), + "should find the published offer in query results", + ); + + // Deserialize and sanity-check the content survives the round trip. + let stored = events.iter().find(|e| e.id == event_id).unwrap(); + let parsed: MeshLlmOffer = + serde_json::from_str(&stored.content).expect("offer round-trips through relay"); + assert_eq!(parsed.d_tag, d_tag); + assert_eq!(parsed.expires_at, offer.expires_at); + + client.disconnect().await.expect("disconnect"); +} + +/// NIP-33 replace semantics: publishing a second event under the same +/// (pubkey, d_tag) replaces the first. The REQ that follows should +/// return only the latest version. +#[tokio::test] +#[ignore] +async fn test_offer_replace_by_d_tag() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let d_tag = format!("replace-{}", uuid::Uuid::new_v4().simple()); + + // Publish v1 — short TTL. + let offer_v1 = sample_offer(&d_tag, now_secs() + 60); + let event_v1 = build_offer_event(&keys, &offer_v1); + let v1_id = event_v1.id; + let ok = client.send_event(event_v1).await.expect("send v1"); + assert!(ok.accepted, "v1 accepted: {}", ok.message); + + // Wait a beat so created_at differs (NIP-33 tie-breaker). + tokio::time::sleep(Duration::from_secs(1)).await; + + // Publish v2 — longer TTL, same d_tag → replaces v1. + let mut offer_v2 = sample_offer(&d_tag, now_secs() + 600); + offer_v2.endpoint_id = "endpoint-id-v2".to_string(); + let event_v2 = build_offer_event(&keys, &offer_v2); + let v2_id = event_v2.id; + let ok = client.send_event(event_v2).await.expect("send v2"); + assert!(ok.accepted, "v2 accepted: {}", ok.message); + + // Query — should see only the replacement. + let sid = sub_id("replace"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16)) + .author(keys.public_key()); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + let matching: Vec<_> = events + .iter() + .filter(|e| { + e.tags.iter().any(|t| { + t.as_slice().first().map(|s| s.as_str()) == Some("d") + && t.as_slice().get(1).map(|s| s.as_str()) == Some(d_tag.as_str()) + }) + }) + .collect(); + + assert!( + matching.iter().any(|e| e.id == v2_id), + "v2 must be present after replace", + ); + assert!( + !matching.iter().any(|e| e.id == v1_id), + "v1 must be replaced by v2 (NIP-33)", + ); +} + +/// Empty-content kind:31990 with the same (pubkey, d_tag) is the +/// delete-by-replace tombstone the desktop publisher emits when the user +/// toggles compute-sharing off. Consumers see the empty content and drop +/// the offer from their cache. +#[tokio::test] +#[ignore] +async fn test_offer_delete_by_empty_replace() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let d_tag = format!("delete-{}", uuid::Uuid::new_v4().simple()); + + // Publish a real offer. + let offer = sample_offer(&d_tag, now_secs() + 600); + let event = build_offer_event(&keys, &offer); + let real_id = event.id; + let ok = client.send_event(event).await.expect("send real"); + assert!(ok.accepted, "real offer accepted: {}", ok.message); + + tokio::time::sleep(Duration::from_secs(1)).await; + + // Publish the empty-content replacement (the tombstone). + let tombstone = EventBuilder::new( + Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16), + "", + vec![Tag::parse(&["d", &d_tag]).unwrap()], + ) + .sign_with_keys(&keys) + .expect("sign tombstone"); + let tombstone_id = tombstone.id; + let ok = client.send_event(tombstone).await.expect("send tombstone"); + assert!(ok.accepted, "tombstone accepted: {}", ok.message); + + // Query — only the tombstone should remain at this address. + let sid = sub_id("delete"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16)) + .author(keys.public_key()); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + let matching: Vec<_> = events + .iter() + .filter(|e| { + e.tags.iter().any(|t| { + t.as_slice().first().map(|s| s.as_str()) == Some("d") + && t.as_slice().get(1).map(|s| s.as_str()) == Some(d_tag.as_str()) + }) + }) + .collect(); + + assert!( + matching + .iter() + .any(|e| e.id == tombstone_id && e.content.is_empty()), + "tombstone (empty content) must be the visible event at this address", + ); + assert!( + !matching.iter().any(|e| e.id == real_id), + "real offer must be replaced by tombstone", + ); + + // Sanity: a consumer would treat the empty content as 'offer withdrawn'. + // We don't enforce this at the relay; it's a consumer-side convention + // pinned by the useMeshLlmOffers hook in desktop and by + // MeshLlmOffer::is_publishable / is_expired in core. +} + +/// kind:31990 is global (`is_global_only_kind`): a stray `h` tag must not +/// channel-scope the event. The relay should accept it; a query without +/// `#h` should find it. +#[tokio::test] +#[ignore] +async fn test_offer_stray_h_tag_is_ignored() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let d_tag = format!("stray-h-{}", uuid::Uuid::new_v4().simple()); + let offer = sample_offer(&d_tag, now_secs() + 600); + let content = serde_json::to_string(&offer).unwrap(); + let fake_channel = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new( + Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16), + content, + vec![ + Tag::parse(&["d", &d_tag]).unwrap(), + Tag::parse(&["h", &fake_channel]).unwrap(), + ], + ) + .custom_created_at(Timestamp::now()) + .sign_with_keys(&keys) + .expect("sign"); + let event_id = event.id; + + let ok = client.send_event(event).await.expect("send"); + assert!( + ok.accepted, + "kind:31990 with stray h-tag should still be accepted (h-tag ignored): {}", + ok.message, + ); + + // Query globally (no #h filter) — must find the offer. + let sid = sub_id("stray-h"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16)) + .author(keys.public_key()); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + events.iter().any(|e| e.id == event_id), + "stray-h-tag offer must be retrievable via global query", + ); + + client.disconnect().await.expect("disconnect"); +} From 1cc536b03e94a602e141fc4d85f49a5e4727905b Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Thu, 21 May 2026 08:57:28 -0400 Subject: [PATCH 14/14] mesh-llm: silence dead_code in client-side NIP-98 bearer module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verifier side of the iroh-relay NIP-98 admission is fully wired and tested in sprout-relay::iroh_relay. The client side (build_nip98_bearer) is reserved for the deferred dial path — it's complete and unit-tested on its own, but has no live caller until upstream mesh-llm PR A lands. Adds a module-level #![allow(dead_code)] with a comment pointing at the deferred-dial dependency, so the workspace stays warning-clean without losing the test coverage on the helper. cargo clippy --workspace -- -D warnings + cargo fmt --check clean. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- desktop/src-tauri/src/mesh_llm/nip98.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/desktop/src-tauri/src/mesh_llm/nip98.rs b/desktop/src-tauri/src/mesh_llm/nip98.rs index 1e821459a..104913eb7 100644 --- a/desktop/src-tauri/src/mesh_llm/nip98.rs +++ b/desktop/src-tauri/src/mesh_llm/nip98.rs @@ -8,6 +8,11 @@ //! Both sides use the same `sprout_auth::nip98_canonical_url` helper, so //! path-prefix / trailing-slash / localhost-vs-127.0.0.1 drift cannot //! create undebuggable per-connection denials. +//! +//! Reserved for the deferred iroh dial path (upstream mesh-llm PR A); the +//! verifier side in `sprout_relay::iroh_relay` is already wired and tested. +//! Once the dial lands these become call-sites, not dead code. +#![allow(dead_code)] use base64::engine::general_purpose::STANDARD; use base64::Engine;