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) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
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) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
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<String>: 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) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
desktop/src-tauri/src/mesh_llm/nip11.rs: fetch_iroh_relay_url(ws_url)
converts ws:// -> http://, GETs / with Accept: application/nostr+json,
extracts the iroh_relay_url field from the NIP-11 JSON. Returns
Ok(None) on unreachable relays / malformed responses / missing field
so mesh-LLM silently disables itself rather than producing a deploy
mystery; only returns Err for un-fixable caller mistakes (non-ws URL).
Mirrors the probe_relay_supports_nip43 helper already in
commands/pairing.rs; deliberately doesn't share code since this is a
different decode shape with different graceful-failure semantics.
desktop mesh_llm tests: 9 -> 11 (+2 nip11 helper).
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
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) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
- 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) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
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<SocketAddr>) 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) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
- 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) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
- 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) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>