feat(cli): find people by name through the relay's name-only profile search

`buzz users get --name`, `messages search --author <name>` and
`notes --author <name>` searched kind:0 with NIP-50, which matches the
whole profile JSON. Agent profiles that share a default avatar whose data
contains "Wes" filled all 100 results, so `--name wes` never found Wes.

All three now use the relay's POST /profiles/search, which matches only
name and display_name and ranks exact names first. A relay without the
endpoint answers 404 or 405, and the lookup uses the old NIP-50 search.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-10-14 16:41:59 -04:00
parent a83ea69b72
commit 3ce8799379
5 changed files with 159 additions and 23 deletions

View File

@@ -514,7 +514,7 @@ pub async fn cmd_search(
/// Resolve an `--author` value to a 64-char hex pubkey.
///
/// Accepts, in order of precedence: 64-char hex (validated), an `npub1…`
/// bech32 key, or a display name resolved via NIP-50 profile search. A name
/// bech32 key, or a display name resolved via profile name search. A name
/// must match exactly one user (case-insensitive, on `display_name` or
/// `name`) — ambiguity is an error listing the candidates rather than a
/// silent mix of authors.
@@ -529,13 +529,8 @@ async fn resolve_author(client: &BuzzClient, author: &str) -> Result<String, Cli
.map_err(|_| CliError::Usage(format!("invalid npub: {author}")));
}
// Display name → NIP-50 search on kind:0, exact case-insensitive match.
let filter = serde_json::json!({
"kinds": [0],
"search": author,
"limit": 100
});
let raw = client.query(&filter).await?;
// Display name → profile name search, exact case-insensitive match.
let raw = crate::profile_search::search_profiles(client, author, 100).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw).unwrap_or_default();
let mut matches = match_profiles_by_name(&events, author);
match matches.len() {

View File

@@ -209,13 +209,8 @@ pub async fn resolve_author(client: &BuzzClient, author_flag: &str) -> Result<Pu
return PublicKey::from_hex(author_flag)
.map_err(|e| CliError::Usage(format!("invalid pubkey: {e}")));
}
// Petname lookup: NIP-50 search on kind:0, then exact-name filter.
let filter = serde_json::json!({
"kinds": [0],
"search": author_flag,
"limit": 100,
});
let raw = client.query(&filter).await?;
// Petname lookup: profile name search, then exact-name filter.
let raw = crate::profile_search::search_profiles(client, author_flag, 100).await?;
let events = parse_events(&raw)?;
let lower = author_flag.to_ascii_lowercase();
let matches: Vec<&Event> = events

View File

@@ -11,7 +11,7 @@ use crate::validate::validate_hex64;
///
/// - 0 pubkeys, no name → query our own profile
/// - 1+ pubkeys → query those users' profiles
/// - --name "foo" → NIP-50 search on kind:0, then client-side filter
/// - --name "foo" → profile name search, then client-side filter
pub async fn cmd_get_users(
client: &BuzzClient,
pubkeys: &[String],
@@ -283,7 +283,7 @@ fn owner_scoped_profiles(
}
/// 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.
/// and verify their profiles; unscoped searches use the relay's profile name search.
async fn search_by_name(
client: &BuzzClient,
query: &str,
@@ -319,12 +319,7 @@ async fn search_by_name(
};
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 raw = crate::profile_search::search_profiles(client, query, 100).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse response: {e}")))?;
name_search_profiles(&events, query)

View File

@@ -4,6 +4,7 @@ mod commands;
mod error;
mod help_tree;
mod links;
mod profile_search;
mod validate;
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};

View File

@@ -0,0 +1,150 @@
//! Finding people by profile name.
//!
//! NIP-50 search over kind:0 matches the whole profile JSON, so text inside
//! avatar data URLs, `about` or `nip05` competes with real names: a shared
//! default avatar can fill every result page and hide the person a name
//! lookup asked for. The relay's `POST /profiles/search` matches only `name`
//! and `display_name` and puts exact and prefix name matches first.
use crate::client::BuzzClient;
use crate::error::CliError;
/// The relay's name search path.
const SEARCH_PATH: &str = "/profiles/search";
/// Longest query the name search accepts, in characters after trimming.
const QUERY_MAX_CHARS: usize = 100;
/// Largest page the name search returns.
const LIMIT_MAX: u32 = 100;
/// Returns up to `limit` kind:0 events whose names match `query`, as the raw
/// JSON array the relay sent.
///
/// Uses the relay's name search. A relay without it answers 404 or 405; the
/// search then uses the NIP-50 kind:0 search that callers used before.
/// Requests the name search cannot take (a query over 100 characters, or a
/// `limit` over 100) also use NIP-50, as before. Other errors are returned.
/// Callers still filter the events by name.
pub(crate) async fn search_profiles(
client: &BuzzClient,
query: &str,
limit: u32,
) -> Result<String, CliError> {
let text = query.trim();
if !text.is_empty()
&& text.chars().count() <= QUERY_MAX_CHARS
&& (1..=LIMIT_MAX).contains(&limit)
{
let body = serde_json::json!({ "query": text, "limit": limit });
match client.post_json_authed(SEARCH_PATH, &body).await {
Ok(raw) => return Ok(raw),
Err(CliError::Relay {
status: 404 | 405, ..
}) => {}
Err(error) => return Err(error),
}
}
client
.query(&serde_json::json!({
"kinds": [0],
"search": query,
"limit": limit,
}))
.await
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::post;
use axum::Router;
use nostr::Keys;
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
type Seen = Arc<Mutex<Vec<(String, serde_json::Value)>>>;
/// A relay whose name search answers `status`; `/query` answers `[]`.
async fn relay(status: StatusCode) -> (BuzzClient, Seen) {
let seen: Seen = Arc::default();
let app = Router::new()
.route(
SEARCH_PATH,
post(move |State(seen): State<Seen>, body: Bytes| async move {
let body = serde_json::from_slice(&body).unwrap();
seen.lock().unwrap().push((SEARCH_PATH.into(), body));
(status, r#"[{"kind":0,"from":"names"}]"#)
}),
)
.route(
"/query",
post(|State(seen): State<Seen>, body: Bytes| async move {
let body = serde_json::from_slice(&body).unwrap();
seen.lock().unwrap().push(("/query".into(), body));
"[]"
}),
)
.with_state(seen.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async { axum::serve(listener, app).await.unwrap() });
let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap();
(client, seen)
}
fn nip50(query: &str, limit: u32) -> (String, serde_json::Value) {
(
"/query".into(),
serde_json::json!([{ "kinds": [0], "search": query, "limit": limit }]),
)
}
#[tokio::test]
async fn uses_the_name_search() {
let (client, seen) = relay(StatusCode::OK).await;
let raw = search_profiles(&client, " Wes ", 100).await.unwrap();
assert_eq!(raw, r#"[{"kind":0,"from":"names"}]"#);
assert_eq!(
*seen.lock().unwrap(),
[(
SEARCH_PATH.to_string(),
serde_json::json!({ "query": "Wes", "limit": 100 })
)]
);
}
#[tokio::test]
async fn a_relay_without_the_name_search_uses_nip50() {
for status in [StatusCode::NOT_FOUND, StatusCode::METHOD_NOT_ALLOWED] {
let (client, seen) = relay(status).await;
assert_eq!(search_profiles(&client, "Wes", 100).await.unwrap(), "[]");
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 2);
assert_eq!(seen[1], nip50("Wes", 100));
}
}
#[tokio::test]
async fn other_name_search_errors_are_returned() {
let (client, seen) = relay(StatusCode::INTERNAL_SERVER_ERROR).await;
let error = search_profiles(&client, "Wes", 100).await.unwrap_err();
assert!(matches!(error, CliError::Relay { status: 500, .. }));
assert!(seen
.lock()
.unwrap()
.iter()
.all(|(path, _)| path != "/query"));
}
#[tokio::test]
async fn requests_the_name_search_cannot_take_use_nip50() {
let long = "w".repeat(QUERY_MAX_CHARS + 1);
for (query, limit) in [(long.as_str(), 100), ("Wes", 101), (" ", 100)] {
let (client, seen) = relay(StatusCode::OK).await;
search_profiles(&client, query, limit).await.unwrap();
assert_eq!(*seen.lock().unwrap(), [nip50(query, limit)]);
}
}
}