Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 19 additions & 12 deletions desktop/src-tauri/src/commands/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -666,14 +666,21 @@ fn parse_relay_event(text: &str, sub_id: &str) -> Option<nostr::Event> {
/// 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}")
Expand Down Expand Up @@ -712,13 +719,13 @@ fn resolve_pairing_relay_url(
) -> Result<String, String> {
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()),
}
}
Expand All @@ -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
}
Expand Down
26 changes: 18 additions & 8 deletions desktop/src-tauri/src/commands/pairing_relay_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -51,15 +51,15 @@ 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]
});

assert_eq!(
pairing_relay_from_nip11(&document),
PairingRelay::LegacyPath
PairingRelay::MembershipWithoutPairingRelay
);
}

Expand All @@ -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]
Expand Down