feat(pairing): auto-detect NIP-43 relay and route target to /pair sidecar (#470)

This commit is contained in:
tlongwell-block
2026-05-03 22:12:14 -04:00
committed by GitHub
parent 15bd9ba2ac
commit 8916825c45
2 changed files with 56 additions and 2 deletions

View File

@@ -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)
]);

View File

@@ -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<nostr_compat::Event> {
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<String> {
let arr: serde_json::Value = serde_json::from_str(text).ok()?;
let arr = arr.as_array()?;