fix(nostr): profile publish broadcasts to ALL enabled relays

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) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-19 04:42:25 -04:00
parent f756365935
commit b77b031b8e
2 changed files with 131 additions and 22 deletions

View File

@@ -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<serde_json::Value>,
@@ -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<String> = 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(),
}))
}

View File

@@ -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<String>,
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<Self> {
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<String> {
/// 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<ProfilePublishOutcome> {
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<RelayUrl>` + `failed: HashMap<RelayUrl, String>`.
// Normalise to string comparisons (RelayUrl trims trailing slashes etc.).
let success_strs: std::collections::HashSet<String> = output
.success
.iter()
.map(|u| u.to_string())
.collect();
let failed_strs: std::collections::HashMap<String, String> = output
.failed
.iter()
.map(|(u, msg)| (u.to_string(), msg.clone()))
.collect();
let mut accepted: Vec<String> = 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).