Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 7 additions & 40 deletions crates/buzz-cli/src/commands/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
&current,
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}")))?;

Expand Down
51 changes: 50 additions & 1 deletion crates/buzz-sdk/src/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,30 @@ pub fn build_profile(
about: Option<&str>,
nip05: Option<&str>,
) -> Result<EventBuilder, SdkError> {
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<String, serde_json::Value>,
display_name: Option<&str>,
name: Option<&str>,
picture: Option<&str>,
about: Option<&str>,
nip05: Option<&str>,
) -> Result<EventBuilder, SdkError> {
let mut map = existing.clone();
if let Some(v) = display_name {
map.insert("display_name".into(), serde_json::Value::String(v.into()));
}
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/agents_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}

Expand Down
36 changes: 17 additions & 19 deletions desktop/src-tauri/src/commands/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Value>(&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::<serde_json::Value>(&event.content)
.ok()?
.as_object()
.cloned()
})
.unwrap_or_default();

let builder = events::build_profile_with_existing(
&current,
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.
Expand Down
7 changes: 4 additions & 3 deletions desktop/src-tauri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,15 +462,16 @@ pub fn build_set_canvas(channel_id: Uuid, content: &str) -> Result<EventBuilder,

// ── Profile ──────────────────────────────────────────────────────────────────

/// Kind 0 — NIP-01 profile metadata (full snapshot).
pub fn build_profile(
/// Build a kind:0 profile snapshot while retaining fields Buzz does not model.
pub fn build_profile_with_existing(
existing: &serde_json::Map<String, serde_json::Value>,
display_name: Option<&str>,
name: Option<&str>,
picture: Option<&str>,
about: Option<&str>,
nip05: Option<&str>,
) -> Result<EventBuilder, String> {
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()));
}
Expand Down
73 changes: 66 additions & 7 deletions desktop/src-tauri/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,11 +399,19 @@ pub fn parse_command_response<T: DeserializeOwned>(message: &str) -> Result<T, S
/// same wire format.
fn build_profile_event(
agent_keys: &nostr::Keys,
existing_profile: &serde_json::Map<String, serde_json::Value>,
display_name: &str,
avatar_url: Option<&str>,
auth_tag_json: Option<&str>,
) -> Result<nostr::Event, String> {
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.
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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(),
}))
}

Expand All @@ -526,6 +546,7 @@ pub async fn query_agent_profile(
pub struct AgentProfileInfo {
pub display_name: Option<String>,
pub picture: Option<String>,
pub metadata: serde_json::Map<String, serde_json::Value>,
}

// ── Signed-event submission ─────────────────────────────────────────────────
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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");
}
}