diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index aedd67854c..2ad34c016d 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -666,14 +666,21 @@ fn parse_relay_event(text: &str, sub_id: &str) -> Option { /// Pairing route discovered from the main relay's NIP-11 document. #[derive(Debug, PartialEq, Eq)] enum PairingRelay { + /// Relay advertises a dedicated NIP-AB pairing relay via `pairing_relay_url`. Configured(String), - LegacyPath, + /// Relay enforces membership (advertises NIP-43) but advertises no + /// `pairing_relay_url`. An unpaired peer is rejected at NIP-42 AUTH on the + /// main relay (`buzz-relay` gates membership at auth time, with no + /// exemption for pairing events), so pairing is impossible until the + /// operator deploys a dedicated pairing relay. + MembershipWithoutPairingRelay, + /// Open relay: an unpaired peer can pair over the main relay itself. MainRelay, } -/// Prefer the relay-advertised dedicated pairing URL. The legacy `/pair` -/// convention remains as a compatibility fallback for NIP-43 relays that do -/// not advertise the extension yet. +/// Prefer the relay-advertised dedicated pairing URL. Open relays pair over the +/// main relay directly; membership-enforcing relays (NIP-43) require the +/// operator to advertise a dedicated pairing relay via `pairing_relay_url`. async fn probe_pairing_relay(relay_url: &str) -> PairingRelay { let http_url = if let Some(rest) = relay_url.strip_prefix("wss://") { format!("https://{rest}") @@ -712,13 +719,13 @@ fn resolve_pairing_relay_url( ) -> Result { match pairing_relay { PairingRelay::Configured(url) => Ok(url), - PairingRelay::LegacyPath => { - let mut url = - url::Url::parse(main_relay_url).map_err(|e| format!("invalid relay URL: {e}"))?; - let path = url.path().trim_end_matches('/').to_string(); - url.set_path(&format!("{path}/pair")); - Ok(url.to_string()) - } + PairingRelay::MembershipWithoutPairingRelay => Err( + "This relay requires membership, so an unpaired device cannot \ + connect to it, and no device-pairing relay is configured. Ask the \ + relay operator to deploy a pairing relay (set BUZZ_PAIRING_RELAY_URL \ + / Helm pairingRelay.url)." + .to_string(), + ), PairingRelay::MainRelay => Ok(main_relay_url.to_string()), } } @@ -740,7 +747,7 @@ fn pairing_relay_from_nip11(json: &serde_json::Value) -> PairingRelay { .and_then(|value| value.as_array()) .is_some_and(|nips| nips.iter().any(|nip| nip.as_u64() == Some(43))) { - PairingRelay::LegacyPath + PairingRelay::MembershipWithoutPairingRelay } else { PairingRelay::MainRelay } diff --git a/desktop/src-tauri/src/commands/pairing_relay_tests.rs b/desktop/src-tauri/src/commands/pairing_relay_tests.rs index f0e765eb9c..a90444c722 100644 --- a/desktop/src-tauri/src/commands/pairing_relay_tests.rs +++ b/desktop/src-tauri/src/commands/pairing_relay_tests.rs @@ -38,7 +38,7 @@ async fn live_nip11_probe_discovers_configured_pairing_relay() { } #[test] -fn configured_pairing_relay_takes_precedence_over_legacy_path() { +fn configured_pairing_relay_takes_precedence_over_membership_gate() { let document = serde_json::json!({ "pairing_relay_url": "wss://pairing.buzz.xyz", "supported_nips": [43] @@ -51,7 +51,7 @@ fn configured_pairing_relay_takes_precedence_over_legacy_path() { } #[test] -fn invalid_pairing_relay_url_falls_back_to_legacy_path() { +fn membership_relay_without_pairing_url_is_not_pairable() { let document = serde_json::json!({ "pairing_relay_url": "https://pairing.buzz.xyz", "supported_nips": [43] @@ -59,7 +59,7 @@ fn invalid_pairing_relay_url_falls_back_to_legacy_path() { assert_eq!( pairing_relay_from_nip11(&document), - PairingRelay::LegacyPath + PairingRelay::MembershipWithoutPairingRelay ); } @@ -82,14 +82,24 @@ fn configured_pairing_relay_resolves_to_configured_url() { } #[test] -fn legacy_pairing_relay_appends_pair_path() { - let resolved = resolve_pairing_relay_url( +fn membership_without_pairing_relay_errors_instead_of_guessing_a_path() { + // Regression: a membership-enforcing relay with no advertised pairing + // relay must surface an actionable error, never fabricate a `/pair` URL + // that every current buzz-relay 404s on (the relay serves WS only at `/`). + let error = resolve_pairing_relay_url( "wss://flint.communities.buzz.xyz/community", - PairingRelay::LegacyPath, + PairingRelay::MembershipWithoutPairingRelay, ) - .expect("resolve legacy pairing relay"); + .expect_err("membership relay without a pairing relay is not pairable"); - assert_eq!(resolved, "wss://flint.communities.buzz.xyz/community/pair"); + assert!( + !error.contains("/pair"), + "must not fabricate a /pair URL: {error}" + ); + assert!( + error.contains("pairing relay"), + "error should name the missing pairing relay: {error}" + ); } #[test]