diff --git a/bobbin/crates/xrpc/src/lib.rs b/bobbin/crates/xrpc/src/lib.rs index 96f50bd2d..d23135119 100644 --- a/bobbin/crates/xrpc/src/lib.rs +++ b/bobbin/crates/xrpc/src/lib.rs @@ -1019,6 +1019,19 @@ struct RecordView { value: V, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CollaboratorListItem { + subject: Did, + added_by: Did, + created_at: Datetime, + effective_since: Datetime, + #[serde(skip_serializing_if = "Option::is_none")] + uri: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + cid: Option>, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct StatefulItem { @@ -2105,6 +2118,44 @@ where )) } +fn collaborator_list_item( + view: RecordView>, +) -> Result, XrpcError> { + let RecordView { uri, cid, value } = view; + let Some(added_by) = source_authority_did(&uri) else { + return Ok(None); + }; + Ok(Some(CollaboratorListItem { + subject: value.subject, + added_by, + created_at: value.created_at.clone(), + effective_since: value.created_at, + uri: Some(uri), + cid, + })) +} + +fn collaborator_list_response( + state: &AppState, + page: EdgePage, + nsid: Nsid, +) -> Result { + let permit = state.heavy_permit()?; + let views = hydrate_record_stream::>( + state, + nsid, + page.items, + HitProvenance::Indexed, + ) + .try_filter_map(|view| async move { collaborator_list_item(view) }); + Ok(json_stream::( + "items", + views, + paged_tail(page.next.map(PageToken::encode_token), page.total), + permit, + )) +} + async fn list_records( state: &AppState, q: TypedListQuery, @@ -2380,7 +2431,8 @@ async fn list_collaborators( State(state): State, XrpcQuery(q): XrpcQuery>, ) -> Result { - list_records::, _>(&state, q).await + let (page, nsid) = record_edge_page::(&state, &q)?; + collaborator_list_response(&state, page, nsid) } async fn count_collaborators( @@ -3047,7 +3099,8 @@ async fn list_collaborators_by( State(state): State, XrpcQuery(q): XrpcQuery>, ) -> Result { - list_mirror::, _>(&state, q).await + let (page, nsid) = mirror_edge_page::(&state, &q)?; + collaborator_list_response(&state, page, nsid) } async fn count_collaborators_by( State(state): State, diff --git a/bobbin/crates/xrpc/tests/aggregation.rs b/bobbin/crates/xrpc/tests/aggregation.rs index 582a958d7..06ec7ae69 100644 --- a/bobbin/crates/xrpc/tests/aggregation.rs +++ b/bobbin/crates/xrpc/tests/aggregation.rs @@ -3271,8 +3271,14 @@ async fn knot_owned_collaborator_is_synthesized_without_slingshot() { assert_eq!(items.len(), 1); assert_eq!(items[0]["uri"], json!(source.as_ref())); assert!(items[0].get("cid").is_none()); - assert_eq!(items[0]["value"]["repo"], json!("did:plc:scallop")); - assert_eq!(items[0]["value"]["subject"], json!("did:plc:olaren")); + assert_eq!(items[0]["subject"], json!("did:plc:olaren")); + assert_eq!(items[0]["addedBy"], json!("did:plc:scallop")); + assert_eq!(items[0]["createdAt"], items[0]["effectiveSince"]); + assert!( + items[0]["effectiveSince"] + .as_str() + .is_some_and(|at| at.starts_with("2026-06-03T12:00")) + ); } #[tokio::test] @@ -3304,8 +3310,14 @@ async fn knot_owned_collaborator_lists_by_subject_did() { assert_eq!(items.len(), 1); assert_eq!(items[0]["uri"], json!(source.as_ref())); assert!(items[0].get("cid").is_none()); - assert_eq!(items[0]["value"]["repo"], json!("did:plc:scallop")); - assert_eq!(items[0]["value"]["subject"], json!("did:plc:olaren")); + assert_eq!(items[0]["subject"], json!("did:plc:olaren")); + assert_eq!(items[0]["addedBy"], json!("did:plc:scallop")); + assert_eq!(items[0]["createdAt"], items[0]["effectiveSince"]); + assert!( + items[0]["effectiveSince"] + .as_str() + .is_some_and(|at| at.starts_with("2026-06-03T12:00")) + ); } #[tokio::test]