diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 39fdb850c91..e7e272d0a71 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -67,7 +67,7 @@ const overrides = new Map([ ["src-tauri/src/huddle/relay_api.rs", 510], // audio relay recv task + per-peer frame counting for remote human TTS interrupt ["src-tauri/src/huddle/tts.rs", 1030], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fades + 18 unit tests for remote interrupt mechanism ["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test - ["src-tauri/src/commands/pairing.rs", 550], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + event parsing helpers + ["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers ["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations ["src/shared/api/tauri.ts", 1210], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) ]); diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index 9e2d41a01cb..9583933bab8 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -122,7 +122,18 @@ pub async fn start_pairing( let ws_url = relay_ws_url_with_override(&state); let http_url = relay_api_base_url_with_override(&state); - let (session, qr_payload) = PairingSession::new_source(ws_url.clone()); + // Detect NIP-43: if the relay requires auth, the target device should + // connect to the /pair sidecar instead of the main relay. + let qr_relay_url = if probe_relay_requires_auth(&ws_url).await { + let mut url = url::Url::parse(&ws_url).map_err(|e| format!("invalid relay URL: {e}"))?; + let path = url.path().trim_end_matches('/').to_string(); + url.set_path(&format!("{path}/pair")); + url.to_string() + } else { + ws_url.clone() + }; + + let (session, qr_payload) = PairingSession::new_source(qr_relay_url); let qr_uri = encode_qr(&qr_payload); let payload_json = serde_json::json!({ @@ -445,6 +456,49 @@ fn parse_relay_event(text: &str, sub_id: &str) -> Option { serde_json::from_value(arr[2].clone()).ok() } +/// Check the relay's NIP-11 information document to determine if auth is +/// required (indicating NIP-43 access control). Returns `true` if the relay +/// advertises `limitation.auth_required: true`, `false` otherwise. +/// +/// Converts the WebSocket URL to HTTP(S) and fetches `GET /` with +/// `Accept: application/nostr+json` per NIP-11. +async fn probe_relay_requires_auth(relay_url: &str) -> bool { + // Convert ws(s):// to http(s):// for the NIP-11 fetch. + let http_url = if let Some(rest) = relay_url.strip_prefix("wss://") { + format!("https://{rest}") + } else if let Some(rest) = relay_url.strip_prefix("ws://") { + format!("http://{rest}") + } else { + return false; + }; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap_or_default(); + + let resp = match client + .get(&http_url) + .header("Accept", "application/nostr+json") + .send() + .await + { + Ok(r) => r, + Err(_) => return false, // can't reach relay — assume open + }; + + let json: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(_) => return false, + }; + + // Check limitation.auth_required per NIP-11. + json.get("limitation") + .and_then(|l| l.get("auth_required")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) +} + fn parse_auth_challenge(text: &str) -> Option { let arr: serde_json::Value = serde_json::from_str(text).ok()?; let arr = arr.as_array()?;