feat(identity): show relay-verified profiles

This commit is contained in:
Franco Sola
2026-07-24 16:36:44 -06:00
parent 7ba86160e5
commit 96c570d204
15 changed files with 398 additions and 49 deletions
+6
View File
@@ -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.
+153 -2
View File
@@ -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<Event, String> {
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::<Result<Vec<_>, _>>()
.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));
+122 -34
View File
@@ -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<nostr::Event>, Option<String>), 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<String, String> {
let Some(relay_self) = relay_self else {
return HashMap::new();
};
let mut verified = HashMap::<String, (u64, String)>::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<ProfileInfo, String> {
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(&current_pubkey_hex_unwrap(&state))))
.unwrap_or_else(|| empty_profile_info(&current_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();
+6
View File
@@ -28,6 +28,8 @@ pub struct IdentityInfo {
pub struct ProfileInfo {
pub pubkey: String,
pub display_name: Option<String>,
#[serde(default)]
pub verified_name: Option<String>,
pub avatar_url: Option<String>,
pub about: Option<String>,
pub nip05_handle: Option<String>,
@@ -42,6 +44,8 @@ pub struct ProfileInfo {
#[derive(Serialize, Deserialize)]
pub struct UserProfileSummaryInfo {
pub display_name: Option<String>,
#[serde(default)]
pub verified_name: Option<String>,
/// 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<String>,
#[serde(default)]
pub verified_name: Option<String>,
pub avatar_url: Option<String>,
pub nip05_handle: Option<String>,
pub owner_pubkey: Option<String>,
+5
View File
@@ -300,6 +300,7 @@ pub fn profile_info_from_event(event: &Event) -> Result<ProfileInfo, String> {
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<String, &Event> = 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),
@@ -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(),
@@ -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(
) : (
<MessageAuthorText as="h3">{message.author}</MessageAuthorText>
);
const verifiedName = message.pubkey
? resolveUserVerification({ pubkey: message.pubkey, profiles })
: null;
const agentOwnerNode = message.isAgent ? (
<MessageAgentOwner
ownerLabel={message.ownerLabel}
@@ -552,6 +559,9 @@ export const MessageRow = React.memo(
) : (
authorNode
)}
{verifiedName ? (
<VerifiedBadge verifiedName={verifiedName} />
) : null}
{agentOwnerNode}
{inlineMetadataNode}
{message.personaDisplayName &&
+20 -1
View File
@@ -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<Profile, "pubkey" | "displayName" | "avatarUrl" | "nip05Handle">
| 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
@@ -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({
/>
</MaskedAvatarBadgeFrame>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold leading-tight text-popover-foreground">
{displayName}
</p>
<div className="flex min-w-0 items-center gap-1.5">
<p className="truncate text-sm font-semibold leading-tight text-popover-foreground">
{displayName}
</p>
{verifiedName ? (
<VerifiedBadge verifiedName={verifiedName} />
) : null}
</div>
{/* ── Presence chip (opens status chooser) ─────────── */}
<Popover
onOpenChange={setPresenceMenuOpen}
@@ -51,6 +51,7 @@ import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
import { useNow } from "@/shared/lib/useNow";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import { VerifiedBadge } from "@/shared/ui/VerifiedBadge";
type UserProfilePopoverProps = {
children: React.ReactNode;
@@ -234,7 +235,14 @@ export function UserProfilePopover({
relayAgentsQuery.isPending ||
managedAgentsQuery.isPending ||
usersBatchQuery.isPending);
const displayName = profile?.displayName ?? truncatePubkey(pubkey);
const displayName =
profile?.verifiedName ?? profile?.displayName ?? truncatePubkey(pubkey);
const profileAlias =
profile?.verifiedName &&
profile.displayName &&
profile.verifiedName !== profile.displayName
? profile.displayName
: null;
// Owner signal mirrors UserProfilePanel: a declared NIP-OA owner whose agent
// runs elsewhere holds no local seckey, so key custody (`isOwner`) alone
// wrongly hides the affordance from them — and gating on bot-ness alone shows
@@ -520,6 +528,9 @@ export function UserProfilePopover({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<HoverPubkeyName displayName={displayName} pubkey={pubkey} />
{profile?.verifiedName ? (
<VerifiedBadge verifiedName={profile.verifiedName} />
) : null}
{isBotProfile && botIdenticonValue ? (
<BotIdenticon
value={botIdenticonValue}
@@ -528,6 +539,11 @@ export function UserProfilePopover({
/>
) : null}
</div>
{profileAlias ? (
<p className="mt-0.5 truncate text-xs leading-4 text-muted-foreground">
{profileAlias}
</p>
) : null}
{isBotProfile && ownerLabel ? (
<p
className="mt-0.5 truncate text-xs leading-4 text-muted-foreground"
@@ -478,7 +478,7 @@ export function AppSidebar({
directMessages,
enabled: shouldLoadDmMetadata,
fallbackDisplayName,
profileDisplayName: profile?.displayName,
profileDisplayName: profile?.verifiedName ?? profile?.displayName,
});
const sortedDirectMessages = React.useMemo(
() =>
@@ -498,6 +498,7 @@ export function AppSidebar({
streamChannels,
});
const resolvedDisplayName =
profile?.verifiedName?.trim() ||
profile?.displayName?.trim() ||
fallbackDisplayName?.trim() ||
"Current identity";
@@ -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"
>
<p
className="truncate text-sm font-semibold leading-tight text-current"
data-testid="sidebar-profile-name"
>
{resolvedDisplayName}
</p>
<span className="flex min-w-0 items-center gap-1.5">
<span
className="truncate text-sm font-semibold leading-tight text-current"
data-testid="sidebar-profile-name"
>
{resolvedDisplayName}
</span>
{profile?.verifiedName ? (
<VerifiedBadge verifiedName={profile.verifiedName} />
) : null}
</span>
</button>
</ProfilePopover>
+4
View File
@@ -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,
+5
View File
@@ -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;
+22
View File
@@ -0,0 +1,22 @@
import { BadgeCheck } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
export function VerifiedBadge({ verifiedName }: { verifiedName: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label={`Verified corporate identity: ${verifiedName}`}
className="inline-flex shrink-0 items-center text-blue-500"
data-testid="verified-corporate-identity"
>
<BadgeCheck aria-hidden="true" className="h-4 w-4" fill="currentColor" />
</span>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">Verified as {verifiedName}</p>
</TooltipContent>
</Tooltip>
);
}