From 8b3cb042b2e1eddba9419b0905985917113db282 Mon Sep 17 00:00:00 2001 From: Cvv9 Date: Thu, 23 Jul 2026 21:45:19 +0530 Subject: [PATCH] fix(profile): preserve unmodeled metadata on update Signed-off-by: Cvv9 --- crates/buzz-cli/src/commands/users.rs | 47 ++---------- crates/buzz-sdk/src/builders.rs | 51 ++++++++++++- .../src-tauri/src/commands/agents_tests.rs | 1 + desktop/src-tauri/src/commands/profile.rs | 36 +++++---- desktop/src-tauri/src/events.rs | 7 +- desktop/src-tauri/src/relay.rs | 73 +++++++++++++++++-- 6 files changed, 145 insertions(+), 70 deletions(-) diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 3f8325b4b9..cadbdd5c5f 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -163,46 +163,13 @@ pub async fn cmd_set_profile( // Read-merge-write: fetch current profile, merge in the new fields, then sign. let current = fetch_current_profile(client).await?; - // Merge: caller-supplied fields win; fall back to current profile values. - let merged_name = display_name - .map(|s| s.to_string()) - .or_else(|| { - current - .get("display_name") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }) - .or_else(|| { - current - .get("name") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }); - let merged_picture = avatar_url.map(|s| s.to_string()).or_else(|| { - current - .get("picture") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }); - let merged_about = about.map(|s| s.to_string()).or_else(|| { - current - .get("about") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }); - let merged_nip05 = nip05_handle.map(|s| s.to_string()).or_else(|| { - current - .get("nip05") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }); - - let builder = buzz_sdk::build_profile( - merged_name.as_deref(), - None, // `name` field (username) — not exposed by CLI - merged_picture.as_deref(), - merged_about.as_deref(), - merged_nip05.as_deref(), + let builder = buzz_sdk::build_profile_with_existing( + ¤t, + display_name, + None, // `name` is not exposed by the CLI, so preserve its existing value. + avatar_url, + about, + nip05_handle, ) .map_err(|e| CliError::Other(format!("build_profile failed: {e}")))?; diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index e52c97603c..2947d3780c 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -541,7 +541,30 @@ pub fn build_profile( about: Option<&str>, nip05: Option<&str>, ) -> Result { - let mut map = serde_json::Map::new(); + build_profile_with_existing( + &serde_json::Map::new(), + display_name, + name, + picture, + about, + nip05, + ) +} + +/// Build a NIP-01 profile metadata event while preserving unmodeled fields +/// from an existing kind:0 snapshot. +/// +/// Kind 0 is replaceable, so updates must merge into the complete current +/// object or fields introduced by other clients and future NIPs are deleted. +pub fn build_profile_with_existing( + existing: &serde_json::Map, + display_name: Option<&str>, + name: Option<&str>, + picture: Option<&str>, + about: Option<&str>, + nip05: Option<&str>, +) -> Result { + let mut map = existing.clone(); if let Some(v) = display_name { map.insert("display_name".into(), serde_json::Value::String(v.into())); } @@ -2334,6 +2357,32 @@ mod tests { assert!(v.as_object().unwrap().is_empty()); } + #[test] + fn profile_update_preserves_unmodeled_fields() { + let existing = serde_json::json!({ + "display_name": "Old name", + "bot": true, + "website": "https://example.com", + "lud16": "alice@example.com" + }); + let ev = sign( + build_profile_with_existing( + existing.as_object().unwrap(), + Some("New name"), + None, + None, + None, + None, + ) + .unwrap(), + ); + let value: serde_json::Value = serde_json::from_str(&ev.content).unwrap(); + assert_eq!(value["display_name"], "New name"); + assert_eq!(value["bot"], true); + assert_eq!(value["website"], "https://example.com"); + assert_eq!(value["lud16"], "alice@example.com"); + } + #[test] fn add_member_with_role() { let cid = uuid(); diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 1b2e0ed179..cec066a3b8 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -265,6 +265,7 @@ fn profile(name: Option<&str>, picture: Option<&str>) -> crate::relay::AgentProf crate::relay::AgentProfileInfo { display_name: name.map(str::to_string), picture: picture.map(str::to_string), + metadata: serde_json::Map::new(), } } diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index ab36b45c5c..c8aa86a840 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -54,26 +54,24 @@ pub async fn update_profile( // Pull the current content as a JSON object so we can merge with // the caller's overrides. - let current: Value = prior_events + let current = prior_events .first() - .and_then(|ev| serde_json::from_str::(&ev.content).ok()) - .unwrap_or(Value::Null); - - let dn = display_name - .as_deref() - .or_else(|| current.get("display_name").and_then(Value::as_str)); - let name = current.get("name").and_then(Value::as_str); - let picture = avatar_url - .as_deref() - .or_else(|| current.get("picture").and_then(Value::as_str)); - let ab = about - .as_deref() - .or_else(|| current.get("about").and_then(Value::as_str)); - let nip05 = nip05_handle - .as_deref() - .or_else(|| current.get("nip05").and_then(Value::as_str)); - - let builder = events::build_profile(dn, name, picture, ab, nip05)?; + .and_then(|event| { + serde_json::from_str::(&event.content) + .ok()? + .as_object() + .cloned() + }) + .unwrap_or_default(); + + let builder = events::build_profile_with_existing( + ¤t, + display_name.as_deref(), + None, + avatar_url.as_deref(), + about.as_deref(), + nip05_handle.as_deref(), + )?; submit_event(builder, &state).await?; // Re-fetch to return canonical profile. diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 5dee578298..61e0eb9b1e 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -462,15 +462,16 @@ pub fn build_set_canvas(channel_id: Uuid, content: &str) -> Result, display_name: Option<&str>, name: Option<&str>, picture: Option<&str>, about: Option<&str>, nip05: Option<&str>, ) -> Result { - let mut map = serde_json::Map::new(); + let mut map = existing.clone(); if let Some(v) = display_name { map.insert("display_name".into(), serde_json::Value::String(v.into())); } diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index e6463f5092..d5ab3e09e9 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -399,11 +399,19 @@ pub fn parse_command_response(message: &str) -> Result, display_name: &str, avatar_url: Option<&str>, auth_tag_json: Option<&str>, ) -> Result { - let builder = crate::events::build_profile(Some(display_name), None, avatar_url, None, None)?; + let builder = crate::events::build_profile_with_existing( + existing_profile, + Some(display_name), + None, + avatar_url, + None, + None, + )?; let builder = if let Some(tag_json) = auth_tag_json { // Bridge nostr 0.37 PublicKey → nostr 0.36 PublicKey via hex encoding. @@ -445,8 +453,19 @@ pub async fn sync_managed_agent_profile( auth_tag: Option<&str>, // NIP-OA auth tag JSON ) -> Result<(), String> { crate::relay_admission::wait_for_rate_limit().await; + let agent_pubkey = agent_keys.public_key().to_hex(); + let existing_profile = query_agent_profile(state, relay_url, &agent_pubkey) + .await? + .map(|profile| profile.metadata) + .unwrap_or_default(); // Build a signed kind:0 profile event (with optional NIP-OA auth tag). - let event = build_profile_event(agent_keys, display_name, avatar_url, auth_tag)?; + let event = build_profile_event( + agent_keys, + &existing_profile, + display_name, + avatar_url, + auth_tag, + )?; let event_json = event.as_json(); let body_bytes = event_json.into_bytes(); @@ -518,6 +537,7 @@ pub async fn query_agent_profile( .get("picture") .and_then(|v| v.as_str()) .map(str::to_string), + metadata: content.as_object().cloned().unwrap_or_default(), })) } @@ -526,6 +546,7 @@ pub async fn query_agent_profile( pub struct AgentProfileInfo { pub display_name: Option, pub picture: Option, + pub metadata: serde_json::Map, } // ── Signed-event submission ───────────────────────────────────────────────── @@ -1018,8 +1039,14 @@ mod tests { fn profile_event_with_valid_auth_tag() { let agent_keys = nostr::Keys::generate(); let tag_json = make_valid_auth_tag(&agent_keys); - let event = build_profile_event(&agent_keys, "TestBot", None, Some(&tag_json)) - .expect("should succeed with a valid auth tag"); + let event = build_profile_event( + &agent_keys, + &serde_json::Map::new(), + "TestBot", + None, + Some(&tag_json), + ) + .expect("should succeed with a valid auth tag"); // Exactly one "auth" tag must be present. let auth_tags: Vec<_> = event @@ -1036,8 +1063,9 @@ mod tests { #[test] fn profile_event_without_auth_tag() { let agent_keys = nostr::Keys::generate(); - let event = build_profile_event(&agent_keys, "TestBot", None, None) - .expect("should succeed without an auth tag"); + let event = + build_profile_event(&agent_keys, &serde_json::Map::new(), "TestBot", None, None) + .expect("should succeed without an auth tag"); // No "auth" tags should be present. let auth_tags: Vec<_> = event @@ -1055,11 +1083,42 @@ mod tests { let agent_keys = nostr::Keys::generate(); // Structurally valid JSON array but with a bogus signature — verification must fail. let bad_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128)); - let result = build_profile_event(&agent_keys, "TestBot", None, Some(&bad_json)); + let result = build_profile_event( + &agent_keys, + &serde_json::Map::new(), + "TestBot", + None, + Some(&bad_json), + ); assert!(result.is_err(), "should reject an invalid auth tag"); assert!( result.unwrap_err().contains("verification failed"), "error message should mention verification failure" ); } + + #[test] + fn profile_event_preserves_unmodeled_metadata() { + let agent_keys = nostr::Keys::generate(); + let existing = serde_json::json!({ + "display_name": "Old bot", + "bot": true, + "website": "https://example.com", + "nip05": "bot@example.com" + }); + let event = build_profile_event( + &agent_keys, + existing.as_object().unwrap(), + "Renamed bot", + None, + None, + ) + .expect("profile merge should succeed"); + let content: serde_json::Value = + serde_json::from_str(&event.content).expect("profile content should be JSON"); + assert_eq!(content["display_name"], "Renamed bot"); + assert_eq!(content["bot"], true); + assert_eq!(content["website"], "https://example.com"); + assert_eq!(content["nip05"], "bot@example.com"); + } }