refactor(desktop): share relay request construction (#7667)

## Summary
This is a behavior-preserving refactor that gives the desktop relay HTTP
bridge one helper for authenticated request construction. It moves the
repeated `Authorization`, JSON content type, optional `x-auth-tag`,
body, and timeout setup into `build_authenticated_relay_request`.

The helper uses the final URL, auth header, and body bytes passed by the
caller. It does not sign, reserialize, normalize, send, parse, classify
errors, choose a client, or handle rate limits.

Excluded paths: media, WebSocket, Git, agents, mobile, and NIP-FI. The
query send helper only routes through the new request builder so the
timeout and `x-auth-tag` behavior are covered by the same seam.

### Related issue
None found. This prepares for the enterprise identity follow-up to be
smaller than #7663.

### Testing
- `cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --check`
passed at `356676a2f70f6bdf80cfdcb9084f1f7548359ecb`.
- `cargo test --manifest-path desktop/src-tauri/Cargo.toml
relay::tests::authenticated -- --nocapture` passed at
`356676a2f70f6bdf80cfdcb9084f1f7548359ecb`.
- `just desktop-tauri-check` passed at
`356676a2f70f6bdf80cfdcb9084f1f7548359ecb`.
- `just desktop-tauri-test` passed at
`356676a2f70f6bdf80cfdcb9084f1f7548359ecb`.
- `just desktop-tauri-clippy` passed at
`356676a2f70f6bdf80cfdcb9084f1f7548359ecb`.

Generated with Codex

Signed-off-by: coder 0 <d97ebdbb198c7237c94f84ea8bb8a73583ea067407eebd0062abbb3962527fb1@buzz.block.builderlab.xyz>
Co-authored-by: coder 0 <d97ebdbb198c7237c94f84ea8bb8a73583ea067407eebd0062abbb3962527fb1@buzz.block.builderlab.xyz>
This commit is contained in:
Brad Seiler
2026-09-16 09:48:06 -04:00
committed by GitHub
parent c507a4d488
commit 6d1f488d22
4 changed files with 223 additions and 35 deletions

View File

@@ -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};
@@ -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.
///
@@ -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);
}

View File

@@ -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.
@@ -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);
}

View File

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

View File

@@ -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() {