diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 2d36929b8d..2da11199b1 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -227,46 +227,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 f9e54de9c5..a038cb0d7c 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())); } @@ -2354,6 +2377,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/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 8de72ec1a8..9eefb075b4 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -409,7 +409,9 @@ const overrides = new Map([ // (NIP-98 signed /query with explicit agent keys + optional x-auth-tag) for // bounded-auth agent relay-membership discovery. Load-bearing; queued to // split alongside the test-helper split. - ["src-tauri/src/relay.rs", 1077], + // +1: preserve unmodeled kind:0 metadata when the managed-agent profile is + // updated. The merge logic and regression test remain at the relay boundary. + ["src-tauri/src/relay.rs", 1078], // degraded-network resilience: visibleChannelId field + getter/setter, NOTICE // handler for relay back-pressure, and rate-limit gate imports add ~74 lines // of load-bearing degraded-network recovery code. Queued to split. @@ -480,6 +482,9 @@ const overrides = new Map([ // the embedded runtime and coordinator. Probe/re-arm logic lives in // mesh_llm/recovery.rs rather than growing AppState or command modules. ["src-tauri/src/app_state.rs", 1085], + // +1: profile snapshots now start from the existing kind:0 map so fields + // Buzz does not model survive an avatar or display-name update. + ["src-tauri/src/events.rs", 1001], // multi-slot splitting + no-op suppression (#1309): the ReadStateManager // class grew from ~700 lines to ~1019 with the addition of // splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots, diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index e32fc1cfe4..ba11702d60 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -291,6 +291,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..01aa7cf6bb 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())); } diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 1c9ba0095a..99a9c560b7 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(); @@ -519,6 +538,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(), })) } @@ -527,6 +547,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 ───────────────────────────────────────────────── @@ -971,8 +992,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 @@ -989,8 +1016,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 @@ -1008,11 +1036,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"); + } }