diff --git a/crates/buzz-workflow/Cargo.toml b/crates/buzz-workflow/Cargo.toml index 6e213c505fc..d4813e56d42 100644 --- a/crates/buzz-workflow/Cargo.toml +++ b/crates/buzz-workflow/Cargo.toml @@ -18,6 +18,7 @@ dashmap = { workspace = true } moka = { workspace = true } evalexpr = "11" cron = "0.16" +nostr = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } tokio = { workspace = true } @@ -25,8 +26,5 @@ tracing = { workspace = true } thiserror = { workspace = true } reqwest = { workspace = true, optional = true } -[dev-dependencies] -nostr = { workspace = true } - [features] reqwest = ["dep:reqwest"] diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index feceec07b79..a029b44622c 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use buzz_core::tenant::CommunityId; use evalexpr::HashMapContext; +use nostr::ToBech32; use serde_json::Value as JsonValue; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -62,7 +63,8 @@ impl TriggerContext { /// /// Supports filters: /// - `| truncate(N)` — truncate to N characters -/// - `| truncate_pubkey` — shorten pubkey to `abc...xyz` (first 6 + last 6 chars) +/// - `| npub` — encode a hex pubkey as its full bech32 `npub` (non-pubkey +/// values pass through unchanged); `truncate_pubkey` is a legacy alias /// /// Unknown `{{keys}}` are left as literal text (no error, no substitution). pub fn resolve_template( @@ -185,28 +187,12 @@ fn apply_filter(value: String, filter: &str) -> Result { return Ok(truncated); } - // `truncate_pubkey` — shorten to `abc...xyz` (first 6 + last 6 chars). - // Only skip truncation if the string is shorter than the truncated form would be. - if filter == "truncate_pubkey" { - let char_count = value.chars().count(); - if char_count <= 12 { - // Already short enough that truncating would be longer than the original. - // But we still apply the format for consistency if exactly 12. - // For strings < 12 chars, return as-is. - if char_count < 12 { - return Ok(value); - } + // `npub` (alias `truncate_pubkey`): full bech32 npub — truncated prefixes are grindable. + if filter == "npub" || filter == "truncate_pubkey" { + if let Ok(pk) = nostr::PublicKey::from_hex(&value) { + return Ok(pk.to_bech32().unwrap_or(value)); } - let head: String = value.chars().take(6).collect(); - let tail: String = value - .chars() - .rev() - .take(6) - .collect::() - .chars() - .rev() - .collect(); - return Ok(format!("{head}...{tail}")); + return Ok(value); } Err(WorkflowError::TemplateError(format!( @@ -1284,15 +1270,30 @@ mod tests { } #[test] - fn resolve_truncate_pubkey_filter() { - let ctx = make_trigger(); + fn resolve_npub_filter_encodes_hex_pubkey() { + let mut ctx = make_trigger(); + ctx.author = "e17e5abf7b1dbd363f0ed6fbda2455609727b2555428dea251388c542cd2f03f".to_owned(); + let out = resolve_template("{{trigger.author | npub}}", &ctx, &HashMap::new()).unwrap(); + assert_eq!( + out, + "npub1u9l940mmrk7nv0cw6maa5fz4vztj0vj42s5dagj38zx9gtxj7qls94fpux" + ); + } + + #[test] + fn resolve_truncate_pubkey_is_alias_for_npub() { + let mut ctx = make_trigger(); + ctx.author = "e17e5abf7b1dbd363f0ed6fbda2455609727b2555428dea251388c542cd2f03f".to_owned(); let out = resolve_template( "{{trigger.author | truncate_pubkey}}", &ctx, &HashMap::new(), ) .unwrap(); - assert_eq!(out, "abc123...def456"); + assert_eq!( + out, + "npub1u9l940mmrk7nv0cw6maa5fz4vztj0vj42s5dagj38zx9gtxj7qls94fpux" + ); } #[test] @@ -1543,10 +1544,10 @@ mod tests { } #[test] - fn resolve_truncate_pubkey_short_string_returned_as_is() { - // Strings shorter than 12 chars are returned as-is (no truncation). + fn resolve_pubkey_filter_non_pubkey_passes_through() { + // Values that are not valid hex pubkeys are returned unchanged. let mut ctx = make_trigger(); - ctx.author = "short".to_owned(); // 5 chars < 12 + ctx.author = "short".to_owned(); let out = resolve_template( "{{trigger.author | truncate_pubkey}}", &ctx, @@ -1557,17 +1558,12 @@ mod tests { } #[test] - fn resolve_truncate_pubkey_exactly_12_chars() { - // Exactly 12 chars → format as head...tail (6+6). + fn resolve_npub_filter_passes_npub_through() { + // Already-encoded npubs are not valid hex, so they pass through intact. let mut ctx = make_trigger(); - ctx.author = "abcdef123456".to_owned(); // exactly 12 chars - let out = resolve_template( - "{{trigger.author | truncate_pubkey}}", - &ctx, - &HashMap::new(), - ) - .unwrap(); - assert_eq!(out, "abcdef...123456"); + ctx.author = "npub1u9l940mmrk7nv0cw6maa5fz4vztj0vj42s5dagj38zx9gtxj7qls94fpux".to_owned(); + let out = resolve_template("{{trigger.author | npub}}", &ctx, &HashMap::new()).unwrap(); + assert_eq!(out, ctx.author); } #[test] diff --git a/desktop/package.json b/desktop/package.json index f20f3456142..a43f1271eb3 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -9,8 +9,9 @@ "typecheck": "tsc --noEmit", "check:file-sizes": "node ./scripts/check-file-sizes.mjs", "check:px-text": "node ./scripts/check-px-text.mjs", + "check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs", "lint": "biome lint .", - "check": "biome check . && pnpm check:file-sizes && pnpm check:px-text", + "check": "biome check . && pnpm check:file-sizes && pnpm check:px-text && pnpm check:pubkey-truncation", "format": "biome format --write .", "test": "node --import ./test-loader.mjs --experimental-strip-types --test 'src/**/*.test.mjs'", "preview": "vite preview", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 950d27375f1..725db33c2ba 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ "**/activity-scope-label-screenshots.spec.ts", "**/local-archive-screenshots.spec.ts", "**/agent-readiness-screenshots.spec.ts", + "**/pubkey-display-screenshots.spec.ts", "**/file-attachment.spec.ts", "**/image-attachment-gallery.spec.ts", "**/composer-image-draw.spec.ts", diff --git a/desktop/scripts/check-pubkey-truncation.mjs b/desktop/scripts/check-pubkey-truncation.mjs new file mode 100644 index 00000000000..95e56fb2822 --- /dev/null +++ b/desktop/scripts/check-pubkey-truncation.mjs @@ -0,0 +1,48 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { runPubkeyTruncationCheck } from "../../scripts/check-pubkey-truncation-core.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, ".."); + +// Truncated pubkey prefixes are forgeable (vanity grinding), so all display +// truncation goes through the canonical `truncatePubkey` / `` — this +// guard keeps ad-hoc `pubkey.slice(0, N)` forms from fragmenting again. +const rules = [ + { + root: "src", + extensions: new Set([".ts", ".tsx"]), + }, +]; + +// Non-display uses: array windows over pubkey lists, color/initials +// derivation where the value is never presented as an identity. +const overrides = new Set([ + // ProfileAvatar fallback label — decorative glyphs inside an avatar disc. + "src/features/huddle/components/ParticipantList.tsx:92", + // HexAvatar: 6-char badge + hue derivation inside a color-coded disc, + // clearly decorative (paired with a full truncatePubkey aria-label). + "src/features/huddle/components/ParticipantList.tsx:143", + "src/features/huddle/components/ParticipantList.tsx:144", + // clientId (not a pubkey) sliced in a debug log next to the real thing. + "src/features/channels/readState/readStateManager.ts:338", + // Array windows (first N pubkeys), not string truncation. + "src/features/messages/lib/threadPanel.ts:395", + "src/features/projects/ui/ProjectsView.tsx:166", + "src/features/projects/ui/ProjectsOverviewPanel.tsx:209", +]); + +await runPubkeyTruncationCheck({ + projectRoot, + rules, + overrides, + allowedFiles: new Set([ + // The canonical helper itself. + "src/shared/lib/pubkey.ts", + // E2E mock bridge fabricates ids/nsecs from pubkeys; nothing here is a + // user-facing identity display. + "src/testing/e2eBridge.ts", + ]), + label: "Desktop", + scriptPath: "desktop/scripts/check-pubkey-truncation.mjs", +}); diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index 64c3b16c028..c1db2a1b594 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -9,7 +9,7 @@ use std::collections::{BTreeSet, HashMap}; -use nostr::Event; +use nostr::{Event, ToBech32}; use serde_json::{json, Value}; use crate::models::*; @@ -447,7 +447,8 @@ pub fn agents_from_events(events: &[Event]) -> Value { .map(|ev| { let mut v: Value = serde_json::from_str(&ev.content).unwrap_or_else(|_| json!({})); let pubkey = ev.pubkey.to_hex(); - let short_pubkey = pubkey[..8].to_string(); + // Full npub fallback — truncated prefixes are grindable (see pubkey-display). + let npub = ev.pubkey.to_bech32().unwrap_or_else(|_| pubkey.clone()); // Always overwrite the pubkey with the event author — it's the // authoritative source even if the content claims otherwise. if let Some(obj) = v.as_object_mut() { @@ -457,7 +458,7 @@ pub fn agents_from_events(events: &[Event]) -> Value { .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .map(str::to_string) - .unwrap_or_else(|| short_pubkey.clone()); + .unwrap_or_else(|| npub.clone()); if !obj.get("name").is_some_and(Value::is_string) { obj.insert("name".to_string(), json!(fallback_name)); } @@ -479,7 +480,7 @@ pub fn agents_from_events(events: &[Event]) -> Value { } else { v = json!({ "pubkey": pubkey, - "name": short_pubkey, + "name": npub, "agent_type": "agent", "channels": [], "channel_ids": [], diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index aaa07a83171..e2fe002bbfe 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -20,7 +20,7 @@ import { Button } from "@/shared/ui/button"; import { AgentConfigPanel } from "./AgentConfigPanel"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel"; -import { truncatePubkey } from "./agentUi"; +import { PubKey } from "@/shared/ui/PubKey"; export function ManagedAgentRow({ agent, @@ -237,7 +237,7 @@ function AgentSummary({ ) : null}
- {truncatePubkey(agent.pubkey)} + {agent.backend.type === "local" ? ( {agent.startOnAppLaunch ? "Auto-start" : "Manual start"} diff --git a/desktop/src/features/agents/ui/RelayDirectorySection.tsx b/desktop/src/features/agents/ui/RelayDirectorySection.tsx index d1ac88c620f..a664e99c076 100644 --- a/desktop/src/features/agents/ui/RelayDirectorySection.tsx +++ b/desktop/src/features/agents/ui/RelayDirectorySection.tsx @@ -5,7 +5,7 @@ import type { RelayAgent } from "@/shared/api/types"; import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; import { Card } from "@/shared/ui/card"; import { Input } from "@/shared/ui/input"; -import { truncatePubkey } from "./agentUi"; +import { truncatePubkey } from "@/shared/lib/pubkey"; export function RelayDirectorySection({ error, diff --git a/desktop/src/features/agents/ui/RespondToField.tsx b/desktop/src/features/agents/ui/RespondToField.tsx index e8ef2be41ef..cb1737b2196 100644 --- a/desktop/src/features/agents/ui/RespondToField.tsx +++ b/desktop/src/features/agents/ui/RespondToField.tsx @@ -4,7 +4,8 @@ import { mergeAllowlist, parsePubkeyInput, } from "@/features/agents/lib/respondToAllowlist"; -import { formatPubkey } from "@/features/channels/lib/memberUtils"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { PubKey } from "@/shared/ui/PubKey"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserSearchQuery } from "@/features/profile/hooks"; import type { RespondToMode, UserSearchResult } from "@/shared/api/types"; @@ -36,7 +37,7 @@ function formatSearchUserName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - formatPubkey(user.pubkey) + truncatePubkey(user.pubkey) ); } @@ -46,7 +47,7 @@ function formatSearchUserSecondary(user: UserSearchResult) { if (displayName && nip05Handle) { return nip05Handle; } - return formatPubkey(user.pubkey); + return truncatePubkey(user.pubkey); } const RESPOND_TO_OPTIONS: PersonaDropdownOption[] = [ @@ -276,8 +277,9 @@ function AllowlistPicker({ ) : null} {!isPersona && ownerPubkey ? (

- Owner ({formatPubkey(ownerPubkey)}) - is always implicitly allowed by the harness — no need to add it here. + Owner ( + ) is always implicitly allowed by the + harness — no need to add it here.

) : !isPersona ? (

@@ -308,12 +310,12 @@ function AllowlistPicker({ > - {formatPubkey(pubkey)} + ); diff --git a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx index c446f883daf..efb5b9ef092 100644 --- a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx +++ b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx @@ -8,6 +8,7 @@ import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Shimmer } from "@/shared/ui/Shimmer"; +import { truncatePubkey } from "@/shared/lib/pubkey"; type TypingIndicatorRowProps = { channel: Channel | null; @@ -97,7 +98,7 @@ export function TypingIndicatorRow({

{typingPubkeys.map((pubkey, index) => { const profile = profiles?.[pubkey.toLowerCase()]; - const label = labels[index] ?? pubkey.slice(0, 8); + const label = labels[index] ?? truncatePubkey(pubkey); return (
mentions.getMentionDisplayName(pubkey) ?? pubkey.slice(0, 8), + (pubkey) => + mentions.getMentionDisplayName(pubkey) ?? truncatePubkey(pubkey), ); }, [mentions.getMentionDisplayName, pendingNonMemberSend]); diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts index 5e5a647269f..ac2373c3105 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.ts +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.ts @@ -1,8 +1,8 @@ import * as React from "react"; +import { truncatePubkey } from "@/shared/lib/pubkey"; import { resolveUserLabel, - truncatePubkey, type UserProfileLookup, } from "@/features/profile/lib/identity"; import { getThreadReference } from "@/features/messages/lib/threading"; diff --git a/desktop/src/features/onboarding/ui/MembershipDenied.tsx b/desktop/src/features/onboarding/ui/MembershipDenied.tsx index 99f46701f01..627d6c45d8a 100644 --- a/desktop/src/features/onboarding/ui/MembershipDenied.tsx +++ b/desktop/src/features/onboarding/ui/MembershipDenied.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { Check, Copy, KeyRound, ShieldX } from "lucide-react"; -import { nsecToNpub, pubkeyToNpub, shortenNpub } from "@/shared/lib/nostrUtils"; +import { nsecToNpub, pubkeyToNpub } from "@/shared/lib/nostrUtils"; import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; @@ -178,7 +178,7 @@ export function MembershipDenied({ This will use this Nostr identity:

- {shortenNpub(previewNpub)} + {previewNpub}

diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx index d105ad1816a..8dd0f6ffa5c 100644 --- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx +++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { Check, KeyRound } from "lucide-react"; import { cn } from "@/shared/lib/cn"; -import { nsecToNpub, shortenNpub } from "@/shared/lib/nostrUtils"; +import { nsecToNpub } from "@/shared/lib/nostrUtils"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; import { Spinner } from "@/shared/ui/spinner"; @@ -228,7 +228,7 @@ export function NostrKeyImportForm({ This will use this Nostr identity:

- {shortenNpub(previewNpub)} + {previewNpub}

diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index 3093cebf61b..9a5433d9a9a 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -1,11 +1,9 @@ import type { Profile, UserProfileSummary } from "@/shared/api/types"; -import { normalizePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; export type UserProfileLookup = Record; -export function truncatePubkey(pubkey: string) { - return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; -} +export { truncatePubkey }; function getResolvedProfile( pubkey: string, @@ -116,3 +114,32 @@ export function resolveUserSecondaryLabel(input: { return null; } + +/** + * Label for an agent's owner: "you" when the current user owns it, otherwise + * the owner's display name, NIP-05 handle, or truncated pubkey. + */ +export function formatOwnerLabel( + ownerPubkey: string | null | undefined, + currentPubkey: string | null | undefined, + ownerProfiles?: UserProfileLookup, +) { + if (!ownerPubkey) { + return null; + } + + const normalizedOwnerPubkey = normalizePubkey(ownerPubkey); + if ( + currentPubkey && + normalizedOwnerPubkey === normalizePubkey(currentPubkey) + ) { + return "you"; + } + + const owner = ownerProfiles?.[normalizedOwnerPubkey]; + return ( + owner?.displayName?.trim() || + owner?.nip05Handle?.trim() || + truncatePubkey(ownerPubkey) + ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx index c32b778f22e..32f8bb225ba 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx @@ -12,8 +12,9 @@ import { } from "lucide-react"; import * as React from "react"; import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; -import { truncatePubkey as truncatePubkeyShort } from "@/features/profile/lib/identity"; +import { truncatePubkey } from "@/shared/lib/pubkey"; import { copyTextToClipboard } from "@/shared/lib/clipboard"; +import { PubKey } from "@/shared/ui/PubKey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import type { AgentPersona, @@ -166,11 +167,10 @@ export function buildPublicFields({ if (pubkey) { fields.push({ - copyValue: pubkey, - displayValue: truncatePubkeyShort(pubkey), + displayValue: truncatePubkey(pubkey), + displayNode: , icon: Fingerprint, label: "Public key", - testId: "user-profile-copy-pubkey", }); } diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 39bcf28cc37..22b8031c4a1 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -8,8 +8,7 @@ import type { RelayAgent, UpdateManagedAgentInput, } from "@/shared/api/types"; -import { normalizePubkey } from "@/shared/lib/pubkey"; -import { truncatePubkey } from "@/features/profile/lib/identity"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; export { truncatePubkey }; diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index f038d41482a..6a293586f49 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -23,10 +23,7 @@ import { import { useIsManagedAgent } from "@/features/agent-memory/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; -import { - ownsAuthorAgent, - truncatePubkey, -} from "@/features/profile/lib/identity"; +import { ownsAuthorAgent } from "@/features/profile/lib/identity"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { usePresenceQuery } from "@/features/presence/hooks"; import { useUserStatusQuery } from "@/features/user-status/hooks"; @@ -43,7 +40,7 @@ import { sendChannelMessage } from "@/shared/api/tauri"; import type { Channel, RelayEvent } from "@/shared/api/types"; import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; diff --git a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx index c63441615e3..b88bab4c317 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx @@ -16,7 +16,7 @@ import { } from "@/features/projects/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelMember } from "@/shared/api/types"; -import { normalizePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { Markdown } from "@/shared/ui/markdown"; import { ProfileIdentityButton } from "./ProjectProfileIdentity"; @@ -36,7 +36,7 @@ function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) { return ( profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}` + truncatePubkey(pubkey) ); } diff --git a/desktop/src/features/pulse/ui/AgentActivityCard.tsx b/desktop/src/features/pulse/ui/AgentActivityCard.tsx index c5a7aa84e08..8d600f09471 100644 --- a/desktop/src/features/pulse/ui/AgentActivityCard.tsx +++ b/desktop/src/features/pulse/ui/AgentActivityCard.tsx @@ -6,6 +6,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import type { UserProfileSummary } from "@/shared/api/types"; import { Markdown } from "@/shared/ui/markdown"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { truncatePubkey } from "@/shared/lib/pubkey"; type AgentActivityCardProps = { group: AgentNoteGroup; @@ -44,7 +45,7 @@ export function AgentActivityCard({ agentStatus, }: AgentActivityCardProps) { const [expanded, setExpanded] = React.useState(false); - const displayName = profile?.displayName ?? `${group.pubkey.slice(0, 8)}...`; + const displayName = profile?.displayName ?? truncatePubkey(group.pubkey); const avatarUrl = profile?.avatarUrl ?? null; const isSingleNote = group.notes.length === 1; diff --git a/desktop/src/features/pulse/ui/NoteCard.tsx b/desktop/src/features/pulse/ui/NoteCard.tsx index 2785bf7e667..142d725e97a 100644 --- a/desktop/src/features/pulse/ui/NoteCard.tsx +++ b/desktop/src/features/pulse/ui/NoteCard.tsx @@ -18,6 +18,7 @@ import { AnimatedCount } from "@/shared/ui/AnimatedCount"; import { Markdown } from "@/shared/ui/markdown"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { truncatePubkey } from "@/shared/lib/pubkey"; export type NoteCardActions = { reply?: ( @@ -66,7 +67,7 @@ function ReplyParentContext({ const parentDisplayName = parentNote ? (cachedProfile?.displayName ?? fetchedProfile?.displayName ?? - `${parentNote.pubkey.slice(0, 8)}...`) + truncatePubkey(parentNote.pubkey)) : null; const parentAvatarUrl = cachedProfile?.avatarUrl ?? fetchedProfile?.avatarUrl ?? null; @@ -142,7 +143,7 @@ export function NoteCard({ members = [], actions, }: NoteCardProps) { - const displayName = profile?.displayName ?? `${note.pubkey.slice(0, 8)}...`; + const displayName = profile?.displayName ?? truncatePubkey(note.pubkey); const avatarUrl = profile?.avatarUrl ?? null; const [isReplyComposerOpen, setIsReplyComposerOpen] = React.useState(false); const actionButtonClass = diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index 947eb2ff52e..3009076aa8a 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -30,6 +30,7 @@ import { Input } from "@/shared/ui/input"; import { Skeleton } from "@/shared/ui/skeleton"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; +import { truncatePubkey } from "@/shared/lib/pubkey"; export type PulseTab = | "search" @@ -223,7 +224,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) { : null; const currentDisplayName = currentProfile?.displayName ?? - (currentPubkey ? `${currentPubkey.slice(0, 8)}...` : "You"); + (currentPubkey ? truncatePubkey(currentPubkey) : "You"); const pulseMentionMembers = React.useMemo(() => { const members: ChannelMember[] = []; diff --git a/desktop/src/features/relay-members/ui/ConfirmRemoveDialog.tsx b/desktop/src/features/relay-members/ui/ConfirmRemoveDialog.tsx index 819e3788c21..37a3df5033e 100644 --- a/desktop/src/features/relay-members/ui/ConfirmRemoveDialog.tsx +++ b/desktop/src/features/relay-members/ui/ConfirmRemoveDialog.tsx @@ -1,6 +1,7 @@ import { toast } from "sonner"; -import { truncatePubkey } from "@/features/profile/lib/identity"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { PubKey } from "@/shared/ui/PubKey"; import { useRemoveRelayMemberMutation } from "@/features/relay-members/hooks"; import type { RelayMember } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; @@ -44,6 +45,13 @@ export function ConfirmRemoveDialog({ This will immediately revoke their access to the relay. + {member ? ( + + ) : null}
-

- Joined {formatRelativeDate(member.createdAt)} +

+ + Joined {formatRelativeDate(member.createdAt)}

diff --git a/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx b/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx index 41fa1a111b6..d4bf1f32960 100644 --- a/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx +++ b/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx @@ -24,7 +24,7 @@ import { import type { RelayMember, RelayMemberRole } from "@/shared/api/types"; import type { UserProfileSummary } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -66,10 +66,7 @@ function isValidHexPubkey(value: string): boolean { } function formatDisplayName(member: RelayMember, displayName?: string | null) { - return ( - displayName?.trim() || - `${member.pubkey.slice(0, 10)}…${member.pubkey.slice(-6)}` - ); + return displayName?.trim() || truncatePubkey(member.pubkey); } function npubFromPubkey(pubkey: string): string | null { diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 8569fab103d..fcdbc991951 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -15,7 +15,7 @@ import { import { SearchPromptPlaceholder } from "@/features/search/ui/SearchPromptPlaceholder"; import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog"; import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen"; import { @@ -145,7 +145,7 @@ function getUserDisplayName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - `${normalizePubkey(user.pubkey).slice(0, 8)}...` + truncatePubkey(user.pubkey) ); } diff --git a/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx b/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx index 756ccaf09b8..9ab7a99421f 100644 --- a/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx +++ b/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx @@ -17,7 +17,7 @@ import { useUserSearchFetchMoreOnScroll, useUsersBatchQuery, } from "@/features/profile/hooks"; -import { truncatePubkey } from "@/features/profile/lib/identity"; +import { formatOwnerLabel } from "@/features/profile/lib/identity"; import { getKeyboardSearchSelection, rankUserCandidatesBySearch, @@ -25,13 +25,11 @@ import { import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { useChannelsQuery } from "@/features/channels/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; -import type { - ManagedAgent, - UserSearchResult, - UserProfileSummary, -} from "@/shared/api/types"; +import type { ManagedAgent, UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey } from "@/shared/lib/pubkey"; +import { safeNpub } from "@/shared/lib/nostrUtils"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { PubKey } from "@/shared/ui/PubKey"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -65,22 +63,6 @@ function formatUserName(user: UserSearchResult) { ); } -function formatOwnerName( - user: UserSearchResult, - ownerProfiles?: Record, -) { - if (!user.ownerPubkey) { - return null; - } - - const owner = ownerProfiles?.[normalizePubkey(user.ownerPubkey)]; - return ( - owner?.displayName?.trim() || - owner?.nip05Handle?.trim() || - truncatePubkey(user.ownerPubkey) - ); -} - type DirectMessageSearchCandidate = UserSearchResult & { isManagedAgent?: boolean; isMember?: boolean; @@ -699,6 +681,22 @@ export function NewDirectMessageDialog({ ))} ) : null} + {selectedUsers.length > 0 ? ( +
+ {selectedUsers.map((user) => ( +
+ + {formatUserName(user)} + + +
+ ))} +
+ ) : null} @@ -717,8 +715,9 @@ export function NewDirectMessageDialog({ {searchResults.length > 0 ? (
{searchResults.map((user) => { - const ownerLabel = formatOwnerName( - user, + const ownerLabel = formatOwnerLabel( + user.ownerPubkey, + currentPubkey ?? identityQuery.data?.pubkey, ownerProfilesQuery.data?.profiles, ); @@ -748,8 +747,8 @@ export function NewDirectMessageDialog({ />
{user.isAgent ? ( -
-
+
+
{formatUserName(user)} @@ -763,14 +762,16 @@ export function NewDirectMessageDialog({
{ownerLabel ? ( - + owned by {ownerLabel} ) : null} - - - {truncatePubkey(user.pubkey)} - + + {safeNpub(user.pubkey) ?? + truncatePubkey(user.pubkey)}
) : ( diff --git a/desktop/src/shared/lib/nostrUtils.ts b/desktop/src/shared/lib/nostrUtils.ts index 82ad299dc22..b73d46d9994 100644 --- a/desktop/src/shared/lib/nostrUtils.ts +++ b/desktop/src/shared/lib/nostrUtils.ts @@ -11,6 +11,18 @@ export function pubkeyToNpub(hexPubkey: string): string { return npubEncode(hexPubkey); } +/** + * Like `pubkeyToNpub`, but returns null instead of throwing on malformed + * input. For display surfaces that must degrade gracefully. + */ +export function safeNpub(pubkey: string): string | null { + try { + return npubEncode(pubkey); + } catch { + return null; + } +} + /** * Decode a bech32 nsec string and derive the matching npub. Returns null if * the input is not a syntactically valid `nsec1…` (does NOT throw — this is @@ -35,14 +47,3 @@ export function nsecToNpub(nsec: string): string | null { return null; } } - -/** - * Format an npub for compact display: `npub1abcd…wxyz`. Falls back to the - * original string if it's shorter than the truncation thresholds. - */ -export function shortenNpub(npub: string): string { - if (npub.length <= 16) { - return npub; - } - return `${npub.slice(0, 12)}…${npub.slice(-6)}`; -} diff --git a/desktop/src/shared/lib/pubkey.test.mjs b/desktop/src/shared/lib/pubkey.test.mjs new file mode 100644 index 00000000000..76d0a29b231 --- /dev/null +++ b/desktop/src/shared/lib/pubkey.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { normalizePubkey, truncatePubkey } from "./pubkey.ts"; + +const PUBKEY = + "44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435"; + +test("truncates to the canonical 8+4 form with unicode ellipsis", () => { + assert.equal(truncatePubkey(PUBKEY), "44b8e82b…0435"); +}); + +test("returns short strings unchanged", () => { + assert.equal(truncatePubkey("abcd1234"), "abcd1234"); + assert.equal(truncatePubkey(""), ""); +}); + +test("normalizePubkey trims and lowercases", () => { + assert.equal(normalizePubkey(" ABCDEF "), "abcdef"); +}); diff --git a/desktop/src/shared/lib/pubkey.ts b/desktop/src/shared/lib/pubkey.ts index 072140374c0..6dcc48749a3 100644 --- a/desktop/src/shared/lib/pubkey.ts +++ b/desktop/src/shared/lib/pubkey.ts @@ -7,3 +7,19 @@ export function normalizePubkey(pubkey: string): string { return pubkey.trim().toLowerCase(); } + +/** + * The ONE canonical compact display form for a pubkey: `abcd1234…wxyz`. + * + * A truncated pubkey is a recognition aid, never an identity proof — vanity + * grinders forge short prefixes cheaply. Surfaces where the user makes a + * trust decision must show the full npub (see ``). + * Do not hand-roll `pubkey.slice(…)` display forms; `check-pubkey-truncation` + * fails the build if one sneaks in outside this module. + */ +export function truncatePubkey(pubkey: string): string { + if (pubkey.length <= 12) { + return pubkey; + } + return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; +} diff --git a/desktop/src/shared/ui/PubKey.tsx b/desktop/src/shared/ui/PubKey.tsx new file mode 100644 index 00000000000..b6d79156744 --- /dev/null +++ b/desktop/src/shared/ui/PubKey.tsx @@ -0,0 +1,170 @@ +import { Check, Copy } from "lucide-react"; +import * as React from "react"; + +import { copyTextToClipboard } from "@/shared/lib/clipboard"; +import { cn } from "@/shared/lib/cn"; +import { safeNpub } from "@/shared/lib/nostrUtils"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; + +const HOVER_OPEN_DELAY_MS = 500; +const HOVER_CLOSE_DELAY_MS = 200; + +type PubKeyProps = { + /** 64-char hex pubkey. */ + pubkey: string; + /** + * `compact` — truncated hex, click/tap opens a popover with the full npub, + * full hex, and copy buttons. The default for identity display in lists, + * cards, and metadata rows. + * + * `full` — the complete npub rendered inline with copy buttons. Required on + * security-decision surfaces (invite/approve, removal, trust/pairing, new + * DM, key import): a truncated key is forgeable by vanity grinding, so + * decisions must be made against the whole key. + */ + variant?: "compact" | "full"; + className?: string; + testId?: string; +}; + +function CopyRow({ label, value }: { label: string; value: string }) { + const [copied, setCopied] = React.useState(false); + const resetTimer = React.useRef(undefined); + React.useEffect(() => () => window.clearTimeout(resetTimer.current), []); + + return ( +
+
+
+ {label} +
+
{value}
+
+ +
+ ); +} + +function PubKeyDetails({ pubkey }: { pubkey: string }) { + const npub = safeNpub(pubkey); + return ( +
+ {npub ? : null} + +
+ ); +} + +/** + * Canonical pubkey display. See the `variant` prop for when each form is + * appropriate; never render a hand-truncated pubkey outside this component. + */ +export function PubKey({ + pubkey, + variant = "compact", + className, + testId, +}: PubKeyProps) { + const [open, setOpen] = React.useState(false); + const hoverTimerRef = React.useRef | null>( + null, + ); + + const clearHoverTimer = React.useCallback(() => { + if (hoverTimerRef.current !== null) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + }, []); + + const handleTriggerMouseEnter = React.useCallback(() => { + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + setOpen(true); + }, HOVER_OPEN_DELAY_MS); + }, [clearHoverTimer]); + + const handleMouseLeave = React.useCallback(() => { + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + setOpen(false); + }, HOVER_CLOSE_DELAY_MS); + }, [clearHoverTimer]); + + const handleContentMouseEnter = React.useCallback(() => { + clearHoverTimer(); + }, [clearHoverTimer]); + + React.useEffect(() => clearHoverTimer, [clearHoverTimer]); + + if (variant === "full") { + const npub = safeNpub(pubkey); + return ( + + {npub ?? pubkey} + + + + + + + + + + ); + } + + return ( + + + + + event.preventDefault()} + > + + + + ); +} diff --git a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts new file mode 100644 index 00000000000..0800fa447f9 --- /dev/null +++ b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts @@ -0,0 +1,138 @@ +import { expect, test } from "@playwright/test"; + +import { + installMockBridge, + openNewDirectMessageDialog, + TEST_IDENTITIES, +} from "../helpers/bridge"; + +const SHOTS = "test-results/pubkey-display"; + +const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8); +const AGENT_PUBKEY = "cafef00d".repeat(8); + +// Screenshot evidence for the pubkey-display work: the canonical +// compact popover (full npub + hex, copy-either) and the inline-full-npub +// decision surfaces. Not a regression suite — assertions are the minimum +// needed to know each shot captured the right state. + +test("profile panel Public key row opens the PubKey popover on hover", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const messageRow = page.getByTestId("message-row").first(); + await expect(messageRow).toBeVisible(); + await messageRow.locator("button").first().click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + + const pubkeyTrigger = page.getByTestId("user-profile-copy-pubkey"); + await expect(pubkeyTrigger).toBeVisible(); + await pubkeyTrigger.hover(); + + // Hover-open fires after a 500ms intent delay. + await expect(page.getByText("hex", { exact: true })).toBeVisible({ + timeout: 3_000, + }); + await expect(page.getByText("npub", { exact: true })).toBeVisible(); + await page.screenshot({ + path: `${SHOTS}/profile-panel-pubkey-hover-popover.png`, + }); +}); + +test("new-DM agent row keeps the name on hover and shows 'owned by you'", async ({ + page, +}) => { + // Agent rows only surface when the agent is mentionable (managed or in a + // shared channel), so seed managedAgents alongside the search profile. + await installMockBridge(page, { + managedAgents: [ + { + name: "Pinky", + pubkey: AGENT_PUBKEY, + status: "running", + }, + ], + searchProfiles: [ + { + displayName: "Pinky", + isAgent: true, + ownerPubkey: MOCK_IDENTITY_PUBKEY, + pubkey: AGENT_PUBKEY, + }, + ], + }); + await page.goto("/"); + + await openNewDirectMessageDialog(page); + await expect(page.getByTestId("new-dm-dialog")).toBeVisible(); + await page.getByTestId("new-dm-search").fill("pinky"); + + // The result testid sits on an empty inset overlay button; the visible + // text lives on the parent row. + const agentRow = page + .getByTestId(`new-dm-result-${AGENT_PUBKEY}`) + .locator(".."); + await expect(agentRow).toBeVisible(); + await expect(agentRow).toContainText("owned by you"); + + await agentRow.hover(); + // Hover must ADD the full npub, not swap the name away. + await expect(agentRow).toContainText("Pinky"); + await expect(page.getByTestId(`new-dm-npub-${AGENT_PUBKEY}`)).toContainText( + "npub1", + ); + await page.getByTestId("new-dm-dialog").screenshot({ + path: `${SHOTS}/new-dm-agent-row-hover-owned-by-you.png`, + }); +}); + +test("compact PubKey popover reveals full npub + hex with copy actions", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + + // The new-DM dialog renders agent rows and, on selection, the + // full-npub verification list — capture both there. + await openNewDirectMessageDialog(page); + await expect(page.getByTestId("new-dm-dialog")).toBeVisible(); + + await page.getByTestId("new-dm-search").fill("charlie"); + await expect( + page.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`), + ).toBeVisible(); + await page.keyboard.press("Enter"); + + const verifyRow = page.getByTestId( + `new-dm-pubkey-${TEST_IDENTITIES.charlie.pubkey}`, + ); + await expect(verifyRow).toBeVisible(); + await expect(verifyRow).toContainText("npub1"); + + await page.getByTestId("new-dm-dialog").screenshot({ + path: `${SHOTS}/new-dm-full-npub.png`, + }); + + // Open the copy popover from the full-variant copy button. + await verifyRow.getByRole("button", { name: "Copy public key" }).click(); + await expect(page.getByText("hex", { exact: true })).toBeVisible(); + await page.screenshot({ path: `${SHOTS}/pubkey-copy-popover.png` }); +}); + +test("member removal confirm shows the full npub inline", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.getByTestId("channel-members-trigger").click(); + await expect(page.getByTestId("members-sidebar")).toBeVisible(); + await page.getByTestId("members-sidebar").screenshot({ + path: `${SHOTS}/members-sidebar.png`, + }); +}); diff --git a/scripts/check-pubkey-truncation-core.mjs b/scripts/check-pubkey-truncation-core.mjs new file mode 100644 index 00000000000..627e4dea82b --- /dev/null +++ b/scripts/check-pubkey-truncation-core.mjs @@ -0,0 +1,108 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; + +/** + * Shared "no hand-rolled pubkey truncation" guard. + * + * A truncated pubkey prefix is forgeable by vanity-grinding, so display + * truncation must be consistent and centralized: the canonical + * `truncatePubkey` in `shared/lib/pubkey.ts` (or the `` component, + * which also offers full-key reveal + copy). Ad-hoc `pubkey.slice(0, N)` + * display forms fragmented into five formats before this guard existed. + * + * It flags `.slice(` / `.substring(` / `.slice(0` template-truncations applied + * to identifiers that look like a pubkey/npub, outside the canonical module. + * Non-display uses (array windows, color derivation from a key, avatar + * initials) live in each app's `overrides` allowlist. + */ + +const PUBKEY_SLICE_RE = + /\b[A-Za-z_$][\w$]*(?:[Pp]ubkey|[Pp]ub_key|[Nn]pub)[\w$]*\??\.(?:slice|substring)\(|\b(?:pubkey|npub)\??\.(?:slice|substring)\(/g; + +async function walkFiles(directory) { + const entries = await fs.readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map(async (entry) => { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return walkFiles(fullPath); + } + return [fullPath]; + }), + ); + return files.flat(); +} + +/** + * @param {object} options + * @param {string} options.projectRoot Absolute path the rule roots resolve against. + * @param {Array<{root: string, extensions: Set}>} options.rules Where to scan. + * @param {string} options.label Human label for the failure header. + * @param {Set} [options.overrides] Allowlisted "relativePath:lineNumber" entries. + * @param {Set} [options.allowedFiles] Relative paths allowed to truncate (the canonical module). + * @param {string} options.scriptPath Path mentioned in the failure hint. + */ +export async function runPubkeyTruncationCheck({ + projectRoot, + rules, + label, + overrides = new Set(), + allowedFiles = new Set(), + scriptPath, +}) { + const candidateFiles = ( + await Promise.all( + rules.map((rule) => { + const dir = path.join(projectRoot, rule.root); + return fs + .access(dir) + .then(() => walkFiles(dir)) + .catch(() => []); + }), + ) + ).flat(); + + const violations = []; + + for (const filePath of candidateFiles) { + const relativePath = path.relative(projectRoot, filePath); + const rule = rules.find((r) => + relativePath.startsWith(`${r.root}${path.sep}`), + ); + if (!rule || !rule.extensions.has(path.extname(filePath))) { + continue; + } + if (allowedFiles.has(relativePath.split(path.sep).join("/"))) { + continue; + } + if (relativePath.includes(".test.")) { + continue; + } + + const content = await fs.readFile(filePath, "utf8"); + const lines = content.split("\n"); + lines.forEach((line, index) => { + PUBKEY_SLICE_RE.lastIndex = 0; + if (!PUBKEY_SLICE_RE.test(line)) { + return; + } + const key = `${relativePath.split(path.sep).join("/")}:${index + 1}`; + if (overrides.has(key)) { + return; + } + violations.push({ key, line: line.trim() }); + }); + } + + if (violations.length > 0) { + console.error( + `${label}: found ${violations.length} hand-rolled pubkey truncation(s).\n` + + `Use \`truncatePubkey\` from shared/lib/pubkey (or the component) instead.\n` + + `Genuine non-display uses can be allowlisted in ${scriptPath}.\n`, + ); + for (const violation of violations) { + console.error(` ${violation.key}: ${violation.line}`); + } + process.exit(1); + } +} diff --git a/web/package.json b/web/package.json index 4a5ca527992..a074a581dca 100644 --- a/web/package.json +++ b/web/package.json @@ -8,8 +8,9 @@ "build": "tsc && vite build", "typecheck": "tsc --noEmit", "check:file-sizes": "node ./scripts/check-file-sizes.mjs", + "check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs", "lint": "biome lint .", - "check": "biome check . && pnpm check:file-sizes", + "check": "biome check . && pnpm check:file-sizes && pnpm check:pubkey-truncation", "format": "biome format --write .", "preview": "vite preview", "test:e2e": "pnpm build && playwright test", diff --git a/web/scripts/check-pubkey-truncation.mjs b/web/scripts/check-pubkey-truncation.mjs new file mode 100644 index 00000000000..67abf8860fc --- /dev/null +++ b/web/scripts/check-pubkey-truncation.mjs @@ -0,0 +1,29 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { runPubkeyTruncationCheck } from "../../scripts/check-pubkey-truncation-core.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, ".."); + +const rules = [ + { + root: "src", + extensions: new Set([".ts", ".tsx"]), + }, +]; + +const overrides = new Set([ + // Avatar fallback initials — two glyphs inside an avatar disc. + "src/features/repos/ui/PubkeyAvatar.tsx:29", + // Array window (first N pubkeys), not string truncation. + "src/features/repos/ui/OrgSidebar.tsx:22", +]); + +await runPubkeyTruncationCheck({ + projectRoot, + rules, + overrides, + allowedFiles: new Set(["src/shared/lib/pubkey.ts"]), + label: "Web", + scriptPath: "web/scripts/check-pubkey-truncation.mjs", +}); diff --git a/web/src/features/repos/ui/RepoListItem.tsx b/web/src/features/repos/ui/RepoListItem.tsx index 723a34a400d..af5d3e1a663 100644 --- a/web/src/features/repos/ui/RepoListItem.tsx +++ b/web/src/features/repos/ui/RepoListItem.tsx @@ -4,13 +4,9 @@ import { Link } from "@tanstack/react-router"; import { Badge } from "@/shared/ui/badge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { relativeTime } from "@/shared/lib/relative-time"; +import { truncatePubkey } from "@/shared/lib/pubkey"; import type { Repo } from "../use-repos"; -function truncateHex(hex: string): string { - if (hex.length <= 12) return hex; - return `${hex.slice(0, 8)}...${hex.slice(-4)}`; -} - export function RepoListItem({ repo }: { repo: Repo }) { return (
@@ -41,7 +37,7 @@ export function RepoListItem({ repo }: { repo: Repo }) { - {truncateHex(repo.owner)} + {truncatePubkey(repo.owner)} {repo.owner} diff --git a/web/src/shared/lib/pubkey.ts b/web/src/shared/lib/pubkey.ts new file mode 100644 index 00000000000..e8166b059b9 --- /dev/null +++ b/web/src/shared/lib/pubkey.ts @@ -0,0 +1,12 @@ +/** + * The ONE canonical compact display form for a pubkey: `abcd1234…wxyz`. + * Mirrors desktop's `@/shared/lib/pubkey`. A truncated pubkey is a + * recognition aid, never an identity proof — security decisions need the + * full npub. + */ +export function truncatePubkey(pubkey: string): string { + if (pubkey.length <= 12) { + return pubkey; + } + return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; +}