1use chrono::{DateTime, Utc};
40use serde::{Deserialize, Serialize};
41
42pub mod task {
50 pub const MEMBERS_LIST: &str = "https://trusttasks.org/spec/vtc/members/list/0.1";
51 pub const MEMBERS_UPDATE: &str = "https://trusttasks.org/spec/vtc/members/update/0.1";
52 pub const MEMBERS_ADMIN_REMOVE: &str =
53 "https://trusttasks.org/spec/vtc/members/admin-remove/0.1";
54 pub const JOIN_REQUESTS_LIST: &str = "https://trusttasks.org/spec/vtc/join-requests/list/0.1";
55 pub const JOIN_REQUESTS_DECIDE: &str =
56 "https://trusttasks.org/spec/vtc/join-requests/decide/0.1";
57 pub const POLICY_LIST: &str = "https://trusttasks.org/spec/policy/list/0.2";
58 pub const POLICY_GET: &str = "https://trusttasks.org/spec/policy/get/0.1";
59 pub const POLICY_UPSERT: &str = "https://trusttasks.org/spec/policy/upsert/0.2";
60 pub const POLICY_ACTIVATE: &str = "https://trusttasks.org/spec/policy/activate/0.1";
61}
62
63pub use vta_sdk::protocols::join_requests;
66
67#[derive(Debug, thiserror::Error)]
69pub enum VtcError {
70 #[error("not authenticated — call VtcClient::connect first")]
73 NotAuthenticated,
74 #[error("VTC returned HTTP {status}: {body}")]
76 Http { status: u16, body: String },
77 #[error("invalid request url: {0}")]
79 Url(String),
80 #[error("transport error: {0}")]
82 Transport(#[from] reqwest::Error),
83 #[error("authentication failed: {0}")]
85 Auth(#[from] vta_sdk::error::VtaError),
86 #[error("unsupported by this client: {0}")]
89 Unsupported(&'static str),
90 #[error("could not sign the request document: {0}")]
93 Signing(String),
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100#[serde(rename_all = "camelCase")]
101pub struct MemberRecord {
102 pub did: String,
104 pub role: String,
107 #[serde(default)]
108 pub label: Option<String>,
109 pub joined_at: DateTime<Utc>,
110 #[serde(default)]
113 pub status_list_index: Option<u32>,
114 #[serde(default)]
116 pub current_vmc_id: Option<String>,
117 #[serde(default)]
118 pub personhood: bool,
119 #[serde(default)]
120 pub joined_via_invitation: bool,
121 #[serde(default)]
124 pub extensions: serde_json::Value,
125}
126
127#[derive(Debug, Clone, Deserialize)]
130struct Page<T> {
131 items: Vec<T>,
132 next_cursor: Option<String>,
133}
134
135#[derive(Debug, Clone, Deserialize, PartialEq)]
137#[serde(rename_all = "camelCase")]
138pub struct JoinRequestSummary {
139 pub id: String,
141 pub applicant_did: String,
143 pub status: String,
145 pub submitted_at: DateTime<Utc>,
146}
147
148#[derive(Debug, Clone, Deserialize, PartialEq)]
150#[serde(rename_all = "camelCase")]
151pub struct DecideResult {
152 pub request_id: String,
153 pub status: String,
154 #[serde(default)]
156 pub vmc: Option<serde_json::Value>,
157 #[serde(default)]
159 pub role_vec: Option<serde_json::Value>,
160}
161
162#[derive(Debug, Clone, Deserialize, PartialEq)]
165#[serde(rename_all = "camelCase")]
166pub struct RemoveResult {
167 pub did: String,
168 pub disposition: String,
170 pub removed: bool,
171}
172
173#[derive(Debug, Clone)]
176pub struct VtcClient {
177 http: reqwest::Client,
178 base_url: String,
181 vtc_did: String,
183 token: Option<String>,
185}
186
187impl VtcClient {
188 pub async fn connect(
194 base_url: &str,
195 vtc_did: &str,
196 client_did: &str,
197 private_key_multibase: &str,
198 ) -> Result<Self, VtcError> {
199 let http = vta_sdk::http::rest_client();
202 let base_url = base_url.trim_end_matches('/').to_string();
203 let auth = vta_sdk::auth_light::challenge_response_light(
204 &http,
205 &base_url,
206 client_did,
207 private_key_multibase,
208 vtc_did,
209 )
210 .await?;
211 Ok(Self {
212 http,
213 base_url,
214 vtc_did: vtc_did.to_string(),
215 token: Some(auth.access_token),
216 })
217 }
218
219 pub fn with_token(base_url: &str, vtc_did: &str, token: impl Into<String>) -> Self {
222 Self {
223 http: vta_sdk::http::rest_client(),
224 base_url: base_url.trim_end_matches('/').to_string(),
225 vtc_did: vtc_did.to_string(),
226 token: Some(token.into()),
227 }
228 }
229
230 pub fn anonymous(base_url: &str, vtc_did: &str) -> Self {
242 Self {
243 http: vta_sdk::http::rest_client(),
244 base_url: base_url.trim_end_matches('/').to_string(),
245 vtc_did: vtc_did.to_string(),
246 token: None,
247 }
248 }
249
250 pub fn vtc_did(&self) -> &str {
252 &self.vtc_did
253 }
254
255 fn tt(
263 &self,
264 method: reqwest::Method,
265 url: impl reqwest::IntoUrl,
266 task: &str,
267 ) -> Result<reqwest::RequestBuilder, VtcError> {
268 let token = self.token()?;
269 Ok(self
270 .http
271 .request(method, url)
272 .header("Trust-Task", task)
273 .bearer_auth(token))
274 }
275
276 pub async fn list_members(&self, role: Option<&str>) -> Result<Vec<MemberRecord>, VtcError> {
280 let mut out: Vec<MemberRecord> = Vec::new();
281 let mut cursor: Option<String> = None;
282
283 loop {
284 let mut params: Vec<(&str, &str)> = Vec::new();
285 if let Some(role) = role {
286 params.push(("role", role));
287 }
288 if let Some(cursor) = &cursor {
289 params.push(("cursor", cursor.as_str()));
290 }
291 let url =
292 reqwest::Url::parse_with_params(&format!("{}/members", self.base_url), ¶ms)
293 .map_err(|e| VtcError::Url(e.to_string()))?;
294
295 let resp = self
296 .tt(reqwest::Method::GET, url, task::MEMBERS_LIST)?
297 .send()
298 .await?;
299 if !resp.status().is_success() {
300 let status = resp.status().as_u16();
301 let body = resp.text().await.unwrap_or_default();
302 return Err(VtcError::Http { status, body });
303 }
304
305 let page: Page<MemberRecord> = resp.json().await?;
306 out.extend(page.items);
307 match page.next_cursor {
308 Some(next) => cursor = Some(next),
309 None => break,
310 }
311 }
312 Ok(out)
313 }
314
315 pub async fn list_join_requests(
319 &self,
320 status: Option<&str>,
321 ) -> Result<Vec<JoinRequestSummary>, VtcError> {
322 let mut out: Vec<JoinRequestSummary> = Vec::new();
323 let mut cursor: Option<String> = None;
324 loop {
325 let mut params: Vec<(&str, &str)> = Vec::new();
326 if let Some(status) = status {
327 params.push(("status", status));
328 }
329 if let Some(cursor) = &cursor {
330 params.push(("cursor", cursor.as_str()));
331 }
332 let url = reqwest::Url::parse_with_params(
333 &format!("{}/join-requests", self.base_url),
334 ¶ms,
335 )
336 .map_err(|e| VtcError::Url(e.to_string()))?;
337
338 let resp = self
339 .tt(reqwest::Method::GET, url, task::JOIN_REQUESTS_LIST)?
340 .send()
341 .await?;
342 if !resp.status().is_success() {
343 let status = resp.status().as_u16();
344 let body = resp.text().await.unwrap_or_default();
345 return Err(VtcError::Http { status, body });
346 }
347 let page: Page<JoinRequestSummary> = resp.json().await?;
348 out.extend(page.items);
349 match page.next_cursor {
350 Some(next) => cursor = Some(next),
351 None => break,
352 }
353 }
354 Ok(out)
355 }
356
357 pub async fn approve_join(&self, request_id: &str) -> Result<DecideResult, VtcError> {
361 self.decide(request_id, "approved", None).await
362 }
363
364 pub async fn reject_join(
367 &self,
368 request_id: &str,
369 reason: Option<&str>,
370 ) -> Result<DecideResult, VtcError> {
371 self.decide(request_id, "rejected", reason).await
372 }
373
374 async fn decide(
383 &self,
384 request_id: &str,
385 decision: &str,
386 reason: Option<&str>,
387 ) -> Result<DecideResult, VtcError> {
388 let url = format!("{}/join-requests/{request_id}/decide", self.base_url);
389 let mut body = serde_json::json!({ "decision": decision });
390 if let Some(reason) = reason {
391 body["reason"] = serde_json::json!(reason);
392 }
393 let resp = self
394 .tt(reqwest::Method::POST, url, task::JOIN_REQUESTS_DECIDE)?
395 .json(&body)
396 .send()
397 .await?;
398 if !resp.status().is_success() {
399 let status = resp.status().as_u16();
400 let body = resp.text().await.unwrap_or_default();
401 return Err(VtcError::Http { status, body });
402 }
403 Ok(resp.json().await?)
404 }
405
406 pub async fn remove_member(
411 &self,
412 did: &str,
413 reason: Option<&str>,
414 ) -> Result<RemoveResult, VtcError> {
415 let url = format!("{}/members/{did}", self.base_url);
416 let mut req = self.tt(reqwest::Method::DELETE, url, task::MEMBERS_ADMIN_REMOVE)?;
417 if let Some(reason) = reason {
418 req = req.json(&serde_json::json!({ "reason": reason }));
419 }
420 let resp = req.send().await?;
421 if !resp.status().is_success() {
422 let status = resp.status().as_u16();
423 let body = resp.text().await.unwrap_or_default();
424 return Err(VtcError::Http { status, body });
425 }
426 Ok(resp.json().await?)
427 }
428
429 pub async fn update_member_extensions(
434 &self,
435 did: &str,
436 extensions: serde_json::Value,
437 ) -> Result<(), VtcError> {
438 let resp = self
439 .tt(
440 reqwest::Method::PATCH,
441 format!("{}/members/{did}", self.base_url),
442 task::MEMBERS_UPDATE,
443 )?
444 .json(&serde_json::json!({ "extensions": extensions }))
445 .send()
446 .await?;
447 if !resp.status().is_success() {
448 let status = resp.status().as_u16();
449 let body = resp.text().await.unwrap_or_default();
450 return Err(VtcError::Http { status, body });
451 }
452 Ok(())
453 }
454
455 pub async fn submit_join(
488 &self,
489 body: &join_requests::JoinRequestSubmitBody,
490 applicant_did: &str,
491 private_key_multibase: &str,
492 ) -> Result<join_requests::VerdictResponse, VtcError> {
493 let payload = serde_json::to_value(body)
494 .map_err(|e| VtcError::Url(format!("serialise submit payload: {e}")))?;
495 let doc = vta_sdk::trust_task_sign::build_signed(
496 join_requests::JOIN_REQUEST_SUBMIT_TYPE,
497 payload,
498 applicant_did,
499 private_key_multibase,
500 &self.vtc_did,
501 )
502 .await
503 .map_err(|e| VtcError::Signing(e.to_string()))?;
504
505 let resp = self
509 .http
510 .post(format!("{}/trust-tasks", self.base_url))
511 .header("content-type", "application/json")
512 .body(doc)
513 .send()
514 .await?;
515 if !resp.status().is_success() {
516 let status = resp.status().as_u16();
517 let body = resp.text().await.unwrap_or_default();
518 return Err(VtcError::Http { status, body });
519 }
520
521 let text = resp.text().await?;
524 let response_doc: trust_tasks_rs::TrustTask<serde_json::Value> =
525 serde_json::from_str(&text).map_err(|e| VtcError::Http {
526 status: 200,
527 body: format!(
528 "unexpected submit response (not a Trust Task document): {e}: {text}"
529 ),
530 })?;
531 serde_json::from_value(response_doc.payload).map_err(|e| VtcError::Http {
532 status: 200,
533 body: format!("submit response payload is not a VerdictResponse: {e}"),
534 })
535 }
536
537 pub async fn list_policies(&self) -> Result<Vec<serde_json::Value>, VtcError> {
539 let mut out = Vec::new();
540 let mut cursor: Option<String> = None;
541 loop {
542 let mut params: Vec<(&str, &str)> = Vec::new();
543 if let Some(cursor) = &cursor {
544 params.push(("cursor", cursor.as_str()));
545 }
546 let url =
547 reqwest::Url::parse_with_params(&format!("{}/policies", self.base_url), ¶ms)
548 .map_err(|e| VtcError::Url(e.to_string()))?;
549 let resp = self
550 .tt(reqwest::Method::GET, url, task::POLICY_LIST)?
551 .send()
552 .await?;
553 if !resp.status().is_success() {
554 let status = resp.status().as_u16();
555 let body = resp.text().await.unwrap_or_default();
556 return Err(VtcError::Http { status, body });
557 }
558 let page: Page<serde_json::Value> = resp.json().await?;
559 out.extend(page.items);
560 match page.next_cursor {
561 Some(next) => cursor = Some(next),
562 None => break,
563 }
564 }
565 Ok(out)
566 }
567
568 pub async fn get_policy(&self, id: &str) -> Result<serde_json::Value, VtcError> {
570 self.get_json(&format!("policies/{id}"), task::POLICY_GET)
571 .await
572 }
573
574 pub async fn upload_policy(
578 &self,
579 purpose: &str,
580 rego_source: &str,
581 ) -> Result<serde_json::Value, VtcError> {
582 self.post_json(
583 "policies",
584 task::POLICY_UPSERT,
585 &serde_json::json!({ "purpose": purpose, "regoSource": rego_source }),
586 )
587 .await
588 }
589
590 pub async fn activate_policy(&self, id: &str) -> Result<serde_json::Value, VtcError> {
593 self.post_json(
594 &format!("policies/{id}/activate"),
595 task::POLICY_ACTIVATE,
596 &serde_json::json!({}),
597 )
598 .await
599 }
600
601 async fn get_json(&self, path: &str, task: &str) -> Result<serde_json::Value, VtcError> {
603 let resp = self
604 .tt(
605 reqwest::Method::GET,
606 format!("{}/{path}", self.base_url),
607 task,
608 )?
609 .send()
610 .await?;
611 if !resp.status().is_success() {
612 let status = resp.status().as_u16();
613 let body = resp.text().await.unwrap_or_default();
614 return Err(VtcError::Http { status, body });
615 }
616 Ok(resp.json().await?)
617 }
618
619 async fn post_json(
622 &self,
623 path: &str,
624 task: &str,
625 body: &serde_json::Value,
626 ) -> Result<serde_json::Value, VtcError> {
627 let resp = self
628 .tt(
629 reqwest::Method::POST,
630 format!("{}/{path}", self.base_url),
631 task,
632 )?
633 .json(body)
634 .send()
635 .await?;
636 if !resp.status().is_success() {
637 let status = resp.status().as_u16();
638 let body = resp.text().await.unwrap_or_default();
639 return Err(VtcError::Http { status, body });
640 }
641 Ok(resp.json().await?)
642 }
643
644 fn token(&self) -> Result<&str, VtcError> {
646 self.token.as_deref().ok_or(VtcError::NotAuthenticated)
647 }
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653
654 #[test]
655 fn member_page_deserializes_from_vtc_shape() {
656 let json = serde_json::json!({
659 "items": [{
660 "did": "did:key:z6MkStaffVta",
661 "role": "member",
662 "label": "Staff VTA",
663 "joinedAt": "2026-06-23T00:00:00Z",
664 "publishConsent": true,
665 "departurePreference": "tombstone",
666 "statusListIndex": 7,
667 "currentVmcId": "urn:uuid:vmc-1",
668 "extensions": {},
669 "personhood": false,
670 "joinedViaInvitation": true
671 }],
672 "next_cursor": null
673 });
674 let page: Page<MemberRecord> = serde_json::from_value(json).unwrap();
675 assert_eq!(page.items.len(), 1);
676 let m = &page.items[0];
677 assert_eq!(m.did, "did:key:z6MkStaffVta");
678 assert_eq!(m.role, "member");
679 assert_eq!(m.status_list_index, Some(7));
680 assert_eq!(m.current_vmc_id.as_deref(), Some("urn:uuid:vmc-1"));
681 assert!(m.joined_via_invitation);
682 assert!(page.next_cursor.is_none());
683 }
684
685 #[tokio::test]
686 async fn list_members_without_token_is_not_authenticated() {
687 let client = VtcClient {
688 http: reqwest::Client::new(),
689 base_url: "https://vtc.example.com/v1".into(),
690 vtc_did: "did:web:vtc.example.com".into(),
691 token: None,
692 };
693 let err = client.list_members(None).await;
695 assert!(matches!(err, Err(VtcError::NotAuthenticated)), "{err:?}");
696 }
697
698 #[test]
699 fn decide_result_deserializes_camel_case() {
700 let json = serde_json::json!({
701 "requestId": "11111111-1111-1111-1111-111111111111",
702 "status": "approved",
703 "vmc": { "type": ["VerifiableCredential", "MembershipCredential"] },
704 "roleVec": null
705 });
706 let d: DecideResult = serde_json::from_value(json).unwrap();
707 assert_eq!(d.request_id, "11111111-1111-1111-1111-111111111111");
708 assert_eq!(d.status, "approved");
709 assert!(d.vmc.is_some());
710 assert!(d.role_vec.is_none());
711 }
712
713 #[test]
714 fn join_request_and_remove_results_deserialize() {
715 let jr: JoinRequestSummary = serde_json::from_value(serde_json::json!({
716 "id": "22222222-2222-2222-2222-222222222222",
717 "applicantDid": "did:key:z6MkApplicant",
718 "status": "pending",
719 "submittedAt": "2026-06-23T00:00:00Z"
720 }))
721 .unwrap();
722 assert_eq!(jr.applicant_did, "did:key:z6MkApplicant");
723 assert_eq!(jr.status, "pending");
724
725 let rm: RemoveResult = serde_json::from_value(serde_json::json!({
726 "did": "did:key:z6MkGone",
727 "disposition": "tombstone",
728 "removed": true
729 }))
730 .unwrap();
731 assert_eq!(rm.did, "did:key:z6MkGone");
732 assert!(rm.removed);
733 }
734
735 #[tokio::test]
736 async fn admin_methods_without_token_are_not_authenticated() {
737 let client = VtcClient {
738 http: reqwest::Client::new(),
739 base_url: "https://vtc.example.com/v1".into(),
740 vtc_did: "did:web:vtc.example.com".into(),
741 token: None,
742 };
743 assert!(matches!(
744 client.list_join_requests(Some("pending")).await,
745 Err(VtcError::NotAuthenticated)
746 ));
747 assert!(matches!(
748 client.approve_join("req-1").await,
749 Err(VtcError::NotAuthenticated)
750 ));
751 assert!(matches!(
752 client.remove_member("did:key:x", Some("reason")).await,
753 Err(VtcError::NotAuthenticated)
754 ));
755 }
756
757 #[test]
758 fn member_extensions_default_and_parse() {
759 let none: MemberRecord = serde_json::from_value(serde_json::json!({
760 "did": "did:key:z", "role": "member", "joinedAt": "2026-06-23T00:00:00Z"
761 }))
762 .unwrap();
763 assert!(none.extensions.is_null());
764 let with: MemberRecord = serde_json::from_value(serde_json::json!({
765 "did": "did:key:z", "role": "member", "joinedAt": "2026-06-23T00:00:00Z",
766 "extensions": { "fleet_index": 3 }
767 }))
768 .unwrap();
769 assert_eq!(with.extensions["fleet_index"], 3);
770 }
771
772 #[tokio::test]
773 async fn policy_admin_methods_without_token_are_not_authenticated() {
774 let client = VtcClient {
775 http: reqwest::Client::new(),
776 base_url: "https://vtc.example.com/v1".into(),
777 vtc_did: "did:web:vtc.example.com".into(),
778 token: None,
779 };
780 assert!(matches!(
781 client.list_policies().await,
782 Err(VtcError::NotAuthenticated)
783 ));
784 assert!(matches!(
785 client.get_policy("p1").await,
786 Err(VtcError::NotAuthenticated)
787 ));
788 assert!(matches!(
789 client.upload_policy("join", "package x").await,
790 Err(VtcError::NotAuthenticated)
791 ));
792 assert!(matches!(
793 client.activate_policy("p1").await,
794 Err(VtcError::NotAuthenticated)
795 ));
796 assert!(matches!(
797 client
798 .update_member_extensions("did:key:z", serde_json::json!({}))
799 .await,
800 Err(VtcError::NotAuthenticated)
801 ));
802 }
803}