Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions crates/buzz-workflow/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,13 @@ dashmap = { workspace = true }
moka = { workspace = true }
evalexpr = "11"
cron = "0.16"
nostr = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
reqwest = { workspace = true, optional = true }

[dev-dependencies]
nostr = { workspace = true }

[features]
reqwest = ["dep:reqwest"]
72 changes: 34 additions & 38 deletions crates/buzz-workflow/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use std::collections::HashMap;

use buzz_core::tenant::CommunityId;
use evalexpr::HashMapContext;
use nostr::ToBech32;
use serde_json::Value as JsonValue;
use tracing::{debug, info, warn};
use uuid::Uuid;
Expand Down Expand Up @@ -62,7 +63,8 @@ impl TriggerContext {
///
/// Supports filters:
/// - `| truncate(N)` — truncate to N characters
/// - `| truncate_pubkey` — shorten pubkey to `abc...xyz` (first 6 + last 6 chars)
/// - `| npub` — encode a hex pubkey as its full bech32 `npub` (non-pubkey
/// values pass through unchanged); `truncate_pubkey` is a legacy alias
///
/// Unknown `{{keys}}` are left as literal text (no error, no substitution).
pub fn resolve_template(
Expand Down Expand Up @@ -185,28 +187,12 @@ fn apply_filter(value: String, filter: &str) -> Result<String, WorkflowError> {
return Ok(truncated);
}

// `truncate_pubkey` — shorten to `abc...xyz` (first 6 + last 6 chars).
// Only skip truncation if the string is shorter than the truncated form would be.
if filter == "truncate_pubkey" {
let char_count = value.chars().count();
if char_count <= 12 {
// Already short enough that truncating would be longer than the original.
// But we still apply the format for consistency if exactly 12.
// For strings < 12 chars, return as-is.
if char_count < 12 {
return Ok(value);
}
// `npub` (alias `truncate_pubkey`): full bech32 npub — truncated prefixes are grindable.
if filter == "npub" || filter == "truncate_pubkey" {
if let Ok(pk) = nostr::PublicKey::from_hex(&value) {
return Ok(pk.to_bech32().unwrap_or(value));
}
let head: String = value.chars().take(6).collect();
let tail: String = value
.chars()
.rev()
.take(6)
.collect::<String>()
.chars()
.rev()
.collect();
return Ok(format!("{head}...{tail}"));
return Ok(value);
}

Err(WorkflowError::TemplateError(format!(
Expand Down Expand Up @@ -1284,15 +1270,30 @@ mod tests {
}

#[test]
fn resolve_truncate_pubkey_filter() {
let ctx = make_trigger();
fn resolve_npub_filter_encodes_hex_pubkey() {
let mut ctx = make_trigger();
ctx.author = "e17e5abf7b1dbd363f0ed6fbda2455609727b2555428dea251388c542cd2f03f".to_owned();
let out = resolve_template("{{trigger.author | npub}}", &ctx, &HashMap::new()).unwrap();
assert_eq!(
out,
"npub1u9l940mmrk7nv0cw6maa5fz4vztj0vj42s5dagj38zx9gtxj7qls94fpux"
);
}

#[test]
fn resolve_truncate_pubkey_is_alias_for_npub() {
let mut ctx = make_trigger();
ctx.author = "e17e5abf7b1dbd363f0ed6fbda2455609727b2555428dea251388c542cd2f03f".to_owned();
let out = resolve_template(
"{{trigger.author | truncate_pubkey}}",
&ctx,
&HashMap::new(),
)
.unwrap();
assert_eq!(out, "abc123...def456");
assert_eq!(
out,
"npub1u9l940mmrk7nv0cw6maa5fz4vztj0vj42s5dagj38zx9gtxj7qls94fpux"
);
}

#[test]
Expand Down Expand Up @@ -1543,10 +1544,10 @@ mod tests {
}

#[test]
fn resolve_truncate_pubkey_short_string_returned_as_is() {
// Strings shorter than 12 chars are returned as-is (no truncation).
fn resolve_pubkey_filter_non_pubkey_passes_through() {
// Values that are not valid hex pubkeys are returned unchanged.
let mut ctx = make_trigger();
ctx.author = "short".to_owned(); // 5 chars < 12
ctx.author = "short".to_owned();
let out = resolve_template(
"{{trigger.author | truncate_pubkey}}",
&ctx,
Expand All @@ -1557,17 +1558,12 @@ mod tests {
}

#[test]
fn resolve_truncate_pubkey_exactly_12_chars() {
// Exactly 12 chars → format as head...tail (6+6).
fn resolve_npub_filter_passes_npub_through() {
// Already-encoded npubs are not valid hex, so they pass through intact.
let mut ctx = make_trigger();
ctx.author = "abcdef123456".to_owned(); // exactly 12 chars
let out = resolve_template(
"{{trigger.author | truncate_pubkey}}",
&ctx,
&HashMap::new(),
)
.unwrap();
assert_eq!(out, "abcdef...123456");
ctx.author = "npub1u9l940mmrk7nv0cw6maa5fz4vztj0vj42s5dagj38zx9gtxj7qls94fpux".to_owned();
let out = resolve_template("{{trigger.author | npub}}", &ctx, &HashMap::new()).unwrap();
assert_eq!(out, ctx.author);
}

#[test]
Expand Down
3 changes: 2 additions & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
"typecheck": "tsc --noEmit",
"check:file-sizes": "node ./scripts/check-file-sizes.mjs",
"check:px-text": "node ./scripts/check-px-text.mjs",
"check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs",
"lint": "biome lint .",
"check": "biome check . && pnpm check:file-sizes && pnpm check:px-text",
"check": "biome check . && pnpm check:file-sizes && pnpm check:px-text && pnpm check:pubkey-truncation",
"format": "biome format --write .",
"test": "node --import ./test-loader.mjs --experimental-strip-types --test 'src/**/*.test.mjs'",
"preview": "vite preview",
Expand Down
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default defineConfig({
"**/activity-scope-label-screenshots.spec.ts",
"**/local-archive-screenshots.spec.ts",
"**/agent-readiness-screenshots.spec.ts",
"**/pubkey-display-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/image-attachment-gallery.spec.ts",
"**/composer-image-draw.spec.ts",
Expand Down
48 changes: 48 additions & 0 deletions desktop/scripts/check-pubkey-truncation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { runPubkeyTruncationCheck } from "../../scripts/check-pubkey-truncation-core.mjs";

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.
const rules = [
{
root: "src",
extensions: new Set([".ts", ".tsx"]),
},
];

// Non-display uses: array windows over pubkey lists, color/initials
// derivation where the value is never presented as an identity.
const overrides = new Set([
// ProfileAvatar fallback label — decorative glyphs inside an avatar disc.
"src/features/huddle/components/ParticipantList.tsx:92",
// HexAvatar: 6-char badge + hue derivation inside a color-coded disc,
// clearly decorative (paired with a full truncatePubkey aria-label).
"src/features/huddle/components/ParticipantList.tsx:143",
"src/features/huddle/components/ParticipantList.tsx:144",
// clientId (not a pubkey) sliced in a debug log next to the real thing.
"src/features/channels/readState/readStateManager.ts:338",
// Array windows (first N pubkeys), not string truncation.
"src/features/messages/lib/threadPanel.ts:395",
"src/features/projects/ui/ProjectsView.tsx:166",
"src/features/projects/ui/ProjectsOverviewPanel.tsx:209",
]);

await runPubkeyTruncationCheck({
projectRoot,
rules,
overrides,
allowedFiles: new Set([
// The canonical helper itself.
"src/shared/lib/pubkey.ts",
// E2E mock bridge fabricates ids/nsecs from pubkeys; nothing here is a
// user-facing identity display.
"src/testing/e2eBridge.ts",
]),
label: "Desktop",
scriptPath: "desktop/scripts/check-pubkey-truncation.mjs",
});
9 changes: 5 additions & 4 deletions desktop/src-tauri/src/nostr_convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

use std::collections::{BTreeSet, HashMap};

use nostr::Event;
use nostr::{Event, ToBech32};
use serde_json::{json, Value};

use crate::models::*;
Expand Down Expand Up @@ -447,7 +447,8 @@ pub fn agents_from_events(events: &[Event]) -> Value {
.map(|ev| {
let mut v: Value = serde_json::from_str(&ev.content).unwrap_or_else(|_| json!({}));
let pubkey = ev.pubkey.to_hex();
let short_pubkey = pubkey[..8].to_string();
// Full npub fallback — truncated prefixes are grindable (see pubkey-display).
let npub = ev.pubkey.to_bech32().unwrap_or_else(|_| pubkey.clone());
// Always overwrite the pubkey with the event author — it's the
// authoritative source even if the content claims otherwise.
if let Some(obj) = v.as_object_mut() {
Expand All @@ -457,7 +458,7 @@ pub fn agents_from_events(events: &[Event]) -> Value {
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| short_pubkey.clone());
.unwrap_or_else(|| npub.clone());
if !obj.get("name").is_some_and(Value::is_string) {
obj.insert("name".to_string(), json!(fallback_name));
}
Expand All @@ -479,7 +480,7 @@ pub fn agents_from_events(events: &[Event]) -> Value {
} else {
v = json!({
"pubkey": pubkey,
"name": short_pubkey,
"name": npub,
"agent_type": "agent",
"channels": [],
"channel_ids": [],
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/features/agents/ui/ManagedAgentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { Button } from "@/shared/ui/button";
import { AgentConfigPanel } from "./AgentConfigPanel";
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
import { truncatePubkey } from "./agentUi";
import { PubKey } from "@/shared/ui/PubKey";

export function ManagedAgentRow({
agent,
Expand Down Expand Up @@ -237,7 +237,7 @@ function AgentSummary({
) : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="font-mono">{truncatePubkey(agent.pubkey)}</span>
<PubKey pubkey={agent.pubkey} />
{agent.backend.type === "local" ? (
<span>
{agent.startOnAppLaunch ? "Auto-start" : "Manual start"}
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/agents/ui/RelayDirectorySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { RelayAgent } from "@/shared/api/types";
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
import { Card } from "@/shared/ui/card";
import { Input } from "@/shared/ui/input";
import { truncatePubkey } from "./agentUi";
import { truncatePubkey } from "@/shared/lib/pubkey";

export function RelayDirectorySection({
error,
Expand Down
22 changes: 12 additions & 10 deletions desktop/src/features/agents/ui/RespondToField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
mergeAllowlist,
parsePubkeyInput,
} from "@/features/agents/lib/respondToAllowlist";
import { formatPubkey } from "@/features/channels/lib/memberUtils";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { PubKey } from "@/shared/ui/PubKey";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
import { useUserSearchQuery } from "@/features/profile/hooks";
import type { RespondToMode, UserSearchResult } from "@/shared/api/types";
Expand Down Expand Up @@ -36,7 +37,7 @@ function formatSearchUserName(user: UserSearchResult) {
return (
user.displayName?.trim() ||
user.nip05Handle?.trim() ||
formatPubkey(user.pubkey)
truncatePubkey(user.pubkey)
);
}

Expand All @@ -46,7 +47,7 @@ function formatSearchUserSecondary(user: UserSearchResult) {
if (displayName && nip05Handle) {
return nip05Handle;
}
return formatPubkey(user.pubkey);
return truncatePubkey(user.pubkey);
}

const RESPOND_TO_OPTIONS: PersonaDropdownOption[] = [
Expand Down Expand Up @@ -276,8 +277,9 @@ function AllowlistPicker({
) : null}
{!isPersona && ownerPubkey ? (
<p className="text-xs text-muted-foreground">
Owner (<span className="font-mono">{formatPubkey(ownerPubkey)}</span>)
is always implicitly allowed by the harness — no need to add it here.
Owner (
<PubKey pubkey={ownerPubkey} />) is always implicitly allowed by the
harness — no need to add it here.
</p>
) : !isPersona ? (
<p className="text-xs text-muted-foreground">
Expand Down Expand Up @@ -308,12 +310,12 @@ function AllowlistPicker({
>
<UserAvatar
avatarUrl={null}
displayName={formatPubkey(pubkey)}
displayName={truncatePubkey(pubkey)}
size="xs"
/>
<span className="font-mono">{formatPubkey(pubkey)}</span>
<PubKey pubkey={pubkey} />
<button
aria-label={`Remove ${formatPubkey(pubkey)}`}
aria-label={`Remove ${truncatePubkey(pubkey)}`}
className="text-muted-foreground transition-colors hover:text-foreground"
disabled={disabled}
onClick={() => onRemove(pubkey)}
Expand Down Expand Up @@ -370,12 +372,12 @@ function AllowlistPicker({
<div className="flex items-center gap-2 min-w-0">
<UserAvatar
avatarUrl={null}
displayName={formatPubkey(deferredQuery)}
displayName={truncatePubkey(deferredQuery)}
size="xs"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium leading-5">
{formatPubkey(deferredQuery)}
{truncatePubkey(deferredQuery)}
</p>
<p className="truncate text-xs text-muted-foreground">
Add pubkey directly
Expand Down
4 changes: 0 additions & 4 deletions desktop/src/features/agents/ui/agentUi.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
export function truncatePubkey(pubkey: string) {
return `${pubkey.slice(0, 8)}…${pubkey.slice(-6)}`;
}

function commandLooksLikePath(command: string) {
const trimmed = command.trim();
return (
Expand Down
7 changes: 2 additions & 5 deletions desktop/src/features/channels/lib/memberUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ChannelMember } from "@/shared/api/types";
import { truncatePubkey } from "@/shared/lib/pubkey";

export const roleOrder: Record<ChannelMember["role"], number> = {
owner: 0,
Expand All @@ -8,10 +9,6 @@ export const roleOrder: Record<ChannelMember["role"], number> = {
bot: 4,
};

export function formatPubkey(pubkey: string) {
return `${pubkey.slice(0, 8)}\u2026${pubkey.slice(-4)}`;
}

export function formatMemberName(
member: ChannelMember,
currentPubkey?: string,
Expand All @@ -20,7 +17,7 @@ export function formatMemberName(
return "You";
}

return member.displayName ?? formatPubkey(member.pubkey);
return member.displayName ?? truncatePubkey(member.pubkey);
}

export function compareMembersByRole(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
writeStoredReadState,
} from "@/features/channels/readState/readStateStorage";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
import { truncatePubkey } from "@/shared/lib/pubkey";

const CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.client-id";
const SLOT_ID_KEY_PREFIX = "buzz.nip-rs.slot-id";
Expand Down Expand Up @@ -335,7 +336,7 @@ export class ReadStateManager {
async initialize(): Promise<void> {
if (this.initialized || this.destroyed) return;
console.debug(
`[ReadStateManager] initialize pubkey=${this.pubkey.substring(0, 8)}… clientId=${this.clientId.substring(0, 8)}… slotId=${this.slotId}`,
`[ReadStateManager] initialize pubkey=${truncatePubkey(this.pubkey)} clientId=${this.clientId.substring(0, 8)}… slotId=${this.slotId}`,
);

this.hydrateFromLocalStorage();
Expand Down
Loading
Loading