From 3f2ec071312865e4f90ba22361bf44111c7154db Mon Sep 17 00:00:00 2001
From: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Date: Tue, 12 May 2026 19:51:21 -0400
Subject: [PATCH] fix(nip11): advertise auth_required: true to match actual
 enforcement

The REQ, EVENT, and COUNT handlers in this relay unconditionally require
AuthState::Authenticated. The previous value (config.require_auth_token)
reflected the REST API token toggle, not WebSocket protocol auth, so
NIP-11 was lying about behaviour.

This breaks the desktop pairing flow: probe_relay_requires_auth in
desktop/src-tauri/src/commands/pairing.rs reads limitation.auth_required
to decide whether to route the phone's QR code URL through the
unauthenticated /pair sidecar. With the wrong NIP-11 value, the QR
pointed at the bare relay URL and the phone could not pair against a
NIP-43 enforcing relay.

Tighten the existing e2e nip11 test to pin the invariant: 'is a bool'
becomes 'is true', with a comment naming what enforces it.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
---
 crates/sprout-relay/src/config.rs            |  7 +--
 crates/sprout-relay/src/nip11.rs             | 53 +++++++++++++-------
 crates/sprout-relay/src/router.rs            |  4 +-
 crates/sprout-test-client/tests/e2e_relay.rs | 12 ++---
 4 files changed, 47 insertions(+), 29 deletions(-)

diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs
index 9aa00aa7dd..23b2cf4a78 100644
--- a/crates/sprout-relay/src/config.rs
+++ b/crates/sprout-relay/src/config.rs
@@ -39,7 +39,8 @@ pub struct Config {
     pub send_buffer_size: usize,
     /// Authentication provider configuration.
     pub auth: sprout_auth::AuthConfig,
-    /// Whether clients must authenticate via NIP-42 before sending events.
+    /// Whether REST API requests must present a valid token. Independent of
+    /// WebSocket protocol auth, which is *always* required by REQ/EVENT/COUNT.
     pub require_auth_token: bool,
     /// Comma-separated list of allowed CORS origins.
     /// If empty, permissive CORS is used (dev mode).
@@ -197,8 +198,8 @@ impl Config {
 
         if !require_auth_token {
             warn!(
-                "SPROUT_REQUIRE_AUTH_TOKEN is false — relay accepts unauthenticated connections. \
-                 Set to true for production."
+                "SPROUT_REQUIRE_AUTH_TOKEN is false — REST API requests bypass token auth. \
+                 WebSocket protocol auth is unaffected. Set to true for production."
             );
         }
 
diff --git a/crates/sprout-relay/src/nip11.rs b/crates/sprout-relay/src/nip11.rs
index 1a1694c222..d3cf6d73dc 100644
--- a/crates/sprout-relay/src/nip11.rs
+++ b/crates/sprout-relay/src/nip11.rs
@@ -48,7 +48,8 @@ pub struct RelayLimitation {
     pub max_subid_length: Option<u32>,
     /// Minimum proof-of-work difficulty required for events.
     pub min_pow_difficulty: Option<u32>,
-    /// Whether NIP-42 authentication is required before sending events.
+    /// Whether NIP-42 authentication is required before subscribing or
+    /// publishing events.
     pub auth_required: bool,
     /// Whether payment is required to use the relay.
     pub payment_required: bool,
@@ -56,12 +57,32 @@ pub struct RelayLimitation {
     pub restricted_writes: bool,
 }
 
+/// Canonical `RelayLimitation` advertised by this relay.
+///
+/// `auth_required` is always `true`: the REQ, EVENT, and COUNT handlers
+/// unconditionally reject connections that are not in
+/// `AuthState::Authenticated`. This is independent of the REST API token
+/// toggle (`config.require_auth_token`).
+fn relay_limitation() -> RelayLimitation {
+    RelayLimitation {
+        max_message_length: Some(MAX_FRAME_BYTES as u64),
+        max_subscriptions: Some(1024),
+        max_filters: Some(10),
+        max_limit: Some(10_000),
+        max_subid_length: Some(256),
+        min_pow_difficulty: None,
+        auth_required: true,
+        payment_required: false,
+        restricted_writes: true,
+    }
+}
+
 impl RelayInfo {
-    /// Builds a `RelayInfo` document from the relay's runtime config.
+    /// Builds the relay's NIP-11 information document.
     ///
     /// `relay_pubkey` is the relay's own signing pubkey (hex), advertised as the
     /// NIP-11 `self` field for NIP-43 membership verification.
-    pub fn from_config(config: &crate::config::Config, relay_pubkey: Option<&str>) -> Self {
+    pub fn build(relay_pubkey: Option<&str>) -> Self {
         Self {
             name: "Sprout Relay".to_string(),
             description: "Sprout — private team communication relay".to_string(),
@@ -70,17 +91,7 @@ impl RelayInfo {
             supported_nips: SUPPORTED_NIPS.to_vec(),
             software: "https://github.com/sprout-rs/sprout".to_string(),
             version: env!("CARGO_PKG_VERSION").to_string(),
-            limitation: Some(RelayLimitation {
-                max_message_length: Some(MAX_FRAME_BYTES as u64),
-                max_subscriptions: Some(1024),
-                max_filters: Some(10),
-                max_limit: Some(10_000),
-                max_subid_length: Some(256),
-                min_pow_difficulty: None,
-                auth_required: config.require_auth_token,
-                payment_required: false,
-                restricted_writes: true,
-            }),
+            limitation: Some(relay_limitation()),
             relay_self: relay_pubkey.map(|s| s.to_string()),
         }
     }
@@ -97,10 +108,7 @@ pub async fn relay_info_handler(
     } else {
         None
     };
-    axum::response::Json(RelayInfo::from_config(
-        &state.config,
-        relay_pubkey.as_deref(),
-    ))
+    axum::response::Json(RelayInfo::build(relay_pubkey.as_deref()))
 }
 
 #[cfg(test)]
@@ -129,6 +137,15 @@ mod tests {
         );
     }
 
+    #[test]
+    fn auth_required_is_advertised_true() {
+        // REQ, EVENT, and COUNT all unconditionally require
+        // `AuthState::Authenticated` (see `crates/sprout-relay/src/handlers/`).
+        // The NIP-11 doc must reflect that or clients (e.g. the desktop pair
+        // flow) misroute unauthenticated peers.
+        assert!(relay_limitation().auth_required);
+    }
+
     #[test]
     fn supported_nips_are_sorted() {
         let mut sorted = SUPPORTED_NIPS.to_vec();
diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs
index eed52be783..8cfdd3acb3 100644
--- a/crates/sprout-relay/src/router.rs
+++ b/crates/sprout-relay/src/router.rs
@@ -160,7 +160,7 @@ async fn nip11_or_ws_handler(
     };
 
     if accept.contains("application/nostr+json") {
-        let info = RelayInfo::from_config(&state.config, relay_pubkey.as_deref());
+        let info = RelayInfo::build(relay_pubkey.as_deref());
         return Json(info).into_response();
     }
 
@@ -179,7 +179,7 @@ async fn nip11_or_ws_handler(
                 }
             }
             // Not a WS request and not asking for nostr+json — serve NIP-11 as fallback.
-            let info = RelayInfo::from_config(&state.config, relay_pubkey.as_deref());
+            let info = RelayInfo::build(relay_pubkey.as_deref());
             Json(info).into_response()
         }
     }
diff --git a/crates/sprout-test-client/tests/e2e_relay.rs b/crates/sprout-test-client/tests/e2e_relay.rs
index 3876f45f1e..30f77fe21f 100644
--- a/crates/sprout-test-client/tests/e2e_relay.rs
+++ b/crates/sprout-test-client/tests/e2e_relay.rs
@@ -605,12 +605,12 @@ async fn test_nip11_relay_info() {
         Some(1024),
         "limitation.max_subscriptions must be 1024"
     );
-    assert!(
-        limitation
-            .get("auth_required")
-            .and_then(|v| v.as_bool())
-            .is_some(),
-        "limitation.auth_required must be a boolean"
+    // The REQ, EVENT, and COUNT handlers unconditionally require an
+    // authenticated connection, so the NIP-11 doc must advertise that.
+    assert_eq!(
+        limitation.get("auth_required").and_then(|v| v.as_bool()),
+        Some(true),
+        "limitation.auth_required must be true — REQ/EVENT/COUNT require NIP-42 auth"
     );
 }
 
