|
| 1 | +//! Native team-catalog fetch and trust-boundary projection. |
| 2 | +//! |
| 3 | +//! The renderer owns presentation/linkage to local teams. Relay paging, |
| 4 | +//! signature verification, NIP-33 head selection, and untrusted-content |
| 5 | +//! parsing stay here — structurally the persona-catalog equivalent |
| 6 | +//! (`persona_catalog.rs`) with kind 30178 and the team content parser swapped |
| 7 | +//! in, so a catalog refresh crosses IPC once and never verifies a signature on |
| 8 | +//! the webview thread. |
| 9 | +//! |
| 10 | +//! Content parsing reuses `managed_agents::team_catalog::team_catalog_content_from_event` |
| 11 | +//! — the same all-or-nothing parse `add_team_from_catalog` re-runs at add time, |
| 12 | +//! so a head this command projects is exactly a head the backend will accept. |
| 13 | +
|
| 14 | +use std::{collections::HashMap, time::Duration}; |
| 15 | + |
| 16 | +use buzz_core_pkg::kind::{event_is_shared, KIND_TEAM_CATALOG}; |
| 17 | +use nostr::Event; |
| 18 | +use serde::Serialize; |
| 19 | +use tauri::State; |
| 20 | + |
| 21 | +use crate::{ |
| 22 | + app_state::AppState, |
| 23 | + managed_agents::team_catalog::{team_catalog_content_from_event, TeamCatalogContent}, |
| 24 | + native_relay_client::NativeRelayClient, |
| 25 | +}; |
| 26 | + |
| 27 | +const CATALOG_PAGE_SIZE: usize = 500; |
| 28 | +const MAX_CATALOG_PAGES: usize = 40; |
| 29 | +const PAGE_TIMEOUT: Duration = Duration::from_secs(10); |
| 30 | + |
| 31 | +#[derive(Debug, PartialEq, Serialize)] |
| 32 | +#[serde(rename_all = "camelCase")] |
| 33 | +pub(crate) struct TeamCatalogPublication { |
| 34 | + event_id: String, |
| 35 | + owner_pubkey: String, |
| 36 | + team_d_tag: String, |
| 37 | + name: String, |
| 38 | + #[serde(skip_serializing_if = "Option::is_none")] |
| 39 | + description: Option<String>, |
| 40 | + #[serde(skip_serializing_if = "Option::is_none")] |
| 41 | + instructions: Option<String>, |
| 42 | + members: Vec<TeamCatalogMemberProjection>, |
| 43 | +} |
| 44 | + |
| 45 | +#[derive(Debug, PartialEq, Serialize)] |
| 46 | +#[serde(rename_all = "camelCase")] |
| 47 | +struct TeamCatalogMemberProjection { |
| 48 | + member_key: String, |
| 49 | + display_name: String, |
| 50 | + system_prompt: String, |
| 51 | + avatar_url: Option<String>, |
| 52 | + runtime: Option<String>, |
| 53 | + model: Option<String>, |
| 54 | + provider: Option<String>, |
| 55 | +} |
| 56 | + |
| 57 | +/// Fetches the active community's relay-confirmed team catalog. |
| 58 | +/// |
| 59 | +/// The command accepts no relay or identity input: both are snapshotted from |
| 60 | +/// `AppState`, then checked again before return so an in-flight old-community |
| 61 | +/// response cannot populate the new community's query cache. |
| 62 | +#[tauri::command] |
| 63 | +pub(crate) async fn fetch_team_catalog( |
| 64 | + state: State<'_, AppState>, |
| 65 | + relay_client: State<'_, NativeRelayClient>, |
| 66 | +) -> Result<Vec<TeamCatalogPublication>, String> { |
| 67 | + let keys = state.signing_keys()?; |
| 68 | + let owner = keys.public_key().to_hex(); |
| 69 | + let relay_url = crate::relay::relay_ws_url_with_override(&state); |
| 70 | + let session = relay_client.session(relay_url.clone(), keys).await; |
| 71 | + let mut by_id = HashMap::new(); |
| 72 | + let mut until = None; |
| 73 | + |
| 74 | + for _ in 0..MAX_CATALOG_PAGES { |
| 75 | + let mut filter = serde_json::json!({ |
| 76 | + "kinds": [KIND_TEAM_CATALOG], |
| 77 | + "limit": CATALOG_PAGE_SIZE, |
| 78 | + }); |
| 79 | + if let Some(until) = until { |
| 80 | + filter["until"] = serde_json::json!(until); |
| 81 | + } |
| 82 | + let page = session.fetch_events(filter, PAGE_TIMEOUT).await?; |
| 83 | + let page_len = page.len(); |
| 84 | + // Schnorr verification is CPU-bound. Keep the complete page off the |
| 85 | + // async executor (and therefore off Tauri command scheduling). |
| 86 | + let verified = tauri::async_runtime::spawn_blocking(move || { |
| 87 | + page.into_iter() |
| 88 | + .filter(|event| event.verify().is_ok()) |
| 89 | + .collect::<Vec<_>>() |
| 90 | + }) |
| 91 | + .await |
| 92 | + .map_err(|error| format!("catalog signature verification failed: {error}"))?; |
| 93 | + |
| 94 | + let progress = merge_verified_page(&mut by_id, page_len, verified); |
| 95 | + match progress { |
| 96 | + PageProgress::Done => break, |
| 97 | + PageProgress::Next(next_until) => until = Some(next_until), |
| 98 | + } |
| 99 | + } |
| 100 | + |
| 101 | + let current_keys = state.signing_keys()?; |
| 102 | + if current_keys.public_key().to_hex() != owner |
| 103 | + || crate::relay::relay_ws_url_with_override(&state) != relay_url |
| 104 | + { |
| 105 | + return Err("team catalog scope changed while fetching".to_string()); |
| 106 | + } |
| 107 | + |
| 108 | + Ok(publications_from_verified_events( |
| 109 | + by_id.into_values().collect(), |
| 110 | + )) |
| 111 | +} |
| 112 | + |
| 113 | +#[derive(Debug, PartialEq)] |
| 114 | +enum PageProgress { |
| 115 | + Done, |
| 116 | + Next(u64), |
| 117 | +} |
| 118 | + |
| 119 | +fn merge_verified_page( |
| 120 | + by_id: &mut HashMap<String, Event>, |
| 121 | + wire_page_len: usize, |
| 122 | + verified: Vec<Event>, |
| 123 | +) -> PageProgress { |
| 124 | + let size_before = by_id.len(); |
| 125 | + let oldest = verified |
| 126 | + .iter() |
| 127 | + .map(|event| event.created_at.as_secs()) |
| 128 | + .min(); |
| 129 | + for event in verified { |
| 130 | + by_id.insert(event.id.to_hex(), event); |
| 131 | + } |
| 132 | + |
| 133 | + // A short page is the end of the catalog; a page of only repeats means the |
| 134 | + // inclusive `until` cursor cannot advance past tied timestamps. |
| 135 | + if wire_page_len < CATALOG_PAGE_SIZE || by_id.len() == size_before { |
| 136 | + return PageProgress::Done; |
| 137 | + } |
| 138 | + // A full page of invalid signatures cannot supply a trusted cursor. |
| 139 | + oldest.map_or(PageProgress::Done, PageProgress::Next) |
| 140 | +} |
| 141 | + |
| 142 | +fn publications_from_verified_events(mut events: Vec<Event>) -> Vec<TeamCatalogPublication> { |
| 143 | + events.sort_by(|left, right| { |
| 144 | + right |
| 145 | + .created_at |
| 146 | + .cmp(&left.created_at) |
| 147 | + .then_with(|| left.id.cmp(&right.id)) |
| 148 | + }); |
| 149 | + let mut claimed = std::collections::HashSet::new(); |
| 150 | + let mut publications = Vec::new(); |
| 151 | + |
| 152 | + for event in events { |
| 153 | + if event.kind.as_u16() as u32 != KIND_TEAM_CATALOG { |
| 154 | + continue; |
| 155 | + } |
| 156 | + let Some(team_d_tag) = single_tag(&event, "d") else { |
| 157 | + continue; |
| 158 | + }; |
| 159 | + if team_d_tag.is_empty() { |
| 160 | + continue; |
| 161 | + } |
| 162 | + let owner_pubkey = event.pubkey.to_hex().to_ascii_lowercase(); |
| 163 | + let coordinate = (owner_pubkey.clone(), team_d_tag.clone()); |
| 164 | + if !claimed.insert(coordinate) { |
| 165 | + continue; |
| 166 | + } |
| 167 | + |
| 168 | + // Claim happens before visibility or parsing. A valid newest unshared |
| 169 | + // or malformed head is still the NIP-33 head and must not resurrect an |
| 170 | + // older shared definition. |
| 171 | + if !event_is_shared(&event) { |
| 172 | + continue; |
| 173 | + } |
| 174 | + // All-or-nothing parse, identical to the add-time re-fetch: a team with |
| 175 | + // any invalid member cannot be adopted, so a partial projection would |
| 176 | + // only offer an un-addable entry. |
| 177 | + let Ok(content) = team_catalog_content_from_event(&event) else { |
| 178 | + continue; |
| 179 | + }; |
| 180 | + publications.push(publication( |
| 181 | + event.id.to_hex(), |
| 182 | + owner_pubkey, |
| 183 | + team_d_tag, |
| 184 | + content, |
| 185 | + )); |
| 186 | + } |
| 187 | + publications |
| 188 | +} |
| 189 | + |
| 190 | +fn publication( |
| 191 | + event_id: String, |
| 192 | + owner_pubkey: String, |
| 193 | + team_d_tag: String, |
| 194 | + content: TeamCatalogContent, |
| 195 | +) -> TeamCatalogPublication { |
| 196 | + TeamCatalogPublication { |
| 197 | + event_id, |
| 198 | + owner_pubkey, |
| 199 | + team_d_tag, |
| 200 | + name: content.name, |
| 201 | + description: content.description, |
| 202 | + instructions: content.instructions, |
| 203 | + members: content |
| 204 | + .members |
| 205 | + .into_iter() |
| 206 | + .map(|member| TeamCatalogMemberProjection { |
| 207 | + member_key: member.member_key, |
| 208 | + display_name: member.display_name, |
| 209 | + system_prompt: member.system_prompt.unwrap_or_default(), |
| 210 | + avatar_url: member.avatar_url, |
| 211 | + runtime: member.runtime, |
| 212 | + model: member.model, |
| 213 | + provider: member.provider, |
| 214 | + }) |
| 215 | + .collect(), |
| 216 | + } |
| 217 | +} |
| 218 | + |
| 219 | +/// A tag's value, but only when the event carries exactly one of that tag. |
| 220 | +/// |
| 221 | +/// Ambiguity is absence: the relay admits exactly one bounded `d` tag, so a |
| 222 | +/// multi-`d` event is malformed and picking the first would resolve a |
| 223 | +/// different coordinate than the publisher addressed. |
| 224 | +fn single_tag(event: &Event, name: &str) -> Option<String> { |
| 225 | + let matches = event |
| 226 | + .tags |
| 227 | + .iter() |
| 228 | + .filter_map(|tag| { |
| 229 | + let values = tag.as_slice(); |
| 230 | + (values.len() >= 2 && values.first().is_some_and(|value| value == name)) |
| 231 | + .then(|| values[1].clone()) |
| 232 | + }) |
| 233 | + .collect::<Vec<_>>(); |
| 234 | + (matches.len() == 1).then(|| matches[0].clone()) |
| 235 | +} |
| 236 | + |
| 237 | +#[cfg(test)] |
| 238 | +#[path = "team_catalog_tests.rs"] |
| 239 | +mod tests; |
0 commit comments