diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 7c15d285a0..e86b8708d2 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -372,46 +372,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 9a139f0377..11c3c0a164 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())); } @@ -2723,6 +2746,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 df135298c4..d568bf8632 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -308,6 +308,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 ef67fac570..f095bacade 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -58,26 +58,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. @@ -156,13 +154,19 @@ fn build_deferred_profile_event( let name = current.get("name").and_then(Value::as_str); let about = current.get("about").and_then(Value::as_str); let nip05 = current.get("nip05").and_then(Value::as_str); - - Ok( - events::build_profile(display_name, name, Some(avatar_url), about, nip05)? - .custom_created_at(monotonic_created_at( - prior_event.map(|event| event.created_at.as_secs() as i64), - )), - ) + let existing = current.as_object().cloned().unwrap_or_default(); + + Ok(events::build_profile_with_existing( + &existing, + display_name, + name, + Some(avatar_url), + about, + nip05, + )? + .custom_created_at(monotonic_created_at( + prior_event.map(|event| event.created_at.as_secs() as i64), + ))) } fn capture_expected_signer(state: &AppState, expected_pubkey: &str) -> Result { diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 777d56d02e..4a2ed2ac61 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -470,15 +470,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())); } @@ -498,6 +499,24 @@ pub fn build_profile( Ok(EventBuilder::new(Kind::Custom(0), content)) } +/// Kind 0 — NIP-01 profile metadata (full snapshot). +pub fn build_profile( + display_name: Option<&str>, + name: Option<&str>, + picture: Option<&str>, + about: Option<&str>, + nip05: Option<&str>, +) -> Result { + build_profile_with_existing( + &serde_json::Map::new(), + display_name, + name, + picture, + about, + nip05, + ) +} + // ── Huddles ────────────────────────────────────────────────────────────────── /// Validate that a string is a valid UUID (defense-in-depth for `&str` channel IDs). diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 71aa21c413..1191368436 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -400,11 +400,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. @@ -446,8 +454,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(); crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "agent profile sync")?; @@ -520,6 +539,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(), })) } @@ -528,6 +548,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 ───────────────────────────────────────────────── @@ -933,8 +954,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 @@ -951,8 +978,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 @@ -970,11 +998,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"); + } }