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({