From 96c570d20446d68d9342173870dfbc9367e1ef2d Mon Sep 17 00:00:00 2001 From: Franco Sola Date: Fri, 24 Jul 2026 16:36:44 -0600 Subject: [PATCH] feat(identity): show relay-verified profiles --- crates/buzz-core/src/kind.rs | 6 + crates/buzz-relay/src/corporate_identity.rs | 155 ++++++++++++++++- desktop/src-tauri/src/commands/profile.rs | 156 ++++++++++++++---- desktop/src-tauri/src/models.rs | 6 + desktop/src-tauri/src/nostr_convert.rs | 5 + .../src/nostr_convert/user_search.rs | 1 + .../src/features/messages/ui/MessageRow.tsx | 12 +- desktop/src/features/profile/lib/identity.ts | 21 ++- .../features/profile/ui/ProfilePopover.tsx | 14 +- .../profile/ui/UserProfilePopover.tsx | 18 +- .../src/features/sidebar/ui/AppSidebar.tsx | 3 +- .../sidebar/ui/SidebarProfileCard.tsx | 19 ++- desktop/src/shared/api/tauriProfiles.ts | 4 + desktop/src/shared/api/types.ts | 5 + desktop/src/shared/ui/VerifiedBadge.tsx | 22 +++ 15 files changed, 398 insertions(+), 49 deletions(-) create mode 100644 desktop/src/shared/ui/VerifiedBadge.tsx diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index b91216980..adafe7916 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -68,6 +68,12 @@ pub const KIND_LONG_FORM: u32 = 30023; /// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`. /// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped. pub const KIND_USER_STATUS: u32 = 30315; +/// NIP-85: relay-signed trusted assertion about a user pubkey. +/// +/// Buzz uses this standard user-subject assertion kind to project an active +/// enterprise identity binding without exposing the binding's stable uid. +/// The relay authors the event and keys it by the subject pubkey in `d`. +pub const KIND_USER_TRUSTED_ASSERTION: u32 = 30382; /// NIP-78 / NIP-RS: Per-client read state blob for cross-device read position sync. /// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`. /// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped. diff --git a/crates/buzz-relay/src/corporate_identity.rs b/crates/buzz-relay/src/corporate_identity.rs index ca73b8e0f..b7769281d 100644 --- a/crates/buzz-relay/src/corporate_identity.rs +++ b/crates/buzz-relay/src/corporate_identity.rs @@ -16,14 +16,15 @@ use jsonwebtoken::{ jwk::{Jwk, JwkSet}, Algorithm, DecodingKey, Validation, }; -use nostr::{FromBech32, PublicKey}; +use nostr::{Event, EventBuilder, FromBech32, Kind, PublicKey, Tag, Timestamp}; use serde::Deserialize; use serde_json::{Map, Value}; use thiserror::Error; use tokio::sync::RwLock; use tracing::{debug, warn}; -use buzz_core::CommunityId; +use buzz_core::{kind::KIND_USER_TRUSTED_ASSERTION, CommunityId}; +use buzz_db::event::EventQuery; use buzz_db::identity_binding::{BindIdentityResult, SOURCE_DB_BINDING, SOURCE_JWT_NPUB}; use crate::config::CorporateIdentityConfig; @@ -355,6 +356,26 @@ async fn enforce_corporate_identity_inner( ) .await; } + if let Err(error) = ensure_identity_assertion( + state, + community_id, + signer, + &claims.display_name, + &service.config.issuer, + ) + .await + { + // The binding remains the authorization authority. A projection + // failure removes the verified affordance but must not lock an + // otherwise authorized user out of the relay. + warn!( + signer = %signer.to_hex(), + error = %error, + "failed to publish corporate identity assertion" + ); + metrics::counter!("buzz_corporate_identity_assertions_total", "result" => "error") + .increment(1); + } debug!( uid = %claims.uid, @@ -379,6 +400,104 @@ async fn enforce_corporate_identity_inner( .await } +fn build_identity_assertion( + relay_keypair: &nostr::Keys, + subject: PublicKey, + display_name: &str, + issuer: &str, + created_at: Timestamp, +) -> Result { + let subject = subject.to_hex(); + let tags = [ + Tag::parse(["d", subject.as_str()]), + Tag::parse(["p", subject.as_str()]), + Tag::parse(["verified", "corporate"]), + Tag::parse(["display_name", display_name]), + Tag::parse(["issuer", issuer]), + ] + .into_iter() + .collect::, _>>() + .map_err(|error| format!("invalid corporate identity assertion tag: {error}"))?; + + EventBuilder::new(Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags(tags) + .custom_created_at(created_at) + .sign_with_keys(relay_keypair) + .map_err(|error| format!("failed to sign corporate identity assertion: {error}")) +} + +fn identity_assertion_matches( + event: &Event, + subject: &str, + display_name: &str, + issuer: &str, +) -> bool { + let has_tag = |name: &str, value: &str| { + event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 && parts[0] == name && parts[1] == value + }) + }; + has_tag("d", subject) + && has_tag("p", subject) + && has_tag("verified", "corporate") + && has_tag("display_name", display_name) + && has_tag("issuer", issuer) +} + +async fn ensure_identity_assertion( + state: &AppState, + community_id: CommunityId, + subject: PublicKey, + display_name: &str, + issuer: &str, +) -> Result<(), String> { + let subject_hex = subject.to_hex(); + let existing = state + .db + .query_events(&EventQuery { + kinds: Some(vec![KIND_USER_TRUSTED_ASSERTION as i32]), + pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), + d_tag: Some(subject_hex.clone()), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(community_id) + }) + .await + .map_err(|error| error.to_string())? + .into_iter() + .next(); + + if existing.as_ref().is_some_and(|stored| { + identity_assertion_matches(&stored.event, &subject_hex, display_name, issuer) + }) { + return Ok(()); + } + + let now = Timestamp::now().as_secs(); + let created_at = existing + .as_ref() + .map(|stored| stored.event.created_at.as_secs().saturating_add(1)) + .unwrap_or(now) + .max(now); + let event = build_identity_assertion( + &state.relay_keypair, + subject, + display_name, + issuer, + Timestamp::from(created_at), + )?; + + state + .db + .replace_parameterized_event(community_id, &event, &subject_hex, None) + .await + .map_err(|error| error.to_string())?; + metrics::counter!("buzz_corporate_identity_assertions_total", "result" => "published") + .increment(1); + Ok(()) +} + async fn enforce_delegated_corporate_identity( db: &buzz_db::Db, config: &CorporateIdentityConfig, @@ -598,6 +717,38 @@ mod tests { } } + #[test] + fn corporate_identity_projects_as_relay_signed_nip85_assertion() { + let relay = Keys::generate(); + let subject = Keys::generate().public_key(); + let event = build_identity_assertion( + &relay, + subject, + "Franco Sola", + "cf-doorman-production", + Timestamp::from(123), + ) + .unwrap(); + + assert_eq!(event.kind.as_u16() as u32, KIND_USER_TRUSTED_ASSERTION); + assert_eq!(event.pubkey, relay.public_key()); + assert!(event.verify_id()); + assert!(event.verify_signature()); + assert!(identity_assertion_matches( + &event, + &subject.to_hex(), + "Franco Sola", + "cf-doorman-production" + )); + assert!( + !event + .tags + .iter() + .any(|tag| tag.as_slice().first().is_some_and(|name| name == "uid")), + "the public assertion must not expose the stable corporate uid" + ); + } + #[test] fn rejects_hmac_jwt_algorithms_in_allowlist() { assert!(!is_allowed_jwt_algorithm(Algorithm::HS256)); diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index ef67fac57..8b3841894 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -1,11 +1,12 @@ use std::collections::HashMap; -use buzz_core_pkg::PresenceStatus; +use buzz_core_pkg::{kind::KIND_USER_TRUSTED_ASSERTION, PresenceStatus}; use serde_json::Value; use tauri::State; use crate::{ app_state::AppState, + commands::identity_archive::fetch_relay_self, events, managed_agents::persona_events::monotonic_created_at, models::{ProfileInfo, SearchUsersResponse, UserNotesResponse, UsersBatchResponse}, @@ -16,24 +17,93 @@ use crate::{ }, }; +async fn query_profiles_with_assertions( + state: &AppState, + pubkeys: &[String], +) -> Result<(Vec, Option), String> { + if pubkeys.is_empty() { + return Ok((Vec::new(), None)); + } + + let relay_self = fetch_relay_self(state).await.unwrap_or(None); + let mut filters = vec![serde_json::json!({ + "kinds": [0], + "authors": pubkeys, + })]; + if let Some(author) = relay_self.as_ref() { + filters.push(serde_json::json!({ + "kinds": [KIND_USER_TRUSTED_ASSERTION], + "authors": [author], + "#d": pubkeys, + })); + } + Ok((query_relay(state, &filters).await?, relay_self)) +} + +fn verified_identities( + events: &[nostr::Event], + relay_self: Option<&str>, +) -> HashMap { + let Some(relay_self) = relay_self else { + return HashMap::new(); + }; + let mut verified = HashMap::::new(); + for event in events { + if event.kind.as_u16() as u32 != KIND_USER_TRUSTED_ASSERTION + || !event.pubkey.to_hex().eq_ignore_ascii_case(relay_self) + || !event.verify_id() + || !event.verify_signature() + { + continue; + } + let tag_value = |name: &str| { + event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.len() == 2 && parts[0] == name).then(|| parts[1].as_str()) + }) + }; + if tag_value("verified") != Some("corporate") { + continue; + } + let (Some(subject), Some(display_name)) = (tag_value("d"), tag_value("display_name")) + else { + continue; + }; + if tag_value("p") != Some(subject) + || subject.len() != 64 + || !subject.chars().all(|value| value.is_ascii_hexdigit()) + || display_name.trim().is_empty() + { + continue; + } + let entry = verified + .entry(subject.to_ascii_lowercase()) + .or_insert_with(|| (0, String::new())); + if event.created_at.as_secs() >= entry.0 { + *entry = (event.created_at.as_secs(), display_name.to_string()); + } + } + verified + .into_iter() + .map(|(pubkey, (_, display_name))| (pubkey, display_name)) + .collect() +} + #[tauri::command] pub async fn get_profile(state: State<'_, AppState>) -> Result { let my_pubkey = current_pubkey_hex(&state)?; - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [0], - "authors": [my_pubkey], - "limit": 1 - })], - ) - .await?; + let (events, relay_self) = + query_profiles_with_assertions(&state, std::slice::from_ref(&my_pubkey)).await?; - Ok(events - .first() + let mut profile = events + .iter() + .find(|event| event.kind.as_u16() == 0 && event.pubkey.to_hex() == my_pubkey) .map(nostr_convert::profile_info_from_event) .transpose()? - .unwrap_or_else(|| empty_profile_info(¤t_pubkey_hex_unwrap(&state)))) + .unwrap_or_else(|| empty_profile_info(¤t_pubkey_hex_unwrap(&state))); + profile.verified_name = verified_identities(&events, relay_self.as_deref()) + .remove(&profile.pubkey); + Ok(profile) } #[tauri::command] @@ -187,21 +257,18 @@ pub async fn get_user_profile( None => current_pubkey_hex(&state)?, }; - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [0], - "authors": [target.clone()], - "limit": 1 - })], - ) - .await?; + let (events, relay_self) = + query_profiles_with_assertions(&state, std::slice::from_ref(&target)).await?; - Ok(events - .first() + let mut profile = events + .iter() + .find(|event| event.kind.as_u16() == 0 && event.pubkey.to_hex() == target) .map(nostr_convert::profile_info_from_event) .transpose()? - .unwrap_or_else(|| empty_profile_info(&target))) + .unwrap_or_else(|| empty_profile_info(&target)); + profile.verified_name = verified_identities(&events, relay_self.as_deref()) + .remove(&profile.pubkey); + Ok(profile) } #[tauri::command] @@ -215,16 +282,14 @@ pub async fn get_users_batch( missing: Vec::new(), }); } - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [0], - "authors": pubkeys, - })], - ) - .await?; + let (events, relay_self) = query_profiles_with_assertions(&state, &pubkeys).await?; - Ok(nostr_convert::users_batch_from_events(&events, &pubkeys)) + let mut response = nostr_convert::users_batch_from_events(&events, &pubkeys); + let verified = verified_identities(&events, relay_self.as_deref()); + for (pubkey, profile) in &mut response.profiles { + profile.verified_name = verified.get(pubkey).cloned(); + } + Ok(response) } #[tauri::command] @@ -406,6 +471,7 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo { ProfileInfo { pubkey: pubkey.to_string(), display_name: None, + verified_name: None, avatar_url: None, about: None, nip05_handle: None, @@ -418,6 +484,28 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo { mod tests { use super::*; + #[test] + fn verified_identity_requires_relay_signed_nip85_assertion() { + let relay = nostr::Keys::generate(); + let subject = nostr::Keys::generate().public_key().to_hex(); + let event = nostr::EventBuilder::new( + nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), + "", + ) + .tags([ + nostr::Tag::parse(["d", subject.as_str()]).unwrap(), + nostr::Tag::parse(["p", subject.as_str()]).unwrap(), + nostr::Tag::parse(["verified", "corporate"]).unwrap(), + nostr::Tag::parse(["display_name", "Franco Sola"]).unwrap(), + nostr::Tag::parse(["issuer", "cf-doorman-production"]).unwrap(), + ]) + .sign_with_keys(&relay) + .unwrap(); + + let verified = verified_identities(&[event], Some(&relay.public_key().to_hex())); + assert_eq!(verified.get(&subject).map(String::as_str), Some("Franco Sola")); + } + #[test] fn deferred_profile_signer_is_captured_and_rejects_wrong_identity() { let state = crate::app_state::build_app_state(); diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 1d9747bc2..36f786012 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -28,6 +28,8 @@ pub struct IdentityInfo { pub struct ProfileInfo { pub pubkey: String, pub display_name: Option, + #[serde(default)] + pub verified_name: Option, pub avatar_url: Option, pub about: Option, pub nip05_handle: Option, @@ -42,6 +44,8 @@ pub struct ProfileInfo { #[derive(Serialize, Deserialize)] pub struct UserProfileSummaryInfo { pub display_name: Option, + #[serde(default)] + pub verified_name: Option, /// Kind-0 `name` field, carried separately from `display_name` so clients /// can match @mention text against either alias (agents and the CLI /// resolve mentions server-side against `display_name` *or* `name`). @@ -64,6 +68,8 @@ pub struct UsersBatchResponse { pub struct UserSearchResultInfo { pub pubkey: String, pub display_name: Option, + #[serde(default)] + pub verified_name: Option, pub avatar_url: Option, pub nip05_handle: Option, pub owner_pubkey: Option, diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index ec4970e0c..b303edff3 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -300,6 +300,7 @@ pub fn profile_info_from_event(event: &Event) -> Result { Ok(ProfileInfo { pubkey: event.pubkey.to_hex(), display_name, + verified_name: None, avatar_url, about, nip05_handle, @@ -319,6 +320,9 @@ pub fn users_batch_from_events( // Keep only the most recent kind:0 per pubkey. let mut latest: HashMap = HashMap::new(); for ev in events { + if ev.kind.as_u16() != 0 { + continue; + } let pk = ev.pubkey.to_hex(); let take = match latest.get(&pk) { None => true, @@ -339,6 +343,7 @@ pub fn users_batch_from_events( .and_then(Value::as_str) .or_else(|| v.get("name").and_then(Value::as_str)) .map(str::to_string), + verified_name: None, name: v.get("name").and_then(Value::as_str).map(str::to_string), avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), diff --git a/desktop/src-tauri/src/nostr_convert/user_search.rs b/desktop/src-tauri/src/nostr_convert/user_search.rs index 43b4288ab..6dbc6faff 100644 --- a/desktop/src-tauri/src/nostr_convert/user_search.rs +++ b/desktop/src-tauri/src/nostr_convert/user_search.rs @@ -18,6 +18,7 @@ pub fn user_search_result_from_event(ev: &Event) -> UserSearchResultInfo { .and_then(Value::as_str) .or_else(|| v.get("name").and_then(Value::as_str)) .map(str::to_string), + verified_name: None, avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), is_agent: owner_pubkey.is_some(), diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index a57bf93e4..cc93f8fa0 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -11,7 +11,10 @@ import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; -import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { + resolveUserVerification, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider"; import { @@ -31,6 +34,7 @@ import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAu import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { parseImetaTags } from "@/features/messages/lib/parseImeta"; import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; @@ -460,6 +464,9 @@ export const MessageRow = React.memo( ) : ( {message.author} ); + const verifiedName = message.pubkey + ? resolveUserVerification({ pubkey: message.pubkey, profiles }) + : null; const agentOwnerNode = message.isAgent ? ( + ) : null} {agentOwnerNode} {inlineMetadataNode} {message.personaDisplayName && diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index d2e0a4fdd..8294ae386 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -37,6 +37,7 @@ export function profileLookupsEqual( if ( next === undefined || prev.displayName !== next.displayName || + prev.verifiedName !== next.verifiedName || prev.name !== next.name || prev.avatarUrl !== next.avatarUrl || prev.nip05Handle !== next.nip05Handle || @@ -64,7 +65,10 @@ function getResolvedProfile( export function mergeCurrentProfileIntoLookup( profiles: UserProfileLookup | undefined, currentProfile: - | Pick + | Pick< + Profile, + "pubkey" | "displayName" | "verifiedName" | "avatarUrl" | "nip05Handle" + > | null | undefined, ) { @@ -76,6 +80,7 @@ export function mergeCurrentProfileIntoLookup( ...(profiles ?? {}), [normalizePubkey(currentProfile.pubkey)]: { displayName: currentProfile.displayName, + verifiedName: currentProfile.verifiedName ?? null, // `Profile` does not carry the kind-0 `name`; keep whatever the batch // lookup already resolved so mention aliases survive the merge. name: profiles?.[normalizePubkey(currentProfile.pubkey)]?.name ?? null, @@ -113,7 +118,11 @@ export function resolveUserLabel(input: { } const profile = getResolvedProfile(pubkey, profiles); + const verifiedName = profile?.verifiedName?.trim(); const displayName = profile?.displayName?.trim(); + if (verifiedName) { + return verifiedName; + } if (displayName) { return displayName; } @@ -131,6 +140,16 @@ export function resolveUserLabel(input: { return truncatePubkey(pubkey); } +export function resolveUserVerification(input: { + pubkey: string; + profiles?: UserProfileLookup; +}): string | null { + return ( + getResolvedProfile(input.pubkey, input.profiles)?.verifiedName?.trim() || + null + ); +} + /** * Returns true when the current user owns the agent that authored a message. * Mirrors the relay's `is_agent_owner` gate: ownership is determined by the diff --git a/desktop/src/features/profile/ui/ProfilePopover.tsx b/desktop/src/features/profile/ui/ProfilePopover.tsx index a09df14a8..cb69db543 100644 --- a/desktop/src/features/profile/ui/ProfilePopover.tsx +++ b/desktop/src/features/profile/ui/ProfilePopover.tsx @@ -17,11 +17,13 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import type { PresenceStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { isMacPlatform } from "@/shared/lib/platform"; +import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; interface ProfilePopoverProps { open: boolean; onOpenChange: (open: boolean) => void; displayName: string; + verifiedName?: string | null; avatarUrl: string | null; avatarDataUrl?: string | null; currentStatus: PresenceStatus; @@ -53,6 +55,7 @@ export function ProfilePopover({ open, onOpenChange, displayName, + verifiedName, avatarUrl, avatarDataUrl, currentStatus, @@ -140,9 +143,14 @@ export function ProfilePopover({ />
-

- {displayName} -

+
+

+ {displayName} +

+ {verifiedName ? ( + + ) : null} +
{/* ── Presence chip (opens status chooser) ─────────── */}
+ {profile?.verifiedName ? ( + + ) : null} {isBotProfile && botIdenticonValue ? ( ) : null}
+ {profileAlias ? ( +

+ {profileAlias} +

+ ) : null} {isBotProfile && ownerLabel ? (

@@ -498,6 +498,7 @@ export function AppSidebar({ streamChannels, }); const resolvedDisplayName = + profile?.verifiedName?.trim() || profile?.displayName?.trim() || fallbackDisplayName?.trim() || "Current identity"; diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx index a714d5e58..ae3e88aa0 100644 --- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx @@ -14,6 +14,7 @@ import type { Community } from "@/features/communities/types"; import { CommunitySwitcher } from "@/features/communities/ui/CommunitySwitcher"; import type { PresenceStatus, Profile, UserStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; type SidebarProfileCardProps = { activeCommunity: Community | null; @@ -147,6 +148,7 @@ export function SidebarProfileCard({ avatarUrl={profile?.avatarUrl ?? null} currentStatus={selfPresenceStatus} displayName={resolvedDisplayName} + verifiedName={profile?.verifiedName} isStatusPending={isPresencePending} onClearUserStatus={onClearUserStatus} onOpenSettings={onOpenSettings} @@ -177,12 +179,17 @@ export function SidebarProfileCard({ data-testid="open-settings" type="button" > -

- {resolvedDisplayName} -

+ + + {resolvedDisplayName} + + {profile?.verifiedName ? ( + + ) : null} + diff --git a/desktop/src/shared/api/tauriProfiles.ts b/desktop/src/shared/api/tauriProfiles.ts index c8e52f516..e3bc3bff2 100644 --- a/desktop/src/shared/api/tauriProfiles.ts +++ b/desktop/src/shared/api/tauriProfiles.ts @@ -11,6 +11,7 @@ import type { type RawProfile = { pubkey: string; display_name: string | null; + verified_name?: string | null; avatar_url: string | null; about: string | null; nip05_handle: string | null; @@ -39,6 +40,7 @@ function fromRawProfile(profile: RawProfile): Profile { return { pubkey: profile.pubkey, displayName: profile.display_name, + verifiedName: profile.verified_name ?? null, avatarUrl: profile.avatar_url, about: profile.about, nip05Handle: profile.nip05_handle, @@ -52,6 +54,7 @@ function fromRawUserProfileSummary( ): UserProfileSummary { return { displayName: profile.display_name, + verifiedName: profile.verified_name ?? null, name: profile.name ?? null, avatarUrl: profile.avatar_url, nip05Handle: profile.nip05_handle, @@ -64,6 +67,7 @@ function fromRawUserSearchResult(user: RawUserSearchResult): UserSearchResult { return { pubkey: user.pubkey, displayName: user.display_name, + verifiedName: user.verified_name ?? null, avatarUrl: user.avatar_url, nip05Handle: user.nip05_handle, ownerPubkey: user.owner_pubkey, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d28f2d0cf..a9ec95d7b 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -126,6 +126,8 @@ export type Identity = { export type Profile = { pubkey: string; displayName: string | null; + /** Relay-authoritative corporate display identity bound to this pubkey. */ + verifiedName?: string | null; avatarUrl: string | null; about: string | null; nip05Handle: string | null; @@ -139,6 +141,8 @@ export type Profile = { export type UserProfileSummary = { displayName: string | null; + /** Relay-authoritative corporate display identity bound to this pubkey. */ + verifiedName?: string | null; /** Kind-0 `name` field, kept separate from `displayName` so @mention text * can be matched against either alias (agents/CLI resolve mentions against * `display_name` *or* `name` at send time). */ @@ -157,6 +161,7 @@ export type UsersBatchResponse = { export type UserSearchResult = { pubkey: string; displayName: string | null; + verifiedName?: string | null; avatarUrl: string | null; nip05Handle: string | null; ownerPubkey: string | null; diff --git a/desktop/src/shared/ui/VerifiedBadge.tsx b/desktop/src/shared/ui/VerifiedBadge.tsx new file mode 100644 index 000000000..e8996ffd0 --- /dev/null +++ b/desktop/src/shared/ui/VerifiedBadge.tsx @@ -0,0 +1,22 @@ +import { BadgeCheck } from "lucide-react"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; + +export function VerifiedBadge({ verifiedName }: { verifiedName: string }) { + return ( + + + + + + +

Verified as {verifiedName}

+
+
+ ); +}