use crate::error::VtaError;
use reqwest::{Client, RequestBuilder};
#[derive(Clone)]
pub(super) struct AuthCredential {
pub(super) did: String,
pub(super) private_key_multibase: String,
pub(super) vta_did: String,
}
pub(super) struct RestAuth {
pub(super) token: Option<String>,
pub(super) expires_at: Option<u64>,
pub(super) refresh_token: Option<String>,
pub(super) refresh_expires_at: Option<u64>,
pub(super) credential: Option<AuthCredential>,
}
#[derive(Clone)]
pub(super) enum Transport {
Rest {
client: Client,
base_url: String,
auth: std::sync::Arc<tokio::sync::Mutex<RestAuth>>,
},
#[cfg(feature = "session")]
DIDComm {
session: crate::didcomm_session::DIDCommSession,
rest_client: Option<Client>,
rest_url: Option<String>,
#[cfg(feature = "tsp")]
tsp: Option<TspLeg>,
},
#[cfg(feature = "tsp")]
Tsp {
session: std::sync::Arc<crate::session::TspSession>,
vta_did: String,
mediator_did: String,
rest_client: Option<Client>,
rest_url: Option<String>,
},
}
#[cfg(all(feature = "session", feature = "tsp"))]
#[derive(Clone)]
pub(super) enum TspLeg {
Multiplexed,
Separate {
session: std::sync::Arc<crate::session::TspSession>,
mediator_did: String,
},
}
#[cfg(all(feature = "session", feature = "tsp"))]
pub(super) fn tsp_leg_kind(didcomm_mediator_did: &str, tsp_mediator_did: &str) -> TspLegKind {
if didcomm_mediator_did == tsp_mediator_did {
TspLegKind::Multiplexed
} else {
TspLegKind::Separate
}
}
#[cfg(all(feature = "session", feature = "tsp"))]
#[derive(Debug, PartialEq, Eq)]
pub(super) enum TspLegKind {
Multiplexed,
Separate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SurfaceTransport {
Rest,
Didcomm,
Tsp,
}
impl std::fmt::Display for SurfaceTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Rest => write!(f, "REST"),
Self::Didcomm => write!(f, "DIDComm"),
Self::Tsp => write!(f, "TSP"),
}
}
}
#[derive(Clone)]
pub struct VtaClient {
pub(super) transport: Transport,
#[cfg(feature = "test-loopback")]
pub(super) loopback: Option<std::sync::Arc<dyn loopback::LoopbackSink>>,
}
pub use crate::protocols::context_management::delete::{
DeleteContextPreviewResultBody as DeleteContextPreviewResponse,
DeleteContextResultBody as DeleteContextResponse,
};
pub use crate::protocols::did_management::create::CreateDidWebvhResultBody as CreateDidWebvhResponse;
pub use crate::protocols::did_management::list::ListDidsWebvhResultBody as ListDidsWebvhResponse;
pub use crate::protocols::did_management::servers::ListWebvhServersResultBody as ListWebvhServersResponse;
pub use crate::did_templates::{
BUILTIN_NAMES as DID_TEMPLATE_BUILTINS, DidTemplate, DidTemplateRecord,
Scope as DidTemplateScope, TemplateError as DidTemplateError, TemplateVars,
};
mod types;
pub use types::*;
mod acl;
mod agent_devices;
#[cfg(feature = "session")]
mod auto_connect;
mod backup;
mod backup_descriptors;
mod bootstrap;
mod consent;
mod contexts;
mod credentials;
mod did_templates;
mod keys;
#[cfg(feature = "test-loopback")]
pub mod loopback;
mod memory;
mod policy;
mod secrets;
mod vault;
mod vta_management;
mod webvh;
pub use webvh::flatten_with_did;
#[cfg(feature = "client")]
mod audit;
#[cfg(feature = "session")]
pub use crate::session::TokenResult;
#[cfg(feature = "session")]
pub use auto_connect::{AutoConnect, ConnectedVta};
pub(super) fn encode_path_segment(s: &str) -> String {
s.replace('%', "%25")
.replace('#', "%23")
.replace('?', "%3F")
.replace('/', "%2F")
}
#[cfg(feature = "tsp")]
fn unsupported_over_tsp(msg_type: &str) -> VtaError {
VtaError::UnsupportedTransport(format!(
"'{msg_type}' is a DIDComm protocol message, which TSP does not carry \
(TSP carries Trust Tasks). Reach this operation over DIDComm:\n \
<cli> --transport didcomm <command>"
))
}
impl VtaClient {
pub(super) fn with_auth_token(req: RequestBuilder, token: &Option<String>) -> RequestBuilder {
match token {
Some(token) => req.bearer_auth(token),
None => req,
}
}
pub(super) async fn handle_response<T: serde::de::DeserializeOwned>(
resp: reqwest::Response,
) -> Result<T, VtaError> {
if resp.status().is_success() {
Ok(resp.json::<T>().await?)
} else {
let status = resp.status();
let text = resp.text().await?;
if status == reqwest::StatusCode::CONFLICT {
return Err(VtaError::Conflict(text));
}
let body = Self::extract_error_message(&text);
Err(VtaError::from_http(status, body))
}
}
fn extract_error_message(text: &str) -> String {
const MAX_RAW_LEN: usize = 256;
serde_json::from_str::<ErrorResponse>(text)
.map(|e| e.error)
.unwrap_or_else(|_| {
if text.is_empty() {
"unknown error".to_string()
} else {
let truncated: String = text.chars().take(MAX_RAW_LEN).collect();
let ellipsis = if truncated.len() < text.len() {
"…"
} else {
""
};
format!("unknown error: {truncated}{ellipsis}")
}
})
}
}
impl VtaClient {
pub fn new(base_url: &str) -> Self {
Self {
#[cfg(feature = "test-loopback")]
loopback: None,
transport: Transport::Rest {
client: crate::http::rest_client(),
base_url: base_url.trim_end_matches('/').to_string(),
auth: std::sync::Arc::new(tokio::sync::Mutex::new(RestAuth {
token: None,
expires_at: None,
refresh_token: None,
refresh_expires_at: None,
credential: None,
})),
},
}
}
pub async fn from_credential(
credential: &crate::credentials::CredentialBundle,
url_override: Option<&str>,
) -> Result<Self, VtaError> {
let (result, cred, http) =
crate::auth_light::authenticate_with_credential(credential, url_override).await?;
let base_url = url_override
.or(cred.vta_url.as_deref())
.ok_or_else(|| VtaError::Validation("no VTA URL".into()))?
.trim_end_matches('/')
.to_string();
Ok(Self {
#[cfg(feature = "test-loopback")]
loopback: None,
transport: Transport::Rest {
client: http,
base_url,
auth: std::sync::Arc::new(tokio::sync::Mutex::new(RestAuth {
token: Some(result.access_token),
expires_at: Some(result.access_expires_at),
refresh_token: result.refresh_token,
refresh_expires_at: result.refresh_expires_at,
credential: Some(AuthCredential {
did: cred.did,
private_key_multibase: cred.private_key_multibase,
vta_did: cred.vta_did,
}),
})),
},
})
}
pub async fn token_expires_at(&self) -> Option<u64> {
match &self.transport {
Transport::Rest { auth, .. } => auth.lock().await.expires_at,
#[cfg(feature = "session")]
Transport::DIDComm { .. } => None,
#[cfg(feature = "tsp")]
Transport::Tsp { .. } => None,
}
}
#[cfg(feature = "session")]
pub async fn connect_didcomm(
client_did: &str,
private_key_multibase: &str,
vta_did: &str,
mediator_did: &str,
rest_url: Option<String>,
) -> Result<Self, VtaError> {
let session = crate::didcomm_session::DIDCommSession::connect(
client_did,
private_key_multibase,
vta_did,
mediator_did,
)
.await
.map_err(|e| VtaError::DidcommTransport(e.to_string()))?;
Ok(Self::didcomm_transport(session, rest_url))
}
#[cfg(feature = "session")]
fn didcomm_transport(
session: crate::didcomm_session::DIDCommSession,
rest_url: Option<String>,
) -> Self {
let rest_client = rest_url.as_ref().map(|_| crate::http::rest_client());
Self {
#[cfg(feature = "test-loopback")]
loopback: None,
transport: Transport::DIDComm {
session,
rest_client,
rest_url: rest_url.map(|u| u.trim_end_matches('/').to_string()),
#[cfg(feature = "tsp")]
tsp: None,
},
}
}
#[cfg(feature = "session")]
pub async fn connect_didcomm_on(
hub: &std::sync::Arc<crate::session_hub::SessionHub>,
client_did: &str,
private_key_multibase: &str,
vta_did: &str,
mediator_did: &str,
rest_url: Option<String>,
) -> Result<Self, VtaError> {
let session = crate::didcomm_session::DIDCommSession::connect_on(
hub,
client_did,
private_key_multibase,
vta_did,
mediator_did,
)
.await
.map_err(|e| VtaError::DidcommTransport(e.to_string()))?;
Ok(Self::didcomm_transport(session, rest_url))
}
#[cfg(feature = "session")]
pub async fn connect_didcomm_bundle(
bundle: &crate::did_secrets::DidSecretsBundle,
vta_did: &str,
mediator_did: &str,
rest_url: Option<String>,
) -> Result<Self, VtaError> {
let secrets = crate::did_key::secrets_from_bundle(bundle)
.map_err(|e| VtaError::DidcommTransport(e.to_string()))?;
let session = crate::didcomm_session::DIDCommSession::connect_with_secrets(
&bundle.did,
secrets,
vta_did,
mediator_did,
)
.await
.map_err(|e| VtaError::DidcommTransport(e.to_string()))?;
Ok(Self::didcomm_transport(session, rest_url))
}
#[cfg(feature = "session")]
pub async fn connect_didcomm_bundle_on(
hub: &std::sync::Arc<crate::session_hub::SessionHub>,
bundle: &crate::did_secrets::DidSecretsBundle,
vta_did: &str,
mediator_did: &str,
rest_url: Option<String>,
) -> Result<Self, VtaError> {
let secrets = crate::did_key::secrets_from_bundle(bundle)
.map_err(|e| VtaError::DidcommTransport(e.to_string()))?;
let session = crate::didcomm_session::DIDCommSession::connect_with_secrets_on(
hub,
&bundle.did,
secrets,
vta_did,
mediator_did,
)
.await
.map_err(|e| VtaError::DidcommTransport(e.to_string()))?;
Ok(Self::didcomm_transport(session, rest_url))
}
#[cfg(feature = "tsp")]
pub async fn connect_tsp(
client_did: &str,
private_key_multibase: &str,
vta_did: &str,
mediator_did: &str,
rest_url: Option<String>,
) -> Result<Self, VtaError> {
let session =
crate::session::TspSession::connect(client_did, private_key_multibase, mediator_did)
.await
.map_err(|e| VtaError::TspTransport(e.to_string()))?;
Ok(Self::tsp_transport(
session,
vta_did,
mediator_did,
rest_url,
))
}
#[cfg(all(feature = "session", feature = "tsp"))]
pub async fn connect_tsp_on(
hub: &std::sync::Arc<crate::session_hub::SessionHub>,
client_did: &str,
private_key_multibase: &str,
vta_did: &str,
mediator_did: &str,
rest_url: Option<String>,
) -> Result<Self, VtaError> {
let session = crate::session::TspSession::connect_on(
hub,
client_did,
private_key_multibase,
mediator_did,
)
.await
.map_err(|e| VtaError::TspTransport(e.to_string()))?;
Ok(Self::tsp_transport(
session,
vta_did,
mediator_did,
rest_url,
))
}
#[cfg(all(feature = "session", feature = "tsp"))]
fn tsp_transport(
session: crate::session::TspSession,
vta_did: &str,
mediator_did: &str,
rest_url: Option<String>,
) -> Self {
let rest_client = rest_url.as_ref().map(|_| crate::http::rest_client());
Self {
#[cfg(feature = "test-loopback")]
loopback: None,
transport: Transport::Tsp {
session: std::sync::Arc::new(session),
vta_did: vta_did.to_string(),
mediator_did: mediator_did.to_string(),
rest_client,
rest_url: rest_url.map(|u| u.trim_end_matches('/').to_string()),
},
}
}
#[cfg(all(feature = "session", feature = "tsp"))]
pub fn enable_tsp_trust_tasks(&mut self, tsp_mediator_did: &str) -> Result<(), VtaError> {
let Transport::DIDComm { session, tsp, .. } = &mut self.transport else {
return Err(VtaError::Validation(
"enable_tsp_trust_tasks needs a DIDComm client — TSP rides its mediator \
session. Connect with `connect_didcomm` first, or use `connect_tsp` for a \
TSP-only client."
.into(),
));
};
match tsp_leg_kind(session.mediator_did(), tsp_mediator_did) {
TspLegKind::Multiplexed => {
*tsp = Some(TspLeg::Multiplexed);
Ok(())
}
TspLegKind::Separate => Err(VtaError::Validation(format!(
"this VTA advertises its TSP mediator ({tsp_mediator_did}) separately from \
its DIDComm mediator ({}), so TSP cannot ride the DIDComm session — build \
a TspSession against the TSP mediator and pass it to `attach_tsp_leg`, or \
use `connect_didcomm_with_tsp`, which does both.",
session.mediator_did()
))),
}
}
#[cfg(all(feature = "session", feature = "tsp"))]
pub fn attach_tsp_leg(
&mut self,
tsp_session: std::sync::Arc<crate::session::TspSession>,
tsp_mediator_did: &str,
) -> Result<(), VtaError> {
let Transport::DIDComm { session, tsp, .. } = &mut self.transport else {
return Err(VtaError::Validation(
"attach_tsp_leg needs a DIDComm client — the TSP leg is the Trust-Task half \
of a two-transport client. Use `connect_tsp` for a TSP-only client."
.into(),
));
};
if tsp_leg_kind(session.mediator_did(), tsp_mediator_did) == TspLegKind::Multiplexed {
return Err(VtaError::Validation(format!(
"refusing to attach a second session for {} on mediator {tsp_mediator_did}: \
the mediator permits one websocket per DID, so this would be evicted as \
`duplicate-channel`. This VTA advertises the same mediator for TSP and \
DIDComm — call `enable_tsp_trust_tasks` instead (no second socket needed).",
session.client_did()
)));
}
*tsp = Some(TspLeg::Separate {
session: tsp_session,
mediator_did: tsp_mediator_did.to_string(),
});
Ok(())
}
#[cfg(all(feature = "session", feature = "tsp"))]
pub async fn connect_didcomm_with_tsp(
client_did: &str,
private_key_multibase: &str,
vta_did: &str,
mediator_did: &str,
tsp_mediator_did: &str,
rest_url: Option<String>,
) -> Result<Self, VtaError> {
let mut client = Self::connect_didcomm(
client_did,
private_key_multibase,
vta_did,
mediator_did,
rest_url,
)
.await?;
let attached = match tsp_leg_kind(mediator_did, tsp_mediator_did) {
TspLegKind::Multiplexed => client.enable_tsp_trust_tasks(tsp_mediator_did),
TspLegKind::Separate => {
tracing::debug!(
didcomm_mediator = %mediator_did,
tsp_mediator = %tsp_mediator_did,
"VTA advertises a separate TSP mediator; connecting a TSP session for it"
);
match crate::session::TspSession::connect(
client_did,
private_key_multibase,
tsp_mediator_did,
)
.await
{
Ok(s) => client.attach_tsp_leg(std::sync::Arc::new(s), tsp_mediator_did),
Err(e) => Err(VtaError::TspTransport(e.to_string())),
}
}
};
if let Err(e) = attached {
client.shutdown().await;
return Err(e);
}
Ok(client)
}
pub fn trust_task_transport(&self) -> SurfaceTransport {
match &self.transport {
Transport::Rest { .. } => SurfaceTransport::Rest,
#[cfg(feature = "tsp")]
Transport::Tsp { .. } => SurfaceTransport::Tsp,
#[cfg(feature = "session")]
Transport::DIDComm {
#[cfg(feature = "tsp")]
tsp,
..
} => {
#[cfg(feature = "tsp")]
if tsp.is_some() {
return SurfaceTransport::Tsp;
}
SurfaceTransport::Didcomm
}
}
}
pub fn protocol_message_transport(&self) -> SurfaceTransport {
match &self.transport {
Transport::Rest { .. } => SurfaceTransport::Rest,
#[cfg(feature = "session")]
Transport::DIDComm { .. } => SurfaceTransport::Didcomm,
#[cfg(feature = "tsp")]
Transport::Tsp { .. } => SurfaceTransport::Tsp,
}
}
pub fn set_token(&self, token: String) {
match &self.transport {
Transport::Rest { auth, .. } => {
if let Ok(mut guard) = auth.try_lock() {
guard.token = Some(token);
}
}
#[cfg(feature = "session")]
Transport::DIDComm { .. } => {}
#[cfg(feature = "tsp")]
Transport::Tsp { .. } => {}
}
}
pub async fn set_token_async(&self, token: String) {
match &self.transport {
Transport::Rest { auth, .. } => {
auth.lock().await.token = Some(token);
}
#[cfg(feature = "session")]
Transport::DIDComm { .. } => {}
#[cfg(feature = "tsp")]
Transport::Tsp { .. } => {}
}
}
pub fn rest_url(&self) -> Option<&str> {
match &self.transport {
Transport::Rest { base_url, .. } => Some(base_url),
#[cfg(feature = "session")]
Transport::DIDComm { rest_url, .. } => rest_url.as_deref(),
#[cfg(feature = "tsp")]
Transport::Tsp { rest_url, .. } => rest_url.as_deref(),
}
}
pub fn vta_did(&self) -> Option<&str> {
match &self.transport {
Transport::Rest { .. } => None,
#[cfg(feature = "session")]
Transport::DIDComm { session, .. } => Some(&session.vta_did),
#[cfg(feature = "tsp")]
Transport::Tsp { vta_did, .. } => Some(vta_did),
}
}
pub fn endpoint_label(&self) -> &str {
match &self.transport {
Transport::Rest { base_url, .. } => base_url,
#[cfg(feature = "session")]
Transport::DIDComm {
session, rest_url, ..
} => rest_url.as_deref().unwrap_or(&session.vta_did),
#[cfg(feature = "tsp")]
Transport::Tsp {
vta_did, rest_url, ..
} => rest_url.as_deref().unwrap_or(vta_did),
}
}
pub async fn shutdown(&self) {
#[cfg(feature = "session")]
if let Transport::DIDComm { session, .. } = &self.transport {
session.shutdown().await;
}
#[cfg(all(feature = "session", feature = "tsp"))]
if let Transport::DIDComm {
tsp: Some(TspLeg::Separate { session, .. }),
..
} = &self.transport
{
session.shutdown().await;
}
#[cfg(feature = "tsp")]
if let Transport::Tsp { session, .. } = &self.transport {
session.shutdown().await;
}
}
#[cfg(feature = "session")]
pub async fn with_didcomm<F, Fut, T>(
client_did: &str,
private_key_multibase: &str,
vta_did: &str,
mediator_did: &str,
rest_url: Option<String>,
f: F,
) -> Result<T, VtaError>
where
F: FnOnce(VtaClient) -> Fut,
Fut: std::future::Future<Output = Result<T, VtaError>>,
{
let client = Self::connect_didcomm(
client_did,
private_key_multibase,
vta_did,
mediator_did,
rest_url,
)
.await?;
let result = f(client.clone()).await;
client.shutdown().await;
result
}
pub(super) async fn ensure_token_valid(
client: &Client,
base_url: &str,
auth: &tokio::sync::Mutex<RestAuth>,
) -> Result<(), VtaError> {
let mut guard = auth.lock().await;
if let Some(expires_at) = guard.expires_at {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now + 30 < expires_at {
return Ok(()); }
} else if guard.token.is_some() {
return Ok(());
}
let Some(ref cred) = guard.credential else {
return Ok(());
};
if let Some(ref refresh_tok) = guard.refresh_token
&& let Some(refresh_exp) = guard.refresh_expires_at
{
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now < refresh_exp
&& let Ok(result) = crate::auth_light::refresh_token_light(
client,
base_url,
&cred.did,
&cred.vta_did,
refresh_tok,
)
.await
{
guard.token = Some(result.access_token);
guard.expires_at = Some(result.access_expires_at);
if let Some(new_refresh) = result.refresh_token {
guard.refresh_token = Some(new_refresh);
}
guard.refresh_expires_at = result.refresh_expires_at;
return Ok(());
}
}
let did = cred.did.clone();
let pk = cred.private_key_multibase.clone();
let vta = cred.vta_did.clone();
drop(guard);
let result =
crate::auth_light::challenge_response_light(client, base_url, &did, &pk, &vta).await?;
let mut guard = auth.lock().await;
guard.token = Some(result.access_token);
guard.expires_at = Some(result.access_expires_at);
guard.refresh_token = result.refresh_token;
guard.refresh_expires_at = result.refresh_expires_at;
Ok(())
}
pub(super) async fn force_reauth(
client: &Client,
base_url: &str,
auth: &tokio::sync::Mutex<RestAuth>,
) -> Result<bool, VtaError> {
let cred = {
let mut guard = auth.lock().await;
let Some(cred) = guard.credential.clone() else {
return Ok(false);
};
guard.token = None;
guard.expires_at = None;
guard.refresh_token = None;
guard.refresh_expires_at = None;
cred
};
let result = crate::auth_light::challenge_response_light(
client,
base_url,
&cred.did,
&cred.private_key_multibase,
&cred.vta_did,
)
.await?;
let mut guard = auth.lock().await;
guard.token = Some(result.access_token);
guard.expires_at = Some(result.access_expires_at);
guard.refresh_token = result.refresh_token;
guard.refresh_expires_at = result.refresh_expires_at;
Ok(true)
}
pub(super) async fn send_authed(
client: &Client,
base_url: &str,
auth: &tokio::sync::Mutex<RestAuth>,
req: RequestBuilder,
) -> Result<reqwest::Response, VtaError> {
Self::ensure_token_valid(client, base_url, auth).await?;
let retry_req = req.try_clone();
let token = auth.lock().await.token.clone();
let resp = Self::with_auth_token(req, &token).send().await?;
let status = resp.status();
if matches!(
status,
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
) && let Some(retry_req) = retry_req
{
match Self::force_reauth(client, base_url, auth).await {
Ok(true) => {
let token = auth.lock().await.token.clone();
return Ok(Self::with_auth_token(retry_req, &token).send().await?);
}
Ok(false) => {}
Err(e) => {
tracing::debug!(
%status,
error = %e,
"re-auth after auth rejection failed; surfacing original response"
);
}
}
}
Ok(resp)
}
#[allow(unused_variables)]
pub fn caller_did(&self) -> Option<&str> {
match &self.transport {
Transport::Rest { .. } => None,
#[cfg(feature = "session")]
Transport::DIDComm { session, .. } => Some(session.client_did()),
#[cfg(feature = "tsp")]
Transport::Tsp { session, .. } => Some(session.client_did()),
}
}
pub(crate) async fn rpc<T: serde::de::DeserializeOwned>(
&self,
msg_type: &str,
body: serde_json::Value,
result_type: &str,
timeout: u64,
build_rest: impl FnOnce(&Client, &str) -> RequestBuilder,
) -> Result<T, VtaError> {
match &self.transport {
Transport::Rest {
client,
base_url,
auth,
} => {
let req = build_rest(client, base_url);
let resp = Self::send_authed(client, base_url, auth, req).await?;
Self::handle_response(resp).await
}
#[cfg(feature = "session")]
Transport::DIDComm { session, .. } => {
session
.send_and_wait(msg_type, body, result_type, timeout)
.await
}
#[cfg(feature = "tsp")]
Transport::Tsp { .. } => Err(unsupported_over_tsp(msg_type)),
}
}
#[cfg_attr(not(feature = "session"), allow(unused_variables))]
pub(crate) async fn rpc_tt<T: serde::de::DeserializeOwned>(
&self,
tt_uri: &str,
payload: serde_json::Value,
timeout: u64,
) -> Result<T, VtaError> {
#[cfg(feature = "test-loopback")]
if let Some(sink) = &self.loopback {
let response = sink.dispatch(tt_uri, &payload)?;
return serde_json::from_value(response)
.map_err(|e| VtaError::Protocol(format!("loopback response decode: {e}")));
}
match &self.transport {
Transport::Rest { .. } => {
let payload = self.dispatch_trust_task(tt_uri, payload, timeout).await?;
serde_json::from_value(payload)
.map_err(|e| VtaError::Protocol(format!("trust-task response decode: {e}")))
}
#[cfg(feature = "session")]
Transport::DIDComm { .. } => {
let payload = self.dispatch_trust_task(tt_uri, payload, timeout).await?;
serde_json::from_value(payload)
.map_err(|e| VtaError::Protocol(format!("trust-task response decode: {e}")))
}
#[cfg(feature = "tsp")]
Transport::Tsp { .. } => {
let payload = self.dispatch_trust_task(tt_uri, payload, timeout).await?;
serde_json::from_value(payload)
.map_err(|e| VtaError::Protocol(format!("trust-task response decode: {e}")))
}
}
}
#[cfg_attr(not(feature = "session"), allow(unused_variables))]
pub(crate) async fn rpc_tt_void(
&self,
tt_uri: &str,
payload: serde_json::Value,
timeout: u64,
) -> Result<(), VtaError> {
#[cfg(feature = "test-loopback")]
if let Some(sink) = &self.loopback {
sink.dispatch(tt_uri, &payload)?;
return Ok(());
}
match &self.transport {
Transport::Rest { .. } => {
let _ = self.dispatch_trust_task(tt_uri, payload, timeout).await?;
Ok(())
}
#[cfg(feature = "session")]
Transport::DIDComm { .. } => {
let _ = self.dispatch_trust_task(tt_uri, payload, timeout).await?;
Ok(())
}
#[cfg(feature = "tsp")]
Transport::Tsp { .. } => {
let _ = self.dispatch_trust_task(tt_uri, payload, timeout).await?;
Ok(())
}
}
}
#[cfg_attr(not(feature = "session"), allow(unused_variables))]
fn check_payload_conforms(type_uri: &str, payload: &serde_json::Value) -> Result<(), VtaError> {
let Some(schema) = trust_tasks_rs::schema_index::schema_for(type_uri) else {
return Ok(());
};
trust_tasks_rs::validate::against_schema(schema, payload).map_err(|e| {
VtaError::Protocol(format!(
"refusing to send a payload that does not conform to {type_uri}: {e}. \
The recipient would reject this as `malformedRequest`. An unset optional \
member must be ABSENT from the payload, not `null`."
))
})
}
pub async fn idempotent<F, Fut, T>(&self, mut op: F) -> Result<T, VtaError>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, VtaError>>,
{
use crate::idempotency::{IDEMPOTENCY_KEY, MAX_ATTEMPTS, backoff_for, is_transient};
let key = crate::idempotency::new_key();
IDEMPOTENCY_KEY
.scope(key.clone(), async move {
let mut attempt = 1usize;
loop {
match op().await {
Ok(v) => return Ok(v),
Err(e) if attempt < MAX_ATTEMPTS && is_transient(&e) => {
let wait = backoff_for(&e, attempt);
#[cfg(feature = "client")]
tracing::warn!(
idempotency_key = %key,
attempt,
max = MAX_ATTEMPTS,
error = %e,
"VTA call failed; retrying under the same idempotency key in {wait:?}"
);
if !wait.is_zero() {
tokio::time::sleep(wait).await;
}
attempt += 1;
}
Err(e) => return Err(e),
}
}
})
.await
}
pub async fn dispatch_trust_task(
&self,
type_uri: &str,
payload: serde_json::Value,
timeout: u64,
) -> Result<serde_json::Value, VtaError> {
Self::check_payload_conforms(type_uri, &payload)?;
#[cfg(feature = "test-loopback")]
if let Some(sink) = &self.loopback {
return sink.dispatch(type_uri, &payload);
}
let doc = build_task_document(type_uri, payload);
match &self.transport {
Transport::Rest {
client,
base_url,
auth,
} => {
let req = client
.post(format!("{base_url}/trust-tasks"))
.json(&doc);
let resp = Self::send_authed(client, base_url, auth, req).await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if let Ok(doc) = serde_json::from_str::<serde_json::Value>(&text)
&& let Some(payload) = doc.get("payload")
&& let Some(err) = Self::trust_task_error(payload)
{
return Err(err);
}
if status == reqwest::StatusCode::CONFLICT {
return Err(VtaError::Conflict(text));
}
return Err(VtaError::from_http(
status,
Self::extract_error_message(&text),
));
}
let response_doc: serde_json::Value = resp.json().await?;
Self::extract_trust_task_payload(response_doc)
}
#[cfg(feature = "tsp")]
Transport::Tsp {
session,
vta_did,
mediator_did,
..
} => {
let body = Self::address_trust_task(doc, session.client_did(), vta_did)?;
let reply = session
.request(
vta_did,
mediator_did,
&body,
std::time::Duration::from_secs(timeout),
)
.await
.map_err(|e| VtaError::TspTransport(e.to_string()))?;
Self::extract_trust_task_payload(Self::decode_trust_task_reply(&reply)?)
}
#[cfg(feature = "session")]
Transport::DIDComm {
session,
#[cfg(feature = "tsp")]
tsp,
..
} => {
#[cfg(feature = "tsp")]
if let Some(leg) = tsp {
let body =
Self::address_trust_task(doc, session.client_did(), &session.vta_did)?;
let timeout = std::time::Duration::from_secs(timeout);
let reply = match leg {
TspLeg::Multiplexed => {
session
.request_tsp(&session.vta_did, &body, timeout)
.await?
}
TspLeg::Separate {
session: tsp_session,
mediator_did,
} => tsp_session
.request(&session.vta_did, mediator_did, &body, timeout)
.await
.map_err(|e| VtaError::TspTransport(e.to_string()))?,
};
return Self::extract_trust_task_payload(Self::decode_trust_task_reply(
&reply,
)?);
}
const TRUST_TASK_ENVELOPE_TYPE: &str =
"https://trusttasks.org/binding/didcomm/0.1/envelope";
let response_doc: serde_json::Value = session
.send_and_wait(
TRUST_TASK_ENVELOPE_TYPE,
doc,
TRUST_TASK_ENVELOPE_TYPE,
timeout,
)
.await?;
Self::extract_trust_task_payload(response_doc)
}
}
}
#[cfg(feature = "tsp")]
fn address_trust_task(
mut doc: serde_json::Value,
issuer: &str,
recipient: &str,
) -> Result<Vec<u8>, VtaError> {
doc["issuer"] = serde_json::Value::String(issuer.to_string());
doc["recipient"] = serde_json::Value::String(recipient.to_string());
serde_json::to_vec(&doc).map_err(|e| VtaError::Protocol(format!("trust-task encode: {e}")))
}
#[cfg(feature = "tsp")]
fn decode_trust_task_reply(reply: &str) -> Result<serde_json::Value, VtaError> {
serde_json::from_str(reply)
.map_err(|e| VtaError::Protocol(format!("trust-task reply decode: {e}")))
}
fn extract_trust_task_payload(doc: serde_json::Value) -> Result<serde_json::Value, VtaError> {
if let Some(payload) = doc.get("payload") {
if let Some(err) = Self::trust_task_error(payload) {
return Err(err);
}
return Ok(payload.clone());
}
let reason = doc
.get("reason")
.or_else(|| doc.get("comment"))
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| doc.to_string());
Err(VtaError::Protocol(format!("trust task rejected: {reason}")))
}
fn trust_task_error(payload: &serde_json::Value) -> Option<VtaError> {
let code = payload.get("code")?.as_str()?;
let message = payload.get("message")?.as_str()?;
if let Some(details) = payload.get("details")
&& details.get("reason").and_then(|r| r.as_str()) == Some("auth:consent_required")
{
let s = |k: &str| {
details
.get(k)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
};
return Some(VtaError::ConsentRequired {
payload_digest: s("payloadDigest"),
challenge: s("challenge"),
approver_set: s("approverSet"),
min_approvals: details
.get("minApprovals")
.and_then(serde_json::Value::as_u64)
.unwrap_or(1) as u32,
exclude_requester: details
.get("excludeRequester")
.and_then(serde_json::Value::as_bool)
.unwrap_or(true),
});
}
if code == "unavailable" {
let retry_after = payload
.get("retryAfter")
.and_then(|v| v.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.with_timezone(&chrono::Utc));
return Some(VtaError::Unavailable { retry_after });
}
Some(VtaError::Protocol(format!(
"trust task failed [{code}]: {message}"
)))
}
#[cfg_attr(not(feature = "session"), allow(unused_variables))]
pub async fn seal_vault_secret(&self, secret: serde_json::Value) -> Result<String, VtaError> {
match &self.transport {
#[cfg(feature = "session")]
Transport::DIDComm { session, .. } => session.seal_to_vta(secret).await,
Transport::Rest { .. } => Err(VtaError::UnsupportedTransport(
"sealing a vault secret requires the DIDComm transport \
(REST clients hold no key material to authcrypt with)"
.into(),
)),
#[cfg(feature = "tsp")]
Transport::Tsp { .. } => Err(VtaError::UnsupportedTransport(
"sealing a vault secret produces a didcomm-authcrypt JWE, which \
requires the DIDComm transport:\n <cli> --transport didcomm <command>"
.into(),
)),
}
}
#[cfg_attr(not(feature = "session"), allow(unused_variables))]
pub async fn open_sealed_secret(&self, jwe: &str) -> Result<serde_json::Value, VtaError> {
match &self.transport {
#[cfg(feature = "session")]
Transport::DIDComm { session, .. } => session.open_from_vta(jwe).await,
Transport::Rest { .. } => Err(VtaError::UnsupportedTransport(
"opening a sealed vault secret requires the DIDComm transport".into(),
)),
#[cfg(feature = "tsp")]
Transport::Tsp { .. } => Err(VtaError::UnsupportedTransport(
"opening a sealed vault secret unwraps a didcomm-authcrypt JWE, which \
requires the DIDComm transport:\n <cli> --transport didcomm <command>"
.into(),
)),
}
}
#[cfg_attr(not(feature = "session"), allow(unused_variables))]
pub async fn receive_next(&self, timeout_secs: u64) -> Result<Option<String>, VtaError> {
match &self.transport {
#[cfg(feature = "session")]
Transport::DIDComm { session, .. } => session.receive_next(timeout_secs).await,
#[cfg(feature = "tsp")]
Transport::Tsp { session, .. } => session
.receive_next(timeout_secs)
.await
.map_err(|e| VtaError::TspTransport(e.to_string())),
Transport::Rest { .. } => Err(VtaError::UnsupportedTransport(
"receiving inbound messages requires the DIDComm transport".into(),
)),
}
}
#[cfg_attr(not(feature = "session"), allow(unused_variables))]
pub async fn send_message(
&self,
recipient_did: &str,
msg_type: &str,
body: serde_json::Value,
) -> Result<(), VtaError> {
match &self.transport {
#[cfg(feature = "session")]
Transport::DIDComm { session, .. } => {
session.send_one_way(recipient_did, msg_type, body).await
}
Transport::Rest { .. } => Err(VtaError::UnsupportedTransport(
"one-way DIDComm send requires the DIDComm transport \
(REST clients hold no key material to authcrypt with)"
.into(),
)),
#[cfg(feature = "tsp")]
Transport::Tsp { .. } => Err(VtaError::UnsupportedTransport(
"one-way DIDComm send requires the DIDComm transport:\n \
<cli> --transport didcomm <command>"
.into(),
)),
}
}
#[cfg(feature = "didcomm")]
pub async fn resolve_did(&self, did: &str) -> Result<serde_json::Value, VtaError> {
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
let resolver = DIDCacheClient::new(crate::resolver::build_did_cache_config_from_env())
.await
.map_err(|e| VtaError::Protocol(format!("resolver init: {e}")))?;
let resolved = resolver
.resolve(did)
.await
.map_err(|e| VtaError::Protocol(format!("resolve {did}: {e}")))?;
serde_json::to_value(resolved.doc).map_err(VtaError::from)
}
pub async fn health(&self) -> Result<HealthResponse, VtaError> {
match &self.transport {
Transport::Rest {
client, base_url, ..
} => {
let resp = client.get(format!("{base_url}/health")).send().await?;
Self::handle_response(resp).await
}
#[cfg(feature = "session")]
Transport::DIDComm {
rest_client,
rest_url,
..
} => match (rest_client, rest_url) {
(Some(client), Some(url)) => {
let resp = client.get(format!("{url}/health")).send().await?;
Self::handle_response(resp).await
}
_ => Err(VtaError::UnsupportedTransport(
"health check not available via DIDComm (no REST URL)".into(),
)),
},
#[cfg(feature = "tsp")]
Transport::Tsp {
rest_client,
rest_url,
..
} => match (rest_client, rest_url) {
(Some(client), Some(url)) => {
let resp = client.get(format!("{url}/health")).send().await?;
Self::handle_response(resp).await
}
_ => Err(VtaError::UnsupportedTransport(
"health check not available via TSP (no REST URL)".into(),
)),
},
}
}
#[cfg(feature = "client")]
pub async fn capabilities(
&self,
) -> Result<crate::protocols::discovery::CapabilitiesResponse, VtaError> {
self.rpc_tt(
crate::trust_tasks::TASK_DISCOVERY_CAPABILITIES_1_0,
serde_json::json!({}),
30,
)
.await
}
#[cfg(feature = "client")]
pub async fn check_auth(&self) -> Result<bool, VtaError> {
match &self.transport {
Transport::Rest {
client,
base_url,
auth,
} => {
let token = auth.lock().await.token.clone();
let req = client.get(format!("{base_url}/health/details"));
let resp = Self::with_auth_token(req, &token).send().await?;
Ok(resp.status().is_success())
}
#[cfg(feature = "session")]
Transport::DIDComm { .. } => {
Ok(true)
}
#[cfg(feature = "tsp")]
Transport::Tsp { .. } => Ok(true),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keys::KeyType;
#[test]
fn a_consent_refusal_carries_what_answering_it_needs() {
let payload = serde_json::json!({
"code": "taskFailed",
"message": "task failed: auth:consent_required",
"details": {
"reason": "auth:consent_required",
"payloadDigest": "A1B2C3",
"challenge": "chal-xyz",
"approverSet": "webvh-approvers",
"minApprovals": 1,
"excludeRequester": true,
"consentRequests": [],
}
});
match VtaClient::trust_task_error(&payload) {
Some(VtaError::ConsentRequired {
payload_digest,
challenge,
approver_set,
min_approvals,
exclude_requester,
}) => {
assert_eq!(payload_digest, "A1B2C3");
assert_eq!(challenge, "chal-xyz");
assert_eq!(approver_set, "webvh-approvers");
assert_eq!(min_approvals, 1);
assert!(exclude_requester, "the two-device posture must be reported");
}
other => panic!("expected ConsentRequired, got {other:?}"),
}
}
#[test]
fn an_absent_exclude_requester_defaults_to_the_restrictive_reading() {
let payload = serde_json::json!({
"code": "taskFailed",
"message": "task failed: auth:consent_required",
"details": { "reason": "auth:consent_required", "challenge": "c" }
});
match VtaClient::trust_task_error(&payload) {
Some(VtaError::ConsentRequired {
exclude_requester, ..
}) => assert!(exclude_requester),
other => panic!("expected ConsentRequired, got {other:?}"),
}
}
#[test]
fn a_non_consent_failure_is_still_a_protocol_error() {
let payload = serde_json::json!({
"code": "malformedRequest",
"message": "payload does not conform",
"details": { "reason": "schema:invalid" }
});
assert!(matches!(
VtaClient::trust_task_error(&payload),
Some(VtaError::Protocol(_))
));
}
#[test]
fn extract_returns_a_success_payload() {
let doc = serde_json::json!({
"id": "urn:uuid:1", "type": "spec/vta/x/1.0",
"payload": { "did": "did:webvh:QmScid:example.com", "names": [] },
});
let got = VtaClient::extract_trust_task_payload(doc).expect("should succeed");
assert_eq!(got["did"], "did:webvh:QmScid:example.com");
}
#[test]
fn extract_surfaces_an_error_envelope_inside_the_payload() {
let doc = serde_json::json!({
"id": "urn:uuid:1", "type": "spec/vta/webvh/agent-name/set/1.0",
"payload": {
"code": "internalError",
"message": "set_agent_name: name_taken: `ops` is already bound on \
webvh.storm.ws",
"retryable": false,
},
});
let err = VtaClient::extract_trust_task_payload(doc).expect_err("must be an error");
let msg = err.to_string();
assert!(msg.contains("internalError"), "{msg}");
assert!(
msg.contains("name_taken"),
"the actionable part of the message must survive: {msg}"
);
}
#[test]
fn extract_does_not_mistake_a_code_field_for_an_error() {
let doc = serde_json::json!({
"payload": { "code": "GB", "country": "United Kingdom" },
});
let got = VtaClient::extract_trust_task_payload(doc).expect("code alone is not an error");
assert_eq!(got["code"], "GB");
}
#[test]
fn extract_reports_a_rejection_without_a_payload() {
let doc = serde_json::json!({ "id": "urn:uuid:1", "reason": "not authorized" });
let err = VtaClient::extract_trust_task_payload(doc).expect_err("must be an error");
assert!(err.to_string().contains("not authorized"), "{err}");
}
#[test]
fn test_encode_hash_in_did_fragment() {
assert_eq!(
encode_path_segment("did:key:z6Mk123#z6Mk123"),
"did:key:z6Mk123%23z6Mk123"
);
}
#[test]
fn test_encode_question_mark() {
assert_eq!(encode_path_segment("foo?bar"), "foo%3Fbar");
}
#[test]
fn test_encode_percent_is_escaped_first() {
assert_eq!(encode_path_segment("100%#done"), "100%25%23done");
}
#[test]
fn test_encode_colon_preserved() {
assert_eq!(encode_path_segment("did:key:z6Mk"), "did:key:z6Mk");
}
#[test]
fn test_encode_plain_string_unchanged() {
assert_eq!(encode_path_segment("simple-id"), "simple-id");
}
#[test]
fn test_encode_multiple_hashes() {
assert_eq!(encode_path_segment("a#b#c"), "a%23b%23c");
}
#[test]
fn test_encode_slash_in_derivation_path() {
assert_eq!(
encode_path_segment("m/44'/0'/0'/0"),
"m%2F44'%2F0'%2F0'%2F0"
);
}
#[test]
fn test_new_strips_trailing_slash() {
let client = VtaClient::new("http://localhost:3000/");
assert_eq!(client.rest_url(), Some("http://localhost:3000"));
}
#[test]
fn test_new_strips_multiple_trailing_slashes() {
let client = VtaClient::new("http://localhost:3000///");
assert_eq!(client.rest_url(), Some("http://localhost:3000"));
}
#[test]
fn test_new_no_trailing_slash_unchanged() {
let client = VtaClient::new("http://localhost:3000");
assert_eq!(client.rest_url(), Some("http://localhost:3000"));
}
#[tokio::test]
async fn test_new_token_initially_none() {
let client = VtaClient::new("http://example.com");
match &client.transport {
Transport::Rest { auth, .. } => assert!(auth.lock().await.token.is_none()),
#[cfg(feature = "session")]
_ => panic!("expected REST transport"),
}
}
#[tokio::test]
async fn test_set_token() {
let client = VtaClient::new("http://example.com");
client.set_token("my-jwt".to_string());
match &client.transport {
Transport::Rest { auth, .. } => {
assert_eq!(auth.lock().await.token.as_deref(), Some("my-jwt"));
}
#[cfg(feature = "session")]
_ => panic!("expected REST transport"),
}
}
#[test]
fn test_update_config_sends_only_named_keys() {
use crate::protocols::vta_management::update_config::UpdateConfigBody;
let mut overrides = std::collections::HashMap::new();
overrides.insert("vta_name".to_string(), serde_json::json!("Test"));
let req = UpdateConfigRequest {
patch: UpdateConfigBody { overrides },
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["overrides"]["vta_name"], "Test");
assert!(
!json["overrides"]
.as_object()
.unwrap()
.contains_key("public_url")
);
assert!(
!json["overrides"]
.as_object()
.unwrap()
.contains_key("vta_did")
);
}
#[test]
fn test_create_key_request_serialization() {
let req = CreateKeyRequest {
internal: None,
key_type: KeyType::Ed25519,
derivation_path: None,
key_id: None,
mnemonic: None,
label: Some("test key".into()),
context_id: Some("vta".into()),
};
let json = serde_json::to_value(&req).unwrap();
assert!(!json.as_object().unwrap().contains_key("derivation_path"));
assert!(!json.as_object().unwrap().contains_key("key_id"));
assert!(!json.as_object().unwrap().contains_key("mnemonic"));
assert_eq!(json["label"], "test key");
assert_eq!(json["context_id"], "vta");
}
#[test]
fn test_create_acl_request_serialization() {
let req = CreateAclRequest {
did: "did:key:z6Mk123".into(),
role: "admin".into(),
label: None,
allowed_contexts: vec!["vta".into()],
expires_at: None,
step_up_approver: None,
step_up_require: None,
approve_all_contexts: false,
approve_contexts: vec![],
allowed_keys: None,
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["entry"]["subject"], "did:key:z6Mk123");
assert_eq!(json["entry"]["role"], "admin");
assert_eq!(json["entry"]["scopes"][0], "vta");
assert!(
json.get("did").is_none(),
"pre-fold flat shape is gone: {json}"
);
assert!(json["entry"].get("stepUp").is_none());
assert!(json["entry"].get("approve").is_none());
assert!(!json["entry"].as_object().unwrap().contains_key("label"));
assert_eq!(json["entry"]["scopes"], serde_json::json!(["vta"]));
assert!(json["entry"].get("allowedContexts").is_none(), "{json}");
}
#[test]
fn test_update_acl_request_all_none() {
let req = UpdateAclRequest {
label: None,
allowed_contexts: None,
step_up_approver: None,
step_up_require: None,
approve_scope: None,
allowed_keys: None,
};
let json = serde_json::to_value(&req).unwrap();
let obj = json.as_object().unwrap();
assert!(obj.is_empty(), "all-None request should serialize to {{}}");
}
#[test]
fn test_update_acl_request_allowed_keys_three_intentions() {
let base = || UpdateAclRequest {
label: None,
allowed_contexts: None,
step_up_approver: None,
step_up_require: None,
approve_scope: None,
allowed_keys: None,
};
let set = UpdateAclRequest {
allowed_keys: Some(Some(vec!["key-1".into()])),
..base()
};
let json = serde_json::to_value(&set).unwrap();
assert_eq!(json["allowedKeys"], serde_json::json!(["key-1"]));
let clear = UpdateAclRequest {
allowed_keys: Some(None),
..base()
};
let json = serde_json::to_value(&clear).unwrap();
assert!(
json["allowedKeys"].is_null(),
"clear is explicit null: {json}"
);
let none_at_all = UpdateAclRequest {
allowed_keys: Some(Some(vec![])),
..base()
};
let json = serde_json::to_value(&none_at_all).unwrap();
assert_eq!(
json["allowedKeys"],
serde_json::json!([]),
"the empty list must be emitted, not skipped: {json}"
);
}
#[test]
fn test_health_response_deserialization() {
let json = r#"{"status":"ok","version":"0.1.0"}"#;
let resp: HealthResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, "ok");
assert_eq!(resp.version.as_deref(), Some("0.1.0"));
}
#[test]
fn test_health_response_minimal() {
let json = r#"{"status":"ok"}"#;
let resp: HealthResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, "ok");
assert_eq!(resp.version, None);
}
#[test]
fn test_error_response_deserialization() {
let json = r#"{"error":"not found"}"#;
let resp: ErrorResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.error, "not found");
}
#[test]
fn test_list_keys_response_deserialization() {
let json = r#"{"keys":[],"total":0}"#;
let resp: ListKeysResponse = serde_json::from_str(json).unwrap();
assert!(resp.keys.is_empty());
assert_eq!(resp.total, 0);
}
#[test]
fn test_acl_list_response_deserialization() {
let json = r#"{"entries":[{"subject":"did:key:z6Mk1","role":"admin","label":null,"scopes":[],"createdAt":"2023-11-14T22:13:20Z","createdBy":"setup"}],"truncated":false}"#;
let resp: AclListResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.entries.len(), 1);
assert_eq!(resp.entries[0].did, "did:key:z6Mk1");
assert_eq!(resp.entries[0].role, "admin");
assert!(resp.entries[0].allowed_contexts.is_empty());
assert_eq!(resp.entries[0].created_at, 1_700_000_000);
}
#[test]
fn test_context_response_deserialization() {
let json = r#"{"id":"vta","name":"Verified Trust Agent","did":null,"description":null,"base_path":"m/26'/2'/0'","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}"#;
let resp: ContextResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.id, "vta");
assert_eq!(resp.name, "Verified Trust Agent");
assert!(resp.did.is_none());
assert_eq!(resp.base_path, "m/26'/2'/0'");
}
#[test]
fn trust_task_payload_extracted_from_success_doc() {
let doc = serde_json::json!({
"id": "urn:uuid:abc",
"type": "https://trusttasks.org/spec/device/list/0.1#response",
"payload": { "devices": [], "truncated": false }
});
let out = VtaClient::extract_trust_task_payload(doc).unwrap();
assert_eq!(
out,
serde_json::json!({ "devices": [], "truncated": false })
);
}
#[test]
fn trust_task_reject_doc_surfaces_reason_as_error() {
let doc = serde_json::json!({
"id": "urn:uuid:def",
"type": "https://trusttasks.org/spec/vault/get/0.1#reject",
"reason": "vault/get:not_found — no such entry"
});
let err = VtaClient::extract_trust_task_payload(doc).unwrap_err();
match err {
VtaError::Protocol(msg) => assert!(msg.contains("not_found"), "got: {msg}"),
other => panic!("expected Protocol error, got {other:?}"),
}
}
#[cfg(all(feature = "session", feature = "tsp"))]
#[test]
fn same_mediator_multiplexes_rather_than_opening_a_second_socket() {
let mediator = "did:webvh:QmTS3a:webvh.storm.ws:mediator";
assert_eq!(tsp_leg_kind(mediator, mediator), TspLegKind::Multiplexed);
}
#[cfg(all(feature = "session", feature = "tsp"))]
#[test]
fn a_separate_tsp_mediator_gets_its_own_session() {
assert_eq!(
tsp_leg_kind(
"did:webvh:QmTS3a:webvh.storm.ws:mediator",
"did:web:tsp-mediator.example.com",
),
TspLegKind::Separate
);
}
#[test]
fn a_rest_client_reports_rest_for_both_surfaces() {
let client = VtaClient::new("https://vta.example.com");
assert_eq!(client.trust_task_transport(), SurfaceTransport::Rest);
assert_eq!(client.protocol_message_transport(), SurfaceTransport::Rest);
}
#[test]
fn surface_transport_renders_the_operator_facing_name() {
assert_eq!(SurfaceTransport::Tsp.to_string(), "TSP");
assert_eq!(SurfaceTransport::Didcomm.to_string(), "DIDComm");
assert_eq!(SurfaceTransport::Rest.to_string(), "REST");
}
}
fn build_task_document(type_uri: &str, payload: serde_json::Value) -> serde_json::Value {
let mut doc = serde_json::json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": type_uri,
"payload": payload,
});
if let Some(key) = crate::idempotency::current_key()
&& crate::retry_safety::retry_safety(type_uri).is_some_and(|s| s.needs_key())
&& let Some(obj) = doc.as_object_mut()
{
obj.insert("idempotencyKey".to_string(), serde_json::json!(key));
}
doc
}
#[cfg(test)]
mod idempotency_document_tests {
use super::build_task_document;
use crate::idempotency::IDEMPOTENCY_KEY;
use crate::trust_tasks;
fn key_in(doc: &serde_json::Value) -> Option<String> {
doc.get("idempotencyKey")?.as_str().map(str::to_string)
}
#[tokio::test]
async fn a_keyed_task_carries_the_scoped_key() {
IDEMPOTENCY_KEY
.scope("urn:uuid:k".to_string(), async {
let doc = build_task_document(
trust_tasks::TASK_WEBVH_DIDS_CREATE_1_0,
serde_json::json!({}),
);
assert_eq!(key_in(&doc).as_deref(), Some("urn:uuid:k"));
})
.await;
}
#[tokio::test]
async fn a_retry_safe_task_carries_no_key_even_in_scope() {
IDEMPOTENCY_KEY
.scope("urn:uuid:k".to_string(), async {
let doc = build_task_document(
trust_tasks::TASK_WEBVH_DIDS_LIST_1_0,
serde_json::json!({}),
);
assert_eq!(key_in(&doc), None);
})
.await;
}
#[tokio::test]
async fn outside_a_scope_no_document_carries_a_key() {
let doc = build_task_document(
trust_tasks::TASK_WEBVH_DIDS_CREATE_1_0,
serde_json::json!({}),
);
assert_eq!(key_in(&doc), None);
}
#[tokio::test]
async fn two_attempts_in_one_scope_share_a_key_but_not_an_envelope_id() {
IDEMPOTENCY_KEY
.scope("urn:uuid:k".to_string(), async {
let a =
build_task_document(trust_tasks::TASK_KEYS_CREATE_0_1, serde_json::json!({}));
let b =
build_task_document(trust_tasks::TASK_KEYS_CREATE_0_1, serde_json::json!({}));
assert_eq!(key_in(&a), key_in(&b), "the retry must reuse the key");
assert_ne!(
a.get("id"),
b.get("id"),
"envelope ids stay per-attempt — which is exactly why the key is needed"
);
})
.await;
}
}