1use chrono::{DateTime, Utc};
40use serde::{Deserialize, Serialize};
41
42pub mod rooms;
50
51pub mod task {
52 pub const MEMBERS_LIST: &str = "https://trusttasks.org/spec/vtc/members/list/0.1";
53 pub const MEMBERS_UPDATE: &str = "https://trusttasks.org/spec/vtc/members/update/0.1";
54 pub const MEMBERS_ADMIN_REMOVE: &str =
55 "https://trusttasks.org/spec/vtc/members/admin-remove/0.1";
56 pub const JOIN_REQUESTS_LIST: &str = "https://trusttasks.org/spec/vtc/join-requests/list/0.1";
57 pub const JOIN_REQUESTS_DECIDE: &str =
58 "https://trusttasks.org/spec/vtc/join-requests/decide/0.1";
59 pub const POLICY_LIST: &str = "https://trusttasks.org/spec/policy/list/0.2";
60 pub const POLICY_GET: &str = "https://trusttasks.org/spec/policy/get/0.1";
61 pub const POLICY_UPSERT: &str = "https://trusttasks.org/spec/policy/upsert/0.2";
62 pub const POLICY_ACTIVATE: &str = "https://trusttasks.org/spec/policy/activate/0.1";
63}
64
65pub use vta_sdk::protocols::join_requests;
68
69#[derive(Debug, thiserror::Error)]
71pub enum VtcError {
72 #[error("not authenticated — call VtcClient::connect first")]
75 NotAuthenticated,
76 #[error("VTC returned HTTP {status}: {body}")]
78 Http { status: u16, body: String },
79 #[error("invalid request url: {0}")]
81 Url(String),
82 #[error("transport error: {0}")]
84 Transport(#[from] reqwest::Error),
85 #[error("authentication failed: {0}")]
87 Auth(#[from] vta_sdk::error::VtaError),
88 #[error("unsupported by this client: {0}")]
91 Unsupported(&'static str),
92 #[error("could not sign the request document: {0}")]
95 Signing(String),
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
102#[serde(rename_all = "camelCase")]
103pub struct MemberRecord {
104 pub did: String,
106 pub role: String,
109 #[serde(default)]
110 pub label: Option<String>,
111 pub joined_at: DateTime<Utc>,
112 #[serde(default)]
115 pub status_list_index: Option<u32>,
116 #[serde(default)]
118 pub current_vmc_id: Option<String>,
119 #[serde(default)]
120 pub personhood: bool,
121 #[serde(default)]
122 pub joined_via_invitation: bool,
123 #[serde(default)]
126 pub extensions: serde_json::Value,
127}
128
129#[derive(Debug, Clone, Deserialize)]
137#[serde(rename_all = "camelCase")]
138struct Page<T> {
139 items: Vec<T>,
140 next_cursor: Option<String>,
141}
142
143#[derive(Debug, Clone, Deserialize, PartialEq)]
145#[serde(rename_all = "camelCase")]
146pub struct JoinRequestSummary {
147 pub id: String,
149 pub applicant_did: String,
151 pub status: String,
153 pub submitted_at: DateTime<Utc>,
154}
155
156#[derive(Debug, Clone, Deserialize, PartialEq)]
158#[serde(rename_all = "camelCase")]
159pub struct DecideResult {
160 pub request_id: String,
161 pub status: String,
162 #[serde(default)]
164 pub vmc: Option<serde_json::Value>,
165 #[serde(default)]
167 pub role_vec: Option<serde_json::Value>,
168}
169
170#[derive(Debug, Clone, Deserialize, PartialEq)]
173#[serde(rename_all = "camelCase")]
174pub struct RemoveResult {
175 pub did: String,
176 pub disposition: String,
178 pub removed: bool,
179}
180
181#[derive(Debug, Clone)]
184pub struct VtcClient {
185 http: reqwest::Client,
186 base_url: String,
189 vtc_did: String,
191 token: Option<String>,
193}
194
195impl VtcClient {
196 pub async fn connect(
202 base_url: &str,
203 vtc_did: &str,
204 client_did: &str,
205 private_key_multibase: &str,
206 ) -> Result<Self, VtcError> {
207 let http = vta_sdk::http::rest_client();
210 let base_url = base_url.trim_end_matches('/').to_string();
211 let auth = vta_sdk::auth_light::challenge_response_light(
212 &http,
213 &base_url,
214 client_did,
215 private_key_multibase,
216 vtc_did,
217 )
218 .await?;
219 Ok(Self {
220 http,
221 base_url,
222 vtc_did: vtc_did.to_string(),
223 token: Some(auth.access_token),
224 })
225 }
226
227 pub fn with_token(base_url: &str, vtc_did: &str, token: impl Into<String>) -> Self {
230 Self {
231 http: vta_sdk::http::rest_client(),
232 base_url: base_url.trim_end_matches('/').to_string(),
233 vtc_did: vtc_did.to_string(),
234 token: Some(token.into()),
235 }
236 }
237
238 pub fn anonymous(base_url: &str, vtc_did: &str) -> Self {
250 Self {
251 http: vta_sdk::http::rest_client(),
252 base_url: base_url.trim_end_matches('/').to_string(),
253 vtc_did: vtc_did.to_string(),
254 token: None,
255 }
256 }
257
258 pub fn vtc_did(&self) -> &str {
260 &self.vtc_did
261 }
262
263 fn tt(
271 &self,
272 method: reqwest::Method,
273 url: impl reqwest::IntoUrl,
274 task: &str,
275 ) -> Result<reqwest::RequestBuilder, VtcError> {
276 let token = self.token()?;
277 Ok(self
278 .http
279 .request(method, url)
280 .header("Trust-Task", task)
281 .bearer_auth(token))
282 }
283
284 pub async fn list_members(&self, role: Option<&str>) -> Result<Vec<MemberRecord>, VtcError> {
288 let mut out: Vec<MemberRecord> = Vec::new();
289 let mut cursor: Option<String> = None;
290
291 loop {
292 let mut params: Vec<(&str, &str)> = Vec::new();
293 if let Some(role) = role {
294 params.push(("role", role));
295 }
296 if let Some(cursor) = &cursor {
297 params.push(("cursor", cursor.as_str()));
298 }
299 let url =
300 reqwest::Url::parse_with_params(&format!("{}/members", self.base_url), ¶ms)
301 .map_err(|e| VtcError::Url(e.to_string()))?;
302
303 let resp = self
304 .tt(reqwest::Method::GET, url, task::MEMBERS_LIST)?
305 .send()
306 .await?;
307 if !resp.status().is_success() {
308 let status = resp.status().as_u16();
309 let body = resp.text().await.unwrap_or_default();
310 return Err(VtcError::Http { status, body });
311 }
312
313 let page: Page<MemberRecord> = resp.json().await?;
314 out.extend(page.items);
315 match page.next_cursor {
316 Some(next) => cursor = Some(next),
317 None => break,
318 }
319 }
320 Ok(out)
321 }
322
323 pub async fn list_join_requests(
327 &self,
328 status: Option<&str>,
329 ) -> Result<Vec<JoinRequestSummary>, VtcError> {
330 let mut out: Vec<JoinRequestSummary> = Vec::new();
331 let mut cursor: Option<String> = None;
332 loop {
333 let mut params: Vec<(&str, &str)> = Vec::new();
334 if let Some(status) = status {
335 params.push(("status", status));
336 }
337 if let Some(cursor) = &cursor {
338 params.push(("cursor", cursor.as_str()));
339 }
340 let url = reqwest::Url::parse_with_params(
341 &format!("{}/join-requests", self.base_url),
342 ¶ms,
343 )
344 .map_err(|e| VtcError::Url(e.to_string()))?;
345
346 let resp = self
347 .tt(reqwest::Method::GET, url, task::JOIN_REQUESTS_LIST)?
348 .send()
349 .await?;
350 if !resp.status().is_success() {
351 let status = resp.status().as_u16();
352 let body = resp.text().await.unwrap_or_default();
353 return Err(VtcError::Http { status, body });
354 }
355 let page: Page<JoinRequestSummary> = resp.json().await?;
356 out.extend(page.items);
357 match page.next_cursor {
358 Some(next) => cursor = Some(next),
359 None => break,
360 }
361 }
362 Ok(out)
363 }
364
365 pub async fn approve_join(&self, request_id: &str) -> Result<DecideResult, VtcError> {
369 self.decide(request_id, "approved", None).await
370 }
371
372 pub async fn reject_join(
375 &self,
376 request_id: &str,
377 reason: Option<&str>,
378 ) -> Result<DecideResult, VtcError> {
379 self.decide(request_id, "rejected", reason).await
380 }
381
382 async fn decide(
391 &self,
392 request_id: &str,
393 decision: &str,
394 reason: Option<&str>,
395 ) -> Result<DecideResult, VtcError> {
396 let url = format!("{}/join-requests/{request_id}/decide", self.base_url);
397 let mut body = serde_json::json!({ "decision": decision });
398 if let Some(reason) = reason {
399 body["reason"] = serde_json::json!(reason);
400 }
401 let resp = self
402 .tt(reqwest::Method::POST, url, task::JOIN_REQUESTS_DECIDE)?
403 .json(&body)
404 .send()
405 .await?;
406 if !resp.status().is_success() {
407 let status = resp.status().as_u16();
408 let body = resp.text().await.unwrap_or_default();
409 return Err(VtcError::Http { status, body });
410 }
411 Ok(resp.json().await?)
412 }
413
414 pub async fn remove_member(
419 &self,
420 did: &str,
421 reason: Option<&str>,
422 ) -> Result<RemoveResult, VtcError> {
423 let url = format!("{}/members/{did}", self.base_url);
424 let mut req = self.tt(reqwest::Method::DELETE, url, task::MEMBERS_ADMIN_REMOVE)?;
425 if let Some(reason) = reason {
426 req = req.json(&serde_json::json!({ "reason": reason }));
427 }
428 let resp = req.send().await?;
429 if !resp.status().is_success() {
430 let status = resp.status().as_u16();
431 let body = resp.text().await.unwrap_or_default();
432 return Err(VtcError::Http { status, body });
433 }
434 Ok(resp.json().await?)
435 }
436
437 pub async fn update_member_extensions(
442 &self,
443 did: &str,
444 extensions: serde_json::Value,
445 ) -> Result<(), VtcError> {
446 let resp = self
447 .tt(
448 reqwest::Method::PATCH,
449 format!("{}/members/{did}", self.base_url),
450 task::MEMBERS_UPDATE,
451 )?
452 .json(&serde_json::json!({ "extensions": extensions }))
453 .send()
454 .await?;
455 if !resp.status().is_success() {
456 let status = resp.status().as_u16();
457 let body = resp.text().await.unwrap_or_default();
458 return Err(VtcError::Http { status, body });
459 }
460 Ok(())
461 }
462
463 pub async fn submit_join(
496 &self,
497 body: &join_requests::JoinRequestSubmitBody,
498 applicant_did: &str,
499 private_key_multibase: &str,
500 ) -> Result<join_requests::VerdictResponse, VtcError> {
501 let payload = serde_json::to_value(body)
502 .map_err(|e| VtcError::Url(format!("serialise submit payload: {e}")))?;
503 let doc = vta_sdk::trust_task_sign::build_signed(
504 join_requests::JOIN_REQUEST_SUBMIT_TYPE,
505 payload,
506 applicant_did,
507 private_key_multibase,
508 &self.vtc_did,
509 )
510 .await
511 .map_err(|e| VtcError::Signing(e.to_string()))?;
512
513 let resp = self
517 .http
518 .post(format!("{}/trust-tasks", self.base_url))
519 .header("content-type", "application/json")
520 .body(doc)
521 .send()
522 .await?;
523 if !resp.status().is_success() {
524 let status = resp.status().as_u16();
525 let body = resp.text().await.unwrap_or_default();
526 return Err(VtcError::Http { status, body });
527 }
528
529 let text = resp.text().await?;
532 let response_doc: trust_tasks_rs::TrustTask<serde_json::Value> =
533 serde_json::from_str(&text).map_err(|e| VtcError::Http {
534 status: 200,
535 body: format!(
536 "unexpected submit response (not a Trust Task document): {e}: {text}"
537 ),
538 })?;
539 serde_json::from_value(response_doc.payload).map_err(|e| VtcError::Http {
540 status: 200,
541 body: format!("submit response payload is not a VerdictResponse: {e}"),
542 })
543 }
544
545 pub async fn list_policies(&self) -> Result<Vec<serde_json::Value>, VtcError> {
547 let mut out = Vec::new();
548 let mut cursor: Option<String> = None;
549 loop {
550 let mut params: Vec<(&str, &str)> = Vec::new();
551 if let Some(cursor) = &cursor {
552 params.push(("cursor", cursor.as_str()));
553 }
554 let url =
555 reqwest::Url::parse_with_params(&format!("{}/policies", self.base_url), ¶ms)
556 .map_err(|e| VtcError::Url(e.to_string()))?;
557 let resp = self
558 .tt(reqwest::Method::GET, url, task::POLICY_LIST)?
559 .send()
560 .await?;
561 if !resp.status().is_success() {
562 let status = resp.status().as_u16();
563 let body = resp.text().await.unwrap_or_default();
564 return Err(VtcError::Http { status, body });
565 }
566 let page: Page<serde_json::Value> = resp.json().await?;
567 out.extend(page.items);
568 match page.next_cursor {
569 Some(next) => cursor = Some(next),
570 None => break,
571 }
572 }
573 Ok(out)
574 }
575
576 pub async fn get_policy(&self, id: &str) -> Result<serde_json::Value, VtcError> {
578 self.get_json(&format!("policies/{id}"), task::POLICY_GET)
579 .await
580 }
581
582 pub async fn upload_policy(
586 &self,
587 purpose: &str,
588 rego_source: &str,
589 ) -> Result<serde_json::Value, VtcError> {
590 self.post_json(
591 "policies",
592 task::POLICY_UPSERT,
593 &serde_json::json!({ "purpose": purpose, "regoSource": rego_source }),
594 )
595 .await
596 }
597
598 pub async fn activate_policy(&self, id: &str) -> Result<serde_json::Value, VtcError> {
601 self.post_json(
602 &format!("policies/{id}/activate"),
603 task::POLICY_ACTIVATE,
604 &serde_json::json!({}),
605 )
606 .await
607 }
608
609 async fn get_json(&self, path: &str, task: &str) -> Result<serde_json::Value, VtcError> {
611 let resp = self
612 .tt(
613 reqwest::Method::GET,
614 format!("{}/{path}", self.base_url),
615 task,
616 )?
617 .send()
618 .await?;
619 if !resp.status().is_success() {
620 let status = resp.status().as_u16();
621 let body = resp.text().await.unwrap_or_default();
622 return Err(VtcError::Http { status, body });
623 }
624 Ok(resp.json().await?)
625 }
626
627 async fn post_json(
630 &self,
631 path: &str,
632 task: &str,
633 body: &serde_json::Value,
634 ) -> Result<serde_json::Value, VtcError> {
635 let resp = self
636 .tt(
637 reqwest::Method::POST,
638 format!("{}/{path}", self.base_url),
639 task,
640 )?
641 .json(body)
642 .send()
643 .await?;
644 if !resp.status().is_success() {
645 let status = resp.status().as_u16();
646 let body = resp.text().await.unwrap_or_default();
647 return Err(VtcError::Http { status, body });
648 }
649 Ok(resp.json().await?)
650 }
651
652 fn token(&self) -> Result<&str, VtcError> {
654 self.token.as_deref().ok_or(VtcError::NotAuthenticated)
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 #[test]
663 fn member_page_deserializes_from_vtc_shape() {
664 let json = serde_json::json!({
667 "items": [{
668 "did": "did:key:z6MkStaffVta",
669 "role": "member",
670 "label": "Staff VTA",
671 "joinedAt": "2026-06-23T00:00:00Z",
672 "publishConsent": true,
673 "departurePreference": "tombstone",
674 "statusListIndex": 7,
675 "currentVmcId": "urn:uuid:vmc-1",
676 "extensions": {},
677 "personhood": false,
678 "joinedViaInvitation": true
679 }],
680 "nextCursor": null
681 });
682 let page: Page<MemberRecord> = serde_json::from_value(json).unwrap();
683 assert_eq!(page.items.len(), 1);
684 let m = &page.items[0];
685 assert_eq!(m.did, "did:key:z6MkStaffVta");
686 assert_eq!(m.role, "member");
687 assert_eq!(m.status_list_index, Some(7));
688 assert_eq!(m.current_vmc_id.as_deref(), Some("urn:uuid:vmc-1"));
689 assert!(m.joined_via_invitation);
690 assert!(page.next_cursor.is_none());
691 }
692
693 #[tokio::test]
694 async fn list_members_without_token_is_not_authenticated() {
695 let client = VtcClient {
696 http: reqwest::Client::new(),
697 base_url: "https://vtc.example.com/v1".into(),
698 vtc_did: "did:web:vtc.example.com".into(),
699 token: None,
700 };
701 let err = client.list_members(None).await;
703 assert!(matches!(err, Err(VtcError::NotAuthenticated)), "{err:?}");
704 }
705
706 #[test]
707 fn decide_result_deserializes_camel_case() {
708 let json = serde_json::json!({
709 "requestId": "11111111-1111-1111-1111-111111111111",
710 "status": "approved",
711 "vmc": { "type": ["VerifiableCredential", "MembershipCredential"] },
712 "roleVec": null
713 });
714 let d: DecideResult = serde_json::from_value(json).unwrap();
715 assert_eq!(d.request_id, "11111111-1111-1111-1111-111111111111");
716 assert_eq!(d.status, "approved");
717 assert!(d.vmc.is_some());
718 assert!(d.role_vec.is_none());
719 }
720
721 #[test]
722 fn join_request_and_remove_results_deserialize() {
723 let jr: JoinRequestSummary = serde_json::from_value(serde_json::json!({
724 "id": "22222222-2222-2222-2222-222222222222",
725 "applicantDid": "did:key:z6MkApplicant",
726 "status": "pending",
727 "submittedAt": "2026-06-23T00:00:00Z"
728 }))
729 .unwrap();
730 assert_eq!(jr.applicant_did, "did:key:z6MkApplicant");
731 assert_eq!(jr.status, "pending");
732
733 let rm: RemoveResult = serde_json::from_value(serde_json::json!({
734 "did": "did:key:z6MkGone",
735 "disposition": "tombstone",
736 "removed": true
737 }))
738 .unwrap();
739 assert_eq!(rm.did, "did:key:z6MkGone");
740 assert!(rm.removed);
741 }
742
743 #[tokio::test]
744 async fn admin_methods_without_token_are_not_authenticated() {
745 let client = VtcClient {
746 http: reqwest::Client::new(),
747 base_url: "https://vtc.example.com/v1".into(),
748 vtc_did: "did:web:vtc.example.com".into(),
749 token: None,
750 };
751 assert!(matches!(
752 client.list_join_requests(Some("pending")).await,
753 Err(VtcError::NotAuthenticated)
754 ));
755 assert!(matches!(
756 client.approve_join("req-1").await,
757 Err(VtcError::NotAuthenticated)
758 ));
759 assert!(matches!(
760 client.remove_member("did:key:x", Some("reason")).await,
761 Err(VtcError::NotAuthenticated)
762 ));
763 }
764
765 #[test]
766 fn member_extensions_default_and_parse() {
767 let none: MemberRecord = serde_json::from_value(serde_json::json!({
768 "did": "did:key:z", "role": "member", "joinedAt": "2026-06-23T00:00:00Z"
769 }))
770 .unwrap();
771 assert!(none.extensions.is_null());
772 let with: MemberRecord = serde_json::from_value(serde_json::json!({
773 "did": "did:key:z", "role": "member", "joinedAt": "2026-06-23T00:00:00Z",
774 "extensions": { "fleet_index": 3 }
775 }))
776 .unwrap();
777 assert_eq!(with.extensions["fleet_index"], 3);
778 }
779
780 #[tokio::test]
781 async fn policy_admin_methods_without_token_are_not_authenticated() {
782 let client = VtcClient {
783 http: reqwest::Client::new(),
784 base_url: "https://vtc.example.com/v1".into(),
785 vtc_did: "did:web:vtc.example.com".into(),
786 token: None,
787 };
788 assert!(matches!(
789 client.list_policies().await,
790 Err(VtcError::NotAuthenticated)
791 ));
792 assert!(matches!(
793 client.get_policy("p1").await,
794 Err(VtcError::NotAuthenticated)
795 ));
796 assert!(matches!(
797 client.upload_policy("join", "package x").await,
798 Err(VtcError::NotAuthenticated)
799 ));
800 assert!(matches!(
801 client.activate_policy("p1").await,
802 Err(VtcError::NotAuthenticated)
803 ));
804 assert!(matches!(
805 client
806 .update_member_extensions("did:key:z", serde_json::json!({}))
807 .await,
808 Err(VtcError::NotAuthenticated)
809 ));
810 }
811}