From 2c68dddefcf16e2ee2cc3d0bae7564c5be6ac754 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 8 Sep 2026 14:15:11 -0400 Subject: [PATCH 1/6] fix(desktop): shared npub identity foundation (canonicalNpub, PubKey gate, strict parser) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-D1a foundation slice for the npub identity display standardization: the shared primitives every identity surface builds on, split out so the descendant slice can focus on the surfaces themselves (profile/settings controls, agents, workflows, Rust display name, guard hints). ## Summary - shared/lib/pubkey.ts: export canonicalNpub(pubkey) — strict canonical full-npub helper (64-char hex any case, or checksum-validated npub → canonical npub; degenerate/short/corrupt → null), alongside the truncateNpub compact display + UNAVAILABLE_KEY_LABEL foundation. Base exports (normalizePubkey, truncatePubkey) unchanged. - shared/ui/PubKey.tsx: the widget's identity gate validates through canonicalNpub — a degenerate-length hex (npubEncode("deadbeef") produces a checksum-valid fake npub) renders Unavailable with no copy affordance in every variant; popover copy is npub-only. - shared/lib/nostrUtils.ts: parsePubkeyInput rejects npubs whose payload is not exactly a 64-char identity key, matching the Rust validate_respond_to_allowlist contract; regression tests pin the degenerate vectors (npub1m6kmamcvty5gd, npub106246s). - Tests: pubkey.test.mjs covers canonicalNpub/truncateNpub/label and the degenerate-encode edge; new PubKey.test.mjs (JSDOM harness per MentionAutocomplete pattern) covers compact/non-interactive/full-popover rendering, npub-only copy, and invalid-key suppression; parsePubkeyInput.test.mjs pins the strict parser vectors. - e2e (shared-widget boundary only): profile.spec.ts owned-agent public key row asserts the npub prefix (the row renders through the shared widget); pubkey-display-screenshots.spec.ts asserts the shared widget popover text is npub-only while the chip's legacy raw-hex popover line still documents the D1a boundary. The remaining profile/settings clipboard assertions and the chip raw-hex line removal land with their surfaces in the descendant slice. Validation: pnpm install (hermit); pnpm check; pnpm typecheck; full desktop unit suite; Playwright pubkey-display (smoke, 4) and profile key-row/ingress (integration) mockbridge assertions. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src/shared/lib/nostrUtils.ts | 6 +- .../src/shared/lib/parsePubkeyInput.test.mjs | 7 + desktop/src/shared/lib/pubkey.test.mjs | 64 +++++- desktop/src/shared/lib/pubkey.ts | 53 ++++- desktop/src/shared/ui/PubKey.test.mjs | 186 ++++++++++++++++++ desktop/src/shared/ui/PubKey.tsx | 81 +++++--- desktop/tests/e2e/profile.spec.ts | 3 +- .../e2e/pubkey-display-screenshots.spec.ts | 4 + 8 files changed, 368 insertions(+), 36 deletions(-) create mode 100644 desktop/src/shared/ui/PubKey.test.mjs diff --git a/desktop/src/shared/lib/nostrUtils.ts b/desktop/src/shared/lib/nostrUtils.ts index d98c6ee8cfb..8f14b740d0e 100644 --- a/desktop/src/shared/lib/nostrUtils.ts +++ b/desktop/src/shared/lib/nostrUtils.ts @@ -31,7 +31,9 @@ const HEX_PUBKEY_REGEX = /^[0-9a-f]{64}$/; * anything else (does NOT throw — intended for live form validation). * * The input is trimmed first; surrounding whitespace from copy-paste is - * tolerated. + * tolerated. The npub payload must decode to exactly a 64-char hex identity + * key: `npubEncode` also encodes degenerate short payloads (even `""`), which + * are never valid identities. */ export function parsePubkeyInput(input: string): string | null { const trimmed = input.trim().toLowerCase(); @@ -41,7 +43,7 @@ export function parsePubkeyInput(input: string): string | null { if (trimmed.startsWith("npub1")) { try { const decoded = decode(trimmed); - if (decoded.type === "npub") { + if (decoded.type === "npub" && HEX_PUBKEY_REGEX.test(decoded.data)) { return decoded.data; } } catch { diff --git a/desktop/src/shared/lib/parsePubkeyInput.test.mjs b/desktop/src/shared/lib/parsePubkeyInput.test.mjs index f9aa57d87f1..279b99dc5c1 100644 --- a/desktop/src/shared/lib/parsePubkeyInput.test.mjs +++ b/desktop/src/shared/lib/parsePubkeyInput.test.mjs @@ -42,6 +42,13 @@ describe("parsePubkeyInput", () => { assert.equal(parsePubkeyInput(`${HEX}0`), null); }); + it("rejects degenerate npubs whose payload is not a 64-char identity", () => { + // `npubEncode` happily encodes short payloads with valid checksums — + // those are not identity keys and must never bind as one. + assert.equal(parsePubkeyInput("npub1m6kmamcvty5gd"), null); + assert.equal(parsePubkeyInput("npub106246s"), null); + }); + it("rejects non-hex non-npub input", () => { assert.equal(parsePubkeyInput(""), null); assert.equal(parsePubkeyInput("alice"), null); diff --git a/desktop/src/shared/lib/pubkey.test.mjs b/desktop/src/shared/lib/pubkey.test.mjs index 76d0a29b231..e7d4fc6cbdc 100644 --- a/desktop/src/shared/lib/pubkey.test.mjs +++ b/desktop/src/shared/lib/pubkey.test.mjs @@ -1,10 +1,21 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { normalizePubkey, truncatePubkey } from "./pubkey.ts"; +import { + canonicalNpub, + normalizePubkey, + truncateNpub, + truncatePubkey, + UNAVAILABLE_KEY_LABEL, +} from "./pubkey.ts"; const PUBKEY = "44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435"; +const PUBKEY_NPUB = + "npub1gjuws2a2dc8z2nszprtg7v6u9q7ffeah3hgl5yx45jwn7y7aqs6s5e9xj6"; +const HEX = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328"; +const HEX_NPUB = + "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60"; test("truncates to the canonical 8+4 form with unicode ellipsis", () => { assert.equal(truncatePubkey(PUBKEY), "44b8e82b…0435"); @@ -18,3 +29,54 @@ test("returns short strings unchanged", () => { test("normalizePubkey trims and lowercases", () => { assert.equal(normalizePubkey(" ABCDEF "), "abcdef"); }); + +test("truncateNpub compacts the hex pubkey's npub, not its hex form", () => { + assert.equal(truncateNpub(PUBKEY), "npub1gju…9xj6"); + assert.equal(truncateNpub(HEX), "npub1a2d…yp60"); +}); + +test("truncateNpub accepts already-npub strings", () => { + assert.equal(truncateNpub(PUBKEY_NPUB), "npub1gju…9xj6"); + assert.equal(truncateNpub(` ${HEX_NPUB} `), "npub1a2d…yp60"); +}); + +test("truncateNpub accepts uppercase hex", () => { + assert.equal(truncateNpub(HEX.toUpperCase()), "npub1a2d…yp60"); +}); + +test("truncateNpub renders the neutral label for invalid identities", () => { + // Never the raw hex/input fallback: a wrong-length or non-hex string is not + // a displayable identity. + assert.equal(truncateNpub(""), UNAVAILABLE_KEY_LABEL); + assert.equal(truncateNpub("not a pubkey"), UNAVAILABLE_KEY_LABEL); + assert.equal(truncateNpub(`${HEX.slice(0, 63)}`), UNAVAILABLE_KEY_LABEL); + assert.equal(truncateNpub(`z${HEX.slice(1)}`), UNAVAILABLE_KEY_LABEL); + // Corrupted npub checksum is not a valid identity either. + assert.equal( + truncateNpub(`${HEX_NPUB.slice(0, -1)}q`), + UNAVAILABLE_KEY_LABEL, + ); + // Other bech32 entities are not pubkeys. + assert.equal( + truncateNpub( + "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + ), + UNAVAILABLE_KEY_LABEL, + ); +}); + +test("UNAVAILABLE_KEY_LABEL stays the existing neutral vocabulary", () => { + assert.equal(UNAVAILABLE_KEY_LABEL, "Unavailable"); +}); + +test("canonicalNpub returns the full npub for valid identities only", () => { + assert.equal(canonicalNpub(HEX), HEX_NPUB); + assert.equal(canonicalNpub(HEX.toUpperCase()), HEX_NPUB); + assert.equal(canonicalNpub(HEX_NPUB), HEX_NPUB); + // Strict identity keys only — short/degenerate payloads never encode. + assert.equal(canonicalNpub(""), null); + assert.equal(canonicalNpub("deadbeef"), null); + assert.equal(canonicalNpub(`${HEX.slice(0, 63)}`), null); + // Corrupted checksum never binds as the identity it resembles. + assert.equal(canonicalNpub(`${HEX_NPUB.slice(0, -2)}qq`), null); +}); diff --git a/desktop/src/shared/lib/pubkey.ts b/desktop/src/shared/lib/pubkey.ts index 6dcc48749a3..6aacb7f2794 100644 --- a/desktop/src/shared/lib/pubkey.ts +++ b/desktop/src/shared/lib/pubkey.ts @@ -1,3 +1,7 @@ +import { decode, npubEncode } from "nostr-tools/nip19"; + +import { safeNpub } from "./nostrUtils"; + /** * Canonical pubkey normalisation. * @@ -8,14 +12,22 @@ export function normalizePubkey(pubkey: string): string { return pubkey.trim().toLowerCase(); } +/** Neutral identity label for keys that cannot be encoded for display. */ +export const UNAVAILABLE_KEY_LABEL = "Unavailable"; + +const HEX_64_REGEX = /^[0-9a-f]{64}$/; + /** - * The ONE canonical compact display form for a pubkey: `abcd1234…wxyz`. + * The ONE canonical compact display form for a hex string: `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. + * + * Identity (pubkey) surfaces should use `truncateNpub` instead; this hex form + * remains canonical for non-identity identifiers — event and blob IDs. */ export function truncatePubkey(pubkey: string): string { if (pubkey.length <= 12) { @@ -23,3 +35,42 @@ export function truncatePubkey(pubkey: string): string { } return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; } + +/** + * Canonical full npub for an identity key: a 64-char hex pubkey (any + * case) or an already-`npub1…` string (checksum-validated) returns the + * canonical npub; anything else returns null. Strict 64-char identity keys + * only — `npubEncode` happily encodes short/degenerate payloads (even `""`), + * which are not displayable identities. + */ +export function canonicalNpub(pubkey: string): string | null { + const trimmed = pubkey.trim(); + if (trimmed.startsWith("npub1")) { + try { + const decoded = decode(trimmed); + if (decoded.type !== "npub" || !HEX_64_REGEX.test(decoded.data)) { + return null; + } + return npubEncode(decoded.data); + } catch { + return null; + } + } + const normalized = normalizePubkey(trimmed); + return HEX_64_REGEX.test(normalized) ? safeNpub(normalized) : null; +} + +/** + * The ONE canonical compact identity display for a pubkey: `npub1abcd…wxyz` + * (first 8 + last 4 of the FULL npub). + * + * Identity surfaces render this form so a displayed prefix is always npub- + * shaped; the underlying hex never leaks as the identity display. A + * truncated key is a recognition aid, never an identity proof — trust + * decisions use `` or the full npub directly. Invalid + * keys render `UNAVAILABLE_KEY_LABEL`, never raw hex or raw input. + */ +export function truncateNpub(pubkey: string): string { + const npub = canonicalNpub(pubkey); + return npub === null ? UNAVAILABLE_KEY_LABEL : truncatePubkey(npub); +} diff --git a/desktop/src/shared/ui/PubKey.test.mjs b/desktop/src/shared/ui/PubKey.test.mjs new file mode 100644 index 00000000000..e72774996b7 --- /dev/null +++ b/desktop/src/shared/ui/PubKey.test.mjs @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + CustomEvent: dom.window.CustomEvent, + document: dom.window.document, + Element: dom.window.Element, + Event: dom.window.Event, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + Node: dom.window.Node, + NodeFilter: dom.window.NodeFilter, + ResizeObserver: class { + disconnect() {} + observe() {} + unobserve() {} + }, + window: dom.window, + }); + // Copy remaining DOM-level globals that Radix Popover focus machinery + // references without a `window.` prefix (NodeFilter, HTMLInputElement, + // …). Bulk-copy follows the HarnessCatalogDialog.acpForcedGate test pattern + // so new Radix internals cannot reintroduce per-global whack-a-mole. + for (const key of Object.getOwnPropertyNames(dom.window)) { + if ( + !(key in globalThis) && + (key.startsWith("HTML") || + key.startsWith("SVG") || + key.startsWith("CSS") || + [ + "Node", + "NodeFilter", + "NodeList", + "NamedNodeMap", + "Event", + "CustomEvent", + "MouseEvent", + "KeyboardEvent", + "FocusEvent", + "InputEvent", + "PointerEvent", + "EventTarget", + "Text", + "DocumentFragment", + "Range", + "Selection", + ].includes(key)) + ) { + const val = dom.window[key]; + if (val !== undefined) globalThis[key] = val; + } + } +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +const HEX = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328"; +const NPUB = "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60"; +const COMPACT_NPUB = "npub1a2d…yp60"; + +test("compact PubKey renders the truncated npub, never the hex", async () => { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { PubKey } = await import("./PubKey.tsx"); + + const view = render(React.createElement(PubKey, { pubkey: HEX })); + + const trigger = view.getByRole("button", { name: "Show full public key" }); + assert.equal(trigger.textContent, COMPACT_NPUB); + assert.equal(view.queryByText(HEX), null); +}); + +test("non-interactive compact PubKey renders the truncated npub as text", async () => { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { PubKey } = await import("./PubKey.tsx"); + + const view = render( + React.createElement(PubKey, { interactive: false, pubkey: HEX }), + ); + + assert.equal(view.getByText(COMPACT_NPUB).tagName, "SPAN"); + assert.equal(view.queryByRole("button"), null); + assert.equal(view.queryByText(HEX), null); +}); + +test("full PubKey renders the complete npub with an npub-only copy popover", async () => { + const React = await import("react"); + const { fireEvent, render, within } = await import("@testing-library/react"); + const { PubKey } = await import("./PubKey.tsx"); + + const view = render( + React.createElement(PubKey, { pubkey: HEX, variant: "full" }), + ); + + assert.equal(within(view.container).getByText(NPUB).textContent, NPUB); + assert.equal(view.queryByText(HEX), null); + + fireEvent.click( + within(view.container).getByRole("button", { name: "Copy public key" }), + ); + // The copy popover offers the full canonical npub only — no hex row. + assert.equal(view.getAllByText(NPUB).length, 2); + assert.equal( + view.getByRole("button", { name: "Copy npub" }).tagName, + "BUTTON", + ); + assert.equal(view.queryByRole("button", { name: "Copy hex" }), null); + assert.equal(view.queryByText("hex"), null); +}); + +test("invalid keys render Unavailable with no copy affordance", async () => { + const React = await import("react"); + const { render, within } = await import("@testing-library/react"); + const { PubKey } = await import("./PubKey.tsx"); + + const compact = render(React.createElement(PubKey, { pubkey: "zz" })); + assert.equal( + within(compact.container).getByText("Unavailable").tagName, + "SPAN", + ); + assert.equal( + within(compact.container).queryByRole("button", { + name: "Show full public key", + }), + null, + ); + + const full = render( + React.createElement(PubKey, { pubkey: "zz", variant: "full" }), + ); + assert.equal(within(full.container).getByText("Unavailable").tagName, "SPAN"); + // No copy action for an identity that cannot be encoded. + assert.equal( + within(full.container).queryByRole("button", { name: "Copy public key" }), + null, + ); +}); + +test("degenerate-length hex keys render Unavailable, never a fake npub", async () => { + const React = await import("react"); + const { render, within } = await import("@testing-library/react"); + const { PubKey } = await import("./PubKey.tsx"); + + // `npubEncode` happily encodes an 8-char hex into a valid-checksum npub — + // that is not an identity key, so no surface may show or copy it. + const short = render(React.createElement(PubKey, { pubkey: "deadbeef" })); + assert.equal( + within(short.container).getByText("Unavailable").tagName, + "SPAN", + ); + assert.equal( + within(short.container).queryByRole("button", { + name: "Show full public key", + }), + null, + ); + + const shortFull = render( + React.createElement(PubKey, { pubkey: "deadbeef", variant: "full" }), + ); + assert.equal( + within(shortFull.container).getByText("Unavailable").tagName, + "SPAN", + ); + assert.equal( + within(shortFull.container).queryByRole("button", { + name: "Copy public key", + }), + null, + ); + assert.equal(shortFull.container.textContent?.includes("npub1"), false); +}); diff --git a/desktop/src/shared/ui/PubKey.tsx b/desktop/src/shared/ui/PubKey.tsx index fb5ac15a441..e8fe0bdead8 100644 --- a/desktop/src/shared/ui/PubKey.tsx +++ b/desktop/src/shared/ui/PubKey.tsx @@ -3,8 +3,11 @@ 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 { + canonicalNpub, + truncateNpub, + UNAVAILABLE_KEY_LABEL, +} from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, @@ -19,12 +22,12 @@ 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. + * `compact` — truncated npub (`npub1abcd…wxyz`), click/tap opens a popover + * with the full npub and its copy button. 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 + * `full` — the complete npub rendered inline with a copy button. 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. */ @@ -66,12 +69,10 @@ function CopyRow({ label, value }: { label: string; value: string }) { ); } -function PubKeyDetails({ pubkey }: { pubkey: string }) { - const npub = safeNpub(pubkey); +function PubKeyDetails({ npub }: { npub: string }) { return (
- {npub ? : null} - +
); } @@ -119,29 +120,47 @@ export function PubKey({ React.useEffect(() => clearHoverTimer, [clearHoverTimer]); + // Strict identity gate: `safeNpub` would happily encode degenerate short + // payloads (e.g. an 8-char hex) as a fake npub, so the widget validates + // through `canonicalNpub` and renders Unavailable for anything else. + const npub = canonicalNpub(pubkey); + if (variant === "full") { - const npub = safeNpub(pubkey); return ( - {npub ?? pubkey} - - - - - - - - + + {npub ?? UNAVAILABLE_KEY_LABEL} + + {npub ? ( + + + + + + + + + ) : null} + + ); + } + + // An unencodable key has no key display to expand: render the neutral + // label without a popover or copy affordance. + if (npub === null) { + return ( + + {UNAVAILABLE_KEY_LABEL} ); } @@ -149,7 +168,7 @@ export function PubKey({ if (!interactive) { return ( - {truncatePubkey(pubkey)} + {truncateNpub(pubkey)} ); } @@ -168,7 +187,7 @@ export function PubKey({ onMouseLeave={handleMouseLeave} type="button" > - {truncatePubkey(pubkey)} + {truncateNpub(pubkey)} event.preventDefault()} > - + ); diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 095f0fe401e..e47d75706cb 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; import { createMockAgentMemoryListing, @@ -420,7 +421,7 @@ test("owned agent profile stays in parity between Agents and its DM", async ({ .getByRole("button", { name: `Open profile for ${agentName}` }) .click(); await expect(page.getByTestId("user-profile-public-key")).toContainText( - agentPubkey.slice(0, 8), + npubEncode(agentPubkey).slice(0, 8), ); const dmSurface = await readOwnedAgentProfileContract(page); diff --git a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts index 77630467da1..1ad248b5bad 100644 --- a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts +++ b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts @@ -189,6 +189,10 @@ test("selected new-DM recipient can be verified again through search", async ({ await charlieNameTrigger.click(); await expect(charlieKeyPopover).toBeVisible(); await expect(charliePubkey).toContainText("npub1"); + // The shared PubKey widget is npub-only — no hex text or hex copy row in + // the widget itself. (D1a boundary: the chip's legacy raw-hex popover line + // is removed with the chip change in the descendant slice.) + await expect(charliePubkey).not.toContainText(TEST_IDENTITIES.charlie.pubkey); await expect(charlieKeyPopover).toContainText(TEST_IDENTITIES.charlie.pubkey); await waitForAnimations(page); await page.getByTestId("new-message-page").screenshot({ From e1a2a463fd2d6c03709ac5d92dbfad211e0594ba Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 8 Sep 2026 14:56:00 -0400 Subject: [PATCH 2/6] test(desktop): slim npub foundation suites per proportionality audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests-only cleanup for the D1a foundation slice, applying the audited consolidations from the npub test proportionality review. No production change: PubKey.tsx, pubkey.ts, nostrUtils.ts, and both e2e specs are untouched, so the original slice's Rust/build/unit evidence still binds. ## Summary - pubkey.test.mjs: drop the standalone UNAVAILABLE_KEY_LABEL constant-vocabulary case — the invalid-output test now pins the literal "Unavailable" — and fold the redundant uppercase-hex spelling into the compact case. Both independent known vectors, the corrupted-checksum, and the nsec rejections are retained. - PubKey.test.mjs: 186 -> 96 lines. The suite duplicated coverage the existing harnesses already own: profile.spec.ts copies the full canonical npub through a widget surface and pubkey-display-screenshots spec mounts this widget's npub-only popover. The slim local suite keeps only the wiring those harnesses cannot pin: the compact truncated npub (interactive trigger and non-interactive text), the full npub with its copy affordance, and the strict identity gate — invalid and degenerate-length hex ("deadbeef" npubEncodes to a checksum-valid fake npub) render Unavailable with no copy affordance and no npub1 text. The bulk JSDOM global-copy scaffolding (needed only to open the Radix popover) and the duplicated short-invalid render matrix are removed. Validation: focused rewritten suites green (pubkey, PubKey, parsePubkeyInput); mutation check — swapping the widget gate to a naive npubEncode fails the unencodable case; full desktop unit suite 6458/6458; just desktop-check; just desktop-typecheck. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src/shared/lib/pubkey.test.mjs | 25 +--- desktop/src/shared/ui/PubKey.test.mjs | 184 +++++++------------------ 2 files changed, 54 insertions(+), 155 deletions(-) diff --git a/desktop/src/shared/lib/pubkey.test.mjs b/desktop/src/shared/lib/pubkey.test.mjs index e7d4fc6cbdc..415c0cf1acd 100644 --- a/desktop/src/shared/lib/pubkey.test.mjs +++ b/desktop/src/shared/lib/pubkey.test.mjs @@ -6,7 +6,6 @@ import { normalizePubkey, truncateNpub, truncatePubkey, - UNAVAILABLE_KEY_LABEL, } from "./pubkey.ts"; const PUBKEY = @@ -33,6 +32,7 @@ test("normalizePubkey trims and lowercases", () => { test("truncateNpub compacts the hex pubkey's npub, not its hex form", () => { assert.equal(truncateNpub(PUBKEY), "npub1gju…9xj6"); assert.equal(truncateNpub(HEX), "npub1a2d…yp60"); + assert.equal(truncateNpub(HEX.toUpperCase()), "npub1a2d…yp60"); }); test("truncateNpub accepts already-npub strings", () => { @@ -40,35 +40,24 @@ test("truncateNpub accepts already-npub strings", () => { assert.equal(truncateNpub(` ${HEX_NPUB} `), "npub1a2d…yp60"); }); -test("truncateNpub accepts uppercase hex", () => { - assert.equal(truncateNpub(HEX.toUpperCase()), "npub1a2d…yp60"); -}); - test("truncateNpub renders the neutral label for invalid identities", () => { // Never the raw hex/input fallback: a wrong-length or non-hex string is not // a displayable identity. - assert.equal(truncateNpub(""), UNAVAILABLE_KEY_LABEL); - assert.equal(truncateNpub("not a pubkey"), UNAVAILABLE_KEY_LABEL); - assert.equal(truncateNpub(`${HEX.slice(0, 63)}`), UNAVAILABLE_KEY_LABEL); - assert.equal(truncateNpub(`z${HEX.slice(1)}`), UNAVAILABLE_KEY_LABEL); + assert.equal(truncateNpub(""), "Unavailable"); + assert.equal(truncateNpub("not a pubkey"), "Unavailable"); + assert.equal(truncateNpub(`${HEX.slice(0, 63)}`), "Unavailable"); + assert.equal(truncateNpub(`z${HEX.slice(1)}`), "Unavailable"); // Corrupted npub checksum is not a valid identity either. - assert.equal( - truncateNpub(`${HEX_NPUB.slice(0, -1)}q`), - UNAVAILABLE_KEY_LABEL, - ); + assert.equal(truncateNpub(`${HEX_NPUB.slice(0, -1)}q`), "Unavailable"); // Other bech32 entities are not pubkeys. assert.equal( truncateNpub( "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", ), - UNAVAILABLE_KEY_LABEL, + "Unavailable", ); }); -test("UNAVAILABLE_KEY_LABEL stays the existing neutral vocabulary", () => { - assert.equal(UNAVAILABLE_KEY_LABEL, "Unavailable"); -}); - test("canonicalNpub returns the full npub for valid identities only", () => { assert.equal(canonicalNpub(HEX), HEX_NPUB); assert.equal(canonicalNpub(HEX.toUpperCase()), HEX_NPUB); diff --git a/desktop/src/shared/ui/PubKey.test.mjs b/desktop/src/shared/ui/PubKey.test.mjs index e72774996b7..a3ef966f0af 100644 --- a/desktop/src/shared/ui/PubKey.test.mjs +++ b/desktop/src/shared/ui/PubKey.test.mjs @@ -1,3 +1,15 @@ +/** + * Widget-boundary coverage for the shared identity gate. + * + * Codec vectors and the exact compact/neutral strings live in + * ../lib/pubkey.test.mjs; real clipboard contents and the npub-only verify + * popover are exercised end-to-end (tests/e2e/profile.spec.ts copies the + * canonical npub; pubkey-display-screenshots.spec.ts mounts this widget). + * This slim local suite pins only the widget wiring those harnesses cannot: + * the rendered text per variant, and that an unencodable identity — including + * a degenerate-length hex whose npubEncode output carries a valid checksum — + * renders the neutral label with no copy affordance, never a fake npub. + */ import assert from "node:assert/strict"; import { after, afterEach, before, test } from "node:test"; @@ -9,15 +21,11 @@ const dom = new JSDOM("", { before(() => { Object.assign(globalThis, { - CustomEvent: dom.window.CustomEvent, document: dom.window.document, - Element: dom.window.Element, - Event: dom.window.Event, getComputedStyle: dom.window.getComputedStyle.bind(dom.window), HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true, Node: dom.window.Node, - NodeFilter: dom.window.NodeFilter, ResizeObserver: class { disconnect() {} observe() {} @@ -25,39 +33,6 @@ before(() => { }, window: dom.window, }); - // Copy remaining DOM-level globals that Radix Popover focus machinery - // references without a `window.` prefix (NodeFilter, HTMLInputElement, - // …). Bulk-copy follows the HarnessCatalogDialog.acpForcedGate test pattern - // so new Radix internals cannot reintroduce per-global whack-a-mole. - for (const key of Object.getOwnPropertyNames(dom.window)) { - if ( - !(key in globalThis) && - (key.startsWith("HTML") || - key.startsWith("SVG") || - key.startsWith("CSS") || - [ - "Node", - "NodeFilter", - "NodeList", - "NamedNodeMap", - "Event", - "CustomEvent", - "MouseEvent", - "KeyboardEvent", - "FocusEvent", - "InputEvent", - "PointerEvent", - "EventTarget", - "Text", - "DocumentFragment", - "Range", - "Selection", - ].includes(key)) - ) { - const val = dom.window[key]; - if (val !== undefined) globalThis[key] = val; - } - } }); afterEach(async () => { @@ -71,116 +46,51 @@ const HEX = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328"; const NPUB = "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60"; const COMPACT_NPUB = "npub1a2d…yp60"; -test("compact PubKey renders the truncated npub, never the hex", async () => { - const React = await import("react"); - const { render } = await import("@testing-library/react"); - const { PubKey } = await import("./PubKey.tsx"); - - const view = render(React.createElement(PubKey, { pubkey: HEX })); - - const trigger = view.getByRole("button", { name: "Show full public key" }); - assert.equal(trigger.textContent, COMPACT_NPUB); - assert.equal(view.queryByText(HEX), null); -}); - -test("non-interactive compact PubKey renders the truncated npub as text", async () => { - const React = await import("react"); - const { render } = await import("@testing-library/react"); - const { PubKey } = await import("./PubKey.tsx"); - - const view = render( - React.createElement(PubKey, { interactive: false, pubkey: HEX }), - ); - - assert.equal(view.getByText(COMPACT_NPUB).tagName, "SPAN"); - assert.equal(view.queryByRole("button"), null); - assert.equal(view.queryByText(HEX), null); -}); - -test("full PubKey renders the complete npub with an npub-only copy popover", async () => { - const React = await import("react"); - const { fireEvent, render, within } = await import("@testing-library/react"); - const { PubKey } = await import("./PubKey.tsx"); - - const view = render( - React.createElement(PubKey, { pubkey: HEX, variant: "full" }), - ); - - assert.equal(within(view.container).getByText(NPUB).textContent, NPUB); - assert.equal(view.queryByText(HEX), null); - - fireEvent.click( - within(view.container).getByRole("button", { name: "Copy public key" }), - ); - // The copy popover offers the full canonical npub only — no hex row. - assert.equal(view.getAllByText(NPUB).length, 2); - assert.equal( - view.getByRole("button", { name: "Copy npub" }).tagName, - "BUTTON", - ); - assert.equal(view.queryByRole("button", { name: "Copy hex" }), null); - assert.equal(view.queryByText("hex"), null); -}); - -test("invalid keys render Unavailable with no copy affordance", async () => { +async function renderPubKey(props) { const React = await import("react"); const { render, within } = await import("@testing-library/react"); const { PubKey } = await import("./PubKey.tsx"); + const view = render(React.createElement(PubKey, props)); + // render()'s bound queries search the whole body; scope to this render so + // earlier mounts (cleaned up only per test) stay invisible. + return { ...within(view.container), container: view.container }; +} - const compact = render(React.createElement(PubKey, { pubkey: "zz" })); - assert.equal( - within(compact.container).getByText("Unavailable").tagName, - "SPAN", - ); +test("compact PubKey renders the truncated npub, never the hex", async () => { + const trigger = await renderPubKey({ pubkey: HEX }); assert.equal( - within(compact.container).queryByRole("button", { - name: "Show full public key", - }), - null, + trigger.getByRole("button", { name: "Show full public key" }).textContent, + COMPACT_NPUB, ); + assert.equal(trigger.queryByText(HEX), null); - const full = render( - React.createElement(PubKey, { pubkey: "zz", variant: "full" }), - ); - assert.equal(within(full.container).getByText("Unavailable").tagName, "SPAN"); - // No copy action for an identity that cannot be encoded. - assert.equal( - within(full.container).queryByRole("button", { name: "Copy public key" }), - null, - ); + // A parent row that owns the interaction gets the same text, not a button. + const text = await renderPubKey({ interactive: false, pubkey: HEX }); + assert.equal(text.getByText(COMPACT_NPUB).tagName, "SPAN"); + assert.equal(text.queryByRole("button"), null); + assert.equal(text.queryByText(HEX), null); }); -test("degenerate-length hex keys render Unavailable, never a fake npub", async () => { - const React = await import("react"); - const { render, within } = await import("@testing-library/react"); - const { PubKey } = await import("./PubKey.tsx"); - - // `npubEncode` happily encodes an 8-char hex into a valid-checksum npub — - // that is not an identity key, so no surface may show or copy it. - const short = render(React.createElement(PubKey, { pubkey: "deadbeef" })); - assert.equal( - within(short.container).getByText("Unavailable").tagName, - "SPAN", - ); +test("full PubKey renders the complete npub with a copy affordance", async () => { + const view = await renderPubKey({ pubkey: HEX, variant: "full" }); + assert.equal(view.getByText(NPUB).textContent, NPUB); assert.equal( - within(short.container).queryByRole("button", { - name: "Show full public key", - }), - null, + view.getByRole("button", { name: "Copy public key" }).tagName, + "BUTTON", ); + assert.equal(view.queryByText(HEX), null); +}); - const shortFull = render( - React.createElement(PubKey, { pubkey: "deadbeef", variant: "full" }), - ); - assert.equal( - within(shortFull.container).getByText("Unavailable").tagName, - "SPAN", - ); - assert.equal( - within(shortFull.container).queryByRole("button", { - name: "Copy public key", - }), - null, - ); - assert.equal(shortFull.container.textContent?.includes("npub1"), false); +test("unencodable keys render Unavailable with no copy affordance", async () => { + // "zz" cannot decode; "deadbeef" is a degenerate-length hex that npubEncode + // would happily turn into a checksum-valid fake npub — refuse both. + for (const pubkey of ["zz", "deadbeef"]) { + for (const variant of [undefined, "full"]) { + const view = await renderPubKey({ pubkey, variant }); + const label = `${pubkey} ${variant ?? "compact"}`; + assert.equal(view.getByText("Unavailable").tagName, "SPAN", label); + assert.equal(view.queryByRole("button"), null, label); + assert.equal(view.container.textContent?.includes("npub1"), false, label); + } + } }); From 1d28f0ca1f4b89347b76207bb53835b9b3a74d07 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 8 Sep 2026 15:06:25 -0400 Subject: [PATCH 3/6] test(desktop): align profile-panel key assertions with npub display The D1a foundation slice renders the user profile panel's public key row through the shared widget, which now displays the canonical truncated npub instead of the raw hex prefix. Two existing smoke specs still pinned the old raw-hex text and failed on the desktop smoke e2e shard that covers them (deterministic across all retries): - identity-archive.spec.ts openAliceProfile asserted ALICE_PUBKEY.slice(0, 8) ("953d3363"); the panel now renders "npub1j57...fjmv". - mentions.spec.ts "clicking author name opens user profile panel" asserted the viewer hex "deadbeef"; the panel now renders "npub1m6k...zuz0". Both assertions now expect the canonical npub prefix via npubEncode(key).slice(0, 8), mirroring the pattern the D1a slice already used for the owned-agent public key row in profile.spec.ts. Test-only change; no production code touched. Validation (local, targeted): pnpm build:e2e; playwright --project=smoke tests/e2e/identity-archive.spec.ts (5/5 pass) and mentions.spec.ts --grep "clicking author name opens user profile panel" (21/21 pass across repeat runs; two early post-build invocations flaked once each, non-reproducible, consistent with prior first-run startup flakes). Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/tests/e2e/identity-archive.spec.ts | 5 ++++- desktop/tests/e2e/mentions.spec.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/desktop/tests/e2e/identity-archive.spec.ts b/desktop/tests/e2e/identity-archive.spec.ts index dfbe2ff2690..cb6e83def1b 100644 --- a/desktop/tests/e2e/identity-archive.spec.ts +++ b/desktop/tests/e2e/identity-archive.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; import { installMockBridge } from "../helpers/bridge"; @@ -33,7 +34,9 @@ async function openAliceProfile(page: import("@playwright/test").Page) { await aliceMessage.locator("button", { hasText: "alice" }).first().click(); const panel = page.getByTestId("user-profile-panel"); await expect(panel).toBeVisible(); - await expect(panel).toContainText(ALICE_PUBKEY.slice(0, 8)); + // The panel's public key row renders through the shared widget, + // which displays the canonical npub form — assert the npub prefix. + await expect(panel).toContainText(npubEncode(ALICE_PUBKEY).slice(0, 8)); } async function openProfileSettingsMenu(page: import("@playwright/test").Page) { diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 6d9baa1e23b..e9248509fd1 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; import { waitForAnimations } from "../helpers/animations"; @@ -4534,7 +4535,9 @@ test("clicking author name opens user profile panel", async ({ page }) => { // Click now opens the full profile panel instead of the popover const panel = page.getByTestId("user-profile-panel"); await expect(panel).toBeVisible(); - await expect(panel).toContainText("deadbeef"); + // The panel's public key row renders through the shared widget, + // which displays the canonical npub form — assert the npub prefix. + await expect(panel).toContainText(npubEncode(MOCK_VIEWER_PUBKEY).slice(0, 8)); }); test("hovering avatar opens popover, clicking opens profile panel", async ({ From 5f3a4a8111998c8aa41ad77cf66992bd1c85343c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 8 Sep 2026 15:21:35 -0400 Subject: [PATCH 4/6] fix(desktop): accept all-uppercase bech32 npubs in canonicalNpub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote-review correctness fix for the D1a foundation (PR #7488, CHANGES_REQUESTED by jedwards27 at 2c68dddef): canonicalNpub()'s case-sensitive startsWith("npub1") rejected valid all-uppercase Bech32 npubs even though parsePubkeyInput accepts them (it lowercases before decoding), so identity surfaces rendered the neutral "Unavailable" label for a key the app itself considers valid. ## Summary - pubkey.ts canonicalNpub: the bech32 prefix gate now accepts both valid casings — lowercase `npub1...` and all-uppercase `NPUB1...` — returning the canonical lowercase npub for either. Mixed-case npubs remain invalid (nostr-tools decode enforces the all-lower/all-upper Bech32 rule and throws, so they return null), and the hex path is untouched: case-insensitive 64-char hex, strict 32-byte identity payloads, and the neutral null/"Unavailable" contract for everything else are preserved. - Regression coverage added to the existing formatter and widget case matrices (no new test files or codec suites): canonicalNpub returns the canonical lowercase npub for an all-uppercase npub and null for a mixed-case one; truncateNpub renders the compact form for uppercase input; the widget renders the compact canonical form — not "Unavailable" — for an all-uppercase npub. ## Validation (local, targeted) - node --test focused suites: pubkey.test.mjs, parsePubkeyInput.test.mjs (unmodified, parser agreement), PubKey.test.mjs — 19/19 pass. - Red-to-green: reverting only pubkey.ts while keeping the new assertions fails 3 tests (canonicalNpub -> null, truncateNpub -> "Unavailable", widget renders "Unavailable"); restoring the fix is 19/19 green. Mixed-case rejection passes in both states (pinned, not regressed). - pnpm typecheck; pnpm check (biome + px-text + pubkey-truncation guards). - pnpm build:e2e + the two smoke specs changed by 1d28f0ca1: identity-archive.spec.ts 5/5, and mentions.spec.ts --grep "clicking author name opens user profile panel" 1/1 deterministic across two repeat runs — the required-CI shard failure cited in review, verified green at this head. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src/shared/lib/pubkey.test.mjs | 12 ++++++++++++ desktop/src/shared/lib/pubkey.ts | 10 ++++++++-- desktop/src/shared/ui/PubKey.test.mjs | 10 ++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/desktop/src/shared/lib/pubkey.test.mjs b/desktop/src/shared/lib/pubkey.test.mjs index 415c0cf1acd..b3ef8ade0ff 100644 --- a/desktop/src/shared/lib/pubkey.test.mjs +++ b/desktop/src/shared/lib/pubkey.test.mjs @@ -38,6 +38,9 @@ test("truncateNpub compacts the hex pubkey's npub, not its hex form", () => { test("truncateNpub accepts already-npub strings", () => { assert.equal(truncateNpub(PUBKEY_NPUB), "npub1gju…9xj6"); assert.equal(truncateNpub(` ${HEX_NPUB} `), "npub1a2d…yp60"); + // All-uppercase Bech32 is a valid identity per the parser; render the + // canonical form, never the neutral label. + assert.equal(truncateNpub(HEX_NPUB.toUpperCase()), "npub1a2d…yp60"); }); test("truncateNpub renders the neutral label for invalid identities", () => { @@ -62,6 +65,15 @@ test("canonicalNpub returns the full npub for valid identities only", () => { assert.equal(canonicalNpub(HEX), HEX_NPUB); assert.equal(canonicalNpub(HEX.toUpperCase()), HEX_NPUB); assert.equal(canonicalNpub(HEX_NPUB), HEX_NPUB); + // All-uppercase Bech32 is valid and returns the canonical lowercase npub + // (parser agreement); a mixed-case npub is invalid Bech32. + assert.equal(canonicalNpub(HEX_NPUB.toUpperCase()), HEX_NPUB); + assert.equal( + canonicalNpub( + `${HEX_NPUB.slice(0, 10)}${HEX_NPUB.slice(10).toUpperCase()}`, + ), + null, + ); // Strict identity keys only — short/degenerate payloads never encode. assert.equal(canonicalNpub(""), null); assert.equal(canonicalNpub("deadbeef"), null); diff --git a/desktop/src/shared/lib/pubkey.ts b/desktop/src/shared/lib/pubkey.ts index 6aacb7f2794..b8cb5044d6a 100644 --- a/desktop/src/shared/lib/pubkey.ts +++ b/desktop/src/shared/lib/pubkey.ts @@ -38,14 +38,20 @@ export function truncatePubkey(pubkey: string): string { /** * Canonical full npub for an identity key: a 64-char hex pubkey (any - * case) or an already-`npub1…` string (checksum-validated) returns the + * case) or an already-npub string (checksum-validated) returns the * canonical npub; anything else returns null. Strict 64-char identity keys * only — `npubEncode` happily encodes short/degenerate payloads (even `""`), * which are not displayable identities. + * + * Bech32 casings mirror the parser (`parsePubkeyInput`): an all-uppercase + * `NPUB1…` is valid and returns the canonical lowercase npub, while a + * mixed-case npub is invalid Bech32 and returns null — `decode` enforces + * the all-lower/all-upper rule, and the prefix gate below accepts both + * valid casings in agreement with the parser. */ export function canonicalNpub(pubkey: string): string | null { const trimmed = pubkey.trim(); - if (trimmed.startsWith("npub1")) { + if (trimmed.startsWith("npub1") || trimmed.startsWith("NPUB1")) { try { const decoded = decode(trimmed); if (decoded.type !== "npub" || !HEX_64_REGEX.test(decoded.data)) { diff --git a/desktop/src/shared/ui/PubKey.test.mjs b/desktop/src/shared/ui/PubKey.test.mjs index a3ef966f0af..989b841ab0c 100644 --- a/desktop/src/shared/ui/PubKey.test.mjs +++ b/desktop/src/shared/ui/PubKey.test.mjs @@ -69,6 +69,16 @@ test("compact PubKey renders the truncated npub, never the hex", async () => { assert.equal(text.getByText(COMPACT_NPUB).tagName, "SPAN"); assert.equal(text.queryByRole("button"), null); assert.equal(text.queryByText(HEX), null); + + // An all-uppercase Bech32 npub is a valid identity (parsePubkeyInput + // accepts it); the gate must render its canonical compact form, not the + // neutral label. + const upper = await renderPubKey({ pubkey: NPUB.toUpperCase() }); + assert.equal( + upper.getByRole("button", { name: "Show full public key" }).textContent, + COMPACT_NPUB, + ); + assert.equal(upper.queryByText("Unavailable"), null); }); test("full PubKey renders the complete npub with a copy affordance", async () => { From 5c20712c80a5440be25d04c86725bd1ddcd43ab5 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 8 Sep 2026 18:27:40 -0400 Subject: [PATCH 5/6] test(desktop): pin short-npub identity gate and casing contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add direct canonicalNpub null assertions for checksum-valid short npubs (npub1m6kmamcvty5gd, npub106246s) and route the same vectors through the existing PubKey widget invalid loop, replacing the false header claim that profile.spec.ts covered widget copy. Pin the parser pre-existing mixed-case npub normalization to hex, and document in pubkey.ts and nostrUtils.ts that canonicalNpub enforces strict Bech32 display casing while parsePubkeyInput case-normalizes input — both requiring a 64-hex identity payload. Production changes are comments only. Co-authored-by: 627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz Signed-off-by: Logan Johnson --- desktop/src/shared/lib/nostrUtils.ts | 10 ++++--- .../src/shared/lib/parsePubkeyInput.test.mjs | 10 +++++++ desktop/src/shared/lib/pubkey.test.mjs | 4 +++ desktop/src/shared/lib/pubkey.ts | 13 ++++++---- desktop/src/shared/ui/PubKey.test.mjs | 26 ++++++++++++------- 5 files changed, 46 insertions(+), 17 deletions(-) diff --git a/desktop/src/shared/lib/nostrUtils.ts b/desktop/src/shared/lib/nostrUtils.ts index 8f14b740d0e..9b6fe0fb23d 100644 --- a/desktop/src/shared/lib/nostrUtils.ts +++ b/desktop/src/shared/lib/nostrUtils.ts @@ -31,9 +31,13 @@ const HEX_PUBKEY_REGEX = /^[0-9a-f]{64}$/; * anything else (does NOT throw — intended for live form validation). * * The input is trimmed first; surrounding whitespace from copy-paste is - * tolerated. The npub payload must decode to exactly a 64-char hex identity - * key: `npubEncode` also encodes degenerate short payloads (even `""`), which - * are never valid identities. + * tolerated. It is also case-normalized before matching and decoding — + * preexisting behavior — so a hex key in any casing resolves, and a + * mixed-case npub (invalid Bech32 as written) is accepted via its + * lowercased form. The identity payload itself stays strict: it must + * decode to exactly a 64-char hex identity key, because `npubEncode` also + * encodes degenerate short payloads (even `""`), which are never valid + * identities. */ export function parsePubkeyInput(input: string): string | null { const trimmed = input.trim().toLowerCase(); diff --git a/desktop/src/shared/lib/parsePubkeyInput.test.mjs b/desktop/src/shared/lib/parsePubkeyInput.test.mjs index 279b99dc5c1..b74331cd984 100644 --- a/desktop/src/shared/lib/parsePubkeyInput.test.mjs +++ b/desktop/src/shared/lib/parsePubkeyInput.test.mjs @@ -19,6 +19,16 @@ describe("parsePubkeyInput", () => { assert.equal(parsePubkeyInput(NPUB), HEX); }); + it("normalizes a mixed-case npub to its canonical hex", () => { + // Preexisting behavior: user input is lowercased before decoding, so a + // mixed-case npub — invalid Bech32 as written — still resolves to the + // identity. canonicalNpub is the strict counterpart (see ../lib/pubkey.ts). + assert.equal( + parsePubkeyInput(`${NPUB.slice(0, 10)}${NPUB.slice(10).toUpperCase()}`), + HEX, + ); + }); + it("tolerates surrounding whitespace from copy-paste", () => { assert.equal(parsePubkeyInput(` ${NPUB}\n`), HEX); assert.equal(parsePubkeyInput(` ${HEX} `), HEX); diff --git a/desktop/src/shared/lib/pubkey.test.mjs b/desktop/src/shared/lib/pubkey.test.mjs index b3ef8ade0ff..bce5834162d 100644 --- a/desktop/src/shared/lib/pubkey.test.mjs +++ b/desktop/src/shared/lib/pubkey.test.mjs @@ -78,6 +78,10 @@ test("canonicalNpub returns the full npub for valid identities only", () => { assert.equal(canonicalNpub(""), null); assert.equal(canonicalNpub("deadbeef"), null); assert.equal(canonicalNpub(`${HEX.slice(0, 63)}`), null); + // Checksum-valid short npubs are degenerate payloads too (8-char and + // empty) — `npubEncode` would happily re-encode them, so never bind them. + assert.equal(canonicalNpub("npub1m6kmamcvty5gd"), null); + assert.equal(canonicalNpub("npub106246s"), null); // Corrupted checksum never binds as the identity it resembles. assert.equal(canonicalNpub(`${HEX_NPUB.slice(0, -2)}qq`), null); }); diff --git a/desktop/src/shared/lib/pubkey.ts b/desktop/src/shared/lib/pubkey.ts index b8cb5044d6a..199ad52d3c1 100644 --- a/desktop/src/shared/lib/pubkey.ts +++ b/desktop/src/shared/lib/pubkey.ts @@ -43,11 +43,14 @@ export function truncatePubkey(pubkey: string): string { * only — `npubEncode` happily encodes short/degenerate payloads (even `""`), * which are not displayable identities. * - * Bech32 casings mirror the parser (`parsePubkeyInput`): an all-uppercase - * `NPUB1…` is valid and returns the canonical lowercase npub, while a - * mixed-case npub is invalid Bech32 and returns null — `decode` enforces - * the all-lower/all-upper rule, and the prefix gate below accepts both - * valid casings in agreement with the parser. + * Bech32 casing is strict on the input as written: a lowercase `npub1…` + * or an all-uppercase `NPUB1…` (both valid Bech32) returns the canonical + * lowercase npub, while a mixed-case npub is invalid Bech32 and returns + * null — `decode` enforces the all-lower/all-upper rule. This is + * intentionally stricter than the parser (`parsePubkeyInput`), which + * normalizes user input before decoding and so also accepts mixed-case + * npubs; the two agree that the payload must be a 64-hex identity key and + * that both valid casings above are acceptable input. */ export function canonicalNpub(pubkey: string): string | null { const trimmed = pubkey.trim(); diff --git a/desktop/src/shared/ui/PubKey.test.mjs b/desktop/src/shared/ui/PubKey.test.mjs index 989b841ab0c..9ae23cb3abc 100644 --- a/desktop/src/shared/ui/PubKey.test.mjs +++ b/desktop/src/shared/ui/PubKey.test.mjs @@ -2,13 +2,13 @@ * Widget-boundary coverage for the shared identity gate. * * Codec vectors and the exact compact/neutral strings live in - * ../lib/pubkey.test.mjs; real clipboard contents and the npub-only verify - * popover are exercised end-to-end (tests/e2e/profile.spec.ts copies the - * canonical npub; pubkey-display-screenshots.spec.ts mounts this widget). - * This slim local suite pins only the widget wiring those harnesses cannot: - * the rendered text per variant, and that an unencodable identity — including - * a degenerate-length hex whose npubEncode output carries a valid checksum — - * renders the neutral label with no copy affordance, never a fake npub. + * ../lib/pubkey.test.mjs. This suite pins what static rendering shows: the + * rendered text per variant, and that an unencodable identity — including + * degenerate-length hex and short-payload npubs whose npubEncode outputs + * carry valid checksums — renders the neutral label with no copy affordance, + * never a fake npub. Static rendering does not exercise the clipboard + * write behind the copy affordance or the popover the widget can open; + * those interactions are outside this suite's scope. */ import assert from "node:assert/strict"; import { after, afterEach, before, test } from "node:test"; @@ -93,8 +93,16 @@ test("full PubKey renders the complete npub with a copy affordance", async () => test("unencodable keys render Unavailable with no copy affordance", async () => { // "zz" cannot decode; "deadbeef" is a degenerate-length hex that npubEncode - // would happily turn into a checksum-valid fake npub — refuse both. - for (const pubkey of ["zz", "deadbeef"]) { + // would happily turn into a checksum-valid fake npub; npub1m6kmamcvty5gd + // and npub106246s decode fine but are checksum-valid short-payload npubs + // (8-char and empty identity payloads). All four would masquerade as + // displayable identities — the gate refuses every one. + for (const pubkey of [ + "zz", + "deadbeef", + "npub1m6kmamcvty5gd", + "npub106246s", + ]) { for (const variant of [undefined, "full"]) { const view = await renderPubKey({ pubkey, variant }); const label = `${pubkey} ${variant ?? "compact"}`; From b3310c24832b29d8ee90ea76a7878ac01be13ea3 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 8 Sep 2026 19:05:51 -0400 Subject: [PATCH 6/6] fix(desktop): keep portaled popover clicks from stealing To-field focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recipient-inspection popovers portal their content to the document body but still bubble click events through React's tree to the new-DM To-field div, whose onClick focused the search input. Focusing that input dismisses the popover via focus-outside, so ordinary clicks on the nested Copy npub button detached the popover mid-click and never copied. The field's onClick now only acts on clicks whose event target is a DOM descendant of the field itself; portaled popover clicks keep their own focus. Clicks physically within the field — including the To label — still focus the input and open the recipient picker. The clipboard regressions this unblocks pin both PubKey variants through the mock bridge to the real browser clipboard: the new-DM recipient verification flow (full variant: pointer copy, inspection survives the copy, inner/outer Escape with the recipient retained, and a natural keyboard Space-then-Enter path) and the agent-access owner hint (compact variant), each expecting the exact canonical npub of the identity shown — never the raw hex the popover also lists, and never a truncation. The static PubKey suite header now names those E2E owners of the clipboard interaction. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../features/messages/ui/NewMessageScreen.tsx | 17 ++++- desktop/src/shared/ui/PubKey.test.mjs | 10 ++- .../tests/e2e/agent-access-warning.spec.ts | 26 ++++++++ .../e2e/pubkey-display-screenshots.spec.ts | 62 +++++++++++++++++++ 4 files changed, 111 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/messages/ui/NewMessageScreen.tsx b/desktop/src/features/messages/ui/NewMessageScreen.tsx index f7192f6e45c..5c5c527df7c 100644 --- a/desktop/src/features/messages/ui/NewMessageScreen.tsx +++ b/desktop/src/features/messages/ui/NewMessageScreen.tsx @@ -328,7 +328,22 @@ export function NewMessageScreen() {
{ + onClick={(event) => { + // Portaled popovers (recipient inspection and its nested key + // copy) still bubble through React's tree to this handler, + // but their event targets are not DOM descendants of the + // field. Those clicks belong to the popover's own controls + // — they must not steal focus into the search input (which + // dismisses the popover via focus-outside) or reopen the + // picker. Only clicks physically within the recipient field + // focus its input. + const { currentTarget, target } = event; + if ( + !(target instanceof Node) || + !currentTarget.contains(target) + ) { + return; + } setIsRecipientPickerOpen(true); searchInputRef.current?.focus({ preventScroll: true }); }} diff --git a/desktop/src/shared/ui/PubKey.test.mjs b/desktop/src/shared/ui/PubKey.test.mjs index 9ae23cb3abc..08167ae1c47 100644 --- a/desktop/src/shared/ui/PubKey.test.mjs +++ b/desktop/src/shared/ui/PubKey.test.mjs @@ -6,9 +6,13 @@ * rendered text per variant, and that an unencodable identity — including * degenerate-length hex and short-payload npubs whose npubEncode outputs * carry valid checksums — renders the neutral label with no copy affordance, - * never a fake npub. Static rendering does not exercise the clipboard - * write behind the copy affordance or the popover the widget can open; - * those interactions are outside this suite's scope. + * never a fake npub. The clipboard write behind the copy affordance and the + * popover the widget opens are real-bridge interactions owned by the E2E + * regressions: the full variant's copy is pinned by the new-DM recipient + * verification flow (tests/e2e/pubkey-display-screenshots.spec.ts) and the + * compact variant's by the agent-access owner hint + * (tests/e2e/agent-access-warning.spec.ts); both drive CopyRow through the + * mock bridge into the actual browser clipboard. */ import assert from "node:assert/strict"; import { after, afterEach, before, test } from "node:test"; diff --git a/desktop/tests/e2e/agent-access-warning.spec.ts b/desktop/tests/e2e/agent-access-warning.spec.ts index adb9c6d58ab..55d5c4a0075 100644 --- a/desktop/tests/e2e/agent-access-warning.spec.ts +++ b/desktop/tests/e2e/agent-access-warning.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -123,6 +124,31 @@ test("open agent access explains the available access before save", async ({ .getByRole("dialog", { name: "Manage agent access" }) .screenshot({ path: `${SHOTS}/selected-people-warning.png` }); + // Compact-variant clipboard regression (D1a): the owner hint's compact + // PubKey must expand to and copy the viewer's complete canonical npub — + // the truncated trigger is only a recognition aid. The real bridge writes + // the browser clipboard and the poll reads it back. + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await page + .getByTestId("agent-respond-to") + .getByRole("button", { name: "Show full public key" }) + .click(); + const copyNpubButton = page.getByRole("button", { name: "Copy npub" }); + await copyNpubButton.click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(npubEncode("deadbeef".repeat(8))); + await expect( + page.locator("[data-sonner-toast]").filter({ hasText: "npub copied" }), + ).toBeVisible(); + // Dismiss just the key popover: the access dialog stays open for the + // remaining mode assertions below. + await page.keyboard.press("Escape"); + await expect(copyNpubButton).toHaveCount(0); + await expect( + page.getByRole("dialog", { name: "Manage agent access" }), + ).toBeVisible(); + // Only me shares nothing, so the warning goes away entirely. await accessSelect.selectOption("owner-only"); await expect(warning).toHaveCount(0); diff --git a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts index 1ad248b5bad..81ff86c3299 100644 --- a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts +++ b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; import { installMockBridge, @@ -198,8 +199,59 @@ test("selected new-DM recipient can be verified again through search", async ({ await page.getByTestId("new-message-page").screenshot({ path: `${SHOTS}/new-dm-selected-recipient-key.png`, }); + + // Full-variant clipboard regression (D1a): the nested copy affordances + // must write the recipient's complete canonical npub through the real + // bridge — never the legacy raw hex the popover also shows, and never a + // truncation. The evidence screenshot above is captured first, so this + // interaction leaves it untouched. + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + const copyPublicKeyTrigger = charliePubkey.getByRole("button", { + name: "Copy public key", + }); + await copyPublicKeyTrigger.click(); + const copyNpubButton = page.getByRole("button", { name: "Copy npub" }); + await copyNpubButton.click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(npubEncode(TEST_IDENTITIES.charlie.pubkey)); + await expect( + page.locator("[data-sonner-toast]").filter({ hasText: "npub copied" }), + ).toBeVisible(); + // Copying is not a dismissal: the nested affordance and the inspection + // popover it lives in both survive the copy. + await expect(copyNpubButton).toBeVisible(); + await expect(charlieKeyPopover).toBeVisible(); + + // The inner Escape closes only the nested key popover — the inspection + // stays open. + await page.keyboard.press("Escape"); + await expect(copyNpubButton).toHaveCount(0); + await expect(charlieKeyPopover).toBeVisible(); + + // Keyboard path: after the inner Escape focus returns naturally to the + // full-key trigger; Space reopens the popover, whose auto-focus lands on + // Copy npub, and Enter activates it. The sentinel proves this keyboard + // copy rewrites the clipboard rather than inheriting the value above. + await expect(copyPublicKeyTrigger).toBeFocused(); + await page.evaluate(() => + navigator.clipboard.writeText("keyboard-copy-sentinel"), + ); + await page.keyboard.press("Space"); + await expect(copyNpubButton).toBeVisible(); + await expect(copyNpubButton).toBeFocused(); + await page.keyboard.press("Enter"); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(npubEncode(TEST_IDENTITIES.charlie.pubkey)); + + // Close the reopened nested popover so the inspection popover owns the + // final Escape; the recipient itself survives both dismissals. + await page.keyboard.press("Escape"); + await expect(copyNpubButton).toHaveCount(0); await page.keyboard.press("Escape"); await expect(charlieKeyPopover).toHaveCount(0); + await expect(charlieChip).toBeVisible(); await search.fill("charlie"); await expect(charlieResult).toBeVisible(); @@ -230,6 +282,16 @@ test("selected new-DM recipient can be verified again through search", async ({ await page.getByTestId("new-message-page").screenshot({ path: `${SHOTS}/new-dm-selected-recipient.png`, }); + + // The To-field guard ignores popover clicks that bubble into the field; + // a click on the label itself — a physical descendant of the field — must + // still focus the input and open the recipient picker. + await page + .getByTestId("new-message-to-field") + .getByText("To:", { exact: true }) + .click(); + await expect(search).toBeFocused(); + await expect(page.getByTestId("new-message-recipient-popover")).toBeVisible(); }); test("member removal confirm shows the full npub inline", async ({ page }) => {