Files
buzz/desktop/src-tauri/src/team_catalog.rs
Will Pfleger 2f66ee24a0 feat(desktop): add team sharing to community catalog (#3995)
Stack: [#5112](https://github.com/block/buzz/pull/5112) → this PR

Stacks on the team catalog backend PR (#5112). Contains the `desktop/**`
changes that turn the add-agent surface into a single Community Catalog
browsing both shared agents and shared teams.

## Owner catalog sync pipeline

`usePersonaSync.ts` gains a hydration pipeline in `startPersonaSync` so
a fresh device that comes online after another already published the
owner's 30178 catalog head reconstructs the complete state without
falsely retracting it. The owner's
persona/team/managed-agent/30178/deletion history is backfilled up front
(a live-only subscription gets no history — reconnect-replay's
since-cursor is undefined until the first live event), then a live
subscription takes over.

Four properties keep a fresh sync from purging the owner's valid shared
head:

- **Paged backfill with a safe termination guarantee.** The relay serves
each REQ newest-first and clamps `limit` to its advertised `max_limit`,
so a large owner's history overflows one page — a newer 30178/30176
could return in-page while an older required 30175 constituent falls
beyond it. `fetchOwnerHistoryToExhaustion` pages the full window with
the `until` time cursor (the only cursor the WS REQ filter exposes; the
DB `before_id` keyset is REST-only), deduping the inclusive-boundary
rows. A short page terminates normally. A full page whose oldest event
cannot advance the time-only cursor is a **dense boundary** — more than
one page of events share one `created_at` second, which the WS filter
has no `(created_at, id)` cursor to escape — and raises
`PersonaHistoryDenseBoundaryError` rather than silently completing as if
the history were exhausted and dropping the older constituents behind
it.
- **Constituents-before-catalog ordering.** Over the complete batch,
`orderCatalogHeadsLast` stably defers every 30178 head past its
30175/30176 constituents. Reconciling a 30178 head before its personas
hydrate makes the inbound team refresh fail member resolution and queue
a dominating false tombstone; deferring the heads guarantees the
constituents are all applied first, while newest-wins order within every
other coordinate is untouched.
- **Hydration boundary for concurrent live events.** The backfill fetch
and the live subscription start concurrently into one reconcile chain. A
live or replayed 30178 that arrives before the backfill reconciles its
constituents would reproduce the same false-tombstone purge, so live
events are buffered until the ordered backfill is dispatched and then
drained in arrival order. Steady-state live events (after hydration
completes) reconcile immediately.
- **Explicit backfill failure policy.** A transient history-fetch
rejection is retried with bounded backoff. When backfill cannot complete
— retries exhausted, or a deterministic dense boundary — the pipeline
enters a **degraded-live** state rather than leaving the subscription
permanently inert with live events accumulating in the buffer: the
hydration boundary still opens so buffered and future live events keep
reconciling, but the whole catalog dependency set is dropped — the 30178
head, its 30175/30176 constituents, and any kind-5 deletion carrying a
dependency-targeting `a` tag (classified by scanning *all* `a` tags,
matching the backend's deletion router, which `find_map`s across every
tag and routes the first signer-owned coordinate — so a malformed or
foreign first `a` tag ahead of an owned 30176 cannot slip a destructive
deletion through) — because backfill never fully hydrated the owner's
constituents. Dropping only the 30178 head is not enough: the backend
refreshes the catalog head after every team/persona save, and live
delivery is newest-first, so a 30176 edit that adds a *new* member would
reach the backend before that member's 30175 and falsely tombstone a
witness-holding device's valid team. Holding the prior hydrated run's
constituents on disk only proves the *old* revision is resolvable — it
says nothing about a new member — which is why the entire dependency set
is held rather than just the head. 30177 managed-agent runtime policy
stays live (it drives no catalog refresh). A degraded device stays stale
on team/persona edits until it self-heals on the next effect re-run
(restart, or an identity/community switch) — the correct trade against
destroying valid shared state.

## Data layer

Relay paging, signature verification, NIP-33 head selection, and
untrusted-content parsing for the kind `30178` team catalog live
natively in the `fetch_team_catalog` Tauri command (`team_catalog.rs`),
structurally mirroring `fetch_persona_catalog` (`persona_catalog.rs`). A
catalog refresh crosses IPC once and never verifies a signature on the
webview thread. `teamCatalogRelay.ts` is now a thin presentation and
local-linkage layer over the verified projection — it shapes entries for
display and links each to a local team, and never parses or verifies.

Parsing is all-or-nothing, identical to the add-time re-fetch in
`add_team_from_catalog`: a team with any invalid member fails to parse
and the publication is dropped from the catalog, matching persona
behavior. **Behavior delta:** the previously reviewed partial-render of
invalid-member teams — a warning banner on an entry that could never be
added — is removed. Invalid publications are dropped entirely rather
than surfaced as un-addable.

## Hooks

`useTeamCatalogRelay.ts` mirrors the persona catalog hook: a
community-keyed query over `fetch_team_catalog`, live invalidation on
kind `30178`, share/unshare, and add-from-catalog (which invalidates
both the teams and personas stores, since adopting a team copies its
members as local personas).

## CommunityCatalogDialog

Single unified surface replacing the former separate dialogs. Agents and
Teams appear as labeled sections with type-tagged selection and a
teams-preferred launch. `TeamsSection`'s discover entry and the
new-agent card both open this one dialog. `PersonaCatalogDialog.tsx` is
removed; persona browsing now lives inside the unified dialog.

## TeamShareDialog

Publishes and unshares team catalog entries via `set_team_shared`.

## e2e + screenshots

`team-catalog.spec.ts` covers the browse + adopt flow;
`team-catalog-screenshots.spec.ts` produces the pixel-regression set.
`e2eBridge.ts` gains `mockTeamCatalogPublications`, which mirrors the
native command's head selection and shared gate and performs only a
shallow `v`/`name`/`members`-array shape check; per-member validation
stays in the Rust command.

## Follow-ups

None. The shared agent-definition text-safety policy
(Unicode-control/bidi/zero-width rejection) already covers the team
surface: #5112's `validate_team_catalog_content` chokepoint gates every
field delivered verbatim to the harness on both the publish and adopt
paths, and this PR's parse layer consumes that verified projection
rather than re-validating on the webview thread.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-28 14:51:25 -04:00

331 lines
12 KiB
Rust

//! Native team-catalog fetch and trust-boundary projection.
//!
//! The renderer owns presentation/linkage to local teams. Relay paging,
//! signature verification, NIP-33 head selection, and untrusted-content
//! parsing stay here — structurally the persona-catalog equivalent
//! (`persona_catalog.rs`) with kind 30178 and the team content parser swapped
//! in, so a catalog refresh crosses IPC once and never verifies a signature on
//! the webview thread.
//!
//! Content parsing reuses `managed_agents::team_catalog::team_catalog_content_from_event`
//! — the same all-or-nothing parse `add_team_from_catalog` re-runs at add time,
//! so a head this command projects is exactly a head the backend will accept.
use std::{collections::HashMap, time::Duration};
use buzz_core_pkg::kind::{event_is_shared, KIND_TEAM_CATALOG};
use nostr::Event;
use serde::Serialize;
use tauri::State;
use crate::{
app_state::AppState,
managed_agents::team_catalog::{team_catalog_content_from_event, TeamCatalogContent},
native_relay_client::NativeRelayClient,
};
const CATALOG_PAGE_SIZE: usize = 500;
const MAX_CATALOG_PAGES: usize = 40;
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TeamCatalogPublication {
event_id: String,
owner_pubkey: String,
team_d_tag: String,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
instructions: Option<String>,
members: Vec<TeamCatalogMemberProjection>,
}
#[derive(Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct TeamCatalogMemberProjection {
member_key: String,
display_name: String,
system_prompt: String,
avatar_url: Option<String>,
runtime: Option<String>,
model: Option<String>,
provider: Option<String>,
}
/// Fetches the active community's relay-confirmed team catalog.
///
/// The command accepts no relay or identity input: both are snapshotted from
/// `AppState`, then checked again before return so an in-flight old-community
/// response cannot populate the new community's query cache.
#[tauri::command]
pub(crate) async fn fetch_team_catalog(
state: State<'_, AppState>,
relay_client: State<'_, NativeRelayClient>,
) -> Result<Vec<TeamCatalogPublication>, String> {
let keys = state.signing_keys()?;
let owner = keys.public_key().to_hex();
let relay_url = crate::relay::relay_ws_url_with_override(&state);
let session = relay_client.session(relay_url.clone(), keys).await;
let by_id = collect_verified_catalog(|until| {
let session = &session;
async move {
let mut filter = serde_json::json!({
"kinds": [KIND_TEAM_CATALOG],
"limit": CATALOG_PAGE_SIZE,
});
if let Some(until) = until {
filter["until"] = serde_json::json!(until);
}
let page = session.fetch_events(filter, PAGE_TIMEOUT).await?;
let page_len = page.len();
// Schnorr verification is CPU-bound. Keep the complete page off the
// async executor (and therefore off Tauri command scheduling).
let verified = tauri::async_runtime::spawn_blocking(move || verify_page(page))
.await
.map_err(|error| format!("catalog signature verification failed: {error}"))?;
Ok((page_len, verified))
}
})
.await?;
let current_keys = state.signing_keys()?;
if current_keys.public_key().to_hex() != owner
|| crate::relay::relay_ws_url_with_override(&state) != relay_url
{
return Err("team catalog scope changed while fetching".to_string());
}
Ok(publications_from_verified_events(
by_id.into_values().collect(),
))
}
/// Page the catalog to exhaustion through `fetch_verified_page`, which returns
/// the wire page length and the signature-verified events for a given inclusive
/// `until` cursor. Kept generic over the fetcher so the paging/termination logic
/// is driven by the exact same code in production and in the cap-boundary
/// regressions, with no relay or Tauri state.
///
/// Exhaustion is proven ONLY by a `Done` (a short page). If the page budget runs
/// out while pages are still full and advancing, the catalog is larger than
/// `MAX_CATALOG_PAGES` can walk: returning the collected heads would silently
/// present a truncated catalog as complete, so fail loudly instead — the same
/// degrade philosophy as `DenseBoundary`/`NoVerifiedEvents`, surfaced by the
/// browse dialog as an error rather than a silently short list.
async fn collect_verified_catalog<F, Fut>(
mut fetch_verified_page: F,
) -> Result<HashMap<String, Event>, String>
where
F: FnMut(Option<u64>) -> Fut,
Fut: std::future::Future<Output = Result<(usize, Vec<Event>), String>>,
{
let mut by_id = HashMap::new();
let mut until = None;
let mut exhausted = false;
for _ in 0..MAX_CATALOG_PAGES {
let (page_len, verified) = fetch_verified_page(until).await?;
match merge_verified_page(&mut by_id, page_len, until, verified) {
PageProgress::Done => {
exhausted = true;
break;
}
PageProgress::Next(next_until) => until = Some(next_until),
// The relay filter exposes no `(created_at, id)` cursor to page
// within a second, so more than one page at the boundary second
// cannot be paged past. Fail loudly rather than project a truncated
// catalog as complete — the browse dialog surfaces this as an error
// instead of silently dropping every older team.
PageProgress::DenseBoundary(second) => {
return Err(format!(
"team catalog has more than one page of events at created_at {second}; \
the time-only relay cursor cannot page past it"
));
}
// A full page with no verifiable events cannot advance the cursor on
// trusted data. Advancing on the wire timestamp would let one forged
// `created_at` warp the cursor past — and silently drop — every valid
// team below it, so fail loudly instead.
PageProgress::NoVerifiedEvents => {
return Err(
"team catalog returned a full page with no verifiable events; \
cannot safely advance the cursor"
.to_string(),
);
}
}
}
if !exhausted {
return Err(format!(
"team catalog exceeds the {MAX_CATALOG_PAGES} page fetch budget \
({CATALOG_PAGE_SIZE} events per page); cannot list it completely"
));
}
Ok(by_id)
}
#[derive(Debug, PartialEq)]
enum PageProgress {
Done,
Next(u64),
/// A full page whose oldest verified timestamp cannot drop the inclusive
/// `until` cursor: more than one page of events shares this second, and the
/// relay filter has no `(created_at, id)` cursor to escape it.
DenseBoundary(u64),
/// A full page with no verifiable events. The cursor can only advance on
/// trusted timestamps, so there is nothing safe to page with.
NoVerifiedEvents,
}
/// Retain only events whose Schnorr signature verifies. This is the single
/// trust gate for a relay page: paging, head selection, and content parsing all
/// run on its output, so a forged or tampered event never influences the
/// cursor or the projected catalog. Shared with the paging regression so the
/// test drives the exact seam production does, not a stubbed result.
fn verify_page(page: Vec<Event>) -> Vec<Event> {
page.into_iter()
.filter(|event| event.verify().is_ok())
.collect()
}
fn merge_verified_page(
by_id: &mut HashMap<String, Event>,
wire_page_len: usize,
until: Option<u64>,
verified: Vec<Event>,
) -> PageProgress {
// The oldest *verified* timestamp is the only value safe to page with: an
// unverifiable event must never control the cursor, or one forged
// `created_at` (e.g. 0) would warp `until` past — and silently drop — every
// valid team below it. Captured before the page is drained into `by_id`.
let verified_oldest = verified
.iter()
.map(|event| event.created_at.as_secs())
.min();
for event in verified {
by_id.insert(event.id.to_hex(), event);
}
// A short page is the end of the catalog.
if wire_page_len < CATALOG_PAGE_SIZE {
return PageProgress::Done;
}
// A full page must advance on a verified timestamp. With none, the cursor
// cannot move safely — fail loudly rather than trust the wire or complete.
let Some(oldest) = verified_oldest else {
return PageProgress::NoVerifiedEvents;
};
// When the oldest verified timestamp cannot drop below the current inclusive
// `until`, the page is stuck at a dense boundary second: silently stopping
// would drop every older team and falsely report the catalog exhausted.
if until.is_some_and(|until| oldest >= until) {
return PageProgress::DenseBoundary(oldest);
}
PageProgress::Next(oldest)
}
fn publications_from_verified_events(mut events: Vec<Event>) -> Vec<TeamCatalogPublication> {
events.sort_by(|left, right| {
right
.created_at
.cmp(&left.created_at)
.then_with(|| left.id.cmp(&right.id))
});
let mut claimed = std::collections::HashSet::new();
let mut publications = Vec::new();
for event in events {
if event.kind.as_u16() as u32 != KIND_TEAM_CATALOG {
continue;
}
let Some(team_d_tag) = single_tag(&event, "d") else {
continue;
};
if team_d_tag.is_empty() {
continue;
}
let owner_pubkey = event.pubkey.to_hex().to_ascii_lowercase();
let coordinate = (owner_pubkey.clone(), team_d_tag.clone());
if !claimed.insert(coordinate) {
continue;
}
// Claim happens before visibility or parsing. A valid newest unshared
// or malformed head is still the NIP-33 head and must not resurrect an
// older shared definition.
if !event_is_shared(&event) {
continue;
}
// All-or-nothing parse, identical to the add-time re-fetch: a team with
// any invalid member cannot be adopted, so a partial projection would
// only offer an un-addable entry.
let Ok(content) = team_catalog_content_from_event(&event) else {
continue;
};
publications.push(publication(
event.id.to_hex(),
owner_pubkey,
team_d_tag,
content,
));
}
publications
}
fn publication(
event_id: String,
owner_pubkey: String,
team_d_tag: String,
content: TeamCatalogContent,
) -> TeamCatalogPublication {
TeamCatalogPublication {
event_id,
owner_pubkey,
team_d_tag,
name: content.name,
description: content.description,
instructions: content.instructions,
members: content
.members
.into_iter()
.map(|member| TeamCatalogMemberProjection {
member_key: member.member_key,
display_name: member.display_name,
system_prompt: member.system_prompt.unwrap_or_default(),
avatar_url: member.avatar_url,
runtime: member.runtime,
model: member.model,
provider: member.provider,
})
.collect(),
}
}
/// A tag's value, but only when the event carries exactly one of that tag.
///
/// Ambiguity is absence: the relay admits exactly one bounded `d` tag, so a
/// multi-`d` event is malformed and picking the first would resolve a
/// different coordinate than the publisher addressed.
fn single_tag(event: &Event, name: &str) -> Option<String> {
let matches = event
.tags
.iter()
.filter_map(|tag| {
let values = tag.as_slice();
(values.len() >= 2 && values.first().is_some_and(|value| value == name))
.then(|| values[1].clone())
})
.collect::<Vec<_>>();
(matches.len() == 1).then(|| matches[0].clone())
}
#[cfg(test)]
#[path = "team_catalog_tests.rs"]
mod tests;