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 acl;
pub mod git_ns;
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_SHOW: &str =
"https://trusttasks.org/spec/vtc/vetting/vetters/show/0.1";
pub const ENDORSEMENTS_REVOKE: &str = "https://trusttasks.org/spec/vtc/endorsements/revoke/0.1";
pub const ENDORSEMENTS_ISSUE: &str = "https://trusttasks.org/spec/vtc/endorsements/issue/0.1";
pub const MEMBERS_RENEW: &str = "https://trusttasks.org/spec/vtc/members/renew/0.1";
pub const MEMBERS_ROTATE_CHALLENGE: &str =
"https://trusttasks.org/spec/vtc/members/rotate-challenge/0.1";
pub const MEMBERS_ROTATE: &str = "https://trusttasks.org/spec/vtc/members/rotate/0.1";
pub const MEMBERS_PERSONHOOD_REVOKE: &str =
"https://trusttasks.org/spec/vtc/members/personhood/revoke/0.1";
pub const RELATIONSHIPS_LIST: &str = "https://trusttasks.org/spec/vtc/relationships/list/0.2";
pub const RELATIONSHIPS_PUBLISH: &str =
"https://trusttasks.org/spec/vtc/relationships/publish/0.2";
pub const RELATIONSHIPS_REVOKE: &str =
"https://trusttasks.org/spec/vtc/relationships/revoke/0.1";
pub const RELATIONSHIPS_REVOKE_0_2: &str =
"https://trusttasks.org/spec/vtc/relationships/revoke/0.2";
pub const AUDIT_VERIFY: &str = "https://trusttasks.org/spec/audit/verify/0.1";
pub const MEMBERS_CREDENTIALS: &str =
<super::members_credentials::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const DID_REGISTER: &str =
<super::did_register::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const VETTING_VETTERS_GRANTS_LIST: &str =
"https://trusttasks.org/spec/vtc/vetting/vetters/grants/list/0.1";
pub const VETTING_AUTO_GRANT_SHOW: &str =
"https://trusttasks.org/spec/vtc/vetting/auto-grant/show/0.1";
pub const VETTING_AUTO_GRANT_UPDATE: &str =
"https://trusttasks.org/spec/vtc/vetting/auto-grant/update/0.1";
pub const VETTING_REVOCATIONS_LIST: &str =
"https://trusttasks.org/spec/vtc/vetting/revocations/list/0.1";
pub const COMMUNITY_BRANDING_SHOW: &str =
"https://trusttasks.org/spec/vtc/community/branding/show/0.1";
pub const COMMUNITY_BRANDING_UPDATE: &str =
"https://trusttasks.org/spec/vtc/community/branding/update/0.1";
pub const COMMUNITY_REQUESTED_ATTRIBUTES_SHOW: &str =
"https://trusttasks.org/spec/vtc/community/requested-attributes/show/0.1";
pub const COMMUNITY_REQUESTED_ATTRIBUTES_UPDATE: &str =
"https://trusttasks.org/spec/vtc/community/requested-attributes/update/0.1";
}
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;
mod backup_chunks {
use base64::Engine as _;
use sha2::Digest as _;
pub const ALGORITHM: &str = vta_sdk::protocols::backup_management::chunked::ALGORITHM_CHUNKED;
pub const CHUNK_SIZE: u64 = 32 * 1024;
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;
pub fn digest(bytes: &[u8]) -> String {
vta_sdk::protocols::backup_management::chunked::sha256_digest_multibase(
&sha2::Sha256::digest(bytes).into(),
)
}
pub fn sha256_hex(bytes: &[u8]) -> String {
sha2::Sha256::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
pub fn encode(bytes: &[u8]) -> String {
B64.encode(bytes)
}
pub fn decode(text: &str) -> Option<Vec<u8>> {
B64.decode(text).ok()
}
}
const MAX_DOCUMENT_RESPONSE_BYTES: usize = 8 * 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 — build the client with a key (VtcClient::connect or VtcClient::with_key) or over a session"
)]
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),
#[error("the VTC refused the request: {document}")]
Refused {
document: 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_vac: 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 MemberRenewal {
pub did: String,
pub vmc: serde_json::Value,
pub role_vac: serde_json::Value,
pub personhood: bool,
pub personhood_changed: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct RotationChallenge {
pub rotation_id: String,
pub expires_at: String,
pub signing_payload_hex: String,
pub canonical_template: serde_json::Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RotationReason {
Routine,
Compromise,
DeviceLoss,
Migration,
Unspecified,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct MemberRotated {
pub new_did: String,
pub method: String,
pub vmc: serde_json::Value,
pub role_vac: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct PersonhoodRevocation {
pub did: String,
pub personhood: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vmc: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_vac: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct RelationshipRecord {
pub id: String,
pub issuer_did: String,
pub subject_did: String,
pub vrc_jsonld: serde_json::Value,
pub vrc_digest_multibase: String,
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct RelationshipPublished {
pub id: String,
pub issuer_did: String,
pub subject_did: String,
pub vrc_digest_multibase: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct RelationshipRevoked {
pub id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct EndorsementIssued {
pub endorsement: IssuedEndorsement,
pub credential: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct IssuedEndorsement {
pub endorsement_id: String,
pub type_uri: String,
pub subject_did: String,
pub issued: IssuedCredentialRef,
pub status_list_index: u32,
pub claim: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct IssuedCredentialRef {
pub credential_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issued_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<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 AutoGrantShowResponse {
#[serde(rename = "autoGrant")]
auto_grant: vetting::AutoGrantStatus,
}
#[derive(Deserialize)]
struct BrandingResponse {
branding: join_requests::manifest::v0_2::CommunityBranding,
}
#[derive(Deserialize)]
struct RequestedAttributesResponse {
#[serde(rename = "requestedAttributes")]
requested_attributes: Vec<join_requests::manifest::v0_2::ResponseRequestedAttributesItem>,
}
#[derive(Clone)]
pub struct VtcClient {
http: reqwest::Client,
base_url: String,
vtc_did: String,
token: Option<String>,
signer: Option<HolderKey>,
#[cfg(feature = "didcomm")]
documents: Option<vta_sdk::client::VtaClient>,
#[cfg(feature = "didcomm")]
session_did: Option<String>,
}
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 signer = HolderKey::from_did_key(client_did, private_key_multibase)
.map_err(|e| VtcError::Signing(e.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),
signer: Some(signer),
#[cfg(feature = "didcomm")]
documents: None,
#[cfg(feature = "didcomm")]
session_did: None,
})
}
pub fn with_key(base_url: &str, vtc_did: &str, key: HolderKey) -> 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,
signer: Some(key),
#[cfg(feature = "didcomm")]
documents: None,
#[cfg(feature = "didcomm")]
session_did: 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,
signer: None,
#[cfg(feature = "didcomm")]
documents: None,
#[cfg(feature = "didcomm")]
session_did: 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, client_did, 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, client_did, vtc_did, rest_url))
}
#[cfg(feature = "didcomm")]
fn over_session(
documents: vta_sdk::client::VtaClient,
client_did: &str,
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,
signer: None,
documents: Some(documents),
session_did: Some(client_did.to_string()),
}
}
pub async fn shutdown(&self) {
#[cfg(feature = "didcomm")]
if let Some(documents) = &self.documents {
documents.shutdown().await;
}
}
pub fn vtc_did(&self) -> &str {
&self.vtc_did
}
async fn document(
&self,
type_uri: &str,
payload: serde_json::Value,
declared: &[trust_tasks_rs::DeclaredErrorCode],
max_bytes: usize,
) -> Result<serde_json::Value, VtcError> {
#[cfg(feature = "didcomm")]
if let Some(documents) = &self.documents {
return documents
.dispatch_trust_task(type_uri, payload, SESSION_TIMEOUT_SECS)
.await
.map_err(|e| VtcError::Session(e.to_string()));
}
let Some(key) = &self.signer else {
return Err(VtcError::NotAuthenticated);
};
if self.base_url.is_empty() {
return Err(VtcError::NoRestTransport("this verb"));
}
let doc =
vta_sdk::trust_task_sign::build_signed_with(type_uri, payload, key, &self.vtc_did)
.await
.map_err(|e| VtcError::Signing(e.to_string()))?;
self.post_document(doc, declared, max_bytes).await
}
async fn document_pages<T: serde::de::DeserializeOwned>(
&self,
type_uri: &str,
payload: serde_json::Value,
verb: &str,
) -> Result<Vec<T>, VtcError> {
let mut out = Vec::new();
let mut cursor: Option<String> = None;
loop {
let mut page_payload = payload.clone();
if let Some(cursor) = &cursor {
page_payload["cursor"] = serde_json::json!(cursor);
}
let reply = self
.document(type_uri, page_payload, &[], MAX_DOCUMENT_RESPONSE_BYTES)
.await?;
let page: Page<T> = decode_payload(reply, verb)?;
out.extend(page.items);
match page.next_cursor {
Some(next) => cursor = Some(next),
None => break,
}
}
Ok(out)
}
async fn post_document(
&self,
doc: String,
declared: &[trust_tasks_rs::DeclaredErrorCode],
max_bytes: usize,
) -> Result<serde_json::Value, VtcError> {
let resp = self
.http
.post(format!("{}/trust-tasks", self.base_url))
.header("content-type", "application/json")
.body(doc)
.send()
.await?;
let status = resp.status().as_u16();
let bytes = vta_sdk::http::read_body_capped(resp, max_bytes)
.await
.map_err(|e| VtcError::Http {
status,
body: e.to_string(),
})?;
let text = String::from_utf8_lossy(&bytes).into_owned();
let response_doc: trust_tasks_rs::TrustTask<serde_json::Value> =
match serde_json::from_str(&text) {
Ok(d) => d,
Err(e) if (200..300).contains(&status) => {
return Err(VtcError::Http {
status,
body: format!(
"unexpected response (not a Trust Task document): {e}: {text}"
),
});
}
Err(_) => return Err(VtcError::Http { status, body: text }),
};
if (200..300).contains(&status) {
return Ok(response_doc.payload);
}
Err(document_error(
status,
&response_doc.payload,
text,
declared,
))
}
pub async fn list_members(&self, role: Option<&str>) -> Result<Vec<MemberRecord>, VtcError> {
let mut payload = serde_json::json!({});
if let Some(role) = role {
payload["role"] = serde_json::json!(role);
}
self.document_pages(task::MEMBERS_LIST, payload, "members/list")
.await
}
pub async fn list_join_requests(
&self,
status: Option<&str>,
) -> Result<Vec<JoinRequestSummary>, VtcError> {
let mut payload = serde_json::json!({});
if let Some(status) = status {
payload["status"] = serde_json::json!(status);
}
self.document_pages(task::JOIN_REQUESTS_LIST, payload, "join-requests/list")
.await
}
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 mut document = serde_json::json!({ "id": request_id, "decision": decision });
if let Some(reason) = reason {
document["reason"] = serde_json::json!(reason);
}
let payload = self
.document(
task::JOIN_REQUESTS_DECIDE,
document,
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload(payload, "decide")
}
pub async fn decide_task_consent(
&self,
decision: &trust_tasks_rs::specs::task_consent::decision::v0_1::Payload,
) -> Result<trust_tasks_rs::specs::task_consent::decision::v0_1::Response, VtcError> {
use trust_tasks_rs::specs::task_consent::decision::v0_1 as spec;
let payload =
serde_json::to_value(decision).map_err(|e| VtcError::InvalidPayload(e.to_string()))?;
let reply = self
.document(
vta_sdk::task_consent::DECISION_TYPE,
payload,
spec::ERROR_CODES,
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload(reply, "task-consent decision")
}
pub async fn remove_member(
&self,
did: &str,
reason: Option<&str>,
) -> Result<RemoveResult, VtcError> {
let mut document = serde_json::json!({ "did": did });
if let Some(reason) = reason {
document["reason"] = serde_json::json!(reason);
}
let payload = self
.document(
task::MEMBERS_ADMIN_REMOVE,
document,
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload(payload, "admin-remove")
}
pub async fn update_member_extensions(
&self,
did: &str,
extensions: serde_json::Value,
) -> Result<(), VtcError> {
self.document(
task::MEMBERS_UPDATE,
serde_json::json!({ "did": did, "extensions": extensions }),
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
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 payload = self
.post_document(doc, &[], MAX_DOCUMENT_RESPONSE_BYTES)
.await?;
serde_json::from_value(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> {
self.document_pages(task::POLICY_LIST, serde_json::json!({}), "policy/list")
.await
}
pub async fn member_credentials(
&self,
did: &str,
) -> Result<members_credentials::Response, VtcError> {
let payload = self
.document(
task::MEMBERS_CREDENTIALS,
serde_json::json!({ "did": did }),
members_credentials::ERROR_CODES,
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload(payload, "members/credentials")
}
pub async fn get_policy(&self, id: &str) -> Result<serde_json::Value, VtcError> {
self.document(
task::POLICY_GET,
serde_json::json!({ "id": id }),
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.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.document(task::POLICY_UPSERT, body, &[], MAX_DOCUMENT_RESPONSE_BYTES)
.await
}
pub async fn activate_policy(&self, id: &str) -> Result<serde_json::Value, VtcError> {
let revision = self.get_policy(id).await?;
let purpose = revision
.pointer("/policy/ext")
.and_then(|ext| ext.get(POLICY_PURPOSE_EXT_KEY))
.and_then(serde_json::Value::as_str)
.ok_or_else(|| VtcError::Http {
status: 200,
body: format!(
"policy {id} names no purpose under ext.{POLICY_PURPOSE_EXT_KEY}: {revision}"
),
})?
.to_string();
self.document(
task::POLICY_ACTIVATE,
serde_json::json!({ "id": id, "purpose": purpose }),
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await
}
pub async fn list_vetter_grants(&self) -> Result<vetting::VetterGrantListResponse, VtcError> {
Ok(vetting::VetterGrantListResponse {
vetters: self.vetter_grants().await?,
})
}
pub async fn install_did_log(
&self,
register: &did_register::v0_1::Payload,
) -> Result<did_register::v0_1::Response, VtcError> {
let payload =
serde_json::to_value(register).map_err(|e| VtcError::InvalidPayload(e.to_string()))?;
let reply = self
.document(
task::DID_REGISTER,
payload,
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload(reply, "did/register")
}
pub async fn grant_vetter(
&self,
grant: &vetting::vetters::grant::v0_1::Payload,
) -> Result<VetterGrant, VtcError> {
let before = self.show_vetter(grant.member_did.as_str()).await?;
let already_live = matches!(
before.status,
vetting::vetters::show::v0_1::GrantStatus::Live
);
let payload =
serde_json::to_value(grant).map_err(|e| VtcError::InvalidPayload(e.to_string()))?;
let reply = self
.document(
task::VETTING_VETTERS_GRANT,
payload,
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
Ok(VetterGrant {
created: !already_live,
grant: decode_payload(reply, "vetting/vetters/grant")?,
})
}
pub async fn show_vetter(
&self,
vetter_did: &str,
) -> Result<vetting::vetters::show::v0_1::Response, VtcError> {
let reply = self
.document(
task::VETTING_VETTERS_SHOW,
serde_json::json!({ "vetterDid": vetter_did }),
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload(reply, "vetting/vetters/show")
}
pub async fn revoke_endorsement(
&self,
endorsement_id: &str,
) -> Result<EndorsementRevocation, VtcError> {
let reply = self
.document(
task::ENDORSEMENTS_REVOKE,
serde_json::json!({ "endorsementId": endorsement_id }),
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload(reply, "endorsements/revoke")
}
pub async fn renew(&self) -> Result<MemberRenewal, VtcError> {
let reply = self
.document(
task::MEMBERS_RENEW,
serde_json::json!({}),
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload(reply, "members/renew")
}
pub async fn rotate_challenge(
&self,
reason: Option<RotationReason>,
) -> Result<RotationChallenge, VtcError> {
let mut payload = serde_json::json!({});
if let Some(reason) = reason {
payload["reason"] = serde_json::to_value(reason)
.map_err(|e| VtcError::InvalidPayload(e.to_string()))?;
}
let reply = self
.document(
task::MEMBERS_ROTATE_CHALLENGE,
payload,
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload(reply, "members/rotate-challenge")
}
pub async fn rotate(
&self,
rotation_id: &str,
old_did: &str,
new_did: &str,
old_signature: &str,
new_signature: &str,
) -> Result<MemberRotated, VtcError> {
let reply = self
.document(
task::MEMBERS_ROTATE,
serde_json::json!({
"rotationId": rotation_id,
"oldDid": old_did,
"newDid": new_did,
"oldSignature": old_signature,
"newSignature": new_signature,
}),
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload(reply, "members/rotate")
}
pub async fn revoke_personhood(&self, did: &str) -> Result<PersonhoodRevocation, VtcError> {
let reply = self
.document(
task::MEMBERS_PERSONHOOD_REVOKE,
serde_json::json!({ "did": did }),
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload(reply, "members/personhood/revoke")
}
pub async fn list_relationships(&self, did: &str) -> Result<Vec<RelationshipRecord>, VtcError> {
self.document_pages(
task::RELATIONSHIPS_LIST,
serde_json::json!({ "did": did }),
"relationships/list",
)
.await
}
pub async fn publish_relationship(
&self,
vrc: serde_json::Value,
pop: Option<serde_json::Value>,
) -> Result<RelationshipPublished, VtcError> {
let mut payload = serde_json::json!({ "vrc": vrc });
if let Some(pop) = pop {
payload["pop"] = pop;
}
let reply = self
.document(
task::RELATIONSHIPS_PUBLISH,
payload,
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload(reply, "relationships/publish")
}
pub async fn revoke_relationship(
&self,
id: &str,
pop: Option<serde_json::Value>,
) -> Result<RelationshipRevoked, VtcError> {
let mut payload = serde_json::json!({ "id": id });
if let Some(pop) = pop {
payload["pop"] = pop;
}
let reply = self
.document(
task::RELATIONSHIPS_REVOKE_0_2,
payload,
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload(reply, "relationships/revoke")
}
pub async fn issue_endorsement(
&self,
subject_did: &str,
type_uri: &str,
claim: serde_json::Value,
valid_for_seconds: Option<u64>,
) -> Result<EndorsementIssued, VtcError> {
self.issue_endorsement_with_ext(subject_did, type_uri, claim, valid_for_seconds, None)
.await
}
pub async fn issue_endorsement_with_ext(
&self,
subject_did: &str,
type_uri: &str,
claim: serde_json::Value,
valid_for_seconds: Option<u64>,
ext: Option<serde_json::Value>,
) -> Result<EndorsementIssued, VtcError> {
let mut payload = serde_json::json!({
"subjectDid": subject_did,
"typeUri": type_uri,
"claim": claim,
});
if let Some(secs) = valid_for_seconds {
payload["validitySeconds"] = serde_json::json!(secs);
}
if let Some(ext) = ext {
payload["ext"] = ext;
}
let reply = self
.document(
task::ENDORSEMENTS_ISSUE,
payload,
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload(reply, "endorsements/issue")
}
pub async fn vetter_grants(&self) -> Result<Vec<vetting::VetterGrantRow>, VtcError> {
self.document_pages(
task::VETTING_VETTERS_GRANTS_LIST,
serde_json::json!({}),
"vetting/vetters/grants/list",
)
.await
}
pub async fn auto_grant(&self) -> Result<vetting::AutoGrantStatus, VtcError> {
let reply = self
.document(
task::VETTING_AUTO_GRANT_SHOW,
serde_json::json!({}),
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload::<AutoGrantShowResponse>(reply, "vetting/auto-grant/show")
.map(|r| r.auto_grant)
}
pub async fn configure_auto_grant(
&self,
config: &vetting::AutoGrantConfig,
) -> Result<vetting::AutoGrantStatus, VtcError> {
let payload = serde_json::to_value(config)
.map_err(|e| VtcError::Signing(format!("auto-grant config: {e}")))?;
let reply = self
.document(
task::VETTING_AUTO_GRANT_UPDATE,
payload,
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload::<AutoGrantShowResponse>(reply, "vetting/auto-grant/update")
.map(|r| r.auto_grant)
}
pub async fn branding(
&self,
) -> Result<join_requests::manifest::v0_2::CommunityBranding, VtcError> {
let reply = self
.document(
task::COMMUNITY_BRANDING_SHOW,
serde_json::json!({}),
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload::<BrandingResponse>(reply, "community/branding/show").map(|r| r.branding)
}
pub async fn set_branding(
&self,
branding: &join_requests::manifest::v0_2::CommunityBranding,
) -> Result<join_requests::manifest::v0_2::CommunityBranding, VtcError> {
let reply = self
.document(
task::COMMUNITY_BRANDING_UPDATE,
serde_json::json!({ "branding": branding }),
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload::<BrandingResponse>(reply, "community/branding/update").map(|r| r.branding)
}
pub async fn requested_attributes(
&self,
) -> Result<Vec<join_requests::manifest::v0_2::ResponseRequestedAttributesItem>, VtcError> {
let reply = self
.document(
task::COMMUNITY_REQUESTED_ATTRIBUTES_SHOW,
serde_json::json!({}),
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload::<RequestedAttributesResponse>(reply, "community/requested-attributes/show")
.map(|r| r.requested_attributes)
}
pub async fn set_requested_attributes(
&self,
requested: &[join_requests::manifest::v0_2::ResponseRequestedAttributesItem],
) -> Result<Vec<join_requests::manifest::v0_2::ResponseRequestedAttributesItem>, VtcError> {
let reply = self
.document(
task::COMMUNITY_REQUESTED_ATTRIBUTES_UPDATE,
serde_json::json!({ "requestedAttributes": requested }),
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload::<RequestedAttributesResponse>(
reply,
"community/requested-attributes/update",
)
.map(|r| r.requested_attributes)
}
pub async fn vetting_revocations(&self) -> Result<Vec<VettingRevocation>, VtcError> {
self.document_pages(
task::VETTING_REVOCATIONS_LIST,
serde_json::json!({}),
"vetting/revocations/list",
)
.await
}
pub async fn audit_verify(&self) -> Result<serde_json::Value, VtcError> {
self.document(
task::AUDIT_VERIFY,
serde_json::json!({}),
&[],
MAX_AUDIT_VERIFY_RESPONSE_BYTES,
)
.await
}
pub async fn export_backup(
&self,
password: &str,
include_audit: bool,
) -> Result<serde_json::Value, VtcError> {
use trust_tasks_rs::specs::backup::{
complete_export::v0_1 as complete, get_chunk::v0_1 as get_chunk,
initiate_export::v0_1 as initiate,
};
let bad = |why: String| VtcError::Http {
status: 200,
body: why,
};
let started = self
.backup_document(
<initiate::Payload as trust_tasks_rs::Payload>::TYPE_URI,
serde_json::json!({
"password": password,
"includeAudit": include_audit,
"algorithm": backup_chunks::ALGORITHM,
"maxChunkSize": backup_chunks::CHUNK_SIZE,
}),
initiate::ERROR_CODES,
)
.await?;
let d = &started["descriptor"];
let bundle_id = d["bundleId"]
.as_str()
.ok_or_else(|| bad("the export descriptor names no bundle".into()))?
.to_string();
let digests: Vec<String> = d["chunks"]["chunkDigests"]
.as_array()
.ok_or_else(|| bad("the export descriptor carries no chunk manifest".into()))?
.iter()
.map(|v| v.as_str().unwrap_or_default().to_string())
.collect();
let expected_sha = d["expectedSha256"].as_str().unwrap_or_default().to_string();
let expected_size = d["expectedSizeBytes"].as_u64().unwrap_or(0);
if expected_size as usize > MAX_BACKUP_RESPONSE_BYTES {
return Err(bad(format!(
"the export is {expected_size} bytes, over this client's {MAX_BACKUP_RESPONSE_BYTES}"
)));
}
let mut bytes = Vec::with_capacity(expected_size as usize);
for (index, digest) in digests.iter().enumerate() {
let chunk = self
.backup_document(
<get_chunk::Payload as trust_tasks_rs::Payload>::TYPE_URI,
serde_json::json!({ "bundleId": bundle_id, "index": index }),
get_chunk::ERROR_CODES,
)
.await?;
let data = backup_chunks::decode(chunk["data"].as_str().unwrap_or_default())
.ok_or_else(|| bad(format!("chunk {index} is not base64url")))?;
if backup_chunks::digest(&data) != *digest {
return Err(bad(format!(
"chunk {index} does not match the manifest the export committed to"
)));
}
bytes.extend_from_slice(&data);
if bytes.len() as u64 > expected_size {
return Err(bad("the chunks exceed the committed size".into()));
}
}
if bytes.len() as u64 != expected_size || backup_chunks::sha256_hex(&bytes) != expected_sha
{
return Err(bad(
"the assembled export does not match the committed digest and size".into(),
));
}
self.backup_document(
<complete::Payload as trust_tasks_rs::Payload>::TYPE_URI,
serde_json::json!({ "bundleId": bundle_id }),
complete::ERROR_CODES,
)
.await?;
match serde_json::from_slice::<serde_json::Value>(&bytes) {
Ok(envelope @ serde_json::Value::Object(_)) => Ok(envelope),
_ => Err(bad("the exported bundle is not a backup envelope".into())),
}
}
pub async fn import_backup(
&self,
backup: &serde_json::Value,
password: &str,
confirm: bool,
) -> Result<serde_json::Value, VtcError> {
use trust_tasks_rs::specs::backup::{
finalize_import::v0_1 as finalize, initiate_import::v0_1 as initiate,
put_chunk::v0_1 as put_chunk,
};
let bytes = serde_json::to_vec(backup).map_err(|e| VtcError::Http {
status: 0,
body: format!("serialise the backup: {e}"),
})?;
let chunks: Vec<&[u8]> = bytes.chunks(backup_chunks::CHUNK_SIZE as usize).collect();
let digests: Vec<String> = chunks.iter().map(|c| backup_chunks::digest(c)).collect();
let slot = self
.backup_document(
<initiate::Payload as trust_tasks_rs::Payload>::TYPE_URI,
serde_json::json!({
"algorithm": backup_chunks::ALGORITHM,
"expectedSha256": backup_chunks::sha256_hex(&bytes),
"expectedSizeBytes": bytes.len(),
"chunks": {
"chunkSize": backup_chunks::CHUNK_SIZE,
"chunkCount": chunks.len(),
"chunkDigests": digests,
},
}),
initiate::ERROR_CODES,
)
.await?;
let bundle_id = slot["descriptor"]["bundleId"]
.as_str()
.ok_or_else(|| VtcError::Http {
status: 200,
body: "the import slot names no bundle".into(),
})?
.to_string();
for (index, chunk) in chunks.iter().enumerate() {
self.backup_document(
<put_chunk::Payload as trust_tasks_rs::Payload>::TYPE_URI,
serde_json::json!({
"bundleId": bundle_id,
"index": index,
"digestMultibase": digests[index],
"data": backup_chunks::encode(chunk),
}),
put_chunk::ERROR_CODES,
)
.await?;
}
self.backup_document(
<finalize::Payload as trust_tasks_rs::Payload>::TYPE_URI,
serde_json::json!({ "bundleId": bundle_id, "password": password, "confirm": confirm }),
finalize::ERROR_CODES,
)
.await
}
async fn backup_document(
&self,
type_uri: &str,
payload: serde_json::Value,
declared: &[trust_tasks_rs::DeclaredErrorCode],
) -> Result<serde_json::Value, VtcError> {
#[cfg(feature = "didcomm")]
if self.documents.is_some() {
return self
.document(type_uri, payload, declared, MAX_BACKUP_RESPONSE_BYTES)
.await;
}
let _ = (type_uri, payload, declared);
Err(VtcError::Session(
"a community backup moves only over DIDComm or TSP: connect with connect_didcomm \
or connect_tsp (the VTC refuses a backup over REST)"
.into(),
))
}
}
fn decode_payload<T: serde::de::DeserializeOwned>(
payload: serde_json::Value,
verb: &str,
) -> Result<T, VtcError> {
serde_json::from_value(payload).map_err(|e| VtcError::Http {
status: 200,
body: format!("{verb} response payload has an unexpected shape: {e}"),
})
}
fn document_error(
status: u16,
payload: &serde_json::Value,
body: String,
declared: &[trust_tasks_rs::DeclaredErrorCode],
) -> VtcError {
let code = payload.get("code").and_then(serde_json::Value::as_str);
let reason = payload
.pointer("/details/reason")
.and_then(serde_json::Value::as_str);
if let Some(code) = code
&& declared.iter().any(|d| d.code == code)
&& (reason == Some(vta_sdk::protocols::trust_task_reject_reasons::NOT_FOUND)
|| status == 404)
{
return VtcError::NotFound {
code: code.to_string(),
message: payload
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
};
}
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()))
}
#[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);
}
#[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.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 row: VettingRevocation = serde_json::from_value(serde_json::json!({
"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!(row.review_state, "needsReview");
assert_eq!(row.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 a_declared_not_found_document_refusal_is_typed() {
let code = members_credentials::error_codes::NOT_FOUND.code;
let payload = serde_json::json!({
"code": code,
"message": "member not found",
"details": { "reason": vta_sdk::protocols::trust_task_reject_reasons::NOT_FOUND },
});
match document_error(
422,
&payload,
String::new(),
members_credentials::ERROR_CODES,
) {
VtcError::NotFound { code: got, message } => {
assert_eq!(got, code);
assert_eq!(message, "member not found");
}
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn a_document_refusal_is_not_found_only_when_both_halves_say_so() {
let undeclared = serde_json::json!({
"code": "permissionDenied",
"details": { "reason": vta_sdk::protocols::trust_task_reject_reasons::NOT_FOUND },
});
assert!(matches!(
document_error(
403,
&undeclared,
"b".into(),
members_credentials::ERROR_CODES
),
VtcError::Http { status: 403, .. }
));
let unmarked = serde_json::json!({
"code": members_credentials::error_codes::NOT_FOUND.code,
});
assert!(matches!(
document_error(422, &unmarked, "b".into(), members_credentials::ERROR_CODES),
VtcError::Http { status: 422, .. }
));
}
#[tokio::test]
async fn an_admin_verb_without_a_rest_base_says_so() {
let key = HolderKey::from_did_key(
"did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG",
"z3u2en7t5LR2WtQH5PfFqMqwVHBeXouLzo6haApm8XHqvjxq",
)
.unwrap();
let client = VtcClient::with_key("", "did:webvh:QmScid:example.com:acme", key);
let err = client
.list_members(None)
.await
.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 {
token: Some("super-secret-bearer-token".to_string()),
..VtcClient::anonymous(
"https://vtc.example.com/v1",
"did:webvh:QmScid:example.com:acme",
)
};
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_a_key_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,
signer: None,
#[cfg(feature = "didcomm")]
documents: None,
#[cfg(feature = "didcomm")]
session_did: 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", "DTGCredential", "MembershipCredential"] },
"roleVac": 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_vac.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_a_key_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,
signer: None,
#[cfg(feature = "didcomm")]
documents: None,
#[cfg(feature = "didcomm")]
session_did: 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_a_key_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,
signer: None,
#[cfg(feature = "didcomm")]
documents: None,
#[cfg(feature = "didcomm")]
session_did: 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(_))
));
}
#[tokio::test]
async fn a_backup_needs_a_session() {
let client = VtcClient::anonymous("https://vtc.example.com/v1", "did:web:vtc");
for err in [
client
.export_backup("a-long-enough-password", false)
.await
.unwrap_err(),
client
.import_backup(&serde_json::json!({}), "a-long-enough-password", false)
.await
.unwrap_err(),
] {
assert!(
matches!(&err, VtcError::Session(m) if m.contains("DIDComm or TSP")),
"{err}"
);
}
}
#[test]
fn backup_chunks_encode_and_digest_consistently() {
let bytes = b"community backup bytes";
assert_eq!(
backup_chunks::decode(&backup_chunks::encode(bytes)).unwrap(),
bytes
);
assert_eq!(backup_chunks::digest(bytes), backup_chunks::digest(bytes));
assert_ne!(
backup_chunks::digest(bytes),
backup_chunks::digest(b"other bytes")
);
assert_eq!(
backup_chunks::sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
}