mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
🤖 ## 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>
111 lines
3.7 KiB
JavaScript
111 lines
3.7 KiB
JavaScript
import { promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
/**
|
|
* Shared "no hand-rolled pubkey truncation" guard.
|
|
*
|
|
* A truncated pubkey prefix is forgeable by vanity-grinding, so display
|
|
* truncation must be consistent and centralized: the canonical
|
|
* `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.
|
|
* Non-display uses (array windows, color derivation from a key, avatar
|
|
* initials) live in each app's `overrides` allowlist.
|
|
*/
|
|
|
|
const PUBKEY_SLICE_RE =
|
|
/\b[A-Za-z_$][\w$]*(?:[Pp]ubkey|[Pp]ub_key|[Nn]pub)[\w$]*\??\.(?:slice|substring)\(|\b(?:pubkey|npub)\??\.(?:slice|substring)\(/g;
|
|
|
|
async function walkFiles(directory) {
|
|
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
const files = await Promise.all(
|
|
entries.map(async (entry) => {
|
|
const fullPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
return walkFiles(fullPath);
|
|
}
|
|
return [fullPath];
|
|
}),
|
|
);
|
|
return files.flat();
|
|
}
|
|
|
|
/**
|
|
* @param {object} options
|
|
* @param {string} options.projectRoot Absolute path the rule roots resolve against.
|
|
* @param {Array<{root: string, extensions: Set<string>}>} options.rules Where to scan.
|
|
* @param {string} options.label Human label for the failure header.
|
|
* @param {Set<string>} [options.overrides] Allowlisted "relativePath:lineNumber" entries.
|
|
* @param {Set<string>} [options.allowedFiles] Relative paths allowed to truncate (the canonical module).
|
|
* @param {string} options.scriptPath Path mentioned in the failure hint.
|
|
*/
|
|
export async function runPubkeyTruncationCheck({
|
|
projectRoot,
|
|
rules,
|
|
label,
|
|
overrides = new Set(),
|
|
allowedFiles = new Set(),
|
|
scriptPath,
|
|
}) {
|
|
const candidateFiles = (
|
|
await Promise.all(
|
|
rules.map((rule) => {
|
|
const dir = path.join(projectRoot, rule.root);
|
|
return fs
|
|
.access(dir)
|
|
.then(() => walkFiles(dir))
|
|
.catch(() => []);
|
|
}),
|
|
)
|
|
).flat();
|
|
|
|
const violations = [];
|
|
|
|
for (const filePath of candidateFiles) {
|
|
const relativePath = path.relative(projectRoot, filePath);
|
|
const rule = rules.find((r) =>
|
|
relativePath.startsWith(`${r.root}${path.sep}`),
|
|
);
|
|
if (!rule || !rule.extensions.has(path.extname(filePath))) {
|
|
continue;
|
|
}
|
|
if (allowedFiles.has(relativePath.split(path.sep).join("/"))) {
|
|
continue;
|
|
}
|
|
if (relativePath.includes(".test.")) {
|
|
continue;
|
|
}
|
|
|
|
const content = await fs.readFile(filePath, "utf8");
|
|
const lines = content.split("\n");
|
|
lines.forEach((line, index) => {
|
|
PUBKEY_SLICE_RE.lastIndex = 0;
|
|
if (!PUBKEY_SLICE_RE.test(line)) {
|
|
return;
|
|
}
|
|
const key = `${relativePath.split(path.sep).join("/")}:${index + 1}`;
|
|
if (overrides.has(key)) {
|
|
return;
|
|
}
|
|
violations.push({ key, line: line.trim() });
|
|
});
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error(
|
|
`${label}: found ${violations.length} hand-rolled pubkey truncation(s).\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) {
|
|
console.error(` ${violation.key}: ${violation.line}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
}
|