From 00f4d91ae10cdcc3f0ee53db847d774a76d3f69b Mon Sep 17 00:00:00 2001 From: Tal Weiss Date: Tue, 4 Aug 2026 15:32:46 +0200 Subject: [PATCH] Extract reusable Buzz client boundary Signed-off-by: Tal Weiss --- Cargo.lock | 22 + Cargo.toml | 2 + Justfile | 7 +- crates/buzz-cli/Cargo.toml | 1 + crates/buzz-cli/src/client.rs | 43 + crates/buzz-cli/src/client_adapter.rs | 65 + crates/buzz-cli/src/commands/channels.rs | 143 +- crates/buzz-cli/src/commands/messages.rs | 128 +- crates/buzz-cli/src/lib.rs | 20 +- crates/buzz-cli/tests/channel_list_compat.rs | 136 + crates/buzz-cli/tests/message_send_compat.rs | 371 +++ crates/buzz-client/Cargo.toml | 28 + crates/buzz-client/src/auth.rs | 46 + crates/buzz-client/src/channels.rs | 175 ++ crates/buzz-client/src/client.rs | 104 + crates/buzz-client/src/config.rs | 134 + crates/buzz-client/src/endpoint.rs | 182 ++ crates/buzz-client/src/error.rs | 97 + crates/buzz-client/src/lib.rs | 79 + crates/buzz-client/src/messages.rs | 518 ++++ crates/buzz-client/src/transport.rs | 319 +++ crates/buzz-client/tests/channel_listing.rs | 328 +++ .../buzz-client/tests/client_construction.rs | 142 + .../buzz-client/tests/community_endpoint.rs | 85 + .../buzz-client/tests/dependency_boundary.rs | 50 + crates/buzz-client/tests/message_delivery.rs | 370 +++ examples/buzz-client-consumer/Cargo.lock | 2352 +++++++++++++++++ examples/buzz-client-consumer/Cargo.toml | 18 + examples/buzz-client-consumer/README.md | 13 + examples/buzz-client-consumer/src/main.rs | 49 + examples/buzz-client-consumer/tests/smoke.rs | 94 + 31 files changed, 6110 insertions(+), 11 deletions(-) create mode 100644 crates/buzz-cli/src/client_adapter.rs create mode 100644 crates/buzz-cli/tests/channel_list_compat.rs create mode 100644 crates/buzz-cli/tests/message_send_compat.rs create mode 100644 crates/buzz-client/Cargo.toml create mode 100644 crates/buzz-client/src/auth.rs create mode 100644 crates/buzz-client/src/channels.rs create mode 100644 crates/buzz-client/src/client.rs create mode 100644 crates/buzz-client/src/config.rs create mode 100644 crates/buzz-client/src/endpoint.rs create mode 100644 crates/buzz-client/src/error.rs create mode 100644 crates/buzz-client/src/lib.rs create mode 100644 crates/buzz-client/src/messages.rs create mode 100644 crates/buzz-client/src/transport.rs create mode 100644 crates/buzz-client/tests/channel_listing.rs create mode 100644 crates/buzz-client/tests/client_construction.rs create mode 100644 crates/buzz-client/tests/community_endpoint.rs create mode 100644 crates/buzz-client/tests/dependency_boundary.rs create mode 100644 crates/buzz-client/tests/message_delivery.rs create mode 100644 examples/buzz-client-consumer/Cargo.lock create mode 100644 examples/buzz-client-consumer/Cargo.toml create mode 100644 examples/buzz-client-consumer/README.md create mode 100644 examples/buzz-client-consumer/src/main.rs create mode 100644 examples/buzz-client-consumer/tests/smoke.rs diff --git a/Cargo.lock b/Cargo.lock index 937ead564a..21d09854f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -941,6 +941,7 @@ version = "0.1.0" dependencies = [ "axum", "base64 0.22.1", + "buzz-client", "buzz-core", "buzz-persona", "buzz-sdk", @@ -966,6 +967,27 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-client" +version = "0.1.0" +dependencies = [ + "axum", + "base64 0.22.1", + "buzz-sdk", + "bytes", + "hex", + "nostr", + "rand 0.10.1", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "url", + "uuid", +] + [[package]] name = "buzz-conformance" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index cc1dd0f9df..beb42848db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "crates/buzz-workflow", "crates/buzz-media", "crates/buzz-cli", + "crates/buzz-client", "crates/buzz-pairing-cli", "crates/buzz-sdk", "crates/buzz-persona", @@ -132,6 +133,7 @@ schemars = { version = "1", default-features = false } # Internal crates buzz-core = { path = "crates/buzz-core" } +buzz-client = { path = "crates/buzz-client" } buzz-conformance = { path = "crates/buzz-conformance" } buzz-db = { path = "crates/buzz-db" } buzz-auth = { path = "crates/buzz-auth" } diff --git a/Justfile b/Justfile index d80341ecac..714e4a88f4 100644 --- a/Justfile +++ b/Justfile @@ -276,7 +276,7 @@ desktop-e2e-pre-push: _ensure-migrations cd {{desktop_dir}} && pnpm build:e2e && pnpm exec playwright test --only-changed=origin/main # Run all checks suitable for CI / pre-push (no infra needed) -ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test +ci: check test-unit buzz-client-consumer-check desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test # ─── Test ───────────────────────────────────────────────────────────────────── @@ -292,6 +292,7 @@ test-unit: cargo nextest run -p buzz-core -p buzz-auth --lib cargo nextest run -p buzz-voice --lib cargo nextest run -p buzz-cli + cargo nextest run -p buzz-client # buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra). # They guard the embedded-migrator invariant (exactly the consolidated # 0001; cutover/backfill stays an operator script, not startup state) @@ -317,6 +318,10 @@ test-unit: ./scripts/run-tests.sh unit fi +# Compile the client exactly as an independent repository would consume it. +buzz-client-consumer-check: + cargo test --manifest-path examples/buzz-client-consumer/Cargo.toml + # Run integration tests only (starts services if needed) test-integration: ./scripts/run-tests.sh integration diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 1476e60bfd..02e7d1f066 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -44,6 +44,7 @@ chrono = { workspace = true } # Typed event builders for all write operations buzz-sdk = { workspace = true } buzz-core = { workspace = true } +buzz-client = { workspace = true } # Base64 encoding — NIP-98 event serialization for Authorization header base64 = "0.22" diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index d0dd2677a9..99b0579abe 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -37,6 +37,7 @@ pub struct BlobDescriptor { } /// Build an `imeta` tag array from a BlobDescriptor (NIP-92 media metadata). +#[cfg(test)] pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec { let mut tag = vec![ "imeta".to_string(), @@ -1837,6 +1838,34 @@ mod retry_policy_tests { ); } + /// A semantic relay rejection is definitive and must not be retried or + /// translated into an ambiguous delivery outcome. + #[tokio::test] + async fn stored_event_422_is_a_definitive_single_attempt_rejection() { + let (url, attempts) = test_server(|_| { + ( + StatusCode::UNPROCESSABLE_ENTITY, + r#"{"error":"invalid event"}"#.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::Relay { + status: 422, + ref body + } if body == "invalid event" + ), + "expected definitive relay rejection, got {err:?}" + ); + assert_eq!(attempts.load(Ordering::SeqCst), 1); + } + /// 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) -> (String, Arc) @@ -2045,6 +2074,9 @@ mod retry_policy_tests { let bodies: Arc>>> = Arc::new(std::sync::Mutex::new(Vec::new())); let bodies2 = bodies.clone(); + let auth_headers: Arc>> = + 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(); @@ -2071,6 +2103,13 @@ mod retry_policy_tests { .unwrap_or(0); let payload = buf[body_end..].to_vec(); bodies2.lock().unwrap().push(payload); + let request = String::from_utf8_lossy(&buf); + let auth = request + .lines() + .find(|line| line.to_ascii_lowercase().starts_with("authorization:")) + .unwrap_or_default() + .to_string(); + auth_headers2.lock().unwrap().push(auth); if n < 3 { // Partial body drop. @@ -2108,6 +2147,10 @@ mod retry_policy_tests { captured[1], captured[2], "attempt 2 and 3 must use identical event bytes" ); + let auth_headers = auth_headers.lock().unwrap(); + assert!(auth_headers.iter().all(|header| !header.is_empty())); + assert_ne!(auth_headers[0], auth_headers[1]); + assert_ne!(auth_headers[1], auth_headers[2]); } /// `upload_file` uses `with_retry_body` — the full operation including response diff --git a/crates/buzz-cli/src/client_adapter.rs b/crates/buzz-cli/src/client_adapter.rs new file mode 100644 index 0000000000..d5a147344e --- /dev/null +++ b/crates/buzz-cli/src/client_adapter.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; +use std::time::Duration; + +use buzz_client::{ + AuthContext, BuzzClient as ReusableBuzzClient, ClientConfig, ClientError, CommunityEndpoint, + RetryPolicy, +}; +use nostr::Keys; + +use crate::error::CliError; + +pub async fn build_reusable_client( + relay_url: &str, + keys: &Keys, + auth_tag_json: Option<&str>, +) -> Result { + let endpoint = CommunityEndpoint::parse(relay_url) + .map_err(ClientError::from) + .map_err(map_client_error)?; + let retry = RetryPolicy::new(3, Duration::from_millis(500), Duration::from_millis(1500)) + .map_err(map_client_error)?; + let config = ClientConfig::new( + env_duration_secs("BUZZ_TIMEOUT_SECS", 30), + env_duration_secs("BUZZ_CONNECT_TIMEOUT_SECS", 15), + retry, + ) + .map_err(map_client_error)?; + let auth = match auth_tag_json { + Some(json) => AuthContext::nip_oa(json).map_err(map_client_error)?, + None => AuthContext::signer_only(), + }; + ReusableBuzzClient::builder(endpoint, Arc::new(keys.clone())) + .config(config) + .auth_context(auth) + .build() + .await + .map_err(map_client_error) +} + +pub fn map_client_error(error: ClientError) -> CliError { + match error { + ClientError::Endpoint(error) => CliError::Usage(error.to_string()), + ClientError::Configuration(message) | ClientError::InvalidInput(message) => { + CliError::Usage(message) + } + ClientError::Authentication(message) => CliError::Auth(message), + ClientError::Signer(error) => CliError::Key(error.to_string()), + ClientError::HttpClient(error) => CliError::Other(error.to_string()), + ClientError::Network(error) => CliError::Network(error), + ClientError::Relay { status, reason, .. } => CliError::Relay { + status, + body: reason, + }, + ClientError::Serialization(error) => CliError::Other(error.to_string()), + } +} + +fn env_duration_secs(name: &str, default: u64) -> Duration { + std::env::var(name) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|seconds| *seconds > 0) + .map(Duration::from_secs) + .unwrap_or_else(|| Duration::from_secs(default)) +} diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 42844bf1e0..4ded1c04ab 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -22,6 +22,7 @@ fn extract_channel_metadata(e: &serde_json::Value) -> serde_json::Value { }) } +#[cfg(test)] pub async fn cmd_list_channels( client: &BuzzClient, visibility: Option<&str>, @@ -40,10 +41,12 @@ pub async fn cmd_list_channels( let member_events = client .query_paginated(member_filter, effective_limit) .await?; + let mut seen_channel_ids = HashSet::new(); let channel_ids: Vec = member_events .iter() .map(extract_d_tag) .filter(|id| !id.is_empty()) + .filter(|id| seen_channel_ids.insert(id.clone())) .collect(); if channel_ids.is_empty() { println!("[]"); @@ -111,6 +114,52 @@ pub async fn cmd_list_channels( Ok(()) } +async fn cmd_list_channels_reusable( + client: &buzz_client::BuzzClient, + visibility: Option<&str>, + member: bool, + limit: Option, + format: &crate::OutputFormat, +) -> Result<(), CliError> { + let visibility = visibility.map(|value| match value { + "open" => buzz_client::ChannelVisibility::Open, + _ => buzz_client::ChannelVisibility::Private, + }); + let channels = client + .list_channels(buzz_client::ListChannelsRequest { + scope: if member { + buzz_client::ChannelScope::Member + } else { + buzz_client::ChannelScope::Visible + }, + visibility, + limit: limit.unwrap_or(500), + }) + .await + .map_err(crate::client_adapter::map_client_error)?; + let output: Vec = channels + .into_iter() + .map(|channel| match format { + crate::OutputFormat::Compact => serde_json::json!({ + "channel_id": channel.channel_id, + "name": channel.name, + }), + crate::OutputFormat::Json => serde_json::json!({ + "channel_id": channel.channel_id, + "name": channel.name, + "description": channel.description.unwrap_or_default(), + "created_at": channel.created_at, + }), + }) + .collect(); + println!( + "{}", + serde_json::to_string(&output) + .map_err(|error| CliError::Other(format!("channel serialization failed: {error}")))? + ); + Ok(()) +} + /// Search channels by human-readable name (kind:39000 group metadata). /// /// The relay's access control already filters out channels the caller can't see @@ -1063,9 +1112,9 @@ pub async fn cmd_set_canvas( Ok(()) } -pub async fn dispatch( +pub async fn dispatch_reusable( cmd: crate::ChannelsCmd, - client: &BuzzClient, + client: &buzz_client::BuzzClient, format: &crate::OutputFormat, ) -> Result<(), CliError> { use crate::ChannelsCmd; @@ -1076,8 +1125,20 @@ pub async fn dispatch( limit, } => { let vis_str = visibility.as_ref().map(|v| v.to_string()); - cmd_list_channels(client, vis_str.as_deref(), Some(member), limit, format).await + cmd_list_channels_reusable(client, vis_str.as_deref(), member, limit, format).await } + _ => Err(CliError::Other( + "only channels list is routed through the reusable client".into(), + )), + } +} + +pub async fn dispatch(cmd: crate::ChannelsCmd, client: &BuzzClient) -> Result<(), CliError> { + use crate::ChannelsCmd; + match cmd { + ChannelsCmd::List { .. } => Err(CliError::Other( + "channels list must be routed before legacy client construction".into(), + )), ChannelsCmd::Get { channel } => cmd_get_channel(client, &channel).await, ChannelsCmd::Search { query, @@ -1176,7 +1237,7 @@ pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &BuzzClient) -> Resu #[cfg(test)] mod tests { use super::{ - apply_cardinality_rule, build_template_report, cmd_set_add_policy, + apply_cardinality_rule, build_template_report, cmd_list_channels, cmd_set_add_policy, finalize_roster_resolution, name_matches, resolve_roster_with_archive_filter, validate_ttl_seconds, ArchivedExclusion, ChannelSummary, ResolvedAgent, RosterResolution, SkippedSlug, @@ -1189,6 +1250,80 @@ mod tests { json!({ "tags": tags }) } + #[tokio::test] + async fn list_member_channels_deduplicates_metadata_filter_ids() { + use std::sync::{Arc, Mutex}; + + use axum::extract::State; + use axum::routing::post; + use axum::{Json, Router}; + + type Requests = Arc>>; + + async fn query( + State(requests): State, + Json(body): Json, + ) -> Json { + let request_index = requests.lock().expect("request lock").len(); + requests.lock().expect("request lock").push(body); + let channel_id = "11111111-1111-1111-1111-111111111111"; + let response = if request_index == 0 { + json!([ + { + "id": "a".repeat(64), + "created_at": 2, + "tags": [["d", channel_id]], + }, + { + "id": "b".repeat(64), + "created_at": 1, + "tags": [["d", channel_id]], + } + ]) + } else { + json!([{ + "id": "c".repeat(64), + "created_at": 3, + "tags": [["d", channel_id], ["name", "general"], ["public"]], + }]) + }; + Json(response) + } + + let requests: Requests = Arc::new(Mutex::new(Vec::new())); + let app = Router::new() + .route("/query", post(query)) + .with_state(requests.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener"); + let address = listener.local_addr().expect("listener address"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("test server"); + }); + + let keys = nostr::Keys::generate(); + let client = + BuzzClient::new(format!("http://{address}"), keys, None, None).expect("test client"); + cmd_list_channels( + &client, + None, + Some(true), + Some(10), + &crate::OutputFormat::Json, + ) + .await + .expect("channel list"); + + let captured = requests.lock().expect("request lock"); + assert_eq!(captured.len(), 2, "membership and metadata queries"); + assert_eq!( + captured[1][0]["#d"], + json!(["11111111-1111-1111-1111-111111111111"]), + "the metadata query must preserve the stable contract without duplicate channel ids" + ); + } + #[test] fn from_event_extracts_known_tags() { let ev = event(json!([ diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b5..e384418d9e 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -8,6 +8,7 @@ use crate::validate::{ infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff, validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, }; +#[cfg(test)] use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP, }; @@ -118,6 +119,7 @@ async fn resolve_channel_id(client: &BuzzClient, event_id: &str) -> Result>, @@ -154,6 +156,7 @@ fn resolve_names_to_pubkeys( /// Returns both the current member set and uniquely name-resolved pubkeys. /// Lookup failures are fatal when mention processing is requested: publishing /// visible mention text without its intended `p` tag is worse than not sending. +#[cfg(test)] async fn resolve_content_mentions( client: &BuzzClient, channel_id: &str, @@ -225,6 +228,7 @@ async fn resolve_content_mentions( Ok((member_pubkeys, resolved)) } +#[cfg(test)] fn normalize_explicit_mentions(values: &[String]) -> Result, CliError> { let mut normalized = Vec::new(); for value in values { @@ -243,6 +247,7 @@ fn normalize_explicit_mentions(values: &[String]) -> Result, CliErro Ok(normalized) } +#[cfg(test)] fn merge_message_mentions( explicit: &[String], uri_pubkeys: &[String], @@ -266,6 +271,7 @@ fn merge_message_mentions( Ok(mentions) } +#[cfg(test)] fn missing_members(mentions: &[String], members: &[String]) -> Vec { let members: std::collections::HashSet<&str> = members.iter().map(String::as_str).collect(); mentions @@ -275,6 +281,7 @@ fn missing_members(mentions: &[String], members: &[String]) -> Vec { .collect() } +#[cfg(test)] fn event_mention_pubkeys(event: &nostr::Event) -> Vec { event .tags @@ -290,6 +297,7 @@ fn event_mention_pubkeys(event: &nostr::Event) -> Vec { /// Fetch raw events for `filter` via the relay's `/query` endpoint. /// Returns `None` on any I/O or parse failure. +#[cfg(test)] async fn fetch_events( client: &BuzzClient, filter: &serde_json::Value, @@ -300,6 +308,7 @@ async fn fetch_events( } /// Extract member pubkeys (the `p` tag values) from a single 39002 event. +#[cfg(test)] async fn fetch_member_pubkeys( client: &BuzzClient, filter: &serde_json::Value, @@ -313,6 +322,7 @@ async fn fetch_member_pubkeys( /// Filters and canonicalizes via `nostr::PublicKey::from_hex` — matching /// MCP's typed-Nostr behavior so both surfaces accept exactly the same /// pubkeys. Pure helper, split out for testing. +#[cfg(test)] fn parse_member_pubkeys(event: &serde_json::Value) -> Vec { let Some(tags) = event.get("tags").and_then(|t| t.as_array()) else { return vec![]; @@ -571,6 +581,8 @@ pub struct SendMessageParams { pub mentions: Vec, } +#[cfg(test)] +#[expect(dead_code, reason = "retained only for legacy compatibility fixtures")] pub async fn cmd_send_message( client: &BuzzClient, mut p: SendMessageParams, @@ -692,6 +704,99 @@ pub async fn cmd_send_message( Ok(()) } +async fn cmd_send_message_reusable( + client: &buzz_client::BuzzClient, + mut p: SendMessageParams, +) -> Result<(), CliError> { + p.content = read_or_stdin(&p.content)?; + validate_content_size(&p.content)?; + let channel_id = parse_uuid(&p.channel_id)?; + let reply_to = p.reply_to.as_deref().map(parse_event_id).transpose()?; + let kind = match p.kind { + None | Some(9) => buzz_client::MessageKind::Stream, + Some(45001) => buzz_client::MessageKind::ForumPost, + Some(45003) => { + if reply_to.is_none() { + return Err(CliError::Usage( + "--reply-to is required for forum comments (kind 45003)".into(), + )); + } + buzz_client::MessageKind::ForumComment + } + Some(kind) => { + return Err(CliError::Usage(format!( + "--kind {kind} is not supported (use 9, 45001, or 45003)" + ))); + } + }; + let mentions = p + .mentions + .iter() + .map(|value| { + PublicKey::parse(value.trim()) + .map_err(|_| CliError::Usage(format!("invalid --mention pubkey: {value}"))) + }) + .collect::, _>>()?; + let mut attachments = Vec::new(); + for path in &p.files { + let metadata = std::fs::metadata(path) + .map_err(|error| CliError::Other(format!("cannot access {path}: {error}")))?; + if !metadata.is_file() { + return Err(CliError::Usage(format!("{path} is not a file"))); + } + let bytes = std::fs::read(path) + .map_err(|error| CliError::Other(format!("failed to read {path}: {error}")))?; + let mime_type = infer::get(&bytes) + .map(|kind| kind.mime_type().to_string()) + .unwrap_or_else(|| "application/octet-stream".to_string()); + attachments.push(buzz_client::MessageAttachment { + bytes: bytes.into(), + mime_type, + }); + } + let result = client + .send_message(buzz_client::SendMessageRequest { + channel_id, + content: p.content, + kind, + reply_to, + broadcast: p.broadcast, + attachments, + mentions, + }) + .await + .map_err(crate::client_adapter::map_client_error)?; + let mention_pubkeys: Vec = result + .mention_pubkeys + .iter() + .map(PublicKey::to_hex) + .collect(); + match result.delivery { + buzz_client::DeliveryOutcome::Accepted(delivery) => { + println!( + "{}", + serde_json::json!({ + "event_id": delivery.event_id.to_hex(), + "accepted": true, + "message": delivery.relay_message, + "mention_pubkeys": mention_pubkeys, + }) + ); + Ok(()) + } + buzz_client::DeliveryOutcome::Rejected(delivery) => Err(CliError::Relay { + status: delivery.status, + body: delivery.reason, + }), + buzz_client::DeliveryOutcome::Unknown(delivery) => Err(CliError::DeliveryUnknown(format!( + "stored event (kind {}) outcome unknown after all attempts: {} (event {})", + p.kind.unwrap_or(9), + delivery.reason, + delivery.event_id.to_hex() + ))), + } +} + pub struct SendDiffParams { pub channel_id: String, pub diff: String, @@ -865,10 +970,9 @@ pub async fn cmd_vote_on_post( Ok(()) } -pub async fn dispatch( +pub async fn dispatch_reusable( cmd: crate::MessagesCmd, - client: &BuzzClient, - format: &crate::OutputFormat, + client: &buzz_client::BuzzClient, ) -> Result<(), CliError> { use crate::MessagesCmd; match cmd { @@ -881,7 +985,7 @@ pub async fn dispatch( files, mentions, } => { - cmd_send_message( + cmd_send_message_reusable( client, SendMessageParams { channel_id: channel, @@ -895,6 +999,22 @@ pub async fn dispatch( ) .await } + _ => Err(CliError::Other( + "only messages send is routed through the reusable client".into(), + )), + } +} + +pub async fn dispatch( + cmd: crate::MessagesCmd, + client: &BuzzClient, + format: &crate::OutputFormat, +) -> Result<(), CliError> { + use crate::MessagesCmd; + match cmd { + MessagesCmd::Send { .. } => Err(CliError::Other( + "messages send must be routed before legacy client construction".into(), + )), MessagesCmd::SendDiff { channel, diff, diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index f745e7b280..d74e042411 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1,5 +1,6 @@ pub mod agent_management; mod client; +mod client_adapter; mod commands; mod error; mod validate; @@ -1968,12 +1969,27 @@ async fn run(cli: Cli) -> Result<(), CliError> { _ => (None, None), }; + let command = match cli.command { + Cmd::Channels(sub @ ChannelsCmd::List { .. }) => { + let client = + client_adapter::build_reusable_client(&relay_url, &keys, auth_tag_json.as_deref()) + .await?; + return commands::channels::dispatch_reusable(sub, &client, &cli.format).await; + } + Cmd::Messages(sub @ MessagesCmd::Send { .. }) => { + let client = + client_adapter::build_reusable_client(&relay_url, &keys, auth_tag_json.as_deref()) + .await?; + return commands::messages::dispatch_reusable(sub, &client).await; + } + command => command, + }; let client = BuzzClient::new(relay_url, keys, auth_tag, auth_tag_json)?; - match cli.command { + match command { Cmd::Agents(sub) => commands::agents::dispatch(sub, &client).await, Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await, - Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await, + Cmd::Channels(sub) => commands::channels::dispatch(sub, &client).await, Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await, Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await, Cmd::Emoji(sub) => commands::emoji::dispatch(sub, &client).await, diff --git a/crates/buzz-cli/tests/channel_list_compat.rs b/crates/buzz-cli/tests/channel_list_compat.rs new file mode 100644 index 0000000000..10e1bf88a0 --- /dev/null +++ b/crates/buzz-cli/tests/channel_list_compat.rs @@ -0,0 +1,136 @@ +use std::process::{Command, Output}; + +use axum::http::StatusCode; +use axum::routing::post; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +const PRIVATE_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + +async fn relay_with_response(status: StatusCode, body: Value) -> String { + let app = Router::new().route( + "/query", + post(move || { + let body = body.clone(); + async move { (status, Json(body)) } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener"); + let address = listener.local_addr().expect("listener address"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("test relay"); + }); + format!("http://{address}") +} + +fn run_buzz(relay: &str, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_buzz")) + .args(["--relay", relay, "--private-key", PRIVATE_KEY]) + .args(args) + .output() + .expect("run buzz") +} + +fn metadata_events() -> Value { + json!([ + { + "id": "a".repeat(64), + "pubkey": "b".repeat(64), + "created_at": 20, + "kind": 39000, + "content": "", + "tags": [ + ["d", "11111111-1111-1111-1111-111111111111"], + ["name", "general"], + ["about", "General discussion"], + ["public"] + ], + "sig": "c".repeat(128) + }, + { + "id": "d".repeat(64), + "pubkey": "e".repeat(64), + "created_at": 10, + "kind": 39000, + "content": "", + "tags": [ + ["d", "22222222-2222-2222-2222-222222222222"], + ["name", "private"], + ["about", "Private discussion"], + ["private"] + ], + "sig": "f".repeat(128) + } + ]) +} + +#[tokio::test(flavor = "multi_thread")] +async fn full_output_and_visibility_filter_are_stable() { + let relay = relay_with_response(StatusCode::OK, metadata_events()).await; + let output = run_buzz( + &relay, + &["channels", "list", "--visibility", "open", "--limit", "5"], + ); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let value: Value = serde_json::from_slice(&output.stdout).expect("JSON stdout"); + assert_eq!( + value, + json!([{ + "channel_id": "11111111-1111-1111-1111-111111111111", + "name": "general", + "description": "General discussion", + "created_at": 20 + }]) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn compact_output_projection_is_stable() { + let relay = relay_with_response(StatusCode::OK, metadata_events()).await; + let output = run_buzz( + &relay, + &["--format", "compact", "channels", "list", "--limit", "5"], + ); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let value: Value = serde_json::from_slice(&output.stdout).expect("JSON stdout"); + assert_eq!( + value, + json!([ + { + "channel_id": "11111111-1111-1111-1111-111111111111", + "name": "general" + }, + { + "channel_id": "22222222-2222-2222-2222-222222222222", + "name": "private" + } + ]) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn relay_forbidden_keeps_auth_error_and_exit_code() { + let relay = relay_with_response(StatusCode::FORBIDDEN, json!({"error": "forbidden"})).await; + let output = run_buzz(&relay, &["channels", "list"]); + + assert_eq!(output.status.code(), Some(3)); + assert!(output.stdout.is_empty()); + let value: Value = serde_json::from_slice(&output.stderr).expect("JSON stderr"); + assert_eq!(value["error"], "auth_error"); + assert_eq!(value["retryable"], false); + assert!(value["message"] + .as_str() + .is_some_and(|message| message.contains("relay error 403: forbidden"))); +} diff --git a/crates/buzz-cli/tests/message_send_compat.rs b/crates/buzz-cli/tests/message_send_compat.rs new file mode 100644 index 0000000000..0278d75572 --- /dev/null +++ b/crates/buzz-cli/tests/message_send_compat.rs @@ -0,0 +1,371 @@ +use std::io::Write; +use std::process::{Command, Output, Stdio}; +use std::sync::{Arc, Mutex}; + +use axum::extract::State; +use axum::routing::{post, put}; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +const PRIVATE_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; +const CHANNEL_ID: &str = "11111111-1111-1111-1111-111111111111"; +const MEMBER_PUBKEY: &str = "35c18ae273fccfaf80d629e20e7f8721b90499379addff533054acc2504c12b4"; + +#[derive(Clone, Default)] +struct RelayState { + events: Arc>>, + query_response: Arc>, + upload_url: Arc>, +} + +async fn accept_event(State(state): State, Json(event): Json) -> Json { + let event_id = event["id"].clone(); + state.events.lock().expect("event capture lock").push(event); + Json(json!({ + "event_id": event_id, + "accepted": true, + "message": "stored" + })) +} + +async fn answer_query(State(state): State) -> Json { + Json( + state + .query_response + .lock() + .expect("query response lock") + .clone(), + ) +} + +async fn accept_upload(State(state): State) -> Json { + let url = state.upload_url.lock().expect("upload URL lock").clone(); + Json(json!({ + "url": url, + "sha256": "abc123", + "size": 12, + "type": "image/png", + "uploaded": 0 + })) +} + +async fn accepting_relay_with_query(query_response: Value) -> (String, RelayState) { + let state = RelayState { + query_response: Arc::new(Mutex::new(query_response)), + ..RelayState::default() + }; + let app = Router::new() + .route("/events", post(accept_event)) + .route("/query", post(answer_query)) + .route("/upload", put(accept_upload)) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener"); + let address = listener.local_addr().expect("listener address"); + *state.upload_url.lock().expect("upload URL lock") = + format!("http://{address}/media/image.png"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("test relay"); + }); + (format!("http://{address}"), state) +} + +async fn accepting_relay() -> (String, RelayState) { + accepting_relay_with_query(json!([])).await +} + +fn buzz_command(relay: &str) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_buzz")); + command.args(["--relay", relay, "--private-key", PRIVATE_KEY]); + command +} + +fn run_buzz(relay: &str, args: &[&str]) -> Output { + buzz_command(relay).args(args).output().expect("run buzz") +} + +fn run_buzz_with_stdin(relay: &str, args: &[&str], stdin: &str) -> Output { + let mut child = buzz_command(relay) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn buzz"); + child + .stdin + .take() + .expect("piped stdin") + .write_all(stdin.as_bytes()) + .expect("write stdin"); + child.wait_with_output().expect("wait for buzz") +} + +fn only_event(state: &RelayState) -> Value { + let events = state.events.lock().expect("event capture lock"); + assert_eq!(events.len(), 1, "expected exactly one submitted event"); + events[0].clone() +} + +fn has_tag(event: &Value, expected: &[&str]) -> bool { + event["tags"].as_array().is_some_and(|tags| { + tags.iter().any(|tag| { + tag.as_array().is_some_and(|parts| { + parts + .iter() + .map(|part| part.as_str()) + .eq(expected.iter().copied().map(Some)) + }) + }) + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn argument_content_and_success_projection_are_stable() { + let (relay, state) = accepting_relay().await; + let output = run_buzz( + &relay, + &[ + "messages", + "send", + "--channel", + CHANNEL_ID, + "--content", + "hello from argv", + ], + ); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let event = only_event(&state); + let stdout: Value = serde_json::from_slice(&output.stdout).expect("JSON stdout"); + assert_eq!(stdout["event_id"], event["id"]); + assert_eq!(stdout["accepted"], true); + assert_eq!(stdout["message"], "stored"); + assert_eq!(stdout["mention_pubkeys"], json!([])); + assert_eq!(event["kind"], 9); + assert_eq!(event["content"], "hello from argv"); + assert!(has_tag(&event, &["h", CHANNEL_ID])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn dash_reads_message_content_from_stdin() { + let (relay, state) = accepting_relay().await; + let output = run_buzz_with_stdin( + &relay, + &[ + "messages", + "send", + "--channel", + CHANNEL_ID, + "--content", + "-", + ], + "content from stdin", + ); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(only_event(&state)["content"], "content from stdin"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn forum_post_kind_is_relay_visible() { + let (relay, state) = accepting_relay().await; + let output = run_buzz( + &relay, + &[ + "messages", + "send", + "--channel", + CHANNEL_ID, + "--content", + "forum root", + "--kind", + "45001", + ], + ); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let event = only_event(&state); + assert_eq!(event["kind"], 45001); + assert_eq!(event["content"], "forum root"); + assert!(has_tag(&event, &["h", CHANNEL_ID])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn explicit_member_mention_is_visible_in_event_and_success_json() { + let membership = json!([{ + "kind": 39002, + "tags": [["d", CHANNEL_ID], ["p", MEMBER_PUBKEY]] + }]); + let (relay, state) = accepting_relay_with_query(membership).await; + let output = run_buzz( + &relay, + &[ + "messages", + "send", + "--channel", + CHANNEL_ID, + "--content", + "hello member", + "--mention", + MEMBER_PUBKEY, + ], + ); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let event = only_event(&state); + assert!( + has_tag(&event, &["p", MEMBER_PUBKEY]), + "submitted event: {event}" + ); + let stdout: Value = serde_json::from_slice(&output.stdout).expect("JSON stdout"); + assert_eq!(stdout["mention_pubkeys"], json!([MEMBER_PUBKEY])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn nested_forum_comment_preserves_root_and_parent_markers() { + let root_id = "a".repeat(64); + let parent_id = "b".repeat(64); + let parent = json!([{ + "id": parent_id, + "kind": 45003, + "tags": [["h", CHANNEL_ID], ["e", root_id, "", "root"]] + }]); + let (relay, state) = accepting_relay_with_query(parent).await; + let output = run_buzz( + &relay, + &[ + "messages", + "send", + "--channel", + CHANNEL_ID, + "--content", + "nested comment", + "--kind", + "45003", + "--reply-to", + &parent_id, + ], + ); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let event = only_event(&state); + assert_eq!(event["kind"], 45003); + assert!(has_tag(&event, &["e", &root_id, "", "root"])); + assert!(has_tag(&event, &["e", &parent_id, "", "reply"])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn attachment_upload_contributes_content_and_imeta() { + let (relay, state) = accepting_relay().await; + let media_url = format!("{relay}/media/image.png"); + let directory = tempfile::tempdir().expect("temp directory"); + let path = directory.path().join("image.png"); + std::fs::write(&path, b"\x89PNG\r\n\x1a\nmock").expect("write PNG fixture"); + let path = path.to_string_lossy(); + let output = run_buzz( + &relay, + &[ + "messages", + "send", + "--channel", + CHANNEL_ID, + "--content", + "with image", + "--file", + &path, + ], + ); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let event = only_event(&state); + assert_eq!( + event["content"], + format!("with image\n![image]({media_url})") + ); + assert!(has_tag( + &event, + &[ + "imeta", + &format!("url {media_url}"), + "m image/png", + "x abc123", + "size 12", + ] + )); +} + +#[test] +fn unsupported_kind_keeps_user_error_and_exit_code() { + let output = run_buzz( + "http://127.0.0.1:9", + &[ + "messages", + "send", + "--channel", + CHANNEL_ID, + "--content", + "hello", + "--kind", + "42", + ], + ); + + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + let stderr: Value = serde_json::from_slice(&output.stderr).expect("JSON stderr"); + assert_eq!(stderr["error"], "user_error"); + assert_eq!(stderr["retryable"], false); + assert!(stderr["message"] + .as_str() + .is_some_and(|message| message.contains("--kind 42 is not supported"))); +} + +#[test] +fn forum_comment_without_reply_keeps_user_error_and_exit_code() { + let output = run_buzz( + "http://127.0.0.1:9", + &[ + "messages", + "send", + "--channel", + CHANNEL_ID, + "--content", + "orphan comment", + "--kind", + "45003", + ], + ); + + assert_eq!(output.status.code(), Some(1)); + let stderr: Value = serde_json::from_slice(&output.stderr).expect("JSON stderr"); + assert_eq!(stderr["error"], "user_error"); + assert!(stderr["message"] + .as_str() + .is_some_and(|message| message.contains("--reply-to is required"))); +} diff --git a/crates/buzz-client/Cargo.toml b/crates/buzz-client/Cargo.toml new file mode 100644 index 0000000000..93f12f545d --- /dev/null +++ b/crates/buzz-client/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "buzz-client" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Reusable authenticated client for a Buzz community" + +[dependencies] +base64 = "0.22" +bytes = "1" +hex = { workspace = true } +nostr = { workspace = true } +rand = { workspace = true } +reqwest = { workspace = true, features = ["json"] } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } + +buzz-sdk = { workspace = true } + +[dev-dependencies] +axum = { workspace = true } diff --git a/crates/buzz-client/src/auth.rs b/crates/buzz-client/src/auth.rs new file mode 100644 index 0000000000..21dc65dda4 --- /dev/null +++ b/crates/buzz-client/src/auth.rs @@ -0,0 +1,46 @@ +use nostr::Tag; + +use crate::ClientError; + +/// Authentication material applied consistently to one client session. +#[derive(Clone, Debug, Default)] +pub struct AuthContext { + pub(crate) ambient: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct AmbientAuth { + pub(crate) tag: Tag, + pub(crate) json: String, +} + +impl AuthContext { + /// Use only the configured signer, without a NIP-OA owner attestation. + pub fn signer_only() -> Self { + Self::default() + } + + /// Parse a NIP-OA owner-attestation tag for validation during construction. + pub fn nip_oa(json: &str) -> Result { + let tag = buzz_sdk::nip_oa::parse_auth_tag(json) + .map_err(|error| ClientError::Authentication(error.to_string()))?; + let json = serde_json::to_string(tag.as_slice()).map_err(ClientError::Serialization)?; + Ok(Self { + ambient: Some(AmbientAuth { tag, json }), + }) + } + + /// Whether a NIP-OA owner attestation is configured. + pub fn has_nip_oa(&self) -> bool { + self.ambient.is_some() + } + + /// Parsed ambient NIP-OA tag, when configured. + pub fn nip_oa_tag(&self) -> Option<&Tag> { + self.ambient.as_ref().map(|ambient| &ambient.tag) + } + + pub(crate) fn ambient(&self) -> Option<&AmbientAuth> { + self.ambient.as_ref() + } +} diff --git a/crates/buzz-client/src/channels.rs b/crates/buzz-client/src/channels.rs new file mode 100644 index 0000000000..ab080b1e96 --- /dev/null +++ b/crates/buzz-client/src/channels.rs @@ -0,0 +1,175 @@ +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{BuzzClient, ClientError}; + +/// Which channel population a listing should query. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ChannelScope { + /// Every channel visible to the authenticated identity. + #[default] + Visible, + /// Only channels whose membership snapshot includes the active signer. + Member, +} + +/// NIP-29 channel visibility classification. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChannelVisibility { + /// Publicly visible channel. + Open, + /// Membership-restricted channel. + Private, +} + +/// Typed request for a complete bounded channel listing. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ListChannelsRequest { + /// Population to query. + pub scope: ChannelScope, + /// Optional visibility filter. + pub visibility: Option, + /// Maximum number of membership and metadata records to inspect and return. + pub limit: u32, +} + +impl Default for ListChannelsRequest { + fn default() -> Self { + Self { + scope: ChannelScope::Visible, + visibility: None, + limit: 500, + } + } +} + +/// Validated channel metadata returned by a Buzz community. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ChannelRecord { + /// Stable channel UUID from the NIP-29 `d` tag. + pub channel_id: Uuid, + /// Human-readable channel name. + pub name: String, + /// Human-readable channel description, when present. + pub description: Option, + /// Relay event creation timestamp. + pub created_at: u64, + /// Relay-declared visibility, when present. + pub visibility: Option, +} + +impl BuzzClient { + /// List channels from the bound community, following composite pagination. + pub async fn list_channels( + &self, + request: ListChannelsRequest, + ) -> Result, ClientError> { + if request.limit == 0 { + return Err(ClientError::InvalidInput( + "channel list limit must be greater than zero".into(), + )); + } + let events = match request.scope { + ChannelScope::Visible => { + self.query_paginated(serde_json::json!({"kinds": [39000]}), request.limit) + .await? + } + ChannelScope::Member => { + let memberships = self + .query_paginated( + serde_json::json!({ + "kinds": [39002], + "#p": [self.public_key().to_hex()], + }), + request.limit, + ) + .await?; + let mut seen = HashSet::new(); + let channel_ids: Vec = memberships + .iter() + .filter_map(|event| tag_value(event, "d")) + .filter(|id| seen.insert(id.clone())) + .collect(); + if channel_ids.is_empty() { + return Ok(Vec::new()); + } + self.query_paginated( + serde_json::json!({"kinds": [39000], "#d": channel_ids}), + request.limit, + ) + .await? + } + }; + + let mut seen = HashSet::new(); + let mut channels = Vec::new(); + for event in events { + let Some(channel) = channel_record(&event) else { + continue; + }; + if request + .visibility + .is_some_and(|visibility| channel.visibility != Some(visibility)) + { + continue; + } + if seen.insert(channel.channel_id) { + channels.push(channel); + } + } + Ok(channels) + } +} + +fn channel_record(event: &serde_json::Value) -> Option { + let channel_id = Uuid::parse_str(&tag_value(event, "d")?).ok()?; + let name = tag_value(event, "name").unwrap_or_default(); + let description = tag_value(event, "about"); + let created_at = event + .get("created_at") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default(); + let visibility = if has_marker(event, "public") { + Some(ChannelVisibility::Open) + } else if has_marker(event, "private") { + Some(ChannelVisibility::Private) + } else { + None + }; + Some(ChannelRecord { + channel_id, + name, + description, + created_at, + visibility, + }) +} + +fn tag_value(event: &serde_json::Value, key: &str) -> Option { + event + .get("tags")? + .as_array()? + .iter() + .filter_map(serde_json::Value::as_array) + .find(|parts| parts.first().and_then(serde_json::Value::as_str) == Some(key))? + .get(1)? + .as_str() + .map(str::to_string) +} + +fn has_marker(event: &serde_json::Value, marker: &str) -> bool { + event + .get("tags") + .and_then(serde_json::Value::as_array) + .is_some_and(|tags| { + tags.iter().any(|tag| { + tag.as_array().is_some_and(|parts| { + parts.len() == 1 + && parts.first().and_then(serde_json::Value::as_str) == Some(marker) + }) + }) + }) +} diff --git a/crates/buzz-client/src/client.rs b/crates/buzz-client/src/client.rs new file mode 100644 index 0000000000..ca3bbac74b --- /dev/null +++ b/crates/buzz-client/src/client.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; + +use nostr::{NostrSigner, PublicKey}; + +use crate::{AuthContext, ClientConfig, ClientError, CommunityEndpoint}; + +/// Client session bound to one Buzz community and signer. +#[derive(Debug)] +pub struct BuzzClient { + endpoint: CommunityEndpoint, + pub(crate) signer: Arc, + public_key: PublicKey, + pub(crate) auth: AuthContext, + config: ClientConfig, + pub(crate) http: reqwest::Client, +} + +impl BuzzClient { + /// Start constructing a client for one community with an asynchronous signer. + pub fn builder(endpoint: CommunityEndpoint, signer: Arc) -> BuzzClientBuilder { + BuzzClientBuilder { + endpoint, + signer, + auth: AuthContext::default(), + config: ClientConfig::default(), + } + } + + /// Configured community endpoint. + pub fn endpoint(&self) -> &CommunityEndpoint { + &self.endpoint + } + + /// Public key obtained and cached from the asynchronous signer. + pub fn public_key(&self) -> PublicKey { + self.public_key + } + + /// Explicit session configuration. + pub fn config(&self) -> &ClientConfig { + &self.config + } + + /// Signer-bound authentication context applied by this session. + pub fn auth_context(&self) -> &AuthContext { + &self.auth + } + + /// Backend class of the configured asynchronous signer. + pub fn signer_backend(&self) -> nostr::signer::SignerBackend<'_> { + self.signer.backend() + } +} + +/// Asynchronous builder for a [`BuzzClient`]. +#[derive(Debug)] +pub struct BuzzClientBuilder { + endpoint: CommunityEndpoint, + signer: Arc, + auth: AuthContext, + config: ClientConfig, +} + +impl BuzzClientBuilder { + /// Set signer-bound authentication material. + pub fn auth_context(mut self, auth: AuthContext) -> Self { + self.auth = auth; + self + } + + /// Set explicit timeouts and bounded retry settings. + pub fn config(mut self, config: ClientConfig) -> Self { + self.config = config; + self + } + + /// Validate the signer, authentication context, and transport configuration. + pub async fn build(self) -> Result { + let public_key = self + .signer + .get_public_key() + .await + .map_err(ClientError::Signer)?; + if let Some(ambient) = self.auth.ambient() { + buzz_sdk::nip_oa::verify_auth_tag(&ambient.json, &public_key) + .map_err(|error| ClientError::Authentication(error.to_string()))?; + } + let http = reqwest::Client::builder() + .timeout(self.config.request_timeout) + .connect_timeout(self.config.connect_timeout) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(ClientError::HttpClient)?; + + Ok(BuzzClient { + endpoint: self.endpoint, + signer: self.signer, + public_key, + auth: self.auth, + config: self.config, + http, + }) + } +} diff --git a/crates/buzz-client/src/config.rs b/crates/buzz-client/src/config.rs new file mode 100644 index 0000000000..cfb9b2fd63 --- /dev/null +++ b/crates/buzz-client/src/config.rs @@ -0,0 +1,134 @@ +use std::time::Duration; + +use crate::ClientError; + +/// Bounded retry settings for authenticated Buzz requests. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RetryPolicy { + pub(crate) max_attempts: u32, + pub(crate) base_delay: Duration, + pub(crate) max_delay: Duration, +} + +impl RetryPolicy { + /// Construct a bounded retry policy. + pub fn new( + max_attempts: u32, + base_delay: Duration, + max_delay: Duration, + ) -> Result { + if !(1..=10).contains(&max_attempts) { + return Err(ClientError::Configuration( + "retry max_attempts must be between 1 and 10".into(), + )); + } + if base_delay.is_zero() { + return Err(ClientError::Configuration( + "retry base_delay must be greater than zero".into(), + )); + } + if max_delay < base_delay { + return Err(ClientError::Configuration( + "retry max_delay must be at least base_delay".into(), + )); + } + if max_delay > Duration::from_secs(300) { + return Err(ClientError::Configuration( + "retry max_delay must not exceed 300 seconds".into(), + )); + } + Ok(Self { + max_attempts, + base_delay, + max_delay, + }) + } + + /// Maximum total attempts, including the initial attempt. + pub fn max_attempts(&self) -> u32 { + self.max_attempts + } + + /// Initial backoff delay before applying exponential growth. + pub fn base_delay(&self) -> Duration { + self.base_delay + } + + /// Maximum delay between attempts. + pub fn max_delay(&self) -> Duration { + self.max_delay + } +} + +impl Default for RetryPolicy { + fn default() -> Self { + Self { + max_attempts: 3, + base_delay: Duration::from_millis(250), + max_delay: Duration::from_secs(10), + } + } +} + +/// Explicit HTTP and retry configuration for a client session. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClientConfig { + pub(crate) request_timeout: Duration, + pub(crate) connect_timeout: Duration, + pub(crate) retry: RetryPolicy, +} + +impl ClientConfig { + /// Construct validated client configuration. + pub fn new( + request_timeout: Duration, + connect_timeout: Duration, + retry: RetryPolicy, + ) -> Result { + if request_timeout.is_zero() { + return Err(ClientError::Configuration( + "request_timeout must be greater than zero".into(), + )); + } + if connect_timeout.is_zero() { + return Err(ClientError::Configuration( + "connect_timeout must be greater than zero".into(), + )); + } + if connect_timeout > request_timeout { + return Err(ClientError::Configuration( + "connect_timeout must not exceed request_timeout".into(), + )); + } + Ok(Self { + request_timeout, + connect_timeout, + retry, + }) + } + + /// Overall HTTP request timeout. + pub fn request_timeout(&self) -> Duration { + self.request_timeout + } + + /// HTTP connection-establishment timeout. + pub fn connect_timeout(&self) -> Duration { + self.connect_timeout + } + + /// Bounded retry policy. + pub fn retry_policy(&self) -> &RetryPolicy { + &self.retry + } +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + request_timeout: Duration::from_secs(30), + connect_timeout: Duration::from_secs(15), + retry: RetryPolicy::default(), + } + } +} diff --git a/crates/buzz-client/src/endpoint.rs b/crates/buzz-client/src/endpoint.rs new file mode 100644 index 0000000000..4d91677004 --- /dev/null +++ b/crates/buzz-client/src/endpoint.rs @@ -0,0 +1,182 @@ +use url::Url; + +/// Failure to parse or validate a Buzz community endpoint. +#[derive(Debug, thiserror::Error)] +pub enum EndpointError { + /// The input is not an absolute URL. + #[error("invalid community URL: {0}")] + InvalidUrl(#[from] url::ParseError), + + /// Only HTTP and WebSocket relay URL schemes are supported. + #[error("unsupported community URL scheme `{0}`; use http, https, ws, or wss")] + UnsupportedScheme(String), + + /// The URL has no host authority. + #[error("community URL must include a host authority")] + MissingHost, + + /// Credentials in community or resource URLs could be disclosed accidentally. + #[error("community URLs must not contain embedded credentials")] + EmbeddedCredentials, + + /// A community base identifies an authority, not a nested path. + #[error("community URL must not contain a path, query, or fragment")] + NonRootBase, + + /// A resource resolves outside the client's bound community authority. + #[error("resource authority `{actual}` does not match community authority `{expected}`")] + CrossAuthority { + /// Normalized authority expected by the client. + expected: String, + /// Normalized authority supplied by the resource. + actual: String, + }, +} + +/// Normalized endpoints for one Buzz community authority. +/// +/// HTTP input is paired with WebSocket input (`http`/`ws` or `https`/`wss`), +/// default ports are normalized, and all derived operation URLs retain the +/// same host, effective port, and transport-security class. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommunityEndpoint { + http_base: Url, + websocket: Url, +} + +impl CommunityEndpoint { + /// Parse and normalize an HTTP or WebSocket community URL. + /// + /// A base URL must identify only an authority: embedded credentials, + /// non-root paths, query strings, and fragments are rejected. + pub fn parse(input: &str) -> Result { + let mut input = Url::parse(input.trim())?; + validate_supported_scheme(input.scheme())?; + validate_credentials(&input)?; + if input.host_str().is_none() { + return Err(EndpointError::MissingHost); + } + if !matches!(input.path(), "" | "/") + || input.query().is_some() + || input.fragment().is_some() + { + return Err(EndpointError::NonRootBase); + } + + let secure = matches!(input.scheme(), "https" | "wss"); + input + .set_scheme(if secure { "https" } else { "http" }) + .map_err(|_| EndpointError::UnsupportedScheme(input.scheme().to_string()))?; + input.set_path("/"); + let mut websocket = input.clone(); + websocket + .set_scheme(if secure { "wss" } else { "ws" }) + .map_err(|_| EndpointError::UnsupportedScheme(input.scheme().to_string()))?; + + Ok(Self { + http_base: input, + websocket, + }) + } + + /// Return the normalized HTTP base URL. + pub fn http_base_url(&self) -> &Url { + &self.http_base + } + + /// Return the normalized WebSocket base URL. + pub fn websocket_url(&self) -> &Url { + &self.websocket + } + + /// Derive the authenticated query endpoint. + pub fn query_url(&self) -> Url { + self.operation_url("query") + } + + /// Derive the stored-event submission endpoint. + pub fn events_url(&self) -> Url { + self.operation_url("events") + } + + /// Derive the media upload endpoint. + pub fn upload_url(&self) -> Url { + self.operation_url("upload") + } + + /// Parse a resource URL and require it to retain this community authority. + /// + /// Both HTTP and WebSocket forms of the configured transport-security + /// class are accepted. A secure client never accepts an insecure resource, + /// even when the host and explicit port happen to match. + pub fn same_authority_url(&self, input: &str) -> Result { + let resource = Url::parse(input.trim())?; + validate_supported_scheme(resource.scheme())?; + validate_credentials(&resource)?; + if resource.host_str().is_none() { + return Err(EndpointError::MissingHost); + } + + let expected = authority_key(&self.http_base); + let actual = authority_key(&resource); + if expected != actual { + return Err(EndpointError::CrossAuthority { + expected: authority_label(&self.http_base), + actual: authority_label(&resource), + }); + } + Ok(resource) + } + + fn operation_url(&self, path: &str) -> Url { + let mut url = self.http_base.clone(); + url.set_path(path); + url + } +} + +fn validate_supported_scheme(scheme: &str) -> Result<(), EndpointError> { + match scheme { + "http" | "https" | "ws" | "wss" => Ok(()), + other => Err(EndpointError::UnsupportedScheme(other.to_string())), + } +} + +fn validate_credentials(url: &Url) -> Result<(), EndpointError> { + if !url.username().is_empty() || url.password().is_some() { + Err(EndpointError::EmbeddedCredentials) + } else { + Ok(()) + } +} + +fn authority_key(url: &Url) -> (bool, &str, Option) { + ( + matches!(url.scheme(), "https" | "wss"), + url.host_str().unwrap_or_default(), + effective_port(url), + ) +} + +fn effective_port(url: &Url) -> Option { + url.port().or_else(|| match url.scheme() { + "http" | "ws" => Some(80), + "https" | "wss" => Some(443), + _ => None, + }) +} + +fn authority_label(url: &Url) -> String { + let security = if matches!(url.scheme(), "https" | "wss") { + "secure" + } else { + "insecure" + }; + format!( + "{security}://{}:{}", + url.host_str().unwrap_or(""), + effective_port(url) + .map(|port| port.to_string()) + .unwrap_or_else(|| "".into()) + ) +} diff --git a/crates/buzz-client/src/error.rs b/crates/buzz-client/src/error.rs new file mode 100644 index 0000000000..191b47139e --- /dev/null +++ b/crates/buzz-client/src/error.rs @@ -0,0 +1,97 @@ +use nostr::EventId; + +use crate::EndpointError; + +/// Typed failure categories returned by reusable Buzz client operations. +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + /// Community endpoint parsing or authority validation failed. + #[error(transparent)] + Endpoint(#[from] EndpointError), + /// Explicit client configuration is invalid. + #[error("invalid client configuration: {0}")] + Configuration(String), + /// Signer-bound authentication is malformed or invalid. + #[error("authentication failed: {0}")] + Authentication(String), + /// The configured signer refused or failed an operation. + #[error("signer failed: {0}")] + Signer(#[source] nostr::signer::SignerError), + /// The HTTP client could not be constructed. + #[error("HTTP client initialization failed: {0}")] + HttpClient(#[source] reqwest::Error), + /// An authenticated network request failed. + #[error("network request failed: {0}")] + Network(#[source] reqwest::Error), + /// The relay definitively rejected or could not fulfill the operation. + #[error("relay returned HTTP {status}: {reason}")] + Relay { + /// HTTP response status. + status: u16, + /// Relay-provided reason. + reason: String, + /// Whether repeating the same operation is classified as safe. + retry_safe: bool, + }, + /// Serialization or response parsing failed. + #[error("serialization failed: {0}")] + Serialization(#[source] serde_json::Error), + /// A domain request is invalid. + #[error("invalid request: {0}")] + InvalidInput(String), +} + +impl ClientError { + /// Whether retrying the same operation is safe under Buzz policy. + pub fn is_retry_safe(&self) -> bool { + matches!( + self, + Self::Relay { + retry_safe: true, + .. + } + ) + } +} + +/// Semantic result of submitting one signed stored event. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DeliveryOutcome { + /// The relay confirmed acceptance. + Accepted(AcceptedDelivery), + /// The relay definitively rejected the event. + Rejected(RejectedDelivery), + /// The final failure cannot establish whether the relay stored the event. + Unknown(DeliveryUnknown), +} + +/// Confirmed stored-event acceptance. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AcceptedDelivery { + /// Identifier of the signed event accepted by the relay. + pub event_id: EventId, + /// Relay-provided acceptance message. + pub relay_message: String, +} + +/// Definitive stored-event rejection. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RejectedDelivery { + /// Identifier of the signed event rejected by the relay. + pub event_id: EventId, + /// HTTP response status, when rejection used the HTTP bridge. + pub status: u16, + /// Relay-provided rejection reason. + pub reason: String, + /// Whether resubmitting the same signed event is safe. + pub retry_safe: bool, +} + +/// Ambiguous stored-event delivery after bounded internal attempts. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeliveryUnknown { + /// Original signed event identifier, unchanged across every attempt. + pub event_id: EventId, + /// Description of the terminal ambiguous failure. + pub reason: String, +} diff --git a/crates/buzz-client/src/lib.rs b/crates/buzz-client/src/lib.rs new file mode 100644 index 0000000000..8dab01b6d6 --- /dev/null +++ b/crates/buzz-client/src/lib.rs @@ -0,0 +1,79 @@ +//! Reusable, authenticated operations for one Buzz community. +//! +//! A [`BuzzClient`] is bound to one validated community authority, one +//! asynchronous Nostr signer, and an explicit authentication context. The +//! crate owns Buzz protocol policy; process arguments, environment variables, +//! local file acquisition, output formatting, and exit codes remain concerns +//! of application adapters such as `buzz-cli`. +//! +//! # Read/write session +//! +//! ```no_run +//! use std::sync::Arc; +//! use buzz_client::{ +//! AuthContext, BuzzClient, CommunityEndpoint, DeliveryOutcome, +//! ListChannelsRequest, MessageKind, SendMessageRequest, +//! }; +//! use nostr::Keys; +//! +//! # async fn run() -> Result<(), Box> { +//! let community = CommunityEndpoint::parse("https://buzz.example")?; +//! let signer = Arc::new(Keys::generate()); +//! let client = BuzzClient::builder(community, signer) +//! .auth_context(AuthContext::signer_only()) +//! .build() +//! .await?; +//! +//! let channels = client +//! .list_channels(ListChannelsRequest::default()) +//! .await?; +//! if let Some(channel) = channels.first() { +//! let result = client +//! .send_message(SendMessageRequest { +//! channel_id: channel.channel_id, +//! content: "hello from another client".into(), +//! kind: MessageKind::Stream, +//! reply_to: None, +//! broadcast: false, +//! attachments: Vec::new(), +//! mentions: Vec::new(), +//! }) +//! .await?; +//! match result.delivery { +//! DeliveryOutcome::Accepted(delivery) => { +//! println!("accepted {}", delivery.event_id); +//! } +//! DeliveryOutcome::Rejected(delivery) => { +//! eprintln!("rejected: {}", delivery.reason); +//! } +//! DeliveryOutcome::Unknown(delivery) => { +//! // Keep this event ID for reconciliation. Do not automatically +//! // create and sign a replacement event. +//! eprintln!("delivery of {} is unknown", delivery.event_id); +//! } +//! } +//! } +//! # Ok(()) +//! # } +//! ``` + +#![forbid(unsafe_code)] + +mod auth; +mod channels; +mod client; +mod config; +mod endpoint; +mod error; +mod messages; +mod transport; + +pub use auth::AuthContext; +pub use channels::{ChannelRecord, ChannelScope, ChannelVisibility, ListChannelsRequest}; +pub use client::{BuzzClient, BuzzClientBuilder}; +pub use config::{ClientConfig, RetryPolicy}; +pub use endpoint::{CommunityEndpoint, EndpointError}; +pub use error::{ + AcceptedDelivery, ClientError, DeliveryOutcome, DeliveryUnknown, RejectedDelivery, +}; +pub use messages::{MessageAttachment, MessageKind, SendMessageRequest, SendMessageResult}; diff --git a/crates/buzz-client/src/messages.rs b/crates/buzz-client/src/messages.rs new file mode 100644 index 0000000000..263636e073 --- /dev/null +++ b/crates/buzz-client/src/messages.rs @@ -0,0 +1,518 @@ +use std::collections::{HashMap, HashSet}; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use bytes::Bytes; +use nostr::{EventBuilder, EventId, JsonUtil, Kind, PublicKey, Tag, Timestamp}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use buzz_sdk::mentions::{ + extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP, +}; +use buzz_sdk::ThreadRef; + +use crate::{BuzzClient, ClientError, DeliveryOutcome}; + +const ALLOWED_MIMES: &[&str] = &[ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "video/mp4", +]; +const MAX_IMAGE_BYTES: usize = 50 * 1024 * 1024; +const MAX_VIDEO_BYTES: usize = 500 * 1024 * 1024; + +/// Message event variant supported by the initial reusable client slice. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MessageKind { + /// NIP-29 stream message (kind 9). + #[default] + Stream, + /// Forum thread root (kind 45001). + ForumPost, + /// Forum reply (kind 45003). + ForumComment, +} + +/// In-memory media acquired by an application adapter for message sending. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MessageAttachment { + /// Complete media bytes. + pub bytes: Bytes, + /// Detected MIME type used for relay upload policy. + pub mime_type: String, +} + +/// Normalized request for one scoped Buzz message. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SendMessageRequest { + /// Target channel in the client's bound community. + pub channel_id: Uuid, + /// Message body before uploaded-media links are appended. + pub content: String, + /// Message event variant. + pub kind: MessageKind, + /// Immediate parent event for a reply. + pub reply_to: Option, + /// Whether a stream message carries the Buzz broadcast marker. + pub broadcast: bool, + /// Media bytes to upload to the bound community. + pub attachments: Vec, + /// Explicit notification identities. + pub mentions: Vec, +} + +/// Message delivery plus identities represented by signed `p` tags. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SendMessageResult { + /// Semantic accepted, rejected, or delivery-unknown result. + pub delivery: DeliveryOutcome, + /// Deduplicated identities represented in the signed event. + pub mention_pubkeys: Vec, +} + +#[derive(Debug, Deserialize)] +struct BlobDescriptor { + url: String, + sha256: String, + size: u64, + #[serde(rename = "type")] + mime_type: String, + #[serde(default)] + dim: Option, + #[serde(default)] + blurhash: Option, + #[serde(default)] + thumb: Option, + #[serde(default)] + duration: Option, +} + +impl BuzzClient { + /// Resolve, build, sign once, and submit a message to the bound community. + pub async fn send_message( + &self, + request: SendMessageRequest, + ) -> Result { + if request.content.len() > 64 * 1024 { + return Err(ClientError::InvalidInput( + "message content exceeds 65536 bytes".into(), + )); + } + if request.kind == MessageKind::ForumComment && request.reply_to.is_none() { + return Err(ClientError::InvalidInput( + "reply_to is required for forum comments".into(), + )); + } + + let mention_pubkeys = self.resolve_mentions(&request).await?; + let mut media_tags = Vec::new(); + let mut media_content = String::new(); + for attachment in &request.attachments { + let descriptor = self.upload_attachment(attachment).await?; + media_tags.push(imeta_tag(&descriptor)); + media_content.push_str(if descriptor.mime_type.starts_with("video/") { + "\n![video](" + } else { + "\n![image](" + }); + media_content.push_str(&descriptor.url); + media_content.push(')'); + } + let content = format!("{}{media_content}", request.content); + let thread = match request.reply_to { + Some(parent) => Some(self.resolve_thread(parent).await?), + None => None, + }; + let mention_hex: Vec = mention_pubkeys.iter().map(PublicKey::to_hex).collect(); + let mention_refs: Vec<&str> = mention_hex.iter().map(String::as_str).collect(); + let builder = match request.kind { + MessageKind::Stream => buzz_sdk::build_message( + request.channel_id, + &content, + thread.as_ref(), + &mention_refs, + request.broadcast, + &media_tags, + ), + MessageKind::ForumPost => { + buzz_sdk::build_forum_post(request.channel_id, &content, &mention_refs, &media_tags) + } + MessageKind::ForumComment => buzz_sdk::build_forum_comment( + request.channel_id, + &content, + thread.as_ref().ok_or_else(|| { + ClientError::InvalidInput("reply_to is required for forum comments".into()) + })?, + &mention_refs, + &media_tags, + ), + } + .map_err(|error| ClientError::InvalidInput(error.to_string()))?; + let event = self.sign_builder(builder).await?; + let emitted_mentions = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("p")) + .then(|| parts.get(1)) + .flatten() + .and_then(|value| PublicKey::from_hex(value).ok()) + }) + .collect(); + let delivery = self.submit_stored_event(event).await?; + Ok(SendMessageResult { + delivery, + mention_pubkeys: emitted_mentions, + }) + } + + async fn resolve_mentions( + &self, + request: &SendMessageRequest, + ) -> Result, ClientError> { + let stripped = strip_code_regions(&request.content); + let mut mentions = request.mentions.clone(); + for uri in extract_nostr_uris(&stripped) { + if let Ok(pubkey) = PublicKey::from_hex(&uri) { + if !mentions.contains(&pubkey) { + mentions.push(pubkey); + } + } + } + let has_explicit = !mentions.is_empty(); + if !stripped.contains('@') && !has_explicit { + return Ok(mentions); + } + let membership_events = self + .query_filters(&[serde_json::json!({ + "kinds": [39002], + "#d": [request.channel_id.to_string()], + "limit": 1, + })]) + .await?; + let members = membership_events + .first() + .map(member_pubkeys) + .unwrap_or_default(); + + if stripped.contains('@') { + let authors: Vec = members.iter().map(PublicKey::to_hex).collect(); + let profiles = self + .query_filters(&[serde_json::json!({ + "kinds": [0], + "authors": authors, + "limit": members.len(), + })]) + .await?; + let (names, display_names) = profile_names(&profiles); + let known: Vec<&str> = display_names.iter().map(String::as_str).collect(); + for name in extract_at_mentions_with_known(&stripped, &known) { + match names.get(&name) { + Some(candidates) if candidates.len() == 1 => { + if !mentions.contains(&candidates[0]) { + mentions.push(candidates[0]); + } + } + None if has_explicit => {} + Some(_) if has_explicit => {} + None => { + return Err(ClientError::InvalidInput(format!( + "mention '@{name}' does not match a current channel member; retry with an explicit pubkey" + ))); + } + Some(candidates) => { + return Err(ClientError::InvalidInput(format!( + "mention '@{name}' is ambiguous; candidates: {}", + candidates + .iter() + .map(PublicKey::to_hex) + .collect::>() + .join(", ") + ))); + } + } + } + } + if mentions.len() > MENTION_CAP { + return Err(ClientError::InvalidInput(format!( + "too many unique message mentions (max {MENTION_CAP})" + ))); + } + let member_set: HashSet = members.into_iter().collect(); + let missing: Vec = mentions + .iter() + .filter(|pubkey| !member_set.contains(pubkey)) + .map(PublicKey::to_hex) + .collect(); + if !missing.is_empty() { + return Err(ClientError::InvalidInput( + serde_json::json!({ + "message": "mentioned pubkeys are not channel members; add them explicitly before retrying", + "missing_member_pubkeys": missing, + "add_member_command": format!( + "buzz channels add-member --channel {} --pubkey --role ", + request.channel_id + ), + }) + .to_string(), + )); + } + Ok(mentions) + } + + async fn resolve_thread(&self, parent: EventId) -> Result { + let parent_hex = parent.to_hex(); + let events = self + .query_filters(&[serde_json::json!({"ids": [parent_hex], "limit": 1})]) + .await?; + let event = events.first().ok_or_else(|| { + ClientError::InvalidInput(format!("parent event {parent_hex} not found")) + })?; + let root = thread_root(event) + .and_then(|hex| EventId::from_hex(hex).ok()) + .filter(|root| *root != parent) + .unwrap_or(parent); + Ok(ThreadRef { + root_event_id: root, + parent_event_id: parent, + }) + } + + async fn upload_attachment( + &self, + attachment: &MessageAttachment, + ) -> Result { + if !ALLOWED_MIMES.contains(&attachment.mime_type.as_str()) { + return Err(ClientError::InvalidInput(format!( + "unsupported file type: {}", + attachment.mime_type + ))); + } + let max = if attachment.mime_type.starts_with("video/") { + MAX_VIDEO_BYTES + } else { + MAX_IMAGE_BYTES + }; + if attachment.bytes.len() > max { + return Err(ClientError::InvalidInput(format!( + "file too large: {} bytes (max {max})", + attachment.bytes.len() + ))); + } + let sha256 = hex::encode(Sha256::digest(&attachment.bytes)); + let primary = self.endpoint().upload_url(); + match self.upload_to(&primary, attachment, &sha256).await { + Err(ClientError::Relay { + status: 404 | 405, .. + }) => { + let mut legacy = self.endpoint().http_base_url().clone(); + legacy.set_path("/media/upload"); + self.upload_to(&legacy, attachment, &sha256).await + } + result => result, + } + } + + async fn upload_to( + &self, + url: &url::Url, + attachment: &MessageAttachment, + sha256: &str, + ) -> Result { + let attempts = self.config().retry_policy().max_attempts(); + for attempt in 0..attempts { + let auth = self + .blossom_upload_header(sha256, &attachment.mime_type) + .await?; + let mut request = self + .http + .put(url.clone()) + .header("Authorization", auth) + .header("Content-Type", &attachment.mime_type) + .header("X-SHA-256", sha256) + .body(attachment.bytes.clone()); + if let Some(ambient) = self.auth.ambient() { + request = request.header("x-auth-tag", &ambient.json); + } + match request.send().await { + Err(_source) if attempt + 1 < attempts => { + self.sleep_before_retry(attempt).await; + } + Err(source) => return Err(ClientError::Network(source)), + Ok(response) => { + let status = response.status().as_u16(); + let text = response.text().await.map_err(ClientError::Network)?; + if (200..300).contains(&status) { + let descriptor: BlobDescriptor = + serde_json::from_str(&text).map_err(ClientError::Serialization)?; + self.endpoint().same_authority_url(&descriptor.url)?; + return Ok(descriptor); + } + if attempt + 1 < attempts && matches!(status, 429 | 502 | 503 | 504) { + self.sleep_before_retry(attempt).await; + continue; + } + return Err(ClientError::Relay { + status, + reason: relay_reason(&text), + retry_safe: matches!(status, 429 | 502 | 503 | 504), + }); + } + } + } + Err(ClientError::Configuration( + "retry policy allowed no upload attempts".into(), + )) + } + + async fn blossom_upload_header( + &self, + sha256: &str, + mime_type: &str, + ) -> Result { + let expiry = if mime_type.starts_with("video/") { + 3600 + } else { + 600 + }; + let expiration = (Timestamp::now().as_secs() + expiry).to_string(); + let server = + self.endpoint().http_base_url().host_str().ok_or_else(|| { + ClientError::InvalidInput("community endpoint has no host".into()) + })?; + let tags = vec![ + parse_tag(["t", "upload"])?, + parse_tag(["x", sha256])?, + parse_tag(["expiration", expiration.as_str()])?, + parse_tag(["server", server])?, + ]; + let unsigned = EventBuilder::new(Kind::Custom(24242), "Upload file") + .tags(tags) + .build(self.public_key()); + let event = self + .signer + .sign_event(unsigned) + .await + .map_err(ClientError::Signer)?; + Ok(format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(event.as_json().as_bytes()) + )) + } +} + +fn member_pubkeys(event: &serde_json::Value) -> Vec { + event + .get("tags") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(serde_json::Value::as_array) + .filter(|parts| parts.first().and_then(serde_json::Value::as_str) == Some("p")) + .filter_map(|parts| parts.get(1).and_then(serde_json::Value::as_str)) + .filter_map(|hex| PublicKey::from_hex(hex).ok()) + .collect() +} + +fn profile_names(events: &[serde_json::Value]) -> (HashMap>, Vec) { + let mut names: HashMap> = HashMap::new(); + let mut display_names = Vec::new(); + for event in events { + let Some(pubkey) = event + .get("pubkey") + .and_then(serde_json::Value::as_str) + .and_then(|hex| PublicKey::from_hex(hex).ok()) + else { + continue; + }; + let Some(profile) = event + .get("content") + .and_then(serde_json::Value::as_str) + .and_then(|content| serde_json::from_str::(content).ok()) + else { + continue; + }; + let Some(name) = profile + .get("display_name") + .or_else(|| profile.get("name")) + .and_then(serde_json::Value::as_str) + .filter(|name| !name.is_empty()) + else { + continue; + }; + names + .entry(name.to_ascii_lowercase()) + .or_default() + .push(pubkey); + display_names.push(name.to_string()); + } + (names, display_names) +} + +fn thread_root(event: &serde_json::Value) -> Option<&str> { + let mut root = None; + let mut reply = None; + for parts in event + .get("tags")? + .as_array()? + .iter() + .filter_map(serde_json::Value::as_array) + { + if parts.len() < 4 || parts.first().and_then(serde_json::Value::as_str) != Some("e") { + continue; + } + let id = parts.get(1).and_then(serde_json::Value::as_str); + match (parts.get(3).and_then(serde_json::Value::as_str), id) { + (Some("root"), Some(id)) => root = Some(id), + (Some("reply"), Some(id)) => reply = Some(id), + _ => {} + } + } + root.or(reply) +} + +fn imeta_tag(descriptor: &BlobDescriptor) -> Vec { + let mut tag = vec![ + "imeta".to_string(), + format!("url {}", descriptor.url), + format!("m {}", descriptor.mime_type), + format!("x {}", descriptor.sha256), + format!("size {}", descriptor.size), + ]; + if let Some(dim) = &descriptor.dim { + tag.push(format!("dim {dim}")); + } + if let Some(blurhash) = &descriptor.blurhash { + tag.push(format!("blurhash {blurhash}")); + } + if let Some(thumb) = &descriptor.thumb { + tag.push(format!("thumb {thumb}")); + } + if let Some(duration) = descriptor.duration { + tag.push(format!("duration {duration}")); + } + tag +} + +fn parse_tag(parts: [&str; N]) -> Result { + Tag::parse(parts).map_err(|error| ClientError::InvalidInput(error.to_string())) +} + +fn relay_reason(body: &str) -> String { + serde_json::from_str::(body) + .ok() + .and_then(|value| { + value + .get("error") + .or_else(|| value.get("message")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| body.to_string()) +} diff --git a/crates/buzz-client/src/transport.rs b/crates/buzz-client/src/transport.rs new file mode 100644 index 0000000000..65c20a9a4f --- /dev/null +++ b/crates/buzz-client/src/transport.rs @@ -0,0 +1,319 @@ +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine; +use bytes::Bytes; +use nostr::{Event, EventBuilder, JsonUtil, Kind, Tag}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; + +use crate::{ + AcceptedDelivery, BuzzClient, ClientError, DeliveryOutcome, DeliveryUnknown, RejectedDelivery, +}; + +const QUERY_PAGE_SIZE: u32 = 500; + +impl BuzzClient { + pub(crate) async fn sign_builder(&self, builder: EventBuilder) -> Result { + let builder = match self.auth_context().nip_oa_tag() { + Some(tag) => builder.tags([tag.clone()]), + None => builder, + }; + let unsigned = builder.build(self.public_key()); + let event = self + .signer + .sign_event(unsigned) + .await + .map_err(ClientError::Signer)?; + let auth_count = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("auth")) + .count(); + let expected = usize::from(self.auth_context().has_nip_oa()); + if auth_count != expected { + return Err(ClientError::InvalidInput(format!( + "signed event contains {auth_count} ambient auth tags; expected {expected}" + ))); + } + Ok(event) + } + + pub(crate) async fn query_paginated( + &self, + mut filter: serde_json::Value, + limit: u32, + ) -> Result, ClientError> { + let mut events = Vec::new(); + let mut seen = HashSet::new(); + while events.len() < limit as usize { + let page_limit = (limit as usize - events.len()).min(QUERY_PAGE_SIZE as usize); + filter["limit"] = serde_json::json!(page_limit); + let page = self.query_filters(&[filter.clone()]).await?; + let full_page = page.len() == page_limit; + if full_page { + advance_query_cursor(&mut filter, &page)?; + } + for event in page { + let id = event + .get("id") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + if id.as_ref().is_none_or(|id| seen.insert(id.clone())) { + events.push(event); + if events.len() == limit as usize { + break; + } + } + } + if !full_page { + break; + } + } + Ok(events) + } + + pub(crate) async fn query_filters( + &self, + filters: &[serde_json::Value], + ) -> Result, ClientError> { + let body = Bytes::from(serde_json::to_vec(filters).map_err(ClientError::Serialization)?); + let url = self.endpoint().query_url(); + let attempts = self.config().retry_policy().max_attempts(); + for attempt in 0..attempts { + let auth = self.nip98_header("POST", url.as_str(), Some(&body)).await?; + let mut request = self + .http + .post(url.clone()) + .header("Authorization", auth) + .header("Content-Type", "application/json") + .body(body.clone()); + if let Some(ambient) = self.auth.ambient() { + request = request.header("x-auth-tag", &ambient.json); + } + match request.send().await { + Err(source) if attempt + 1 < attempts && is_retryable_network(&source) => { + self.sleep_before_retry(attempt).await; + } + Err(source) => return Err(ClientError::Network(source)), + Ok(response) => { + let status = response.status().as_u16(); + let text = match response.text().await { + Ok(text) => text, + Err(source) if attempt + 1 < attempts && is_retryable_network(&source) => { + self.sleep_before_retry(attempt).await; + continue; + } + Err(source) => return Err(ClientError::Network(source)), + }; + if (200..300).contains(&status) { + return serde_json::from_str(&text).map_err(ClientError::Serialization); + } + if attempt + 1 < attempts && is_retryable_status(status) { + self.sleep_before_retry(attempt).await; + continue; + } + return Err(ClientError::Relay { + status, + reason: relay_reason(&text), + retry_safe: is_retryable_status(status), + }); + } + } + } + Err(ClientError::Configuration( + "retry policy allowed no query attempts".into(), + )) + } + + pub(crate) async fn submit_stored_event( + &self, + event: Event, + ) -> Result { + let event_id = event.id; + let body = Bytes::from(serde_json::to_vec(&event).map_err(ClientError::Serialization)?); + let url = self.endpoint().events_url(); + let attempts = self.config().retry_policy().max_attempts(); + for attempt in 0..attempts { + let auth = self.nip98_header("POST", url.as_str(), Some(&body)).await?; + let mut request = self + .http + .post(url.clone()) + .header("Authorization", auth) + .header("Content-Type", "application/json") + .body(body.clone()); + if let Some(ambient) = self.auth.ambient() { + request = request.header("x-auth-tag", &ambient.json); + } + let response = match request.send().await { + Err(source) if attempt + 1 < attempts && is_retryable_network(&source) => { + self.sleep_before_retry(attempt).await; + continue; + } + Err(source) if source.is_connect() => return Err(ClientError::Network(source)), + Err(source) => { + return Ok(DeliveryOutcome::Unknown(DeliveryUnknown { + event_id, + reason: source.to_string(), + })); + } + Ok(response) => response, + }; + + let status = response.status().as_u16(); + let text = match response.text().await { + Ok(text) => text, + Err(source) if attempt + 1 < attempts && is_retryable_network(&source) => { + self.sleep_before_retry(attempt).await; + continue; + } + Err(source) => { + return Ok(DeliveryOutcome::Unknown(DeliveryUnknown { + event_id, + reason: source.to_string(), + })); + } + }; + if (200..300).contains(&status) { + let parsed: serde_json::Value = match serde_json::from_str(&text) { + Ok(parsed) => parsed, + Err(source) => { + return Ok(DeliveryOutcome::Unknown(DeliveryUnknown { + event_id, + reason: format!("invalid relay success response: {source}"), + })); + } + }; + let accepted = parsed + .get("accepted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let message = parsed + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + return if accepted { + Ok(DeliveryOutcome::Accepted(AcceptedDelivery { + event_id, + relay_message: message, + })) + } else { + Ok(DeliveryOutcome::Rejected(RejectedDelivery { + event_id, + status, + reason: message, + retry_safe: false, + })) + }; + } + if attempt + 1 < attempts && is_retryable_status(status) { + self.sleep_before_retry(attempt).await; + continue; + } + if matches!(status, 502..=504) { + return Ok(DeliveryOutcome::Unknown(DeliveryUnknown { + event_id, + reason: format!("HTTP {status}: {}", relay_reason(&text)), + })); + } + return Ok(DeliveryOutcome::Rejected(RejectedDelivery { + event_id, + status, + reason: relay_reason(&text), + retry_safe: status == 429, + })); + } + Err(ClientError::Configuration( + "retry policy allowed no submission attempts".into(), + )) + } + + async fn nip98_header( + &self, + method: &str, + url: &str, + body: Option<&[u8]>, + ) -> Result { + let nonce = uuid::Uuid::new_v4().to_string(); + let mut tags = vec![ + parse_tag(["u", url])?, + parse_tag(["method", method])?, + parse_tag(["nonce", nonce.as_str()])?, + ]; + if let Some(body) = body { + let hash = hex::encode(Sha256::digest(body)); + tags.push(parse_tag(["payload", hash.as_str()])?); + } + let unsigned = EventBuilder::new(Kind::Custom(27235), "") + .tags(tags) + .build(self.public_key()); + let event = self + .signer + .sign_event(unsigned) + .await + .map_err(ClientError::Signer)?; + Ok(format!( + "Nostr {}", + BASE64_STANDARD.encode(event.as_json().as_bytes()) + )) + } + + pub(crate) async fn sleep_before_retry(&self, attempt: u32) { + let retry = self.config().retry_policy(); + let multiplier = 1_u32.checked_shl(attempt).unwrap_or(u32::MAX); + let delay = retry + .base_delay() + .saturating_mul(multiplier) + .min(retry.max_delay()); + tokio::time::sleep(delay).await; + } +} + +fn parse_tag(parts: [&str; N]) -> Result { + Tag::parse(parts).map_err(|error| ClientError::InvalidInput(error.to_string())) +} + +fn advance_query_cursor( + filter: &mut serde_json::Value, + page: &[serde_json::Value], +) -> Result<(), ClientError> { + let last = page + .last() + .ok_or_else(|| ClientError::InvalidInput("full query page is empty".into()))?; + let created_at = last + .get("created_at") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| ClientError::InvalidInput("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(|character| character.is_ascii_hexdigit())) + .ok_or_else(|| ClientError::InvalidInput("query event missing valid id".into()))?; + filter["until"] = serde_json::json!(created_at); + filter["before_id"] = serde_json::json!(id); + Ok(()) +} + +fn is_retryable_network(error: &reqwest::Error) -> bool { + error.is_connect() + || error.is_timeout() + || error.is_request() + || error.is_body() + || error.is_decode() +} + +fn is_retryable_status(status: u16) -> bool { + matches!(status, 429 | 502 | 503 | 504) +} + +fn relay_reason(body: &str) -> String { + serde_json::from_str::(body) + .ok() + .and_then(|value| { + value + .get("error") + .or_else(|| value.get("message")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| body.to_string()) +} diff --git a/crates/buzz-client/tests/channel_listing.rs b/crates/buzz-client/tests/channel_listing.rs new file mode 100644 index 0000000000..fe49465bb8 --- /dev/null +++ b/crates/buzz-client/tests/channel_listing.rs @@ -0,0 +1,328 @@ +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::extract::State; +use axum::http::HeaderMap; +use axum::routing::post; +use axum::{Json, Router}; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine; +use buzz_client::{ + AuthContext, BuzzClient, ChannelScope, ChannelVisibility, ClientConfig, ClientError, + CommunityEndpoint, ListChannelsRequest, RetryPolicy, +}; +use nostr::{Event, JsonUtil, Keys}; +use serde_json::{json, Value}; + +#[derive(Clone, Default)] +struct Capture { + headers: Arc>>, + filters: Arc>>, +} + +async fn query( + State(capture): State, + headers: HeaderMap, + Json(filters): Json, +) -> Json { + capture.headers.lock().expect("headers lock").push(headers); + capture.filters.lock().expect("filters lock").push(filters); + Json(json!([{ + "id": "a".repeat(64), + "pubkey": "b".repeat(64), + "created_at": 42, + "kind": 39000, + "content": "", + "tags": [ + ["d", "11111111-1111-1111-1111-111111111111"], + ["name", "general"], + ["about", "General discussion"], + ["public"] + ], + "sig": "c".repeat(128) + }])) +} + +#[tokio::test] +async fn listing_uses_async_nip98_and_exactly_one_ambient_header() { + let capture = Capture::default(); + let app = Router::new() + .route("/query", post(query)) + .with_state(capture.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("test relay"); + }); + + let owner = Keys::generate(); + let agent = Keys::generate(); + let auth_json = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "kind=9") + .expect("auth tag"); + let client = BuzzClient::builder( + CommunityEndpoint::parse(&format!("http://{address}")).expect("endpoint"), + Arc::new(agent.clone()), + ) + .auth_context(AuthContext::nip_oa(&auth_json).expect("auth context")) + .build() + .await + .expect("client"); + + let channels = client + .list_channels(ListChannelsRequest { + scope: ChannelScope::Visible, + visibility: Some(ChannelVisibility::Open), + limit: 10, + }) + .await + .expect("channel list"); + assert_eq!(channels.len(), 1); + assert_eq!(channels[0].name, "general"); + assert_eq!( + channels[0].description.as_deref(), + Some("General discussion") + ); + + let headers = capture.headers.lock().expect("headers lock"); + assert_eq!(headers.len(), 1); + assert_eq!( + headers[0] + .get("x-auth-tag") + .and_then(|value| value.to_str().ok()), + Some(auth_json.as_str()) + ); + let authorization = headers[0] + .get("authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Nostr ")) + .expect("NIP-98 authorization"); + let event_json = BASE64_STANDARD + .decode(authorization) + .expect("base64 authorization"); + let event = Event::from_json(event_json).expect("NIP-98 event"); + event.verify().expect("valid NIP-98 signature"); + assert_eq!(event.pubkey, agent.public_key()); + assert_eq!(event.kind.as_u16(), 27235); + assert_eq!( + event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("auth")) + .count(), + 0 + ); +} + +#[tokio::test] +async fn equal_timestamp_pages_advance_with_composite_cursor() { + let filters = Arc::new(Mutex::new(Vec::::new())); + let captured_filters = filters.clone(); + let first_page: Vec = (0_u64..500) + .map(|index| { + json!({ + "id": format!("{index:064x}"), + "created_at": 1000, + "kind": 39000, + "tags": [ + ["d", format!("00000000-0000-0000-0000-{index:012}")], + ["name", format!("channel-{index}")], + ["public"] + ] + }) + }) + .collect(); + let second_page = vec![json!({ + "id": format!("{:064x}", 500_u64), + "created_at": 1000, + "kind": 39000, + "tags": [ + ["d", "00000000-0000-0000-0000-000000000500"], + ["name", "channel-500"], + ["public"] + ] + })]; + let app = Router::new().route( + "/query", + post(move |Json(filter): Json| { + let captured_filters = captured_filters.clone(); + let first_page = first_page.clone(); + let second_page = second_page.clone(); + async move { + let paginated = filter[0].get("before_id").is_some(); + captured_filters.lock().expect("filters lock").push(filter); + Json(if paginated { second_page } else { first_page }) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("test relay"); + }); + let client = BuzzClient::builder( + CommunityEndpoint::parse(&format!("http://{address}")).expect("endpoint"), + Arc::new(Keys::generate()), + ) + .build() + .await + .expect("client"); + + let channels = client + .list_channels(ListChannelsRequest { + limit: 501, + ..ListChannelsRequest::default() + }) + .await + .expect("channel list"); + assert_eq!(channels.len(), 501); + let filters = filters.lock().expect("filters lock"); + assert_eq!(filters.len(), 2); + assert_eq!(filters[1][0]["until"], 1000); + assert_eq!(filters[1][0]["before_id"], format!("{:064x}", 499_u64)); +} + +#[tokio::test] +async fn member_scope_deduplicates_membership_ids_before_metadata_query() { + let keys = Keys::generate(); + let public_key = keys.public_key().to_hex(); + let filters = Arc::new(Mutex::new(Vec::::new())); + let captured_filters = filters.clone(); + let app = Router::new().route( + "/query", + post(move |Json(filter): Json| { + let captured_filters = captured_filters.clone(); + let public_key = public_key.clone(); + async move { + let membership = filter[0]["kinds"] == json!([39002]); + captured_filters + .lock() + .expect("filters lock") + .push(filter); + if membership { + Json(json!([ + { + "id": "a".repeat(64), + "created_at": 2, + "tags": [["d", "11111111-1111-1111-1111-111111111111"], ["p", public_key]] + }, + { + "id": "b".repeat(64), + "created_at": 1, + "tags": [["d", "11111111-1111-1111-1111-111111111111"], ["p", public_key]] + } + ])) + } else { + Json(json!([{ + "id": "c".repeat(64), + "created_at": 3, + "tags": [ + ["d", "11111111-1111-1111-1111-111111111111"], + ["name", "member-channel"], + ["private"] + ] + }])) + } + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("test relay"); + }); + let client = BuzzClient::builder( + CommunityEndpoint::parse(&format!("http://{address}")).expect("endpoint"), + Arc::new(keys), + ) + .build() + .await + .expect("client"); + + let channels = client + .list_channels(ListChannelsRequest { + scope: ChannelScope::Member, + visibility: Some(ChannelVisibility::Private), + limit: 10, + }) + .await + .expect("member channels"); + assert_eq!(channels.len(), 1); + assert_eq!(channels[0].name, "member-channel"); + let filters = filters.lock().expect("filters lock"); + assert_eq!(filters.len(), 2); + assert_eq!( + filters[1][0]["#d"], + json!(["11111111-1111-1111-1111-111111111111"]) + ); +} + +#[tokio::test] +async fn relay_rejection_preserves_status_reason_and_retry_classification() { + let app = Router::new().route( + "/query", + post(|| async { + ( + axum::http::StatusCode::FORBIDDEN, + Json(json!({"error": "membership required"})), + ) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("test relay"); + }); + let client = BuzzClient::builder( + CommunityEndpoint::parse(&format!("http://{address}")).expect("endpoint"), + Arc::new(Keys::generate()), + ) + .build() + .await + .expect("client"); + + let error = client + .list_channels(ListChannelsRequest::default()) + .await + .expect_err("forbidden query must fail"); + assert!(matches!( + error, + ClientError::Relay { + status: 403, + reason, + retry_safe: false, + } if reason == "membership required" + )); +} + +#[tokio::test] +async fn pre_relay_channel_network_failure_is_typed() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve address"); + let address = listener.local_addr().expect("address"); + drop(listener); + let retry = RetryPolicy::new(1, Duration::from_millis(1), Duration::from_millis(1)) + .expect("retry policy"); + let config = ClientConfig::new(Duration::from_secs(1), Duration::from_secs(1), retry) + .expect("client config"); + let client = BuzzClient::builder( + CommunityEndpoint::parse(&format!("http://{address}")).expect("endpoint"), + Arc::new(Keys::generate()), + ) + .config(config) + .build() + .await + .expect("client"); + + let error = client + .list_channels(ListChannelsRequest::default()) + .await + .expect_err("unreachable relay must fail"); + assert!(matches!(error, ClientError::Network(_))); +} diff --git a/crates/buzz-client/tests/client_construction.rs b/crates/buzz-client/tests/client_construction.rs new file mode 100644 index 0000000000..b2771c5064 --- /dev/null +++ b/crates/buzz-client/tests/client_construction.rs @@ -0,0 +1,142 @@ +use std::sync::Arc; +use std::time::Duration; + +use buzz_client::{ + AuthContext, BuzzClient, ClientConfig, ClientError, CommunityEndpoint, RetryPolicy, +}; +use nostr::signer::SignerBackend; +use nostr::util::BoxedFuture; +use nostr::{Event, Keys, NostrSigner, PublicKey, SignerError, UnsignedEvent}; + +#[derive(Debug)] +struct FailingSigner; + +impl NostrSigner for FailingSigner { + fn backend(&self) -> SignerBackend<'_> { + SignerBackend::Custom("failing-test-signer".into()) + } + + fn get_public_key(&self) -> BoxedFuture<'_, Result> { + Box::pin(async { Err(SignerError::from("public key unavailable")) }) + } + + fn sign_event(&self, _unsigned: UnsignedEvent) -> BoxedFuture<'_, Result> { + Box::pin(async { Err(SignerError::from("signing unavailable")) }) + } + + fn nip04_encrypt<'a>( + &'a self, + _public_key: &'a PublicKey, + _content: &'a str, + ) -> BoxedFuture<'a, Result> { + Box::pin(async { Err(SignerError::from("NIP-04 unavailable")) }) + } + + fn nip04_decrypt<'a>( + &'a self, + _public_key: &'a PublicKey, + _content: &'a str, + ) -> BoxedFuture<'a, Result> { + Box::pin(async { Err(SignerError::from("NIP-04 unavailable")) }) + } + + fn nip44_encrypt<'a>( + &'a self, + _public_key: &'a PublicKey, + _content: &'a str, + ) -> BoxedFuture<'a, Result> { + Box::pin(async { Err(SignerError::from("NIP-44 unavailable")) }) + } + + fn nip44_decrypt<'a>( + &'a self, + _public_key: &'a PublicKey, + _payload: &'a str, + ) -> BoxedFuture<'a, Result> { + Box::pin(async { Err(SignerError::from("NIP-44 unavailable")) }) + } +} + +#[test] +fn config_and_retry_bounds_are_validated_without_environment_lookup() { + let retry = RetryPolicy::new(4, Duration::from_millis(20), Duration::from_millis(200)) + .expect("valid retry policy"); + assert_eq!(retry.max_attempts(), 4); + let config = ClientConfig::new(Duration::from_secs(20), Duration::from_secs(5), retry) + .expect("valid client config"); + assert_eq!(config.request_timeout(), Duration::from_secs(20)); + assert_eq!(config.connect_timeout(), Duration::from_secs(5)); + + assert!(RetryPolicy::new(0, Duration::from_millis(1), Duration::from_secs(1)).is_err()); + assert!(RetryPolicy::new(2, Duration::ZERO, Duration::from_secs(1)).is_err()); + assert!(RetryPolicy::new(2, Duration::from_secs(2), Duration::from_secs(1)).is_err()); + assert!(ClientConfig::new( + Duration::ZERO, + Duration::from_secs(1), + RetryPolicy::default(), + ) + .is_err()); + assert!(ClientConfig::new( + Duration::from_secs(1), + Duration::from_secs(2), + RetryPolicy::default(), + ) + .is_err()); +} + +#[tokio::test] +async fn key_backed_signer_builds_and_caches_its_public_key() { + let keys = Keys::generate(); + let expected = keys.public_key(); + let endpoint = CommunityEndpoint::parse("https://buzz.example").expect("endpoint"); + let client = BuzzClient::builder(endpoint.clone(), Arc::new(keys)) + .build() + .await + .expect("client"); + + assert_eq!(client.public_key(), expected); + assert_eq!(client.endpoint(), &endpoint); + assert_eq!(client.config(), &ClientConfig::default()); +} + +#[tokio::test] +async fn signer_public_key_failure_is_typed() { + let endpoint = CommunityEndpoint::parse("https://buzz.example").expect("endpoint"); + let error = BuzzClient::builder(endpoint, Arc::new(FailingSigner)) + .build() + .await + .expect_err("failing signer must reject construction"); + + assert!(matches!(error, ClientError::Signer(_))); +} + +#[tokio::test] +async fn nip_oa_context_is_validated_against_the_active_signer() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let other_agent = Keys::generate(); + let tag_json = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "kind=9") + .expect("auth tag"); + let auth = AuthContext::nip_oa(&tag_json).expect("parsed auth context"); + assert!(auth.has_nip_oa()); + let endpoint = CommunityEndpoint::parse("https://buzz.example").expect("endpoint"); + + BuzzClient::builder(endpoint.clone(), Arc::new(agent)) + .auth_context(auth.clone()) + .build() + .await + .expect("matching signer"); + + let error = BuzzClient::builder(endpoint, Arc::new(other_agent)) + .auth_context(auth) + .build() + .await + .expect_err("mismatched signer must fail before requests"); + assert!(matches!(error, ClientError::Authentication(_))); +} + +#[test] +fn malformed_nip_oa_context_is_rejected_at_the_input_boundary() { + let error = AuthContext::nip_oa("not JSON").expect_err("malformed auth must fail"); + assert!(matches!(error, ClientError::Authentication(_))); +} diff --git a/crates/buzz-client/tests/community_endpoint.rs b/crates/buzz-client/tests/community_endpoint.rs new file mode 100644 index 0000000000..956a4efbe2 --- /dev/null +++ b/crates/buzz-client/tests/community_endpoint.rs @@ -0,0 +1,85 @@ +use buzz_client::CommunityEndpoint; + +#[test] +fn production_http_and_websocket_schemes_normalize_to_one_authority() { + let from_https = CommunityEndpoint::parse("https://BUZZ.Example:443/") + .expect("valid HTTPS community endpoint"); + assert_eq!(from_https.http_base_url().as_str(), "https://buzz.example/"); + assert_eq!(from_https.websocket_url().as_str(), "wss://buzz.example/"); + assert_eq!( + from_https.query_url().as_str(), + "https://buzz.example/query" + ); + assert_eq!( + from_https.events_url().as_str(), + "https://buzz.example/events" + ); + assert_eq!( + from_https.upload_url().as_str(), + "https://buzz.example/upload" + ); + + let from_wss = + CommunityEndpoint::parse("wss://buzz.example").expect("valid WSS community endpoint"); + assert_eq!(from_wss.http_base_url(), from_https.http_base_url()); + assert_eq!(from_wss.websocket_url(), from_https.websocket_url()); +} + +#[test] +fn development_endpoint_preserves_non_default_effective_port() { + let endpoint = CommunityEndpoint::parse("ws://127.0.0.1:3123/") + .expect("valid development community endpoint"); + assert_eq!(endpoint.http_base_url().as_str(), "http://127.0.0.1:3123/"); + assert_eq!(endpoint.websocket_url().as_str(), "ws://127.0.0.1:3123/"); + assert_eq!( + endpoint + .same_authority_url("http://127.0.0.1:3123/media/a.png") + .expect("same authority media URL") + .as_str(), + "http://127.0.0.1:3123/media/a.png" + ); +} + +#[test] +fn effective_default_ports_are_the_same_authority() { + let endpoint = CommunityEndpoint::parse("https://buzz.example").expect("endpoint"); + endpoint + .same_authority_url("https://buzz.example:443/media/a.png") + .expect("explicit default port is same authority"); + endpoint + .same_authority_url("wss://buzz.example/socket") + .expect("paired secure websocket scheme is same authority"); +} + +#[test] +fn invalid_or_ambiguous_bases_are_rejected() { + for invalid in [ + "not a URL", + "ftp://buzz.example", + "https://user:secret@buzz.example", + "https://buzz.example/community", + "https://buzz.example?tenant=other", + "https://buzz.example/#fragment", + ] { + assert!( + CommunityEndpoint::parse(invalid).is_err(), + "must reject {invalid}" + ); + } +} + +#[test] +fn cross_authority_resources_are_rejected_before_use() { + let endpoint = CommunityEndpoint::parse("https://buzz.example").expect("endpoint"); + for foreign in [ + "https://other.example/media/a.png", + "https://buzz.example:444/media/a.png", + "http://buzz.example/media/a.png", + "https://user@buzz.example/media/a.png", + ] { + assert!( + endpoint.same_authority_url(foreign).is_err(), + "must reject {foreign}" + ); + } +} diff --git a/crates/buzz-client/tests/dependency_boundary.rs b/crates/buzz-client/tests/dependency_boundary.rs new file mode 100644 index 0000000000..24da8d4d05 --- /dev/null +++ b/crates/buzz-client/tests/dependency_boundary.rs @@ -0,0 +1,50 @@ +use std::path::Path; + +const BANNED_DEPENDENCIES: &[&str] = &["buzz-cli", "tauri", "clap", "dotenv", "dirs"]; +const BANNED_SOURCE_PATTERNS: &[&str] = &[ + "std::env::", + "env::var(", + "env::vars(", + "std::io::stdin", + "std::io::stdout", + "std::io::stderr", +]; + +fn rust_sources(path: &Path, sources: &mut Vec) { + for entry in std::fs::read_dir(path).expect("read source directory") { + let path = entry.expect("source entry").path(); + if path.is_dir() { + rust_sources(&path, sources); + } else if path.extension().is_some_and(|extension| extension == "rs") { + sources.push(path); + } + } +} + +#[test] +fn manifest_and_source_keep_the_application_boundary_out() { + let crate_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let manifest = std::fs::read_to_string(crate_root.join("Cargo.toml")).expect("read manifest"); + for dependency in BANNED_DEPENDENCIES { + assert!( + !manifest.lines().any(|line| { + let line = line.trim_start(); + !line.starts_with('#') && line.starts_with(dependency) + }), + "buzz-client must not depend on {dependency}" + ); + } + + let mut sources = Vec::new(); + rust_sources(&crate_root.join("src"), &mut sources); + for source in sources { + let text = std::fs::read_to_string(&source).expect("read Rust source"); + for pattern in BANNED_SOURCE_PATTERNS { + assert!( + !text.contains(pattern), + "{} contains application-only source pattern {pattern}", + source.display() + ); + } + } +} diff --git a/crates/buzz-client/tests/message_delivery.rs b/crates/buzz-client/tests/message_delivery.rs new file mode 100644 index 0000000000..32dd4a53e1 --- /dev/null +++ b/crates/buzz-client/tests/message_delivery.rs @@ -0,0 +1,370 @@ +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use buzz_client::{ + BuzzClient, ClientConfig, ClientError, CommunityEndpoint, DeliveryOutcome, MessageAttachment, + MessageKind, RetryPolicy, SendMessageRequest, +}; +use nostr::signer::SignerBackend; +use nostr::util::BoxedFuture; +use nostr::{Event, JsonUtil, Keys, NostrSigner, PublicKey, SignerError, UnsignedEvent}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use uuid::Uuid; + +fn request() -> SendMessageRequest { + SendMessageRequest { + channel_id: Uuid::parse_str("11111111-1111-1111-1111-111111111111").expect("channel UUID"), + content: "retry me once, sign me once".into(), + kind: MessageKind::Stream, + reply_to: None, + broadcast: false, + attachments: Vec::new(), + mentions: Vec::new(), + } +} + +fn retry_config() -> ClientConfig { + ClientConfig::new( + Duration::from_secs(5), + Duration::from_secs(1), + RetryPolicy::new(3, Duration::from_millis(1), Duration::from_millis(1)) + .expect("retry policy"), + ) + .expect("client config") +} + +#[derive(Debug)] +struct RefusingSigner { + public_key: PublicKey, +} + +impl NostrSigner for RefusingSigner { + fn backend(&self) -> SignerBackend<'_> { + SignerBackend::Custom("refusing-test-signer".into()) + } + + fn get_public_key(&self) -> BoxedFuture<'_, Result> { + Box::pin(async { Ok(self.public_key) }) + } + + fn sign_event(&self, _unsigned: UnsignedEvent) -> BoxedFuture<'_, Result> { + Box::pin(async { Err(SignerError::from("signing refused")) }) + } + + fn nip04_encrypt<'a>( + &'a self, + _public_key: &'a PublicKey, + _content: &'a str, + ) -> BoxedFuture<'a, Result> { + Box::pin(async { Err(SignerError::from("NIP-04 unavailable")) }) + } + + fn nip04_decrypt<'a>( + &'a self, + _public_key: &'a PublicKey, + _content: &'a str, + ) -> BoxedFuture<'a, Result> { + Box::pin(async { Err(SignerError::from("NIP-04 unavailable")) }) + } + + fn nip44_encrypt<'a>( + &'a self, + _public_key: &'a PublicKey, + _content: &'a str, + ) -> BoxedFuture<'a, Result> { + Box::pin(async { Err(SignerError::from("NIP-44 unavailable")) }) + } + + fn nip44_decrypt<'a>( + &'a self, + _public_key: &'a PublicKey, + _payload: &'a str, + ) -> BoxedFuture<'a, Result> { + Box::pin(async { Err(SignerError::from("NIP-44 unavailable")) }) + } +} + +#[tokio::test] +async fn body_loss_retries_identical_event_with_fresh_request_auth() { + let attempts = Arc::new(AtomicU32::new(0)); + let captured_bodies = Arc::new(Mutex::new(Vec::>::new())); + let captured_auth = Arc::new(Mutex::new(Vec::::new())); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + let attempts_for_server = attempts.clone(); + let bodies_for_server = captured_bodies.clone(); + let auth_for_server = captured_auth.clone(); + tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + let attempt = attempts_for_server.fetch_add(1, Ordering::SeqCst) + 1; + let mut bytes = vec![0_u8; 16 * 1024]; + let Ok(Ok(read)) = + tokio::time::timeout(Duration::from_millis(500), stream.read(&mut bytes)).await + else { + continue; + }; + bytes.truncate(read); + let body_offset = bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) + .unwrap_or(bytes.len()); + bodies_for_server + .lock() + .expect("body capture lock") + .push(bytes[body_offset..].to_vec()); + let request_text = String::from_utf8_lossy(&bytes); + let authorization = request_text + .lines() + .find(|line| line.to_ascii_lowercase().starts_with("authorization:")) + .unwrap_or_default() + .to_string(); + auth_for_server + .lock() + .expect("auth capture lock") + .push(authorization); + + if attempt < 3 { + 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 body = r#"{"event_id":"ignored","accepted":true,"message":"stored"}"#; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + } + } + }); + + let keys = Keys::generate(); + let client = BuzzClient::builder( + CommunityEndpoint::parse(&format!("http://{address}")).expect("endpoint"), + Arc::new(keys), + ) + .config(retry_config()) + .build() + .await + .expect("client"); + let result = client.send_message(request()).await.expect("send result"); + let accepted = match result.delivery { + DeliveryOutcome::Accepted(accepted) => accepted, + other => panic!("expected acceptance, got {other:?}"), + }; + + assert_eq!(attempts.load(Ordering::SeqCst), 3); + let bodies = captured_bodies.lock().expect("body capture lock"); + assert_eq!(bodies.len(), 3); + assert_eq!(bodies[0], bodies[1]); + assert_eq!(bodies[1], bodies[2]); + let event = Event::from_json(&bodies[0]).expect("submitted event JSON"); + event.verify().expect("event signature"); + assert_eq!(accepted.event_id, event.id); + let auth = captured_auth.lock().expect("auth capture lock"); + assert!(auth.iter().all(|header| !header.is_empty())); + assert_ne!(auth[0], auth[1]); + assert_ne!(auth[1], auth[2]); +} + +#[tokio::test] +async fn exhausted_proxy_failures_preserve_original_id_as_delivery_unknown() { + let attempts = Arc::new(AtomicU32::new(0)); + let first_event_id = Arc::new(Mutex::new(None)); + let app_attempts = attempts.clone(); + let app_event_id = first_event_id.clone(); + let app = axum::Router::new().route( + "/events", + axum::routing::post(move |axum::Json(event): axum::Json| { + let app_attempts = app_attempts.clone(); + let app_event_id = app_event_id.clone(); + async move { + app_attempts.fetch_add(1, Ordering::SeqCst); + let mut event_id = app_event_id.lock().expect("event id lock"); + if event_id.is_none() { + *event_id = event["id"].as_str().map(str::to_string); + } + ( + axum::http::StatusCode::BAD_GATEWAY, + axum::Json(serde_json::json!({"error": "proxy failed"})), + ) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("test relay"); + }); + let client = BuzzClient::builder( + CommunityEndpoint::parse(&format!("http://{address}")).expect("endpoint"), + Arc::new(Keys::generate()), + ) + .config(retry_config()) + .build() + .await + .expect("client"); + + let result = client.send_message(request()).await.expect("send result"); + let unknown = match result.delivery { + DeliveryOutcome::Unknown(unknown) => unknown, + other => panic!("expected unknown delivery, got {other:?}"), + }; + assert_eq!(attempts.load(Ordering::SeqCst), 3); + assert_eq!( + unknown.event_id.to_hex(), + first_event_id + .lock() + .expect("event id lock") + .clone() + .expect("captured event id") + ); +} + +#[tokio::test] +async fn semantic_rejection_is_definitive_and_not_retried() { + let attempts = Arc::new(AtomicU32::new(0)); + let app_attempts = attempts.clone(); + let app = axum::Router::new().route( + "/events", + axum::routing::post(move || { + let app_attempts = app_attempts.clone(); + async move { + app_attempts.fetch_add(1, Ordering::SeqCst); + ( + axum::http::StatusCode::UNPROCESSABLE_ENTITY, + axum::Json(serde_json::json!({"error": "invalid event"})), + ) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("test relay"); + }); + let client = BuzzClient::builder( + CommunityEndpoint::parse(&format!("http://{address}")).expect("endpoint"), + Arc::new(Keys::generate()), + ) + .config(retry_config()) + .build() + .await + .expect("client"); + + let result = client.send_message(request()).await.expect("send result"); + let rejected = match result.delivery { + DeliveryOutcome::Rejected(rejected) => rejected, + other => panic!("expected rejection, got {other:?}"), + }; + assert_eq!(attempts.load(Ordering::SeqCst), 1); + assert_eq!(rejected.status, 422); + assert_eq!(rejected.reason, "invalid event"); + assert!(!rejected.retry_safe); +} + +#[tokio::test] +async fn malformed_success_response_is_delivery_unknown() { + let app = axum::Router::new().route( + "/events", + axum::routing::post(|| async { + ( + axum::http::StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "application/json")], + "not valid JSON", + ) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("test relay"); + }); + let client = BuzzClient::builder( + CommunityEndpoint::parse(&format!("http://{address}")).expect("endpoint"), + Arc::new(Keys::generate()), + ) + .config(retry_config()) + .build() + .await + .expect("client"); + + let result = client.send_message(request()).await.expect("send result"); + let unknown = match result.delivery { + DeliveryOutcome::Unknown(unknown) => unknown, + other => panic!("expected unknown delivery, got {other:?}"), + }; + assert!(unknown.reason.contains("invalid relay success response")); +} + +#[tokio::test] +async fn signer_refusal_during_message_build_is_typed() { + let keys = Keys::generate(); + let client = BuzzClient::builder( + CommunityEndpoint::parse("https://buzz.example").expect("endpoint"), + Arc::new(RefusingSigner { + public_key: keys.public_key(), + }), + ) + .build() + .await + .expect("client construction"); + + let error = client + .send_message(request()) + .await + .expect_err("signing refusal must fail"); + assert!(matches!(error, ClientError::Signer(_))); +} + +#[tokio::test] +async fn upload_response_outside_community_authority_is_rejected() { + let app = axum::Router::new().route( + "/upload", + axum::routing::put(|| async { + axum::Json(serde_json::json!({ + "url": "https://media.example/asset.png", + "sha256": "00", + "size": 3, + "type": "image/png" + })) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("test relay"); + }); + let client = BuzzClient::builder( + CommunityEndpoint::parse(&format!("http://{address}")).expect("endpoint"), + Arc::new(Keys::generate()), + ) + .build() + .await + .expect("client"); + let mut send = request(); + send.attachments.push(MessageAttachment { + bytes: bytes::Bytes::from_static(b"png"), + mime_type: "image/png".into(), + }); + + let error = client + .send_message(send) + .await + .expect_err("foreign upload location must fail"); + assert!(matches!(error, ClientError::Endpoint(_))); +} diff --git a/examples/buzz-client-consumer/Cargo.lock b/examples/buzz-client-consumer/Cargo.lock new file mode 100644 index 0000000000..499752e08c --- /dev/null +++ b/examples/buzz-client-consumer/Cargo.lock @@ -0,0 +1,2352 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.2.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "buzz-client" +version = "0.1.0" +dependencies = [ + "base64", + "buzz-sdk", + "bytes", + "hex", + "nostr", + "rand 0.10.2", + "reqwest", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "buzz-client-consumer" +version = "0.1.0" +dependencies = [ + "axum", + "buzz-client", + "nostr", + "serde_json", + "tokio", +] + +[[package]] +name = "buzz-core" +version = "0.1.0" +dependencies = [ + "base64", + "chrono", + "hex", + "hmac 0.13.0", + "nostr", + "percent-encoding", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "subtle", + "thiserror", + "url", + "uuid", + "zeroize", +] + +[[package]] +name = "buzz-sdk" +version = "0.1.0" +dependencies = [ + "buzz-core", + "nostr", + "serde", + "serde_json", + "thiserror", + "uuid", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nostr" +version = "0.44.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" +dependencies = [ + "base64", + "bech32", + "bip39", + "bitcoin_hashes", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "instant", + "scrypt", + "secp256k1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand 0.8.7", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/examples/buzz-client-consumer/Cargo.toml b/examples/buzz-client-consumer/Cargo.toml new file mode 100644 index 0000000000..2455ed598b --- /dev/null +++ b/examples/buzz-client-consumer/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "buzz-client-consumer" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +buzz-client = { path = "../../crates/buzz-client" } +nostr = "0.44" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +[dev-dependencies] +axum = "0.8" +serde_json = "1" + +# Keep this verification consumer outside Buzz's workspace feature and +# dev-dependency unification, like a separate repository. +[workspace] diff --git a/examples/buzz-client-consumer/README.md b/examples/buzz-client-consumer/README.md new file mode 100644 index 0000000000..6781e3ed2b --- /dev/null +++ b/examples/buzz-client-consumer/README.md @@ -0,0 +1,13 @@ +# Independent `buzz-client` consumer + +This small, separate Cargo workspace proves that `buzz-client` can be consumed +without `buzz-cli`, Tauri, workspace-private modules, environment loaders, or +the client's dev-dependencies. It configures a community and asynchronous +signer, lists member channels, and sends one scoped message when a channel is +available. + +From the Buzz repository root: + +```sh +just buzz-client-consumer-check +``` diff --git a/examples/buzz-client-consumer/src/main.rs b/examples/buzz-client-consumer/src/main.rs new file mode 100644 index 0000000000..a28d1cf13d --- /dev/null +++ b/examples/buzz-client-consumer/src/main.rs @@ -0,0 +1,49 @@ +use std::sync::Arc; + +use buzz_client::{ + BuzzClient, ChannelScope, CommunityEndpoint, DeliveryOutcome, ListChannelsRequest, + MessageKind, SendMessageRequest, +}; +use nostr::Keys; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let endpoint = CommunityEndpoint::parse("http://127.0.0.1:3000")?; + let signer = Arc::new(Keys::generate()); + let client = BuzzClient::builder(endpoint, signer).build().await?; + + let channels = client + .list_channels(ListChannelsRequest { + scope: ChannelScope::Member, + ..ListChannelsRequest::default() + }) + .await?; + if let Some(channel) = channels.first() { + let sent = client + .send_message(SendMessageRequest { + channel_id: channel.channel_id, + content: "hello from an independent buzz-client consumer".into(), + kind: MessageKind::Stream, + reply_to: None, + broadcast: false, + attachments: Vec::new(), + mentions: Vec::new(), + }) + .await?; + match sent.delivery { + DeliveryOutcome::Accepted(accepted) => { + println!("accepted {}", accepted.event_id); + } + DeliveryOutcome::Rejected(rejected) => { + println!("rejected {}: {}", rejected.event_id, rejected.reason); + } + DeliveryOutcome::Unknown(unknown) => { + println!( + "delivery unknown for {}; do not re-sign automatically: {}", + unknown.event_id, unknown.reason + ); + } + } + } + Ok(()) +} diff --git a/examples/buzz-client-consumer/tests/smoke.rs b/examples/buzz-client-consumer/tests/smoke.rs new file mode 100644 index 0000000000..09ff6d931a --- /dev/null +++ b/examples/buzz-client-consumer/tests/smoke.rs @@ -0,0 +1,94 @@ +use std::sync::{Arc, Mutex}; + +use axum::extract::State; +use axum::routing::post; +use axum::{Json, Router}; +use buzz_client::{ + BuzzClient, CommunityEndpoint, DeliveryOutcome, ListChannelsRequest, MessageKind, + SendMessageRequest, +}; +use nostr::Keys; +use serde_json::{json, Value}; + +#[derive(Clone, Default)] +struct RelayState { + submitted: Arc>>, +} + +#[tokio::test] +async fn independent_public_api_lists_then_sends() { + let state = RelayState::default(); + let app = Router::new() + .route( + "/query", + post(|| async { + Json(json!([{ + "id": "a".repeat(64), + "created_at": 1, + "kind": 39000, + "tags": [ + ["d", "11111111-1111-1111-1111-111111111111"], + ["name", "independent"], + ["public"] + ] + }])) + }), + ) + .route( + "/events", + post( + |State(state): State, Json(event): Json| async move { + let event_id = event["id"].clone(); + state + .submitted + .lock() + .expect("submitted lock") + .push(event); + Json(json!({ + "event_id": event_id, + "accepted": true, + "message": "stored" + })) + }, + ), + ) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("test relay"); + }); + + let client = BuzzClient::builder( + CommunityEndpoint::parse(&format!("http://{address}")).expect("endpoint"), + Arc::new(Keys::generate()), + ) + .build() + .await + .expect("client"); + let channels = client + .list_channels(ListChannelsRequest::default()) + .await + .expect("channels"); + let channel = channels.first().expect("one channel"); + let result = client + .send_message(SendMessageRequest { + channel_id: channel.channel_id, + content: "hello from outside the workspace".into(), + kind: MessageKind::Stream, + reply_to: None, + broadcast: false, + attachments: Vec::new(), + mentions: Vec::new(), + }) + .await + .expect("send"); + + assert!(matches!(result.delivery, DeliveryOutcome::Accepted(_))); + let submitted = state.submitted.lock().expect("submitted lock"); + assert_eq!(submitted.len(), 1); + assert_eq!(submitted[0]["kind"], 9); + assert_eq!(submitted[0]["content"], "hello from outside the workspace"); +}