fix(desktop): npub identity controls across profile, agents, and workflows (#7489)

🤖
## 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 <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-09-09 14:08:01 -04:00
committed by GitHub
parent ad2a84131e
commit 2226b6f95b
24 changed files with 1125 additions and 146 deletions

View File

@@ -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` / `<PubKey>` — 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 `<PubKey>` — this guard keeps ad-hoc `pubkey.slice(0, N)`
// forms from fragmenting again.
const rules = [
{
root: "src",

View File

@@ -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<String, String> {
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<IdentityInfo, String> {
let keys = state.keys.lock().map_err(|error| error.to_string())?;

View File

@@ -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]);
});

View File

@@ -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<string>();
@@ -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;
}

View File

@@ -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({
</p>
<div className="mt-3 flex items-center justify-between gap-3">
<code className="min-w-0 flex-1 break-all rounded-xl border border-border/70 bg-background/80 px-3 py-2 text-xs">
{agent?.pubkey ?? "No agent selected"}
{agent
? (agentNpub ?? UNAVAILABLE_KEY_LABEL)
: "No agent selected"}
</code>
{agent ? (
<CopyButton label="Copy pubkey" value={agent.pubkey} />
{agent && agentNpub ? (
<CopyButton label="Copy pubkey" value={agentNpub} />
) : null}
</div>
</div>

View File

@@ -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 (
<div
@@ -382,12 +383,12 @@ function AllowlistPicker({
>
<UserAvatar
avatarUrl={null}
displayName={truncatePubkey(pubkey)}
displayName={truncateNpub(pubkey)}
size="xs"
/>
<PubKey pubkey={pubkey} />
<button
aria-label={`Remove ${truncatePubkey(pubkey)}`}
aria-label={`Remove ${truncateNpub(pubkey)}`}
className="text-muted-foreground transition-colors hover:text-foreground"
disabled={disabled}
onClick={() => onRemove(pubkey)}
@@ -435,22 +436,22 @@ function AllowlistPicker({
</button>
))}
</div>
) : queryIsHexPubkey ? (
) : queryIsDirectPubkey ? (
<button
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left transition-colors hover:bg-accent hover:text-accent-foreground"
data-testid="agent-respond-to-add-raw-pubkey"
onClick={() => onAddRawPubkey(deferredQuery.toLowerCase())}
onClick={() => onAddRawPubkey(queryPubkey)}
type="button"
>
<div className="flex items-center gap-2 min-w-0">
<UserAvatar
avatarUrl={null}
displayName={truncatePubkey(deferredQuery)}
displayName={truncateNpub(queryPubkey)}
size="xs"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium leading-5">
{truncatePubkey(deferredQuery)}
{truncateNpub(queryPubkey)}
</p>
<p className="truncate text-xs text-muted-foreground">
Add pubkey directly
@@ -494,22 +495,22 @@ function AllowlistPicker({
id="agent-respond-to-direct-panel"
>
<p className="text-xs text-muted-foreground">
One per line, or comma/space-separated. 64-char lowercase hex
only — npub decoding is not yet supported here.
One per line, or comma/space-separated. Each entry is an npub
(npub1…) or a 64-char hex public key.
</p>
<Textarea
className="min-h-20 font-mono text-xs"
data-testid="agent-respond-to-paste"
disabled={disabled}
onChange={(event) => onPasteTextChange(event.target.value)}
placeholder="abcdef0123…"
placeholder="npub1… or abcdef0123…"
value={pasteText}
/>
{pasteInvalid.length > 0 ? (
<p className="text-xs text-destructive">
{pasteInvalid.length} entr
{pasteInvalid.length === 1 ? "y is" : "ies are"} not 64-char
hex and will be ignored.
{pasteInvalid.length === 1 ? "y is" : "ies are"} not a valid
npub or 64-char hex public key and will be ignored.
</p>
) : null}
<div className="flex items-center justify-between gap-2">

View File

@@ -0,0 +1,98 @@
/**
* Unit tests for the hosted-identity usable-bound-key predicate.
*
* Hosted surfaces gate connected/readiness/create/connect decisions on
* whether the identity payload carries an authoritative `pubkey_hex` that
* normalizes to a hex key — never on the mere presence of the identity
* object, and never on the display parser's npub-accepting domain. These
* tests pin the predicate's boundary semantics; the E2E specs assert the
* rendered fail-closed states through the real surfaces.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { npubEncode } from "nostr-tools/nip19";
import {
normalizedBoundKeyHex,
usableBoundIdentityNpub,
} from "./hostedCommunityApi.ts";
const BOUND_HEX = "deadbeef".repeat(8);
test("returns the canonical npub for a valid bound hex", () => {
assert.equal(
usableBoundIdentityNpub({ pubkey_hex: BOUND_HEX }),
npubEncode(BOUND_HEX),
);
});
test("normalizes mixed-case bound hex to the canonical npub", () => {
assert.equal(
usableBoundIdentityNpub({ pubkey_hex: BOUND_HEX.toUpperCase() }),
npubEncode(BOUND_HEX),
);
});
test("npub stored in pubkey_hex is never a usable hex binding", () => {
// A checksum-valid npub — even one spelling the very key this device
// signs with — is the display field's domain. Stored in the authoritative
// hex field it is not a hex key, so it must fail closed: never usable,
// never a comparison operand, never a pending-local bypass that reads the
// account as connected because the raw comparison was skipped.
assert.equal(normalizedBoundKeyHex(npubEncode(BOUND_HEX)), null);
assert.equal(
usableBoundIdentityNpub({ pubkey_hex: npubEncode(BOUND_HEX) }),
null,
);
// An all-uppercase NPUB spelling is equally rejected.
assert.equal(
normalizedBoundKeyHex(npubEncode(BOUND_HEX).toUpperCase()),
null,
);
});
test("same-key normalization: padded hex is the key it spells, on both sides", () => {
// Whitespace-padded, mixed-case hex normalizes to the identical value
// usability and comparisons use, so the same key never demands a
// delete/rebind of an identity the device already holds.
const padded = ` ${BOUND_HEX.toUpperCase()} `;
assert.equal(normalizedBoundKeyHex(padded), BOUND_HEX);
assert.equal(normalizedBoundKeyHex(BOUND_HEX), BOUND_HEX);
assert.equal(
usableBoundIdentityNpub({ pubkey_hex: padded }),
npubEncode(BOUND_HEX),
);
});
test("returns null when the identity object is absent", () => {
assert.equal(usableBoundIdentityNpub(null), null);
assert.equal(usableBoundIdentityNpub(undefined), null);
});
test("returns null when the authoritative key field is missing or empty", () => {
// npub-only payload: the server-sent `npub` is an independent, unverified
// spelling and must never substitute for the authoritative key.
assert.equal(usableBoundIdentityNpub({ npub: npubEncode(BOUND_HEX) }), null);
assert.equal(usableBoundIdentityNpub({}), null);
assert.equal(usableBoundIdentityNpub({ pubkey_hex: "" }), null);
});
test("returns null when the authoritative key cannot encode a key", () => {
// Non-hex alphabet.
assert.equal(usableBoundIdentityNpub({ pubkey_hex: "zz".repeat(32) }), null);
// Valid hex alphabet, wrong length for an identity key.
assert.equal(usableBoundIdentityNpub({ pubkey_hex: "f".repeat(63) }), null);
assert.equal(usableBoundIdentityNpub({ pubkey_hex: "f".repeat(65) }), null);
// Whitespace is not an identity key.
assert.equal(usableBoundIdentityNpub({ pubkey_hex: " " }), null);
});
test("the string normalizer rejects the same non-key values directly", () => {
assert.equal(normalizedBoundKeyHex(null), null);
assert.equal(normalizedBoundKeyHex(undefined), null);
assert.equal(normalizedBoundKeyHex("zz".repeat(32)), null);
assert.equal(normalizedBoundKeyHex("f".repeat(63)), null);
assert.equal(normalizedBoundKeyHex("f".repeat(65)), null);
assert.equal(normalizedBoundKeyHex(" "), null);
});

View File

@@ -1,5 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import { safeNpub } from "@/shared/lib/nostrUtils";
export const HOSTED_COMMUNITY_SUFFIX = "communities.buzz.xyz";
export const HOSTED_COMMUNITY_LIMIT = 5;
export const VALID_HOSTED_COMMUNITY_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -90,6 +92,55 @@ export function hostedCommunityRelayUrl(community: HostedCommunity) {
return host ? `wss://${host.replace(/^wss?:\/\//, "")}` : null;
}
const BOUND_KEY_HEX_REGEX = /^[0-9a-f]{64}$/;
/**
* The ONE operational form of a hosted identity's bound key: the trimmed,
* lowercased `pubkey_hex` when that field carries a 64-character hex key,
* null otherwise.
*
* `pubkey_hex` is the authoritative field the mismatch gate and every
* hosted-community operation act on, so its accepted shape is defined here
* once and is hex-only. An `npub` spelling belongs to the server's separate
* display field: one stored in the hex field is not a hex key and fails
* closed here instead of widening the authorization domain beyond what
* comparisons see. Whitespace-padded or mixed-case hex normalizes to the
* identical value on both sides of every comparison, so a key that is the
* same after normalization never reads as a mismatch demanding a
* delete/rebind of an identity the device already holds. Consumers
* normalize the local key through this same function before comparing.
*/
export function normalizedBoundKeyHex(
value: string | null | undefined,
): string | null {
if (!value) return null;
const normalized = value.trim().toLowerCase();
return BOUND_KEY_HEX_REGEX.test(normalized) ? normalized : null;
}
/**
* The canonical npub of the hosted identity's authoritative bound key, or
* null when the payload carries no key the app can act on.
*
* `pubkey_hex` is the field the mismatch gate and every hosted-community
* operation act on, and it is only usable when it normalizes to a hex key
* (`normalizedBoundKeyHex`) — the server-sent `npub` is an independent,
* unverified spelling and is never accepted in its place. The returned npub
* is derived from that same normalized key, so the displayed account
* identity can never disagree with the binding the decisions act on. Hosted
* surfaces derive their connected/readiness/create/connect gates from the
* normalized key instead of identity-object presence, so an identity payload
* without a usable authoritative key (missing, empty, invalid/non-hex,
* wrong-length, npub-only, or an npub stored in the hex field) fails closed
* into recovery rather than presenting the account as connected.
*/
export function usableBoundIdentityNpub(
identity: HostedNostrIdentity | null | undefined,
): string | null {
const hex = normalizedBoundKeyHex(identity?.pubkey_hex);
return hex === null ? null : safeNpub(hex);
}
export function getBuilderlabAuth() {
return invoke<BuilderlabAuth | null>("get_builderlab_auth");
}

View File

@@ -13,12 +13,14 @@ import {
HOSTED_COMMUNITY_SUFFIX,
hostedCommunityErrorMessage,
hostedCommunityRelayUrl,
loadHostedCommunityAccount,
normalizedBoundKeyHex,
startBuilderlabLogin,
usableBoundIdentityNpub,
VALID_HOSTED_COMMUNITY_NAME,
type BuilderlabAuth,
type HostedCommunity,
type HostedNostrIdentity,
loadHostedCommunityAccount,
startBuilderlabLogin,
VALID_HOSTED_COMMUNITY_NAME,
} from "@/features/communities/hostedCommunityApi";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import {
@@ -28,6 +30,7 @@ import {
import { useIdentityQuery } from "@/shared/api/hooks";
import { cn } from "@/shared/lib/cn";
import { safeNpub } from "@/shared/lib/nostrUtils";
import { UNAVAILABLE_KEY_LABEL } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
@@ -147,14 +150,31 @@ export function HostedCommunityCreateFlow({
setCommunities([]);
});
const boundPubkey = identity?.pubkey_hex ?? null;
// Identity rows display npubs derived from the same key the mismatch gate
// and recovery actions act on: the account row from the authoritative
// bound `pubkey_hex` — the server-sent `npub` is an independent field that
// nothing on this path proves encodes the same key — and the device row
// from the local key. An unencodable or non-identity-length key renders the
// neutral label instead of leaking raw hex or the unverified npub. Both
// keys are compared in the one normalized hex form, so a padded or
// mixed-case spelling of the same key never reads as a mismatch, and an
// npub stored in the hex field is not a key at all.
const boundHex = normalizedBoundKeyHex(identity?.pubkey_hex);
const localHex = normalizedBoundKeyHex(localPubkey);
const localNpub = localHex === null ? null : safeNpub(localHex);
const boundNpub = usableBoundIdentityNpub(identity);
// The account is only usable to this flow when its authoritative
// `pubkey_hex` normalizes to a hex key: without that key the flow cannot
// establish which identity create/connect actions would affect. An
// identity payload without a usable authoritative key therefore counts as
// a mismatch that requires recovery — never as a connected, ready
// account.
const usableBoundIdentity = boundHex !== null;
const identityMismatch = Boolean(
identity &&
boundPubkey &&
localPubkey &&
boundPubkey.toLowerCase() !== localPubkey.toLowerCase(),
(!usableBoundIdentity ||
(boundHex !== null && localHex !== null && boundHex !== localHex)),
);
const localNpub = localPubkey ? safeNpub(localPubkey) : null;
const switchToDeviceIdentity = () =>
run("Switching identity…", async () => {
@@ -190,7 +210,7 @@ export function HostedCommunityCreateFlow({
normalizedName.length <= 63 &&
VALID_HOSTED_COMMUNITY_NAME.test(normalizedName);
const atCommunityLimit = communities.length >= HOSTED_COMMUNITY_LIMIT;
const ready = Boolean(auth && identity && !identityMismatch);
const ready = Boolean(auth && usableBoundIdentity && !identityMismatch);
React.useEffect(() => {
if (!ready || !normalizedName || !validName) {
@@ -222,7 +242,13 @@ export function HostedCommunityCreateFlow({
const create = (event: React.FormEvent) => {
event.preventDefault();
if (!validName || !identity || identityMismatch || atCommunityLimit) return;
if (
!validName ||
!usableBoundIdentity ||
identityMismatch ||
atCommunityLimit
)
return;
void run("Creating community…", async () => {
const available = await checkHostedCommunityName(normalizedName);
if (available.error || !available.available) {
@@ -343,9 +369,11 @@ export function HostedCommunityCreateFlow({
this device, or sign in with another account.
</p>
<div className="rounded-xl bg-muted/40 px-4 py-3 font-mono text-xs text-muted-foreground">
<p className="break-all">Account: {identity.npub ?? boundPubkey}</p>
<p className="break-all">
Account: {boundNpub ?? UNAVAILABLE_KEY_LABEL}
</p>
<p className="mt-1 break-all">
This device: {localNpub ?? localPubkey}
This device: {localNpub ?? UNAVAILABLE_KEY_LABEL}
</p>
</div>
{errorBox}

View File

@@ -14,16 +14,19 @@ import {
HOSTED_COMMUNITY_SUFFIX,
hostedCommunityErrorMessage,
hostedCommunityRelayUrl,
loadHostedCommunityAccount,
normalizedBoundKeyHex,
startBuilderlabLogin,
usableBoundIdentityNpub,
VALID_HOSTED_COMMUNITY_NAME,
type BuilderlabAuth,
type HostedCommunity,
type HostedNostrIdentity,
loadHostedCommunityAccount,
startBuilderlabLogin,
VALID_HOSTED_COMMUNITY_NAME,
} from "@/features/communities/hostedCommunityApi";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import { useIdentityQuery } from "@/shared/api/hooks";
import { safeNpub } from "@/shared/lib/nostrUtils";
import { UNAVAILABLE_KEY_LABEL } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { Input } from "@/shared/ui/input";
@@ -195,14 +198,30 @@ export function HostedCommunityOnboarding({
await loadAccount();
});
const boundPubkey = identity?.pubkey_hex ?? null;
// Identity rows display npubs derived from the same key the mismatch gate
// and recovery actions act on: the account row from the authoritative
// bound `pubkey_hex` — the server-sent `npub` is an independent field that
// nothing on this path proves encodes the same key — and the device row
// from the local key. An unencodable or non-identity-length key renders the
// neutral label instead of leaking raw hex or the unverified npub. Both
// keys are compared in the one normalized hex form, so a padded or
// mixed-case spelling of the same key never reads as a mismatch, and an
// npub stored in the hex field is not a key at all.
const boundHex = normalizedBoundKeyHex(identity?.pubkey_hex);
const localHex = normalizedBoundKeyHex(localPubkey);
const localNpub = localHex === null ? null : safeNpub(localHex);
const boundNpub = usableBoundIdentityNpub(identity);
// The account only drives this flow when its authoritative `pubkey_hex`
// normalizes to a hex key: without that key the flow cannot establish
// which identity create/connect actions would affect. An identity payload
// without a usable authoritative key therefore counts as a mismatch that
// requires recovery — never as a linked, ready account.
const usableBoundIdentity = boundHex !== null;
const identityMismatch = Boolean(
identity &&
boundPubkey &&
localPubkey &&
boundPubkey.toLowerCase() !== localPubkey.toLowerCase(),
(!usableBoundIdentity ||
(boundHex !== null && localHex !== null && boundHex !== localHex)),
);
const localNpub = localPubkey ? safeNpub(localPubkey) : null;
const switchToDeviceIdentity = () =>
run("Switching identity…", async () => {
@@ -244,7 +263,12 @@ export function HostedCommunityOnboarding({
const hasCommunities = activeCommunities.length > 0;
React.useEffect(() => {
if (!identity || identityMismatch || !normalizedName || !validName) {
if (
!usableBoundIdentity ||
identityMismatch ||
!normalizedName ||
!validName
) {
setCheckingName(false);
return;
}
@@ -269,7 +293,7 @@ export function HostedCommunityOnboarding({
cancelled = true;
window.clearTimeout(handle);
};
}, [identity, identityMismatch, normalizedName, validName]);
}, [normalizedName, validName, usableBoundIdentity, identityMismatch]);
const connect = (community: HostedCommunity, created = false) => {
const relayUrl = hostedCommunityRelayUrl(community);
@@ -295,7 +319,13 @@ export function HostedCommunityOnboarding({
const create = (event: React.FormEvent) => {
event.preventDefault();
if (!validName || !identity || identityMismatch || atCommunityLimit) return;
if (
!validName ||
!usableBoundIdentity ||
identityMismatch ||
atCommunityLimit
)
return;
void run("Creating community…", async () => {
const available = await checkHostedCommunityName(normalizedName);
if (available.error || !available.available) {
@@ -324,9 +354,11 @@ export function HostedCommunityOnboarding({
const busy = action !== null;
// The account is set up once we're signed in with a linked, matching
// identity. Until then the sign-in / link-identity modal drives the flow and
// the page behind it shows a blurred preview of where communities will land.
const ready = Boolean(auth && identity && !identityMismatch);
// identity — and a link is only usable when its authoritative `pubkey_hex`
// canonicalizes. Until then the sign-in / link-identity modal drives the
// flow and the page behind it shows a blurred preview of where
// communities will land.
const ready = Boolean(auth && usableBoundIdentity && !identityMismatch);
const modalOpen = !loading && !ready;
// Tell the parent once sign-in completes so it can reveal the stage page.
@@ -543,9 +575,9 @@ export function HostedCommunityOnboarding({
this device, or sign out to use a different email.
</DialogDescription>
<p className="mt-4 w-full break-all rounded-xl bg-[rgb(var(--buzz-hosted-community-identity-bg)/0.5)] px-4 py-3 text-left font-mono text-xs text-foreground">
Account: {identity.npub ?? boundPubkey}
Account: {boundNpub ?? UNAVAILABLE_KEY_LABEL}
<br />
This device: {localNpub ?? localPubkey}
This device: {localNpub ?? UNAVAILABLE_KEY_LABEL}
</p>
{errorBox ? <div className="mt-5 w-full">{errorBox}</div> : null}
<div className="mt-6 flex flex-col items-stretch gap-2">

View File

@@ -2,7 +2,8 @@ import * as React from "react";
import { Check, Copy, KeyRound, ShieldX, Ticket } from "lucide-react";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import { nsecToNpub, pubkeyToNpub } from "@/shared/lib/nostrUtils";
import { nsecToNpub } from "@/shared/lib/nostrUtils";
import { canonicalNpub, UNAVAILABLE_KEY_LABEL } from "@/shared/lib/pubkey";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
@@ -29,17 +30,14 @@ export function MembershipDenied({
onRetry,
pubkey,
}: MembershipDeniedProps) {
const npub = React.useMemo(() => {
if (!pubkey) {
return "Unknown public key";
}
try {
return pubkeyToNpub(pubkey);
} catch {
return pubkey;
}
}, [pubkey]);
// Full canonical npub; an unencodable identity renders the neutral label
// (never raw hex) and is never copyable.
const identityNpub = React.useMemo(
() => (pubkey ? canonicalNpub(pubkey) : null),
[pubkey],
);
const npub =
identityNpub ?? (pubkey ? UNAVAILABLE_KEY_LABEL : "Unknown public key");
const [copied, setCopied] = React.useState(false);
const [importError, setImportError] = React.useState<string | null>(null);
const [isImportFormOpen, setIsImportFormOpen] = React.useState(false);
@@ -53,6 +51,7 @@ export function MembershipDenied({
const communityOnboarding = useCommunityOnboarding();
const handleCopy = React.useCallback(async () => {
if (!identityNpub) return;
try {
await writeTextToClipboard(npub);
setCopied(true);
@@ -60,7 +59,7 @@ export function MembershipDenied({
} catch {
// Fallback: select the text so the user can copy manually
}
}, [npub]);
}, [identityNpub, npub]);
const handleImportKey = React.useCallback(async () => {
if (!previewNpub) {
@@ -130,6 +129,7 @@ export function MembershipDenied({
</code>
<Button
className="shrink-0"
disabled={!identityNpub}
onClick={() => {
void handleCopy();
}}

View File

@@ -118,9 +118,6 @@ export function SelectedRecipientChip({
testId={testIds?.pubkey}
variant="full"
/>
<p className="break-all font-mono text-xs text-muted-foreground">
{user.pubkey}
</p>
</PopoverContent>
</Popover>
) : (

View File

@@ -11,7 +11,7 @@ import {
} from "lucide-react";
import * as React from "react";
import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { canonicalNpub, truncateNpub } from "@/shared/lib/pubkey";
import {
HoverCopyIndicator,
useCopyFeedback,
@@ -164,9 +164,12 @@ export function buildPublicFields({
const fields: ProfileField[] = [];
if (pubkey) {
const npub = canonicalNpub(pubkey);
fields.push({
copyValue: pubkey,
displayValue: truncatePubkey(pubkey),
// Copy the full canonical npub; an identity that cannot be encoded is
// never copyable.
copyValue: npub ?? undefined,
displayValue: truncateNpub(pubkey),
displayNode: (
<PubKey
interactive={false}
@@ -260,12 +263,18 @@ export function buildOwnerFields({
: null;
const ownerClickable = Boolean(onOpenProfile && ownerProfilePubkey);
// Non-clickable owner rows copy the owner's full npub (handle only when no
// key is known); an unencodable owner key copies nothing.
const ownerCopyKey = ownerProfilePubkey ?? ownerPubkey;
const ownerCopyValue = ownerClickable
? undefined
: ownerCopyKey
? (canonicalNpub(ownerCopyKey) ?? undefined)
: (ownerHandle ?? undefined);
if (ownerDisplayName) {
fields.push({
copyValue: ownerClickable
? undefined
: (ownerProfilePubkey ?? ownerPubkey ?? ownerHandle ?? undefined),
copyValue: ownerCopyValue,
displayValue: ownerDisplayName,
displayNode: <span className="truncate">{ownerDisplayName}</span>,
icon: UserRound,
@@ -308,7 +317,7 @@ export function buildOwnerFields({
});
} else if (ownerPubkey) {
fields.push({
copyValue: ownerPubkey,
copyValue: canonicalNpub(ownerPubkey) ?? undefined,
displayValue: "Declared owner verified",
icon: UserRound,
label: "Agent profile",

View File

@@ -19,6 +19,8 @@ import {
HOSTED_COMMUNITY_SUFFIX as HOST_SUFFIX,
hostedCommunityErrorMessage as errorMessage,
hostedCommunityRelayUrl as relayUrl,
normalizedBoundKeyHex,
usableBoundIdentityNpub,
type BuilderlabAuth,
type HostedCommunityAvailabilityResponse as AvailabilityResponse,
type HostedCommunitiesResponse as CommunitiesResponse,
@@ -30,8 +32,9 @@ import {
} from "@/features/communities/hostedCommunityApi";
import { CommunityIconSettingsCard } from "@/features/communities/ui/CommunityIconSettingsCard";
import { useCommunities } from "@/features/communities/useCommunities";
import { safeNpub } from "@/shared/lib/nostrUtils";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import { safeNpub } from "@/shared/lib/nostrUtils";
import { UNAVAILABLE_KEY_LABEL } from "@/shared/lib/pubkey";
import {
AlertDialog,
AlertDialogAction,
@@ -205,15 +208,33 @@ export function HostedCommunitiesSettingsCard() {
// to a different test identity). When that happens the community list and
// Connect buttons operate on the *bound* npub's communities, so "Connect"
// would drop you into a relay your local key isn't a member of. Detect it and
// block Connect + Create until the identities match.
const boundPubkey = identity?.pubkey_hex ?? null;
// block Connect + Create until the identities match. Both keys are compared
// in the one normalized hex form, so a padded or mixed-case spelling of
// the same key never reads as a mismatch, and an npub stored in the hex
// field is not a key at all.
// Identity rows display npubs; an unencodable or non-identity-length key
// renders the neutral label instead of leaking raw hex. The account's
// `pubkey_hex` is the authoritative binding key — the mismatch gate and
// every hosted-community operation act on it — so the displayed account
// npub is derived from it, not from the server-provided `npub` spelling.
// Nothing on this path proves the two fields encode the same key, and two
// individually valid but contradictory values must never make the screen
// show one identity while binding decisions act on another.
const boundHex = normalizedBoundKeyHex(identity?.pubkey_hex);
const localHex = normalizedBoundKeyHex(localPubkey);
const localNpub = localHex === null ? null : safeNpub(localHex);
const boundNpub = usableBoundIdentityNpub(identity);
// The account is only usable here when its authoritative `pubkey_hex`
// normalizes to a hex key: without that key this card cannot establish
// which identity the connected claim, Connect, and Create actions affect.
// An identity payload without a usable authoritative key therefore counts
// as a mismatch that requires recovery — never as a connected account.
const usableBoundIdentity = boundHex !== null;
const identityMismatch = Boolean(
identity &&
boundPubkey &&
localPubkey &&
boundPubkey.toLowerCase() !== localPubkey.toLowerCase(),
(!usableBoundIdentity ||
(boundHex !== null && localHex !== null && boundHex !== localHex)),
);
const localNpub = localPubkey ? safeNpub(localPubkey) : null;
const switchToDeviceIdentity = () =>
run("Switching identity…", async () => {
@@ -324,7 +345,12 @@ export function HostedCommunitiesSettingsCard() {
// Create (no separate "check" click). onChange clears the previous result, so
// the indicator reflects the current input while typing.
React.useEffect(() => {
if (!identity || identityMismatch || !normalizedName || !validName) {
if (
!usableBoundIdentity ||
identityMismatch ||
!normalizedName ||
!validName
) {
setCheckingName(false);
return;
}
@@ -352,13 +378,13 @@ export function HostedCommunitiesSettingsCard() {
cancelled = true;
window.clearTimeout(handle);
};
}, [normalizedName, validName, identity, identityMismatch]);
}, [normalizedName, validName, usableBoundIdentity, identityMismatch]);
const createCommunity = (event: React.FormEvent) => {
event.preventDefault();
if (
!validName ||
!identity ||
!usableBoundIdentity ||
identityMismatch ||
communities.length >= MAX_COMMUNITIES
)
@@ -522,12 +548,14 @@ export function HostedCommunitiesSettingsCard() {
<div className="flex flex-wrap gap-x-2">
<dt className="text-muted-foreground">Account uses</dt>
<dd className="font-mono">
{identity.npub ?? boundPubkey}
{boundNpub ?? UNAVAILABLE_KEY_LABEL}
</dd>
</div>
<div className="flex flex-wrap gap-x-2">
<dt className="text-muted-foreground">This device</dt>
<dd className="font-mono">{localNpub ?? localPubkey}</dd>
<dd className="font-mono">
{localNpub ?? UNAVAILABLE_KEY_LABEL}
</dd>
</div>
</dl>
</div>
@@ -548,8 +576,8 @@ export function HostedCommunitiesSettingsCard() {
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-emerald-500" /> Buzz
identity connected
{identity.npub ? (
<span className="font-mono text-xs">{identity.npub}</span>
{boundNpub ? (
<span className="font-mono text-xs">{boundNpub}</span>
) : null}
</div>
<UnpairIdentityButton
@@ -593,12 +621,20 @@ export function HostedCommunitiesSettingsCard() {
key={community.id ?? community.normalized_host ?? index}
community={community}
busy={busy}
canConnect={!identityMismatch}
canConnect={usableBoundIdentity && !identityMismatch}
showIconPicker={
relayHost(relayUrl(community)) ===
relayHost(activeCommunity?.relayUrl)
}
onConnect={() => {
// Invocation guard: the Connect affordance only
// renders for a usable, matching binding, but this
// callback is the last gate — a click that lands after
// a refresh returned an absent or unusable identity
// must not start onboarding either.
if (!usableBoundIdentity || identityMismatch) {
return;
}
const url = relayUrl(community);
if (url)
onboarding.start({
@@ -641,7 +677,10 @@ export function HostedCommunitiesSettingsCard() {
aria-label="Community address"
autoComplete="off"
disabled={
!identity || identityMismatch || busy || atCommunityLimit
!usableBoundIdentity ||
identityMismatch ||
busy ||
atCommunityLimit
}
maxLength={63}
onChange={(event) => {
@@ -675,7 +714,7 @@ export function HostedCommunitiesSettingsCard() {
) : null}
<Button
disabled={
!identity ||
!usableBoundIdentity ||
identityMismatch ||
!validName ||
availability === false ||

View File

@@ -30,6 +30,7 @@ import {
import { SettingsSectionHeader } from "./SettingsSectionHeader";
import { SignOutSection } from "./SignOutSection";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
import { canonicalNpub, UNAVAILABLE_KEY_LABEL } from "@/shared/lib/pubkey";
type ProfileSettingsCardProps = {
currentPubkey?: string;
@@ -302,7 +303,10 @@ export function ProfileSettingsCard({
profile?.displayName ||
fallbackDisplayName ||
"Your profile";
const resolvedPubkey = profile?.pubkey ?? currentPubkey ?? "Unavailable";
// Identity details show and copy the full canonical npub; a key that
// cannot be encoded renders the neutral label and is never copyable.
const identityNpub = canonicalNpub(profile?.pubkey ?? currentPubkey ?? "");
const resolvedPubkey = identityNpub ?? UNAVAILABLE_KEY_LABEL;
const nip05Handle = profile?.nip05Handle ?? "Not set";
const emojiAvatarPreview = React.useMemo(
() => parseEmojiAvatarDataUrl(avatarUrlDraft),
@@ -786,9 +790,7 @@ export function ProfileSettingsCard({
data-testid="profile-identity-details"
>
<IdentityRow
copyValue={
profile?.pubkey ?? currentPubkey ?? undefined
}
copyValue={identityNpub ?? undefined}
label="Public key"
testId="profile-pubkey"
value={resolvedPubkey}

View File

@@ -218,7 +218,7 @@ function StepConfigFields({
onChange={(event) =>
onUpdate({ ...step, to: event.target.value })
}
placeholder="e.g. {{trigger.author}} or hex pubkey"
placeholder="e.g. {{trigger.author}}, npub1…, or hex pubkey"
value={step.to ?? ""}
/>
</div>
@@ -311,7 +311,7 @@ function StepConfigFields({
onChange={(event) =>
onUpdate({ ...step, from: event.target.value })
}
placeholder="Pubkey or role"
placeholder="npub1…, hex pubkey, or role"
value={step.from ?? ""}
/>
</div>

View File

@@ -315,3 +315,74 @@ test("absent reply_in_thread parses as false", () => {
assert.equal(parsed.ok, true);
assert.equal(parsed.state.steps[0].replyInThread, false);
});
// Handoff vectors, round-trip verified with nostr-tools nip19.
const KEY_HEX =
"ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328";
const KEY_NPUB =
"npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60";
test("stored hex to/from keys display as npubs in the form", () => {
const parsed = yamlToFormState(
`name: Keys
trigger: { on: message_posted }
steps: [{ id: s1, action: send_dm, to: ${KEY_HEX}, text: hi }, { id: s2, action: request_approval, from: ${KEY_HEX}, message: ok }]
`,
);
assert.equal(parsed.ok, true);
assert.equal(parsed.state.steps[0].to, KEY_NPUB);
assert.equal(parsed.state.steps[1].from, KEY_NPUB);
});
test("npub to/from keys serialize to canonical hex; other spellings pass through", () => {
const dm = formStateToYaml({
...DEFAULT_FORM_STATE,
name: "Keys",
steps: [{ id: "s1", action: "send_dm", to: KEY_NPUB, text: "hi" }],
});
assert.match(dm, new RegExp(`to: ${KEY_HEX}`));
assert.doesNotMatch(dm, /npub1/);
const approval = formStateToYaml({
...DEFAULT_FORM_STATE,
name: "Keys",
steps: [
{ id: "s1", action: "request_approval", from: KEY_NPUB, message: "ok" },
],
});
assert.match(approval, new RegExp(`from: ${KEY_HEX}`));
// A hex spelling serializes unchanged, and a corrupted-checksum npub is
// never bound as an identity — it stays exactly as the author wrote it
// for the YAML editor to surface.
const hexDm = formStateToYaml({
...DEFAULT_FORM_STATE,
name: "Keys",
steps: [{ id: "s1", action: "send_dm", to: KEY_HEX, text: "hi" }],
});
assert.match(hexDm, new RegExp(`to: ${KEY_HEX}`));
const corrupt = `${KEY_NPUB.slice(0, -2)}qq`;
const corruptDm = formStateToYaml({
...DEFAULT_FORM_STATE,
name: "Keys",
steps: [{ id: "s1", action: "send_dm", to: corrupt, text: "hi" }],
});
assert.match(corruptDm, new RegExp(`to: ${corrupt.replace(/\./g, "\\.")}`));
});
test("templates and roles pass through both directions untouched", () => {
const parsed = yamlToFormState(
`name: Keys
trigger: { on: message_posted }
steps: [{ id: s1, action: send_dm, to: "{{trigger.author}}", text: hi }, { id: s2, action: request_approval, from: manager, message: ok }]
`,
);
assert.equal(parsed.ok, true);
assert.equal(parsed.state.steps[0].to, "{{trigger.author}}");
assert.equal(parsed.state.steps[1].from, "manager");
const reserialized = formStateToYaml(parsed.state);
assert.match(reserialized, /to: "\{\{trigger\.author\}\}"/);
assert.match(reserialized, /from: manager/);
});

View File

@@ -1,5 +1,10 @@
import { stringify as yamlStringify, parse as yamlParse } from "yaml";
import {
parsePubkeyInput as parseCanonicalPubkey,
safeNpub,
} from "@/shared/lib/nostrUtils";
import { cronExpressionError } from "./cronExpression";
import {
formatDurationSeconds,
@@ -116,6 +121,29 @@ export const ACTION_LABELS: Record<ActionType, string> = {
set_channel_topic: "Set Channel Topic",
};
const EXACT_HEX_64 = /^[0-9a-fA-F]{64}$/;
/**
* Display form of a DM recipient (`to`) or approver (`from`): an exact 64-char
* hex pubkey from stored YAML is shown as its npub; npubs, templates
* (`{{trigger.author}}`), roles, and any other free text pass through
* unchanged. Encoding failures (non-canonical hex) keep the stored value.
*/
function keyDisplayValue(value: string | undefined): string | undefined {
if (value === undefined || !EXACT_HEX_64.test(value)) return value;
return safeNpub(value.toLowerCase()) ?? value;
}
/**
* Storage form: an exact npub decodes back to the canonical hex pubkey so the
* wire YAML keeps its machine-readable hex contract. Hex, templates, roles,
* and any other free text pass through unchanged.
*/
function keyStorageValue(value: string): string {
if (!value.trim().toLowerCase().startsWith("npub1")) return value;
return parseCanonicalPubkey(value) ?? value;
}
function toHeaderRows(
headers: unknown,
stepId: string,
@@ -169,7 +197,7 @@ function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
if (step.replyInThread) fields.reply_in_thread = true;
break;
case "send_dm":
if (step.to) fields.to = step.to;
if (step.to) fields.to = keyStorageValue(step.to);
if (step.text) fields.text = step.text;
break;
case "call_webhook":
@@ -182,7 +210,7 @@ function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
if (step.body) fields.body = step.body;
break;
case "request_approval":
if (step.from) fields.from = step.from;
if (step.from) fields.from = keyStorageValue(step.from);
if (step.message) fields.message = step.message;
if (step.timeout) fields.timeout = step.timeout;
break;
@@ -624,14 +652,14 @@ export function yamlToFormState(
text: step.text as string | undefined,
channel: step.channel as string | undefined,
replyInThread: step.reply_in_thread === true,
to: step.to as string | undefined,
to: keyDisplayValue(step.to as string | undefined),
url: step.url as string | undefined,
method: step.method as string | undefined,
headers: toHeaderRows(step.headers, step.id),
body: step.body as string | undefined,
emoji: step.emoji as string | undefined,
topic: step.topic as string | undefined,
from: step.from as string | undefined,
from: keyDisplayValue(step.from as string | undefined),
message: step.message as string | undefined,
timeout: step.timeout as string | undefined,
});

View File

@@ -1,4 +1,5 @@
import { test } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { npubEncode } from "nostr-tools/nip19";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
@@ -45,6 +46,78 @@ test.beforeEach(async ({ page }) => {
await page.getByTestId("community-rail-add").click();
});
test("identity: npub-only account key is recovery, never a ready create flow", async ({
page,
}) => {
await installMockBridge(
page,
{
builderlabAuth: {
email: "old-owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: {
// The identity object is present but carries no authoritative
// pubkey_hex — only the independent, unverified server npub.
npub: npubEncode("b".repeat(64)),
},
},
{ skipCommunitySeed: true },
);
await page.reload();
await page.getByTestId("community-rail-add").click();
await page.getByTestId("add-community-create").click();
// Presence of the identity object must not read as a ready account: the
// mismatch recovery branch owns the create tab instead.
await expect(
page.getByText("This Builderlab account uses a different Buzz identity."),
).toBeVisible();
await expect(page.getByText("Account: Unavailable")).toBeVisible();
await expect(
page.getByText(`This device: ${npubEncode(DEFAULT_MOCK_PUBKEY)}`),
).toBeVisible();
// The create form — and with it the create action — stays unrendered.
await expect(page.getByTestId("hosted-community-create-name")).toHaveCount(0);
await expect(page.getByTestId("hosted-community-create-submit")).toHaveCount(
0,
);
// The unverified hosted npub spelling never renders.
await expect(page.getByText(npubEncode("b".repeat(64)))).toHaveCount(0);
// Recovery stays explicit.
await expect(
page.getByRole("button", { name: "Use this device" }),
).toBeVisible();
});
test("identity: padded same-key hex is a ready create flow, not a mismatch", async ({
page,
}) => {
await installMockBridge(
page,
{
builderlabAuth: {
email: "owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: {
// This device's own key, padded and uppercased: the same key
// after normalization, so the account is ready — never a mismatch
// demanding a delete/rebind of the identity it already holds.
pubkey_hex: ` ${DEFAULT_MOCK_PUBKEY.toUpperCase()} `,
},
},
{ skipCommunitySeed: true },
);
await page.reload();
await page.getByTestId("community-rail-add").click();
await page.getByTestId("add-community-create").click();
// The create form — and with it the create action — is available.
await expect(page.getByTestId("hosted-community-create-name")).toBeVisible();
await expect(
page.getByText("This Builderlab account uses a different Buzz identity."),
).toHaveCount(0);
});
test("capture: add-community choices", async ({ page }) => {
const dialog = page.getByTestId("add-community-dialog");
await dialog.waitFor();
@@ -71,3 +144,65 @@ test("capture: create a new community", async ({ page }) => {
await waitForAnimations(page);
await dialog.screenshot({ path: `${OUTDIR}/03-create.png` });
});
test("identity: create owner shows the bound key's npub, never the hosted npub or raw hex", async ({
page,
}) => {
await installMockBridge(
page,
{
builderlabAuth: {
email: "old-owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: {
pubkey_hex: "f".repeat(64),
npub: npubEncode("b".repeat(64)),
},
},
{ skipCommunitySeed: true },
);
await page.reload();
await page.getByTestId("community-rail-add").click();
await page.getByTestId("add-community-create").click();
await expect(
page.getByText("This Builderlab account uses a different Buzz identity."),
).toBeVisible();
await expect(
page.getByText(`Account: ${npubEncode("f".repeat(64))}`),
).toBeVisible();
await expect(
page.getByText(`This device: ${npubEncode(DEFAULT_MOCK_PUBKEY)}`),
).toBeVisible();
await expect(page.getByText(npubEncode("b".repeat(64)))).toHaveCount(0);
await expect(page.getByText("f".repeat(64))).toHaveCount(0);
});
test("identity: create owner renders the neutral label when the bound hex is unusable", async ({
page,
}) => {
await installMockBridge(
page,
{
builderlabAuth: {
email: "old-owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: {
// Valid hex alphabet, wrong length — unusable as an identity key.
pubkey_hex: "f".repeat(63),
npub: npubEncode("b".repeat(64)),
},
},
{ skipCommunitySeed: true },
);
await page.reload();
await page.getByTestId("community-rail-add").click();
await page.getByTestId("add-community-create").click();
await expect(
page.getByText("This Builderlab account uses a different Buzz identity."),
).toBeVisible();
await expect(page.getByText("Account: Unavailable")).toBeVisible();
await expect(page.getByText(npubEncode("b".repeat(64)))).toHaveCount(0);
await expect(page.getByText("f".repeat(63))).toHaveCount(0);
});

View File

@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
import { npubEncode } from "nostr-tools/nip19";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
@@ -6,14 +7,28 @@ import { openSettings } from "../helpers/settings";
const OUTDIR = "test-results/hosted-communities";
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
/** A second valid identity key, used only as a contradictory hosted npub. */
const OTHER_HEX = "b".repeat(64);
test.beforeEach(async ({ page }) => {
/**
* Install the default hosted-communities fixture and open its settings
* section. `builderlabIdentity` overrides the bound account identity so a
* spec can drive independent — even contradictory — `pubkey_hex`/`npub`
* fields; the mock bridge passes both through verbatim, like the native
* command.
*/
async function openHostedCommunitiesSettings(
page: Page,
builderlabIdentity?: { npub?: string; pubkey_hex?: string } | null,
) {
await installMockBridge(page, {
builderlabAuth: {
email: "owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: { pubkey_hex: DEFAULT_MOCK_PUBKEY },
builderlabIdentity: builderlabIdentity ?? {
pubkey_hex: DEFAULT_MOCK_PUBKEY,
},
builderlabCommunities: [
{
id: "active-community",
@@ -29,8 +44,210 @@ test.beforeEach(async ({ page }) => {
});
await page.goto("/");
await openSettings(page, "hosted-communities");
}
test.beforeEach(async ({ page }) => {
await openHostedCommunitiesSettings(page);
});
test("identity: mismatch rows follow pubkey_hex, never the hosted npub or raw hex", async ({
page,
}) => {
await openHostedCommunitiesSettings(page, {
pubkey_hex: "f".repeat(64),
npub: npubEncode(OTHER_HEX),
});
await expect(
page.getByText("This account is connected to a different Buzz identity"),
).toBeVisible();
const settingsView = page.getByTestId("settings-view");
await expect(
page
.getByText("Account uses", { exact: true })
.locator("xpath=following-sibling::dd[1]"),
).toHaveText(npubEncode("f".repeat(64)));
await expect(
page
.getByText("This device", { exact: true })
.locator("xpath=following-sibling::dd[1]"),
).toHaveText(npubEncode(DEFAULT_MOCK_PUBKEY));
// The independently valid but contradictory hosted npub, and the raw hex
// it would stand in for, must never render.
await expect(settingsView.getByText(npubEncode(OTHER_HEX))).toHaveCount(0);
await expect(settingsView.getByText("f".repeat(64))).toHaveCount(0);
});
test("identity: connected row follows pubkey_hex when the hosted npub encodes another key", async ({
page,
}) => {
await openHostedCommunitiesSettings(page, {
pubkey_hex: DEFAULT_MOCK_PUBKEY,
npub: npubEncode(OTHER_HEX),
});
const connectedNpub = page
.getByText("Buzz identity connected")
.locator("span.font-mono");
await expect(connectedNpub).toHaveText(npubEncode(DEFAULT_MOCK_PUBKEY));
await expect(
page.getByTestId("settings-view").getByText(npubEncode(OTHER_HEX)),
).toHaveCount(0);
});
test("identity: unusable bound hex renders the neutral label, not the hosted npub or raw hex", async ({
page,
}) => {
await openHostedCommunitiesSettings(page, {
// Valid hex alphabet, wrong length — unusable as an identity key.
pubkey_hex: "f".repeat(63),
npub: npubEncode(OTHER_HEX),
});
await expect(
page.getByText("This account is connected to a different Buzz identity"),
).toBeVisible();
const settingsView = page.getByTestId("settings-view");
await expect(
page
.getByText("Account uses", { exact: true })
.locator("xpath=following-sibling::dd[1]"),
).toHaveText("Unavailable");
await expect(settingsView.getByText(npubEncode(OTHER_HEX))).toHaveCount(0);
await expect(settingsView.getByText("f".repeat(63))).toHaveCount(0);
});
test("identity: consistent hosted identity renders its canonical npub", async ({
page,
}) => {
await openHostedCommunitiesSettings(page, {
pubkey_hex: DEFAULT_MOCK_PUBKEY,
npub: npubEncode(DEFAULT_MOCK_PUBKEY),
});
const connectedNpub = page
.getByText("Buzz identity connected")
.locator("span.font-mono");
await expect(connectedNpub).toHaveText(npubEncode(DEFAULT_MOCK_PUBKEY));
});
test("identity: unlinked account offers linking, never a connected claim", async ({
page,
}) => {
await installMockBridge(page, {
builderlabAuth: {
email: "owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
// No identity object at all: the account has not linked a Buzz key.
builderlabIdentity: null,
builderlabCommunities: [
{
id: "active-community",
name: "E2E Test",
normalized_host: "localhost:3000",
},
],
});
await page.goto("/");
await openSettings(page, "hosted-communities");
await expect(
page.getByText("Link this account to your Buzz identity"),
).toBeVisible();
await expect(page.getByText("Buzz identity connected")).toHaveCount(0);
// The seeded owned community still lists — every affordance that does
// not act on the binding stays available — but Connect is an action on
// the binding and cannot occur without a usable bound key: no row
// affordance, and no onboarding it could start.
await expect(page.getByTestId("hosted-community-row")).toHaveCount(1);
await expect(
page.getByRole("button", { name: "Connect", exact: true }),
).toHaveCount(0);
// Creation stays unavailable: there is no authoritative key to bind the
// new community to.
await expect(page.getByLabel("Community address")).toBeDisabled();
await expect(
page.getByRole("button", { name: "Create and connect", exact: true }),
).toBeDisabled();
});
test("identity: padded or mixed-case bound hex is the same key, not a mismatch", async ({
page,
}) => {
await openHostedCommunitiesSettings(page, {
// The same key this device signs with, padded and uppercased. It
// normalizes to the local key on both sides of the comparison — never
// a mismatch demanding delete/rebind of an identity the device already
// holds.
pubkey_hex: ` ${DEFAULT_MOCK_PUBKEY.toUpperCase()} `,
});
await expect(
page.getByText("This account is connected to a different Buzz identity"),
).toHaveCount(0);
const connectedNpub = page
.getByText("Buzz identity connected")
.locator("span.font-mono");
await expect(connectedNpub).toHaveText(npubEncode(DEFAULT_MOCK_PUBKEY));
// A binding that is the same key after normalization keeps its Connect
// affordance — recovery is reserved for a binding that actually differs.
await expect(
page.getByRole("button", { name: "Connect", exact: true }).first(),
).toBeVisible();
});
/**
* Identity payloads whose authoritative `pubkey_hex` cannot act as a key.
* Each is an identity *object* — so presence alone must never read as a
* connected, ready account — yet none carries a key the app can use.
*/
const UNUSABLE_BOUND_KEY_PAYLOADS: Array<
[label: string, payload: { npub?: string; pubkey_hex?: string }]
> = [
["missing key", {}],
["non-hex key", { pubkey_hex: "zz".repeat(32) }],
["npub-only key", { npub: npubEncode(OTHER_HEX) }],
// A checksum-valid npub stored in the hex field itself is not a hex
// key: it must fail closed like any other unusable spelling, never
// display as the account's authoritative key, and never read as a
// usable binding when the local comparison is skipped.
["npub stored in the hex field", { pubkey_hex: npubEncode(OTHER_HEX) }],
];
for (const [label, payload] of UNUSABLE_BOUND_KEY_PAYLOADS) {
test(`identity: ${label} is recovery, never a connected account or actions`, async ({
page,
}) => {
await openHostedCommunitiesSettings(page, payload);
const settingsView = page.getByTestId("settings-view");
// No connected claim anywhere on the surface, despite the identity
// object being present.
await expect(page.getByText("Buzz identity connected")).toHaveCount(0);
// The mismatch recovery block owns the identity panel instead.
await expect(
page.getByText("This account is connected to a different Buzz identity"),
).toBeVisible();
await expect(
page
.getByText("Account uses", { exact: true })
.locator("xpath=following-sibling::dd[1]"),
).toHaveText("Unavailable");
// The unverified server-sent npub spelling never renders.
await expect(settingsView.getByText(npubEncode(OTHER_HEX))).toHaveCount(0);
// Connect stays unavailable for every owned community.
await expect(
page.getByRole("button", { name: "Connect", exact: true }),
).toHaveCount(0);
// Creation stays unavailable: no key the new community would bind to.
await expect(page.getByLabel("Community address")).toBeDisabled();
await expect(
page.getByRole("button", { name: "Create and connect", exact: true }),
).toBeDisabled();
});
}
test("capture: community icon picker sits beside its hosted community", async ({
page,
}) => {

View File

@@ -1,6 +1,6 @@
import { hexToBytes } from "@noble/hashes/utils.js";
import { expect, test, type Page } from "@playwright/test";
import { nsecEncode } from "nostr-tools/nip19";
import { nsecEncode, npubEncode } from "nostr-tools/nip19";
import {
installMockBridge,
@@ -1482,7 +1482,12 @@ test("first-community owner can replace a mismatched account identity", async ({
email: "old-owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: { pubkey_hex: "f".repeat(64) },
builderlabIdentity: {
pubkey_hex: "f".repeat(64),
// Contradiction: the server npub spells this device's key, not the
// bound pubkey_hex the mismatch gate and recovery actions use.
npub: npubEncode(BLANK_TYLER_IDENTITY.pubkey),
},
},
{
relayWsUrl: "ws://localhost:3000",
@@ -1498,6 +1503,19 @@ test("first-community owner can replace a mismatched account identity", async ({
name: "This account uses a different Buzz identity",
}),
).toBeVisible();
// The account row must show the authoritative bound key's npub, never the
// contradictory hosted npub (which here spells the device key) or raw hex.
const identityRows = page.getByText(/^Account: npub1/);
await expect(identityRows).toContainText(
`Account: ${npubEncode("f".repeat(64))}`,
);
await expect(identityRows).toContainText(
`This device: ${npubEncode(BLANK_TYLER_IDENTITY.pubkey)}`,
);
await expect(identityRows).not.toContainText(
`Account: ${npubEncode(BLANK_TYLER_IDENTITY.pubkey)}`,
);
await expect(page.getByText("f".repeat(64))).toHaveCount(0);
await page
.getByRole("button", { name: "Use this device's identity" })
.click();
@@ -1514,6 +1532,190 @@ test("first-community owner can replace a mismatched account identity", async ({
);
});
test("first-community owner recovers from an npub-only account identity", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(
page,
{
builderlabAuth: {
email: "old-owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: {
// Identity object present, but no authoritative pubkey_hex — only
// the independent server npub, spelled for a different key.
npub: npubEncode("f".repeat(64)),
},
builderlabCommunities: [
{
id: "owned-community",
name: "North Star",
normalized_host: "north-star.communities.buzz.xyz",
},
],
},
{
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-choice-create").click();
// Presence of the identity object must not read as a linked, ready
// account: the mismatch recovery modal drives the flow instead.
await expect(
page.getByRole("heading", {
name: "This account uses a different Buzz identity",
}),
).toBeVisible();
await expect(
page.getByText(`Account: ${npubEncode("f".repeat(64))}`),
).toHaveCount(0);
await expect(page.getByText("Account: Unavailable")).toBeVisible();
await expect(
page.getByText(`This device: ${npubEncode(BLANK_TYLER_IDENTITY.pubkey)}`),
).toBeVisible();
// No create/connect surface is exposed behind the recovery modal.
await expect(page.getByTestId("hosted-community-create-surface")).toHaveCount(
0,
);
await expect(
page.getByRole("button", { name: "Connect", exact: true }),
).toHaveCount(0);
// Recovery rebinds the device key and restores readiness.
await page
.getByRole("button", { name: "Use this device's identity" })
.click();
await expect
.poll(() => page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? []))
.toEqual(
expect.arrayContaining([
"delete_builderlab_nostr_identity",
"bind_builderlab_nostr_identity",
]),
);
await expect(
page.getByRole("heading", { name: "Choose a community" }),
).toBeVisible();
await expect(page.getByText("North Star")).toBeVisible();
await expect(
page.getByRole("button", { name: "Connect", exact: true }),
).toBeVisible();
});
test("first-community owner never rebinds over a same-key spelling in the hex field", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(
page,
{
builderlabAuth: {
email: "old-owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: {
// A checksum-valid npub stored in the authoritative hex field,
// spelling this very device's key. It is not a hex key: recovery
// owns the flow, the spelling never renders as the account's key,
// and no delete/rebind of the identity the device already holds
// is demanded for it.
pubkey_hex: npubEncode(BLANK_TYLER_IDENTITY.pubkey),
},
builderlabCommunities: [
{
id: "owned-community",
name: "North Star",
normalized_host: "north-star.communities.buzz.xyz",
},
],
},
{
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-choice-create").click();
await expect(
page.getByRole("heading", {
name: "This account uses a different Buzz identity",
}),
).toBeVisible();
await expect(page.getByText("Account: Unavailable")).toBeVisible();
await expect(
page.getByText(`Account: ${npubEncode(BLANK_TYLER_IDENTITY.pubkey)}`),
).toHaveCount(0);
await expect(
page.getByText(`This device: ${npubEncode(BLANK_TYLER_IDENTITY.pubkey)}`),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Connect", exact: true }),
).toHaveCount(0);
});
test("first-community owner with a padded same-key hex is ready, not mismatched", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(
page,
{
builderlabAuth: {
email: "owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: {
// The device's own key, padded and uppercased: the same key after
// normalization, so the account is ready — never a mismatch
// demanding a delete/rebind of the identity it already holds.
pubkey_hex: ` ${BLANK_TYLER_IDENTITY.pubkey.toUpperCase()} `,
},
},
{
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-choice-create").click();
await expect(
page.getByRole("heading", {
name: "This account uses a different Buzz identity",
}),
).toHaveCount(0);
await expect(
page.getByRole("textbox", { name: "Community name" }),
).toBeVisible();
});
test("first-community explains when the local identity belongs to another account", async ({
page,
}) => {

View File

@@ -491,7 +491,14 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
await expect(page.getByTestId("profile-identity-details")).toBeHidden();
await expandIdentity(page);
await expect(page.getByTestId("profile-pubkey")).toContainText("deadbeef");
// The mock identity pubkey is "deadbeef" repeated 8×; its canonical npub
// is npub1m6kmam774…zuz0, so the identity row shows the npub, not the hex.
await expect(page.getByTestId("profile-pubkey")).toContainText(
npubEncode("deadbeef".repeat(8)).slice(0, 8),
);
await expect(page.getByTestId("profile-pubkey")).not.toContainText(
"deadbeefdeadbeef",
);
await expect(page.getByTestId("profile-nip05")).toContainText("Not set");
await page.getByTestId("profile-metadata-edit").click();
@@ -1483,7 +1490,7 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a
await expect(publicKeyCopy).toHaveAttribute("data-copied", "true");
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(agentPubkey);
.toBe(npubEncode(agentPubkey));
await expect(page.getByTestId("user-profile-agent-instruction")).toHaveCount(
0,
);

View File

@@ -191,11 +191,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);
// The verify popover is npub-only — the raw hex line is gone.
await expect(charlieKeyPopover).not.toContainText(
TEST_IDENTITIES.charlie.pubkey,
);
await waitForAnimations(page);
await page.getByTestId("new-message-page").screenshot({
path: `${SHOTS}/new-dm-selected-recipient-key.png`,

View File

@@ -6,9 +6,11 @@ import path from "node:path";
*
* A truncated pubkey prefix is forgeable by vanity-grinding, so display
* truncation must be consistent and centralized: the canonical
* `truncatePubkey` in `shared/lib/pubkey.ts` (or the `<PubKey>` component,
* which also offers full-key reveal + copy). Ad-hoc `pubkey.slice(0, N)`
* display forms fragmented into five formats before this guard existed.
* `truncateNpub` (identity keys — compact npub) and `truncatePubkey`
* (generic hex identifiers such as event/blob IDs) in
* `shared/lib/pubkey.ts` (or the `<PubKey>` component, which also offers
* full-key reveal + copy). Ad-hoc `pubkey.slice(0, N)` display forms
* fragmented into five formats before this guard existed.
*
* It flags `.slice(` / `.substring(` / `.slice(0` template-truncations applied
* to identifiers that look like a pubkey/npub, outside the canonical module.
@@ -97,7 +99,7 @@ export async function runPubkeyTruncationCheck({
if (violations.length > 0) {
console.error(
`${label}: found ${violations.length} hand-rolled pubkey truncation(s).\n` +
`Use \`truncatePubkey\` from shared/lib/pubkey (or the <PubKey> component) instead.\n` +
`Use \`truncateNpub\` (identity) or \`truncatePubkey\` (event/blob IDs) from shared/lib/pubkey (or the <PubKey> component) instead.\n` +
`Genuine non-display uses can be allowlisted in ${scriptPath}.\n`,
);
for (const violation of violations) {