Skip to content
Merged
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
58 changes: 44 additions & 14 deletions desktop/src-tauri/src/relay.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
use reqwest::Method;
use reqwest::{Method, RequestBuilder};
use serde::de::DeserializeOwned;
use serde::Deserialize;
use sha2::{Digest, Sha256};
Expand Down Expand Up @@ -409,6 +409,37 @@ pub async fn query_relay_at_with_keys(
.await
}

/// Build an authenticated relay HTTP request using already-signed NIP-98 auth.
///
/// The caller owns request ordering around this helper: rate-limit admission,
/// egress checks, URL/body construction, send, error classification, and response
/// parsing remain outside. `body` is accepted as final bytes so this helper never
/// reserializes, normalizes, or changes the payload that was signed.
fn build_authenticated_relay_request(
client: &reqwest::Client,
method: Method,
url: &str,
auth: &str,
body: Option<Vec<u8>>,
auth_tag: Option<&str>,
timeout: Option<std::time::Duration>,
) -> RequestBuilder {
let mut request = client.request(method, url).header("Authorization", auth);
if body.is_some() {
request = request.header("Content-Type", "application/json");
}
if let Some(tag) = auth_tag {
request = request.header("x-auth-tag", tag);
}
if let Some(timeout) = timeout {
request = request.timeout(timeout);
}
if let Some(body) = body {
request = request.body(body);
}
request
}

/// Issue an authenticated `POST /query` and parse the response, applying the
/// per-request `timeout` that bounds a stalled or half-open relay connection.
///
Expand All @@ -425,19 +456,18 @@ async fn send_query_request(
body_bytes: Vec<u8>,
timeout: std::time::Duration,
) -> Result<Vec<nostr::Event>, String> {
let mut request = http_client
.post(url)
.header("Authorization", auth)
.header("Content-Type", "application/json")
.timeout(timeout);
if let Some(tag) = auth_tag {
request = request.header("x-auth-tag", tag);
}
let response = request
.body(body_bytes)
.send()
.await
.map_err(|e| classify_request_error(&e))?;
let response = build_authenticated_relay_request(
http_client,
Method::POST,
url,
auth,
Some(body_bytes),
auth_tag,
Some(timeout),
)
.send()
.await
.map_err(|e| classify_request_error(&e))?;
if !response.status().is_success() {
return Err(relay_error_message(response).await);
}
Expand Down
23 changes: 14 additions & 9 deletions desktop/src-tauri/src/relay/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use serde::de::DeserializeOwned;
use crate::app_state::AppState;

use super::{
build_nip98_auth_header, classify_request_error, parse_json_response,
relay_api_base_url_with_override, relay_error_message,
build_authenticated_relay_request, build_nip98_auth_header, classify_request_error,
parse_json_response, relay_api_base_url_with_override, relay_error_message,
};

/// Execute an authenticated GET against the active relay and decode its JSON body.
Expand All @@ -23,13 +23,18 @@ pub async fn get_relay_json<T: DeserializeOwned>(
path_with_query
);
let auth = build_nip98_auth_header(&Method::GET, &url, &[], state)?;
let response = state
.http_client
.get(&url)
.header("Authorization", auth)
.send()
.await
.map_err(|error| classify_request_error(&error))?;
let response = build_authenticated_relay_request(
&state.http_client,
Method::GET,
&url,
&auth,
None,
None,
None,
)
.send()
.await
.map_err(|error| classify_request_error(&error))?;
if !response.status().is_success() {
return Err(relay_error_message(response).await);
}
Expand Down
21 changes: 12 additions & 9 deletions desktop/src-tauri/src/relay/submit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,18 @@ pub async fn submit_signed_event_at_with_keys(
crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?;
let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?;

let response = state
.http_client
.post(&url)
.header("Authorization", auth_header)
.header("Content-Type", "application/json")
.body(body_bytes)
.send()
.await
.map_err(|e| classify_request_error(&e))?;
let response = build_authenticated_relay_request(
&state.http_client,
Method::POST,
&url,
&auth_header,
Some(body_bytes),
None,
None,
)
.send()
.await
.map_err(|e| classify_request_error(&e))?;

if !response.status().is_success() {
return Err(relay_error_message(response).await);
Expand Down
156 changes: 153 additions & 3 deletions desktop/src-tauri/src/relay/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,162 @@
//! Extracted from `relay.rs` to keep that module under the file-size ratchet.

use super::{
build_profile_event, classify_intercepted_response, effective_agent_relay_url,
extract_retry_in_hint, parse_command_response, relay_http_base_url, MALFORMED_RESPONSE_MESSAGE,
build_authenticated_relay_request, build_profile_event, classify_intercepted_response,
effective_agent_relay_url, extract_retry_in_hint, parse_command_response, relay_http_base_url,
MALFORMED_RESPONSE_MESSAGE,
};
use serde::Deserialize;

// ── extract_retry_in_hint ────────────────────────────────────────────────
const AUTH_HEADER: reqwest::header::HeaderName = reqwest::header::AUTHORIZATION;
const CONTENT_TYPE_HEADER: reqwest::header::HeaderName = reqwest::header::CONTENT_TYPE;
const X_AUTH_TAG_HEADER: reqwest::header::HeaderName =
reqwest::header::HeaderName::from_static("x-auth-tag");

fn assert_header_values(
request: &reqwest::Request,
expected: &[(&reqwest::header::HeaderName, &[&str])],
) {
let headers = request.headers();
let actual_names: std::collections::BTreeSet<String> = headers
.keys()
.map(|name| name.as_str().to_ascii_lowercase())
.collect();
let expected_names: std::collections::BTreeSet<String> = expected
.iter()
.map(|(name, _)| name.as_str().to_ascii_lowercase())
.collect();
assert_eq!(actual_names, expected_names, "unexpected header set");

for (name, values) in expected {
let actual: Vec<&str> = headers
.get_all(*name)
.iter()
.map(|value| value.to_str().expect("test header must be visible text"))
.collect();
assert_eq!(actual, *values, "unexpected values for {name}");
}
}

fn request_body_bytes(request: &reqwest::Request) -> Option<&[u8]> {
request.body().and_then(reqwest::Body::as_bytes)
}

#[test]
fn authenticated_get_request_matches_existing_shape() {
let client = reqwest::Client::new();
let request = build_authenticated_relay_request(
&client,
reqwest::Method::GET,
"https://relay.example/info?verbose=1",
"Nostr signed-get",
None,
None,
None,
)
.build()
.expect("request should build");

assert_eq!(request.method(), reqwest::Method::GET);
assert_eq!(
request.url().as_str(),
"https://relay.example/info?verbose=1"
);
assert_header_values(&request, &[(&AUTH_HEADER, &["Nostr signed-get"])]);
assert!(request.timeout().is_none());
assert!(request_body_bytes(&request).is_none());
}

#[test]
fn authenticated_json_post_request_preserves_final_body_bytes() {
let client = reqwest::Client::new();
let body = br#"[{"kinds":[9],"limit":1}]"#.to_vec();
let request = build_authenticated_relay_request(
&client,
reqwest::Method::POST,
"https://relay.example/query",
"Nostr signed-post",
Some(body.clone()),
None,
None,
)
.build()
.expect("request should build");

assert_eq!(request.method(), reqwest::Method::POST);
assert_eq!(request.url().as_str(), "https://relay.example/query");
assert_header_values(
&request,
&[
(&AUTH_HEADER, &["Nostr signed-post"]),
(&CONTENT_TYPE_HEADER, &["application/json"]),
],
);
assert!(
request.headers().get("x-auth-tag").is_none(),
"absence of x-auth-tag must remain absence, not an empty header"
);
assert!(request.timeout().is_none());
assert_eq!(request_body_bytes(&request), Some(body.as_slice()));
}

#[test]
fn authenticated_json_post_request_keeps_auth_tag_when_present() {
let client = reqwest::Client::new();
let body = b"{}".to_vec();
let request = build_authenticated_relay_request(
&client,
reqwest::Method::POST,
"https://relay.example/event-submit",
"Nostr signed-event",
Some(body.clone()),
Some("owner-auth-tag"),
None,
)
.build()
.expect("request should build");

assert_eq!(request.method(), reqwest::Method::POST);
assert_eq!(request.url().as_str(), "https://relay.example/event-submit");
assert_header_values(
&request,
&[
(&AUTH_HEADER, &["Nostr signed-event"]),
(&CONTENT_TYPE_HEADER, &["application/json"]),
(&X_AUTH_TAG_HEADER, &["owner-auth-tag"]),
],
);
assert!(request.timeout().is_none());
assert_eq!(request_body_bytes(&request), Some(body.as_slice()));
}

#[test]
fn authenticated_query_request_preserves_timeout() {
let client = reqwest::Client::new();
let timeout = std::time::Duration::from_millis(250);
let request = build_authenticated_relay_request(
&client,
reqwest::Method::POST,
"https://relay.example/query",
"Nostr signed-query",
Some(b"[]".to_vec()),
None,
Some(timeout),
)
.build()
.expect("request should build");

assert_eq!(request.method(), reqwest::Method::POST);
assert_eq!(request.url().as_str(), "https://relay.example/query");
assert_header_values(
&request,
&[
(&AUTH_HEADER, &["Nostr signed-query"]),
(&CONTENT_TYPE_HEADER, &["application/json"]),
],
);
assert_eq!(request.timeout(), Some(&timeout));
assert_eq!(request_body_bytes(&request), Some(&b"[]"[..]));
}

#[test]
fn extracts_hint_from_429_body() {
Expand Down
Loading