diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index ef67fac570..8faf589240 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use buzz_core_pkg::PresenceStatus; +use nostr::PublicKey; use serde_json::Value; use tauri::State; @@ -261,6 +262,17 @@ fn build_user_search_filter(query: &str, limit: usize, page: u32) -> serde_json: }) } +fn parse_exact_pubkey_query(query: &str) -> Option { + let trimmed = query.trim(); + let is_hex = trimmed.len() == 64 && trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()); + let is_npub = trimmed.to_ascii_lowercase().starts_with("npub1"); + if !is_hex && !is_npub { + return None; + } + + PublicKey::parse(trimmed).ok() +} + #[tauri::command] pub async fn search_users( query: String, @@ -306,6 +318,32 @@ pub async fn search_users( return Ok(response); } + if let Some(target) = parse_exact_pubkey_query(trimmed) { + let target_hex = target.to_hex(); + let events = query_relay( + &state, + &[ + serde_json::json!({ + "kinds": [0], + "authors": [target_hex], + "limit": 1 + }), + serde_json::json!({ + "kinds": [13534], + "limit": 1 + }), + ], + ) + .await?; + let users = nostr_convert::exact_user_search_result(&events, &target) + .into_iter() + .collect(); + return Ok(SearchUsersResponse { + users, + next_cursor: None, + }); + } + // NIP-50 full-text search on kind:0 profiles. The relay's HTTP bridge // intercepts the `search` field on POST /query and routes to Postgres FTS // (see `crates/buzz-relay/src/api/bridge.rs::handle_bridge_search`), @@ -417,6 +455,7 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo { #[cfg(test)] mod tests { use super::*; + use nostr::ToBech32; #[test] fn deferred_profile_signer_is_captured_and_rejects_wrong_identity() { @@ -481,4 +520,23 @@ mod tests { assert_eq!(filter["limit"], serde_json::json!(25)); assert_eq!(filter["page"], serde_json::json!(1)); } + + #[test] + fn exact_pubkey_query_accepts_hex_and_npub() { + let public_key = nostr::Keys::generate().public_key(); + let hex = public_key.to_hex(); + let npub = public_key.to_bech32().expect("encode npub"); + + assert_eq!(parse_exact_pubkey_query(&hex), Some(public_key)); + assert_eq!(parse_exact_pubkey_query(&npub), Some(public_key)); + } + + #[test] + fn exact_pubkey_query_rejects_prefixes_and_other_text() { + let public_key = nostr::Keys::generate().public_key().to_hex(); + + assert_eq!(parse_exact_pubkey_query(&public_key[..16]), None); + assert_eq!(parse_exact_pubkey_query("alice"), None); + assert_eq!(parse_exact_pubkey_query("note1invalid"), None); + } } diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index ec4970e0c9..781b1216a5 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -16,8 +16,8 @@ use crate::models::*; mod user_search; pub use user_search::{ - list_user_search_results, rank_user_search_results, search_users_from_events, - user_search_result_from_event, + exact_user_search_result, list_user_search_results, rank_user_search_results, + search_users_from_events, user_search_result_from_event, }; // ── Tag helpers ───────────────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/nostr_convert/user_search.rs b/desktop/src-tauri/src/nostr_convert/user_search.rs index 43b4288abb..76bcd6a91c 100644 --- a/desktop/src-tauri/src/nostr_convert/user_search.rs +++ b/desktop/src-tauri/src/nostr_convert/user_search.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, HashSet}; -use nostr::Event; +use nostr::{Event, PublicKey}; use serde_json::Value; use crate::models::{SearchUsersResponse, UserSearchResultInfo}; @@ -34,6 +34,53 @@ pub fn search_users_from_events(events: &[Event]) -> SearchUsersResponse { } } +/// Resolve an exact pubkey query from profile and relay-membership events. +/// +/// A NIP-43 community member may not have published kind:0 metadata yet. In +/// that case the membership snapshot is still authoritative enough to expose a +/// bare-key search result. When a snapshot exists, identities outside it fail +/// closed; when no snapshot exists, the relay is treated as open. +pub fn exact_user_search_result( + events: &[Event], + target: &PublicKey, +) -> Option { + let target_hex = target.to_hex(); + let membership_event = events + .iter() + .filter(|event| event.kind.as_u16() == 13534) + .max_by_key(|event| event.created_at); + + if membership_event.is_some_and(|event| !relay_membership_contains(event, &target_hex)) { + return None; + } + + events + .iter() + .filter(|event| event.kind.as_u16() == 0 && event.pubkey == *target) + .max_by_key(|event| event.created_at) + .map(user_search_result_from_event) + .or(Some(UserSearchResultInfo { + pubkey: target_hex, + display_name: None, + avatar_url: None, + nip05_handle: None, + owner_pubkey: None, + is_agent: false, + })) +} + +fn relay_membership_contains(event: &Event, target_hex: &str) -> bool { + event.tags.iter().any(|tag| { + let values = tag.as_slice(); + matches!( + values.first().map(String::as_str), + Some("member") | Some("p") + ) && values + .get(1) + .is_some_and(|pubkey| pubkey.eq_ignore_ascii_case(target_hex)) + }) +} + /// Convert a default kind:0 page to user-search results for empty-query pickers. pub fn list_user_search_results(events: &[Event], limit: usize) -> SearchUsersResponse { if limit == 0 { @@ -228,6 +275,24 @@ mod tests { .expect("sign") } + fn membership_event(members: &[&str]) -> Event { + let tags = members + .iter() + .map(|pubkey| { + Tag::parse(vec![ + "member".to_string(), + (*pubkey).to_string(), + "member".to_string(), + ]) + .expect("membership tag") + }) + .collect::>(); + EventBuilder::new(Kind::from_u16(13534), "") + .tags(tags) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign membership") + } + #[test] fn search_users_maps_each_event() { let e1 = ev(0, r#"{"name":"a"}"#, vec![]); @@ -238,6 +303,54 @@ mod tests { assert_eq!(r.users[1].display_name.as_deref(), Some("B")); } + #[test] + fn exact_search_returns_profileless_relay_member() { + let target = nostr::Keys::generate().public_key(); + let membership = membership_event(&[&target.to_hex()]); + + let result = exact_user_search_result(std::slice::from_ref(&membership), &target).unwrap(); + + assert_eq!(result.pubkey, target.to_hex()); + assert_eq!(result.display_name, None); + assert_eq!(result.avatar_url, None); + assert_eq!(result.nip05_handle, None); + assert!(!result.is_agent); + } + + #[test] + fn exact_search_preserves_profile_for_relay_member() { + let keys = nostr::Keys::generate(); + let target = keys.public_key(); + let profile = EventBuilder::metadata(&nostr::Metadata::new().name("Ada")) + .sign_with_keys(&keys) + .expect("sign profile"); + let membership = membership_event(&[&target.to_hex()]); + + let result = exact_user_search_result(&[profile, membership], &target).unwrap(); + + assert_eq!(result.pubkey, target.to_hex()); + assert_eq!(result.display_name.as_deref(), Some("Ada")); + } + + #[test] + fn exact_search_rejects_non_member_when_snapshot_exists() { + let member = nostr::Keys::generate().public_key(); + let outsider = nostr::Keys::generate().public_key(); + let membership = membership_event(&[&member.to_hex()]); + + assert!(exact_user_search_result(&[membership], &outsider).is_none()); + } + + #[test] + fn exact_search_allows_bare_key_when_relay_has_no_membership_snapshot() { + let target = nostr::Keys::generate().public_key(); + + let result = exact_user_search_result(&[], &target).unwrap(); + + assert_eq!(result.pubkey, target.to_hex()); + assert_eq!(result.display_name, None); + } + #[test] fn user_search_result_marks_valid_nip_oa_profile_as_agent() { let event = oa_profile_event(r#"{"display_name":"Mira"}"#); diff --git a/desktop/src/features/profile/lib/userCandidateSearch.test.mjs b/desktop/src/features/profile/lib/userCandidateSearch.test.mjs index 210e92758b..cff9e0ff12 100644 --- a/desktop/src/features/profile/lib/userCandidateSearch.test.mjs +++ b/desktop/src/features/profile/lib/userCandidateSearch.test.mjs @@ -6,6 +6,7 @@ import { rankUserCandidatesBySearch, scoreUserCandidate, } from "./userCandidateSearch.ts"; +import { pubkeyToNpub } from "../../../shared/lib/nostrUtils.ts"; function makeUser(overrides = {}) { return { @@ -47,6 +48,15 @@ test("scoreUserCandidate ranks display labels before pubkeys", () => { ); }); +test("scoreUserCandidate matches the canonical npub", () => { + const pubkey = + "50d0af578a29c245c08c9e2ad95be422ba6e3a9df938dcf6b8863ebcdec399db"; + const npub = pubkeyToNpub(pubkey); + const user = makeUser({ pubkey }); + + assert.equal(scoreUserCandidate({ label: pubkey, query: npub, user }), 3); +}); + test("scoreUserCandidate supports agent labels and empty-query defaults", () => { const agent = makeUser({ isAgent: true }); diff --git a/desktop/src/features/profile/lib/userCandidateSearch.ts b/desktop/src/features/profile/lib/userCandidateSearch.ts index 7447b01c4d..b41dd83678 100644 --- a/desktop/src/features/profile/lib/userCandidateSearch.ts +++ b/desktop/src/features/profile/lib/userCandidateSearch.ts @@ -1,4 +1,5 @@ import type { UserSearchResult } from "@/shared/api/types"; +import { safeNpub } from "@/shared/lib/nostrUtils"; import { normalizePubkey } from "@/shared/lib/pubkey"; type ScoreUserCandidateInput = { @@ -55,6 +56,10 @@ export function scoreUserCandidate({ if (pubkey.startsWith(normalizedQuery)) return 3; if (pubkey.includes(normalizedQuery)) return 4; + const npub = safeNpub(pubkey)?.toLowerCase(); + if (npub?.startsWith(normalizedQuery)) return 3; + if (npub?.includes(normalizedQuery)) return 4; + return null; }