From f1c982bc95aa83789fbce57f89e27276c5ca46ed Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 19 Apr 2026 04:42:25 -0400 Subject: [PATCH] fix(nostr): profile publish broadcasts to ALL enabled relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously handle_identity_publish_profile defaulted to a single hard-coded relay (ws://localhost:18081) so the user's kind:0 profile event only ever landed on the local relay — hence "Manage Relays shows N connected, but profile edits don't propagate" from testing. Fix — two-layer change: - identity_manager::publish_profile now takes `&[String]` relays instead of one URL. Adds each relay to the nostr-sdk client, gives 15s for handshakes, publishes, then surfaces per-relay accept/reject in a new ProfilePublishOutcome struct so the UI can show WHICH relays accepted vs. rejected and WHY. - RPC handle_identity_publish_profile no longer defaults to the local relay: pulls the ENABLED list from nostr_relays::list_relays (the same table that powers Manage Relays) and publishes to every entry. Accepts an optional `relays: [...]` override for tests. - At-least-one-accept guarantee: if every relay rejects, the call errors instead of silently reporting published=true. User gets a real error message listing the failures. - Response shape: `{event_id, accepted: [urls], rejected: [[url, reason]], relays_attempted: N, published: bool}` so the UI can show a useful status block after clicking Publish. relay_url_matches is tolerant of trailing-slash / case differences since nostr-sdk canonicalises URLs internally. Covers the publishing half of task #29; avatar/banner upload UI is still open. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/api/rpc/identity/handlers.rs | 41 +++++-- core/archipelago/src/identity_manager.rs | 112 ++++++++++++++++-- 2 files changed, 131 insertions(+), 22 deletions(-) diff --git a/core/archipelago/src/api/rpc/identity/handlers.rs b/core/archipelago/src/api/rpc/identity/handlers.rs index 0bb96e84..65bb45ef 100644 --- a/core/archipelago/src/api/rpc/identity/handlers.rs +++ b/core/archipelago/src/api/rpc/identity/handlers.rs @@ -727,7 +727,10 @@ impl RpcHandler { Ok(serde_json::json!({ "ok": true })) } - /// Publish kind 0 (metadata) profile to the local Nostr relay. + /// Publish kind 0 (metadata) profile to every enabled Nostr relay + /// configured in Manage Relays. Callers can override the default + /// list by passing `relays: [..]` in params (e.g. to publish to a + /// single relay for testing). pub(in crate::api::rpc) async fn handle_identity_publish_profile( &self, params: Option, @@ -739,18 +742,38 @@ impl RpcHandler { .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?; validate_identity_id(id)?; - let relay_url = params - .get("relay") - .and_then(|v| v.as_str()) - .unwrap_or("ws://localhost:18081"); + let relay_urls: Vec = if let Some(arr) = params.get("relays").and_then(|v| v.as_array()) { + arr.iter() + .filter_map(|v| v.as_str()) + .map(|s| s.to_string()) + .collect() + } else if let Some(single) = params.get("relay").and_then(|v| v.as_str()) { + vec![single.to_string()] + } else { + // Default: every enabled relay in the user's Manage Relays list. + let statuses = crate::nostr_relays::list_relays(&self.config.data_dir) + .await + .unwrap_or_default(); + statuses + .into_iter() + .filter(|s| s.enabled) + .map(|s| s.url) + .collect() + }; + + if relay_urls.is_empty() { + anyhow::bail!("No enabled relays configured; add one under Manage Relays"); + } let manager = IdentityManager::new(&self.config.data_dir).await?; - let event_id = manager.publish_profile(id, relay_url).await?; + let outcome = manager.publish_profile(id, &relay_urls).await?; Ok(serde_json::json!({ - "event_id": event_id, - "relay": relay_url, - "published": true, + "event_id": outcome.event_id, + "accepted": outcome.accepted, + "rejected": outcome.rejected, + "relays_attempted": relay_urls.len(), + "published": !outcome.accepted.is_empty(), })) } diff --git a/core/archipelago/src/identity_manager.rs b/core/archipelago/src/identity_manager.rs index 766f7623..71f83201 100644 --- a/core/archipelago/src/identity_manager.rs +++ b/core/archipelago/src/identity_manager.rs @@ -99,6 +99,26 @@ pub struct IdentityManager { identities_dir: PathBuf, } +/// Result of a multi-relay profile broadcast. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ProfilePublishOutcome { + pub event_id: String, + pub accepted: Vec, + pub rejected: Vec<(String, String)>, +} + +/// Relay URL equality that tolerates minor normalization differences +/// (trailing slash, case). nostr-sdk canonicalises URLs internally and +/// we compare on the surface strings, so be liberal about what matches. +fn relay_url_matches(a: &str, b: &str) -> bool { + let norm = |s: &str| { + s.trim_end_matches('/') + .trim() + .to_ascii_lowercase() + }; + norm(a) == norm(b) +} + impl IdentityManager { pub async fn new(data_dir: &Path) -> Result { let identities_dir = data_dir.join(IDENTITIES_DIR); @@ -512,12 +532,26 @@ impl IdentityManager { Ok(()) } - /// Publish kind 0 (metadata) event to a Nostr relay. - pub async fn publish_profile(&self, id: &str, relay_url: &str) -> Result { + /// Publish kind 0 (metadata) event to one or more Nostr relays. + /// + /// Connects all relays in parallel, broadcasts the signed event to + /// every one of them, and reports back the event id plus per-relay + /// acceptance status. At least one successful relay is required — + /// if every relay rejects the event, this returns an error so the + /// UI can surface "publish failed" instead of silently succeeding. + pub async fn publish_profile( + &self, + id: &str, + relay_urls: &[String], + ) -> Result { let record = self.get(id).await?; let keys = self.load_nostr_keys(id).await?; let profile = record.profile.unwrap_or_default(); + if relay_urls.is_empty() { + anyhow::bail!("No relays configured — add a relay under Manage Relays first"); + } + // Build kind 0 content JSON (NIP-01 + NIP-24) let mut content = serde_json::Map::new(); content.insert("name".to_string(), serde_json::json!(record.name)); @@ -547,26 +581,78 @@ impl IdentityManager { serde_json::to_string(&content).context("Failed to serialize profile content")?; let client = nostr_sdk::Client::new(keys); - client - .add_relay(relay_url) - .await - .context("Failed to add relay")?; - if tokio::time::timeout(Duration::from_secs(10), client.connect()) + for url in relay_urls { + if let Err(e) = client.add_relay(url).await { + tracing::warn!(relay = %url, error = %e, "Failed to add relay; continuing"); + } + } + // 15s gives each relay a reasonable chance to hand-shake before we + // fire the publish. nostr-sdk's send_event_builder to "all relays" + // will only reach relays that have connected by then — some slow + // relays can miss the first publish but subsequent publishes hit + // them once the connection has settled. + if tokio::time::timeout(Duration::from_secs(15), client.connect()) .await .is_err() { - tracing::warn!("Nostr relay connection timed out after 10s, continuing anyway"); + tracing::warn!("Nostr relay connection timed out after 15s, continuing anyway"); } let builder = nostr_sdk::prelude::EventBuilder::new(nostr_sdk::prelude::Kind::Metadata, &content_str); - let output = client - .send_event_builder(builder) - .await - .context("Failed to publish profile")?; + let output = match client.send_event_builder(builder).await { + Ok(o) => o, + Err(e) => { + client.disconnect().await; + return Err(anyhow::anyhow!("Publish failed on every relay: {}", e)); + } + }; + + let event_id = output.id().to_hex(); + // `Output` has `success: HashSet` + `failed: HashMap`. + // Normalise to string comparisons (RelayUrl trims trailing slashes etc.). + let success_strs: std::collections::HashSet = output + .success + .iter() + .map(|u| u.to_string()) + .collect(); + let failed_strs: std::collections::HashMap = output + .failed + .iter() + .map(|(u, msg)| (u.to_string(), msg.clone())) + .collect(); + let mut accepted: Vec = Vec::new(); + let mut rejected: Vec<(String, String)> = Vec::new(); + for url in relay_urls { + let match_url = success_strs + .iter() + .any(|s| relay_url_matches(s, url)); + if match_url { + accepted.push(url.clone()); + } else if let Some((_, reason)) = failed_strs + .iter() + .find(|(s, _)| relay_url_matches(s, url)) + { + rejected.push((url.clone(), reason.clone())); + } else { + rejected.push((url.clone(), "(no ack from relay)".to_string())); + } + } client.disconnect().await; - Ok(output.id().to_hex()) + if accepted.is_empty() { + anyhow::bail!( + "Profile published on 0 relays — {} attempted. Failures: {:?}", + relay_urls.len(), + rejected + ); + } + + Ok(ProfilePublishOutcome { + event_id, + accepted, + rejected, + }) } /// Export all keys for an identity (SENSITIVE — only call after password verification).