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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export default defineConfig({
"**/mentions.spec.ts",
"**/mention-spacing.spec.ts",
"**/mention-clipboard.spec.ts",
"**/mention-descriptions-screenshots.spec.ts",
"**/cloud-provenance.spec.ts",
"**/team-mentions.spec.ts",
"**/persistent-agent-audience.spec.ts",
Expand Down
14 changes: 14 additions & 0 deletions desktop/src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ pub struct UserProfileSummaryInfo {
#[serde(default)]
pub name: Option<String>,
pub avatar_url: Option<String>,
/// Kind-0 `about` field — one-line role/description surfaced in the
/// @-mention selector for agents. Untrusted, and capped to
/// `nostr_convert::MENTION_ABOUT_MAX_BYTES` bytes at the point it is
/// built from the event (`nostr_convert::users_batch_from_events`), not
/// merely at render — see that constant's doc comment.
#[serde(default)]
pub about: Option<String>,
pub nip05_handle: Option<String>,
pub owner_pubkey: Option<String>,
#[serde(default)]
Expand All @@ -67,6 +74,13 @@ pub struct UserSearchResultInfo {
pub pubkey: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
/// Kind-0 `about` field — one-line role/description surfaced in the
/// @-mention selector for agents. Untrusted, and capped to
/// `nostr_convert::MENTION_ABOUT_MAX_BYTES` bytes at the point it is
/// built from the event (`nostr_convert::user_search_result_from_event`),
/// not merely at render — see that constant's doc comment.
#[serde(default)]
pub about: Option<String>,
pub nip05_handle: Option<String>,
pub owner_pubkey: Option<String>,
#[serde(default)]
Expand Down
39 changes: 39 additions & 0 deletions desktop/src-tauri/src/nostr_convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,42 @@ fn tags_named<'a>(event: &'a Event, name: &'a str) -> impl Iterator<Item = &'a [
})
}

/// Byte cap applied to a kind:0 `about` before it crosses the Tauri IPC
/// boundary in [`crate::models::UserProfileSummaryInfo`] and
/// [`crate::models::UserSearchResultInfo`].
///
/// `about` is untrusted kind-0 content: the relay allows up to 256 KiB of
/// kind-0 `content` (`MAX_EVENT_CONTENT_BYTES`,
/// `crates/buzz-relay/src/handlers/ingest.rs`), and `get_users_batch` sends a
/// batch filter with no limit, so a single batch response could otherwise
/// carry hundreds of MiB of untrusted text across the IPC boundary and into
/// the React Query cache. The frontend's 120-grapheme render cap
/// (`MENTION_DESCRIPTION_MAX_GRAPHEMES`,
/// `desktop/src/features/messages/lib/mentionSuggestionMapping.ts`) runs
/// downstream of this and does not bound what crosses the seam. 512 bytes
/// comfortably covers that render cap (grapheme clusters are a handful of
/// bytes each in the common case) while cutting the untrusted payload from
/// 256 KiB to 512 B per profile at the boundary itself.
pub(crate) const MENTION_ABOUT_MAX_BYTES: usize = 512;

/// Truncate `about` to at most [`MENTION_ABOUT_MAX_BYTES`] bytes on a UTF-8
/// character boundary. Used by the mention-picker DTOs
/// ([`crate::models::UserProfileSummaryInfo`],
/// [`crate::models::UserSearchResultInfo`]); the full, uncapped `about`
/// remains available on the `get_profile` detail path.
pub(crate) fn truncate_mention_about(about: Option<String>) -> Option<String> {
about.map(|s| {
if s.len() <= MENTION_ABOUT_MAX_BYTES {
return s;
}
let mut end = MENTION_ABOUT_MAX_BYTES;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s[..end].to_string()
})
}

/// Return the owner pubkey from a valid NIP-OA owner tag on a kind:0 profile.
///
/// NIP-OA requires a valid event and exactly one well-formed `auth` tag whose
Expand Down Expand Up @@ -358,6 +394,9 @@ pub fn users_batch_from_events(
.map(str::to_string),
name: v.get("name").and_then(Value::as_str).map(str::to_string),
avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string),
about: truncate_mention_about(
v.get("about").and_then(Value::as_str).map(str::to_string),
),
nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string),
is_agent: owner_pubkey.is_some(),
owner_pubkey,
Expand Down
69 changes: 69 additions & 0 deletions desktop/src-tauri/src/nostr_convert/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,75 @@ fn users_batch_marks_valid_nip_oa_profiles_as_agents() {
);
}

#[test]
fn users_batch_carries_about_when_present() {
let with_about = ev(
0,
r#"{"display_name":"Bumble","about":"Researcher — deep dives & sourcing"}"#,
vec![],
);
let without_about = ev(0, r#"{"display_name":"Fizz"}"#, vec![]);
let pk_with = with_about.pubkey.to_hex();
let pk_without = without_about.pubkey.to_hex();

let resp = users_batch_from_events(
&[with_about, without_about],
&[pk_with.clone(), pk_without.clone()],
);
assert_eq!(
resp.profiles[&pk_with].about.as_deref(),
Some("Researcher — deep dives & sourcing")
);
assert_eq!(resp.profiles[&pk_without].about, None);
}

#[test]
fn users_batch_truncates_oversized_about_at_the_seam() {
// A batch response has no per-request limit (`get_users_batch` sends the
// filter with no `limit`) and a kind:0 `content` may reach 256 KiB
// (`MAX_EVENT_CONTENT_BYTES`), so the DTO must not carry the untrusted
// `about` verbatim across the Tauri IPC boundary — the frontend's
// 120-grapheme render cap runs downstream and does not bound this.
let oversized_about = "a".repeat(MENTION_ABOUT_MAX_BYTES * 4);
let event = ev(
0,
&format!(r#"{{"display_name":"Bumble","about":"{oversized_about}"}}"#),
vec![],
);
let pk = event.pubkey.to_hex();

let resp = users_batch_from_events(std::slice::from_ref(&event), std::slice::from_ref(&pk));

let capped = resp.profiles[&pk].about.as_deref().expect("about present");
assert!(
capped.len() <= MENTION_ABOUT_MAX_BYTES,
"capped about is {} bytes, expected <= {MENTION_ABOUT_MAX_BYTES}",
capped.len()
);
assert!(capped.len() < oversized_about.len());
}

#[test]
fn truncate_mention_about_cuts_on_a_char_boundary() {
// Every grapheme here is a 4-byte UTF-8 scalar (outside the BMP), chosen
// so a naive byte-index slice would land mid-character and panic.
let about = "\u{1F600}".repeat(MENTION_ABOUT_MAX_BYTES); // far over the cap
let truncated = truncate_mention_about(Some(about)).expect("some");
assert!(truncated.len() <= MENTION_ABOUT_MAX_BYTES);
assert!(truncated.is_char_boundary(truncated.len()));
// Sanity: the result is still valid UTF-8 (would already panic above if not).
assert!(!truncated.is_empty());
}

#[test]
fn truncate_mention_about_leaves_short_strings_untouched() {
assert_eq!(
truncate_mention_about(Some("short bio".to_string())).as_deref(),
Some("short bio")
);
assert_eq!(truncate_mention_about(None), None);
}

#[test]
fn user_notes_builds_cursor_from_last() {
let e1 = ev(1, "first", vec![]);
Expand Down
34 changes: 34 additions & 0 deletions desktop/src-tauri/src/nostr_convert/user_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ pub fn user_search_result_from_event(ev: &Event) -> UserSearchResultInfo {
.or_else(|| v.get("name").and_then(Value::as_str))
.map(str::to_string),
avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string),
about: super::truncate_mention_about(
v.get("about").and_then(Value::as_str).map(str::to_string),
),
nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string),
is_agent: owner_pubkey.is_some(),
owner_pubkey,
Expand Down Expand Up @@ -238,6 +241,37 @@ mod tests {
assert_eq!(r.users[1].display_name.as_deref(), Some("B"));
}

#[test]
fn user_search_result_carries_about_when_present() {
let with_about = ev(0, r#"{"name":"honey","about":"Writer bee"}"#, vec![]);
let without_about = ev(0, r#"{"name":"fizz"}"#, vec![]);

assert_eq!(
user_search_result_from_event(&with_about).about.as_deref(),
Some("Writer bee")
);
assert_eq!(user_search_result_from_event(&without_about).about, None);
}

#[test]
fn user_search_result_truncates_oversized_about_at_the_seam() {
// `search_users` is bounded to 500 rows (not unbounded like the batch
// command) but each row's `about` was still uncapped before crossing
// the IPC boundary — cap it here too, matching `users_batch`.
let oversized_about = "a".repeat(super::super::MENTION_ABOUT_MAX_BYTES * 4);
let event = ev(
0,
&format!(r#"{{"name":"honey","about":"{oversized_about}"}}"#),
vec![],
);

let capped = user_search_result_from_event(&event)
.about
.expect("about present");
assert!(capped.len() <= super::super::MENTION_ABOUT_MAX_BYTES);
assert!(capped.len() < oversized_about.len());
}

#[test]
fn user_search_result_marks_valid_nip_oa_profile_as_agent() {
let event = oa_profile_event(r#"{"display_name":"Mira"}"#);
Expand Down
98 changes: 98 additions & 0 deletions desktop/src/features/channels/ui/useMessageProfiles.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";

import { JSDOM } from "jsdom";

// Regression coverage for the `about` branch of `profileLookupsEqual`
// (identity.ts) through its one production caller. `about` is what makes
// the mention selector's role line pick up an about-only kind-0 update
// (mentionSuggestionMapping.ts): the stabilized reference this hook returns
// must be RELEASED (a new object identity) when only `about` changes, or
// MessageRow's `prev.profiles === next.profiles` memo keeps rendering the
// stale bio. See identity.test.mjs for the field-by-field coverage of
// `profileLookupsEqual` itself.

const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});

before(() => {
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
window: dom.window,
});
});

after(() => dom.window.close());

const PUBKEY = "a".repeat(64);

function profiles(about) {
return {
[PUBKEY]: {
displayName: "Ada",
name: null,
avatarUrl: null,
about,
nip05Handle: null,
ownerPubkey: null,
isAgent: true,
},
};
}

async function renderMessageProfiles(initialProps) {
const { renderHook } = await import("@testing-library/react");
const { useMessageProfiles } = await import("./useMessageProfiles.ts");

return renderHook((props) => useMessageProfiles(props), { initialProps });
}

const baseProps = {
channelMembers: undefined,
currentProfile: undefined,
currentPubkey: undefined,
managedAgents: [],
relayAgents: [],
};

test("releases the stabilized reference when only `about` changes", async () => {
const { result, rerender } = await renderMessageProfiles({
...baseProps,
profiles: profiles("Researcher — deep dives"),
});

const first = result.current;
assert.equal(first[PUBKEY].about, "Researcher — deep dives");

rerender({ ...baseProps, profiles: profiles("Now doing something else") });

const second = result.current;
assert.notEqual(
second,
first,
"an about-only change must release the stabilized reference",
);
assert.equal(second[PUBKEY].about, "Now doing something else");
});

test("keeps the stabilized reference when no profile value actually changed", async () => {
const { result, rerender } = await renderMessageProfiles({
...baseProps,
profiles: profiles("Researcher — deep dives"),
});

const first = result.current;

// A fresh `profiles` object with value-identical content — the shape a
// re-keyed `users-batch` query produces on typing churn.
rerender({ ...baseProps, profiles: profiles("Researcher — deep dives") });

assert.equal(
result.current,
first,
"a value-identical re-key must keep the stabilized reference",
);
});
4 changes: 4 additions & 0 deletions desktop/src/features/messages/lib/buildMentionCandidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ export function buildMentionCandidates({
role: current.role ?? candidate.role ?? null,
secondaryLabel:
current.secondaryLabel ?? candidate.secondaryLabel ?? null,
// `??` (not `?? null`) so both-undefined stays undefined — that is
// the "still unknown, resolve from the profile lookup" signal.
description: current.description ?? candidate.description,
ownerPubkey:
current.ownerPubkey ??
candidate.ownerPubkey ??
Expand Down Expand Up @@ -219,6 +222,7 @@ export function buildMentionCandidates({
relayAgentNamesByPubkey.has(pubkey),
personaName: personaNameByPubkey.get(pubkey) ?? null,
secondaryLabel: formatSearchUserSecondaryLabel(user),
description: user.about ?? null,
ownerPubkey: user.ownerPubkey ?? null,
isGlobalSearchResult: true,
isManagedAgent: managedAgentNamesByPubkey.has(pubkey),
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/features/messages/lib/mentionCandidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export type MentionCandidate = {
role?: ChannelRole | null;
personaName?: string | null;
secondaryLabel?: string | null;
/** Kind-0 `about` when already resolved at candidate-build time (e.g. from
* a user-search result). `undefined` means "unknown — resolve from the
* profile lookup at suggestion-mapping time"; `null` means "known absent". */
description?: string | null;
ownerPubkey?: string | null;
isAgent: boolean;
isActiveAgent?: boolean;
Expand Down
Loading
Loading