diff --git a/bobbin/crates/xrpc/src/lib.rs b/bobbin/crates/xrpc/src/lib.rs index ae5c1292c..32f8150e5 100644 --- a/bobbin/crates/xrpc/src/lib.rs +++ b/bobbin/crates/xrpc/src/lib.rs @@ -2670,19 +2670,42 @@ fn parse_network_vouch_query(query: Option<&str>) -> Result) -> std::collections::HashSet { - edges - .sources_for(&EdgeKey::new( - nsid_static("sh.tangled.graph.vouch.by"), - SubjectRef::Did(actor.clone()), - )) - .iter() - .filter_map(|uri| Did::::new_owned(uri.rkey()?.as_ref()).ok()) - .map(|did| did.as_ref().to_owned()) +const VOUCH_KIND_VOUCH: &str = "vouch"; + +fn network_of(actor: &Did, vouched: Vec) -> std::collections::HashSet { + vouched + .into_iter() .chain(std::iter::once(actor.as_ref().to_owned())) .collect() } +async fn vouch_network( + state: &AppState, + actor: &Did, +) -> std::collections::HashSet { + let nsid = nsid_static("sh.tangled.graph.vouch"); + let sources = state.edges.sources_for(&EdgeKey::new( + nsid_static("sh.tangled.graph.vouch.by"), + SubjectRef::Did(actor.clone()), + )); + let vouched = stream::iter(sources) + .map(|uri| { + let nsid = nsid.clone(); + async move { + let subject = uri.rkey().map(|rkey| rkey.as_ref().to_owned())?; + let view = hydrate_record_view::>(state, &nsid, uri, 0) + .await + .ok()??; + (AsRef::::as_ref(&view.value.kind) == VOUCH_KIND_VOUCH).then_some(subject) + } + }) + .buffer_unordered(FETCH_CONCURRENCY) + .filter_map(|subject| async move { subject }) + .collect() + .await; + network_of(actor, vouched) +} + #[derive(Clone, Copy)] enum VouchList { Received, @@ -3022,7 +3045,7 @@ mod vouch_pagination_tests { &actor, 42, )); - let network = VouchNetworkFilter::new(vouch_network(&store, &viewer)); + let network = VouchNetworkFilter::new(network_of(&viewer, Vec::new())); let (items, _) = network_vouch_page( &store, &actor, @@ -3127,6 +3150,92 @@ mod vouch_pagination_tests { ); } + fn vouch_state(edges: Arc) -> AppState { + AppState::new( + Arc::new(bobbin_record_lru::LruRecordStore::new( + bobbin_record_lru::CacheCapacity::from_bytes(64 * 1024), + )), + SlingshotClient::with_default_http(url::Url::parse("http://127.0.0.1:1").unwrap()) + .unwrap(), + edges, + Arc::new(StateIndex::new(RuntimeHasher::default())), + Arc::new(StateIndex::new(RuntimeHasher::default())), + Arc::new(CoverageWatch::new()), + Arc::new( + KnotProxy::new( + bobbin_knot_proxy::KnotProxyConfig::default(), + bobbin_knot_proxy::KnotHttpConfig::default(), + Arc::new(bobbin_runtime::SystemClock::new()), + RuntimeHasher::default(), + ) + .unwrap(), + ), + Arc::new( + bobbin_search::SearchIndex::new( + bobbin_search::DEFAULT_WRITER_HEAP_BYTES, + Arc::new(bobbin_runtime::SystemClock::new()), + ) + .unwrap(), + ) as Arc, + Arc::new(RepoIdResolver::detached(RuntimeHasher::default())), + Arc::new(crate::default_directory()), + ) + } + + fn cache_vouch( + state: &AppState, + author: &Did, + subject: &Did, + kind: &str, + ) { + let at = uri(format!("at://{author}/sh.tangled.graph.vouch/{subject}")); + let value = serde_json::json!({ + "$type": "sh.tangled.graph.vouch", + "kind": kind, + "createdAt": "2026-05-01T00:00:00Z" + }); + state.records.put( + at.clone(), + Arc::new(RecordBody { + uri: at, + cid: Cid::new(b"bafyreieqygohnz2zqyvtvktbjpvhutphobcmbsnt4q5lc36ri7vpcmoz4i") + .unwrap(), + value: serde_json::to_vec(&value).unwrap().into(), + }), + ); + } + + #[tokio::test] + async fn a_denounced_did_stays_out_of_the_viewer_network() { + let edges = Arc::new(EdgeStore::new(RuntimeHasher::from_seeds(1, 2, 3, 4))); + let viewer = did("did:plc:viewer"); + let friend = did("did:plc:friend"); + let enemy = did("did:plc:enemy"); + edges.add(vouch_edge( + "sh.tangled.graph.vouch.by", + &viewer, + &viewer, + &friend, + 42, + )); + edges.add(vouch_edge( + "sh.tangled.graph.vouch.by", + &viewer, + &viewer, + &enemy, + 41, + )); + let state = vouch_state(edges); + cache_vouch(&state, &viewer, &friend, "vouch"); + cache_vouch(&state, &viewer, &enemy, "denounce"); + + let network = vouch_network(&state, &viewer).await; + + assert!(network.contains(friend.as_ref())); + assert!(!network.contains(enemy.as_ref())); + assert!(network.contains(viewer.as_ref())); + } + #[test] fn cursor_is_rejected_for_multiple_actors() { assert!(matches!( @@ -3149,7 +3258,7 @@ async fn list_network_vouches( let (page_items, next_cursor) = match auth.as_ref() { Some(auth) => { let viewer = auth.did().into_static(); - let network = VouchNetworkFilter::new(vouch_network(&state.edges, &viewer)); + let network = VouchNetworkFilter::new(vouch_network(&state, &viewer).await); network_vouch_pages(&state.edges, &q.actors, &network, q.start, q.limit) } None => (Vec::new(), None),