Skip to content
Open
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
97 changes: 87 additions & 10 deletions desktop/src-tauri/src/commands/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ use nostr::ToBech32;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State};
use tokio::sync::mpsc;
use tokio_tungstenite::{connect_async, tungstenite::Message};
use tokio_tungstenite::{
connect_async,
tungstenite::{Error as WebSocketError, Message},
};
use tokio_util::sync::CancellationToken;
use zeroize::Zeroizing;

Expand Down Expand Up @@ -129,8 +132,19 @@ async fn start_pairing_session(

let ws_url = relay_ws_url_with_override(&state);
let http_url = relay_api_base_url_with_override(&state);
let pairing_relay_url = resolve_pairing_relay_url(&ws_url, probe_pairing_relay(&ws_url).await)?;
let (session, qr_payload) = PairingSession::new_source(pairing_relay_url.clone());
// NIP-43 relays gate connections on membership, so an unpaired peer can't
// reach the main relay yet — it must go through the /pair sidecar. Open
// relays (no NIP-43) accept the peer directly. We key off the relay's
// own NIP-11 declaration of NIP-43 support rather than `auth_required`,
// which is also true for plain NIP-42 / NIP-OA relays where the main
// relay is reachable.
let pairing_relay = probe_pairing_relay(&ws_url).await;
let pairing_endpoint = PairingEndpoint {
source: PairingEndpointSource::from(&pairing_relay),
url: resolve_pairing_relay_url(&ws_url, pairing_relay)?,
};

let (session, qr_payload) = PairingSession::new_source(pairing_endpoint.url.clone());
let mut qr_uri = encode_qr(&qr_payload);
if mode == PairingMode::RecoverIdentity {
qr_uri.push_str("&mode=recover");
Expand Down Expand Up @@ -162,7 +176,7 @@ async fn start_pairing_session(
*pairing.cancel.lock().map_err(|e| e.to_string())? = Some(cancel.clone());

tauri::async_runtime::spawn(pairing_ws_task(
pairing_relay_url,
pairing_endpoint,
Arc::clone(&pairing.session),
PairingTaskContext {
mode,
Expand Down Expand Up @@ -271,15 +285,15 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str
}

async fn pairing_ws_task(
relay_url: String,
pairing_endpoint: PairingEndpoint,
session: Arc<tokio::sync::Mutex<Option<PairingSession>>>,
context: PairingTaskContext,
cancel: CancellationToken,
mut outbound_rx: mpsc::Receiver<String>,
app: AppHandle,
) {
if let Err(e) = pairing_ws_task_inner(
&relay_url,
&pairing_endpoint,
&session,
&context,
&cancel,
Expand All @@ -296,19 +310,21 @@ async fn pairing_ws_task(
}

async fn pairing_ws_task_inner(
relay_url: &str,
pairing_endpoint: &PairingEndpoint,
session: &Arc<tokio::sync::Mutex<Option<PairingSession>>>,
context: &PairingTaskContext,
cancel: &CancellationToken,
outbound_rx: &mut mpsc::Receiver<String>,
app: &AppHandle,
) -> Result<(), String> {
let (ws, _) = connect_async(relay_url)
let (ws, _) = connect_async(&pairing_endpoint.url)
.await
.map_err(|e| format!("WebSocket connection failed: {e}"))?;
.map_err(|error| {
pairing_connection_error(&pairing_endpoint.url, pairing_endpoint.source, &error)
})?;
let (mut write, mut read) = ws.split();

handle_nip42_auth(&mut read, &mut write, session, relay_url).await?;
handle_nip42_auth(&mut read, &mut write, session, &pairing_endpoint.url).await?;

let our_pk = {
let guard = session.lock().await;
Expand Down Expand Up @@ -671,6 +687,67 @@ enum PairingRelay {
MainRelay,
}

struct PairingEndpoint {
url: String,
source: PairingEndpointSource,
}

#[derive(Clone, Copy)]
enum PairingEndpointSource {
Configured,
LegacyPath,
MainRelay,
}

impl From<&PairingRelay> for PairingEndpointSource {
fn from(relay: &PairingRelay) -> Self {
match relay {
PairingRelay::Configured(_) => Self::Configured,
PairingRelay::LegacyPath => Self::LegacyPath,
PairingRelay::MainRelay => Self::MainRelay,
}
}
}

fn pairing_connection_error(
relay_url: &str,
source: PairingEndpointSource,
error: &WebSocketError,
) -> String {
if matches!(source, PairingEndpointSource::LegacyPath) {
if let WebSocketError::Http(response) = error {
if response.status() == tokio_tungstenite::tungstenite::http::StatusCode::NOT_FOUND {
let endpoint = pairing_endpoint_for_display(relay_url, "/pair");
return format!(
"Pairing endpoint {endpoint} returned 404. This relay advertises NIP-43 \
without a pairing_relay_url, but nothing serves /pair. Set \
BUZZ_PAIRING_RELAY_URL or route /pair to buzz-pair-relay, then try again."
);
}
}
}

if matches!(source, PairingEndpointSource::Configured) {
let endpoint = pairing_endpoint_for_display(relay_url, "the configured endpoint");
return format!(
"Connection to configured pairing endpoint {endpoint} failed: {error}. Check the \
relay's pairing_relay_url and that the pairing service is reachable, then try again."
);
}

format!("WebSocket connection failed: {error}")
}

fn pairing_endpoint_for_display(relay_url: &str, fallback: &str) -> String {
url::Url::parse(relay_url)
.map(|mut url| {
url.set_query(None);
url.set_fragment(None);
url.to_string()
})
.unwrap_or_else(|_| fallback.to_string())
}

/// 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.
Expand Down
64 changes: 63 additions & 1 deletion desktop/src-tauri/src/commands/pairing_relay_tests.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
use super::{
pairing_relay_from_nip11, probe_pairing_relay, resolve_pairing_relay_url, PairingRelay,
pairing_connection_error, pairing_relay_from_nip11, probe_pairing_relay,
resolve_pairing_relay_url, PairingEndpointSource, PairingRelay,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_tungstenite::tungstenite::{http::Response, Error as WebSocketError};

fn websocket_http_error(status: u16) -> WebSocketError {
WebSocketError::Http(Box::new(
Response::builder()
.status(status)
.body(None)
.expect("build test HTTP response"),
))
}

#[tokio::test]
async fn live_nip11_probe_discovers_configured_pairing_relay() {
Expand Down Expand Up @@ -102,3 +113,54 @@ fn main_relay_pairing_uses_main_relay_url() {

assert_eq!(resolved, "wss://sprout-oss.stage.blox.sqprod.co");
}

#[test]
fn legacy_pairing_404_explains_the_missing_pairing_service() {
let error = websocket_http_error(404);
let message = pairing_connection_error(
"wss://relay.example.com/pair?token=do-not-display#fragment",
PairingEndpointSource::LegacyPath,
&error,
);

assert_eq!(
message,
"Pairing endpoint wss://relay.example.com/pair returned 404. This relay advertises \
NIP-43 without a pairing_relay_url, but nothing serves /pair. Set \
BUZZ_PAIRING_RELAY_URL or route /pair to buzz-pair-relay, then try again."
);
assert!(!message.contains("do-not-display"));
}

#[test]
fn configured_pairing_error_names_configured_endpoint_and_remedy() {
let error = websocket_http_error(404);
let message = pairing_connection_error(
"wss://pairing.example.com/connect?token=do-not-display#fragment",
PairingEndpointSource::Configured,
&error,
);

assert_eq!(
message,
"Connection to configured pairing endpoint wss://pairing.example.com/connect failed: \
HTTP error: 404 Not Found. Check the relay's pairing_relay_url and that the pairing \
service is reachable, then try again."
);
assert!(!message.contains("do-not-display"));
assert!(!message.contains("route /pair"));
}

#[test]
fn main_relay_pairing_404_keeps_the_generic_connection_error() {
let error = websocket_http_error(404);

assert_eq!(
pairing_connection_error(
"wss://pairing.example.com",
PairingEndpointSource::MainRelay,
&error,
),
"WebSocket connection failed: HTTP error: 404 Not Found"
);
}
29 changes: 29 additions & 0 deletions desktop/tests/e2e/mobile-pairing-qr.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,3 +449,32 @@ test("step completion respects reduced motion", async ({ page }) => {
await expect(completedContent).toHaveCSS("opacity", "1");
await expect(completedContent).toHaveCSS("transform", "none");
});

test("legacy pairing 404 explains how to configure the relay", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await page.getByTestId("settings-nav-mobile").click();

mkdirSync(SCREENSHOT_DIR, { recursive: true });

const card = page.getByTestId("mobile-pairing-card");
await card.getByTestId("start-pairing-button").click();
await expect(page.getByTestId("mobile-pairing-qr")).toBeVisible();

await emitPairingEvent(page, "pairing-error", {
message:
"Pairing endpoint wss://relay.example.com/pair returned 404. This relay advertises NIP-43 without a pairing_relay_url, but nothing serves /pair. Set BUZZ_PAIRING_RELAY_URL or route /pair to buzz-pair-relay, then try again.",
});

await expect(
card.getByText(/Set BUZZ_PAIRING_RELAY_URL or route \/pair/),
).toBeVisible();
await expect(card.getByTestId("retry-pairing-button")).toBeVisible();
await waitForAnimations(page);
await card.screenshot({
path: `${SCREENSHOT_DIR}/pairing-legacy-404.png`,
});
});