Files
buzz/crates/buzz-cli/src/client.rs
Will Pfleger 65e864301d feat: send a User-Agent on relay WebSocket upgrades (#9246)
Relay WebSocket clients in this repo now send a `User-Agent` of the form
`<name>/<version>` on the upgrade request. The version comes from the
build.

| Client | User-Agent |
|---|---|
| `buzz-acp` | `buzz-acp/<crate version>` |
| `buzz-cli` | `buzz-cli/<crate version>` |
| `buzz-ws-client` (default) | `buzz-ws-client/<crate version>` |
| `buzz-pairing-cli` | `buzz-pairing-cli/<crate version>` |
| `buzz-audio-client` (default) | `buzz-audio-client/<crate version>` |
| mobile (relay and huddle sockets) | `Buzz-Mobile/<app version>` |

- **`buzz-ws-client`**: every connect path sends the default. A
`User-Agent` in the caller's `connect_with_headers` headers replaces it,
so other consumers can identify themselves without any new API.
`buzz-cli` uses this to send its own name.
- **`buzz-audio-client`**: keeps a `User-Agent` already on the
host-supplied request, and adds its default otherwise.
- **Mobile**: `openRelayWebSocket` sets the HTTP client's `userAgent`,
which also covers the huddle transport. Startup waits up to 500 ms for
the app version from `package_info_plus` before the app runs, so the
first socket carries it; if the lookup fails or times out, the value is
`Buzz-Mobile`.

Each client has a test asserting the header on the upgrade request.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-11-08 20:43:55 -05:00

4299 lines
173 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::future::Future;
use std::time::Duration;
use base64::Engine;
use nostr::{EventBuilder, JsonUtil, Kind, PublicKey, Tag};
use sha2::{Digest, Sha256};
use crate::credentials::{
Credentials, FEDERATED_IDENTITY_ADAPTER_ENV, FEDERATED_IDENTITY_TOKEN_ENV,
};
use crate::error::CliError;
/// NIP-FI assertion header (`docs/nips/NIP-FI.md` §Client-attached transport).
const FEDERATED_IDENTITY_HEADER: &str = "Nostr-Federated-Identity";
/// `User-Agent` sent on every WebSocket upgrade.
const USER_AGENT: &str = concat!("buzz-cli/", env!("CARGO_PKG_VERSION"));
/// Descriptor returned by the relay after a successful upload.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BlobDescriptor {
/// Public URL of the uploaded blob.
pub url: String,
/// Hex-encoded SHA-256 of the file content.
pub sha256: String,
/// File size in bytes.
pub size: u64,
/// MIME type (e.g. `image/jpeg`).
#[serde(rename = "type")]
pub mime_type: String,
/// Unix timestamp when the file was uploaded.
pub uploaded: i64,
/// Image dimensions as `<width>x<height>` (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub dim: Option<String>,
/// Blurhash placeholder string (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub blurhash: Option<String>,
/// Thumbnail URL (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb: Option<String>,
/// Duration in seconds for video/audio (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
}
/// Build an `imeta` tag array from a BlobDescriptor (NIP-92 media metadata).
pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
let mut tag = vec![
"imeta".to_string(),
format!("url {}", d.url),
format!("m {}", d.mime_type),
format!("x {}", d.sha256),
format!("size {}", d.size),
];
if let Some(ref dim) = d.dim {
tag.push(format!("dim {dim}"));
}
if let Some(ref bh) = d.blurhash {
tag.push(format!("blurhash {bh}"));
}
if let Some(ref th) = d.thumb {
tag.push(format!("thumb {th}"));
}
if let Some(dur) = d.duration {
tag.push(format!("duration {dur}"));
}
tag
}
/// MIME types the relay stores as inline media; everything else is a generic
/// file it validates, stores and serves as a download.
const MEDIA_MIMES: &[&str] = &[
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"video/mp4",
];
/// Maximum file size for image uploads (50 MB).
const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024;
/// Maximum file size for video uploads (500 MB).
const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024;
/// Maximum file size for generic file uploads (100 MB), the relay's default.
const MAX_FILE_BYTES: u64 = 100 * 1024 * 1024;
fn relay_server_tag(relay_url: &str) -> Option<String> {
let authority = buzz_core::tenant::relay_url_authority(relay_url);
if authority.is_empty() {
None
} else {
Some(authority)
}
}
/// Maximum number of attempts per request (initial attempt + two retries).
/// The WebSocket connect and upgrade, before any NIP-42 wait.
const WS_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
/// Connections a WebSocket publish may open to complete NIP-42 sign-in.
const WS_SIGN_IN_ATTEMPTS: u32 = 3;
/// Times an HTTP proof is signed while its NIP-FI assertion keeps expiring.
const AUTHORIZE_ROUNDS: u32 = 3;
/// How old an HTTP proof may be when sent, well inside the relay's 60 s.
const PROOF_MAX_AGE_SECS: u64 = 30;
const RETRY_MAX_ATTEMPTS: u32 = 3;
/// Base sleep durations for full-jitter exponential backoff.
/// `RETRY_BASE_SECS[i]` is the ceiling for attempt `i` before attempt `i+1`.
const RETRY_BASE_SECS: [f64; 2] = [0.5, 1.5];
/// Maximum seconds to honour a relay-provided `retry in Ns` hint from a 429.
/// Defensive cap against pathological hints; real relay hints observed up to ~24 s.
const RETRY_IN_MAX_SECS: u64 = 30;
/// Returns a full-jitter delay for attempt `i`: a random duration in `[0, RETRY_BASE_SECS[i])`.
fn jitter_delay(attempt: u32) -> Duration {
Duration::from_secs_f64(RETRY_BASE_SECS[attempt as usize] * rand::random::<f64>())
}
/// Read an env var as a `u64` of seconds and return the corresponding `Duration`.
/// Falls back to `default` if the var is unset, unparseable, or zero (zero is treated
/// as invalid to prevent accidentally disabling all timeouts).
fn env_duration_secs(name: &str, default: u64) -> Duration {
std::env::var(name)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|&n| n > 0)
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(default))
}
/// Scan a plain-text string for a `retry in <N>s` pattern and return `N`.
///
/// Matches the literal substring `retry in ` followed by one or more ASCII digits
/// and the character `s`. Works on both extracted field values (`rate-limited:
/// quota exceeded; retry in 4s`) and substrings of raw relay JSON bodies.
/// Returns `None` when the pattern is absent or the digit sequence is empty.
fn parse_retry_hint_text(text: &str) -> Option<u64> {
const PREFIX: &str = "retry in ";
let after = text.find(PREFIX).map(|i| &text[i + PREFIX.len()..])?;
let end = after
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(after.len());
if end == 0 || after.as_bytes().get(end) != Some(&b's') {
return None;
}
after[..end].parse::<u64>().ok()
}
/// Parse a `retry in Ns` hint from a relay 429 JSON body.
///
/// Extracts the `error` or `message` field and delegates to
/// `parse_retry_hint_text`. Returns `None` when the body is not valid JSON or
/// the extracted field does not contain the pattern.
#[cfg(test)]
fn parse_retry_in_secs(body: &str) -> Option<u64> {
let text = serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| {
v.get("error")
.or_else(|| v.get("message"))
.and_then(|m| m.as_str().map(str::to_string))
})?;
parse_retry_hint_text(&text)
}
/// Extract the `error` or `message` field from a relay JSON error body.
///
/// Production relay error bodies are shaped as `{"error":"..."}` (via `api_error()`).
/// Returns the extracted field value, or `None` if the body is not valid JSON or
/// neither field is present. The raw body should be retained for diagnostics when
/// `None` is returned.
fn extract_relay_message_field(body: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| {
v.get("error")
.or_else(|| v.get("message"))
.and_then(|m| m.as_str().map(str::to_string))
})
}
fn should_retry_legacy_upload(status: reqwest::StatusCode) -> bool {
matches!(
status,
reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::METHOD_NOT_ALLOWED
)
}
/// Returns `true` for moderation command kinds (9040–9044).
///
/// These events execute immediately at the relay without dedup, so they must
/// not be blindly retried on ambiguous outcomes.
fn is_moderation_kind(kind: u16) -> bool {
matches!(kind, 9040..=9044)
}
/// Returns `true` for HTTP status codes that indicate a successful response
/// (equivalent to `reqwest::StatusCode::is_success()` for u16).
fn resp_was_success(status: u16) -> bool {
(200..300).contains(&status)
}
/// Returns `true` if a stored-event exhaustion error is ambiguous (the relay
/// may have executed the command) and should be converted to `DeliveryUnknown`.
///
/// Connect failures are definitively pre-relay (never executed) so they remain
/// retryable. Canonical pre-ingest 429 (`Relay{status:429}`) was provably
/// rejected before storage — also retryable. Everything else (timeout, body
/// loss, decode error, proxy 502-504) may have crossed the relay's storage
/// boundary and must not invite an outer re-sign.
fn is_stored_event_exhaustion_ambiguous(e: &CliError) -> bool {
match e {
CliError::Network(net_err) => {
// Connect is definitively pre-relay.
if net_err.is_connect() {
return false;
}
// Timeout, body, decode, request — ambiguous.
net_err.is_timeout() || net_err.is_body() || net_err.is_decode() || net_err.is_request()
}
// Canonical pre-ingest 429 — relay did not store.
CliError::Relay { status: 429, .. } => false,
// Proxy 502-504 — relay may have accepted before the proxy failed.
CliError::Relay {
status: 502..=504, ..
} => true,
// All other variants are not retried by with_retry_body; not ambiguous.
_ => false,
}
}
fn is_safe_media_path_segment(sha256_ext: &str) -> bool {
let segments: Vec<&str> = sha256_ext.split('.').collect();
match segments.as_slice() {
[hash] => is_lower_hex_sha256(hash),
[hash, ext] => is_lower_hex_sha256(hash) && is_safe_media_ext(ext),
[hash, "thumb", "webp" | "jpg"] => is_lower_hex_sha256(hash),
_ => false,
}
}
fn is_lower_hex_sha256(value: &str) -> bool {
value.len() == 64 && value.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))
}
fn is_safe_media_ext(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 8
&& value.chars().all(|c| matches!(c, 'a'..='z' | '0'..='9'))
}
fn media_url_from_input(relay_url: &str, input: &str) -> Result<String, CliError> {
let input = input.trim();
if input.starts_with("http://") || input.starts_with("https://") {
let parsed = url::Url::parse(input)
.map_err(|e| CliError::Usage(format!("invalid media URL: {e}")))?;
if !parsed.path().starts_with("/media/") {
return Err(CliError::Usage(
"media URL must point at a /media/ path".to_string(),
));
}
let Some(sha256_ext) = parsed.path().strip_prefix("/media/") else {
return Err(CliError::Usage(
"media URL must point at a /media/ path".to_string(),
));
};
if !is_safe_media_path_segment(sha256_ext) {
return Err(CliError::Usage(
"media path must be sha256, sha256.ext, or sha256.thumb.{webp,jpg}".to_string(),
));
}
let relay = url::Url::parse(relay_url)
.map_err(|e| CliError::Usage(format!("invalid relay URL: {e}")))?;
if parsed.scheme() != relay.scheme()
|| parsed.host_str() != relay.host_str()
|| parsed.port_or_known_default() != relay.port_or_known_default()
{
return Err(CliError::Usage(
"refusing to sign media GET for a non-relay origin".to_string(),
));
}
return Ok(input.to_string());
}
if input.contains("://") {
return Err(CliError::Usage(
"media URL must use http:// or https://".to_string(),
));
}
let sha256_ext = input.trim_start_matches("/media/");
if sha256_ext.is_empty() {
return Err(CliError::Usage(
"media input must be a URL or sha256[.ext]".to_string(),
));
}
if !is_safe_media_path_segment(sha256_ext) {
return Err(CliError::Usage(
"media input must be sha256, sha256.ext, or sha256.thumb.{webp,jpg}".to_string(),
));
}
Ok(format!(
"{}/media/{sha256_ext}",
relay_url.trim_end_matches('/')
))
}
/// Blossom (BUD-01) `t=get` proof for `media_url`, scoped to its server.
fn blossom_get_event(media_url: &str) -> Result<EventBuilder, CliError> {
blossom_event(
"get",
None,
relay_server_tag(media_url)
.ok_or_else(|| CliError::Usage(format!("invalid media URL: {media_url}")))?,
"Get media",
)
}
/// Blossom (BUD-02) `t=upload` proof for blob `sha256` on this relay.
fn blossom_upload_event(sha256: &str, relay_url: &str) -> Result<EventBuilder, CliError> {
blossom_event(
"upload",
Some(sha256),
relay_server_tag(relay_url)
.ok_or_else(|| CliError::Usage(format!("invalid relay URL: {relay_url}")))?,
"Upload file",
)
}
fn blossom_event(
verb: &str,
sha256: Option<&str>,
server: String,
content: &str,
) -> Result<EventBuilder, CliError> {
let exp = (nostr::Timestamp::now().as_secs() + 60).to_string();
let tag = |parts: &[&str]| {
Tag::parse(parts.iter().copied()).map_err(|e| CliError::Other(e.to_string()))
};
let mut tags = vec![tag(&["t", verb])?];
if let Some(sha256) = sha256 {
tags.push(tag(&["x", sha256])?);
}
tags.push(tag(&["expiration", &exp])?);
tags.push(tag(&["server", &server])?);
Ok(EventBuilder::new(Kind::from(24242), content).tags(tags))
}
#[cfg(test)]
mod media_download_tests {
use super::*;
#[test]
fn media_url_from_sha_uses_relay_media_path() {
let hash = "a".repeat(64);
assert_eq!(
media_url_from_input("https://relay.example", &format!("{hash}.jpg")).unwrap(),
format!("https://relay.example/media/{hash}.jpg")
);
assert_eq!(
media_url_from_input("https://relay.example/", &format!("/media/{hash}.jpg")).unwrap(),
format!("https://relay.example/media/{hash}.jpg")
);
}
#[test]
fn media_url_accepts_only_same_relay_media_urls() {
let hash = "a".repeat(64);
assert!(media_url_from_input(
"https://relay.example:443",
&format!("https://relay.example/media/{hash}.jpg")
)
.is_ok());
assert!(media_url_from_input(
"https://relay.example",
&format!("http://relay.example/media/{hash}.jpg")
)
.is_err());
assert!(media_url_from_input(
"https://relay.example",
&format!("https://evil.example/media/{hash}.jpg")
)
.is_err());
assert!(media_url_from_input(
"https://relay.example",
&format!("https://relay.example/media-evil/{hash}.jpg")
)
.is_err());
assert!(media_url_from_input(
"https://relay.example",
&format!("ftp://relay.example/media/{hash}.jpg")
)
.is_err());
}
#[test]
fn media_url_rejects_path_confusion_and_non_hash_inputs() {
for input in [
"abc123.jpg",
"../evil",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/evil.jpg",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.JPG",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.eviltoolong",
"https://relay.example/media/abc123.jpg",
"https://relay.example/media/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.JPG",
] {
assert!(
media_url_from_input("https://relay.example", input).is_err(),
"input should be rejected: {input}"
);
}
}
#[test]
fn media_get_auth_header_is_server_scoped() {
let keys = nostr::Keys::generate();
let hash = "a".repeat(64);
let event = blossom_get_event(&format!("https://relay.example:443/media/{hash}.jpg"))
.unwrap()
.sign_with_keys(&keys)
.unwrap();
assert_eq!(event.kind, Kind::from(24242));
let tags: Vec<Vec<String>> = event
.tags
.iter()
.map(|tag| tag.as_slice().to_vec())
.collect();
assert!(tags.iter().any(|tag| tag.as_slice() == ["t", "get"]));
assert!(tags
.iter()
.any(|tag| tag.as_slice() == ["server", "relay.example"]));
assert!(!tags
.iter()
.any(|tag| tag.first().map(String::as_str) == Some("x")));
}
#[test]
fn legacy_upload_retry_statuses_are_narrow() {
assert!(should_retry_legacy_upload(reqwest::StatusCode::NOT_FOUND));
assert!(should_retry_legacy_upload(
reqwest::StatusCode::METHOD_NOT_ALLOWED
));
assert!(!should_retry_legacy_upload(
reqwest::StatusCode::UNPROCESSABLE_ENTITY
));
assert!(!should_retry_legacy_upload(
reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE
));
}
}
pub(crate) const QUERY_PAGE_SIZE: u32 = 500;
/// The most pages one limited query reads before failing, so a relay that
/// never runs out of events to skip cannot keep a command running forever.
pub(crate) const MAX_QUERY_PAGES: usize = 100;
fn stalled_query(detail: &str) -> CliError {
CliError::Relay {
status: 200,
body: format!("query pagination stopped: {detail}"),
}
}
/// Move the composite cursor to the page's last event, failing unless it
/// lands strictly older than the current cursor.
fn advance_query_cursor(
filter: &mut serde_json::Value,
page: &[serde_json::Value],
) -> Result<(), CliError> {
let last = page
.last()
.expect("a full query page always has a last event");
let created_at = last
.get("created_at")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| CliError::Other("query event missing created_at".into()))?;
let id = last
.get("id")
.and_then(serde_json::Value::as_str)
.filter(|id| id.len() == 64 && id.chars().all(|c| c.is_ascii_hexdigit()))
.ok_or_else(|| CliError::Other("query event missing valid id".into()))?;
if let (Some(until), Some(before_id)) = (
filter.get("until").and_then(serde_json::Value::as_u64),
filter.get("before_id").and_then(serde_json::Value::as_str),
) {
// The relay pages newest first, equal timestamps by ascending id:
// the next page lies at `created_at < until`, or at `until` past `before_id`.
let older = created_at < until
|| (created_at == until && id.to_ascii_lowercase() > before_id.to_ascii_lowercase());
if !older {
return Err(stalled_query(
"the relay did not move the cursor to older events",
));
}
}
filter["until"] = serde_json::json!(created_at);
filter["before_id"] = serde_json::json!(id);
Ok(())
}
/// How a protected relay request proves key possession in `Authorization`.
enum Proof<'a> {
/// NIP-98 over the request method, URL and body.
Nip98 {
method: &'a str,
url: &'a str,
body: Option<&'a [u8]>,
},
/// Blossom kind-24242 upload proof for blob `sha256`.
BlossomUpload { sha256: &'a str },
/// Blossom kind-24242 read proof for `url`.
BlossomGet { url: &'a str },
}
impl<'a> Proof<'a> {
fn post(url: &'a str, body: &'a [u8]) -> Self {
Self::Nip98 {
method: "POST",
url,
body: Some(body),
}
}
}
pub struct BuzzClient {
http: reqwest::Client,
relay_url: String, // base URL, no trailing slash, e.g. "https://relay.buzz.place"
/// The configured URL as a WebSocket URL, path and trailing slash kept,
/// as NIP-42 AUTH must name it.
ws_url: String,
/// Identity, signer, NIP-OA auth tag and NIP-FI evidence source.
credentials: Credentials,
/// Whether any request has carried a NIP-FI assertion, for error hints.
assertion_sent: std::sync::atomic::AtomicBool,
}
/// A credential header value, marked sensitive so `Debug` output redacts it.
fn sensitive(value: String) -> Result<reqwest::header::HeaderValue, CliError> {
let mut value = reqwest::header::HeaderValue::try_from(value)
.map_err(|_| CliError::Auth("credential is not a valid header value".into()))?;
value.set_sensitive(true);
Ok(value)
}
impl BuzzClient {
/// Create a new client pointing at `relay_url`.
///
/// Timeout defaults are tuned for degraded WAN links and can be overridden
/// via environment variables:
///
/// - `BUZZ_CONNECT_TIMEOUT_SECS` — TCP connect timeout (default 15 s)
/// - `BUZZ_TIMEOUT_SECS` — per-request total timeout (default 30 s)
///
/// A value of zero for either variable is treated as invalid and falls back to the default.
/// A client for the relay at `relay_url`, as configured.
pub fn new(relay_url: String, credentials: Credentials) -> Result<Self, CliError> {
let http = reqwest::Client::builder()
// Never replay credentials to a redirect target: reqwest strips
// `Authorization` across origins but not `x-auth-tag` or the
// NIP-FI assertion. A 3xx surfaces as a relay error instead.
.redirect(reqwest::redirect::Policy::none())
.timeout(env_duration_secs("BUZZ_TIMEOUT_SECS", 30))
.connect_timeout(env_duration_secs("BUZZ_CONNECT_TIMEOUT_SECS", 15))
.build()
.map_err(|e| CliError::Other(e.to_string()))?;
Ok(Self {
http,
ws_url: to_ws_url(&relay_url),
relay_url: normalize_relay_url(&relay_url),
credentials,
assertion_sent: Default::default(),
})
}
/// The public key this client acts as.
pub fn public_key(&self) -> PublicKey {
self.credentials.public_key()
}
/// The credentials this client acts with.
pub fn credentials(&self) -> &Credentials {
&self.credentials
}
/// Get the relay base URL.
#[allow(dead_code)]
pub fn relay_url(&self) -> &str {
&self.relay_url
}
/// Return the owner pubkey carried by the NIP-OA auth tag, if any.
///
/// The auth tag is `["auth", owner_pubkey, conditions, sig]`; the
/// owner pubkey lives at index 1.
pub fn auth_tag_owner_hex(&self) -> Option<String> {
self.credentials
.auth_tag()
.map(|t| t.tag.as_slice())
.and_then(|slice| slice.get(1).cloned())
}
/// Sign an event builder, injecting the NIP-OA auth tag if configured.
///
/// All event creation should go through this method to ensure consistent
/// auth tag injection. Callers MUST NOT add `auth` tags to the builder
/// before calling this method.
pub async fn sign_event(&self, builder: EventBuilder) -> Result<nostr::Event, CliError> {
let auth_tag = self.credentials.auth_tag();
let builder = match auth_tag {
Some(tag) => builder.tags([tag.tag.clone()]),
None => builder,
};
let event = self.credentials.sign(builder).await?;
// Enforce: auth tags may only come from the credentials' auth tag.
let auth_count = event
.tags
.iter()
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
.count();
let expected = if auth_tag.is_some() { 1 } else { 0 };
if auth_count != expected {
return Err(CliError::Other(format!(
"event has {auth_count} auth tags — expected {expected}; \
callers must not add auth tags manually"
)));
}
Ok(event)
}
/// The one place protected relay requests get their credentials:
/// `proof` in `Authorization`, the NIP-OA `x-auth-tag` (relay membership
/// delegation) when configured, and the NIP-FI `Nostr-Federated-Identity`
/// assertion when an evidence source is configured. Called once per
/// attempt so every retry carries a fresh proof.
async fn authorize(
&self,
req: reqwest::RequestBuilder,
proof: Proof<'_>,
) -> Result<reqwest::RequestBuilder, CliError> {
// Evidence first: a refresh may wait out adapter retries, and the
// proof must be signed after it, just before sending, to stay fresh.
// Signing may wait out throttling in turn, so evidence that is due
// for refresh by then is fetched again, and the proof re-signed when
// the assertion changed or that fetch aged the proof.
let mut assertion = self.evidence().await?;
let mut round = 1;
let authorization = loop {
// The proof is dated before signing, which may be held for long.
let signed_at = crate::cached_assertion::unix_now();
let authorization = self.proof(&proof).await?;
let current = self.evidence().await?;
let age = crate::cached_assertion::unix_now().saturating_sub(signed_at);
if current == assertion && age < PROOF_MAX_AGE_SECS {
break authorization;
}
if round == AUTHORIZE_ROUNDS {
return Err(CliError::Unavailable(
"the NIP-FI assertion kept expiring while the proof was signed".into(),
));
}
(assertion, round) = (current, round + 1);
};
let mut req = req.header(reqwest::header::AUTHORIZATION, sensitive(authorization)?);
if let Some(tag) = self.credentials.auth_tag() {
req = req.header("x-auth-tag", sensitive(tag.json.clone())?);
}
if let Some(assertion) = assertion {
req = req.header(
FEDERATED_IDENTITY_HEADER,
sensitive(format!("Bearer {assertion}"))?,
);
}
Ok(req)
}
/// `proof`, signed now, as an `Authorization` value.
async fn proof(&self, proof: &Proof<'_>) -> Result<String, CliError> {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
Ok(match *proof {
Proof::Nip98 { method, url, body } => {
format!("Nostr {}", self.credentials.nip98(method, url, body).await?)
}
Proof::BlossomUpload { sha256 } => {
let event = self
.credentials
.sign(blossom_upload_event(sha256, &self.relay_url)?)
.await?;
format!(
"Nostr {}",
URL_SAFE_NO_PAD.encode(event.as_json().as_bytes())
)
}
Proof::BlossomGet { url } => {
let event = self.credentials.sign(blossom_get_event(url)?).await?;
format!(
"Nostr {}",
URL_SAFE_NO_PAD.encode(event.as_json().as_bytes())
)
}
})
}
/// The WebSocket upgrade headers: the CLI's `User-Agent`, and the NIP-FI
/// assertion when there is an evidence source.
async fn upgrade_headers(&self) -> Result<buzz_ws_client::HeaderMap, CliError> {
let mut headers = buzz_ws_client::HeaderMap::new();
headers.insert(
"user-agent",
buzz_ws_client::HeaderValue::from_static(USER_AGENT),
);
if let Some(assertion) = self.evidence().await? {
let mut value =
buzz_ws_client::HeaderValue::from_str(&format!("Bearer {assertion}"))
.map_err(|_| CliError::Auth("assertion is not a valid header value".into()))?;
value.set_sensitive(true);
headers.insert(FEDERATED_IDENTITY_HEADER, value);
}
Ok(headers)
}
/// The NIP-FI assertion for this relay, noting that one was sent.
async fn evidence(&self) -> Result<Option<String>, CliError> {
let assertion = self.credentials.evidence().await?;
if assertion.is_some() {
self.assertion_sent
.store(true, std::sync::atomic::Ordering::Relaxed);
}
Ok(assertion)
}
/// Map a non-2xx relay response to a `CliError`.
///
/// The relay's `{"error": …}` / `{"message": …}` field becomes the
/// message. A 401 without a NIP-FI evidence source names the settings,
/// since an enforcing relay answers every request that way.
fn relay_error(&self, status: u16, body: String) -> CliError {
let message = extract_relay_message_field(&body).unwrap_or(body);
let message = message.trim_end().to_owned();
let sent = self
.assertion_sent
.load(std::sync::atomic::Ordering::Relaxed);
let desktop = self.credentials.is_desktop();
if status == 401 && !sent && desktop {
return CliError::Auth(format!(
"relay requires authentication ({message}); if it enforces federated identity \
(NIP-FI), the Buzz desktop has no assertion for this relay: sign in to this \
community in Buzz, then retry"
));
}
if status == 401 && !sent && !self.credentials.has_evidence_source() {
return CliError::Auth(format!(
"relay requires authentication ({message}); if it enforces federated identity \
(NIP-FI), set {FEDERATED_IDENTITY_TOKEN_ENV} to your own NIP-FA sign-in session \
(a human session for this key, not an agent grant) and \
{FEDERATED_IDENTITY_ADAPTER_ENV} to its adapter, which mints the assertion"
));
}
CliError::Relay {
status,
body: message,
}
}
/// Execute `op` up to `RETRY_MAX_ATTEMPTS` times, including body-transfer failures
/// and transient relay error statuses.
///
/// The closure is expected to consume the response body and return the parsed result
/// as `T`. Retries on non-last attempts when `op` returns:
///
/// - `Err(CliError::Network(e))` where `e.is_connect() || e.is_timeout() ||
/// e.is_request() || e.is_body() || e.is_decode()` — covers both connection
/// failures and mid-body TCP drops.
/// - `Err(CliError::Relay { status: 429 | 502 | 503 | 504, .. })` — transient relay
/// or proxy errors. For 429 the `retry in Ns` hint from the body is used as the
/// delay (capped at `RETRY_IN_MAX_SECS`); all others use exponential jitter.
///
/// Use this variant for all operations (reads, writes, uploads); the retry boundary
/// covers the entire operation including response body transfer.
async fn with_retry_body<'a, T, F, Fut>(&'a self, op: F) -> Result<T, CliError>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<T, CliError>> + 'a,
T: 'a,
{
for attempt in 0..RETRY_MAX_ATTEMPTS {
let is_last = attempt == RETRY_MAX_ATTEMPTS - 1;
match op().await {
Ok(value) => return Ok(value),
Err(e) => {
if !is_last {
let delay = match &e {
CliError::Network(net_err)
if net_err.is_connect()
|| net_err.is_timeout()
|| net_err.is_request()
|| net_err.is_body()
|| net_err.is_decode() =>
{
Some(jitter_delay(attempt))
}
CliError::Relay { status: 429, body } => {
let d = parse_retry_hint_text(body)
.map(|s| Duration::from_secs(s.min(RETRY_IN_MAX_SECS)))
.unwrap_or_else(|| jitter_delay(attempt));
Some(d)
}
CliError::Relay {
status: 502..=504, ..
} => Some(jitter_delay(attempt)),
_ => None,
};
if let Some(d) = delay {
tokio::time::sleep(d).await;
continue;
}
}
return Err(e);
}
}
}
unreachable!("loop exhausts all RETRY_MAX_ATTEMPTS")
}
async fn query_pages(
&self,
filter: serde_json::Value,
limit: Option<u32>,
) -> Result<Vec<serde_json::Value>, CliError> {
self.query_pages_keeping(filter, limit, None).await
}
/// Like `query_pages`, but with `keep`, only events it accepts count
/// toward `limit`: paging continues past the ones it drops, always in
/// full pages, for at most `MAX_QUERY_PAGES` pages.
async fn query_pages_keeping(
&self,
mut filter: serde_json::Value,
limit: Option<u32>,
keep: Option<&dyn Fn(&serde_json::Value) -> bool>,
) -> Result<Vec<serde_json::Value>, CliError> {
let mut events = Vec::new();
let mut pages = 0;
while limit.is_none_or(|limit| events.len() < limit as usize) {
if keep.is_some() && pages == MAX_QUERY_PAGES {
return Err(stalled_query(&format!(
"read {MAX_QUERY_PAGES} pages without reaching the end of the results"
)));
}
pages += 1;
let page_limit = match (limit, keep) {
(Some(limit), None) => {
(limit as usize - events.len()).min(QUERY_PAGE_SIZE as usize)
}
_ => QUERY_PAGE_SIZE as usize,
};
filter["limit"] = serde_json::json!(page_limit);
let raw = self.query(&filter).await?;
let page: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse query response: {e}")))?;
let done = page.len() < page_limit;
if !done {
advance_query_cursor(&mut filter, &page)?;
}
events.extend(page.into_iter().filter(|e| keep.is_none_or(|keep| keep(e))));
if done {
break;
}
}
if let Some(limit) = limit {
events.truncate(limit as usize);
}
Ok(events)
}
/// Query up to `limit` historical events that `keep` accepts, paging past
/// the rest until the limit is met or the history runs out.
pub async fn query_paginated_keeping(
&self,
filter: serde_json::Value,
limit: u32,
keep: impl Fn(&serde_json::Value) -> bool,
) -> Result<Vec<serde_json::Value>, CliError> {
self.query_pages_keeping(filter, Some(limit), Some(&keep))
.await
}
/// Query up to `limit` historical events, following the relay bridge's
/// composite `(until, before_id)` cursor across bounded result pages.
pub async fn query_paginated(
&self,
filter: serde_json::Value,
limit: u32,
) -> Result<Vec<serde_json::Value>, CliError> {
self.query_pages(filter, Some(limit)).await
}
/// Query every historical event matching a filter across bounded pages.
pub async fn query_all(
&self,
filter: serde_json::Value,
) -> Result<Vec<serde_json::Value>, CliError> {
self.query_pages(filter, None).await
}
/// Query a filter exhaustively up to `max_events`.
///
/// One extra event is requested so reaching the bound is reported as
/// truncation instead of being mistaken for authoritative absence.
pub async fn query_all_bounded(
&self,
filter: serde_json::Value,
max_events: u32,
) -> Result<Vec<serde_json::Value>, CliError> {
let probe_limit = max_events
.checked_add(1)
.ok_or_else(|| CliError::Other("query bound is too large".into()))?;
let events = self.query_pages(filter, Some(probe_limit)).await?;
if events.len() > max_events as usize {
return Err(CliError::Other(format!(
"query exceeded the exhaustive {max_events}-event bound; narrow the query or retry"
)));
}
Ok(events)
}
/// Sign an event builder verbatim: no NIP-OA auth-tag injection, and none
/// of [`sign_event`]'s "callers must not add auth tags" enforcement.
///
/// Used only for NIP-IA identity archive/unarchive requests (kind
/// 9035/9036), whose optional `auth` tag is a *content-level*
/// owner-of-agent attestation about the *target* identity — unrelated to
/// this client's own NIP-OA membership delegation (the credentials' auth tag,
/// which [`sign_event`] injects into every other event and which
/// `submit_event` separately attaches via the `x-auth-tag` HTTP header).
/// Routing an identity archive request through `sign_event` would either
/// silently drop the caller's owner attestation or double up an
/// unrelated tag.
pub async fn sign_event_unchecked(
&self,
builder: EventBuilder,
) -> Result<nostr::Event, CliError> {
self.credentials.sign(builder).await
}
/// GET a public, unauthenticated relay endpoint (e.g. the NIP-11 `/info`
/// document), returning the raw JSON body. No NIP-98 Authorization and no
/// `x-auth-tag` header — the endpoint is public relay metadata, not a
/// membership-scoped resource.
pub async fn get_public(&self, path: &str) -> Result<String, CliError> {
let url = format!("{}{path}", self.relay_url);
let resp = self
.http
.get(&url)
.header("Accept", "application/nostr+json")
.send()
.await?;
self.handle_response(resp).await
}
/// Execute a one-shot query via the HTTP bridge.
/// `filter` is a Nostr filter object (will be wrapped in an array).
/// Returns the raw JSON response (array of events).
pub async fn query(&self, filter: &serde_json::Value) -> Result<String, CliError> {
self.query_multi(std::slice::from_ref(filter)).await
}
/// Execute a one-shot query with multiple filters via the HTTP bridge.
/// Each filter is ORed by the relay (standard Nostr REQ behavior).
pub async fn query_multi(&self, filters: &[serde_json::Value]) -> Result<String, CliError> {
let url = format!("{}/query", self.relay_url);
let body = bytes::Bytes::from(
serde_json::to_vec(filters)
.map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?,
);
self.with_retry_body(|| {
let body = body.clone();
let url = url.clone();
async move {
let req = self
.authorize(
self.http
.post(&url)
.header("Content-Type", "application/json")
.body(body.clone()),
Proof::post(&url, &body),
)
.await?;
let resp = req.send().await?;
self.handle_response(resp).await
}
})
.await
}
/// Execute a one-shot count via the HTTP bridge.
/// Returns the count as a JSON string.
#[allow(dead_code)]
pub async fn count(&self, filter: &serde_json::Value) -> Result<String, CliError> {
let url = format!("{}/count", self.relay_url);
let body = bytes::Bytes::from(
serde_json::to_vec(&[filter])
.map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?,
);
self.with_retry_body(|| {
let body = body.clone();
let url = url.clone();
async move {
let req = self
.authorize(
self.http
.post(&url)
.header("Content-Type", "application/json")
.body(body.clone()),
Proof::post(&url, &body),
)
.await?;
let resp = req.send().await?;
self.handle_response(resp).await
}
})
.await
}
/// GET an authed relay endpoint (NIP-98), returning the raw JSON body.
///
/// `path` is a root-relative path incl. any query string, e.g.
/// `/moderation/reports?status=open&limit=20`. Used by the moderation
/// read commands, which read structured queue/audit rows rather than
/// stored events.
pub async fn get_authed(&self, path: &str) -> Result<String, CliError> {
let url = format!("{}{path}", self.relay_url);
self.with_retry_body(|| {
let url = url.clone();
async move {
let req = self
.authorize(
self.http.get(&url),
Proof::Nip98 {
method: "GET",
url: &url,
body: None,
},
)
.await?;
let resp = req.send().await?;
self.handle_response(resp).await
}
})
.await
}
/// POST a JSON body to a relay-relative path with NIP-98 authentication.
///
/// Used by `buzz gifs search` and `buzz gifs share` to reach the relay's
/// KLIPY proxy endpoints. Returns the raw response body as a string (may
/// be empty for 204 No Content responses).
pub async fn post_json_authed(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<String, CliError> {
let url = format!("{}{path}", self.relay_url);
let body_bytes = bytes::Bytes::from(
serde_json::to_vec(body)
.map_err(|e| CliError::Other(format!("request serialization failed: {e}")))?,
);
self.with_retry_body(|| {
let body_bytes = body_bytes.clone();
let url = url.clone();
async move {
let req = self
.authorize(
self.http
.post(&url)
.header("Content-Type", "application/json")
.body(body_bytes.clone()),
Proof::post(&url, &body_bytes),
)
.await?;
let resp = req.send().await?;
// 204 No Content: return empty string rather than failing on
// an empty body that cannot be parsed as JSON.
if resp.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(String::new());
}
self.handle_response(resp).await
}
})
.await
}
/// Send a state-changing JSON command exactly once. Ambiguous delivery
/// never invites an automatic re-run with a newly observed version.
pub async fn post_json_once_authed(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<String, CliError> {
let url = format!("{}{path}", self.relay_url);
let body = serde_json::to_vec(body).map_err(|e| CliError::Other(e.to_string()))?;
let unknown = |detail: String| CliError::DeliveryUnknown(detail);
let http = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(env_duration_secs("BUZZ_TIMEOUT_SECS", 30))
.connect_timeout(env_duration_secs("BUZZ_CONNECT_TIMEOUT_SECS", 15))
.build()?;
let req = self
.authorize(
http.post(&url)
.header("Content-Type", "application/json")
.body(body.clone()),
Proof::post(&url, &body),
)
.await?;
let response = req.send().await.map_err(|e| {
if e.is_connect() || e.is_builder() {
CliError::Network(e)
} else {
unknown(e.to_string())
}
})?;
let status = response.status();
let body = response.text().await.map_err(|e| unknown(e.to_string()))?;
let message = extract_relay_message_field(&body).unwrap_or_else(|| body.clone());
if status.is_server_error()
|| status.is_redirection()
|| (status.as_u16() == 429 && !message.starts_with("rate-limited:"))
{
return Err(unknown(format!("HTTP {}: {message}", status.as_u16())));
}
if !status.is_success() {
return Err(self.relay_error(status.as_u16(), body));
}
Ok(body)
}
/// Submit a signed Nostr event via POST /events.
///
/// For non-idempotent moderation command kinds (9040–9044), an ambiguous
/// outcome (mid-request error, body loss, non-ingest 429, or 502/503/504)
/// surfaces as `CliError::DeliveryUnknown` instead of being retried. These
/// events execute at the relay *before* any dedup check, so a blind re-send
/// can duplicate the mutation. Only confirmed-unreceived failures (TCP
/// connect error or a pre-ingest 429 carrying a `rate-limited:` body) are
/// safe to retry.
///
/// All other event kinds retain the standard retry policy.
pub async fn submit_event(&self, event: nostr::Event) -> Result<String, CliError> {
let kind = event.kind.as_u16();
if is_moderation_kind(kind) {
self.submit_moderation_event(event).await
} else {
self.submit_stored_event(event).await
}
}
/// Submit a moderation command (kinds 9040–9044) with non-idempotent retry policy.
async fn submit_moderation_event(&self, event: nostr::Event) -> Result<String, CliError> {
let url = format!("{}/events", self.relay_url);
let body = bytes::Bytes::from(
serde_json::to_vec(&event)
.map_err(|e| CliError::Other(format!("event serialization failed: {e}")))?,
);
for attempt in 0..RETRY_MAX_ATTEMPTS {
let is_last = attempt == RETRY_MAX_ATTEMPTS - 1;
// Re-sign NIP-98 each attempt: the nonce tag generates a fresh
// event ID, keeping retries safe against the relay's replay guard.
let req = self
.authorize(
self.http
.post(&url)
.header("Content-Type", "application/json")
.body(body.clone()),
Proof::post(&url, &body),
)
.await?;
let send_result: Result<reqwest::Response, CliError> =
req.send().await.map_err(CliError::from);
match send_result {
Err(e) => {
if let CliError::Network(ref net_err) = e {
// Only connect-failure is safe to retry: the relay never saw
// the request. Timeout and mid-request errors are ambiguous.
if !is_last && net_err.is_connect() {
tokio::time::sleep(jitter_delay(attempt)).await;
continue;
}
if net_err.is_connect() {
// Final attempt: definitively never reached the relay — retryable.
return Err(e);
}
if net_err.is_timeout()
|| net_err.is_request()
|| net_err.is_body()
|| net_err.is_decode()
{
// Ambiguous: the relay may have executed this command.
return Err(CliError::DeliveryUnknown(format!(
"moderation command (kind {}) outcome unknown: {}",
event.kind.as_u16(),
net_err
)));
}
}
return Err(e);
}
Ok(resp) => {
let status = resp.status().as_u16();
if status == 429 {
// Only retry if the relay's own ingest layer rejected it:
// the extracted error/message field must start with
// "rate-limited:". A proxy-level 429 (or JSON-wrapped body
// whose field does not start with "rate-limited:") leaves
// relay execution state ambiguous.
let body_text = resp.text().await.unwrap_or_default();
let extracted = extract_relay_message_field(&body_text);
let msg = extracted.as_deref().unwrap_or(&body_text);
if msg.starts_with("rate-limited:") {
// Canonical pre-ingest 429: the relay provably did not execute
// the command. Retry while budget remains; on exhaustion return
// Relay(429) (retryable:true) — the caller may retry the exact
// same command. DeliveryUnknown is reserved for outcomes where
// relay execution is genuinely ambiguous (proxy 429, 502-504,
// timeout/body-loss after the relay may have acted).
if !is_last {
let delay = parse_retry_hint_text(msg)
.map(|s| Duration::from_secs(s.min(RETRY_IN_MAX_SECS)))
.unwrap_or_else(|| jitter_delay(attempt));
tokio::time::sleep(delay).await;
continue;
}
// Budget exhausted — still pre-ingest, still safe to retry.
return Err(CliError::Relay {
status: 429,
body: body_text,
});
}
// Non-canonical 429 (proxy-level or unrecognised body): outcome unknown.
return Err(CliError::DeliveryUnknown(format!(
"moderation command (kind {}) outcome unknown: HTTP 429",
event.kind.as_u16()
)));
}
if matches!(status, 502..=504) {
// Proxy-level error: the relay may have received and executed
// the command before the proxy failed.
return Err(CliError::DeliveryUnknown(format!(
"moderation command (kind {}) outcome unknown: HTTP {status}",
event.kind.as_u16()
)));
}
// 2xx or definitive error (4xx other than 429): read body normally.
let body_text = resp.text().await.map_err(|e| {
// Body loss after relay confirmed receipt is ambiguous for
// non-idempotent commands.
CliError::DeliveryUnknown(format!(
"moderation command (kind {}) outcome unknown: response body lost: {e}",
event.kind.as_u16()
))
})?;
// Map the body through handle_response's error logic inline.
if !resp_was_success(status) {
return Err(self.relay_error(status, body_text));
}
return Ok(body_text);
}
}
}
unreachable!("loop exhausts all RETRY_MAX_ATTEMPTS")
}
/// Submit a stored event (all non-moderation kinds) with the standard retry policy.
///
/// The full operation — network send AND response body read — is inside the retry
/// boundary so that a dropped body after a 200 header is retried with the same
/// serialized event bytes (and a fresh per-attempt NIP-98 auth event).
///
/// **Exhaustion policy:** after all attempts, connect failures and canonical
/// pre-ingest 429 remain retryable (`CliError::Network`/`CliError::Relay{429}`)
/// because the relay provably never executed them. Any other final failure
/// (timeout, request, body loss, decode, proxy 502-504) is ambiguous — the
/// relay may have stored the event — so we surface `DeliveryUnknown`
/// (retryable:false) to prevent an outer re-sign creating a duplicate write.
/// Content-addressed uploads are exempt: same bytes ⇒ same hash, so outer
/// re-run is safe regardless of the failure kind.
async fn submit_stored_event(&self, event: nostr::Event) -> Result<String, CliError> {
let url = format!("{}/events", self.relay_url);
let body = bytes::Bytes::from(
serde_json::to_vec(&event)
.map_err(|e| CliError::Other(format!("event serialization failed: {e}")))?,
);
// Whether any attempt reached the relay without an authoritative
// answer. A later failure, even one before sending, cannot clear it.
let maybe_delivered = std::sync::atomic::AtomicBool::new(false);
let result = self
.with_retry_body(|| {
let body = body.clone();
let url = url.clone();
let maybe_delivered = &maybe_delivered;
async move {
// Re-sign NIP-98 each attempt: the nonce tag generates a fresh
// event ID, keeping retries safe against the relay's replay guard.
let req = self
.authorize(
self.http
.post(&url)
.header("Content-Type", "application/json")
.body(body.clone()),
Proof::post(&url, &body),
)
.await?;
let sent = match req.send().await {
Ok(resp) => self.handle_response(resp).await,
Err(e) => Err(e.into()),
};
if let Err(ref e) = sent {
if is_stored_event_exhaustion_ambiguous(e) {
maybe_delivered.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
sent
}
})
.await;
// Translate ambiguous final errors to DeliveryUnknown so an outer agent
// following retryable:true does not re-sign and risk a duplicate write.
// Connect failures stay Network (retryable:true) — definitively never received.
// Canonical pre-ingest 429 (Relay{429}) stays retryable — definitively not stored.
if let Err(ref e) = result {
// Once an attempt may have been stored, no later answer settles
// the outcome, except a canvas 409, which the canvas command
// resolves by looking the canvas up.
let settled = matches!(e, CliError::Relay { status: 409, body } if body.contains("canvas changed"));
if is_stored_event_exhaustion_ambiguous(e)
|| (maybe_delivered.load(std::sync::atomic::Ordering::Relaxed) && !settled)
{
let kind_u16 = event.kind.as_u16();
return Err(CliError::DeliveryUnknown(format!(
"stored event (kind {kind_u16}) outcome unknown after all attempts: {e}"
)));
}
}
result
}
/// Publish an ephemeral event via WebSocket with NIP-42 authentication.
///
/// The relay accepts only typing (kind:20002) of the ephemeral kinds
/// (20000–29999) over HTTP. Delegates to
/// `buzz_ws_client::publish_event` which handles connect, NIP-42 auth,
/// EVENT send, OK wait, and graceful close.
pub async fn publish_ephemeral_event(&self, event: nostr::Event) -> Result<String, CliError> {
let ws_url = &self.ws_url;
// Two kinds of deadline: each network phase is capped as a whole,
// writes included (the connect below, then buzz_ws_client's challenge,
// AUTH, EVENT and close phases), and NIP-42 signing between them is
// bounded by the signer itself. A
// desktop provider may wait out throttling and hold `sign_event` for a
// person's decision, which no relay budget should cut short.
let signer = crate::credentials::CapturingSigner::new(&self.credentials);
let ws_error = |e: buzz_ws_client::WsClientError| match e.upgrade_rejection() {
// A denial decided on the upgrade is an HTTP response (NIP-FI).
Some((status, body)) => self.relay_error(status, body),
// A post-upgrade denial (e.g. a NIP-FI key mismatch) is a 1008 close.
None if matches!(e, buzz_ws_client::WsClientError::PolicyClose(_)) => {
CliError::Auth(e.to_string())
}
None => CliError::Other(e.to_string()),
};
//
// The relay closes a socket that has not signed in within seconds,
// so a slow or throttled sign-in signature reconnects and signs the
// new challenge, a bounded number of times, with current evidence:
// the old assertion may have expired meanwhile. A throttled sign-in
// is waited out between connections, never on one. EVENT, signed
// before any of this, is sent only once AUTH succeeds.
let mut attempt = 1;
let mut conn = loop {
let headers = self.upgrade_headers().await?;
let mut conn = tokio::time::timeout(
WS_CONNECT_TIMEOUT,
buzz_ws_client::NostrWsConnection::connect_with_headers(ws_url, headers),
)
.await
.map_err(|_| CliError::Other("relay WebSocket connect timed out".into()))?
.map_err(ws_error)?;
let signed_in = conn
.authenticate(&signer, self.credentials.auth_tag().map(|t| &t.tag))
.await;
let Err(e) = signed_in else { break conn };
let failure = signer.take_failure();
let lost = matches!(
e,
buzz_ws_client::WsClientError::ConnectionClosed
| buzz_ws_client::WsClientError::WebSocket(_)
);
let (failure, wait) = failure.unzip();
let wait = wait.flatten();
let throttled = matches!(failure, Some(CliError::Throttled(_)));
let _ = conn.disconnect().await;
if !(lost || throttled) {
return Err(failure.unwrap_or_else(|| ws_error(e)));
}
if attempt == WS_SIGN_IN_ATTEMPTS {
return Err(failure.unwrap_or_else(|| {
CliError::Unavailable(format!(
"the relay closed the WebSocket before sign-in completed, \
{WS_SIGN_IN_ATTEMPTS} times: {e}"
))
}));
}
tokio::time::sleep(wait.unwrap_or_else(|| jitter_delay(attempt - 1))).await;
attempt += 1;
};
let ok = conn.send_event(event).await.map_err(ws_error)?;
let _ = conn.disconnect().await;
if !ok.accepted {
return Err(CliError::Relay {
status: 400,
body: ok.message,
});
}
Ok(serde_json::json!({
"event_id": ok.event_id,
"accepted": true,
"message": ok.message,
})
.to_string())
}
/// Upload a file to the relay's Blossom endpoint.
/// Returns a BlobDescriptor on success.
pub async fn upload_file(&self, file_path: &str) -> Result<BlobDescriptor, CliError> {
// 1. Read file — validate it exists and is a regular file
let metadata = std::fs::metadata(file_path)
.map_err(|e| CliError::Usage(format!("cannot access {file_path}: {e}")))?;
if !metadata.is_file() {
return Err(CliError::Usage(format!("{file_path} is not a file")));
}
let bytes = std::fs::read(file_path)
.map_err(|e| CliError::Usage(format!("failed to read {file_path}: {e}")))?;
// 2. Detect MIME from magic bytes
let mime = infer::get(&bytes)
.map(|t| t.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
// 3. Size check. The relay decides which other types it accepts.
let max = if mime == "video/mp4" {
MAX_VIDEO_BYTES
} else if MEDIA_MIMES.contains(&mime.as_str()) {
MAX_IMAGE_BYTES
} else {
MAX_FILE_BYTES
};
if bytes.len() as u64 > max {
return Err(CliError::Usage(format!(
"file too large: {} bytes (max {})",
bytes.len(),
max
)));
}
// 4. SHA-256
let sha256 = hex::encode(Sha256::digest(&bytes));
// 5. PUT request to the BUD-02 /upload endpoint with a generous timeout.
// Auth is signed per attempt — matches the per-attempt signing pattern in download_media.
let upload_timeout = if max > MAX_IMAGE_BYTES {
Duration::from_secs(600)
} else {
Duration::from_secs(120)
};
let url = format!("{}/upload", self.relay_url);
let upload_body = bytes::Bytes::from(bytes);
// The full upload operation — network send AND response body read — lives inside
// with_retry_body so that a dropped body after 200 headers is retried with the
// same file bytes and a fresh Blossom auth per attempt.
let result: Result<BlobDescriptor, CliError> = self
.with_retry_body(|| {
let upload_body = upload_body.clone();
let url = url.clone();
let mime = mime.clone();
let sha256 = sha256.clone();
async move {
let req = self
.authorize(
self.http
.put(&url)
.timeout(upload_timeout)
.header("Content-Type", &mime)
.header("X-SHA-256", &sha256)
.body(upload_body),
Proof::BlossomUpload { sha256: &sha256 },
)
.await?;
let resp = req.send().await?;
let status = resp.status();
if !status.is_success() {
let s = status.as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(self.relay_error(s, body));
}
resp.json::<BlobDescriptor>().await.map_err(CliError::from)
}
})
.await;
// If the primary /upload endpoint definitively doesn't exist on this relay version
// (404 or 405), fall back to the legacy /media/upload endpoint. The 404/405 switch
// itself is not retried; only transient failures on the selected legacy endpoint are.
match result {
Ok(desc) => return Ok(desc),
Err(CliError::Relay { status: s, body: _ })
if should_retry_legacy_upload(
reqwest::StatusCode::from_u16(s).unwrap_or(reqwest::StatusCode::NOT_FOUND),
) =>
{
// Fall through to legacy endpoint below.
}
Err(e) => return Err(e),
}
let legacy_url = format!("{}/media/upload", self.relay_url);
self.with_retry_body(|| {
let upload_body = upload_body.clone();
let legacy_url = legacy_url.clone();
let mime = mime.clone();
let sha256 = sha256.clone();
async move {
let req = self
.authorize(
self.http
.put(&legacy_url)
.timeout(upload_timeout)
.header("Content-Type", &mime)
.header("X-SHA-256", &sha256)
.body(upload_body),
Proof::BlossomUpload { sha256: &sha256 },
)
.await?;
let resp = req.send().await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(self.relay_error(status, body));
}
resp.json::<BlobDescriptor>().await.map_err(CliError::from)
}
})
.await
}
/// Download a Blossom media blob using BUD-01 `t=get` auth.
pub async fn download_media(&self, input: &str) -> Result<bytes::Bytes, CliError> {
let url = media_url_from_input(&self.relay_url, input)?;
// Use a dedicated client: 120 s timeout, no redirect forwarding.
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
// Do not forward Authorization or x-auth-tag to redirect targets.
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| CliError::Other(format!("http client init failed: {e}")))?;
self.with_retry_body(|| {
let url = url.clone();
let client = client.clone();
async move {
let req = self
.authorize(client.get(&url), Proof::BlossomGet { url: &url })
.await?;
let resp = req.send().await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(self.relay_error(status, body));
}
resp.bytes().await.map_err(CliError::Network)
}
})
.await
}
async fn handle_response(&self, resp: reqwest::Response) -> Result<String, CliError> {
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(self.relay_error(status, body));
}
Ok(resp.text().await?)
}
}
/// Normalize a relay URL: ws:// → http://, wss:// → https://, strip trailing slash.
/// BUZZ_RELAY_URL may be ws/wss (copied from MCP config).
pub fn normalize_relay_url(url: &str) -> String {
url.replace("wss://", "https://")
.replace("ws://", "http://")
.trim_end_matches('/')
.to_string()
}
/// Convert an HTTP(S) relay base URL back to a WebSocket URL for NIP-01 connections.
fn to_ws_url(http_url: &str) -> String {
http_url
.replace("https://", "wss://")
.replace("http://", "ws://")
}
/// Normalize raw event JSON array into the canonical Nostr event shape.
/// String signatures are preserved; absent or non-string signatures remain absent.
pub fn normalize_events(events: &[serde_json::Value]) -> String {
let normalized: Vec<serde_json::Value> = events
.iter()
.map(|e| {
let mut event = serde_json::json!({
"id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""),
"pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""),
"kind": e.get("kind").and_then(|v| v.as_u64()).unwrap_or(0),
"content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
"tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])),
});
if let Some(sig) = e.get("sig").and_then(|v| v.as_str()) {
event["sig"] = serde_json::json!(sig);
}
event
})
.collect();
serde_json::to_string(&normalized).unwrap_or_default()
}
/// Extract the d-tag value from a Nostr event JSON object.
pub fn extract_d_tag(event: &serde_json::Value) -> String {
event
.get("tags")
.and_then(|t| t.as_array())
.and_then(|tags| {
tags.iter().find(|t| {
t.as_array()
.and_then(|a| a.first())
.and_then(|v| v.as_str())
== Some("d")
})
})
.and_then(|t| t.as_array())
.and_then(|a| a.get(1))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
/// Extract a named tag's value from a Nostr event JSON object.
/// Finds the first tag whose first element matches `key` and returns the second element.
pub fn extract_tag_value(event: &serde_json::Value, key: &str) -> String {
event
.get("tags")
.and_then(|t| t.as_array())
.and_then(|tags| {
tags.iter().find(|t| {
t.as_array()
.and_then(|a| a.first())
.and_then(|v| v.as_str())
== Some(key)
})
})
.and_then(|t| t.as_array())
.and_then(|a| a.get(1))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
/// Extract all p-tags into [{pubkey, role}] from a Nostr event JSON object.
pub fn extract_p_tags(event: &serde_json::Value) -> Vec<serde_json::Value> {
event
.get("tags")
.and_then(|t| t.as_array())
.map(|tags| {
tags.iter()
.filter(|t| {
t.as_array()
.and_then(|a| a.first())
.and_then(|v| v.as_str())
== Some("p")
})
.map(|t| {
let a = t.as_array().unwrap();
serde_json::json!({
"pubkey": a.get(1).and_then(|v| v.as_str()).unwrap_or(""),
"role": a.get(3).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).unwrap_or("member"),
})
})
.collect()
})
.unwrap_or_default()
}
/// Return a create-command response, injecting the entity ID **only** when the
/// relay accepted the event (`"accepted": true`). When the relay rejected the
/// event, emitting the locally-computed link would be misleading — callers
/// that copy or share the link would reference an event that was never stored.
pub fn create_response_with_id_if_accepted(resp: &str, id_key: &str, id_val: &str) -> String {
let mut v: serde_json::Value = serde_json::from_str(resp).unwrap_or(serde_json::json!({}));
let accepted = v.get("accepted").and_then(|a| a.as_bool()).unwrap_or(false);
if accepted {
v[id_key] = serde_json::json!(id_val);
}
v.to_string()
}
/// Print a create-command response, injecting the generated entity ID.
pub fn print_create_response(resp: &str, id_key: &str, id_val: &str) {
println!(
"{}",
create_response_with_id_if_accepted(resp, id_key, id_val)
);
}
/// Extract a JSON field from relay write response messages shaped as
/// `response:{...}`.
pub fn extract_relay_response_field(resp: &str, field: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(resp)
.ok()?
.get("message")?
.as_str()?
.strip_prefix("response:")
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
.and_then(|v| v.get(field)?.as_str().map(str::to_string))
}
/// Normalize a relay write-response into a consistent JSON object.
/// Relay returns: {"event_id": "...", "accepted": true, "message": "..."}
/// Falls back to raw text if parsing fails.
pub fn normalize_write_response(raw: &str) -> String {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw) {
if v.get("event_id").is_some() || v.get("accepted").is_some() {
return serde_json::json!({
"event_id": v.get("event_id").and_then(|v| v.as_str()).unwrap_or(""),
"accepted": v.get("accepted").and_then(|v| v.as_bool()).unwrap_or(false),
"message": v.get("message").and_then(|v| v.as_str()).unwrap_or(""),
})
.to_string();
}
}
raw.to_string()
}
#[cfg(test)]
mod retry_tests {
use std::time::Duration;
use super::{
env_duration_secs, is_moderation_kind, jitter_delay, parse_retry_hint_text,
parse_retry_in_secs, RETRY_BASE_SECS, RETRY_IN_MAX_SECS, RETRY_MAX_ATTEMPTS,
};
// ---- parse_retry_in_secs ----
#[test]
fn parse_relay_json_with_error_field() {
let body = r#"{"error":"rate-limited: quota exceeded; retry in 5s"}"#;
assert_eq!(parse_retry_in_secs(body), Some(5));
}
#[test]
fn parse_relay_json_with_message_field() {
let body = r#"{"message":"back off; retry in 3s please"}"#;
assert_eq!(parse_retry_in_secs(body), Some(3));
}
#[test]
fn parse_retry_in_zero_seconds() {
let body = r#"{"error":"retry in 0s"}"#;
assert_eq!(parse_retry_in_secs(body), Some(0));
}
#[test]
fn parse_garbled_body_returns_none() {
assert_eq!(parse_retry_in_secs("not json at all"), None);
}
#[test]
fn parse_missing_retry_pattern_returns_none() {
let body = r#"{"error":"rate-limited, please slow down"}"#;
assert_eq!(parse_retry_in_secs(body), None);
}
#[test]
fn parse_empty_body_returns_none() {
assert_eq!(parse_retry_in_secs(""), None);
}
// ---- parse_retry_hint_text ----
#[test]
fn hint_text_plain_extracted_field_returns_secs() {
// Shape produced by handle_response: JSON extracted, plain text arrives.
assert_eq!(
parse_retry_hint_text("rate-limited: quota exceeded; retry in 4s"),
Some(4)
);
}
#[test]
fn hint_text_raw_json_body_returns_secs() {
// Shape from download_media's inline error path: raw JSON body preserved.
assert_eq!(
parse_retry_hint_text(r#"{"error":"rate-limited: retry in 7s"}"#),
Some(7)
);
}
#[test]
fn hint_text_plain_no_pattern_returns_none() {
assert_eq!(parse_retry_hint_text("rate-limited: slow down"), None);
}
#[test]
fn hint_text_empty_returns_none() {
assert_eq!(parse_retry_hint_text(""), None);
}
// ---- is_moderation_kind ----
#[test]
fn moderation_kind_covers_9040_through_9044() {
for kind in 9040u16..=9044 {
assert!(is_moderation_kind(kind), "kind {kind} should be moderation");
}
}
#[test]
fn non_moderation_kinds_are_not_moderation() {
for kind in [1u16, 9039, 9045, 39000, 20000, 30023] {
assert!(
!is_moderation_kind(kind),
"kind {kind} should not be moderation"
);
}
}
// ---- jitter bounds ----
#[test]
fn jitter_stays_within_base() {
for attempt in 0..RETRY_BASE_SECS.len() as u32 {
let base = RETRY_BASE_SECS[attempt as usize];
for _ in 0..100 {
let delay = jitter_delay(attempt).as_secs_f64();
assert!(
(0.0..=base).contains(&delay),
"jitter {delay} out of [0, {base}]"
);
}
}
}
// ---- constant sanity ----
#[test]
fn retry_constants_are_sensible() {
assert_eq!(RETRY_MAX_ATTEMPTS, 3);
assert_eq!(RETRY_BASE_SECS.len(), (RETRY_MAX_ATTEMPTS - 1) as usize);
const { assert!(RETRY_IN_MAX_SECS > 0) };
}
// ---- env_duration_secs ----
#[test]
fn env_duration_secs_parsing() {
// All assertions share one env var key; sequential set/remove prevents races.
const KEY: &str = "BUZZ_CLI_TEST_DURATION_SECS";
// Valid numeric value is parsed.
std::env::set_var(KEY, "42");
assert_eq!(env_duration_secs(KEY, 30), Duration::from_secs(42));
// Non-numeric falls back to default.
std::env::set_var(KEY, "not-a-number");
assert_eq!(env_duration_secs(KEY, 30), Duration::from_secs(30));
// Zero is treated as invalid and falls back to default.
std::env::set_var(KEY, "0");
assert_eq!(env_duration_secs(KEY, 30), Duration::from_secs(30));
// Unset uses the default.
std::env::remove_var(KEY);
assert_eq!(env_duration_secs(KEY, 30), Duration::from_secs(30));
}
}
/// Integration tests for the kind-aware retry policy and body-boundary coverage.
///
/// These tests spin up a local HTTP server using axum and issue real HTTP requests
/// through `BuzzClient` to verify behavioural properties — not implementation details.
#[cfg(test)]
mod retry_policy_tests {
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use axum::body::Body;
use axum::extract::State;
use axum::http::{HeaderMap, Response, StatusCode};
use axum::routing::post;
use axum::Router;
use nostr::{EventBuilder, Keys, Kind};
use tokio::net::TcpListener;
use super::super::error::CliError;
use super::BuzzClient;
/// Spawn a one-shot axum server on a random port. The handler `f` receives the
/// attempt counter (incremented before every call) and returns a `(StatusCode,
/// String)`. Returns the base URL and a join handle so the caller can assert
/// attempt counts after the test.
async fn test_server<F>(f: F) -> (String, Arc<AtomicU32>)
where
F: Fn(u32) -> (StatusCode, String) + Send + Sync + 'static,
{
let counter = Arc::new(AtomicU32::new(0));
let handler: Arc<dyn Fn(u32) -> (StatusCode, String) + Send + Sync> = Arc::new(f);
let state = (handler, counter.clone());
type S = (
Arc<dyn Fn(u32) -> (StatusCode, String) + Send + Sync>,
Arc<AtomicU32>,
);
let app = Router::new()
.route(
"/events",
post(
|State((handler, ctr)): State<S>, _headers: HeaderMap, _body: Body| async move {
let n = ctr.fetch_add(1, Ordering::SeqCst) + 1;
let (status, body) = handler(n);
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap()
},
),
)
.with_state(state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr: SocketAddr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(format!("http://{addr}"), counter)
}
fn test_client(base_url: &str) -> BuzzClient {
BuzzClient::new(
base_url.to_string(),
crate::credentials::Credentials::from_keys(Keys::generate()),
)
.unwrap()
}
fn client_keys(client: &BuzzClient) -> Keys {
client.credentials().test_keys()
}
fn make_moderation_event(keys: &Keys, kind: u16) -> nostr::Event {
EventBuilder::new(Kind::Custom(kind), "")
.sign_with_keys(keys)
.unwrap()
}
fn make_stored_event(keys: &Keys) -> nostr::Event {
EventBuilder::new(Kind::TextNote, "hi")
.sign_with_keys(keys)
.unwrap()
}
/// A moderation command (kind 9040) that fails the first attempt with HTTP 429
/// carrying a plain (non-relay-ingest) body is NOT retried — surfaces as
/// `DeliveryUnknown`.
#[tokio::test]
async fn moderation_kind_non_ingest_429_returns_delivery_unknown() {
let (url, attempts) = test_server(|_n| {
(
StatusCode::TOO_MANY_REQUESTS,
r#"{"error":"slow down"}"#.to_string(),
)
})
.await;
let client = test_client(&url);
let event = make_moderation_event(&client_keys(&client), 9040);
let err = client.submit_event(event).await.unwrap_err();
assert!(
matches!(err, CliError::DeliveryUnknown(_)),
"expected DeliveryUnknown, got {err:?}"
);
// Non-ingest 429 must not be retried — exactly 1 attempt.
assert_eq!(
attempts.load(Ordering::SeqCst),
1,
"must not retry non-ingest 429"
);
}
/// A moderation command (kind 9041) that gets a relay-ingest 429 (production JSON
/// envelope `{"error":"rate-limited: ..."}`) IS retried, and the `retry in Ns` hint
/// is honoured.
///
/// Uses a 2s hint; jitter max for attempt 0 is 0.5s, so asserting elapsed ≥ 2s
/// cleanly distinguishes hint-honoured from jitter-fallback.
#[tokio::test]
async fn moderation_kind_ingest_429_is_retried_until_success() {
let (url, attempts) = test_server(|n| {
if n < 2 {
(
StatusCode::TOO_MANY_REQUESTS,
// Exact production envelope: api_error() wraps every message as
// {"error":"..."}. The extracted field starts with "rate-limited:"
// so the command is retried; the hint is honoured.
r#"{"error":"rate-limited: quota exceeded; retry in 2s"}"#.to_string(),
)
} else {
(
StatusCode::OK,
r#"{"event_id":"abc","accepted":true,"message":""}"#.to_string(),
)
}
})
.await;
let client = test_client(&url);
let event = make_moderation_event(&client_keys(&client), 9041);
let t0 = std::time::Instant::now();
let result = client.submit_event(event).await;
let elapsed = t0.elapsed();
assert!(
result.is_ok(),
"expected Ok after ingest-429 retry, got {result:?}"
);
assert!(
attempts.load(Ordering::SeqCst) >= 2,
"must have retried at least once"
);
assert!(
elapsed.as_secs_f64() >= 2.0,
"elapsed {:.2}s < 2s — hint was not honoured (fell back to jitter)",
elapsed.as_secs_f64()
);
}
/// A moderation command that receives the canonical pre-ingest 429 on EVERY
/// attempt exhausts the retry budget and surfaces `CliError::Relay { status: 429 }` —
/// NOT `DeliveryUnknown`. The relay provably never executed the command on any
/// attempt, so the caller must be told it is safe to retry.
#[tokio::test]
async fn exhausted_ingest_429_returns_relay_429_retryable() {
let (url, attempts) = test_server(|_n| {
(
StatusCode::TOO_MANY_REQUESTS,
r#"{"error":"rate-limited: quota exceeded; retry in 0s"}"#.to_string(),
)
})
.await;
let client = test_client(&url);
let event = make_moderation_event(&client_keys(&client), 9040);
let err = client.submit_event(event).await.unwrap_err();
// Must be Relay(429), not DeliveryUnknown.
assert!(
matches!(err, CliError::Relay { status: 429, .. }),
"exhausted ingest 429 must surface as Relay(429), got {err:?}"
);
// Must NOT be retryable:false.
assert!(
crate::error::is_retryable_error(&err),
"Relay(429) must be retryable; got {err:?}"
);
// All RETRY_MAX_ATTEMPTS must have been tried.
assert_eq!(
attempts.load(Ordering::SeqCst),
3,
"all retry attempts must fire for exhausted ingest 429"
);
}
/// A moderation command (kind 9042) that gets HTTP 502 returns `DeliveryUnknown`
/// immediately — proxy errors leave relay execution state ambiguous.
#[tokio::test]
async fn moderation_kind_502_returns_delivery_unknown() {
let (url, attempts) =
test_server(|_n| (StatusCode::BAD_GATEWAY, "bad gateway".to_string())).await;
let client = test_client(&url);
let event = make_moderation_event(&client_keys(&client), 9042);
let err = client.submit_event(event).await.unwrap_err();
assert!(
matches!(err, CliError::DeliveryUnknown(_)),
"expected DeliveryUnknown for 502, got {err:?}"
);
// 502 must not be retried — exactly 1 attempt.
assert_eq!(
attempts.load(Ordering::SeqCst),
1,
"must not retry 502 for moderation kind"
);
}
/// When all retry attempts are connect-failures (the relay definitively never saw
/// the request), `submit_event` must return `CliError::Network` with
/// `retryable:true` — not `DeliveryUnknown`. Connect-failure is the one error
/// condition the implementation itself identifies as confirmed-unreceived.
#[tokio::test]
async fn exhausted_connect_failures_return_network_retryable() {
// Bind a port, capture the address, then drop the listener so every
// subsequent connect attempt is refused immediately.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let base = format!("http://{addr}");
let client = test_client(&base);
let event = make_moderation_event(&client_keys(&client), 9040);
let err = client.submit_event(event).await.unwrap_err();
// Must be Network (retryable), not DeliveryUnknown (retryable:false).
assert!(
matches!(err, super::super::error::CliError::Network(_)),
"exhausted connect failures must surface as Network, got {err:?}"
);
// Confirm the error description does not suggest ambiguous delivery.
let description = format!("{err:?}");
assert!(
!description.contains("outcome unknown"),
"connect failure must not be labeled DeliveryUnknown; got: {description}"
);
}
/// A stored (non-moderation) event submitted to a server that returns 502 on the
/// first attempt and then succeeds is retried under the standard policy.
#[tokio::test]
async fn stored_event_502_is_retried_under_standard_policy() {
let (url, attempts) = test_server(|n| {
if n == 1 {
(StatusCode::BAD_GATEWAY, "transient".to_string())
} else {
(
StatusCode::OK,
r#"{"event_id":"abc","accepted":true,"message":""}"#.to_string(),
)
}
})
.await;
let client = test_client(&url);
let event = make_stored_event(&client_keys(&client));
let result = client.submit_event(event).await;
assert!(
result.is_ok(),
"expected Ok after 502 retry for stored event, got {result:?}"
);
assert!(
attempts.load(Ordering::SeqCst) >= 2,
"must have retried at least once"
);
}
/// Spin up a one-shot axum server that handles `GET /info` (and any other GET).
/// Same contract as `test_server` — returns base URL and attempt counter.
async fn get_server<F>(f: F) -> (String, Arc<AtomicU32>)
where
F: Fn(u32) -> (StatusCode, String) + Send + Sync + 'static,
{
let counter = Arc::new(AtomicU32::new(0));
let handler: Arc<dyn Fn(u32) -> (StatusCode, String) + Send + Sync> = Arc::new(f);
let state = (handler, counter.clone());
type S = (
Arc<dyn Fn(u32) -> (StatusCode, String) + Send + Sync>,
Arc<AtomicU32>,
);
let app = Router::new()
.route(
"/{*path}",
axum::routing::get(
|State((handler, ctr)): State<S>, _headers: HeaderMap| async move {
let n = ctr.fetch_add(1, Ordering::SeqCst) + 1;
let (status, body) = handler(n);
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap()
},
),
)
.with_state(state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr: SocketAddr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(format!("http://{addr}"), counter)
}
/// `with_retry_body` retries transient HTTP 502 on a read path (`get_authed`)
/// and succeeds on the next attempt.
#[tokio::test]
async fn query_502_is_retried_then_succeeds() {
let (url, attempts) = get_server(|n| {
if n == 1 {
(
StatusCode::BAD_GATEWAY,
"transient gateway error".to_string(),
)
} else {
(StatusCode::OK, r#"{"ok":true}"#.to_string())
}
})
.await;
let client = test_client(&url);
let result = client.get_authed("/info").await;
assert!(
result.is_ok(),
"expected Ok after 502 retry, got {result:?}"
);
assert!(
attempts.load(Ordering::SeqCst) >= 2,
"must have retried at least once"
);
}
/// `with_retry_body` retries a 429 with a `retry in Ns` hint, honours the hint
/// delay (not the shorter jitter fallback), and ultimately succeeds.
///
/// Uses a 2s hint; jitter max for attempt 0 is 0.5s, so asserting elapsed ≥ 2s
/// cleanly distinguishes hint-honoured from jitter-fallback.
#[tokio::test]
async fn query_429_with_hint_is_retried() {
let (url, attempts) = get_server(|n| {
if n < 2 {
(
StatusCode::TOO_MANY_REQUESTS,
// handle_response extracts the "error" field; the plain text
// "rate-limited: retry in 2s" then reaches parse_retry_hint_text.
r#"{"error":"rate-limited: retry in 2s"}"#.to_string(),
)
} else {
(StatusCode::OK, r#"{"ok":true}"#.to_string())
}
})
.await;
let client = test_client(&url);
let t0 = std::time::Instant::now();
// Measure from just before attempt 1 fires so we capture the inter-attempt wait.
let result = client.get_authed("/info").await;
// Record elapsed after attempt 1 returns (inside the future) is not possible
// directly, but the total includes the hint sleep; jitter max is 0.5s so ≥ 2s
// proves the hint was honoured.
let elapsed = t0.elapsed();
assert!(
result.is_ok(),
"expected Ok after 429 retry, got {result:?}"
);
assert!(
attempts.load(Ordering::SeqCst) >= 2,
"must have retried at least once"
);
assert!(
elapsed.as_secs_f64() >= 2.0,
"elapsed {:.2}s < 2s — hint was not honoured (fell back to jitter)",
elapsed.as_secs_f64()
);
}
/// A definitive 4xx (403 Forbidden) is NOT retried — exactly 1 attempt.
#[tokio::test]
async fn query_403_is_not_retried() {
let (url, attempts) = get_server(|_n| {
(
StatusCode::FORBIDDEN,
r#"{"error":"not allowed"}"#.to_string(),
)
})
.await;
let client = test_client(&url);
let result = client.get_authed("/info").await;
assert!(
matches!(result, Err(CliError::Relay { status: 403, .. })),
"expected Relay 403 error, got {result:?}"
);
assert_eq!(
attempts.load(Ordering::SeqCst),
1,
"403 must not be retried"
);
}
/// `with_retry_body` retries on `is_body()` network errors (F2: body transfer inside
/// the retry boundary). Verified by confirming that a call through `get_authed`
/// (which uses `with_retry_body`) retries when the server drops the connection after
/// sending headers. We simulate body loss by returning an intentionally truncated
/// chunked response that reqwest will surface as an `is_body()` error.
///
/// This test uses a raw TCP server to write partial HTTP responses; axum cannot
/// easily simulate mid-body connection drops.
#[tokio::test]
async fn with_retry_body_retries_on_body_transfer_failure() {
use tokio::io::AsyncWriteExt;
let counter = Arc::new(AtomicU32::new(0));
let counter2 = counter.clone();
// Bind a raw TCP listener.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
let n = counter2.fetch_add(1, Ordering::SeqCst) + 1;
// Consume the request (required to avoid connection reset by server).
let mut buf = vec![0u8; 4096];
use tokio::io::AsyncReadExt;
let _ = tokio::time::timeout(
std::time::Duration::from_millis(100),
stream.read(&mut buf),
)
.await;
if n < 3 {
// Attempts 1 & 2: send valid headers claiming a body, then drop.
let partial = b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 100\r\n\r\n{\"partial\":";
let _ = stream.write_all(partial).await;
// Drop the stream without completing the body — causes is_body() on client.
} else {
// Attempt 3: complete response.
let ok = b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 2\r\n\r\n{}";
let _ = stream.write_all(ok).await;
}
}
});
let base = format!("http://{addr}");
// get_authed internally uses with_retry_body — the body read is inside the retry loop.
let client = test_client(&base);
// Stub path: the raw TCP server ignores the URL and always responds based on attempt count.
let result = client.get_authed("/any-path").await;
assert!(
result.is_ok(),
"expected Ok after body-loss retries, got {result:?}"
);
assert_eq!(
counter.load(Ordering::SeqCst),
3,
"expected 3 attempts (2 body-loss + 1 success)"
);
}
/// `submit_event` (non-moderation kind) uses `with_retry_body` — the full
/// operation including response body read is inside the retry boundary.
/// A partial-body drop after 200 headers must be retried with the same
/// serialized event bytes (and a fresh NIP-98 auth per attempt).
#[tokio::test]
async fn stored_event_body_loss_is_retried_with_same_event_bytes() {
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
let counter = Arc::new(AtomicU32::new(0));
let counter2 = counter.clone();
let bodies: Arc<std::sync::Mutex<Vec<Vec<u8>>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let bodies2 = bodies.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
let n = counter2.fetch_add(1, Ordering::SeqCst) + 1;
// Read the full HTTP request so we can capture the body.
let mut buf = vec![0u8; 8192];
let _ = tokio::time::timeout(
std::time::Duration::from_millis(200),
stream.read(&mut buf),
)
.await;
// Capture raw request bytes for assertion.
let body_end = buf
.windows(4)
.position(|w| w == b"\r\n\r\n")
.map(|i| i + 4)
.unwrap_or(0);
let payload = buf[body_end..].to_vec();
bodies2.lock().unwrap().push(payload);
if n < 3 {
// Partial body drop.
let partial = b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 100\r\n\r\n{\"partial\":";
let _ = stream.write_all(partial).await;
} else {
let ok = b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 41\r\n\r\n{\"event_id\":\"abc\",\"accepted\":true,\"message\":\"\"}";
let _ = stream.write_all(ok).await;
}
}
});
let base = format!("http://{addr}");
let client = test_client(&base);
let event = make_stored_event(&client_keys(&client));
let result = client.submit_event(event).await;
assert!(
result.is_ok(),
"expected Ok after body-loss retries, got {result:?}"
);
assert_eq!(
counter.load(Ordering::SeqCst),
3,
"expected 3 attempts (2 body-loss + 1 success)"
);
// All three attempts must have sent the same serialized event bytes.
let captured = bodies.lock().unwrap();
assert_eq!(captured.len(), 3, "must have captured 3 request bodies");
// Each attempt's payload must be identical (same signed event bytes).
assert_eq!(
captured[0], captured[1],
"attempt 1 and 2 must use identical event bytes"
);
assert_eq!(
captured[1], captured[2],
"attempt 2 and 3 must use identical event bytes"
);
}
/// `upload_file` uses `with_retry_body` — the full operation including response
/// body read is inside the retry boundary. A partial-body drop after 200 headers
/// must be retried with identical file bytes and a fresh Blossom auth per attempt.
#[tokio::test]
async fn upload_body_loss_is_retried_with_same_file_bytes() {
use std::io::Write;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
// Write a minimal JPEG file so MIME detection works.
let mut tmp = tempfile::NamedTempFile::new().unwrap();
// JPEG magic + JFIF app0 marker: enough for `infer` to detect image/jpeg.
let jpeg_header: &[u8] = &[
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
];
tmp.write_all(jpeg_header).unwrap();
let file_path = tmp.path().to_str().unwrap().to_string();
let counter = Arc::new(AtomicU32::new(0));
let counter2 = counter.clone();
let auth_headers: Arc<std::sync::Mutex<Vec<String>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let auth_headers2 = auth_headers.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
let n = counter2.fetch_add(1, Ordering::SeqCst) + 1;
// Read the request headers to extract the Authorization value.
let mut buf = vec![0u8; 8192];
let _ = tokio::time::timeout(
std::time::Duration::from_millis(200),
stream.read(&mut buf),
)
.await;
// Extract the Authorization header value.
let req_str = String::from_utf8_lossy(&buf);
let auth = req_str
.lines()
.find(|l| l.to_lowercase().starts_with("authorization:"))
.map(|l| l.to_string())
.unwrap_or_default();
auth_headers2.lock().unwrap().push(auth);
if n < 3 {
// Partial body drop.
let partial = b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 100\r\n\r\n{\"partial\":";
let _ = stream.write_all(partial).await;
} else {
// Valid BlobDescriptor response.
let ok_body = r#"{"url":"https://relay.test/media/aabbcc.jpg","sha256":"aabbcc","size":12,"type":"image/jpeg","uploaded":0}"#;
let ok = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
ok_body.len(),
ok_body
);
let _ = stream.write_all(ok.as_bytes()).await;
}
}
});
let base = format!("http://{addr}");
let client = test_client(&base);
let result = client.upload_file(&file_path).await;
assert!(
result.is_ok(),
"expected Ok after upload body-loss retries, got {result:?}"
);
assert_eq!(
counter.load(Ordering::SeqCst),
3,
"expected 3 upload attempts (2 body-loss + 1 success)"
);
// Each attempt must carry a distinct Authorization header (fresh Blossom auth).
let auths = auth_headers.lock().unwrap();
assert_eq!(auths.len(), 3, "must have captured 3 auth headers");
// All three must be non-empty (auth was signed).
assert!(
auths.iter().all(|a| a.contains("Nostr ")),
"each attempt must carry Nostr auth"
);
}
/// When all retry attempts for a stored event end with a partial body (200
/// headers, dropped connection), the final error must be `DeliveryUnknown`
/// (retryable:false) — the relay may have stored the event on any attempt, so
/// an outer re-sign would risk a duplicate visible write. All three attempts
/// must fire with identical serialized event bytes.
#[tokio::test]
async fn stored_event_all_body_losses_return_delivery_unknown() {
use tokio::io::AsyncWriteExt;
let bodies: Arc<std::sync::Mutex<Vec<Vec<u8>>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let bodies2 = bodies.clone();
let counter = Arc::new(AtomicU32::new(0));
let counter2 = counter.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
use tokio::io::AsyncReadExt;
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
counter2.fetch_add(1, Ordering::SeqCst);
let mut buf = vec![0u8; 8192];
let _ = tokio::time::timeout(
std::time::Duration::from_millis(200),
stream.read(&mut buf),
)
.await;
// Extract the request body (after the blank line separating headers).
let raw = buf.split(|&b| b == 0).next().unwrap_or(&buf).to_vec();
if let Some(pos) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
bodies2.lock().unwrap().push(raw[pos + 4..].to_vec());
}
// Partial body: send headers + truncated body, then drop.
let _ = stream
.write_all(
b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 100\r\n\r\n{\"partial\":",
)
.await;
// Drop stream — causes body-loss error on the client side.
}
});
let base = format!("http://{addr}");
let client = test_client(&base);
let event = make_stored_event(&client_keys(&client));
let err = client.submit_event(event).await.unwrap_err();
// Final error must be DeliveryUnknown — relay may have accepted any attempt.
assert!(
matches!(err, CliError::DeliveryUnknown(_)),
"all-body-loss exhaustion must return DeliveryUnknown, got {err:?}"
);
// All RETRY_MAX_ATTEMPTS must have fired.
assert_eq!(
counter.load(Ordering::SeqCst),
3,
"all 3 attempts must be made before surfacing DeliveryUnknown"
);
// All attempts must have sent identical serialized event bytes.
let captured = bodies.lock().unwrap();
if captured.len() >= 2 {
assert_eq!(
captured[0], captured[1],
"all attempts must use identical event bytes"
);
}
}
/// When all retry attempts for a stored event return HTTP 502, the final error
/// must be `DeliveryUnknown` (retryable:false) — a proxy 502 may occur after
/// the relay accepted the event.
#[tokio::test]
async fn stored_event_all_502s_return_delivery_unknown() {
let (url, attempts) =
test_server(|_n| (StatusCode::BAD_GATEWAY, "bad gateway".to_string())).await;
let client = test_client(&url);
let event = make_stored_event(&client_keys(&client));
let err = client.submit_event(event).await.unwrap_err();
assert!(
matches!(err, CliError::DeliveryUnknown(_)),
"all-502 exhaustion must return DeliveryUnknown, got {err:?}"
);
assert_eq!(
attempts.load(Ordering::SeqCst),
3,
"all 3 attempts must fire before surfacing DeliveryUnknown"
);
}
/// The first send is stored but its answer is lost; the retry is then
/// refused before ingest. The refusal cannot undo the first send, so the
/// outcome stays unknown rather than a plain rejection.
#[tokio::test]
async fn a_403_after_a_lost_stored_send_stays_delivery_unknown() {
let (url, attempts) = test_server(|n| match n {
1 => (StatusCode::BAD_GATEWAY, "bad gateway".to_string()),
_ => (
StatusCode::FORBIDDEN,
r#"{"error":"restricted: not allowed"}"#.to_string(),
),
})
.await;
let client = test_client(&url);
let event = make_stored_event(&client_keys(&client));
let err = client.submit_event(event).await.unwrap_err();
assert!(matches!(err, CliError::DeliveryUnknown(_)), "{err:?}");
assert!(!crate::error::is_retryable_error(&err));
assert_eq!(attempts.load(Ordering::SeqCst), 2, "no retry after the 403");
}
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
#[tokio::test]
async fn an_unreadable_file_is_bad_input() {
use std::os::unix::fs::PermissionsExt;
let file = tempfile::NamedTempFile::new().unwrap();
std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o000)).unwrap();
let client = BuzzClient::new(
"http://127.0.0.1:9".into(),
crate::credentials::Credentials::from_keys(nostr::Keys::generate()),
)
.unwrap();
let err = client
.upload_file(file.path().to_str().unwrap())
.await
.unwrap_err();
assert!(
matches!(err, crate::error::CliError::Usage(ref m) if m.contains("failed to read")),
"{err:?}"
);
}
use super::{
advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field,
normalize_events, BuzzClient, Credentials, Proof,
};
use nostr::{EventBuilder, Keys, Kind, Tag};
#[test]
fn normalize_events_preserves_the_complete_signed_event_shape() {
let signed_event = EventBuilder::new(Kind::TextNote, "signed content")
.tags([Tag::parse(["h", "channel-id"]).unwrap()])
.sign_with_keys(&Keys::generate())
.unwrap();
let mut event = serde_json::to_value(&signed_event).unwrap();
event["relay_internal"] = serde_json::json!("excluded");
let output: Vec<serde_json::Value> =
serde_json::from_str(&normalize_events(&[event])).unwrap();
let normalized = &output[0];
let round_tripped: nostr::Event = serde_json::from_value(normalized.clone()).unwrap();
assert_eq!(round_tripped, signed_event);
round_tripped.verify().unwrap();
assert!(normalized.get("sig").is_some());
assert!(normalized.get("relay_internal").is_none());
}
#[test]
fn normalize_events_omits_missing_or_non_string_signatures() {
let output: Vec<serde_json::Value> = serde_json::from_str(&normalize_events(&[
serde_json::json!({}),
serde_json::json!({"sig": 42}),
]))
.unwrap();
assert!(output[0].get("sig").is_none());
assert!(output[1].get("sig").is_none());
}
#[test]
fn query_cursor_uses_last_events_composite_sort_key() {
let mut filter = serde_json::json!({"kinds": [39000], "limit": 500});
let page = vec![
serde_json::json!({"id": "a".repeat(64), "created_at": 20}),
serde_json::json!({"id": "b".repeat(64), "created_at": 10}),
];
advance_query_cursor(&mut filter, &page).unwrap();
assert_eq!(filter["until"], serde_json::json!(10));
assert_eq!(filter["before_id"], serde_json::json!("b".repeat(64)));
}
#[test]
fn query_cursor_rejects_missing_or_malformed_sort_key() {
let mut filter = serde_json::json!({});
assert!(
advance_query_cursor(&mut filter, &[serde_json::json!({"id": "a".repeat(64)})])
.is_err()
);
assert!(advance_query_cursor(
&mut filter,
&[serde_json::json!({"id": "not-an-event-id", "created_at": 10})]
)
.is_err());
}
#[test]
fn extract_relay_response_field_reads_response_message_json() {
let raw = r#"{"event_id":"abc","accepted":true,"message":"response:{\"workflow_id\":\"relay-id\",\"created\":true}"}"#;
assert_eq!(
extract_relay_response_field(raw, "workflow_id").as_deref(),
Some("relay-id")
);
}
#[test]
fn extract_relay_response_field_returns_none_for_non_response_message() {
let raw = r#"{"event_id":"abc","accepted":true,"message":""}"#;
assert!(extract_relay_response_field(raw, "workflow_id").is_none());
}
#[test]
fn create_response_with_id_if_accepted_injects_id_when_accepted() {
let raw = r#"{"event_id":"abc","accepted":true,"message":"response:{\"workflow_id\":\"relay-id\"}"}"#;
let out = create_response_with_id_if_accepted(raw, "workflow_id", "relay-id");
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
// ID injected and original fields preserved when accepted.
assert_eq!(v["workflow_id"].as_str(), Some("relay-id"));
assert_eq!(v["event_id"].as_str(), Some("abc"));
assert_eq!(v["accepted"].as_bool(), Some(true));
}
#[test]
fn create_response_with_id_if_accepted_omits_id_when_rejected() {
let raw = r#"{"event_id":"abc","accepted":false,"message":"duplicate"}"#;
let out = create_response_with_id_if_accepted(raw, "workflow_id", "local-id");
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
// ID must not be present when relay rejected the event; emitting a
// link to an event that was never stored would mislead callers.
assert!(
v.get("workflow_id").is_none(),
"link field must be absent on rejected create"
);
assert_eq!(v["accepted"].as_bool(), Some(false));
}
// --- (a) auth-suppression regression pair ---
fn make_auth_tag() -> (Tag, String) {
let owner_hex = "a".repeat(64);
let sig_hex = "b".repeat(128);
let tag_vec = vec![
"auth".to_string(),
owner_hex,
"conditions".to_string(),
sig_hex,
];
let json = serde_json::to_string(&tag_vec).unwrap();
let tag = Tag::parse(tag_vec).unwrap();
(tag, json)
}
#[tokio::test]
async fn sign_event_unchecked_does_not_inject_ambient_auth_tag() {
let keys = Keys::generate();
let (auth_tag, auth_json) = make_auth_tag();
let client = BuzzClient::new(
"https://test.relay".into(),
Credentials::from_keys(keys).with_auth_tag(auth_tag, auth_json),
)
.unwrap();
let builder =
EventBuilder::new(Kind::Custom(9035), "archive").tags([Tag::parse(["-"]).unwrap()]);
let event = client.sign_event_unchecked(builder).await.unwrap();
let auth_tags: Vec<_> = event
.tags
.iter()
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
.collect();
assert!(
auth_tags.is_empty(),
"sign_event_unchecked must not inject the ambient NIP-OA auth tag \
into identity archive events; found {auth_tags:?}"
);
}
#[tokio::test]
async fn sign_event_unchecked_preserves_callers_content_auth_tag() {
let keys = Keys::generate();
let (auth_tag, auth_json) = make_auth_tag();
let client = BuzzClient::new(
"https://test.relay".into(),
Credentials::from_keys(keys).with_auth_tag(auth_tag, auth_json),
)
.unwrap();
let content_auth = Tag::parse([
"auth",
&"c".repeat(64),
"owner-attestation",
&"d".repeat(128),
])
.unwrap();
let builder = EventBuilder::new(Kind::Custom(9035), "archive")
.tags([Tag::parse(["-"]).unwrap(), content_auth]);
let event = client.sign_event_unchecked(builder).await.unwrap();
let auth_tags: Vec<_> = event
.tags
.iter()
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
.collect();
assert_eq!(
auth_tags.len(),
1,
"content-level auth tag must survive sign_event_unchecked; found {auth_tags:?}"
);
assert_eq!(auth_tags[0].as_slice()[1], "c".repeat(64));
}
#[tokio::test]
async fn authorize_sets_auth_tag_header_when_configured() {
let keys = Keys::generate();
let (auth_tag, auth_json) = make_auth_tag();
let client = BuzzClient::new(
"https://test.relay".into(),
Credentials::from_keys(keys).with_auth_tag(auth_tag, auth_json.clone()),
)
.unwrap();
let url = "https://test.relay/events";
let built = client
.authorize(client.http.post(url), Proof::post(url, b"{}"))
.await
.unwrap()
.build()
.unwrap();
let header = built
.headers()
.get("x-auth-tag")
.expect("x-auth-tag header must be present");
assert_eq!(
header.to_str().unwrap(),
&auth_json,
"x-auth-tag header must carry the raw auth tag JSON"
);
}
#[tokio::test]
async fn authorize_omits_optional_headers_when_not_configured() {
let client = BuzzClient::new(
"https://test.relay".into(),
Credentials::from_keys(Keys::generate()),
)
.unwrap();
let url = "https://test.relay/events";
let built = client
.authorize(client.http.post(url), Proof::post(url, b"{}"))
.await
.unwrap()
.build()
.unwrap();
assert!(built.headers().get("authorization").is_some());
assert!(
built.headers().get("x-auth-tag").is_none(),
"x-auth-tag header must not be present when no auth tag is configured"
);
assert!(
built.headers().get("nostr-federated-identity").is_none(),
"no NIP-FI header without an evidence source"
);
}
}
/// NIP-FI evidence on every protected route (Review-Proven Rule #2: every
/// request path that carries auth is enumerated here, plus the WebSocket
/// upgrade). One mock origin serves both the NIP-FA adapter and the relay.
#[cfg(test)]
mod federated_identity_route_tests {
use std::sync::{Arc, Mutex};
use axum::extract::Request;
use axum::http::{HeaderMap, StatusCode};
use axum::response::IntoResponse;
use nostr::{EventBuilder, Keys, Kind};
use super::BuzzClient;
use crate::credentials::Credentials;
use crate::error::CliError;
use crate::federated_identity::AdapterSession;
use crate::federated_identity::test_support::{self, count, raw_adapter};
const HASH: &str = "4f2b5e7c1a9d3e6f8b0c2d4e6f8a0b1c3d5e7f9a1b3c5d7e9f0a2b4c6d8e0f1a";
#[derive(Default)]
struct Log {
mints: usize,
/// The last assertion the adapter issued.
assertion: String,
requests: Vec<(String, String, HeaderMap)>,
}
/// Serve the adapter and relay on one loopback origin. Relay routes
/// answer `deny` when set, otherwise a plausible success; `PUT /upload`
/// is 404 so the legacy `/media/upload` fallback runs too.
async fn origin(deny: Option<StatusCode>) -> (String, Arc<Mutex<Log>>) {
let log: Arc<Mutex<Log>> = Arc::default();
let state = log.clone();
let app = axum::Router::new()
.route(
"/v1/identity/assertions",
axum::routing::post({
let log = log.clone();
move |body: axum::body::Bytes| {
let log = log.clone();
async move {
let req: serde_json::Value = serde_json::from_slice(&body).unwrap();
let pubkey = req["nostr_pubkey"].as_str().unwrap_or_default();
let expires_at = nostr::Timestamp::now().as_secs() + 300;
let assertion = test_support::assertion(pubkey, expires_at);
let mut log = log.lock().unwrap();
log.mints += 1;
log.assertion = assertion.clone();
axum::Json(serde_json::json!({
"assertion": assertion,
"nostr_pubkey": pubkey,
"expires_at": expires_at,
}))
}
}
}),
)
.fallback(move |req: Request| {
let log = state.clone();
async move {
let method = req.method().to_string();
let path = req.uri().path().to_owned();
log.lock().unwrap().requests.push((
method.clone(),
path.clone(),
req.headers().clone(),
));
if let Some(status) = deny {
return (status, r#"{"error":"authentication required"}"#).into_response();
}
let body = match (method.as_str(), path.as_str()) {
("PUT", "/upload") => return StatusCode::NOT_FOUND.into_response(),
// No WebSocket server: the test only needs the upgrade's headers.
("GET", "/") => return StatusCode::FORBIDDEN.into_response(),
("PUT", "/media/upload") => serde_json::json!({
"url": format!("http://x/media/{HASH}.png"),
"sha256": HASH,
"size": 8,
"type": "image/png",
"uploaded": 0,
})
.to_string(),
("POST", "/query") => "[]".to_owned(),
("POST", "/count") => r#"{"count":0}"#.to_owned(),
_ => r#"{"accepted":true}"#.to_owned(),
};
(StatusCode::OK, body).into_response()
}
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(base, log)
}
fn client(base: &str, evidence: bool) -> BuzzClient {
if evidence {
client_via(base, base)
} else {
BuzzClient::new(base.to_owned(), Credentials::from_keys(Keys::generate())).unwrap()
}
}
/// A client for the relay at `relay` that mints from the adapter at `adapter`.
fn client_via(relay: &str, adapter: &str) -> BuzzClient {
let credentials = Credentials::from_keys(Keys::generate())
.with_evidence(AdapterSession::new(adapter, "token", relay).unwrap());
BuzzClient::new(relay.to_owned(), credentials).unwrap()
}
/// Drive every protected request path once, ignoring results (the WS
/// publish fails by design once its upgrade headers are recorded).
async fn exercise_every_route(client: &BuzzClient) -> Vec<Result<String, CliError>> {
let keys = client.credentials().test_keys();
let png = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
std::fs::write(png.path(), b"\x89PNG\r\n\x1a\n").unwrap();
let filter = serde_json::json!({"kinds": [1]});
let body = serde_json::json!({"k": "v"});
let event = |kind: u16| {
EventBuilder::new(Kind::Custom(kind), "")
.sign_with_keys(&keys)
.unwrap()
};
let mut results = vec![
client.query(&filter).await,
client.query_multi(&[filter.clone(), filter.clone()]).await,
client.count(&filter).await,
client.get_authed("/api/get").await,
client.post_json_authed("/api/post", &body).await,
client.post_json_once_authed("/api/post-once", &body).await,
client.submit_event(event(1)).await,
client.submit_event(event(9041)).await,
client.publish_ephemeral_event(event(20001)).await,
];
results.push(
client
.upload_file(png.path().to_str().unwrap())
.await
.map(|d| d.url),
);
results.push(
client
.download_media(&format!("{HASH}.png"))
.await
.map(|_| String::new()),
);
results
}
/// Each `(method, path)` the CLI sends on a protected route.
const PROTECTED: &[(&str, &str)] = &[
("POST", "/query"),
("POST", "/count"),
("GET", "/api/get"),
("POST", "/api/post"),
("POST", "/api/post-once"),
("POST", "/events"),
("GET", "/"),
("PUT", "/upload"),
("PUT", "/media/upload"),
];
fn covered(log: &Log) -> Vec<(String, String)> {
let mut seen: Vec<(String, String)> = log
.requests
.iter()
.map(|(m, p, _)| (m.clone(), p.clone()))
.collect();
seen.sort();
seen.dedup();
seen
}
/// The relay proof is signed after the assertion arrives, so a slow
/// mint (adapter retries can exceed NIP-98's 60 s window) cannot age it.
#[tokio::test]
async fn the_relay_proof_is_signed_after_a_slow_mint() {
use base64::Engine;
let minted_at: Arc<Mutex<u64>> = Arc::default();
let proofs: Arc<Mutex<Vec<String>>> = Arc::default();
let app = axum::Router::new()
.route(
"/v1/identity/assertions",
axum::routing::post({
let minted_at = minted_at.clone();
move |body: axum::body::Bytes| {
let minted_at = minted_at.clone();
async move {
tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
let req: serde_json::Value = serde_json::from_slice(&body).unwrap();
let pubkey = req["nostr_pubkey"].as_str().unwrap_or_default();
let now = nostr::Timestamp::now().as_secs();
*minted_at.lock().unwrap() = now;
axum::Json(serde_json::json!({
"assertion": test_support::assertion(pubkey, now + 300),
"nostr_pubkey": pubkey,
"expires_at": now + 300,
}))
}
}
}),
)
.fallback({
let proofs = proofs.clone();
move |headers: HeaderMap| {
let proofs = proofs.clone();
async move {
let auth = headers["authorization"].to_str().unwrap().to_owned();
proofs.lock().unwrap().push(auth);
(StatusCode::OK, "{}")
}
}
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
client(&base, true).get_authed("/api/get").await.unwrap();
let proof = proofs.lock().unwrap()[0].clone();
let json = base64::engine::general_purpose::STANDARD
.decode(proof.trim_start_matches("Nostr "))
.unwrap();
let event: nostr::Event = serde_json::from_slice(&json).unwrap();
assert!(
event.created_at.as_secs() >= *minted_at.lock().unwrap(),
"the proof was signed before the assertion arrived"
);
}
#[tokio::test]
async fn every_protected_route_and_the_upgrade_carry_the_assertion() {
let (base, log) = origin(None).await;
let results = exercise_every_route(&client(&base, true)).await;
let log = log.lock().unwrap();
for (method, path) in PROTECTED {
assert!(
covered(&log).contains(&(method.to_string(), path.to_string())),
"{method} {path} was not exercised"
);
}
assert!(
log.requests
.iter()
.any(|(_, p, _)| p.starts_with("/media/") && p != "/media/upload"),
"GET /media/<hash> was not exercised"
);
for (method, path, headers) in &log.requests {
assert_eq!(
headers
.get("nostr-federated-identity")
.and_then(|v| v.to_str().ok()),
Some(format!("Bearer {}", log.assertion).as_str()),
"{method} {path}"
);
if path != "/" {
assert!(
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.starts_with("Nostr ")),
"{method} {path}: Authorization proof"
);
}
}
assert_eq!(log.mints, 1, "one assertion serves the whole process");
// Everything but the WS publish (no WS server) succeeds.
for (i, result) in results.iter().enumerate() {
if i != 8 {
assert!(result.is_ok(), "route {i}: {result:?}");
}
}
}
#[tokio::test]
async fn exempt_routes_and_relays_without_a_source_get_no_assertion() {
let (base, log) = origin(None).await;
let plain = client(&base, false);
exercise_every_route(&plain).await;
plain.get_public("/info").await.unwrap();
{
let log = log.lock().unwrap();
assert_eq!(log.mints, 0);
assert!(log.requests.len() >= PROTECTED.len());
for (method, path, headers) in &log.requests {
assert!(
headers.get("nostr-federated-identity").is_none(),
"{method} {path}"
);
}
}
// An exempt route never mints or presents evidence, even with a source.
let (base, log) = origin(None).await;
client(&base, true).get_public("/info").await.unwrap();
let log = log.lock().unwrap();
assert_eq!(log.mints, 0);
assert!(log.requests[0].2.get("nostr-federated-identity").is_none());
}
/// An enforcing relay's 401 with no evidence source names the settings,
/// on HTTP and on the WebSocket upgrade, and is not retried.
#[tokio::test]
async fn a_401_without_a_source_names_the_evidence_settings() {
let (base, log) = origin(Some(StatusCode::UNAUTHORIZED)).await;
let plain = client(&base, false);
let keys = plain.credentials().test_keys();
let ws_event = EventBuilder::new(Kind::Custom(20001), "")
.sign_with_keys(&keys)
.unwrap();
for err in [
plain.query(&serde_json::json!({})).await.unwrap_err(),
plain.publish_ephemeral_event(ws_event).await.unwrap_err(),
] {
assert!(
matches!(&err, CliError::Auth(m)
if m.contains("BUZZ_FEDERATED_IDENTITY_TOKEN")
&& m.contains("BUZZ_FEDERATED_IDENTITY_ADAPTER")),
"{err:?}"
);
}
assert_eq!(log.lock().unwrap().requests.len(), 2, "401 is not retried");
// With a source configured, a 401 is the relay's verdict, not a hint.
let (base, _) = origin(Some(StatusCode::UNAUTHORIZED)).await;
let err = client(&base, true)
.query(&serde_json::json!({}))
.await
.unwrap_err();
assert!(
matches!(err, CliError::Relay { status: 401, .. }),
"{err:?}"
);
}
/// The paths a failed mint must stop before anything reaches the relay:
/// a read, a post-once write, a moderation command, a stored message
/// (`messages send`) and the WebSocket upgrade (`users set-presence`).
/// Returns each path's error and how many mints that one call made.
async fn every_path_once(
client: &BuzzClient,
mints: &std::sync::atomic::AtomicUsize,
) -> Vec<(&'static str, CliError, usize)> {
let keys = client.credentials().test_keys();
let event = |kind: u16| {
EventBuilder::new(Kind::Custom(kind), "hi")
.sign_with_keys(&keys)
.unwrap()
};
let body = serde_json::json!({"k": "v"});
let mut out = Vec::new();
let mut record = |path, result: Result<String, CliError>, before| {
out.push((path, result.unwrap_err(), count(mints) - before));
};
let before = count(mints);
record(
"read",
client.query(&serde_json::json!({"kinds": [1]})).await,
before,
);
let before = count(mints);
record(
"post-once",
client.post_json_once_authed("/api/post-once", &body).await,
before,
);
let before = count(mints);
record("moderation", client.submit_event(event(9041)).await, before);
let before = count(mints);
record("message", client.submit_event(event(9)).await, before);
let before = count(mints);
record(
"websocket",
client.publish_ephemeral_event(event(20001)).await,
before,
);
out
}
/// An adapter 500 or 503 is retried a bounded number of times on every
/// path, then fails as a retryable adapter error. Nothing reaches the
/// relay, so a message is never reported as possibly delivered.
#[tokio::test]
async fn adapter_5xx_is_retried_on_every_path_and_never_reaches_the_relay() {
for response in [test_support::HTTP_500, test_support::HTTP_503] {
let (relay, log) = origin(None).await;
let (adapter, mints) = raw_adapter(response).await;
let client = client_via(&relay, &adapter);
for (path, err, attempts) in every_path_once(&client, &mints).await {
assert!(matches!(err, CliError::Adapter(_)), "{path}: {err:?}");
assert!(crate::error::is_retryable_error(&err), "{path}");
assert_eq!(attempts, 3, "{path}: three mints, no more");
}
assert!(
log.lock().unwrap().requests.is_empty(),
"the relay saw a request"
);
}
}
/// A first send whose outcome is lost, then an assertion refresh that
/// fails, is still possibly delivered: the adapter error must not invite
/// an outer re-sign.
#[tokio::test]
async fn a_refresh_failure_after_a_lost_send_stays_delivery_unknown() {
use std::sync::atomic::{AtomicUsize, Ordering};
let mints = Arc::new(AtomicUsize::new(0));
let sends = Arc::new(AtomicUsize::new(0));
let app = axum::Router::new()
.route(
"/v1/identity/assertions",
axum::routing::post({
let mints = mints.clone();
move |body: axum::body::Bytes| {
let mints = mints.clone();
async move {
if mints.fetch_add(1, Ordering::SeqCst) > 0 {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
let req: serde_json::Value = serde_json::from_slice(&body).unwrap();
let pubkey = req["nostr_pubkey"].as_str().unwrap_or_default();
let expires_at = nostr::Timestamp::now().as_secs() + 300;
axum::Json(serde_json::json!({
"assertion": test_support::assertion(pubkey, expires_at),
"nostr_pubkey": pubkey,
"expires_at": expires_at,
}))
.into_response()
}
}
}),
)
.fallback({
let sends = sends.clone();
move || {
let sends = sends.clone();
async move {
sends.fetch_add(1, Ordering::SeqCst);
// The relay may have stored it; the proxy lost the answer
// after the assertion reached its refresh boundary.
crate::cached_assertion::test_clock::advance(300);
StatusCode::BAD_GATEWAY.into_response()
}
}
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let client = client_via(&base, &base);
let keys = client.credentials().test_keys();
let event = EventBuilder::new(Kind::Custom(9), "hi")
.sign_with_keys(&keys)
.unwrap();
let err = client.submit_event(event).await.unwrap_err();
assert!(matches!(err, CliError::DeliveryUnknown(_)), "{err:?}");
assert!(!crate::error::is_retryable_error(&err));
assert_eq!(
sends.load(Ordering::SeqCst),
1,
"one send reached the relay"
);
assert_eq!(
mints.load(Ordering::SeqCst),
4,
"one mint, then three failed refreshes"
);
}
/// A 403 whose body is cut off is one refusal on every path, not a
/// retryable network error, and nothing reaches the relay.
#[tokio::test]
async fn a_truncated_adapter_403_is_a_single_refusal_on_every_path() {
let (relay, log) = origin(None).await;
let (adapter, mints) = raw_adapter(test_support::TRUNCATED_403).await;
let client = client_via(&relay, &adapter);
for (path, err, attempts) in every_path_once(&client, &mints).await {
assert!(matches!(err, CliError::Auth(_)), "{path}: {err:?}");
assert!(!crate::error::is_retryable_error(&err), "{path}");
assert_eq!(attempts, 1, "{path}: one mint");
}
assert!(
log.lock().unwrap().requests.is_empty(),
"the relay saw a request"
);
}
/// A relay redirect to another origin is never followed: the assertion,
/// proof and auth tag stay with the relay that was asked.
#[tokio::test]
async fn a_cross_origin_relay_redirect_is_not_followed() {
let (target, target_log) = origin(None).await;
let location = format!("{target}/query");
let app = axum::Router::new().fallback(move || {
let location = location.clone();
async move { (StatusCode::TEMPORARY_REDIRECT, [("location", location)]) }
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
// Mint from the target origin's adapter so the client has an assertion to leak.
let client = client_via(&relay, &target);
let body = serde_json::json!({"k": "v"});
for err in [
client.query(&serde_json::json!({})).await.unwrap_err(),
client.get_authed("/api/get").await.unwrap_err(),
client
.post_json_authed("/api/post", &body)
.await
.unwrap_err(),
] {
assert!(
matches!(err, CliError::Relay { status: 307, .. }),
"{err:?}"
);
}
let log = target_log.lock().unwrap();
assert_eq!(log.mints, 1);
assert!(
log.requests.is_empty(),
"redirect followed: {:?}",
covered(&log)
);
}
/// A WebSocket refused after NIP-42 with a policy close (a NIP-FI key
/// mismatch) is an auth error, exit 3, not a generic failure.
#[tokio::test]
async fn a_websocket_policy_close_is_an_auth_error() {
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::protocol::{frame::coding::CloseCode, CloseFrame};
use tokio_tungstenite::tungstenite::Message;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
ws.send(Message::Text(r#"["AUTH","challenge"]"#.into()))
.await
.unwrap();
let _auth = ws.next().await;
let _ = ws
.send(Message::Close(Some(CloseFrame {
code: CloseCode::Policy,
reason: "auth-required: key mismatch".into(),
})))
.await;
});
let client = client(&relay, false);
let event = EventBuilder::new(Kind::Custom(20001), "")
.sign_with_keys(&client.credentials().test_keys())
.unwrap();
let err = client.publish_ephemeral_event(event).await.unwrap_err();
assert!(
matches!(&err, CliError::Auth(m) if m.contains("key mismatch")),
"{err:?}"
);
assert_eq!(crate::error::exit_code(&err), 3);
assert!(!crate::error::is_retryable_error(&err));
}
}
/// A desktop that stays `rate_limited`, or throttles and then holds a
/// signature, through every command path that signs or encrypts.
#[cfg(all(test, unix))]
mod desktop_throttle_tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use nostr::{EventBuilder, Kind};
use super::ws_deadline_tests::ws_relay;
use super::BuzzClient;
use crate::desktop_provider::{tests as dp, Provider};
use crate::error::CliError;
/// A desktop holding `keys` that answers `rate_limited` to `throttled`.
async fn client(relay: &str, keys: &nostr::Keys, throttled: &'static str) -> BuzzClient {
let answer = dp::desktop_answers(keys.clone(), vec![relay.into()]);
let (endpoint, _) = dp::provider(move |version, request| {
if request["method"] == throttled {
return serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 1});
}
answer(version, request)
})
.await;
let provider = Provider::open(endpoint, "t".into(), false, relay)
.await
.unwrap()
.ok()
.unwrap();
let credentials = crate::credentials::Credentials::from_desktop(provider)
.await
.unwrap();
BuzzClient::new(relay.into(), credentials).unwrap()
}
/// A desktop user's 401 and 403 hints name what they can act on in Buzz,
/// not the headless settings.
#[tokio::test]
async fn desktop_users_get_hints_for_the_desktop() {
let app = axum::Router::new().fallback(|req: axum::extract::Request| async move {
let status = if req.uri().path() == "/forbidden" {
403
} else {
401
};
(
axum::http::StatusCode::from_u16(status).unwrap(),
r#"{"error":"no"}"#,
)
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let keys = nostr::Keys::generate();
let owner = nostr::Keys::generate();
let tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &keys.public_key(), "").unwrap();
let answer = dp::desktop_answers(keys.clone(), vec![relay.clone()]);
let (endpoint, _) =
dp::provider(move |version, request| match request["method"].as_str() {
Some("federated_identity") => serde_json::json!({"result": ""}),
Some("auth_tag") => serde_json::json!({"result": tag}),
Some("describe") => {
let mut described = answer(version, request);
let mut body: serde_json::Value =
serde_json::from_str(described["result"].as_str().unwrap()).unwrap();
body["methods"]
.as_array_mut()
.unwrap()
.push("auth_tag".into());
described["result"] = body.to_string().into();
described
}
_ => answer(version, request),
})
.await;
let provider = Provider::open(endpoint, "t".into(), false, &relay)
.await
.unwrap()
.ok()
.unwrap();
let credentials = crate::credentials::Credentials::from_desktop(provider)
.await
.unwrap();
let client = BuzzClient::new(relay, credentials).unwrap();
let err = client.get_authed("/api").await.unwrap_err();
assert!(
matches!(&err, CliError::Auth(m) if m.contains("in Buzz")),
"{err:?}"
);
let err = client.get_authed("/forbidden").await.unwrap_err();
let text = err.to_string();
assert!(
!text.contains("stale") && !text.contains("BUZZ_AUTH_TAG"),
"{text}"
);
}
/// A desktop holding `keys` whose assertions live `lifetime` s and
/// which answers `sign_event` with `sign` (`None`: sign it), logging the
/// sign-in challenges it signs.
async fn desktop(
relay: &str,
keys: &nostr::Keys,
assertion: impl Fn() -> String + Send + Sync + 'static,
sign: impl Fn(&nostr::UnsignedEvent) -> Option<serde_json::Value> + Send + Sync + 'static,
) -> (BuzzClient, super::ws_deadline_tests::Log) {
let signed: super::ws_deadline_tests::Log = Default::default();
let log = signed.clone();
let answer = dp::desktop_answers(keys.clone(), vec![relay.into()]);
let (endpoint, _) =
dp::provider(move |version, request| match request["method"].as_str() {
Some("federated_identity") => {
serde_json::json!({ "result": assertion() })
}
Some("sign_event") => {
let unsigned = <nostr::UnsignedEvent as nostr::JsonUtil>::from_json(
request["params"][0].as_str().unwrap(),
)
.unwrap();
if let Some(refusal) = sign(&unsigned) {
return refusal;
}
if let Some(c) = unsigned.tags.find(nostr::TagKind::Challenge) {
log.lock().unwrap().push(c.content().unwrap().to_owned());
}
answer(version, request)
}
_ => answer(version, request),
})
.await;
let provider = Provider::open(endpoint, "t".into(), false, relay)
.await
.unwrap()
.ok()
.unwrap();
let credentials = crate::credentials::Credentials::from_desktop(provider)
.await
.unwrap();
(BuzzClient::new(relay.into(), credentials).unwrap(), signed)
}
/// A desktop allowing one sign-in signature per 300 ms against a relay
/// that drops unsigned sockets after 200 ms: the throttle is waited out
/// between connections, so only the live challenge is signed, with a
/// fresh assertion on the reconnect after the old one expired.
#[tokio::test]
async fn a_throttled_sign_in_reconnects_with_fresh_evidence() {
use super::ws_deadline_tests::deadline_relay;
let keys = nostr::Keys::generate();
let (relay, conns, _, bearers) =
deadline_relay(std::time::Duration::from_millis(200)).await;
let last = std::sync::Mutex::new(std::time::Instant::now());
let (c, signed) = desktop(&relay, &keys, || dp::assertion(40), move |_| {
let mut last = last.lock().unwrap();
if last.elapsed() < std::time::Duration::from_millis(300) {
crate::cached_assertion::test_clock::advance(60);
return Some(
serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 30}),
);
}
*last = std::time::Instant::now();
None
})
.await;
let event = EventBuilder::new(Kind::Custom(20001), "")
.sign_with_keys(&keys)
.unwrap();
c.publish_ephemeral_event(event).await.unwrap();
assert_eq!(conns.load(Ordering::SeqCst), 2);
assert_eq!(
*signed.lock().unwrap(),
["c1"],
"the dead challenge is never signed"
);
let bearers = bearers.lock().unwrap();
assert_ne!(
bearers[0], bearers[1],
"the reconnect brings a fresh assertion"
);
}
/// An HTTP relay logging each request's NIP-FI assertion.
async fn logging_relay() -> (String, super::ws_deadline_tests::Log) {
let seen: super::ws_deadline_tests::Log = Default::default();
let log = seen.clone();
let app = axum::Router::new().fallback(move |headers: axum::http::HeaderMap| {
let bearer = headers["nostr-federated-identity"].to_str().unwrap();
log.lock()
.unwrap()
.push(bearer.trim_start_matches("Bearer ").to_owned());
async { axum::Json(serde_json::json!([])) }
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(relay, seen)
}
/// A NIP-98 signature that outlives the assertion is signed again after
/// fresh evidence, and goes out with both current.
#[tokio::test]
async fn a_slow_http_signature_goes_out_with_fresh_evidence() {
let (relay, seen) = logging_relay().await;
let signs = Arc::new(AtomicUsize::new(0));
let n = signs.clone();
let (c, _) = desktop(
&relay,
&nostr::Keys::generate(),
|| dp::assertion(40),
move |_| {
if n.fetch_add(1, Ordering::SeqCst) == 0 {
crate::cached_assertion::test_clock::advance(60);
}
None
},
)
.await;
c.query(&serde_json::json!({})).await.unwrap();
assert_eq!(signs.load(Ordering::SeqCst), 2, "the proof is signed again");
let sent = crate::cached_assertion::jws_exp(&seen.lock().unwrap()[0]).unwrap();
assert!(
sent > crate::cached_assertion::unix_now(),
"the assertion sent is current"
);
}
/// A post-sign refresh that waits and returns the same assertion still
/// gets a proof signed after it.
#[tokio::test]
async fn a_slow_refresh_of_the_same_assertion_re_signs_the_proof() {
let (relay, seen) = logging_relay().await;
let exp = crate::cached_assertion::unix_now() + 600;
let fetches = AtomicUsize::new(0);
let same = move || {
if fetches.fetch_add(1, Ordering::SeqCst) == 1 {
// The desktop waits out throttling before it answers.
crate::cached_assertion::test_clock::advance(45);
}
dp::assertion(exp - crate::cached_assertion::unix_now())
};
let signs = Arc::new(AtomicUsize::new(0));
let n = signs.clone();
let (c, _) = desktop(&relay, &nostr::Keys::generate(), same, move |_| {
if n.fetch_add(1, Ordering::SeqCst) == 0 {
crate::cached_assertion::test_clock::advance(310);
}
None
})
.await;
c.query(&serde_json::json!({})).await.unwrap();
assert_eq!(
signs.load(Ordering::SeqCst),
2,
"the aged proof is signed again"
);
assert_eq!(seen.lock().unwrap().len(), 1);
}
/// A signature slower than a proof's lifetime is signed again even when
/// the evidence did not change.
#[tokio::test]
async fn a_slow_signature_with_unchanged_evidence_is_signed_again() {
let (relay, seen) = logging_relay().await;
let exp = crate::cached_assertion::unix_now() + 3600;
let signs = Arc::new(AtomicUsize::new(0));
let n = signs.clone();
let (c, _) = desktop(
&relay,
&nostr::Keys::generate(),
move || dp::assertion(exp - crate::cached_assertion::unix_now()),
move |_| {
if n.fetch_add(1, Ordering::SeqCst) == 0 {
crate::cached_assertion::test_clock::advance(65);
}
None
},
)
.await;
c.query(&serde_json::json!({})).await.unwrap();
assert_eq!(
signs.load(Ordering::SeqCst),
2,
"the stale proof is signed again"
);
assert_eq!(seen.lock().unwrap().len(), 1);
}
/// Evidence that keeps expiring while the proof is signed ends retryable
/// after three rounds, sending nothing.
#[tokio::test]
async fn evidence_that_keeps_expiring_ends_retryable() {
let (relay, seen) = logging_relay().await;
let signs = Arc::new(AtomicUsize::new(0));
let n = signs.clone();
let (c, _) = desktop(
&relay,
&nostr::Keys::generate(),
|| dp::assertion(40),
move |_| {
n.fetch_add(1, Ordering::SeqCst);
crate::cached_assertion::test_clock::advance(60);
None
},
)
.await;
let err = c.query(&serde_json::json!({})).await.unwrap_err();
assert!(matches!(err, CliError::Unavailable(_)), "{err:?}");
assert_eq!(signs.load(Ordering::SeqCst), 3);
assert!(seen.lock().unwrap().is_empty());
}
/// An HTTP relay answering every query with no events.
async fn http_relay() -> String {
let app = axum::Router::new().fallback(|| async { axum::Json(serde_json::json!([])) });
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
relay
}
fn assert_throttled(err: CliError) {
assert!(matches!(err, CliError::Throttled(_)), "{err:?}");
assert!(crate::error::is_retryable_error(&err));
assert_eq!(crate::error::exit_code(&err), 2);
}
#[tokio::test]
async fn persistent_throttling_is_retryable_on_every_signing_path() {
let keys = nostr::Keys::generate();
let relay = http_relay().await;
let c = client(&relay, &keys, "sign_event").await;
// NIP-98 on an ordinary request.
assert_throttled(c.query(&serde_json::json!({})).await.unwrap_err());
// Event signing.
assert_throttled(
c.sign_event(EventBuilder::new(Kind::TextNote, "x"))
.await
.unwrap_err(),
);
// Memory encryption.
let c = client(&relay, &keys, "nip44_encrypt").await;
let owner = nostr::Keys::generate().public_key().to_hex();
assert_throttled(
crate::commands::mem::cmd_set(&c, "mem/a", "v", Some(&owner), false)
.await
.unwrap_err(),
);
}
#[tokio::test]
async fn persistent_throttling_during_nip42_is_retryable() {
let keys = nostr::Keys::generate();
let (relay, auths, events) = ws_relay().await;
let c = client(&relay, &keys, "sign_event").await;
let event = EventBuilder::new(Kind::Custom(20001), "")
.sign_with_keys(&keys)
.unwrap();
assert_throttled(c.publish_ephemeral_event(event).await.unwrap_err());
assert_eq!(auths.load(Ordering::SeqCst), 0);
assert_eq!(events.load(Ordering::SeqCst), 0);
}
/// NIP-42 signing that is throttled, then held for a decision, completes
/// the publish: provider time is outside the relay's deadlines, and the
/// relay sees one AUTH and one EVENT.
#[tokio::test(flavor = "multi_thread")]
async fn a_throttled_then_held_nip42_signature_completes_without_replay() {
let keys = nostr::Keys::generate();
let (relay, auths, events) = ws_relay().await;
let signs = Arc::new(AtomicUsize::new(0));
let counted = signs.clone();
let answer = dp::desktop_answers(keys.clone(), vec![relay.clone()]);
let (endpoint, _) = dp::provider(move |version, request| {
if request["method"] == "sign_event" {
if counted.fetch_add(1, Ordering::SeqCst) == 0 {
return serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 30});
}
// Held for a person's decision.
std::thread::sleep(std::time::Duration::from_secs(2));
}
answer(version, request)
})
.await;
let provider = Provider::open(endpoint, "t".into(), false, &relay)
.await
.unwrap()
.ok()
.unwrap();
let credentials = crate::credentials::Credentials::from_desktop(provider)
.await
.unwrap();
let c = BuzzClient::new(relay, credentials).unwrap();
let event = EventBuilder::new(Kind::Custom(20001), "")
.sign_with_keys(&keys)
.unwrap();
c.publish_ephemeral_event(event).await.unwrap();
assert_eq!(signs.load(Ordering::SeqCst), 2);
assert_eq!(auths.load(Ordering::SeqCst), 1);
assert_eq!(events.load(Ordering::SeqCst), 1);
}
}
/// WebSocket publish deadlines, on a paused clock: a signer may take as long
/// as it needs, while each network phase, writes included, is capped.
#[cfg(test)]
mod ws_deadline_tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use nostr::{Event, EventBuilder, Keys, Kind, NostrSigner, PublicKey, UnsignedEvent};
use tokio_tungstenite::tungstenite::Message;
use super::BuzzClient;
use crate::credentials::Credentials;
use crate::error::CliError;
/// A WebSocket relay that sends a challenge, then OKs AUTH and one event.
/// Returns its URL and the count of AUTH and EVENT frames it received.
pub(super) async fn ws_relay() -> (String, Arc<AtomicUsize>, Arc<AtomicUsize>) {
let (auths, events) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
let (a, e) = (auths.clone(), events.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let (a, e) = (a.clone(), e.clone());
tokio::spawn(async move {
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
let _ = ws
.send(Message::Text(r#"["AUTH","challenge"]"#.into()))
.await;
while let Some(Ok(Message::Text(text))) = ws.next().await {
let frame: serde_json::Value = serde_json::from_str(&text).unwrap();
let counter = if frame[0] == "AUTH" { &a } else { &e };
counter.fetch_add(1, Ordering::SeqCst);
let ok = serde_json::json!(["OK", frame[1]["id"], true, ""]);
let _ = ws.send(Message::Text(ok.to_string().into())).await;
}
});
}
});
(relay, auths, events)
}
/// Keys that take `delay` to sign, like a signer held for a decision.
/// The clock is paused only while signing (after the real connect), so
/// the delay passes at once unless an earlier timer fires first.
#[derive(Debug)]
struct Slow(Keys, Duration);
/// Keys whose first signature takes `delay`, then sign at once.
#[derive(Debug)]
struct SlowFirst(Slow, std::sync::atomic::AtomicBool);
impl NostrSigner for Slow {
fn backend(&self) -> nostr::signer::SignerBackend<'_> {
nostr::signer::SignerBackend::Custom("slow".into())
}
fn get_public_key(
&self,
) -> nostr::util::BoxedFuture<'_, Result<PublicKey, nostr::signer::SignerError>> {
self.0.get_public_key()
}
fn sign_event(
&self,
unsigned: UnsignedEvent,
) -> nostr::util::BoxedFuture<'_, Result<Event, nostr::signer::SignerError>> {
Box::pin(async move {
tokio::time::pause();
tokio::time::sleep(self.1).await;
tokio::time::resume();
self.0.sign_event(unsigned).await
})
}
fn nip04_encrypt<'a>(
&'a self,
p: &'a PublicKey,
c: &'a str,
) -> nostr::util::BoxedFuture<'a, Result<String, nostr::signer::SignerError>> {
self.0.nip04_encrypt(p, c)
}
fn nip04_decrypt<'a>(
&'a self,
p: &'a PublicKey,
c: &'a str,
) -> nostr::util::BoxedFuture<'a, Result<String, nostr::signer::SignerError>> {
self.0.nip04_decrypt(p, c)
}
fn nip44_encrypt<'a>(
&'a self,
p: &'a PublicKey,
c: &'a str,
) -> nostr::util::BoxedFuture<'a, Result<String, nostr::signer::SignerError>> {
self.0.nip44_encrypt(p, c)
}
fn nip44_decrypt<'a>(
&'a self,
p: &'a PublicKey,
c: &'a str,
) -> nostr::util::BoxedFuture<'a, Result<String, nostr::signer::SignerError>> {
self.0.nip44_decrypt(p, c)
}
}
impl NostrSigner for SlowFirst {
fn backend(&self) -> nostr::signer::SignerBackend<'_> {
self.0.backend()
}
fn get_public_key(
&self,
) -> nostr::util::BoxedFuture<'_, Result<PublicKey, nostr::signer::SignerError>> {
self.0.get_public_key()
}
fn sign_event(
&self,
unsigned: UnsignedEvent,
) -> nostr::util::BoxedFuture<'_, Result<Event, nostr::signer::SignerError>> {
if self.1.swap(false, Ordering::SeqCst) {
self.0.sign_event(unsigned)
} else {
self.0 .0.sign_event(unsigned)
}
}
fn nip04_encrypt<'a>(
&'a self,
p: &'a PublicKey,
c: &'a str,
) -> nostr::util::BoxedFuture<'a, Result<String, nostr::signer::SignerError>> {
self.0.nip04_encrypt(p, c)
}
fn nip04_decrypt<'a>(
&'a self,
p: &'a PublicKey,
c: &'a str,
) -> nostr::util::BoxedFuture<'a, Result<String, nostr::signer::SignerError>> {
self.0.nip04_decrypt(p, c)
}
fn nip44_encrypt<'a>(
&'a self,
p: &'a PublicKey,
c: &'a str,
) -> nostr::util::BoxedFuture<'a, Result<String, nostr::signer::SignerError>> {
self.0.nip44_encrypt(p, c)
}
fn nip44_decrypt<'a>(
&'a self,
p: &'a PublicKey,
c: &'a str,
) -> nostr::util::BoxedFuture<'a, Result<String, nostr::signer::SignerError>> {
self.0.nip44_decrypt(p, c)
}
}
/// A relay that, like buzz-relay, drops a socket not signed in within
/// 5 s; otherwise as [`ws_relay`]. Returns its URL, its connection
/// count, and the challenges each AUTH answered.
/// A relay closing sockets not signed in within `deadline`: the
/// connection count, the challenges answered and each upgrade's NIP-FI
/// header.
#[allow(clippy::result_large_err)] // tungstenite's handshake callback type
pub(super) async fn deadline_relay(deadline: Duration) -> (String, Arc<AtomicUsize>, Log, Log) {
let conns = Arc::new(AtomicUsize::new(0));
let (answered, bearers): (Log, Log) = Default::default();
let (n, log, seen) = (conns.clone(), answered.clone(), bearers.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let challenge = format!("c{}", n.fetch_add(1, Ordering::SeqCst));
let (log, seen) = (log.clone(), seen.clone());
tokio::spawn(async move {
let record =
|req: &tokio_tungstenite::tungstenite::handshake::server::Request, res| {
let bearer = req.headers().get("nostr-federated-identity");
let bearer = bearer.map(|v| v.to_str().unwrap().to_owned());
seen.lock().unwrap().push(bearer.unwrap_or_default());
Ok(res)
};
let mut ws = tokio_tungstenite::accept_hdr_async(stream, record)
.await
.unwrap();
let hello = serde_json::json!(["AUTH", challenge]).to_string();
let _ = ws.send(Message::Text(hello.into())).await;
let deadline = tokio::time::sleep(deadline);
tokio::pin!(deadline);
let mut authed = false;
loop {
let next = tokio::select! {
_ = &mut deadline, if !authed => return,
next = ws.next() => next,
};
let Some(Ok(Message::Text(text))) = next else {
return;
};
let frame: serde_json::Value = serde_json::from_str(&text).unwrap();
if frame[0] == "AUTH" {
let tags = frame[1]["tags"].as_array().unwrap();
let c = tags.iter().find(|t| t[0] == "challenge").unwrap();
log.lock().unwrap().push(c[1].as_str().unwrap().to_owned());
authed = true;
}
let ok = serde_json::json!(["OK", frame[1]["id"], true, ""]);
let _ = ws.send(Message::Text(ok.to_string().into())).await;
}
});
}
});
(relay, conns, answered, bearers)
}
pub(super) type Log = Arc<std::sync::Mutex<Vec<String>>>;
/// Every upgrade names the CLI and its version.
#[tokio::test]
async fn upgrade_headers_carry_the_cli_user_agent() {
let keys = Keys::generate();
let c = BuzzClient::new(
"http://127.0.0.1:1".into(),
Credentials::from_signer(Arc::new(keys.clone()), keys.public_key()),
)
.unwrap();
let headers = c.upgrade_headers().await.unwrap();
assert_eq!(headers.get("user-agent").unwrap(), super::USER_AGENT);
assert!(super::USER_AGENT.starts_with("buzz-cli/"));
}
/// A sign-in signature that comes back after the relay's 5 s deadline
/// reconnects and signs the new challenge, never the old one.
#[tokio::test]
async fn a_late_sign_in_reconnects_and_signs_the_new_challenge() {
let keys = Keys::generate();
let (relay, conns, answered, _) = deadline_relay(Duration::from_secs(5)).await;
let signer = Arc::new(SlowFirst(
Slow(keys.clone(), Duration::from_secs(8)),
std::sync::atomic::AtomicBool::new(true),
));
let c =
BuzzClient::new(relay, Credentials::from_signer(signer, keys.public_key())).unwrap();
let event = EventBuilder::new(Kind::Custom(20001), "")
.sign_with_keys(&keys)
.unwrap();
c.publish_ephemeral_event(event).await.unwrap();
assert_eq!(conns.load(Ordering::SeqCst), 2);
assert_eq!(
*answered.lock().unwrap(),
["c1"],
"only the new challenge is signed"
);
}
/// A signer that is always too slow ends retryable after the bound.
#[tokio::test]
async fn a_sign_in_always_too_late_ends_retryable() {
let keys = Keys::generate();
let (relay, conns, ..) = deadline_relay(Duration::from_secs(5)).await;
let c = client(relay, &keys, Duration::from_secs(8));
let event = EventBuilder::new(Kind::Custom(20001), "")
.sign_with_keys(&keys)
.unwrap();
let err = c.publish_ephemeral_event(event).await.unwrap_err();
assert!(crate::error::is_retryable_error(&err), "{err:?}");
assert_eq!(
conns.load(Ordering::SeqCst),
super::WS_SIGN_IN_ATTEMPTS as usize
);
}
fn client(relay: String, keys: &Keys, delay: Duration) -> BuzzClient {
let signer = Arc::new(Slow(keys.clone(), delay));
BuzzClient::new(relay, Credentials::from_signer(signer, keys.public_key())).unwrap()
}
/// Signing held for 300 s, past the old 225 s single cap, still publishes,
/// once.
#[tokio::test]
async fn a_signature_held_past_every_relay_cap_still_publishes_once() {
let keys = Keys::generate();
let (relay, auths, events) = ws_relay().await;
let c = client(relay, &keys, Duration::from_secs(300));
let event = EventBuilder::new(Kind::Custom(20001), "")
.sign_with_keys(&keys)
.unwrap();
let started = tokio::time::Instant::now();
c.publish_ephemeral_event(event).await.unwrap();
assert!(started.elapsed() >= Duration::from_secs(300));
assert!(
started.elapsed() < Duration::from_secs(330),
"real time stays short"
);
assert_eq!(auths.load(Ordering::SeqCst), 1);
assert_eq!(events.load(Ordering::SeqCst), 1);
}
/// A relay that OKs AUTH and then stops reading stalls the EVENT write;
/// the publish fails within its phase cap and is not sent again.
#[tokio::test]
async fn a_stalled_event_write_fails_within_its_phase_cap() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay = format!("http://{}", listener.local_addr().unwrap());
let accepted = Arc::new(AtomicUsize::new(0));
let count = accepted.clone();
// The relay pauses the clock once it has OKed AUTH, so the EVENT
// phase runs on virtual time from that mark.
let authed = Arc::new(std::sync::Mutex::new(None));
let mark = authed.clone();
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
count.fetch_add(1, Ordering::SeqCst);
let mark = mark.clone();
tokio::spawn(async move {
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
let _ = ws
.send(Message::Text(r#"["AUTH","challenge"]"#.into()))
.await;
if let Some(Ok(Message::Text(text))) = ws.next().await {
let frame: serde_json::Value = serde_json::from_str(&text).unwrap();
let ok = serde_json::json!(["OK", frame[1]["id"], true, ""]);
let _ = ws.send(Message::Text(ok.to_string().into())).await;
tokio::time::pause();
*mark.lock().unwrap() = Some(tokio::time::Instant::now());
}
// Hold the socket open without reading.
std::future::pending::<()>().await;
drop(ws);
});
}
});
let keys = Keys::generate();
let c = BuzzClient::new(relay, Credentials::from_keys(keys.clone())).unwrap();
let event = EventBuilder::new(Kind::Custom(20001), "x".repeat(32 * 1024 * 1024))
.sign_with_keys(&keys)
.unwrap();
// An unbounded write would wait here for the hour, not forever.
let err = tokio::time::timeout(Duration::from_secs(3600), c.publish_ephemeral_event(event))
.await
.expect("the stalled write is bounded")
.unwrap_err();
let stalled = authed.lock().unwrap().expect("AUTH was OKed").elapsed();
// From the OK the client has at most the rest of the AUTH phase (the
// paused clock jumps to its read deadline before the OK is read) and
// the whole EVENT phase.
use buzz_ws_client::connection::{AUTH_OK_TIMEOUT_SECS, PUBLISH_OK_TIMEOUT_SECS};
let cap = Duration::from_secs(AUTH_OK_TIMEOUT_SECS + 1 + PUBLISH_OK_TIMEOUT_SECS + 1);
assert!(stalled <= cap, "{stalled:?} {err:?}");
assert!(
matches!(err, CliError::Other(ref m) if m.contains("Timeout")),
"{err:?}"
);
assert_eq!(accepted.load(Ordering::SeqCst), 1);
}
/// The NIP-42 AUTH event names the configured community URL, path and
/// trailing slash kept, as the relay checks it.
#[tokio::test]
async fn the_auth_event_names_the_relay_with_its_trailing_slash() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let named = Arc::new(std::sync::Mutex::new(None::<String>));
let log = named.clone();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
let _ = ws
.send(Message::Text(r#"["AUTH","challenge"]"#.into()))
.await;
while let Some(Ok(Message::Text(text))) = ws.next().await {
let frame: serde_json::Value = serde_json::from_str(&text).unwrap();
if frame[0] == "AUTH" {
let relay = frame[1]["tags"]
.as_array()
.unwrap()
.iter()
.find(|t| t[0] == "relay")
.map(|t| t[1].as_str().unwrap().to_owned());
*log.lock().unwrap() = relay;
}
let ok = serde_json::json!(["OK", frame[1]["id"], true, ""]);
let _ = ws.send(Message::Text(ok.to_string().into())).await;
}
});
let keys = Keys::generate();
let c = BuzzClient::new(
format!("http://{addr}/team/"),
Credentials::from_keys(keys.clone()),
)
.unwrap();
let event = EventBuilder::new(Kind::Custom(20001), "")
.sign_with_keys(&keys)
.unwrap();
c.publish_ephemeral_event(event).await.unwrap();
assert_eq!(
named.lock().unwrap().as_deref(),
Some(format!("ws://{addr}/team/").as_str())
);
}
#[test]
fn auth_keeps_the_configured_relay_path() {
let client = BuzzClient::new(
"wss://host.example/team/".into(),
Credentials::from_keys(Keys::generate()),
)
.unwrap();
assert_eq!(client.ws_url, "wss://host.example/team/");
assert_eq!(client.relay_url, "https://host.example/team");
}
}