use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub mod task {
pub const MEMBERS_LIST: &str = "https://trusttasks.org/spec/vtc/members/list/0.1";
pub const MEMBERS_UPDATE: &str = "https://trusttasks.org/spec/vtc/members/update/0.1";
pub const MEMBERS_ADMIN_REMOVE: &str =
"https://trusttasks.org/spec/vtc/members/admin-remove/0.1";
pub const JOIN_REQUESTS_LIST: &str = "https://trusttasks.org/spec/vtc/join-requests/list/0.1";
pub const JOIN_REQUESTS_DECIDE: &str =
"https://trusttasks.org/spec/vtc/join-requests/decide/0.1";
pub const POLICY_LIST: &str = "https://trusttasks.org/spec/policy/list/0.2";
pub const POLICY_GET: &str = "https://trusttasks.org/spec/policy/get/0.1";
pub const POLICY_UPSERT: &str = "https://trusttasks.org/spec/policy/upsert/0.2";
pub const POLICY_ACTIVATE: &str = "https://trusttasks.org/spec/policy/activate/0.1";
}
pub use vta_sdk::protocols::join_requests;
#[derive(Debug, thiserror::Error)]
pub enum VtcError {
#[error("not authenticated — call VtcClient::connect first")]
NotAuthenticated,
#[error("VTC returned HTTP {status}: {body}")]
Http { status: u16, body: String },
#[error("invalid request url: {0}")]
Url(String),
#[error("transport error: {0}")]
Transport(#[from] reqwest::Error),
#[error("authentication failed: {0}")]
Auth(#[from] vta_sdk::error::VtaError),
#[error("unsupported by this client: {0}")]
Unsupported(&'static str),
#[error("could not sign the request document: {0}")]
Signing(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MemberRecord {
pub did: String,
pub role: String,
#[serde(default)]
pub label: Option<String>,
pub joined_at: DateTime<Utc>,
#[serde(default)]
pub status_list_index: Option<u32>,
#[serde(default)]
pub current_vmc_id: Option<String>,
#[serde(default)]
pub personhood: bool,
#[serde(default)]
pub joined_via_invitation: bool,
#[serde(default)]
pub extensions: serde_json::Value,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Page<T> {
items: Vec<T>,
next_cursor: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct JoinRequestSummary {
pub id: String,
pub applicant_did: String,
pub status: String,
pub submitted_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DecideResult {
pub request_id: String,
pub status: String,
#[serde(default)]
pub vmc: Option<serde_json::Value>,
#[serde(default)]
pub role_vec: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RemoveResult {
pub did: String,
pub disposition: String,
pub removed: bool,
}
#[derive(Debug, Clone)]
pub struct VtcClient {
http: reqwest::Client,
base_url: String,
vtc_did: String,
token: Option<String>,
}
impl VtcClient {
pub async fn connect(
base_url: &str,
vtc_did: &str,
client_did: &str,
private_key_multibase: &str,
) -> Result<Self, VtcError> {
let http = vta_sdk::http::rest_client();
let base_url = base_url.trim_end_matches('/').to_string();
let auth = vta_sdk::auth_light::challenge_response_light(
&http,
&base_url,
client_did,
private_key_multibase,
vtc_did,
)
.await?;
Ok(Self {
http,
base_url,
vtc_did: vtc_did.to_string(),
token: Some(auth.access_token),
})
}
pub fn with_token(base_url: &str, vtc_did: &str, token: impl Into<String>) -> Self {
Self {
http: vta_sdk::http::rest_client(),
base_url: base_url.trim_end_matches('/').to_string(),
vtc_did: vtc_did.to_string(),
token: Some(token.into()),
}
}
pub fn anonymous(base_url: &str, vtc_did: &str) -> Self {
Self {
http: vta_sdk::http::rest_client(),
base_url: base_url.trim_end_matches('/').to_string(),
vtc_did: vtc_did.to_string(),
token: None,
}
}
pub fn vtc_did(&self) -> &str {
&self.vtc_did
}
fn tt(
&self,
method: reqwest::Method,
url: impl reqwest::IntoUrl,
task: &str,
) -> Result<reqwest::RequestBuilder, VtcError> {
let token = self.token()?;
Ok(self
.http
.request(method, url)
.header("Trust-Task", task)
.bearer_auth(token))
}
pub async fn list_members(&self, role: Option<&str>) -> Result<Vec<MemberRecord>, VtcError> {
let mut out: Vec<MemberRecord> = Vec::new();
let mut cursor: Option<String> = None;
loop {
let mut params: Vec<(&str, &str)> = Vec::new();
if let Some(role) = role {
params.push(("role", role));
}
if let Some(cursor) = &cursor {
params.push(("cursor", cursor.as_str()));
}
let url =
reqwest::Url::parse_with_params(&format!("{}/members", self.base_url), ¶ms)
.map_err(|e| VtcError::Url(e.to_string()))?;
let resp = self
.tt(reqwest::Method::GET, url, task::MEMBERS_LIST)?
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(VtcError::Http { status, body });
}
let page: Page<MemberRecord> = resp.json().await?;
out.extend(page.items);
match page.next_cursor {
Some(next) => cursor = Some(next),
None => break,
}
}
Ok(out)
}
pub async fn list_join_requests(
&self,
status: Option<&str>,
) -> Result<Vec<JoinRequestSummary>, VtcError> {
let mut out: Vec<JoinRequestSummary> = Vec::new();
let mut cursor: Option<String> = None;
loop {
let mut params: Vec<(&str, &str)> = Vec::new();
if let Some(status) = status {
params.push(("status", status));
}
if let Some(cursor) = &cursor {
params.push(("cursor", cursor.as_str()));
}
let url = reqwest::Url::parse_with_params(
&format!("{}/join-requests", self.base_url),
¶ms,
)
.map_err(|e| VtcError::Url(e.to_string()))?;
let resp = self
.tt(reqwest::Method::GET, url, task::JOIN_REQUESTS_LIST)?
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(VtcError::Http { status, body });
}
let page: Page<JoinRequestSummary> = resp.json().await?;
out.extend(page.items);
match page.next_cursor {
Some(next) => cursor = Some(next),
None => break,
}
}
Ok(out)
}
pub async fn approve_join(&self, request_id: &str) -> Result<DecideResult, VtcError> {
self.decide(request_id, "approved", None).await
}
pub async fn reject_join(
&self,
request_id: &str,
reason: Option<&str>,
) -> Result<DecideResult, VtcError> {
self.decide(request_id, "rejected", reason).await
}
async fn decide(
&self,
request_id: &str,
decision: &str,
reason: Option<&str>,
) -> Result<DecideResult, VtcError> {
let url = format!("{}/join-requests/{request_id}/decide", self.base_url);
let mut body = serde_json::json!({ "decision": decision });
if let Some(reason) = reason {
body["reason"] = serde_json::json!(reason);
}
let resp = self
.tt(reqwest::Method::POST, url, task::JOIN_REQUESTS_DECIDE)?
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(VtcError::Http { status, body });
}
Ok(resp.json().await?)
}
pub async fn remove_member(
&self,
did: &str,
reason: Option<&str>,
) -> Result<RemoveResult, VtcError> {
let url = format!("{}/members/{did}", self.base_url);
let mut req = self.tt(reqwest::Method::DELETE, url, task::MEMBERS_ADMIN_REMOVE)?;
if let Some(reason) = reason {
req = req.json(&serde_json::json!({ "reason": reason }));
}
let resp = req.send().await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(VtcError::Http { status, body });
}
Ok(resp.json().await?)
}
pub async fn update_member_extensions(
&self,
did: &str,
extensions: serde_json::Value,
) -> Result<(), VtcError> {
let resp = self
.tt(
reqwest::Method::PATCH,
format!("{}/members/{did}", self.base_url),
task::MEMBERS_UPDATE,
)?
.json(&serde_json::json!({ "extensions": extensions }))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(VtcError::Http { status, body });
}
Ok(())
}
pub async fn submit_join(
&self,
body: &join_requests::JoinRequestSubmitBody,
applicant_did: &str,
private_key_multibase: &str,
) -> Result<join_requests::VerdictResponse, VtcError> {
let payload = serde_json::to_value(body)
.map_err(|e| VtcError::Url(format!("serialise submit payload: {e}")))?;
let doc = vta_sdk::trust_task_sign::build_signed(
join_requests::JOIN_REQUEST_SUBMIT_TYPE,
payload,
applicant_did,
private_key_multibase,
&self.vtc_did,
)
.await
.map_err(|e| VtcError::Signing(e.to_string()))?;
let resp = self
.http
.post(format!("{}/trust-tasks", self.base_url))
.header("content-type", "application/json")
.body(doc)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(VtcError::Http { status, body });
}
let text = resp.text().await?;
let response_doc: trust_tasks_rs::TrustTask<serde_json::Value> =
serde_json::from_str(&text).map_err(|e| VtcError::Http {
status: 200,
body: format!(
"unexpected submit response (not a Trust Task document): {e}: {text}"
),
})?;
serde_json::from_value(response_doc.payload).map_err(|e| VtcError::Http {
status: 200,
body: format!("submit response payload is not a VerdictResponse: {e}"),
})
}
pub async fn list_policies(&self) -> Result<Vec<serde_json::Value>, VtcError> {
let mut out = Vec::new();
let mut cursor: Option<String> = None;
loop {
let mut params: Vec<(&str, &str)> = Vec::new();
if let Some(cursor) = &cursor {
params.push(("cursor", cursor.as_str()));
}
let url =
reqwest::Url::parse_with_params(&format!("{}/policies", self.base_url), ¶ms)
.map_err(|e| VtcError::Url(e.to_string()))?;
let resp = self
.tt(reqwest::Method::GET, url, task::POLICY_LIST)?
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(VtcError::Http { status, body });
}
let page: Page<serde_json::Value> = resp.json().await?;
out.extend(page.items);
match page.next_cursor {
Some(next) => cursor = Some(next),
None => break,
}
}
Ok(out)
}
pub async fn get_policy(&self, id: &str) -> Result<serde_json::Value, VtcError> {
self.get_json(&format!("policies/{id}"), task::POLICY_GET)
.await
}
pub async fn upload_policy(
&self,
purpose: &str,
rego_source: &str,
) -> Result<serde_json::Value, VtcError> {
self.post_json(
"policies",
task::POLICY_UPSERT,
&serde_json::json!({ "purpose": purpose, "regoSource": rego_source }),
)
.await
}
pub async fn activate_policy(&self, id: &str) -> Result<serde_json::Value, VtcError> {
self.post_json(
&format!("policies/{id}/activate"),
task::POLICY_ACTIVATE,
&serde_json::json!({}),
)
.await
}
async fn get_json(&self, path: &str, task: &str) -> Result<serde_json::Value, VtcError> {
let resp = self
.tt(
reqwest::Method::GET,
format!("{}/{path}", self.base_url),
task,
)?
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(VtcError::Http { status, body });
}
Ok(resp.json().await?)
}
async fn post_json(
&self,
path: &str,
task: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value, VtcError> {
let resp = self
.tt(
reqwest::Method::POST,
format!("{}/{path}", self.base_url),
task,
)?
.json(body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(VtcError::Http { status, body });
}
Ok(resp.json().await?)
}
fn token(&self) -> Result<&str, VtcError> {
self.token.as_deref().ok_or(VtcError::NotAuthenticated)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn member_page_deserializes_from_vtc_shape() {
let json = serde_json::json!({
"items": [{
"did": "did:key:z6MkStaffVta",
"role": "member",
"label": "Staff VTA",
"joinedAt": "2026-06-23T00:00:00Z",
"publishConsent": true,
"departurePreference": "tombstone",
"statusListIndex": 7,
"currentVmcId": "urn:uuid:vmc-1",
"extensions": {},
"personhood": false,
"joinedViaInvitation": true
}],
"nextCursor": null
});
let page: Page<MemberRecord> = serde_json::from_value(json).unwrap();
assert_eq!(page.items.len(), 1);
let m = &page.items[0];
assert_eq!(m.did, "did:key:z6MkStaffVta");
assert_eq!(m.role, "member");
assert_eq!(m.status_list_index, Some(7));
assert_eq!(m.current_vmc_id.as_deref(), Some("urn:uuid:vmc-1"));
assert!(m.joined_via_invitation);
assert!(page.next_cursor.is_none());
}
#[tokio::test]
async fn list_members_without_token_is_not_authenticated() {
let client = VtcClient {
http: reqwest::Client::new(),
base_url: "https://vtc.example.com/v1".into(),
vtc_did: "did:web:vtc.example.com".into(),
token: None,
};
let err = client.list_members(None).await;
assert!(matches!(err, Err(VtcError::NotAuthenticated)), "{err:?}");
}
#[test]
fn decide_result_deserializes_camel_case() {
let json = serde_json::json!({
"requestId": "11111111-1111-1111-1111-111111111111",
"status": "approved",
"vmc": { "type": ["VerifiableCredential", "MembershipCredential"] },
"roleVec": null
});
let d: DecideResult = serde_json::from_value(json).unwrap();
assert_eq!(d.request_id, "11111111-1111-1111-1111-111111111111");
assert_eq!(d.status, "approved");
assert!(d.vmc.is_some());
assert!(d.role_vec.is_none());
}
#[test]
fn join_request_and_remove_results_deserialize() {
let jr: JoinRequestSummary = serde_json::from_value(serde_json::json!({
"id": "22222222-2222-2222-2222-222222222222",
"applicantDid": "did:key:z6MkApplicant",
"status": "pending",
"submittedAt": "2026-06-23T00:00:00Z"
}))
.unwrap();
assert_eq!(jr.applicant_did, "did:key:z6MkApplicant");
assert_eq!(jr.status, "pending");
let rm: RemoveResult = serde_json::from_value(serde_json::json!({
"did": "did:key:z6MkGone",
"disposition": "tombstone",
"removed": true
}))
.unwrap();
assert_eq!(rm.did, "did:key:z6MkGone");
assert!(rm.removed);
}
#[tokio::test]
async fn admin_methods_without_token_are_not_authenticated() {
let client = VtcClient {
http: reqwest::Client::new(),
base_url: "https://vtc.example.com/v1".into(),
vtc_did: "did:web:vtc.example.com".into(),
token: None,
};
assert!(matches!(
client.list_join_requests(Some("pending")).await,
Err(VtcError::NotAuthenticated)
));
assert!(matches!(
client.approve_join("req-1").await,
Err(VtcError::NotAuthenticated)
));
assert!(matches!(
client.remove_member("did:key:x", Some("reason")).await,
Err(VtcError::NotAuthenticated)
));
}
#[test]
fn member_extensions_default_and_parse() {
let none: MemberRecord = serde_json::from_value(serde_json::json!({
"did": "did:key:z", "role": "member", "joinedAt": "2026-06-23T00:00:00Z"
}))
.unwrap();
assert!(none.extensions.is_null());
let with: MemberRecord = serde_json::from_value(serde_json::json!({
"did": "did:key:z", "role": "member", "joinedAt": "2026-06-23T00:00:00Z",
"extensions": { "fleet_index": 3 }
}))
.unwrap();
assert_eq!(with.extensions["fleet_index"], 3);
}
#[tokio::test]
async fn policy_admin_methods_without_token_are_not_authenticated() {
let client = VtcClient {
http: reqwest::Client::new(),
base_url: "https://vtc.example.com/v1".into(),
vtc_did: "did:web:vtc.example.com".into(),
token: None,
};
assert!(matches!(
client.list_policies().await,
Err(VtcError::NotAuthenticated)
));
assert!(matches!(
client.get_policy("p1").await,
Err(VtcError::NotAuthenticated)
));
assert!(matches!(
client.upload_policy("join", "package x").await,
Err(VtcError::NotAuthenticated)
));
assert!(matches!(
client.activate_policy("p1").await,
Err(VtcError::NotAuthenticated)
));
assert!(matches!(
client
.update_member_extensions("did:key:z", serde_json::json!({}))
.await,
Err(VtcError::NotAuthenticated)
));
}
}