use crate::error::VtaError;
use crate::keys::KeyRecord;
use crate::protocols::key_management::sign::SignAlgorithm;
use reqwest::{Client, RequestBuilder};
struct AuthCredential {
did: String,
private_key_multibase: String,
vta_did: String,
}
struct RestAuth {
token: Option<String>,
expires_at: Option<u64>,
refresh_token: Option<String>,
refresh_expires_at: Option<u64>,
credential: Option<AuthCredential>,
}
#[derive(Clone)]
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>,
},
}
#[derive(Clone)]
pub struct VtaClient {
transport: Transport,
}
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::*;
fn encode_path_segment(s: &str) -> String {
s.replace('%', "%25")
.replace('#', "%23")
.replace('?', "%3F")
.replace('/', "%2F")
}
impl VtaClient {
fn with_auth_token(req: RequestBuilder, token: &Option<String>) -> RequestBuilder {
match token {
Some(token) => req.bearer_auth(token),
None => req,
}
}
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 body = resp
.json::<ErrorResponse>()
.await
.map(|e| e.error)
.unwrap_or_else(|_| "unknown error".to_string());
Err(VtaError::from_http(status, body))
}
}
async fn handle_delete_response(resp: reqwest::Response) -> Result<(), VtaError> {
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status();
let body = resp
.json::<ErrorResponse>()
.await
.map(|e| e.error)
.unwrap_or_else(|_| "unknown error".to_string());
Err(VtaError::from_http(status, body))
}
}
}
#[cfg(feature = "client")]
use crate::protocols::{
acl_management, context_management, did_management, key_management, seed_management,
vta_management,
};
impl VtaClient {
pub fn new(base_url: &str) -> Self {
Self {
transport: Transport::Rest {
client: Client::new(),
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 {
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 = "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()))?;
let rest_client = rest_url.as_ref().map(|_| Client::new());
Ok(Self {
transport: Transport::DIDComm {
session,
rest_client,
rest_url: rest_url.map(|u| u.trim_end_matches('/').to_string()),
},
})
}
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 { .. } => {}
}
}
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 { .. } => {}
}
}
pub fn base_url(&self) -> &str {
match &self.transport {
Transport::Rest { base_url, .. } => base_url,
#[cfg(feature = "session")]
Transport::DIDComm { session, .. } => &session.vta_did,
}
}
pub async fn shutdown(&self) {
#[cfg(feature = "session")]
if let Transport::DIDComm { session, .. } = &self.transport {
session.shutdown().await;
}
}
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(())
}
#[allow(unused_variables)]
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,
} => {
Self::ensure_token_valid(client, base_url, auth).await?;
let token = auth.lock().await.token.clone();
let req = build_rest(client, base_url);
let resp = Self::with_auth_token(req, &token).send().await?;
Self::handle_response(resp).await
}
#[cfg(feature = "session")]
Transport::DIDComm { session, .. } => {
session
.send_and_wait(msg_type, body, result_type, timeout)
.await
}
}
}
#[allow(unused_variables)]
async fn rpc_void(
&self,
msg_type: &str,
body: serde_json::Value,
result_type: &str,
timeout: u64,
build_rest: impl FnOnce(&Client, &str) -> RequestBuilder,
) -> Result<(), VtaError> {
match &self.transport {
Transport::Rest {
client,
base_url,
auth,
} => {
Self::ensure_token_valid(client, base_url, auth).await?;
let token = auth.lock().await.token.clone();
let req = build_rest(client, base_url);
let resp = Self::with_auth_token(req, &token).send().await?;
Self::handle_delete_response(resp).await
}
#[cfg(feature = "session")]
Transport::DIDComm { session, .. } => {
let _: serde_json::Value = session
.send_and_wait(msg_type, body, result_type, timeout)
.await?;
Ok(())
}
}
}
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 = "client")]
impl VtaClient {
pub async fn capabilities(
&self,
) -> Result<crate::protocols::discovery::CapabilitiesResponse, VtaError> {
use crate::protocols::discovery;
self.rpc(
discovery::DISCOVER_CAPABILITIES,
serde_json::json!({}),
discovery::DISCOVER_CAPABILITIES_RESULT,
30,
|c, url| c.get(format!("{url}/capabilities")),
)
.await
}
pub async fn restart(&self) -> Result<vta_management::restart::RestartResult, VtaError> {
self.rpc(
vta_management::RESTART,
serde_json::json!({}),
vta_management::RESTART_RESULT,
30,
|c, url| {
c.post(format!("{url}/vta/restart"))
.json(&serde_json::json!({}))
},
)
.await
}
pub async fn backup_export(
&self,
password: &str,
include_audit: bool,
) -> Result<crate::protocols::backup_management::types::BackupEnvelope, VtaError> {
self.rpc(
crate::protocols::backup_management::EXPORT_BACKUP,
serde_json::json!({ "password": password, "include_audit": include_audit }),
crate::protocols::backup_management::EXPORT_BACKUP_RESULT,
120, |c, url| {
c.post(format!("{url}/backup/export")).json(
&serde_json::json!({ "password": password, "include_audit": include_audit }),
)
},
)
.await
}
pub async fn backup_import(
&self,
backup: &crate::protocols::backup_management::types::BackupEnvelope,
password: &str,
confirm: bool,
) -> Result<crate::protocols::backup_management::types::ImportResult, VtaError> {
self.rpc(
crate::protocols::backup_management::IMPORT_BACKUP,
serde_json::json!({ "backup": backup, "password": password, "confirm": confirm }),
crate::protocols::backup_management::IMPORT_BACKUP_RESULT,
120,
|c, url| {
c.post(format!("{url}/backup/import"))
.json(&serde_json::json!({ "backup": backup, "password": password, "confirm": confirm }))
},
)
.await
}
pub async fn get_config(&self) -> Result<ConfigResponse, VtaError> {
self.rpc(
vta_management::GET_CONFIG,
serde_json::json!({}),
vta_management::GET_CONFIG_RESULT,
30,
|c, url| c.get(format!("{url}/config")),
)
.await
}
pub async fn update_config(
&self,
req: UpdateConfigRequest,
) -> Result<ConfigResponse, VtaError> {
self.rpc(
vta_management::UPDATE_CONFIG,
serde_json::to_value(&req)?,
vta_management::UPDATE_CONFIG_RESULT,
30,
|c, url| c.patch(format!("{url}/config")).json(&req),
)
.await
}
pub async fn create_key(&self, req: CreateKeyRequest) -> Result<CreateKeyResponse, VtaError> {
self.rpc(
key_management::CREATE_KEY,
serde_json::json!({
"key_type": serde_json::to_value(&req.key_type)?,
"derivation_path": req.derivation_path.as_deref().unwrap_or_default(),
"mnemonic": req.mnemonic.as_deref(),
"label": req.label.as_deref(),
"context_id": req.context_id.as_deref(),
}),
key_management::CREATE_KEY_RESULT,
30,
|c, url| c.post(format!("{url}/keys")).json(&req),
)
.await
}
pub async fn list_keys(
&self,
offset: u64,
limit: u64,
status: Option<&str>,
context_id: Option<&str>,
) -> Result<ListKeysResponse, VtaError> {
self.rpc(
key_management::LIST_KEYS,
serde_json::json!({
"offset": offset,
"limit": limit,
"status": status,
"context_id": context_id,
}),
key_management::LIST_KEYS_RESULT,
30,
|c, url| {
let mut u = format!("{url}/keys?offset={offset}&limit={limit}");
if let Some(s) = status {
u.push_str(&format!("&status={s}"));
}
if let Some(ctx) = context_id {
u.push_str(&format!("&context_id={ctx}"));
}
c.get(u)
},
)
.await
}
pub async fn get_key(&self, key_id: &str) -> Result<KeyRecord, VtaError> {
self.rpc(
key_management::GET_KEY,
serde_json::json!({ "key_id": key_id }),
key_management::GET_KEY_RESULT,
30,
|c, url| c.get(format!("{url}/keys/{}", encode_path_segment(key_id))),
)
.await
}
pub async fn get_key_secret(&self, key_id: &str) -> Result<GetKeySecretResponse, VtaError> {
self.rpc(
key_management::GET_KEY_SECRET,
serde_json::json!({ "key_id": key_id }),
key_management::GET_KEY_SECRET_RESULT,
30,
|c, url| c.get(format!("{url}/keys/{}/secret", encode_path_segment(key_id))),
)
.await
}
pub async fn sign(
&self,
key_id: &str,
payload: &[u8],
algorithm: SignAlgorithm,
) -> Result<SignResponse, VtaError> {
use base64::Engine;
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload);
self.rpc(
key_management::SIGN_REQUEST,
serde_json::json!({
"key_id": key_id,
"payload": payload_b64,
"algorithm": algorithm,
}),
key_management::SIGN_RESULT,
30,
|c, url| {
c.post(format!("{url}/keys/{}/sign", encode_path_segment(key_id)))
.json(&serde_json::json!({
"payload": payload_b64,
"algorithm": algorithm,
}))
},
)
.await
}
pub async fn invalidate_key(&self, key_id: &str) -> Result<InvalidateKeyResponse, VtaError> {
self.rpc(
key_management::REVOKE_KEY,
serde_json::json!({ "key_id": key_id }),
key_management::REVOKE_KEY_RESULT,
30,
|c, url| c.delete(format!("{url}/keys/{}", encode_path_segment(key_id))),
)
.await
}
pub async fn rename_key(
&self,
key_id: &str,
new_key_id: &str,
) -> Result<RenameKeyResponse, VtaError> {
self.rpc(
key_management::RENAME_KEY,
serde_json::json!({ "key_id": key_id, "new_key_id": new_key_id }),
key_management::RENAME_KEY_RESULT,
30,
|c, url| {
c.patch(format!("{url}/keys/{}", encode_path_segment(key_id)))
.json(&RenameKeyRequest {
key_id: new_key_id.to_string(),
})
},
)
.await
}
pub async fn get_wrapping_key(&self) -> Result<WrappingKeyResponse, VtaError> {
match &self.transport {
Transport::Rest {
client,
base_url,
auth,
} => {
Self::ensure_token_valid(client, base_url, auth).await?;
let token = auth.lock().await.token.clone();
let req = client.get(format!("{base_url}/keys/import/wrapping-key"));
let resp = Self::with_auth_token(req, &token).send().await?;
Self::handle_response(resp).await
}
#[cfg(feature = "session")]
Transport::DIDComm { .. } => Err(VtaError::UnsupportedTransport(
"wrapping key not needed for DIDComm transport".into(),
)),
}
}
pub async fn import_key(&self, req: ImportKeyRequest) -> Result<ImportKeyResponse, VtaError> {
self.rpc(
key_management::IMPORT_KEY,
serde_json::to_value(&req)?,
key_management::IMPORT_KEY_RESULT,
30,
|c, url| c.post(format!("{url}/keys/import")).json(&req),
)
.await
}
pub async fn list_seeds(&self) -> Result<ListSeedsResponse, VtaError> {
self.rpc(
seed_management::LIST_SEEDS,
serde_json::json!({}),
seed_management::LIST_SEEDS_RESULT,
30,
|c, url| c.get(format!("{url}/keys/seeds")),
)
.await
}
pub async fn rotate_seed(
&self,
mnemonic: Option<String>,
) -> Result<RotateSeedResponse, VtaError> {
let body = RotateSeedRequest {
mnemonic: mnemonic.clone(),
};
self.rpc(
seed_management::ROTATE_SEED,
serde_json::json!({ "mnemonic": mnemonic }),
seed_management::ROTATE_SEED_RESULT,
30,
|c, url| c.post(format!("{url}/keys/seeds/rotate")).json(&body),
)
.await
}
pub async fn list_acl(&self, context: Option<&str>) -> Result<AclListResponse, VtaError> {
self.rpc(
acl_management::LIST_ACL,
serde_json::json!({ "context": context }),
acl_management::LIST_ACL_RESULT,
30,
|c, url| {
let mut u = format!("{url}/acl");
if let Some(ctx) = context {
u.push_str(&format!("?context={ctx}"));
}
c.get(u)
},
)
.await
}
pub async fn get_acl(&self, did: &str) -> Result<AclEntryResponse, VtaError> {
self.rpc(
acl_management::GET_ACL,
serde_json::json!({ "did": did }),
acl_management::GET_ACL_RESULT,
30,
|c, url| c.get(format!("{url}/acl/{}", encode_path_segment(did))),
)
.await
}
pub async fn create_acl(&self, req: CreateAclRequest) -> Result<AclEntryResponse, VtaError> {
self.rpc(
acl_management::CREATE_ACL,
serde_json::to_value(&req)?,
acl_management::CREATE_ACL_RESULT,
30,
|c, url| c.post(format!("{url}/acl")).json(&req),
)
.await
}
pub async fn update_acl(
&self,
did: &str,
req: UpdateAclRequest,
) -> Result<AclEntryResponse, VtaError> {
self.rpc(
acl_management::UPDATE_ACL,
serde_json::json!({
"did": did,
"role": &req.role,
"label": &req.label,
"allowed_contexts": &req.allowed_contexts,
}),
acl_management::UPDATE_ACL_RESULT,
30,
|c, url| {
c.patch(format!("{url}/acl/{}", encode_path_segment(did)))
.json(&req)
},
)
.await
}
pub async fn delete_acl(&self, did: &str) -> Result<(), VtaError> {
self.rpc_void(
acl_management::DELETE_ACL,
serde_json::json!({ "did": did }),
acl_management::DELETE_ACL_RESULT,
30,
|c, url| c.delete(format!("{url}/acl/{}", encode_path_segment(did))),
)
.await
}
pub async fn list_contexts(&self) -> Result<ContextListResponse, VtaError> {
self.rpc(
context_management::LIST_CONTEXTS,
serde_json::json!({}),
context_management::LIST_CONTEXTS_RESULT,
30,
|c, url| c.get(format!("{url}/contexts")),
)
.await
}
pub async fn get_context(&self, id: &str) -> Result<ContextResponse, VtaError> {
self.rpc(
context_management::GET_CONTEXT,
serde_json::json!({ "id": id }),
context_management::GET_CONTEXT_RESULT,
30,
|c, url| c.get(format!("{url}/contexts/{}", encode_path_segment(id))),
)
.await
}
pub async fn create_context(
&self,
req: CreateContextRequest,
) -> Result<ContextResponse, VtaError> {
self.rpc(
context_management::CREATE_CONTEXT,
serde_json::to_value(&req)?,
context_management::CREATE_CONTEXT_RESULT,
30,
|c, url| c.post(format!("{url}/contexts")).json(&req),
)
.await
}
pub async fn update_context(
&self,
id: &str,
req: UpdateContextRequest,
) -> Result<ContextResponse, VtaError> {
self.rpc(
context_management::UPDATE_CONTEXT,
serde_json::json!({
"id": id,
"name": &req.name,
"did": &req.did,
"description": &req.description,
}),
context_management::UPDATE_CONTEXT_RESULT,
30,
|c, url| {
c.patch(format!("{url}/contexts/{}", encode_path_segment(id)))
.json(&req)
},
)
.await
}
pub async fn update_context_did(
&self,
id: &str,
did: impl Into<String>,
) -> Result<ContextResponse, VtaError> {
let did = did.into();
self.rpc(
context_management::UPDATE_CONTEXT_DID,
serde_json::json!({ "id": id, "did": &did }),
context_management::UPDATE_CONTEXT_DID_RESULT,
30,
|c, url| {
c.put(format!("{url}/contexts/{}/did", encode_path_segment(id)))
.json(&UpdateContextDidRequest { did: did.clone() })
},
)
.await
}
pub async fn preview_delete_context(
&self,
id: &str,
) -> Result<context_management::delete::DeleteContextPreviewResultBody, VtaError> {
self.rpc(
context_management::PREVIEW_DELETE_CONTEXT,
serde_json::json!({ "id": id }),
context_management::PREVIEW_DELETE_CONTEXT_RESULT,
30,
|c, url| {
c.get(format!(
"{url}/contexts/{}/delete-preview",
encode_path_segment(id)
))
},
)
.await
}
pub async fn delete_context(&self, id: &str, force: bool) -> Result<(), VtaError> {
self.rpc_void(
context_management::DELETE_CONTEXT,
serde_json::json!({ "id": id, "force": force }),
context_management::DELETE_CONTEXT_RESULT,
30,
|c, url| {
let mut url = format!("{url}/contexts/{}", encode_path_segment(id));
if force {
url.push_str("?force=true");
}
c.delete(url)
},
)
.await
}
pub async fn add_webvh_server(
&self,
req: AddWebvhServerRequest,
) -> Result<crate::webvh::WebvhServerRecord, VtaError> {
self.rpc(
did_management::ADD_WEBVH_SERVER,
serde_json::to_value(&req)?,
did_management::ADD_WEBVH_SERVER_RESULT,
30,
|c, url| c.post(format!("{url}/webvh/servers")).json(&req),
)
.await
}
pub async fn list_webvh_servers(
&self,
) -> Result<crate::protocols::did_management::servers::ListWebvhServersResultBody, VtaError>
{
self.rpc(
did_management::LIST_WEBVH_SERVERS,
serde_json::json!({}),
did_management::LIST_WEBVH_SERVERS_RESULT,
30,
|c, url| c.get(format!("{url}/webvh/servers")),
)
.await
}
pub async fn update_webvh_server(
&self,
id: &str,
req: UpdateWebvhServerRequest,
) -> Result<crate::webvh::WebvhServerRecord, VtaError> {
self.rpc(
did_management::UPDATE_WEBVH_SERVER,
serde_json::json!({ "id": id, "label": &req.label }),
did_management::UPDATE_WEBVH_SERVER_RESULT,
30,
|c, url| {
c.patch(format!("{url}/webvh/servers/{}", encode_path_segment(id)))
.json(&req)
},
)
.await
}
pub async fn remove_webvh_server(&self, id: &str) -> Result<(), VtaError> {
self.rpc_void(
did_management::REMOVE_WEBVH_SERVER,
serde_json::json!({ "id": id }),
did_management::REMOVE_WEBVH_SERVER_RESULT,
30,
|c, url| c.delete(format!("{url}/webvh/servers/{}", encode_path_segment(id))),
)
.await
}
pub async fn create_did_webvh(
&self,
req: CreateDidWebvhRequest,
) -> Result<crate::protocols::did_management::create::CreateDidWebvhResultBody, VtaError> {
self.rpc(
did_management::CREATE_DID_WEBVH,
serde_json::to_value(&req)?,
did_management::CREATE_DID_WEBVH_RESULT,
60,
|c, url| c.post(format!("{url}/webvh/dids")).json(&req),
)
.await
}
pub async fn list_dids_webvh(
&self,
context_id: Option<&str>,
server_id: Option<&str>,
) -> Result<crate::protocols::did_management::list::ListDidsWebvhResultBody, VtaError> {
self.rpc(
did_management::LIST_DIDS_WEBVH,
serde_json::json!({
"context_id": context_id,
"server_id": server_id,
}),
did_management::LIST_DIDS_WEBVH_RESULT,
30,
|c, url| {
let mut u = format!("{url}/webvh/dids");
let mut sep = '?';
if let Some(ctx) = context_id {
u.push_str(&format!("{sep}context_id={ctx}"));
sep = '&';
}
if let Some(srv) = server_id {
u.push_str(&format!("{sep}server_id={srv}"));
}
c.get(u)
},
)
.await
}
pub async fn get_did_webvh(&self, did: &str) -> Result<crate::webvh::WebvhDidRecord, VtaError> {
self.rpc(
did_management::GET_DID_WEBVH,
serde_json::json!({ "did": did }),
did_management::GET_DID_WEBVH_RESULT,
30,
|c, url| c.get(format!("{url}/webvh/dids/{}", encode_path_segment(did))),
)
.await
}
pub async fn get_did_webvh_log(&self, did: &str) -> Result<GetDidLogResponse, VtaError> {
self.rpc(
did_management::GET_DID_WEBVH_LOG,
serde_json::json!({ "did": did }),
did_management::GET_DID_WEBVH_LOG_RESULT,
30,
|c, url| c.get(format!("{url}/webvh/dids/{}/log", encode_path_segment(did))),
)
.await
}
pub async fn delete_did_webvh(&self, did: &str) -> Result<(), VtaError> {
self.rpc_void(
did_management::DELETE_DID_WEBVH,
serde_json::json!({ "did": did }),
did_management::DELETE_DID_WEBVH_RESULT,
60,
|c, url| c.delete(format!("{url}/webvh/dids/{}", encode_path_segment(did))),
)
.await
}
pub async fn update_did_webvh(
&self,
ctx_id: &str,
scid: &str,
body: crate::protocols::did_management::update::UpdateDidWebvhBody,
) -> Result<crate::protocols::did_management::update::UpdateDidWebvhResultBody, VtaError> {
self.rpc(
did_management::UPDATE_DID_WEBVH,
serde_json::json!({
"context_id": ctx_id,
"scid": scid,
"body": &body,
}),
did_management::UPDATE_DID_WEBVH_RESULT,
60,
|c, url| {
c.post(format!(
"{url}/contexts/{}/dids/{}/update",
encode_path_segment(ctx_id),
encode_path_segment(scid)
))
.json(&body)
},
)
.await
}
pub async fn rotate_did_webvh_keys(
&self,
ctx_id: &str,
scid: &str,
body: crate::protocols::did_management::update::RotateDidWebvhKeysBody,
) -> Result<crate::protocols::did_management::update::UpdateDidWebvhResultBody, VtaError> {
self.rpc(
did_management::ROTATE_DID_WEBVH_KEYS,
serde_json::json!({
"context_id": ctx_id,
"scid": scid,
"body": &body,
}),
did_management::ROTATE_DID_WEBVH_KEYS_RESULT,
60,
|c, url| {
c.post(format!(
"{url}/contexts/{}/dids/{}/rotate-keys",
encode_path_segment(ctx_id),
encode_path_segment(scid)
))
.json(&body)
},
)
.await
}
pub async fn list_audit_logs(
&self,
params: &crate::protocols::audit_management::list::ListAuditLogsBody,
) -> Result<crate::protocols::audit_management::list::ListAuditLogsResultBody, VtaError> {
use crate::protocols::audit_management;
self.rpc(
audit_management::LIST_LOGS,
serde_json::to_value(params)?,
audit_management::LIST_LOGS_RESULT,
30,
|c, url| {
let mut qs = vec![
format!("page={}", params.page),
format!("page_size={}", params.page_size),
];
if let Some(from) = params.from {
qs.push(format!("from={from}"));
}
if let Some(to) = params.to {
qs.push(format!("to={to}"));
}
if let Some(ref action) = params.action {
qs.push(format!("action={action}"));
}
if let Some(ref actor) = params.actor {
qs.push(format!("actor={actor}"));
}
if let Some(ref outcome) = params.outcome {
qs.push(format!("outcome={outcome}"));
}
if let Some(ref ctx) = params.context_id {
qs.push(format!("context_id={ctx}"));
}
c.get(format!("{url}/audit/logs?{}", qs.join("&")))
},
)
.await
}
pub async fn get_audit_retention(
&self,
) -> Result<crate::protocols::audit_management::retention::RetentionResultBody, VtaError> {
use crate::protocols::audit_management;
self.rpc(
audit_management::GET_RETENTION,
serde_json::json!({}),
audit_management::GET_RETENTION_RESULT,
30,
|c, url| c.get(format!("{url}/audit/retention")),
)
.await
}
pub async fn update_audit_retention(
&self,
retention_days: u32,
) -> Result<crate::protocols::audit_management::retention::RetentionResultBody, VtaError> {
use crate::protocols::audit_management;
let body = audit_management::retention::UpdateRetentionBody { retention_days };
self.rpc(
audit_management::UPDATE_RETENTION,
serde_json::to_value(&body)?,
audit_management::UPDATE_RETENTION_RESULT,
30,
|c, url| c.patch(format!("{url}/audit/retention")).json(&body),
)
.await
}
pub async fn fetch_context_secrets(
&self,
context_id: &str,
) -> Result<Vec<affinidi_tdk::secrets_resolver::secrets::Secret>, VtaError> {
let page_size = 100u64;
let mut offset = 0u64;
let mut secrets = Vec::new();
loop {
let resp = self
.list_keys(offset, page_size, Some("active"), Some(context_id))
.await?;
if resp.keys.is_empty() {
break;
}
for key in &resp.keys {
let secret_resp = self.get_key_secret(&key.key_id).await?;
let secret = crate::did_key::secret_from_key_response(&secret_resp)?;
secrets.push(secret);
}
offset += resp.keys.len() as u64;
if offset >= resp.total {
break;
}
}
Ok(secrets)
}
pub async fn fetch_did_secrets_bundle(
&self,
context_id: &str,
) -> Result<crate::did_secrets::DidSecretsBundle, VtaError> {
let ctx = self.get_context(context_id).await?;
let did = ctx.did.ok_or_else(|| {
VtaError::Validation(format!("context '{context_id}' has no DID assigned"))
})?;
let page_size = 100u64;
let mut offset = 0u64;
let mut secrets = Vec::new();
loop {
let resp = self
.list_keys(offset, page_size, Some("active"), Some(context_id))
.await?;
if resp.keys.is_empty() {
break;
}
for key in &resp.keys {
let secret_resp = self.get_key_secret(&key.key_id).await?;
let mut entry = crate::did_secrets::SecretEntry::from(secret_resp);
if let Some(label) = key.label.as_deref()
&& (label.contains('#') || label.starts_with("did:"))
{
entry.key_id = label.to_string();
}
secrets.push(entry);
}
offset += resp.keys.len() as u64;
if offset >= resp.total {
break;
}
}
Ok(crate::did_secrets::DidSecretsBundle { did, secrets })
}
pub async fn list_did_templates(
&self,
) -> Result<Vec<crate::did_templates::DidTemplateRecord>, VtaError> {
use crate::protocols::did_template_management;
#[derive(serde::Deserialize)]
struct Wrapper {
templates: Vec<crate::did_templates::DidTemplateRecord>,
}
let resp: Wrapper = self
.rpc(
did_template_management::LIST_TEMPLATES,
serde_json::json!({}),
did_template_management::LIST_TEMPLATES_RESULT,
30,
|c, url| c.get(format!("{url}/did-templates")),
)
.await?;
Ok(resp.templates)
}
pub async fn get_did_template(
&self,
name: &str,
) -> Result<crate::did_templates::DidTemplateRecord, VtaError> {
use crate::protocols::did_template_management;
self.rpc(
did_template_management::GET_TEMPLATE,
serde_json::json!({ "name": name }),
did_template_management::GET_TEMPLATE_RESULT,
30,
|c, url| c.get(format!("{url}/did-templates/{}", encode_path_segment(name))),
)
.await
}
pub async fn create_did_template(
&self,
template: crate::did_templates::DidTemplate,
) -> Result<crate::did_templates::DidTemplateRecord, VtaError> {
use crate::protocols::did_template_management;
self.rpc(
did_template_management::CREATE_TEMPLATE,
serde_json::to_value(&template)?,
did_template_management::CREATE_TEMPLATE_RESULT,
30,
|c, url| c.post(format!("{url}/did-templates")).json(&template),
)
.await
}
pub async fn update_did_template(
&self,
name: &str,
template: crate::did_templates::DidTemplate,
) -> Result<crate::did_templates::DidTemplateRecord, VtaError> {
use crate::protocols::did_template_management;
self.rpc(
did_template_management::UPDATE_TEMPLATE,
serde_json::to_value(&template)?,
did_template_management::UPDATE_TEMPLATE_RESULT,
30,
|c, url| {
c.put(format!("{url}/did-templates/{}", encode_path_segment(name)))
.json(&template)
},
)
.await
}
pub async fn delete_did_template(&self, name: &str) -> Result<(), VtaError> {
use crate::protocols::did_template_management;
self.rpc_void(
did_template_management::DELETE_TEMPLATE,
serde_json::json!({ "name": name }),
did_template_management::DELETE_TEMPLATE_RESULT,
30,
|c, url| c.delete(format!("{url}/did-templates/{}", encode_path_segment(name))),
)
.await
}
pub async fn render_did_template(
&self,
name: &str,
vars: std::collections::HashMap<String, serde_json::Value>,
) -> Result<serde_json::Value, VtaError> {
use crate::protocols::did_template_management;
#[derive(serde::Serialize, serde::Deserialize)]
struct Req {
vars: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(serde::Deserialize)]
struct Resp {
document: serde_json::Value,
}
let body = Req { vars };
let resp: Resp = self
.rpc(
did_template_management::RENDER_TEMPLATE,
serde_json::to_value(&body)?,
did_template_management::RENDER_TEMPLATE_RESULT,
30,
|c, url| {
c.post(format!(
"{url}/did-templates/{}/render",
encode_path_segment(name)
))
.json(&body)
},
)
.await?;
Ok(resp.document)
}
pub async fn list_context_did_templates(
&self,
context_id: &str,
) -> Result<Vec<crate::did_templates::DidTemplateRecord>, VtaError> {
use crate::protocols::did_template_management;
#[derive(serde::Deserialize)]
struct Wrapper {
templates: Vec<crate::did_templates::DidTemplateRecord>,
}
let resp: Wrapper = self
.rpc(
did_template_management::LIST_TEMPLATES,
serde_json::json!({ "context_id": context_id }),
did_template_management::LIST_TEMPLATES_RESULT,
30,
|c, url| {
c.get(format!(
"{url}/contexts/{}/did-templates",
encode_path_segment(context_id)
))
},
)
.await?;
Ok(resp.templates)
}
pub async fn get_context_did_template(
&self,
context_id: &str,
name: &str,
) -> Result<crate::did_templates::DidTemplateRecord, VtaError> {
use crate::protocols::did_template_management;
self.rpc(
did_template_management::GET_TEMPLATE,
serde_json::json!({ "context_id": context_id, "name": name }),
did_template_management::GET_TEMPLATE_RESULT,
30,
|c, url| {
c.get(format!(
"{url}/contexts/{}/did-templates/{}",
encode_path_segment(context_id),
encode_path_segment(name)
))
},
)
.await
}
pub async fn create_context_did_template(
&self,
context_id: &str,
template: crate::did_templates::DidTemplate,
) -> Result<crate::did_templates::DidTemplateRecord, VtaError> {
use crate::protocols::did_template_management;
self.rpc(
did_template_management::CREATE_TEMPLATE,
serde_json::to_value(&template)?,
did_template_management::CREATE_TEMPLATE_RESULT,
30,
|c, url| {
c.post(format!(
"{url}/contexts/{}/did-templates",
encode_path_segment(context_id)
))
.json(&template)
},
)
.await
}
pub async fn update_context_did_template(
&self,
context_id: &str,
name: &str,
template: crate::did_templates::DidTemplate,
) -> Result<crate::did_templates::DidTemplateRecord, VtaError> {
use crate::protocols::did_template_management;
self.rpc(
did_template_management::UPDATE_TEMPLATE,
serde_json::to_value(&template)?,
did_template_management::UPDATE_TEMPLATE_RESULT,
30,
|c, url| {
c.put(format!(
"{url}/contexts/{}/did-templates/{}",
encode_path_segment(context_id),
encode_path_segment(name)
))
.json(&template)
},
)
.await
}
pub async fn delete_context_did_template(
&self,
context_id: &str,
name: &str,
) -> Result<(), VtaError> {
use crate::protocols::did_template_management;
self.rpc_void(
did_template_management::DELETE_TEMPLATE,
serde_json::json!({ "context_id": context_id, "name": name }),
did_template_management::DELETE_TEMPLATE_RESULT,
30,
|c, url| {
c.delete(format!(
"{url}/contexts/{}/did-templates/{}",
encode_path_segment(context_id),
encode_path_segment(name)
))
},
)
.await
}
pub async fn render_context_did_template(
&self,
context_id: &str,
name: &str,
vars: std::collections::HashMap<String, serde_json::Value>,
) -> Result<serde_json::Value, VtaError> {
use crate::protocols::did_template_management;
#[derive(serde::Serialize, serde::Deserialize)]
struct Req {
vars: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(serde::Deserialize)]
struct Resp {
document: serde_json::Value,
}
let body = Req { vars };
let resp: Resp = self
.rpc(
did_template_management::RENDER_TEMPLATE,
serde_json::to_value(&body)?,
did_template_management::RENDER_TEMPLATE_RESULT,
30,
|c, url| {
c.post(format!(
"{url}/contexts/{}/did-templates/{}/render",
encode_path_segment(context_id),
encode_path_segment(name)
))
.json(&body)
},
)
.await?;
Ok(resp.document)
}
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 = "provision-integration")]
pub async fn provision_integration(
&self,
req: crate::provision_integration::http::ProvisionIntegrationRequest,
) -> Result<crate::provision_integration::http::ProvisionIntegrationResponse, VtaError> {
match &self.transport {
Transport::Rest {
client,
base_url,
auth,
} => {
Self::ensure_token_valid(client, base_url, auth).await?;
let token = auth.lock().await.token.clone();
let http_req = client
.post(format!("{base_url}/bootstrap/provision-integration"))
.json(&req);
let resp = Self::with_auth_token(http_req, &token).send().await?;
Self::handle_response(resp).await
}
#[cfg(feature = "session")]
Transport::DIDComm { .. } => Err(VtaError::UnsupportedTransport(
"provision-integration is REST-only in phase 1".into(),
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keys::KeyType;
#[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.base_url(), "http://localhost:3000");
}
#[test]
fn test_new_strips_multiple_trailing_slashes() {
let client = VtaClient::new("http://localhost:3000///");
assert_eq!(client.base_url(), "http://localhost:3000");
}
#[test]
fn test_new_no_trailing_slash_unchanged() {
let client = VtaClient::new("http://localhost:3000");
assert_eq!(client.base_url(), "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_skips_none_fields() {
let req = UpdateConfigRequest {
vta_did: None,
vta_name: Some("Test".into()),
public_url: None,
};
let json = serde_json::to_value(&req).unwrap();
assert!(!json.as_object().unwrap().contains_key("vta_did"));
assert_eq!(json["vta_name"], "Test");
assert!(!json.as_object().unwrap().contains_key("public_url"));
}
#[test]
fn test_create_key_request_serialization() {
let req = CreateKeyRequest {
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,
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["did"], "did:key:z6Mk123");
assert_eq!(json["role"], "admin");
assert!(!json.as_object().unwrap().contains_key("label"));
assert_eq!(json["allowed_contexts"], serde_json::json!(["vta"]));
}
#[test]
fn test_update_acl_request_all_none() {
let req = UpdateAclRequest {
role: None,
label: None,
allowed_contexts: 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_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":[{"did":"did:key:z6Mk1","role":"admin","label":null,"allowed_contexts":[],"created_at":1700000000,"created_by":"setup"}]}"#;
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());
}
#[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'");
}
}