diff --git a/Justfile b/Justfile index bcef8983bcc..6ca7cd21a28 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/README.md b/crates/buzz-cli/README.md index a8c668cf062..bc093f1af61 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 3f8325b4b9d..34697b77d44 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -1,4 +1,7 @@ -use crate::client::{normalize_write_response, BuzzClient}; +use buzz_core::kind::KIND_MANAGED_AGENT; +use nostr::PublicKey; + +use crate::client::{extract_d_tag, 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,68 +84,269 @@ pub async fn cmd_get_users( Ok(()) } -/// 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( +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; + } + let pubkey = extract_d_tag(event); + (!pubkey.is_empty()).then_some(pubkey) + }) + .collect(); + pubkeys.sort(); + pubkeys.dedup(); + pubkeys +} + +async fn owned_agent_pubkeys_by_name( client: &BuzzClient, + owner: &str, query: &str, - format: &crate::OutputFormat, -) -> Result<(), CliError> { - if query.trim().is_empty() { - return Err(CliError::Usage("--name cannot be empty".into())); - } - +) -> Result, CliError> { let filter = serde_json::json!({ - "kinds": [0], - "search": query, - "limit": 100 + "kinds": [KIND_MANAGED_AGENT], + "authors": [owner], }); - let raw = client.query(&filter).await?; - - // 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 events = client.query_all(filter).await?; + Ok(owned_agent_pubkeys_from_events(&events, query)) +} - let Some(arr) = events.as_array() else { - println!("[]"); - return Ok(()); - }; +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(); - let profiles: Vec = arr + events .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 + let mut profile = profile_content(event); + let display_name = profile .get("display_name") - .and_then(|v| v.as_str()) + .and_then(|value| value.as_str()) + .unwrap_or(""); + let name = profile + .get("name") + .and_then(|value| value.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("")), - ); + 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 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") + .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 => "owner_mismatch", + Ok(_) if !auth_conditions_apply(auth_tag, event) => "condition_mismatch", + Ok(_) => "verified", + 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)); } - Some(profile) + serde_json::Value::Object(profile) }) - .collect(); + .collect() +} + +/// 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, + owner: Option<&str>, + format: &crate::OutputFormat, +) -> Result<(), CliError> { + if query.trim().is_empty() { + return Err(CliError::Usage("--name cannot be empty".into())); + } + + let owner = resolve_owner(client, 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 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 + }); + 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 output = match format { 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(), - })) + .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", "verification"] { + 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() } @@ -311,9 +520,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 +547,178 @@ pub async fn dispatch( #[cfg(test)] mod tests { - use super::presence_subject; + use super::{ + owned_agent_pubkeys_from_events, owner_scoped_profiles, owner_verification, + presence_subject, + }; + use nostr::Keys; 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()); + } + + 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, + }) + } + + #[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=0") + .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_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=0") + .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"]]}); diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 6ab81a082df..8b5215a0d38 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")] diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 4dd11428011..0a3b2ed5a23 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