mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user