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)]
135#[serde(rename_all = "camelCase")]
136struct Page<T> {
137 items: Vec<T>,
138 next_cursor: Option<String>,
139}
140
141#[derive(Debug, Clone, Deserialize, PartialEq)]
143#[serde(rename_all = "camelCase")]
144pub struct JoinRequestSummary {
145 pub id: String,
147 pub applicant_did: String,
149 pub status: String,
151 pub submitted_at: DateTime<Utc>,
152}
153
154#[derive(Debug, Clone, Deserialize, PartialEq)]
156#[serde(rename_all = "camelCase")]
157pub struct DecideResult {
158 pub request_id: String,
159 pub status: String,
160 #[serde(default)]
162 pub vmc: Option<serde_json::Value>,
163 #[serde(default)]
165 pub role_vec: Option<serde_json::Value>,
166}
167
168#[derive(Debug, Clone, Deserialize, PartialEq)]
171#[serde(rename_all = "camelCase")]
172pub struct RemoveResult {
173 pub did: String,
174 pub disposition: String,
176 pub removed: bool,
177}
178
179#[derive(Debug, Clone)]
182pub struct VtcClient {
183 http: reqwest::Client,
184 base_url: String,
187 vtc_did: String,
189 token: Option<String>,
191}
192
193impl VtcClient {
194 pub async fn connect(
200 base_url: &str,
201 vtc_did: &str,
202 client_did: &str,
203 private_key_multibase: &str,
204 ) -> Result<Self, VtcError> {
205 let http = vta_sdk::http::rest_client();
208 let base_url = base_url.trim_end_matches('/').to_string();
209 let auth = vta_sdk::auth_light::challenge_response_light(
210 &http,
211 &base_url,
212 client_did,
213 private_key_multibase,
214 vtc_did,
215 )
216 .await?;
217 Ok(Self {
218 http,
219 base_url,
220 vtc_did: vtc_did.to_string(),
221 token: Some(auth.access_token),
222 })
223 }
224
225 pub fn with_token(base_url: &str, vtc_did: &str, token: impl Into<String>) -> Self {
228 Self {
229 http: vta_sdk::http::rest_client(),
230 base_url: base_url.trim_end_matches('/').to_string(),
231 vtc_did: vtc_did.to_string(),
232 token: Some(token.into()),
233 }
234 }
235
236 pub fn anonymous(base_url: &str, vtc_did: &str) -> Self {
248 Self {
249 http: vta_sdk::http::rest_client(),
250 base_url: base_url.trim_end_matches('/').to_string(),
251 vtc_did: vtc_did.to_string(),
252 token: None,
253 }
254 }
255
256 pub fn vtc_did(&self) -> &str {
258 &self.vtc_did
259 }
260
261 fn tt(
269 &self,
270 method: reqwest::Method,
271 url: impl reqwest::IntoUrl,
272 task: &str,
273 ) -> Result<reqwest::RequestBuilder, VtcError> {
274 let token = self.token()?;
275 Ok(self
276 .http
277 .request(method, url)
278 .header("Trust-Task", task)
279 .bearer_auth(token))
280 }
281
282 pub async fn list_members(&self, role: Option<&str>) -> Result<Vec<MemberRecord>, VtcError> {
286 let mut out: Vec<MemberRecord> = Vec::new();
287 let mut cursor: Option<String> = None;
288
289 loop {
290 let mut params: Vec<(&str, &str)> = Vec::new();
291 if let Some(role) = role {
292 params.push(("role", role));
293 }
294 if let Some(cursor) = &cursor {
295 params.push(("cursor", cursor.as_str()));
296 }
297 let url =
298 reqwest::Url::parse_with_params(&format!("{}/members", self.base_url), ¶ms)
299 .map_err(|e| VtcError::Url(e.to_string()))?;
300
301 let resp = self
302 .tt(reqwest::Method::GET, url, task::MEMBERS_LIST)?
303 .send()
304 .await?;
305 if !resp.status().is_success() {
306 let status = resp.status().as_u16();
307 let body = resp.text().await.unwrap_or_default();
308 return Err(VtcError::Http { status, body });
309 }
310
311 let page: Page<MemberRecord> = resp.json().await?;
312 out.extend(page.items);
313 match page.next_cursor {
314 Some(next) => cursor = Some(next),
315 None => break,
316 }
317 }
318 Ok(out)
319 }
320
321 pub async fn list_join_requests(
325 &self,
326 status: Option<&str>,
327 ) -> Result<Vec<JoinRequestSummary>, VtcError> {
328 let mut out: Vec<JoinRequestSummary> = Vec::new();
329 let mut cursor: Option<String> = None;
330 loop {
331 let mut params: Vec<(&str, &str)> = Vec::new();
332 if let Some(status) = status {
333 params.push(("status", status));
334 }
335 if let Some(cursor) = &cursor {
336 params.push(("cursor", cursor.as_str()));
337 }
338 let url = reqwest::Url::parse_with_params(
339 &format!("{}/join-requests", self.base_url),
340 ¶ms,
341 )
342 .map_err(|e| VtcError::Url(e.to_string()))?;
343
344 let resp = self
345 .tt(reqwest::Method::GET, url, task::JOIN_REQUESTS_LIST)?
346 .send()
347 .await?;
348 if !resp.status().is_success() {
349 let status = resp.status().as_u16();
350 let body = resp.text().await.unwrap_or_default();
351 return Err(VtcError::Http { status, body });
352 }
353 let page: Page<JoinRequestSummary> = resp.json().await?;
354 out.extend(page.items);
355 match page.next_cursor {
356 Some(next) => cursor = Some(next),
357 None => break,
358 }
359 }
360 Ok(out)
361 }
362
363 pub async fn approve_join(&self, request_id: &str) -> Result<DecideResult, VtcError> {
367 self.decide(request_id, "approved", None).await
368 }
369
370 pub async fn reject_join(
373 &self,
374 request_id: &str,
375 reason: Option<&str>,
376 ) -> Result<DecideResult, VtcError> {
377 self.decide(request_id, "rejected", reason).await
378 }
379
380 async fn decide(
389 &self,
390 request_id: &str,
391 decision: &str,
392 reason: Option<&str>,
393 ) -> Result<DecideResult, VtcError> {
394 let url = format!("{}/join-requests/{request_id}/decide", self.base_url);
395 let mut body = serde_json::json!({ "decision": decision });
396 if let Some(reason) = reason {
397 body["reason"] = serde_json::json!(reason);
398 }
399 let resp = self
400 .tt(reqwest::Method::POST, url, task::JOIN_REQUESTS_DECIDE)?
401 .json(&body)
402 .send()
403 .await?;
404 if !resp.status().is_success() {
405 let status = resp.status().as_u16();
406 let body = resp.text().await.unwrap_or_default();
407 return Err(VtcError::Http { status, body });
408 }
409 Ok(resp.json().await?)
410 }
411
412 pub async fn remove_member(
417 &self,
418 did: &str,
419 reason: Option<&str>,
420 ) -> Result<RemoveResult, VtcError> {
421 let url = format!("{}/members/{did}", self.base_url);
422 let mut req = self.tt(reqwest::Method::DELETE, url, task::MEMBERS_ADMIN_REMOVE)?;
423 if let Some(reason) = reason {
424 req = req.json(&serde_json::json!({ "reason": reason }));
425 }
426 let resp = req.send().await?;
427 if !resp.status().is_success() {
428 let status = resp.status().as_u16();
429 let body = resp.text().await.unwrap_or_default();
430 return Err(VtcError::Http { status, body });
431 }
432 Ok(resp.json().await?)
433 }
434
435 pub async fn update_member_extensions(
440 &self,
441 did: &str,
442 extensions: serde_json::Value,
443 ) -> Result<(), VtcError> {
444 let resp = self
445 .tt(
446 reqwest::Method::PATCH,
447 format!("{}/members/{did}", self.base_url),
448 task::MEMBERS_UPDATE,
449 )?
450 .json(&serde_json::json!({ "extensions": extensions }))
451 .send()
452 .await?;
453 if !resp.status().is_success() {
454 let status = resp.status().as_u16();
455 let body = resp.text().await.unwrap_or_default();
456 return Err(VtcError::Http { status, body });
457 }
458 Ok(())
459 }
460
461 pub async fn submit_join(
494 &self,
495 body: &join_requests::JoinRequestSubmitBody,
496 applicant_did: &str,
497 private_key_multibase: &str,
498 ) -> Result<join_requests::VerdictResponse, VtcError> {
499 let payload = serde_json::to_value(body)
500 .map_err(|e| VtcError::Url(format!("serialise submit payload: {e}")))?;
501 let doc = vta_sdk::trust_task_sign::build_signed(
502 join_requests::JOIN_REQUEST_SUBMIT_TYPE,
503 payload,
504 applicant_did,
505 private_key_multibase,
506 &self.vtc_did,
507 )
508 .await
509 .map_err(|e| VtcError::Signing(e.to_string()))?;
510
511 let resp = self
515 .http
516 .post(format!("{}/trust-tasks", self.base_url))
517 .header("content-type", "application/json")
518 .body(doc)
519 .send()
520 .await?;
521 if !resp.status().is_success() {
522 let status = resp.status().as_u16();
523 let body = resp.text().await.unwrap_or_default();
524 return Err(VtcError::Http { status, body });
525 }
526
527 let text = resp.text().await?;
530 let response_doc: trust_tasks_rs::TrustTask<serde_json::Value> =
531 serde_json::from_str(&text).map_err(|e| VtcError::Http {
532 status: 200,
533 body: format!(
534 "unexpected submit response (not a Trust Task document): {e}: {text}"
535 ),
536 })?;
537 serde_json::from_value(response_doc.payload).map_err(|e| VtcError::Http {
538 status: 200,
539 body: format!("submit response payload is not a VerdictResponse: {e}"),
540 })
541 }
542
543 pub async fn list_policies(&self) -> Result<Vec<serde_json::Value>, VtcError> {
545 let mut out = Vec::new();
546 let mut cursor: Option<String> = None;
547 loop {
548 let mut params: Vec<(&str, &str)> = Vec::new();
549 if let Some(cursor) = &cursor {
550 params.push(("cursor", cursor.as_str()));
551 }
552 let url =
553 reqwest::Url::parse_with_params(&format!("{}/policies", self.base_url), ¶ms)
554 .map_err(|e| VtcError::Url(e.to_string()))?;
555 let resp = self
556 .tt(reqwest::Method::GET, url, task::POLICY_LIST)?
557 .send()
558 .await?;
559 if !resp.status().is_success() {
560 let status = resp.status().as_u16();
561 let body = resp.text().await.unwrap_or_default();
562 return Err(VtcError::Http { status, body });
563 }
564 let page: Page<serde_json::Value> = resp.json().await?;
565 out.extend(page.items);
566 match page.next_cursor {
567 Some(next) => cursor = Some(next),
568 None => break,
569 }
570 }
571 Ok(out)
572 }
573
574 pub async fn get_policy(&self, id: &str) -> Result<serde_json::Value, VtcError> {
576 self.get_json(&format!("policies/{id}"), task::POLICY_GET)
577 .await
578 }
579
580 pub async fn upload_policy(
584 &self,
585 purpose: &str,
586 rego_source: &str,
587 ) -> Result<serde_json::Value, VtcError> {
588 self.post_json(
589 "policies",
590 task::POLICY_UPSERT,
591 &serde_json::json!({ "purpose": purpose, "regoSource": rego_source }),
592 )
593 .await
594 }
595
596 pub async fn activate_policy(&self, id: &str) -> Result<serde_json::Value, VtcError> {
599 self.post_json(
600 &format!("policies/{id}/activate"),
601 task::POLICY_ACTIVATE,
602 &serde_json::json!({}),
603 )
604 .await
605 }
606
607 async fn get_json(&self, path: &str, task: &str) -> Result<serde_json::Value, VtcError> {
609 let resp = self
610 .tt(
611 reqwest::Method::GET,
612 format!("{}/{path}", self.base_url),
613 task,
614 )?
615 .send()
616 .await?;
617 if !resp.status().is_success() {
618 let status = resp.status().as_u16();
619 let body = resp.text().await.unwrap_or_default();
620 return Err(VtcError::Http { status, body });
621 }
622 Ok(resp.json().await?)
623 }
624
625 async fn post_json(
628 &self,
629 path: &str,
630 task: &str,
631 body: &serde_json::Value,
632 ) -> Result<serde_json::Value, VtcError> {
633 let resp = self
634 .tt(
635 reqwest::Method::POST,
636 format!("{}/{path}", self.base_url),
637 task,
638 )?
639 .json(body)
640 .send()
641 .await?;
642 if !resp.status().is_success() {
643 let status = resp.status().as_u16();
644 let body = resp.text().await.unwrap_or_default();
645 return Err(VtcError::Http { status, body });
646 }
647 Ok(resp.json().await?)
648 }
649
650 fn token(&self) -> Result<&str, VtcError> {
652 self.token.as_deref().ok_or(VtcError::NotAuthenticated)
653 }
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659
660 #[test]
661 fn member_page_deserializes_from_vtc_shape() {
662 let json = serde_json::json!({
665 "items": [{
666 "did": "did:key:z6MkStaffVta",
667 "role": "member",
668 "label": "Staff VTA",
669 "joinedAt": "2026-06-23T00:00:00Z",
670 "publishConsent": true,
671 "departurePreference": "tombstone",
672 "statusListIndex": 7,
673 "currentVmcId": "urn:uuid:vmc-1",
674 "extensions": {},
675 "personhood": false,
676 "joinedViaInvitation": true
677 }],
678 "nextCursor": null
679 });
680 let page: Page<MemberRecord> = serde_json::from_value(json).unwrap();
681 assert_eq!(page.items.len(), 1);
682 let m = &page.items[0];
683 assert_eq!(m.did, "did:key:z6MkStaffVta");
684 assert_eq!(m.role, "member");
685 assert_eq!(m.status_list_index, Some(7));
686 assert_eq!(m.current_vmc_id.as_deref(), Some("urn:uuid:vmc-1"));
687 assert!(m.joined_via_invitation);
688 assert!(page.next_cursor.is_none());
689 }
690
691 #[tokio::test]
692 async fn list_members_without_token_is_not_authenticated() {
693 let client = VtcClient {
694 http: reqwest::Client::new(),
695 base_url: "https://vtc.example.com/v1".into(),
696 vtc_did: "did:web:vtc.example.com".into(),
697 token: None,
698 };
699 let err = client.list_members(None).await;
701 assert!(matches!(err, Err(VtcError::NotAuthenticated)), "{err:?}");
702 }
703
704 #[test]
705 fn decide_result_deserializes_camel_case() {
706 let json = serde_json::json!({
707 "requestId": "11111111-1111-1111-1111-111111111111",
708 "status": "approved",
709 "vmc": { "type": ["VerifiableCredential", "MembershipCredential"] },
710 "roleVec": null
711 });
712 let d: DecideResult = serde_json::from_value(json).unwrap();
713 assert_eq!(d.request_id, "11111111-1111-1111-1111-111111111111");
714 assert_eq!(d.status, "approved");
715 assert!(d.vmc.is_some());
716 assert!(d.role_vec.is_none());
717 }
718
719 #[test]
720 fn join_request_and_remove_results_deserialize() {
721 let jr: JoinRequestSummary = serde_json::from_value(serde_json::json!({
722 "id": "22222222-2222-2222-2222-222222222222",
723 "applicantDid": "did:key:z6MkApplicant",
724 "status": "pending",
725 "submittedAt": "2026-06-23T00:00:00Z"
726 }))
727 .unwrap();
728 assert_eq!(jr.applicant_did, "did:key:z6MkApplicant");
729 assert_eq!(jr.status, "pending");
730
731 let rm: RemoveResult = serde_json::from_value(serde_json::json!({
732 "did": "did:key:z6MkGone",
733 "disposition": "tombstone",
734 "removed": true
735 }))
736 .unwrap();
737 assert_eq!(rm.did, "did:key:z6MkGone");
738 assert!(rm.removed);
739 }
740
741 #[tokio::test]
742 async fn admin_methods_without_token_are_not_authenticated() {
743 let client = VtcClient {
744 http: reqwest::Client::new(),
745 base_url: "https://vtc.example.com/v1".into(),
746 vtc_did: "did:web:vtc.example.com".into(),
747 token: None,
748 };
749 assert!(matches!(
750 client.list_join_requests(Some("pending")).await,
751 Err(VtcError::NotAuthenticated)
752 ));
753 assert!(matches!(
754 client.approve_join("req-1").await,
755 Err(VtcError::NotAuthenticated)
756 ));
757 assert!(matches!(
758 client.remove_member("did:key:x", Some("reason")).await,
759 Err(VtcError::NotAuthenticated)
760 ));
761 }
762
763 #[test]
764 fn member_extensions_default_and_parse() {
765 let none: MemberRecord = serde_json::from_value(serde_json::json!({
766 "did": "did:key:z", "role": "member", "joinedAt": "2026-06-23T00:00:00Z"
767 }))
768 .unwrap();
769 assert!(none.extensions.is_null());
770 let with: MemberRecord = serde_json::from_value(serde_json::json!({
771 "did": "did:key:z", "role": "member", "joinedAt": "2026-06-23T00:00:00Z",
772 "extensions": { "fleet_index": 3 }
773 }))
774 .unwrap();
775 assert_eq!(with.extensions["fleet_index"], 3);
776 }
777
778 #[tokio::test]
779 async fn policy_admin_methods_without_token_are_not_authenticated() {
780 let client = VtcClient {
781 http: reqwest::Client::new(),
782 base_url: "https://vtc.example.com/v1".into(),
783 vtc_did: "did:web:vtc.example.com".into(),
784 token: None,
785 };
786 assert!(matches!(
787 client.list_policies().await,
788 Err(VtcError::NotAuthenticated)
789 ));
790 assert!(matches!(
791 client.get_policy("p1").await,
792 Err(VtcError::NotAuthenticated)
793 ));
794 assert!(matches!(
795 client.upload_policy("join", "package x").await,
796 Err(VtcError::NotAuthenticated)
797 ));
798 assert!(matches!(
799 client.activate_policy("p1").await,
800 Err(VtcError::NotAuthenticated)
801 ));
802 assert!(matches!(
803 client
804 .update_member_extensions("did:key:z", serde_json::json!({}))
805 .await,
806 Err(VtcError::NotAuthenticated)
807 ));
808 }
809}