mesh-llm plan v6.1: Steps 1, 2, 4, 5

- 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>
This commit is contained in:
tlongwell-block
2026-05-19 15:33:13 -04:00
co-authored by Dawn
parent 2fed74bdcd
commit f4ca099b1c
8 changed files with 472 additions and 68 deletions
+3
View File
@@ -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,
};
+234
View File
@@ -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<String> {
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<String> {
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());
}
}
+12
View File
@@ -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!(
+96 -52
View File
@@ -38,8 +38,91 @@ pub mod relay_members {
use crate::state::AppState;
/// Outcome of the transport-neutral membership check.
///
/// 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.
///
/// 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.
///
/// `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<MembershipDecision, String> {
if !state.config.require_relay_membership {
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| format!("relay membership check failed: {e}"))?;
if is_member {
return Ok(MembershipDecision::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| 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| {
format!("relay membership check (owner) failed: {e}")
})?;
if owner_is_member {
debug!(
agent = %pubkey_hex,
owner = %owner_hex,
"NIP-OA membership granted via owner"
);
return Ok(MembershipDecision::ViaOwner(owner_pubkey));
}
}
Err(e) => {
debug!(agent = %pubkey_hex, "NIP-OA auth tag invalid: {e}");
}
}
}
}
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.
///
@@ -54,60 +137,21 @@ pub mod relay_members {
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
) -> Result<Option<nostr::PublicKey>, (StatusCode, Json<serde_json::Value>)> {
if !state.config.require_relay_membership {
return Ok(None);
}
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}"))
})?;
if is_member {
return Ok(None);
}
// 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}"))
})?;
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}"
))
})?;
if owner_is_member {
debug!(
agent = %pubkey_hex,
owner = %owner_hex,
"NIP-OA membership granted via owner"
);
return Ok(Some(owner_pubkey));
}
}
Err(e) => {
debug!(agent = %pubkey_hex, "NIP-OA auth tag invalid: {e}");
}
}
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))
}
}
Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "relay_membership_required",
"message": "You must be a relay member to access this relay"
})),
))
}
/// Extract NIP-OA owner from an auth tag without membership enforcement.
+22
View File
@@ -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<std::path::PathBuf>,
// ── 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<String>,
}
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,
})
}
}
+34 -3
View File
@@ -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<Scope, &'static s
// Command kinds — DM management, workflows, approvals
KIND_DM_OPEN | KIND_DM_ADD_MEMBER | KIND_DM_HIDE => 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));
+60 -10
View File
@@ -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<String>,
/// 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<String>,
}
/// 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<std::sync::Arc<crate::state::AppState>>,
) -> axum::response::Json<RelayInfo> {
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<String>, bool) {
pub(crate) fn nip11_facts(
state: &crate::state::AppState,
) -> (Option<String>, bool, Option<String>) {
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}"
);
}
}
+11 -3
View File
@@ -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()
}
}