From 378d58bda58fcb51b2926e9bf3ee6f0456363d3e Mon Sep 17 00:00:00 2001 From: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> Date: Mon, 27 Jul 2026 14:48:32 -0400 Subject: [PATCH 1/5] fix(cli): resolve agents from owner records Co-authored-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> Signed-off-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> --- crates/buzz-cli/README.md | 1 + crates/buzz-cli/src/commands/users.rs | 135 +++++++++++++++++++++++--- crates/buzz-cli/src/lib.rs | 3 + 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a8c668cf06..bc093f1af6 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -56,6 +56,7 @@ buzz reactions get --event buzz users get # your own profile buzz users get --pubkey # single user buzz users get --pubkey --pubkey # batch (max 200) +buzz users get --name Honey --owner me # exact-name lookup in your managed agents buzz users set-presence --status online # DMs diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 3f8325b4b9..a111026832 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -1,3 +1,6 @@ +use buzz_core::kind::KIND_MANAGED_AGENT; +use nostr::PublicKey; + use crate::client::{normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::validate_hex64; @@ -13,6 +16,7 @@ pub async fn cmd_get_users( client: &BuzzClient, pubkeys: &[String], name: Option<&str>, + owner: Option<&str>, format: &crate::OutputFormat, ) -> Result<(), CliError> { if let Some(query) = name { @@ -21,7 +25,11 @@ pub async fn cmd_get_users( "--name and --pubkey are mutually exclusive".into(), )); } - return search_by_name(client, query, format).await; + return search_by_name(client, query, owner, format).await; + } + + if owner.is_some() { + return Err(CliError::Usage("--owner requires --name".into())); } for pk in pubkeys { @@ -76,23 +84,95 @@ pub async fn cmd_get_users( Ok(()) } +fn effective_owner(client: &BuzzClient) -> String { + client + .auth_tag_owner_hex() + .unwrap_or_else(|| client.keys().public_key().to_hex()) +} + +fn resolve_owner(client: &BuzzClient, owner: Option<&str>) -> Result, CliError> { + owner + .map(|owner| { + if owner == "me" { + Ok(effective_owner(client)) + } else { + PublicKey::parse(owner) + .map(|pubkey| pubkey.to_hex()) + .map_err(|e| { + CliError::Usage(format!("--owner must be `me`, a pubkey, or npub: {e}")) + }) + } + }) + .transpose() +} + +fn owned_agent_pubkeys_from_events(events: &[serde_json::Value], query: &str) -> Vec { + let mut pubkeys: Vec = events + .iter() + .filter_map(|event| { + let content: serde_json::Value = + serde_json::from_str(event.get("content")?.as_str()?).ok()?; + let name = content.get("name")?.as_str()?; + if !name.eq_ignore_ascii_case(query) { + return None; + } + event.get("tags")?.as_array()?.iter().find_map(|tag| { + let tag = tag.as_array()?; + (tag.first()?.as_str()? == "d").then(|| tag.get(1)?.as_str().map(str::to_string))? + }) + }) + .collect(); + pubkeys.sort(); + pubkeys.dedup(); + pubkeys +} + +async fn owned_agent_pubkeys_by_name( + client: &BuzzClient, + owner: &str, + query: &str, +) -> Result, CliError> { + let filter = serde_json::json!({ + "kinds": [KIND_MANAGED_AGENT], + "authors": [owner], + }); + let events = client.query_all(filter).await?; + Ok(owned_agent_pubkeys_from_events(&events, query)) +} + /// Search for users by display name via NIP-50 full-text search on kind:0 profiles. /// Returns [] if the relay does not implement NIP-50 search. async fn search_by_name( client: &BuzzClient, query: &str, + owner: Option<&str>, format: &crate::OutputFormat, ) -> Result<(), CliError> { if query.trim().is_empty() { return Err(CliError::Usage("--name cannot be empty".into())); } - let filter = serde_json::json!({ - "kinds": [0], - "search": query, - "limit": 100 - }); - let raw = client.query(&filter).await?; + let owner = resolve_owner(client, owner)?; + let (raw, result_owner) = if let Some(owner) = owner { + let pubkeys = owned_agent_pubkeys_by_name(client, &owner, query).await?; + if pubkeys.is_empty() { + println!("[]"); + return Ok(()); + } + let filter = serde_json::json!({ + "kinds": [0], + "authors": pubkeys, + "limit": pubkeys.len(), + }); + (client.query(&filter).await?, Some(owner)) + } else { + let filter = serde_json::json!({ + "kinds": [0], + "search": query, + "limit": 100 + }); + (client.query(&filter).await?, None) + }; // Parse and filter client-side for case-insensitive substring match // on display_name or name fields (NIP-50 may return broader matches). @@ -126,6 +206,13 @@ async fn search_by_name( "pubkey".to_string(), serde_json::json!(event.get("pubkey").and_then(|v| v.as_str()).unwrap_or("")), ); + if let Some(owner) = &result_owner { + obj.insert("owner_pubkey".to_string(), serde_json::json!(owner)); + obj.insert( + "owned_by_me".to_string(), + serde_json::json!(owner == &effective_owner(client)), + ); + } } Some(profile) }) @@ -137,6 +224,8 @@ async fn search_by_name( .map(|p| serde_json::json!({ "pubkey": p.get("pubkey").cloned().unwrap_or_default(), "display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(), + "owner_pubkey": p.get("owner_pubkey").cloned().unwrap_or_default(), + "owned_by_me": p.get("owned_by_me").cloned().unwrap_or_default(), })) .collect(); serde_json::to_string(&compact).unwrap_or_default() @@ -311,9 +400,11 @@ pub async fn dispatch( ) -> Result<(), CliError> { use crate::UsersCmd; match cmd { - UsersCmd::Get { pubkeys, name } => { - cmd_get_users(client, &pubkeys, name.as_deref(), format).await - } + UsersCmd::Get { + pubkeys, + name, + owner, + } => cmd_get_users(client, &pubkeys, name.as_deref(), owner.as_deref(), format).await, UsersCmd::SetProfile { name, avatar, @@ -336,9 +427,31 @@ pub async fn dispatch( #[cfg(test)] mod tests { - use super::presence_subject; + use super::{owned_agent_pubkeys_from_events, presence_subject}; use serde_json::json; + #[test] + fn owned_agent_lookup_matches_exact_name_case_insensitively() { + let events = vec![ + json!({"content": r#"{"name":"Honey"}"#, "tags": [["d", "b"]]}), + json!({"content": r#"{"name":"Honeybee"}"#, "tags": [["d", "c"]]}), + json!({"content": r#"{"name":"honey"}"#, "tags": [["d", "a"]]}), + ]; + assert_eq!( + owned_agent_pubkeys_from_events(&events, "Honey"), + vec!["a", "b"] + ); + } + + #[test] + fn owned_agent_lookup_ignores_malformed_events() { + let events = vec![ + json!({"content": "not json", "tags": [["d", "a"]]}), + json!({"content": r#"{"name":"Honey"}"#, "tags": [["p", "b"]]}), + ]; + assert!(owned_agent_pubkeys_from_events(&events, "Honey").is_empty()); + } + #[test] fn presence_subject_uses_p_tag() { let event = json!({"pubkey": "relay", "tags": [["p", "user"]]}); diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 6ab81a082d..8b5215a0d3 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -808,6 +808,9 @@ pub enum UsersCmd { /// Search by display name (case-insensitive substring match) #[arg(long = "name")] name: Option, + /// Scope an exact-name agent lookup to its owner (`me`, hex, or npub) + #[arg(long = "owner", requires = "name")] + owner: Option, }, /// Update the current identity's profile #[command(name = "set-profile")] From 1654cafd53afec8a3f718c0ed25d1eba88b4923e Mon Sep 17 00:00:00 2001 From: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> Date: Mon, 27 Jul 2026 14:54:54 -0400 Subject: [PATCH 2/5] fix(cli): preserve unscoped compact output Co-authored-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> Signed-off-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> --- crates/buzz-cli/src/commands/users.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index a111026832..129deac3e1 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -221,12 +221,20 @@ async fn search_by_name( crate::OutputFormat::Compact => { let compact: Vec = profiles .iter() - .map(|p| serde_json::json!({ - "pubkey": p.get("pubkey").cloned().unwrap_or_default(), - "display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(), - "owner_pubkey": p.get("owner_pubkey").cloned().unwrap_or_default(), - "owned_by_me": p.get("owned_by_me").cloned().unwrap_or_default(), - })) + .map(|p| { + let mut value = serde_json::json!({ + "pubkey": p.get("pubkey").cloned().unwrap_or_default(), + "display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(), + }); + if let Some(obj) = value.as_object_mut() { + for field in ["owner_pubkey", "owned_by_me"] { + if let Some(field_value) = p.get(field) { + obj.insert(field.to_string(), field_value.clone()); + } + } + } + value + }) .collect(); serde_json::to_string(&compact).unwrap_or_default() } From 906072bd980f77ceb311fdd7ff635d7b7e7962b9 Mon Sep 17 00:00:00 2001 From: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> Date: Tue, 28 Jul 2026 14:41:29 -0400 Subject: [PATCH 3/5] fix(cli): verify agent ownership Co-authored-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> Signed-off-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> --- crates/buzz-cli/src/commands/users.rs | 317 +++++++++++++++++++++----- 1 file changed, 258 insertions(+), 59 deletions(-) diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 129deac3e1..b242b93e3c 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -1,7 +1,7 @@ use buzz_core::kind::KIND_MANAGED_AGENT; use nostr::PublicKey; -use crate::client::{normalize_write_response, BuzzClient}; +use crate::client::{extract_d_tag, normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::validate_hex64; @@ -116,10 +116,8 @@ fn owned_agent_pubkeys_from_events(events: &[serde_json::Value], query: &str) -> if !name.eq_ignore_ascii_case(query) { return None; } - event.get("tags")?.as_array()?.iter().find_map(|tag| { - let tag = tag.as_array()?; - (tag.first()?.as_str()? == "d").then(|| tag.get(1)?.as_str().map(str::to_string))? - }) + let pubkey = extract_d_tag(event); + (!pubkey.is_empty()).then_some(pubkey) }) .collect(); pubkeys.sort(); @@ -140,6 +138,121 @@ async fn owned_agent_pubkeys_by_name( Ok(owned_agent_pubkeys_from_events(&events, query)) } +fn profile_content(event: &serde_json::Value) -> serde_json::Map { + event + .get("content") + .and_then(|value| value.as_str()) + .and_then(|content| serde_json::from_str::(content).ok()) + .and_then(|content| content.as_object().cloned()) + .unwrap_or_default() +} + +fn name_search_profiles(events: &[serde_json::Value], query: &str) -> Vec { + let lower_query = query.to_ascii_lowercase(); + events + .iter() + .filter_map(|event| { + let mut profile = profile_content(event); + let display_name = profile + .get("display_name") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let name = profile + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or(""); + if !display_name.to_ascii_lowercase().contains(&lower_query) + && !name.to_ascii_lowercase().contains(&lower_query) + { + return None; + } + profile.insert( + "pubkey".to_string(), + serde_json::json!(event + .get("pubkey") + .and_then(|value| value.as_str()) + .unwrap_or("")), + ); + Some(serde_json::Value::Object(profile)) + }) + .collect() +} + +fn auth_tag_values(event: &serde_json::Value) -> Vec<&serde_json::Value> { + event + .get("tags") + .and_then(|tags| tags.as_array()) + .into_iter() + .flatten() + .filter(|tag| { + tag.as_array() + .and_then(|values| values.first()) + .and_then(|value| value.as_str()) + == Some("auth") + }) + .collect() +} + +fn owner_verification(event: &serde_json::Value, expected_owner: &str) -> &'static str { + let Some(agent_pubkey) = event + .get("pubkey") + .and_then(|value| value.as_str()) + .and_then(|value| PublicKey::parse(value).ok()) + else { + return "invalid_agent_pubkey"; + }; + let auth_tags = auth_tag_values(event); + let [auth_tag] = auth_tags.as_slice() else { + return if auth_tags.is_empty() { + "missing_auth" + } else { + "multiple_auth_tags" + }; + }; + let Ok(auth_tag_json) = serde_json::to_string(auth_tag) else { + return "invalid_auth"; + }; + match buzz_sdk::nip_oa::verify_auth_tag(&auth_tag_json, &agent_pubkey) { + Ok(owner) if owner.to_hex() == expected_owner => "verified", + Ok(_) => "owner_mismatch", + Err(_) => "invalid_auth", + } +} + +fn owner_scoped_profiles( + events: &[serde_json::Value], + pubkeys: &[String], + owner: &str, + effective_owner: &str, +) -> Vec { + pubkeys + .iter() + .map(|pubkey| { + let event = events.iter().find(|event| { + event.get("pubkey").and_then(|value| value.as_str()) == Some(pubkey.as_str()) + }); + let mut profile = event.map(profile_content).unwrap_or_default(); + let verification = if PublicKey::parse(pubkey).is_err() { + "invalid_agent_pubkey" + } else { + event + .map(|event| owner_verification(event, owner)) + .unwrap_or("missing_profile") + }; + profile.insert("pubkey".to_string(), serde_json::json!(pubkey)); + profile.insert("verification".to_string(), serde_json::json!(verification)); + profile.insert( + "owned_by_me".to_string(), + serde_json::json!(verification == "verified" && owner == effective_owner), + ); + if verification == "verified" { + profile.insert("owner_pubkey".to_string(), serde_json::json!(owner)); + } + serde_json::Value::Object(profile) + }) + .collect() +} + /// Search for users by display name via NIP-50 full-text search on kind:0 profiles. /// Returns [] if the relay does not implement NIP-50 search. async fn search_by_name( @@ -153,70 +266,40 @@ async fn search_by_name( } let owner = resolve_owner(client, owner)?; - let (raw, result_owner) = if let Some(owner) = owner { + let profiles = if let Some(owner) = owner { let pubkeys = owned_agent_pubkeys_by_name(client, &owner, query).await?; if pubkeys.is_empty() { println!("[]"); return Ok(()); } - let filter = serde_json::json!({ - "kinds": [0], - "authors": pubkeys, - "limit": pubkeys.len(), - }); - (client.query(&filter).await?, Some(owner)) + let valid_pubkeys: Vec<&String> = pubkeys + .iter() + .filter(|pubkey| PublicKey::parse(pubkey.as_str()).is_ok()) + .collect(); + let events = if valid_pubkeys.is_empty() { + Vec::new() + } else { + let filter = serde_json::json!({ + "kinds": [0], + "authors": valid_pubkeys, + "limit": valid_pubkeys.len(), + }); + let raw = client.query(&filter).await?; + serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("failed to parse response: {e}")))? + }; + owner_scoped_profiles(&events, &pubkeys, &owner, &effective_owner(client)) } else { let filter = serde_json::json!({ "kinds": [0], "search": query, "limit": 100 }); - (client.query(&filter).await?, None) - }; - - // Parse and filter client-side for case-insensitive substring match - // on display_name or name fields (NIP-50 may return broader matches). - let events: serde_json::Value = serde_json::from_str(&raw) - .map_err(|e| CliError::Other(format!("failed to parse response: {e}")))?; - - let Some(arr) = events.as_array() else { - println!("[]"); - return Ok(()); + let raw = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("failed to parse response: {e}")))?; + name_search_profiles(&events, query) }; - - let lower_query = query.to_ascii_lowercase(); - let profiles: Vec = arr - .iter() - .filter_map(|event| { - let content_str = event.get("content").and_then(|v| v.as_str())?; - let content: serde_json::Value = serde_json::from_str(content_str).ok()?; - let display_name = content - .get("display_name") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let name = content.get("name").and_then(|v| v.as_str()).unwrap_or(""); - if !display_name.to_ascii_lowercase().contains(&lower_query) - && !name.to_ascii_lowercase().contains(&lower_query) - { - return None; - } - let mut profile = content; - if let Some(obj) = profile.as_object_mut() { - obj.insert( - "pubkey".to_string(), - serde_json::json!(event.get("pubkey").and_then(|v| v.as_str()).unwrap_or("")), - ); - if let Some(owner) = &result_owner { - obj.insert("owner_pubkey".to_string(), serde_json::json!(owner)); - obj.insert( - "owned_by_me".to_string(), - serde_json::json!(owner == &effective_owner(client)), - ); - } - } - Some(profile) - }) - .collect(); let output = match format { crate::OutputFormat::Compact => { let compact: Vec = profiles @@ -227,7 +310,7 @@ async fn search_by_name( "display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(), }); if let Some(obj) = value.as_object_mut() { - for field in ["owner_pubkey", "owned_by_me"] { + for field in ["owner_pubkey", "owned_by_me", "verification"] { if let Some(field_value) = p.get(field) { obj.insert(field.to_string(), field_value.clone()); } @@ -435,7 +518,11 @@ pub async fn dispatch( #[cfg(test)] mod tests { - use super::{owned_agent_pubkeys_from_events, presence_subject}; + use super::{ + owned_agent_pubkeys_from_events, owner_scoped_profiles, owner_verification, + presence_subject, + }; + use nostr::Keys; use serde_json::json; #[test] @@ -460,6 +547,118 @@ mod tests { assert!(owned_agent_pubkeys_from_events(&events, "Honey").is_empty()); } + fn profile_event(agent_keys: &Keys, auth_tags: Vec) -> serde_json::Value { + json!({ + "pubkey": agent_keys.public_key().to_hex(), + "content": r#"{"display_name":"Renamed Honey"}"#, + "tags": auth_tags, + }) + } + + #[test] + fn owner_verification_requires_one_valid_auth_tag_for_requested_owner() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let foreign_owner_keys = Keys::generate(); + let valid_tag: serde_json::Value = serde_json::from_str( + &buzz_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "kind=9") + .unwrap(), + ) + .unwrap(); + let foreign_tag: serde_json::Value = serde_json::from_str( + &buzz_sdk::nip_oa::compute_auth_tag( + &foreign_owner_keys, + &agent_keys.public_key(), + "kind=9", + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!( + owner_verification( + &profile_event(&agent_keys, vec![valid_tag.clone()]), + &owner_keys.public_key().to_hex(), + ), + "verified" + ); + assert_eq!( + owner_verification( + &profile_event(&agent_keys, vec![foreign_tag]), + &owner_keys.public_key().to_hex(), + ), + "owner_mismatch" + ); + assert_eq!( + owner_verification( + &profile_event(&agent_keys, vec![]), + &owner_keys.public_key().to_hex() + ), + "missing_auth" + ); + assert_eq!( + owner_verification( + &profile_event(&agent_keys, vec![valid_tag.clone(), valid_tag]), + &owner_keys.public_key().to_hex(), + ), + "multiple_auth_tags" + ); + assert_eq!( + owner_verification( + &profile_event( + &agent_keys, + vec![json!([ + "auth", + owner_keys.public_key().to_hex(), + "kind=9", + "0".repeat(128) + ])], + ), + &owner_keys.public_key().to_hex(), + ), + "invalid_auth" + ); + } + + #[test] + fn owner_scoped_profiles_keep_drifted_and_missing_profiles_without_claiming_ownership() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let missing_keys = Keys::generate(); + let auth_tag: serde_json::Value = serde_json::from_str( + &buzz_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "kind=9") + .unwrap(), + ) + .unwrap(); + let events = vec![profile_event(&agent_keys, vec![auth_tag])]; + let pubkeys = vec![ + agent_keys.public_key().to_hex(), + missing_keys.public_key().to_hex(), + "malformed".to_string(), + ]; + + let profiles = owner_scoped_profiles( + &events, + &pubkeys, + &owner_keys.public_key().to_hex(), + &owner_keys.public_key().to_hex(), + ); + + assert_eq!(profiles[0]["display_name"], "Renamed Honey"); + assert_eq!(profiles[0]["verification"], "verified"); + assert_eq!(profiles[0]["owned_by_me"], true); + assert_eq!( + profiles[0]["owner_pubkey"], + owner_keys.public_key().to_hex() + ); + assert_eq!(profiles[1]["verification"], "missing_profile"); + assert_eq!(profiles[1]["owned_by_me"], false); + assert!(profiles[1].get("owner_pubkey").is_none()); + assert_eq!(profiles[2]["verification"], "invalid_agent_pubkey"); + assert_eq!(profiles[2]["owned_by_me"], false); + assert!(profiles[2].get("owner_pubkey").is_none()); + } + #[test] fn presence_subject_uses_p_tag() { let event = json!({"pubkey": "relay", "tags": [["p", "user"]]}); From a7c177ae15b78713503c00754fc5ea1604e0987a Mon Sep 17 00:00:00 2001 From: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> Date: Wed, 29 Jul 2026 14:51:09 -0400 Subject: [PATCH 4/5] fix(cli): apply auth conditions to profiles Co-authored-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> Signed-off-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> --- Justfile | 1 + crates/buzz-cli/src/commands/users.rs | 68 +++++++++++++++++++++++++-- scripts/run-tests.sh | 3 ++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/Justfile b/Justfile index bcef8983bc..6ca7cd21a2 100644 --- a/Justfile +++ b/Justfile @@ -276,6 +276,7 @@ test-unit: #!/usr/bin/env bash if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib + cargo nextest run -p buzz-cli # buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra). # They guard the embedded-migrator invariant (exactly the consolidated # 0001; cutover/backfill stays an operator script, not startup state) diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index b242b93e3c..118d500c55 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -193,6 +193,34 @@ fn auth_tag_values(event: &serde_json::Value) -> Vec<&serde_json::Value> { .collect() } +fn auth_conditions_apply(auth_tag: &serde_json::Value, event: &serde_json::Value) -> bool { + let Some(conditions) = auth_tag + .as_array() + .and_then(|values| values.get(2)) + .and_then(|value| value.as_str()) + else { + return false; + }; + let Some(kind) = event.get("kind").and_then(|value| value.as_u64()) else { + return false; + }; + let Some(created_at) = event.get("created_at").and_then(|value| value.as_u64()) else { + return false; + }; + + conditions.split('&').all(|clause| { + if let Some(value) = clause.strip_prefix("kind=") { + value.parse::() == Ok(kind) + } else if let Some(value) = clause.strip_prefix("created_at<") { + value.parse::().is_ok_and(|bound| created_at < bound) + } else if let Some(value) = clause.strip_prefix("created_at>") { + value.parse::().is_ok_and(|bound| created_at > bound) + } else { + clause.is_empty() + } + }) +} + fn owner_verification(event: &serde_json::Value, expected_owner: &str) -> &'static str { let Some(agent_pubkey) = event .get("pubkey") @@ -213,8 +241,9 @@ fn owner_verification(event: &serde_json::Value, expected_owner: &str) -> &'stat return "invalid_auth"; }; match buzz_sdk::nip_oa::verify_auth_tag(&auth_tag_json, &agent_pubkey) { - Ok(owner) if owner.to_hex() == expected_owner => "verified", - Ok(_) => "owner_mismatch", + Ok(owner) if owner.to_hex() != expected_owner => "owner_mismatch", + Ok(_) if !auth_conditions_apply(auth_tag, event) => "condition_mismatch", + Ok(_) => "verified", Err(_) => "invalid_auth", } } @@ -550,6 +579,8 @@ mod tests { fn profile_event(agent_keys: &Keys, auth_tags: Vec) -> serde_json::Value { json!({ "pubkey": agent_keys.public_key().to_hex(), + "kind": 0, + "created_at": 100, "content": r#"{"display_name":"Renamed Honey"}"#, "tags": auth_tags, }) @@ -561,7 +592,7 @@ mod tests { let agent_keys = Keys::generate(); let foreign_owner_keys = Keys::generate(); let valid_tag: serde_json::Value = serde_json::from_str( - &buzz_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "kind=9") + &buzz_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "kind=0") .unwrap(), ) .unwrap(); @@ -620,13 +651,42 @@ mod tests { ); } + #[test] + fn owner_verification_requires_conditions_to_apply_to_profile_event() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let verification = |conditions: &str| { + let auth_tag: serde_json::Value = serde_json::from_str( + &buzz_sdk::nip_oa::compute_auth_tag( + &owner_keys, + &agent_keys.public_key(), + conditions, + ) + .unwrap(), + ) + .unwrap(); + owner_verification( + &profile_event(&agent_keys, vec![auth_tag]), + &owner_keys.public_key().to_hex(), + ) + }; + + assert_eq!(verification("kind=9"), "condition_mismatch"); + assert_eq!(verification("created_at<100"), "condition_mismatch"); + assert_eq!(verification("created_at>100"), "condition_mismatch"); + assert_eq!( + verification("kind=0&created_at>99&created_at<101"), + "verified" + ); + } + #[test] fn owner_scoped_profiles_keep_drifted_and_missing_profiles_without_claiming_ownership() { let owner_keys = Keys::generate(); let agent_keys = Keys::generate(); let missing_keys = Keys::generate(); let auth_tag: serde_json::Value = serde_json::from_str( - &buzz_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "kind=9") + &buzz_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "kind=0") .unwrap(), ) .unwrap(); diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 4dd1142801..0a3b2ed5a2 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -84,6 +84,9 @@ run_unit_tests() { run_test_step "buzz-auth unit tests" \ cargo test -p buzz-auth --lib -- --nocapture + run_test_step "buzz-cli tests" \ + cargo test -p buzz-cli -- --nocapture + # buzz-db migrator/lint unit tests (no infra): guard the embedded-migrator # invariant (exactly the consolidated 0001; cutover/backfill stays an operator # script, not startup state) and the tenant-scoping lints. The Postgres-backed From 4275a48d1b65cdbb590a90541bad843cd667d995 Mon Sep 17 00:00:00 2001 From: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> Date: Wed, 29 Jul 2026 15:12:56 -0400 Subject: [PATCH 5/5] docs(cli): clarify user lookup modes Co-authored-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> Signed-off-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz> --- crates/buzz-cli/src/commands/users.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 118d500c55..34697b77d4 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -282,8 +282,8 @@ fn owner_scoped_profiles( .collect() } -/// Search for users by display name via NIP-50 full-text search on kind:0 profiles. -/// Returns [] if the relay does not implement NIP-50 search. +/// Search for users by display name. Owner-scoped searches resolve managed-agent records +/// and verify their profiles; unscoped searches use NIP-50 and return [] if unsupported. async fn search_by_name( client: &BuzzClient, query: &str,