From 2226b6f95b0218d5ba896a8afa6ea217706cc8a3 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 14:08:01 -0400 Subject: [PATCH] fix(desktop): npub identity controls across profile, agents, and workflows (#7489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– ## Summary Building on #7488's npub foundation, this PR finishes the identity display change for the controls where you actually manage people and keys: profile, settings, agent access, and workflows. Everywhere in these surfaces, an identity key shows β€” and copies β€” as its canonical npub (npub is the human-readable encoding of a Nostr public key: the compact `npub1j57...fjmv` form where space is tight, the full npub where the whole key matters), and accepts npub as input. After this change: - Profile panel: the public-key row and the managed-by / declared-owner copies show the full npub. If a key can't be encoded, you see "Unavailable" with no copy button β€” never a raw or partial key. - Settings: the identity card shows and copies the npub. The hosted-communities account identity derives from the bound key (`pubkey_hex`) β€” the same authority as the mismatch gate and hosted operations β€” so the display can never disagree with what the app acts on; an unusable hex falls back to a neutral label instead of rendering the unverified server npub. The connected claim and a community's Connect action require that same usable bound key to match the local one β€” with no usable binding the card cannot claim connected or start Connect, while the community list, linking, and delete/rebind recovery stay available. - Hosted create/onboarding: the account and device identity rows in the create flow and owner onboarding derive from the same authoritative fields (bound key / local key), with the same neutral fallback; readiness requires a usable bound key that matches the local one. - Respond-to allowlist (controls who may respond to an agent): entries can be typed or pasted as hex or npub; both spellings of the same key are recognized as one entry and dedupe. Search results, chips, and remove buttons use the compact npub. - Workflow key fields: to/from keys display as npubs in the form and save back as canonical hex. Templates like `{{trigger.author}}`, roles, and free text pass through untouched; placeholders accept both spellings. - Recipient and agent dialogs: the verify popover is npub-only (the raw-hex line is gone); denied-membership screens never show a raw key. - The Rust-side truncated display name (used for native surfaces) shows the same compact npub, so those surfaces match the web UI. Internal representation is unchanged: keys are still stored, sent, and matched as canonical 64-character hex β€” npub is a display and input spelling, normalized to hex at the boundary, so existing data and integrations keep working. Bound-key usability and comparison use one normalized form (trimmed, lowercased, 64 hex characters; npub rejected), so padded or mixed-case spellings of the same key match. Display names, private keys, and event IDs are untouched. ## Details - `respondToAllowlist` / `RespondToField`: npub entries normalize to canonical hex; cross-form dedupe; compact npub in rows and chips; direct-add accepts npub and stores canonical hex. - `workflowFormTypes` / `WorkflowStepCard`: hex β†’ npub for display, npub β†’ canonical hex on save; templates, roles, and free text pass through in both directions (roundtrip-tested). - `UserProfilePanelFields`, `ProfileSettingsCard`, `HostedCommunitiesSettingsCard`, `MembershipDenied`, `SelectedRecipientChip`, `AddAgentToChannelDialog`: npub display and copy; invalid keys β†’ "Unavailable" with no copy; hosted identity rows derive from the bound `pubkey_hex` (create/onboarding rows from the bound and local keys), never the unverified server npub; connected/readiness/Connect gates use the same usable-bound-key predicate, and the settings Connect invocation callback re-checks it before starting. - `src-tauri/src/commands/identity.rs`: `truncated_display_name` compacts to the first 8 + last 4 characters of the npub (above a 12-char threshold), mirroring `truncateNpub`. - e2e: profile key rows and clipboard polls assert npub forms and raw-hex suppression; the display-screenshots spec pins the npub-only popover; hosted specs drive the real settings card, create flow, and onboarding rows through their real providers, and the unlinked/npub-only identity cases assert no connected claim and no Connect action. ### Related issue - Fixes: N/A. No separate issue; the related work is the stack below. - Stack: builds on #7488 (shared npub foundation), now merged; this PR is rebased onto main and stands on its own. ### Testing At head `303c90ffa` (base: main `bfc38485`; 24 files, +1125/βˆ’146): - Focused unit suites (respondToAllowlist, workflowFormTypes, hostedCommunityApi bound-key helpers) green; mutation-checked β€” dropping allowlist canonicalization fails the dedupe case, and dropping bound-key normalization fails the npub-in-hex and padded same-key cases. - Full desktop unit suite 6,477/6,477, `desktop-typecheck`, `desktop-check` (formatting fixed narrowly with `biome check --write` on the touched files only), and a fresh E2E build at the current head; the add-community + hosted-communities-settings specs 18/18 and onboarding integration 69/69 on a fresh dedicated port, with focused new-case runs 4+4 covering padded same-key (ready, Connect kept β€” no false rebind) and npub-in-hex (neutral label, recovery, no Connect) across the settings card, create flow, and first-community onboarding, plus the unlinked-account settings regression asserting Connect cannot occur. - `cargo fmt`/clippy (both feature sets) and `cargo test identity` (71 pass) passed at the earlier full-change head; since then, the only production changes in this PR's delta are the hosted identity display authority and its fail-closed bound-key gating/normalization above (base-side fixes carry #7488's receipts) β€” every other change is test-only. ### Task provenance Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9` Task: buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6 --------- Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- desktop/scripts/check-pubkey-truncation.mjs | 6 +- desktop/src-tauri/src/commands/identity.rs | 35 ++- .../agents/lib/respondToAllowlist.test.mjs | 23 +- .../features/agents/lib/respondToAllowlist.ts | 41 ++-- .../agents/ui/AddAgentToChannelDialog.tsx | 18 +- .../src/features/agents/ui/RespondToField.tsx | 41 ++-- .../communities/hostedCommunityApi.test.mjs | 98 ++++++++ .../communities/hostedCommunityApi.ts | 51 ++++ .../ui/HostedCommunityCreateFlow.tsx | 52 +++- .../ui/HostedCommunityOnboarding.tsx | 64 +++-- .../onboarding/ui/MembershipDenied.tsx | 26 +- .../profile/ui/SelectedRecipientChip.tsx | 3 - .../profile/ui/UserProfilePanelFields.tsx | 23 +- .../ui/HostedCommunitiesSettingsCard.tsx | 73 ++++-- .../settings/ui/ProfileSettingsCard.tsx | 10 +- .../workflows/ui/WorkflowStepCard.tsx | 4 +- .../workflows/ui/workflowFormTypes.test.mjs | 71 ++++++ .../workflows/ui/workflowFormTypes.ts | 36 ++- .../e2e/add-community-screenshots.spec.ts | 137 ++++++++++- ...d-communities-settings-screenshots.spec.ts | 223 +++++++++++++++++- desktop/tests/e2e/onboarding.spec.ts | 206 +++++++++++++++- desktop/tests/e2e/profile.spec.ts | 11 +- .../e2e/pubkey-display-screenshots.spec.ts | 9 +- scripts/check-pubkey-truncation-core.mjs | 10 +- 24 files changed, 1125 insertions(+), 146 deletions(-) create mode 100644 desktop/src/features/communities/hostedCommunityApi.test.mjs diff --git a/desktop/scripts/check-pubkey-truncation.mjs b/desktop/scripts/check-pubkey-truncation.mjs index d65db13545..67ce2a9091 100644 --- a/desktop/scripts/check-pubkey-truncation.mjs +++ b/desktop/scripts/check-pubkey-truncation.mjs @@ -6,8 +6,10 @@ 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. +// truncation goes through the canonical `truncateNpub` (identity keys β€” +// compact npub), `truncatePubkey` (generic hex identifiers such as event and +// blob IDs), or `` β€” this guard keeps ad-hoc `pubkey.slice(0, N)` +// forms from fragmenting again. const rules = [ { root: "src", diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index ec2357b85e..8852fcb7e0 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -11,19 +11,46 @@ use crate::{ relay::{self, relay_api_base_url_with_override, relay_ws_url_with_override}, }; -/// Encode `pubkey` as npub bech32 and truncate it for display: first 10 chars -/// + "…" + last 4 chars. Returns the full bech32 when it is 16 chars or fewer. +/// Encode `pubkey` as npub bech32 and truncate it for display: first 8 +/// chars, an ellipsis, then the last 4 chars, mirroring the frontend +/// `truncateNpub` compact policy (`first8…last4` of the whole npub string). +/// Returns the full bech32 when it is 12 chars or fewer, mirroring +/// `truncatePubkey`'s short-string threshold. fn truncated_display_name(pubkey: &PublicKey) -> Result { let bech32 = pubkey .to_bech32() .map_err(|error| format!("bech32 encode failed: {error}"))?; - Ok(if bech32.len() > 16 { - format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..]) + Ok(if bech32.len() > 12 { + format!("{}…{}", &bech32[..8], &bech32[bech32.len() - 4..]) } else { bech32 }) } +#[cfg(test)] +mod truncated_display_name_tests { + use super::truncated_display_name; + use nostr::{PublicKey, ToBech32}; + + #[test] + fn compacts_to_first_8_and_last_4_of_the_npub() { + // Vector shared with the frontend `truncateNpub` tests; the expected + // form is derived from the encoded npub, not hardcoded, so the test + // asserts the compaction policy rather than one key's string. + let hex = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328"; + let pubkey = PublicKey::from_hex(hex).unwrap(); + let npub = pubkey.to_bech32().unwrap(); + let expected = format!("{}…{}", &npub[..8], &npub[npub.len() - 4..]); + assert_eq!(truncated_display_name(&pubkey).unwrap(), expected); + // 13 characters, matching the frontend compact form (the ellipsis is + // one char but three UTF-8 bytes, so count chars, not bytes). + assert_eq!(expected.chars().count(), 13); + assert!(expected.starts_with("npub1")); + // The compact form must not carry the raw hex key. + assert!(!expected.contains(hex)); + } +} + #[tauri::command] pub fn get_identity(state: State<'_, AppState>) -> Result { let keys = state.keys.lock().map_err(|error| error.to_string())?; diff --git a/desktop/src/features/agents/lib/respondToAllowlist.test.mjs b/desktop/src/features/agents/lib/respondToAllowlist.test.mjs index bbe07f7204..0d494652e7 100644 --- a/desktop/src/features/agents/lib/respondToAllowlist.test.mjs +++ b/desktop/src/features/agents/lib/respondToAllowlist.test.mjs @@ -6,6 +6,10 @@ import { mergeAllowlist, parsePubkeyInput } from "./respondToAllowlist.ts"; const HEX_A = "a".repeat(64); const HEX_B = "b".repeat(64); const HEX_A_UPPER = "A".repeat(64); +// Handoff vector, round-trip verified with nostr-tools nip19. +const HEX = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328"; +const HEX_NPUB = + "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60"; test("parsePubkeyInput splits on commas, whitespace, and newlines", () => { const input = `${HEX_A}, ${HEX_B}\n${HEX_A_UPPER}`; @@ -25,11 +29,15 @@ test("parsePubkeyInput surfaces invalid entries separately", () => { assert.deepEqual(result.invalid, ["notgood", "z".repeat(64)]); }); -test("parsePubkeyInput rejects npub-style strings (hex only)", () => { - const npub = `npub1${"a".repeat(59)}`; - const result = parsePubkeyInput(npub); - assert.deepEqual(result.valid, []); - assert.deepEqual(result.invalid, [npub]); +test("parsePubkeyInput accepts npub entries, normalizes to hex, and dedupes across spellings", () => { + // One invalid npub-shaped token proves classification at this seam; the + // full codec negative matrix lives at the shared parser (nostrUtils). + const corrupt = `${HEX_NPUB.slice(0, -2)}qq`; + const result = parsePubkeyInput( + `${HEX_NPUB} ${HEX} ${HEX.toUpperCase()} ${corrupt}`, + ); + assert.deepEqual(result.valid, [HEX]); + assert.deepEqual(result.invalid, [corrupt]); }); test("parsePubkeyInput rejects wrong-length entries", () => { @@ -60,3 +68,8 @@ test("mergeAllowlist skips invalid additions silently", () => { const merged = mergeAllowlist([HEX_A], ["not-hex", HEX_B]); assert.deepEqual(merged, [HEX_A, HEX_B]); }); + +test("mergeAllowlist normalizes npub additions to canonical hex and dedupes", () => { + assert.deepEqual(mergeAllowlist([HEX_A], [HEX_NPUB]), [HEX_A, HEX]); + assert.deepEqual(mergeAllowlist([HEX], [HEX_NPUB]), [HEX]); +}); diff --git a/desktop/src/features/agents/lib/respondToAllowlist.ts b/desktop/src/features/agents/lib/respondToAllowlist.ts index c376aa1d1a..237a457cc3 100644 --- a/desktop/src/features/agents/lib/respondToAllowlist.ts +++ b/desktop/src/features/agents/lib/respondToAllowlist.ts @@ -5,9 +5,14 @@ * `desktop/src-tauri/src/managed_agents/types.rs::validate_respond_to_allowlist`). * These helpers exist to give the UI immediate, inline feedback before the * round-trip, and to normalize input so the Rust validator sees clean data. + * + * Entry pieces may be 64-char hex pubkeys or bech32 `npub1…` strings; both are + * normalized to the canonical lowercase hex via the shared + * `parsePubkeyInput`, so npub and hex spellings of the same key dedupe to one + * entry (users copy npubs from profile/verify surfaces elsewhere in the app). */ -const HEX_64 = /^[0-9a-f]{64}$/i; +import { parsePubkeyInput as parseCanonicalPubkey } from "@/shared/lib/nostrUtils"; export type ParsedAllowlist = { /** Successfully parsed entries β€” lowercase hex, deduplicated, in order. */ @@ -22,9 +27,9 @@ export type ParsedAllowlist = { * pattern used by `ChannelMemberInviteCard` so users have one mental model. * * - Splits on `/[\s,]+/`. - * - Trims and lowercases each entry. - * - Validates each entry is exactly 64 hex chars. - * - Deduplicates while preserving insertion order. + * - Accepts 64-char hex (any case) or `npub1…` bech32 per piece, normalizing + * to the canonical lowercase hex pubkey. + * - Deduplicates the canonical form while preserving insertion order. */ export function parsePubkeyInput(raw: string): ParsedAllowlist { const seen = new Set(); @@ -33,14 +38,14 @@ export function parsePubkeyInput(raw: string): ParsedAllowlist { for (const piece of raw.split(/[\s,]+/)) { const trimmed = piece.trim(); if (trimmed.length === 0) continue; - if (!HEX_64.test(trimmed)) { + const canonical = parseCanonicalPubkey(trimmed); + if (canonical === null) { invalid.push(trimmed); continue; } - const lower = trimmed.toLowerCase(); - if (!seen.has(lower)) { - seen.add(lower); - valid.push(lower); + if (!seen.has(canonical)) { + seen.add(canonical); + valid.push(canonical); } } return { valid, invalid }; @@ -48,16 +53,20 @@ export function parsePubkeyInput(raw: string): ParsedAllowlist { /** * Merge an existing allowlist with newly-added pubkeys, normalizing and - * deduplicating without reordering existing entries. + * deduplicating without reordering existing entries. Both hex and npub + * spellings normalize to the canonical hex, so the same key cannot enter + * twice regardless of the form it was added in. */ export function mergeAllowlist(existing: string[], add: string[]): string[] { - const seen = new Set(existing.map((p) => p.toLowerCase())); - const out = [...existing.map((p) => p.toLowerCase())]; + const normalize = (pubkey: string): string => + parseCanonicalPubkey(pubkey) ?? pubkey.toLowerCase(); + const out = existing.map(normalize); + const seen = new Set(out); for (const candidate of add) { - const lower = candidate.toLowerCase(); - if (!HEX_64.test(lower) || seen.has(lower)) continue; - seen.add(lower); - out.push(lower); + const canonical = parseCanonicalPubkey(candidate); + if (canonical === null || seen.has(canonical)) continue; + seen.add(canonical); + out.push(canonical); } return out; } diff --git a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx index 05441e8618..366a19d7ca 100644 --- a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx @@ -9,7 +9,11 @@ import { useChannelsQuery, } from "@/features/channels/hooks"; import type { Channel, ChannelRole, ManagedAgent } from "@/shared/api/types"; -import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + canonicalNpub, + normalizePubkey, + UNAVAILABLE_KEY_LABEL, +} from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -87,6 +91,10 @@ export function AddAgentToChannelDialog({ ); }, [agent?.pubkey, membersQuery.data]); + // The agent's public key displays as its full canonical npub; an + // unencodable key renders the neutral label and is not copyable. + const agentNpub = agent?.pubkey ? canonicalNpub(agent.pubkey) : null; + const selectedChannel = channels.find((channel) => channel.id === channelId) ?? null; @@ -186,10 +194,12 @@ export function AddAgentToChannelDialog({

- {agent?.pubkey ?? "No agent selected"} + {agent + ? (agentNpub ?? UNAVAILABLE_KEY_LABEL) + : "No agent selected"} - {agent ? ( - + {agent && agentNpub ? ( + ) : null}
diff --git a/desktop/src/features/agents/ui/RespondToField.tsx b/desktop/src/features/agents/ui/RespondToField.tsx index 400f93af7c..d910bfc8a6 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 { truncatePubkey } from "@/shared/lib/pubkey"; +import { parsePubkeyInput as parseCanonicalPubkey } from "@/shared/lib/nostrUtils"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { PubKey } from "@/shared/ui/PubKey"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserSearchQuery } from "@/features/profile/hooks"; @@ -59,7 +60,7 @@ function formatSearchUserName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } @@ -69,7 +70,7 @@ function formatSearchUserSecondary(user: UserSearchResult) { if (displayName && nip05Handle) { return nip05Handle; } - return truncatePubkey(user.pubkey); + return truncateNpub(user.pubkey); } const RESPOND_TO_OPTIONS: PersonaDropdownOption[] = [ @@ -278,8 +279,6 @@ export function CreateAgentRespondToField({ ); } -const HEX_64_RE = /^[0-9a-f]{64}$/i; - function AllowlistPicker({ allowlist, deferredQuery, @@ -325,10 +324,12 @@ function AllowlistPicker({ }) { const isPersona = variant === "persona"; - // Detect if the query is a valid hex pubkey that's not already in the list. - const queryIsHexPubkey = - HEX_64_RE.test(deferredQuery) && - !allowlist.some((p) => p.toLowerCase() === deferredQuery.toLowerCase()); + // Detect if the query is a pubkey (npub or hex) not already in the list; + // direct entry offers the canonical hex for storage. + const queryPubkey = parseCanonicalPubkey(deferredQuery); + const queryIsDirectPubkey = + queryPubkey !== null && + !allowlist.some((p) => p.toLowerCase() === queryPubkey); return (
))}
- ) : queryIsHexPubkey ? ( + ) : queryIsDirectPubkey ? (