mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
98
desktop/src/features/communities/hostedCommunityApi.test.mjs
Normal file
98
desktop/src/features/communities/hostedCommunityApi.test.mjs
Normal 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);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user