mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Summary
Gives Buzz-hosted git entities the same "GitHub-style" chat experience
GitHub links already get: rich preview cards, real titles, and
click-through — except clicks navigate **in-app** to the Projects view
instead of a browser.
- **Spec**: `docs/buzz-entity-links.md` — link scheme, slices, and
deferred work (`buzz://project`, OS deep links, web routes).
- **Canonical `buzz://` deep links**: new
`desktop/src/shared/lib/entityLink.ts` with builders + strict parser for
`buzz://pr?id=…&owner=…&d=…`, `buzz://issue?…`, and
`buzz://repo?owner=…&d=…`, mirrored by a Rust module
(`crates/buzz-cli/src/links.rs`) with a shared golden-format test so the
two implementations can't drift.
- **Preview cards**: `linkPreview.ts` recognizes `buzz://` entity links
*and* HTTPS relay clone URLs (`{origin}/git/<pubkey>/<repo>`, the shape
agents paste today). Both normalize onto the canonical `buzz://` href,
so the two spellings of a repo dedupe to one `Buzz`-provider card
(`BuzzMark` logo) rendered by `link-preview-attachment.tsx`.
- **Title enrichment**: PR/issue cards fetch the real subject from the
relay event (`subject` tag or first content line) via
`useResolvedLinkPreviews.ts`; the cache is community-scoped and reset in
`resetCommunityState()`.
- **In-app navigation**: clicking a card or inline anchor (including
HTTPS relay clone URLs whose origin matches the active relay) routes to
the canonical `30617:<owner>:<d>` coordinate via `goProject()`
(`markdown/entityLinks.tsx`). **Merge dependency: #4671 must merge
first** — route resolution for `30617:` coordinates is implemented on
that branch (`feat/multi-repository-projects`). Entity-link and
external-anchor logic were extracted out of `markdown.tsx` to stay under
the file-size ratchet.
- **Agent side**: `buzz pr open`, `buzz issues create`, and `buzz repos
create` now return a ready-made `link` field (omitted when the relay
returns `accepted: false`), and `base_prompt.md` instructs agents to
paste it verbatim when announcing work.
## Test plan
- [x] Desktop unit tests: pass, including new `entityLink.test.mjs` and
`linkPreview.test.mjs` coverage (golden formats, malformed-link
rejection, clone-URL/`buzz://` dedupe, origin-gated anchor behavior,
label-must-win invariant, cache epoch)
- [x] Rust: `cargo test -p buzz-cli` golden-format test +
accepted/rejected link guard assertions, clippy + fmt clean
- [x] Biome + `tsc --noEmit` clean; pre-push hooks
(desktop-tauri-checks, rust-tests, desktop-test) pass
- [ ] Manual: paste a relay clone URL and a `buzz://pr` link in a
channel — verify one card each, real PR title, and in-app navigation to
the Projects view
Related: [#4671](https://github.com/block/buzz/pull/4671)
---------
Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
2500 lines
97 KiB
Rust
2500 lines
97 KiB
Rust
use std::future::Future;
|
||
use std::time::Duration;
|
||
|
||
use base64::engine::general_purpose::STANDARD as B64;
|
||
use base64::Engine;
|
||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
|
||
use sha2::{Digest, Sha256};
|
||
|
||
use crate::error::CliError;
|
||
|
||
/// 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 accepted for upload.
|
||
const ALLOWED_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;
|
||
|
||
/// Sign a NIP-98 HTTP auth event (kind:27235) and return the Authorization header value.
|
||
///
|
||
/// The event includes:
|
||
/// - `u` tag: the full request URL
|
||
/// - `method` tag: HTTP method (GET, POST, PUT, DELETE)
|
||
/// - `payload` tag: SHA-256 hex of the request body (if present)
|
||
fn sign_nip98(
|
||
keys: &Keys,
|
||
method: &str,
|
||
url: &str,
|
||
body: Option<&[u8]>,
|
||
) -> Result<String, CliError> {
|
||
let mut tags = vec![
|
||
Tag::parse(["u", url]).map_err(|e| CliError::Other(format!("tag error: {e}")))?,
|
||
Tag::parse(["method", method]).map_err(|e| CliError::Other(format!("tag error: {e}")))?,
|
||
// Nonce prevents replay rejection for rapid-fire requests with identical bodies.
|
||
Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()])
|
||
.map_err(|e| CliError::Other(format!("tag error: {e}")))?,
|
||
];
|
||
if let Some(b) = body {
|
||
let hash = hex::encode(Sha256::digest(b));
|
||
tags.push(
|
||
Tag::parse(["payload", &hash])
|
||
.map_err(|e| CliError::Other(format!("tag error: {e}")))?,
|
||
);
|
||
}
|
||
let event = EventBuilder::new(Kind::Custom(27235), "")
|
||
.tags(tags)
|
||
.sign_with_keys(keys)
|
||
.map_err(|e| CliError::Other(format!("NIP-98 signing failed: {e}")))?;
|
||
let json = event.as_json();
|
||
Ok(format!("Nostr {}", B64.encode(json.as_bytes())))
|
||
}
|
||
|
||
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).
|
||
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", "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.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.jpg".to_string(),
|
||
));
|
||
}
|
||
Ok(format!(
|
||
"{}/media/{sha256_ext}",
|
||
relay_url.trim_end_matches('/')
|
||
))
|
||
}
|
||
|
||
fn sign_blossom_get(keys: &Keys, media_url: &str) -> Result<String, CliError> {
|
||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||
use nostr::Timestamp;
|
||
|
||
let now = Timestamp::now().as_secs();
|
||
let exp_str = (now + 600).to_string();
|
||
let domain = relay_server_tag(media_url)
|
||
.ok_or_else(|| CliError::Usage(format!("invalid media URL: {media_url}")))?;
|
||
let tags = vec![
|
||
Tag::parse(["t", "get"]).map_err(|e| CliError::Other(e.to_string()))?,
|
||
Tag::parse(["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?,
|
||
Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?,
|
||
];
|
||
|
||
let auth_event = EventBuilder::new(Kind::from(24242), "Get media")
|
||
.tags(tags)
|
||
.sign_with_keys(keys)
|
||
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
|
||
|
||
Ok(format!(
|
||
"Nostr {}",
|
||
URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes())
|
||
))
|
||
}
|
||
|
||
fn sign_blossom_upload(
|
||
keys: &Keys,
|
||
sha256: &str,
|
||
mime: &str,
|
||
relay_url: &str,
|
||
) -> Result<String, CliError> {
|
||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||
use nostr::Timestamp;
|
||
|
||
let now = Timestamp::now().as_secs();
|
||
let expiry: u64 = if mime.starts_with("video/") {
|
||
3600
|
||
} else {
|
||
600
|
||
};
|
||
let exp_str = (now + expiry).to_string();
|
||
|
||
let mut tags = vec![
|
||
Tag::parse(["t", "upload"]).map_err(|e| CliError::Other(e.to_string()))?,
|
||
Tag::parse(["x", sha256]).map_err(|e| CliError::Other(e.to_string()))?,
|
||
Tag::parse(["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?,
|
||
];
|
||
if let Some(domain) = relay_server_tag(relay_url) {
|
||
tags.push(Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?);
|
||
}
|
||
|
||
let auth_event = EventBuilder::new(Kind::from(24242), "Upload file")
|
||
.tags(tags)
|
||
.sign_with_keys(keys)
|
||
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
|
||
|
||
Ok(format!(
|
||
"Nostr {}",
|
||
URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes())
|
||
))
|
||
}
|
||
|
||
#[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 = Keys::generate();
|
||
let hash = "a".repeat(64);
|
||
let header = sign_blossom_get(
|
||
&keys,
|
||
&format!("https://relay.example:443/media/{hash}.jpg"),
|
||
)
|
||
.unwrap();
|
||
let encoded = header.strip_prefix("Nostr ").unwrap();
|
||
let json = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||
.decode(encoded)
|
||
.unwrap();
|
||
let event = nostr::Event::from_json(std::str::from_utf8(&json).unwrap()).unwrap();
|
||
event.verify().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
|
||
));
|
||
}
|
||
}
|
||
|
||
const QUERY_PAGE_SIZE: u32 = 500;
|
||
|
||
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()))?;
|
||
filter["until"] = serde_json::json!(created_at);
|
||
filter["before_id"] = serde_json::json!(id);
|
||
Ok(())
|
||
}
|
||
|
||
pub struct BuzzClient {
|
||
http: reqwest::Client,
|
||
relay_url: String, // base URL, no trailing slash, e.g. "https://relay.buzz.place"
|
||
keys: Keys,
|
||
/// Optional NIP-OA auth tag injected into every signed event.
|
||
auth_tag: Option<Tag>,
|
||
/// Raw JSON of the auth tag for the `x-auth-tag` HTTP header.
|
||
auth_tag_json: Option<String>,
|
||
}
|
||
|
||
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.
|
||
pub fn new(
|
||
relay_url: String,
|
||
keys: Keys,
|
||
auth_tag: Option<Tag>,
|
||
auth_tag_json: Option<String>,
|
||
) -> Result<Self, CliError> {
|
||
let http = reqwest::Client::builder()
|
||
.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,
|
||
relay_url,
|
||
keys,
|
||
auth_tag,
|
||
auth_tag_json,
|
||
})
|
||
}
|
||
|
||
/// Get the keypair.
|
||
pub fn keys(&self) -> &Keys {
|
||
&self.keys
|
||
}
|
||
|
||
/// 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.auth_tag
|
||
.as_ref()
|
||
.map(|t| t.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 fn sign_event(&self, builder: EventBuilder) -> Result<nostr::Event, CliError> {
|
||
let builder = if let Some(ref tag) = self.auth_tag {
|
||
builder.tags([tag.clone()])
|
||
} else {
|
||
builder
|
||
};
|
||
let event = builder
|
||
.sign_with_keys(&self.keys)
|
||
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
|
||
|
||
// Enforce: auth tags may only come from self.auth_tag injection.
|
||
let auth_count = event
|
||
.tags
|
||
.iter()
|
||
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
|
||
.count();
|
||
let expected = if self.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)
|
||
}
|
||
|
||
/// Attach the `x-auth-tag` header if configured (NIP-OA relay membership delegation).
|
||
fn with_auth_tag(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||
match self.auth_tag_json {
|
||
Some(ref json) => req.header("x-auth-tag", json),
|
||
None => req,
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
mut filter: serde_json::Value,
|
||
limit: Option<u32>,
|
||
) -> Result<Vec<serde_json::Value>, CliError> {
|
||
let mut events = Vec::new();
|
||
|
||
while limit.is_none_or(|limit| events.len() < limit as usize) {
|
||
let page_limit = limit
|
||
.map(|limit| (limit as usize - events.len()).min(QUERY_PAGE_SIZE as usize))
|
||
.unwrap_or(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);
|
||
if done {
|
||
break;
|
||
}
|
||
}
|
||
|
||
Ok(events)
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// 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 (`self.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 fn sign_event_unchecked(&self, builder: EventBuilder) -> Result<nostr::Event, CliError> {
|
||
builder
|
||
.sign_with_keys(&self.keys)
|
||
.map_err(|e| CliError::Other(format!("signing failed: {e}")))
|
||
}
|
||
|
||
/// 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 auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?;
|
||
let resp = self
|
||
.with_auth_tag(
|
||
self.http
|
||
.post(&url)
|
||
.header("Authorization", auth)
|
||
.header("Content-Type", "application/json")
|
||
.body(body),
|
||
)
|
||
.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 auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?;
|
||
let resp = self
|
||
.with_auth_tag(
|
||
self.http
|
||
.post(&url)
|
||
.header("Authorization", auth)
|
||
.header("Content-Type", "application/json")
|
||
.body(body),
|
||
)
|
||
.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 auth = sign_nip98(&self.keys, "GET", &url, None)?;
|
||
let resp = self
|
||
.with_auth_tag(self.http.get(&url).header("Authorization", auth))
|
||
.send()
|
||
.await?;
|
||
self.handle_response(resp).await
|
||
}
|
||
})
|
||
.await
|
||
}
|
||
|
||
/// 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 auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?;
|
||
let send_result: Result<reqwest::Response, CliError> = self
|
||
.with_auth_tag(
|
||
self.http
|
||
.post(&url)
|
||
.header("Authorization", auth)
|
||
.header("Content-Type", "application/json")
|
||
.body(body.clone()),
|
||
)
|
||
.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) {
|
||
let message = serde_json::from_str::<serde_json::Value>(&body_text)
|
||
.ok()
|
||
.and_then(|v| {
|
||
v.get("error")
|
||
.or_else(|| v.get("message"))
|
||
.and_then(|m| m.as_str())
|
||
.map(str::to_string)
|
||
})
|
||
.unwrap_or(body_text);
|
||
let message = if status == 403 && std::env::var("BUZZ_AUTH_TAG").is_ok() {
|
||
format!(
|
||
"{message} (BUZZ_AUTH_TAG is set — it may be stale or revoked; try unsetting it)"
|
||
)
|
||
} else {
|
||
message
|
||
};
|
||
return Err(CliError::Relay {
|
||
status,
|
||
body: message,
|
||
});
|
||
}
|
||
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}")))?,
|
||
);
|
||
let result = self
|
||
.with_retry_body(|| {
|
||
let body = body.clone();
|
||
let url = url.clone();
|
||
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 auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?;
|
||
let resp = self
|
||
.with_auth_tag(
|
||
self.http
|
||
.post(&url)
|
||
.header("Authorization", auth)
|
||
.header("Content-Type", "application/json")
|
||
.body(body),
|
||
)
|
||
.send()
|
||
.await?;
|
||
self.handle_response(resp).await
|
||
}
|
||
})
|
||
.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 {
|
||
if is_stored_event_exhaustion_ambiguous(e) {
|
||
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 rejects 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 = to_ws_url(&self.relay_url);
|
||
// Hard cap — inner wait ceilings sum to 70 s; connect time and network RTT are
|
||
// additional overhead absorbed by this budget.
|
||
// See buzz_ws_client::{AUTH_CHALLENGE_TIMEOUT_SECS, AUTH_OK_TIMEOUT_SECS,
|
||
// PUBLISH_OK_TIMEOUT_SECS} for the inner ceilings.
|
||
let ok =
|
||
buzz_ws_client::publish_event(&ws_url, event, &self.keys, self.auth_tag.as_ref(), 75)
|
||
.await
|
||
.map_err(|e| CliError::Other(e.to_string()))?;
|
||
|
||
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::Other(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::Other(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());
|
||
|
||
if !ALLOWED_MIMES.contains(&mime.as_str()) {
|
||
return Err(CliError::Usage(format!("unsupported file type: {mime}")));
|
||
}
|
||
|
||
// 3. Size check
|
||
let max = if mime.starts_with("video/") {
|
||
MAX_VIDEO_BYTES
|
||
} else {
|
||
MAX_IMAGE_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 mime.starts_with("video/") {
|
||
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 auth_header =
|
||
sign_blossom_upload(&self.keys, &sha256, &mime, &self.relay_url)?;
|
||
let resp = self
|
||
.with_auth_tag(
|
||
self.http
|
||
.put(&url)
|
||
.timeout(upload_timeout)
|
||
.header("Authorization", auth_header)
|
||
.header("Content-Type", &mime)
|
||
.header("X-SHA-256", &sha256)
|
||
.body(upload_body),
|
||
)
|
||
.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(CliError::Relay { status: 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 auth_header = sign_blossom_upload(&self.keys, &sha256, &mime, &self.relay_url)?;
|
||
let resp = self
|
||
.with_auth_tag(
|
||
self.http
|
||
.put(&legacy_url)
|
||
.timeout(upload_timeout)
|
||
.header("Authorization", auth_header)
|
||
.header("Content-Type", &mime)
|
||
.header("X-SHA-256", &sha256)
|
||
.body(upload_body),
|
||
)
|
||
.send()
|
||
.await?;
|
||
if !resp.status().is_success() {
|
||
let status = resp.status().as_u16();
|
||
let body = resp.text().await.unwrap_or_default();
|
||
return Err(CliError::Relay { 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 auth_header = sign_blossom_get(&self.keys, &url)?;
|
||
let resp = self
|
||
.with_auth_tag(client.get(&url).header("Authorization", auth_header))
|
||
.send()
|
||
.await?;
|
||
if !resp.status().is_success() {
|
||
let status = resp.status().as_u16();
|
||
let body = resp.text().await.unwrap_or_default();
|
||
return Err(CliError::Relay { 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();
|
||
let message = 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(|s| s.to_string())
|
||
})
|
||
.unwrap_or(body);
|
||
if status == 403 && std::env::var("BUZZ_AUTH_TAG").is_ok() {
|
||
let message = format!(
|
||
"{message} (BUZZ_AUTH_TAG is set — it may be stale or revoked; try unsetting it)"
|
||
);
|
||
return Err(CliError::Relay {
|
||
status,
|
||
body: message,
|
||
});
|
||
}
|
||
return Err(CliError::Relay {
|
||
status,
|
||
body: message,
|
||
});
|
||
}
|
||
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 consistent shape.
|
||
/// Each event becomes: {id, pubkey, kind, content, created_at, tags}
|
||
pub fn normalize_events(events: &[serde_json::Value]) -> String {
|
||
let normalized: Vec<serde_json::Value> = events
|
||
.iter()
|
||
.map(|e| {
|
||
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!([])),
|
||
})
|
||
})
|
||
.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 {
|
||
let keys = Keys::generate();
|
||
BuzzClient::new(base_url.to_string(), keys, None, None).unwrap()
|
||
}
|
||
|
||
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(), 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(), 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(), 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(), 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(), 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());
|
||
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());
|
||
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());
|
||
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());
|
||
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"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{
|
||
advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field,
|
||
BuzzClient,
|
||
};
|
||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||
|
||
#[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)
|
||
}
|
||
|
||
#[test]
|
||
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(),
|
||
keys,
|
||
Some(auth_tag),
|
||
Some(auth_json),
|
||
)
|
||
.unwrap();
|
||
|
||
let builder =
|
||
EventBuilder::new(Kind::Custom(9035), "archive").tags([Tag::parse(["-"]).unwrap()]);
|
||
let event = client.sign_event_unchecked(builder).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:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
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(),
|
||
keys,
|
||
Some(auth_tag),
|
||
Some(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).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));
|
||
}
|
||
|
||
#[test]
|
||
fn with_auth_tag_sets_header_when_configured() {
|
||
let keys = Keys::generate();
|
||
let (auth_tag, auth_json) = make_auth_tag();
|
||
let client = BuzzClient::new(
|
||
"https://test.relay".into(),
|
||
keys,
|
||
Some(auth_tag),
|
||
Some(auth_json.clone()),
|
||
)
|
||
.unwrap();
|
||
|
||
let req = client.http.post("https://test.relay/events");
|
||
let req = client.with_auth_tag(req);
|
||
let built = req.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"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn with_auth_tag_omits_header_when_not_configured() {
|
||
let keys = Keys::generate();
|
||
let client = BuzzClient::new("https://test.relay".into(), keys, None, None).unwrap();
|
||
|
||
let req = client.http.post("https://test.relay/events");
|
||
let req = client.with_auth_tag(req);
|
||
let built = req.build().unwrap();
|
||
assert!(
|
||
built.headers().get("x-auth-tag").is_none(),
|
||
"x-auth-tag header must not be present when no auth tag is configured"
|
||
);
|
||
}
|
||
}
|