use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub use vta_sdk::trust_task_sign::HolderKey;
#[cfg(feature = "didcomm")]
const SESSION_TIMEOUT_SECS: u64 = 60;
pub mod rooms;
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 const VETTING_VETTERS_GRANT: &str =
"https://trusttasks.org/spec/vtc/vetting/vetters/grant/0.1";
pub const VETTING_VETTERS_RESEND: &str =
"https://trusttasks.org/spec/vtc/vetting/vetters/resend/0.1";
pub const ENDORSEMENTS_REVOKE: &str = "https://trusttasks.org/spec/vtc/endorsements/revoke/0.1";
pub const AUDIT_VERIFY: &str = "https://trusttasks.org/spec/audit/verify/0.1";
pub const BACKUP_EXPORT: &str = "https://trusttasks.org/spec/vtc/backup/export/0.1";
pub const BACKUP_IMPORT: &str = "https://trusttasks.org/spec/vtc/backup/import/0.1";
pub const MEMBERS_CREDENTIALS: &str =
<super::members_credentials::Payload as trust_tasks_rs::Payload>::TYPE_URI;
}
pub const REST_SERVICE_TYPE: &str = "VTCRest";
pub fn api_base_from_did_document(doc: &serde_json::Value) -> Option<String> {
let has_type = |svc: &serde_json::Value| match svc.get("type") {
Some(serde_json::Value::String(t)) => t == REST_SERVICE_TYPE,
Some(serde_json::Value::Array(ts)) => ts.iter().any(|t| t == REST_SERVICE_TYPE),
_ => false,
};
fn uri(endpoint: &serde_json::Value) -> Option<String> {
match endpoint {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Object(map) => map.get("uri")?.as_str().map(str::to_string),
serde_json::Value::Array(items) => items.iter().find_map(uri),
_ => None,
}
}
doc.get("service")?
.as_array()?
.iter()
.filter(|svc| has_type(svc))
.find_map(|svc| uri(svc.get("serviceEndpoint")?))
.map(|u| u.trim_end_matches('/').to_string())
.filter(|u| !u.is_empty())
}
const MAX_AUDIT_VERIFY_RESPONSE_BYTES: usize = 1024 * 1024;
const MAX_BACKUP_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
pub use vta_sdk::protocols::join_requests;
pub use trust_tasks_rs::specs::did_management::did::register as did_register;
pub use vta_sdk::protocols::vetting;
pub use trust_tasks_rs::specs::policy::upsert::v0_2 as policy_upsert;
pub use trust_tasks_rs::specs::vtc::members::credentials::v0_1 as members_credentials;
pub const POLICY_PURPOSE_EXT_KEY: &str = "org.openvtc.purpose";
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
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),
#[error("session transport error: {0}")]
Session(String),
#[error("this client has no REST base — {0} needs one; pass rest_url when connecting")]
NoRestTransport(&'static str),
#[error("not found ({code}): {message}")]
NotFound {
code: String,
message: String,
},
#[error("invalid request payload: {0}")]
InvalidPayload(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)]
#[non_exhaustive]
pub struct VetterGrant {
pub created: bool,
pub grant: vetting::vetters::grant::v0_1::Response,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct EndorsementRevocation {
pub endorsement_id: String,
pub revocation: RevocationDetail,
pub status_list_index: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct RevocationDetail {
pub credential_id: String,
pub revoked_at: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct VettingRevocation {
pub issuer: String,
pub statement_id: String,
pub statement_digest_multibase: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub recorded_at: DateTime<Utc>,
pub review_state: String,
#[serde(default)]
pub affected_join_requests: Vec<String>,
#[serde(default)]
pub affected_members: Vec<String>,
}
#[derive(Deserialize)]
struct VettingRevocationList {
revocations: Vec<VettingRevocation>,
}
#[derive(Clone)]
pub struct VtcClient {
http: reqwest::Client,
base_url: String,
vtc_did: String,
token: Option<String>,
#[cfg(feature = "didcomm")]
documents: Option<vta_sdk::client::VtaClient>,
}
impl std::fmt::Debug for VtcClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut out = f.debug_struct("VtcClient");
out.field("base_url", &self.base_url)
.field("vtc_did", &self.vtc_did)
.field("authenticated", &self.token.is_some());
#[cfg(feature = "didcomm")]
out.field("session", &self.documents.is_some());
out.finish()
}
}
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),
#[cfg(feature = "didcomm")]
documents: None,
})
}
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()),
#[cfg(feature = "didcomm")]
documents: None,
}
}
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,
#[cfg(feature = "didcomm")]
documents: None,
}
}
#[cfg(feature = "didcomm")]
pub async fn connect_didcomm(
client_did: &str,
private_key_multibase: &str,
vtc_did: &str,
mediator_did: &str,
rest_url: Option<&str>,
) -> Result<Self, VtcError> {
let documents = vta_sdk::client::VtaClient::connect_didcomm(
client_did,
private_key_multibase,
vtc_did,
mediator_did,
None,
)
.await
.map_err(|e| VtcError::Session(e.to_string()))?;
Ok(Self::over_session(documents, vtc_did, rest_url))
}
#[cfg(feature = "tsp")]
pub async fn connect_tsp(
client_did: &str,
private_key_multibase: &str,
vtc_did: &str,
mediator_did: &str,
rest_url: Option<&str>,
) -> Result<Self, VtcError> {
let documents = vta_sdk::client::VtaClient::connect_tsp(
client_did,
private_key_multibase,
vtc_did,
mediator_did,
None,
)
.await
.map_err(|e| VtcError::Session(e.to_string()))?;
Ok(Self::over_session(documents, vtc_did, rest_url))
}
#[cfg(feature = "didcomm")]
fn over_session(
documents: vta_sdk::client::VtaClient,
vtc_did: &str,
rest_url: Option<&str>,
) -> Self {
Self {
http: vta_sdk::http::rest_client(),
base_url: rest_url
.unwrap_or_default()
.trim_end_matches('/')
.to_string(),
vtc_did: vtc_did.to_string(),
token: None,
documents: Some(documents),
}
}
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> {
if self.base_url.is_empty() {
return Err(VtcError::NoRestTransport("this verb"));
}
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 key = HolderKey::from_did_key(applicant_did, private_key_multibase)
.map_err(|e| VtcError::Signing(e.to_string()))?;
self.submit_join_as(body, &key).await
}
pub async fn submit_join_as(
&self,
body: &join_requests::JoinRequestSubmitBody,
key: &HolderKey,
) -> Result<join_requests::VerdictResponse, VtcError> {
let payload = serde_json::to_value(body)
.map_err(|e| VtcError::Url(format!("serialise submit payload: {e}")))?;
#[cfg(feature = "didcomm")]
if let Some(documents) = &self.documents {
let value = documents
.dispatch_trust_task(
join_requests::JOIN_REQUEST_SUBMIT_TYPE,
payload,
SESSION_TIMEOUT_SECS,
)
.await
.map_err(|e| VtcError::Session(e.to_string()))?;
return serde_json::from_value(value).map_err(|e| VtcError::Http {
status: 200,
body: format!("unexpected submit verdict: {e}"),
});
}
let doc = vta_sdk::trust_task_sign::build_signed_with(
join_requests::JOIN_REQUEST_SUBMIT_TYPE,
payload,
key,
&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 member_credentials(
&self,
did: &str,
) -> Result<members_credentials::Response, VtcError> {
let url = self.api_url(&["members", did, "credentials"])?;
let resp = self
.tt(reqwest::Method::GET, url, task::MEMBERS_CREDENTIALS)?
.send()
.await?;
let resp = expect_success_declaring(resp, members_credentials::ERROR_CODES).await?;
Ok(resp.json().await?)
}
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> {
let payload = policy_upload_payload(purpose, rego_source)?;
let body =
serde_json::to_value(&payload).map_err(|e| VtcError::InvalidPayload(e.to_string()))?;
self.post_json("policies", task::POLICY_UPSERT, &body).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
}
pub async fn list_vetter_grants(&self) -> Result<vetting::VetterGrantListResponse, VtcError> {
let url = self.api_url(&["vetting", "vetters"])?;
let resp = self.untasked(reqwest::Method::GET, url)?.send().await?;
Ok(expect_success(resp).await?.json().await?)
}
pub async fn install_did_log(
&self,
register: &did_register::v0_1::Payload,
) -> Result<did_register::v0_1::Response, VtcError> {
let url = self.api_url(&["admin", "did", "register"])?;
let resp = self
.tt(
reqwest::Method::POST,
url,
<did_register::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI,
)?
.json(register)
.send()
.await?;
Ok(expect_success(resp).await?.json().await?)
}
pub async fn grant_vetter(
&self,
grant: &vetting::vetters::grant::v0_1::Payload,
) -> Result<VetterGrant, VtcError> {
let url = self.api_url(&["vetting", "vetters"])?;
let resp = self
.tt(reqwest::Method::POST, url, task::VETTING_VETTERS_GRANT)?
.json(grant)
.send()
.await?;
let resp = expect_success(resp).await?;
let created = resp.status() == reqwest::StatusCode::CREATED;
Ok(VetterGrant {
created,
grant: resp.json().await?,
})
}
pub async fn revoke_endorsement(
&self,
endorsement_id: &str,
) -> Result<EndorsementRevocation, VtcError> {
let url = self.api_url(&["credentials", "endorsements", endorsement_id])?;
let resp = self
.tt(reqwest::Method::DELETE, url, task::ENDORSEMENTS_REVOKE)?
.send()
.await?;
Ok(expect_success(resp).await?.json().await?)
}
pub async fn resend_vetter_grant(
&self,
member_did: &str,
) -> Result<vetting::vetters::resend::v0_1::Response, VtcError> {
let url = self.api_url(&["vetting", "vetters", member_did, "resend"])?;
let resp = self
.tt(reqwest::Method::POST, url, task::VETTING_VETTERS_RESEND)?
.send()
.await?;
Ok(expect_success(resp).await?.json().await?)
}
pub async fn auto_grant(&self) -> Result<vetting::AutoGrantStatus, VtcError> {
let url = self.api_url(&["vetting", "auto-grant"])?;
let resp = self.untasked(reqwest::Method::GET, url)?.send().await?;
Ok(expect_success(resp).await?.json().await?)
}
pub async fn configure_auto_grant(
&self,
config: &vetting::AutoGrantConfig,
) -> Result<vetting::AutoGrantStatus, VtcError> {
let url = self.api_url(&["vetting", "auto-grant"])?;
let resp = self
.untasked(reqwest::Method::PUT, url)?
.json(config)
.send()
.await?;
Ok(expect_success(resp).await?.json().await?)
}
pub async fn branding(
&self,
) -> Result<join_requests::manifest::v0_2::CommunityBranding, VtcError> {
let url = self.api_url(&["community", "branding"])?;
let resp = self.untasked(reqwest::Method::GET, url)?.send().await?;
Ok(expect_success(resp).await?.json().await?)
}
pub async fn set_branding(
&self,
branding: &join_requests::manifest::v0_2::CommunityBranding,
) -> Result<join_requests::manifest::v0_2::CommunityBranding, VtcError> {
let url = self.api_url(&["community", "branding"])?;
let resp = self
.untasked(reqwest::Method::PUT, url)?
.json(branding)
.send()
.await?;
Ok(expect_success(resp).await?.json().await?)
}
pub async fn requested_attributes(
&self,
) -> Result<Vec<join_requests::manifest::v0_2::ResponseRequestedAttributesItem>, VtcError> {
let url = self.api_url(&["community", "requested-attributes"])?;
let resp = self.untasked(reqwest::Method::GET, url)?.send().await?;
Ok(expect_success(resp).await?.json().await?)
}
pub async fn set_requested_attributes(
&self,
requested: &[join_requests::manifest::v0_2::ResponseRequestedAttributesItem],
) -> Result<Vec<join_requests::manifest::v0_2::ResponseRequestedAttributesItem>, VtcError> {
let url = self.api_url(&["community", "requested-attributes"])?;
let resp = self
.untasked(reqwest::Method::PUT, url)?
.json(requested)
.send()
.await?;
Ok(expect_success(resp).await?.json().await?)
}
pub async fn vetting_revocations(&self) -> Result<Vec<VettingRevocation>, VtcError> {
let url = self.api_url(&["vetting", "revocations"])?;
let resp = self.untasked(reqwest::Method::GET, url)?.send().await?;
let list: VettingRevocationList = expect_success(resp).await?.json().await?;
Ok(list.revocations)
}
pub async fn audit_verify(&self) -> Result<serde_json::Value, VtcError> {
let url = self.api_url(&["audit", "verify"])?;
let resp = self
.tt(reqwest::Method::GET, url, task::AUDIT_VERIFY)?
.send()
.await?;
read_json_capped(expect_success(resp).await?, MAX_AUDIT_VERIFY_RESPONSE_BYTES).await
}
pub async fn export_backup(
&self,
password: &str,
include_audit: bool,
) -> Result<serde_json::Value, VtcError> {
let url = self.api_url(&["backup", "export"])?;
let resp = self
.tt(reqwest::Method::POST, url, task::BACKUP_EXPORT)?
.json(&serde_json::json!({ "password": password, "includeAudit": include_audit }))
.send()
.await?;
let mut body =
read_json_capped(expect_success(resp).await?, MAX_BACKUP_RESPONSE_BYTES).await?;
match body.get_mut("envelope").map(serde_json::Value::take) {
Some(envelope @ serde_json::Value::Object(_)) => Ok(envelope),
_ if body.get("format").is_some() => Ok(body),
_ => Err(VtcError::Http {
status: 200,
body: "the export response carries no backup envelope".into(),
}),
}
}
pub async fn import_backup(
&self,
backup: &serde_json::Value,
password: &str,
confirm: bool,
) -> Result<serde_json::Value, VtcError> {
let url = self.api_url(&["backup", "import"])?;
let resp = self
.tt(reqwest::Method::POST, url, task::BACKUP_IMPORT)?
.json(&serde_json::json!({
"backup": backup,
"password": password,
"confirm": confirm,
}))
.send()
.await?;
read_json_capped(expect_success(resp).await?, MAX_BACKUP_RESPONSE_BYTES).await
}
fn api_url(&self, segments: &[&str]) -> Result<reqwest::Url, VtcError> {
if self.base_url.is_empty() {
return Err(VtcError::NoRestTransport("this verb"));
}
let mut url =
reqwest::Url::parse(&self.base_url).map_err(|e| VtcError::Url(e.to_string()))?;
url.path_segments_mut()
.map_err(|()| VtcError::Url(format!("{} cannot be a base URL", self.base_url)))?
.pop_if_empty()
.extend(segments);
Ok(url)
}
fn untasked(
&self,
method: reqwest::Method,
url: reqwest::Url,
) -> Result<reqwest::RequestBuilder, VtcError> {
if self.base_url.is_empty() {
return Err(VtcError::NoRestTransport("this verb"));
}
let token = self.token()?;
Ok(self.http.request(method, url).bearer_auth(token))
}
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)
}
}
async fn expect_success(resp: reqwest::Response) -> Result<reqwest::Response, VtcError> {
if resp.status().is_success() {
return Ok(resp);
}
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
Err(VtcError::Http { status, body })
}
async fn expect_success_declaring(
resp: reqwest::Response,
declared: &[trust_tasks_rs::DeclaredErrorCode],
) -> Result<reqwest::Response, VtcError> {
if resp.status().is_success() {
return Ok(resp);
}
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
Err(typed_error(status, body, declared))
}
fn typed_error(
status: u16,
body: String,
declared: &[trust_tasks_rs::DeclaredErrorCode],
) -> VtcError {
if status == 404
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(&body)
&& let Some(code) = v.get("code").and_then(serde_json::Value::as_str)
&& declared.iter().any(|d| d.code == code)
{
let message = v
.get("error")
.or_else(|| v.get("message"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
return VtcError::NotFound {
code: code.to_string(),
message,
};
}
VtcError::Http { status, body }
}
fn policy_upload_payload(
purpose: &str,
rego_source: &str,
) -> Result<policy_upsert::Payload, VtcError> {
let key: policy_upsert::ExtKey = POLICY_PURPOSE_EXT_KEY
.parse()
.map_err(|e| VtcError::InvalidPayload(format!("ext key: {e}")))?;
let ext = policy_upsert::Ext::from(std::collections::HashMap::from([(
key,
serde_json::Value::String(purpose.to_string()),
)]));
policy_upsert::Payload::try_from(
policy_upsert::Payload::builder()
.name(purpose)
.module(rego_source)
.ext(Some(ext)),
)
.map_err(|e| VtcError::InvalidPayload(e.to_string()))
}
async fn read_json_capped(
resp: reqwest::Response,
max: usize,
) -> Result<serde_json::Value, VtcError> {
let status = resp.status().as_u16();
let bytes = vta_sdk::http::read_body_capped(resp, max)
.await
.map_err(|e| VtcError::Http {
status,
body: e.to_string(),
})?;
serde_json::from_slice(&bytes).map_err(|e| VtcError::Http {
status,
body: format!(
"response is not JSON ({e}): {}",
String::from_utf8_lossy(&bytes)
),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn api_base_is_read_from_the_vtc_rest_service_by_type() {
let doc = serde_json::json!({
"id": "did:webvh:Qm:vtc.example.com",
"service": [
{ "id": "#rest", "type": "VTARest", "serviceEndpoint": "https://vta.example.com" },
{ "id": "#anything", "type": ["VTCRest"], "serviceEndpoint": "https://vtc.example.com/v1/" },
],
});
assert_eq!(
api_base_from_did_document(&doc).as_deref(),
Some("https://vtc.example.com/v1")
);
let vta_only = serde_json::json!({
"service": [{ "id": "#vtc-rest", "type": "VTARest", "serviceEndpoint": "https://x" }],
});
assert_eq!(api_base_from_did_document(&vta_only), None);
assert_eq!(api_base_from_did_document(&serde_json::json!({})), None);
}
#[test]
fn path_segments_are_encoded_not_interpolated() {
let client = VtcClient::with_token("https://vtc.example.com/v1/", "did:web:vtc", "t");
let url = client
.api_url(&[
"vetting",
"vetters",
"did:webvh:Qm:x.example/../admin?x",
"resend",
])
.unwrap();
assert_eq!(
url.as_str(),
"https://vtc.example.com/v1/vetting/vetters/did:webvh:Qm:x.example%2F..%2Fadmin%3Fx/resend"
);
}
#[tokio::test]
async fn vetting_admin_methods_without_token_are_not_authenticated() {
let client = VtcClient::anonymous("https://vtc.example.com/v1", "did:web:vtc");
assert!(matches!(
client.list_vetter_grants().await,
Err(VtcError::NotAuthenticated)
));
assert!(matches!(
client
.grant_vetter(
&serde_json::from_value(serde_json::json!({ "memberDid": "did:key:z" }))
.unwrap()
)
.await,
Err(VtcError::NotAuthenticated)
));
assert!(matches!(
client.revoke_endorsement("e1").await,
Err(VtcError::NotAuthenticated)
));
assert!(matches!(
client.resend_vetter_grant("did:key:z").await,
Err(VtcError::NotAuthenticated)
));
assert!(matches!(
client.auto_grant().await,
Err(VtcError::NotAuthenticated)
));
assert!(matches!(
client.branding().await,
Err(VtcError::NotAuthenticated)
));
assert!(matches!(
client.vetting_revocations().await,
Err(VtcError::NotAuthenticated)
));
}
#[test]
fn a_withdrawal_row_deserializes_from_the_vtc_shape() {
let rows: VettingRevocationList = serde_json::from_value(serde_json::json!({
"revocations": [{
"issuer": "did:key:zCarol",
"statementId": "urn:uuid:s1",
"statementDigestMultibase": "zDigest",
"reason": "mistake",
"recordedAt": "2026-09-01T00:00:00Z",
"reviewState": "needsReview",
"affectedJoinRequests": ["3f1c9a52-8c1e-4f2b-9d7a-0b6e5c4d3a21"],
"affectedMembers": ["did:key:zAlice"]
}]
}))
.unwrap();
assert_eq!(rows.revocations[0].review_state, "needsReview");
assert_eq!(rows.revocations[0].affected_members, vec!["did:key:zAlice"]);
}
#[test]
fn a_holder_key_names_any_did_method() {
let webvh = HolderKey::new(
"did:webvh:QmScid:example.com:glenn#key-0",
"z3u2en7t5LR2WtQH5PfFqMqwVHBeXouLzo6haApm8XHqvjxq",
)
.expect("a did:webvh verification method is a verification method");
assert_eq!(webvh.holder_did(), "did:webvh:QmScid:example.com:glenn");
let key = HolderKey::from_did_key(
"did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG",
"z3u2en7t5LR2WtQH5PfFqMqwVHBeXouLzo6haApm8XHqvjxq",
)
.expect("a did:key derives its verification method");
assert!(key.verification_method().starts_with("did:key:"));
}
#[test]
fn a_did_without_a_fragment_is_not_a_verification_method() {
assert!(HolderKey::new("did:webvh:QmScid:example.com:glenn", "z3u2").is_err());
}
#[test]
fn an_admin_verb_without_a_rest_base_says_so() {
let client = VtcClient {
http: vta_sdk::http::rest_client(),
base_url: String::new(),
vtc_did: "did:webvh:QmScid:example.com:acme".to_string(),
token: Some("t".to_string()),
#[cfg(feature = "didcomm")]
documents: None,
};
let err = client
.tt(reqwest::Method::GET, "http://x/members", task::MEMBERS_LIST)
.expect_err("no REST base means no admin verb");
assert!(
matches!(err, VtcError::NoRestTransport(_)),
"expected a missing-transport error, got {err:?}"
);
}
#[test]
fn debug_does_not_leak_the_token() {
let client = VtcClient::with_token(
"https://vtc.example.com/v1",
"did:webvh:QmScid:example.com:acme",
"super-secret-bearer-token",
);
let rendered = format!("{client:?}");
assert!(
!rendered.contains("super-secret-bearer-token"),
"the token is in Debug output: {rendered}"
);
assert!(rendered.contains("authenticated: true"), "{rendered}");
}
#[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,
#[cfg(feature = "didcomm")]
documents: 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,
#[cfg(feature = "didcomm")]
documents: 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,
#[cfg(feature = "didcomm")]
documents: 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.member_credentials("did:key:z").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)
));
}
#[test]
fn policy_upload_sends_the_canonical_upsert_shape() {
let body = serde_json::to_value(policy_upload_payload("join", "package vtc.join").unwrap())
.unwrap();
assert_eq!(body["name"], "join");
assert_eq!(body["module"], "package vtc.join");
assert_eq!(body["ext"][POLICY_PURPOSE_EXT_KEY], "join");
assert!(
body.get("purpose").is_none(),
"not a canonical member: {body}"
);
assert!(
body.get("regoSource").is_none(),
"renamed to module: {body}"
);
}
#[test]
fn an_empty_policy_module_is_refused_before_sending() {
assert!(matches!(
policy_upload_payload("join", ""),
Err(VtcError::InvalidPayload(_))
));
}
#[test]
fn only_a_declared_not_found_code_is_typed() {
let declared = members_credentials::ERROR_CODES;
let code = members_credentials::error_codes::NOT_FOUND.code;
let typed = typed_error(
404,
serde_json::json!({ "error": "not found: x", "code": code }).to_string(),
declared,
);
assert!(
matches!(&typed, VtcError::NotFound { code: c, message } if c == code && message == "not found: x"),
"{typed:?}"
);
for (status, body) in [
(404, serde_json::json!({ "error": "not found" }).to_string()),
(404, "no route".to_string()),
(
404,
serde_json::json!({ "error": "x", "code": "vtc/other:notFound" }).to_string(),
),
(
403,
serde_json::json!({ "error": "x", "code": code }).to_string(),
),
] {
assert!(
matches!(
typed_error(status, body.clone(), declared),
VtcError::Http { .. }
),
"{status} {body} must stay Http"
);
}
}
}