From 3a6a24df360842a7371e13e8c75c909ecbb17ae7 Mon Sep 17 00:00:00 2001 From: Abdulwahab Date: Tue, 28 Jul 2026 02:06:03 +0300 Subject: [PATCH] fix(profile): merge kind:0 writes instead of replacing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kind:0 is replaceable, so publishing a profile built from a fixed set of keys is not a partial update — it deletes every field that was not built. Buzz constructed kind:0 content from a 5-key allowlist, so any identity whose profile carried `bot`, `website`, `banner` or `lud16` lost them the first time Buzz touched the profile, silently and with exit 0. `bot` is the costly one: an agent that quietly loses its NIP-24 flag reads as a human account to every client that renders it. Add `ProfileFields` and `merge_profile_content` to buzz-sdk: a merge over the identity's current content that carries unknown keys forward, so a key from a future NIP also survives contact with Buzz. The merge is plain serde_json with no nostr types, so the desktop crate shares it across the nostr 0.36/0.37 boundary that keeps these builders duplicated — the duplicated content-building logic in desktop/src-tauri/src/events.rs is gone rather than fixed twice. Fix all three write paths: - CLI `set-profile` merges into the full fetched object. This also drops the `display_name` -> `current.display_name` -> `current.name` fallback chain, which promoted a short handle into `display_name` for identities that had `name` but no `display_name`, and stops hardcoding `name` to None on every write. Adds `--username`, `--website` and `--bot`; `--name` keeps mapping to `display_name` for compatibility. - Desktop `update_profile` and the deferred avatar save merge rather than rebuild from per-key fallbacks. - Desktop managed-agent sync reads the agent's live profile and merges into it. This path published a two-field event and is unattended — reached from agent save, model change, persona edit, persona and team snapshot import, profile reconciliation and managed-agent restore — so it erased `about`/`nip05`/`bot` with no user action. The read degrades to an empty base on failure, so a transient query error cannot block the sync. Field semantics are unchanged for callers: `None` leaves a field alone. Clearing a field is still not expressible and is left out deliberately. Fixes #2534 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0164eVvBNsWPzJZVtkDJAqPx Signed-off-by: Abdulwahab --- crates/buzz-cli/README.md | 2 +- crates/buzz-cli/TESTING.md | 11 + crates/buzz-cli/src/commands/users.rs | 74 ++--- crates/buzz-cli/src/lib.rs | 14 +- crates/buzz-sdk/src/builders.rs | 259 ++++++++++++++++-- .../src-tauri/src/commands/agents_tests.rs | 1 + desktop/src-tauri/src/commands/profile.rs | 48 ++-- desktop/src-tauri/src/events.rs | 37 +-- desktop/src-tauri/src/relay.rs | 95 ++++++- 9 files changed, 419 insertions(+), 122 deletions(-) diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a8c668cf06..f1bd554977 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -130,7 +130,7 @@ stored rules in `validation_error` so an owner can remove and repair them. | | `open` | Open a DM (1–8 pubkeys) | | | `add-member` | Add member to DM group | | `users` | `get` | Get user profile(s) | -| | `set-profile` | Update your profile | +| | `set-profile` | Update your profile (merges — unset fields are preserved) | | | `presence` | Get presence status | | | `set-presence` | Set presence status | | `workflows` | `list` | List workflows | diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index 4b7257aba7..c71614283f 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -323,6 +323,13 @@ buzz users get --pubkey "$MY_PUBKEY" --pubkey "$MY_PUBKEY" | jq . # users set-profile buzz users set-profile --name "CLI Test Agent" --about "Testing buzz-cli" | jq . +# users set-profile — fields not passed are preserved, including ones the CLI +# does not model. Set the bot flag, then confirm --about did not clear it. +buzz users set-profile --bot true --website "https://example.com" | jq . +buzz users set-profile --about "still a bot" | jq . +buzz users get --pubkey "$MY_PUBKEY" | jq '.[0].content | fromjson | {bot, website, about}' +# expect: {"bot": true, "website": "https://example.com", "about": "still a bot"} + # users presence buzz users presence --pubkeys "$MY_PUBKEY" | jq . @@ -499,6 +506,10 @@ buzz messages vote --event "$(printf '0%.0s' {1..64})" \ buzz users set-profile 2>&1; echo "exit: $?" # exit: 1 (at least one field required) +# Exit 1: Non-boolean --bot +buzz users set-profile --bot maybe 2>&1; echo "exit: $?" +# exit: 1 + # Exit 3: No auth configured env -u BUZZ_PRIVATE_KEY \ cargo run -p buzz-cli -- channels list 2>&1; echo "exit: $?" diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 3f8325b4b9..4daab93c4d 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -147,64 +147,42 @@ async fn search_by_name( Ok(()) } +#[allow(clippy::too_many_arguments)] pub async fn cmd_set_profile( client: &BuzzClient, display_name: Option<&str>, + username: Option<&str>, avatar_url: Option<&str>, about: Option<&str>, nip05_handle: Option<&str>, + website: Option<&str>, + bot: Option, ) -> Result<(), CliError> { - if display_name.is_none() && avatar_url.is_none() && about.is_none() && nip05_handle.is_none() { + let fields = buzz_sdk::ProfileFields { + display_name, + name: username, + picture: avatar_url, + about, + nip05: nip05_handle, + website, + bot, + ..Default::default() + }; + if fields == buzz_sdk::ProfileFields::default() { return Err(CliError::Usage( - "at least one field required (--name, --avatar, --about, --nip05)".into(), + "at least one field required (--name, --username, --avatar, --about, --nip05, \ + --website, --bot)" + .into(), )); } - // Read-merge-write: fetch current profile, merge in the new fields, then sign. + // Read-merge-write. kind:0 is replaceable, so anything not carried forward is + // deleted — merge into the full current content, not a rebuilt subset, or + // fields Buzz does not model (bot, lud16, …) are silently destroyed. 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(), - ) - .map_err(|e| CliError::Other(format!("build_profile failed: {e}")))?; + let builder = buzz_sdk::build_profile_merged(¤t, &fields) + .map_err(|e| CliError::Other(format!("build_profile failed: {e}")))?; let event = client.sign_event(builder)?; @@ -316,16 +294,22 @@ pub async fn dispatch( } UsersCmd::SetProfile { name, + username, avatar, about, nip05, + website, + bot, } => { cmd_set_profile( client, name.as_deref(), + username.as_deref(), avatar.as_deref(), about.as_deref(), nip05.as_deref(), + website.as_deref(), + bot, ) .await } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 6ab81a082d..c40a72dbaa 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -810,11 +810,17 @@ pub enum UsersCmd { name: Option, }, /// Update the current identity's profile + /// + /// Fields you do not pass are left untouched, including profile keys Buzz + /// does not model. #[command(name = "set-profile")] SetProfile { - /// Display name + /// Display name (the `display_name` profile field) #[arg(long)] name: Option, + /// Short username handle (the `name` profile field) + #[arg(long)] + username: Option, /// Avatar URL #[arg(long)] avatar: Option, @@ -824,6 +830,12 @@ pub enum UsersCmd { /// NIP-05 identifier (e.g. user@example.com) #[arg(long)] nip05: Option, + /// Homepage URL + #[arg(long)] + website: Option, + /// NIP-24 automated-account flag + #[arg(long)] + bot: Option, }, /// Get presence status for users Presence { diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index f9e54de9c5..2cb52e4f2a 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -531,9 +531,104 @@ pub fn build_set_canvas(channel_id: Uuid, content: &str) -> Result { + /// Long-form display name (`display_name`). + pub display_name: Option<&'a str>, + /// Short username handle (`name`). + pub name: Option<&'a str>, + /// Avatar URL (`picture`). + pub picture: Option<&'a str>, + /// Bio text (`about`). + pub about: Option<&'a str>, + /// NIP-05 identifier (`nip05`). + pub nip05: Option<&'a str>, + /// Homepage URL (`website`). + pub website: Option<&'a str>, + /// Profile banner URL (`banner`). + pub banner: Option<&'a str>, + /// Lightning address (`lud16`). + pub lud16: Option<&'a str>, + /// NIP-24 automated-account flag (`bot`). + pub bot: Option, +} + +/// Merge `fields` into an existing kind:0 content object, preserving every key +/// Buzz does not model. +/// +/// kind:0 is a replaceable event, so a partial rebuild is not a partial update — +/// it is a deletion of everything omitted. Callers that publish a profile must +/// therefore merge into the identity's current content rather than construct a +/// fresh object, or fields such as `bot`, `website` and `lud16` are silently +/// destroyed. +/// +/// This function is deliberately free of any `nostr` types so that callers built +/// against a different `nostr` version (the desktop crate) can share it. +pub fn merge_profile_content( + current: &serde_json::Map, + fields: &ProfileFields<'_>, +) -> serde_json::Map { + let mut map = current.clone(); + let mut set_str = |key: &str, value: Option<&str>| { + if let Some(v) = value { + map.insert(key.into(), serde_json::Value::String(v.into())); + } + }; + set_str("display_name", fields.display_name); + set_str("name", fields.name); + set_str("picture", fields.picture); + set_str("about", fields.about); + set_str("nip05", fields.nip05); + set_str("website", fields.website); + set_str("banner", fields.banner); + set_str("lud16", fields.lud16); + if let Some(v) = fields.bot { + map.insert("bot".into(), serde_json::Value::Bool(v)); + } + map +} + +/// Build a NIP-01 profile metadata event (kind 0) that merges `fields` into the +/// identity's `current` profile content. +/// +/// This is the safe way to publish a profile: unknown keys in `current` are +/// carried forward. Pass an empty map only when you genuinely intend to replace +/// the whole profile. +pub fn build_profile_merged( + current: &serde_json::Map, + fields: &ProfileFields<'_>, +) -> Result { + let map = merge_profile_content(current, fields); + let content = serde_json::Value::Object(map).to_string(); + Ok(EventBuilder::new(Kind::Custom(0), content).tags([])) +} + +/// Build a NIP-01 profile metadata event (kind 0) from scratch. /// /// Only present (Some) fields are included in the JSON object. +/// +/// # Warning +/// +/// kind:0 is replaceable, so this **deletes every field it is not given**, +/// including `bot`, `website`, `banner` and `lud16`. Prefer +/// [`build_profile_merged`], which carries the identity's existing keys forward. pub fn build_profile( display_name: Option<&str>, name: Option<&str>, @@ -541,24 +636,17 @@ pub fn build_profile( about: Option<&str>, nip05: Option<&str>, ) -> Result { - let mut map = serde_json::Map::new(); - if let Some(v) = display_name { - map.insert("display_name".into(), serde_json::Value::String(v.into())); - } - if let Some(v) = name { - map.insert("name".into(), serde_json::Value::String(v.into())); - } - if let Some(v) = picture { - map.insert("picture".into(), serde_json::Value::String(v.into())); - } - if let Some(v) = about { - map.insert("about".into(), serde_json::Value::String(v.into())); - } - if let Some(v) = nip05 { - map.insert("nip05".into(), serde_json::Value::String(v.into())); - } - let content = serde_json::Value::Object(map).to_string(); - Ok(EventBuilder::new(Kind::Custom(0), content).tags([])) + build_profile_merged( + &serde_json::Map::new(), + &ProfileFields { + display_name, + name, + picture, + about, + nip05, + ..Default::default() + }, + ) } /// Build a NIP-29 add-member event (kind 9000). @@ -2354,6 +2442,139 @@ mod tests { assert!(v.as_object().unwrap().is_empty()); } + /// Parse a kind:0 content object from a JSON literal. + fn profile_content(v: serde_json::Value) -> serde_json::Map { + v.as_object().cloned().unwrap() + } + + /// Regression for the silent field-drop: publishing one field must not delete + /// the rest of the profile, including keys the SDK does not model. + #[test] + fn merged_profile_preserves_unmodelled_fields() { + let current = profile_content(serde_json::json!({ + "display_name": "Dennis", + "name": "dennis", + "about": "hello", + "picture": "https://example.com/d.jpg", + "website": "https://nave.pub", + "bot": true, + "lud16": "dennis@getalby.com", + "some_future_nip_key": {"nested": [1, 2]}, + })); + + // The repro from the issue: set one field to a new value. + let ev = sign( + build_profile_merged( + ¤t, + &ProfileFields { + nip05: Some("dennis@nave.pub"), + ..Default::default() + }, + ) + .unwrap(), + ); + + let v: serde_json::Value = serde_json::from_str(&ev.content).unwrap(); + assert_eq!(v["nip05"], "dennis@nave.pub"); + // Everything else survives — this is what regressed. + assert_eq!(v["display_name"], "Dennis"); + assert_eq!(v["name"], "dennis"); + assert_eq!(v["about"], "hello"); + assert_eq!(v["picture"], "https://example.com/d.jpg"); + assert_eq!(v["website"], "https://nave.pub"); + assert_eq!(v["bot"], true); + assert_eq!(v["lud16"], "dennis@getalby.com"); + assert_eq!( + v["some_future_nip_key"], + serde_json::json!({"nested": [1, 2]}) + ); + } + + #[test] + fn merged_profile_overwrites_supplied_fields_only() { + let current = profile_content(serde_json::json!({ + "display_name": "Old", + "about": "keep me", + })); + let ev = sign( + build_profile_merged( + ¤t, + &ProfileFields { + display_name: Some("New"), + ..Default::default() + }, + ) + .unwrap(), + ); + let v: serde_json::Value = serde_json::from_str(&ev.content).unwrap(); + assert_eq!(v["display_name"], "New"); + assert_eq!(v["about"], "keep me"); + } + + #[test] + fn merged_profile_can_set_bot_flag_both_ways() { + let current = profile_content(serde_json::json!({"bot": true})); + let cleared = merge_profile_content( + ¤t, + &ProfileFields { + bot: Some(false), + ..Default::default() + }, + ); + assert_eq!(cleared["bot"], false); + + // `None` leaves the flag exactly as it was, rather than clearing it. + let untouched = merge_profile_content(¤t, &ProfileFields::default()); + assert_eq!(untouched["bot"], true); + } + + #[test] + fn merged_profile_from_empty_current_matches_legacy_builder() { + let fields = ProfileFields { + display_name: Some("Alice"), + name: Some("alice"), + picture: Some("https://example.com/pic.jpg"), + about: Some("Hello world"), + nip05: Some("alice@example.com"), + ..Default::default() + }; + let merged = sign(build_profile_merged(&serde_json::Map::new(), &fields).unwrap()); + let legacy = sign( + build_profile( + Some("Alice"), + Some("alice"), + Some("https://example.com/pic.jpg"), + Some("Hello world"), + Some("alice@example.com"), + ) + .unwrap(), + ); + assert_eq!(merged.content, legacy.content); + } + + #[test] + fn merged_profile_sets_every_modelled_field() { + let map = merge_profile_content( + &serde_json::Map::new(), + &ProfileFields { + display_name: Some("A"), + name: Some("a"), + picture: Some("p"), + about: Some("b"), + nip05: Some("a@e.com"), + website: Some("https://w"), + banner: Some("https://bn"), + lud16: Some("a@alby"), + bot: Some(true), + }, + ); + assert_eq!(map.len(), 9); + assert_eq!(map["banner"], "https://bn"); + assert_eq!(map["lud16"], "a@alby"); + assert_eq!(map["website"], "https://w"); + assert_eq!(map["bot"], true); + } + #[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 e32fc1cfe4..e5236a1c71 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), + content: serde_json::Map::new(), } } diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index ef67fac570..738410b0b7 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -63,21 +63,18 @@ pub async fn update_profile( .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)?; + // Merge into the full current content: per-key fallbacks would still drop + // every key not enumerated here, and kind:0 is replaceable. + let builder = events::build_profile_merged( + ¤t.as_object().cloned().unwrap_or_default(), + &buzz_sdk_pkg::ProfileFields { + display_name: display_name.as_deref(), + picture: avatar_url.as_deref(), + about: about.as_deref(), + nip05: nip05_handle.as_deref(), + ..Default::default() + }, + )?; submit_event(builder, &state).await?; // Re-fetch to return canonical profile. @@ -152,17 +149,16 @@ fn build_deferred_profile_event( avatar_url: &str, prior_event: Option<&nostr::Event>, ) -> Result { - let display_name = current.get("display_name").and_then(Value::as_str); - 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), - )), - ) + Ok(events::build_profile_merged( + ¤t.as_object().cloned().unwrap_or_default(), + &buzz_sdk_pkg::ProfileFields { + picture: Some(avatar_url), + ..Default::default() + }, + )? + .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..8e92f48311 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -470,30 +470,21 @@ pub fn build_set_canvas(channel_id: Uuid, content: &str) -> Result, - name: Option<&str>, - picture: Option<&str>, - about: Option<&str>, - nip05: Option<&str>, +/// Kind 0 — NIP-01 profile metadata, merging `fields` into the identity's +/// `current` profile content. +/// +/// kind:0 is replaceable, so publishing a rebuilt subset deletes every key it +/// omits. There is no full-replacement variant here on purpose: every desktop +/// profile write goes through a merge. +/// +/// The merge itself lives in `buzz-sdk` so the two crates share one +/// implementation — it is plain `serde_json`, so it crosses the nostr 0.36/0.37 +/// boundary that keeps the rest of these builders duplicated. +pub fn build_profile_merged( + current: &serde_json::Map, + fields: &buzz_sdk_pkg::ProfileFields<'_>, ) -> Result { - let mut map = serde_json::Map::new(); - if let Some(v) = display_name { - map.insert("display_name".into(), serde_json::Value::String(v.into())); - } - if let Some(v) = name { - map.insert("name".into(), serde_json::Value::String(v.into())); - } - if let Some(v) = picture { - map.insert("picture".into(), serde_json::Value::String(v.into())); - } - if let Some(v) = about { - map.insert("about".into(), serde_json::Value::String(v.into())); - } - if let Some(v) = nip05 { - map.insert("nip05".into(), serde_json::Value::String(v.into())); - } + let map = buzz_sdk_pkg::merge_profile_content(current, fields); let content = serde_json::Value::Object(map).to_string(); Ok(EventBuilder::new(Kind::Custom(0), content)) } diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 1c9ba0095a..2d96b10d2c 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -403,8 +403,16 @@ fn build_profile_event( display_name: &str, avatar_url: Option<&str>, auth_tag_json: Option<&str>, + current: &serde_json::Map, ) -> Result { - let builder = crate::events::build_profile(Some(display_name), None, avatar_url, None, None)?; + let builder = crate::events::build_profile_merged( + current, + &buzz_sdk_pkg::ProfileFields { + display_name: Some(display_name), + picture: avatar_url, + ..Default::default() + }, + )?; let builder = if let Some(tag_json) = auth_tag_json { // Bridge nostr 0.37 PublicKey → nostr 0.36 PublicKey via hex encoding. @@ -437,6 +445,12 @@ fn build_profile_event( /// /// The agent signs its own profile event and the NIP-98 HTTP-auth event, so no /// API token is required. +/// +/// kind:0 is replaceable, so the agent's published profile is read first and the +/// name/avatar are merged into it. Without that read this writes a two-field +/// event that deletes the agent's `about`, `nip05`, `bot` and any other key — +/// and it runs unattended, from agent save, model change, persona edit, snapshot +/// import and profile reconciliation. pub async fn sync_managed_agent_profile( state: &AppState, relay_url: &str, @@ -445,9 +459,18 @@ pub async fn sync_managed_agent_profile( avatar_url: Option<&str>, auth_tag: Option<&str>, // NIP-OA auth tag JSON ) -> Result<(), String> { + // Read the live profile so unmodelled keys survive the republish. A failed or + // absent read degrades to an empty base: the sync still publishes name and + // avatar rather than being blocked by a transient query error. + let current = query_agent_profile(state, relay_url, &agent_keys.public_key().to_hex()) + .await + .unwrap_or(None) + .map(|info| info.content) + .unwrap_or_default(); + crate::relay_admission::wait_for_rate_limit().await; // 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, display_name, avatar_url, auth_tag, ¤t)?; let event_json = event.as_json(); let body_bytes = event_json.into_bytes(); @@ -519,6 +542,7 @@ pub async fn query_agent_profile( .get("picture") .and_then(|v| v.as_str()) .map(str::to_string), + content: content.as_object().cloned().unwrap_or_default(), })) } @@ -527,6 +551,10 @@ pub async fn query_agent_profile( pub struct AgentProfileInfo { pub display_name: Option, pub picture: Option, + /// The full kind:0 content object, retained so a republish can merge into it + /// instead of replacing it. kind:0 is replaceable — dropping these keys on + /// the way out deletes them (`bot`, `nip05`, `website`, …). + pub content: serde_json::Map, } // ── Signed-event submission ───────────────────────────────────────────────── @@ -971,8 +999,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, + "TestBot", + None, + Some(&tag_json), + &serde_json::Map::new(), + ) + .expect("should succeed with a valid auth tag"); // Exactly one "auth" tag must be present. let auth_tags: Vec<_> = event @@ -986,11 +1020,52 @@ mod tests { assert_eq!(event.kind, nostr::Kind::Metadata); } + /// The managed-agent sync republishes name + avatar on agent save, model + /// change, persona edit, snapshot import and reconciliation — unattended. It + /// must merge into the agent's live profile, or those writes silently delete + /// the agent's `bot` flag along with everything else it does not model. + #[test] + fn profile_event_preserves_existing_profile_fields() { + let agent_keys = nostr::Keys::generate(); + let current = serde_json::json!({ + "display_name": "Old Name", + "picture": "https://example.com/old.png", + "about": "an agent", + "nip05": "agent@example.com", + "bot": true, + "website": "https://example.com", + }) + .as_object() + .cloned() + .expect("literal is an object"); + + let event = build_profile_event( + &agent_keys, + "New Name", + Some("https://example.com/new.png"), + None, + ¤t, + ) + .expect("should succeed without an auth tag"); + + let content: serde_json::Value = + serde_json::from_str(&event.content).expect("content should be JSON"); + // The two fields this path owns are updated… + assert_eq!(content["display_name"], "New Name"); + assert_eq!(content["picture"], "https://example.com/new.png"); + // …and everything else survives. + assert_eq!(content["about"], "an agent"); + assert_eq!(content["nip05"], "agent@example.com"); + assert_eq!(content["bot"], true); + assert_eq!(content["website"], "https://example.com"); + } + #[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, "TestBot", None, None, &serde_json::Map::new()) + .expect("should succeed without an auth tag"); // No "auth" tags should be present. let auth_tags: Vec<_> = event @@ -1008,7 +1083,13 @@ 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, + "TestBot", + None, + Some(&bad_json), + &serde_json::Map::new(), + ); assert!(result.is_err(), "should reject an invalid auth tag"); assert!( result.unwrap_err().contains("verification failed"),