mesh-llm: Max review fixups — expires_at, EndpointAddr, same-relay filter

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) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-05-19 17:20:29 -04:00
parent 72824221c0
commit f711e37a7a
4 changed files with 264 additions and 17 deletions

View File

@@ -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::<MeshLlmOffer>(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::<MeshLlmOffer>(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 {

View File

@@ -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()
})?;

View File

@@ -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<MeshLlmOffer> {
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<PathBuf, OfferPrefsError> {
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());
}

View File

@@ -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<Map<string, ResolvedOffer>>(new Map());
const [error, setError] = useState<string | null>(null);
const [localRelay, setLocalRelay] = useState<string | null>(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<string>("get_relay_ws_url");
const irohUrl = await invoke<string | null>("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 };
}