use std::sync::Arc;
use thiserror::Error;
use tokio::sync::RwLock;
use vta_sdk::protocol::services::{ServiceState, ServicesListResponse};
use crate::auth::AuthClaims;
use crate::config::AppConfig;
use crate::error::AppError;
use crate::operations::protocol::document::{
current_didcomm_service, current_rest_service, current_webauthn_service,
};
use crate::store::KeyspaceHandle;
use crate::webvh_store;
#[derive(Debug, Error)]
pub enum ListServicesError {
#[error("VTA DID is not configured — run `vta setup` first")]
VtaDidNotConfigured,
#[error("VTA DID `{0}` has no webvh record")]
VtaDidRecordMissing(String),
#[error("VTA DID `{0}` has no published log")]
VtaDidLogMissing(String),
#[error("VTA DID log is empty")]
EmptyLog,
#[error("auth: {0}")]
Auth(String),
#[error("storage error: {0}")]
Storage(String),
}
impl From<AppError> for ListServicesError {
fn from(value: AppError) -> Self {
Self::Storage(value.to_string())
}
}
pub async fn list_services(
config: &Arc<RwLock<AppConfig>>,
webvh_ks: &KeyspaceHandle,
auth: &AuthClaims,
) -> Result<ServicesListResponse, ListServicesError> {
auth.require_super_admin()
.map_err(|e| ListServicesError::Auth(e.to_string()))?;
let cfg_view = {
let cfg = config.read().await;
ConfigView {
rest_enabled: cfg.services.rest,
didcomm_enabled: cfg.services.didcomm,
webauthn_enabled: cfg.services.webauthn,
vta_did: cfg.vta_did.clone(),
mediator_did: cfg
.messaging
.as_ref()
.map(|m| m.mediator_did.clone())
.filter(|did| !did.is_empty()),
public_url: cfg.public_url.clone(),
}
};
let vta_did = cfg_view
.vta_did
.ok_or(ListServicesError::VtaDidNotConfigured)?;
if !vta_did.starts_with("did:webvh:") {
let services = vec![
ServiceState::Didcomm {
enabled: cfg_view.didcomm_enabled && cfg_view.mediator_did.is_some(),
mediator_did: cfg_view.mediator_did,
routing_keys: Vec::new(),
},
ServiceState::Rest {
enabled: cfg_view.rest_enabled,
url: cfg_view.public_url.clone(),
},
ServiceState::Webauthn {
enabled: cfg_view.webauthn_enabled,
url: None,
},
];
return Ok(ServicesListResponse { services });
}
let _record = webvh_store::get_did(webvh_ks, &vta_did)
.await?
.ok_or_else(|| ListServicesError::VtaDidRecordMissing(vta_did.clone()))?;
let did_log = webvh_store::get_did_log(webvh_ks, &vta_did)
.await?
.ok_or_else(|| ListServicesError::VtaDidLogMissing(vta_did.clone()))?;
let current_doc = crate::operations::protocol::document::current_document_from_log(&did_log)?;
let rest_url = current_rest_service(¤t_doc).map(|s| s.url);
let webauthn_url = current_webauthn_service(¤t_doc).map(|s| s.url);
let didcomm = current_didcomm_service(¤t_doc);
let (didcomm_mediator, didcomm_routing_keys) = match didcomm {
Some(svc) => (Some(svc.mediator_did), svc.routing_keys),
None => (None, Vec::new()),
};
let services = vec![
ServiceState::Didcomm {
enabled: cfg_view.didcomm_enabled && didcomm_mediator.is_some(),
mediator_did: didcomm_mediator,
routing_keys: didcomm_routing_keys,
},
ServiceState::Rest {
enabled: cfg_view.rest_enabled && rest_url.is_some(),
url: rest_url,
},
ServiceState::Webauthn {
enabled: cfg_view.webauthn_enabled && webauthn_url.is_some(),
url: webauthn_url,
},
];
Ok(ServicesListResponse { services })
}
struct ConfigView {
rest_enabled: bool,
didcomm_enabled: bool,
webauthn_enabled: bool,
vta_did: Option<String>,
mediator_did: Option<String>,
public_url: Option<String>,
}
impl From<crate::operations::protocol::document::CurrentDocumentError> for ListServicesError {
fn from(value: crate::operations::protocol::document::CurrentDocumentError) -> Self {
use crate::operations::protocol::document::CurrentDocumentError as E;
match value {
E::EmptyLog => Self::EmptyLog,
E::Parse(s) => Self::Storage(s),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
use vti_common::config::StoreConfig as VtiStoreConfig;
#[tokio::test]
async fn rejects_non_super_admin() {
use crate::test_support::test_app_config;
use vti_common::acl::Role;
let dir = tempfile::tempdir().unwrap();
let mut cfg = test_app_config(dir.path().into());
cfg.services.rest = true;
cfg.services.didcomm = true;
cfg.vta_did = Some("did:webvh:scid:host:vta".into());
cfg.config_path = dir.path().join("vta.toml");
let config = Arc::new(RwLock::new(cfg));
let store = Store::open(&VtiStoreConfig {
data_dir: dir.path().into(),
})
.unwrap();
let webvh_ks = store.keyspace(crate::keyspaces::WEBVH).unwrap();
let context_admin = AuthClaims {
did: "did:key:z6Mk-context-admin".into(),
role: Role::Admin,
allowed_contexts: vec!["vta".into()],
session_id: "test-session".into(),
access_expires_at: 0,
amr: Vec::new(),
acr: String::new(),
};
let err = list_services(&config, &webvh_ks, &context_admin)
.await
.unwrap_err();
assert!(
matches!(err, ListServicesError::Auth(_)),
"expected Auth rejection, got {err:?}"
);
}
#[tokio::test]
async fn rejects_when_vta_did_not_configured() {
use crate::test_support::test_app_config;
let dir = tempfile::tempdir().unwrap();
let mut cfg = test_app_config(dir.path().into());
cfg.services.rest = true;
cfg.services.didcomm = true;
cfg.vta_did = None;
cfg.config_path = dir.path().join("vta.toml");
let config = Arc::new(RwLock::new(cfg));
let store = Store::open(&VtiStoreConfig {
data_dir: dir.path().into(),
})
.unwrap();
let webvh_ks = store.keyspace(crate::keyspaces::WEBVH).unwrap();
let super_admin = AuthClaims::unsafe_local_cli_super_admin("test");
let err = list_services(&config, &webvh_ks, &super_admin)
.await
.unwrap_err();
assert!(matches!(err, ListServicesError::VtaDidNotConfigured));
}
#[tokio::test]
async fn returns_config_state_for_did_key_vta() {
use crate::test_support::test_app_config;
let dir = tempfile::tempdir().unwrap();
let mut cfg = test_app_config(dir.path().into());
cfg.services.rest = false;
cfg.services.didcomm = true;
cfg.services.webauthn = false;
cfg.vta_did = Some("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK".into());
cfg.public_url = Some("https://vta.example.com".into());
cfg.messaging = Some(crate::config::MessagingConfig {
mediator_did: "did:peer:2.MEDIATOR".into(),
mediator_url: "ws://mediator:7037".into(),
mediator_host: None,
});
cfg.config_path = dir.path().join("vta.toml");
let config = Arc::new(RwLock::new(cfg));
let store = Store::open(&VtiStoreConfig {
data_dir: dir.path().into(),
})
.unwrap();
let webvh_ks = store.keyspace(crate::keyspaces::WEBVH).unwrap();
let super_admin = AuthClaims::unsafe_local_cli_super_admin("test");
let response = list_services(&config, &webvh_ks, &super_admin)
.await
.unwrap();
assert_eq!(response.services.len(), 3);
match &response.services[0] {
ServiceState::Didcomm {
enabled,
mediator_did,
..
} => {
assert!(enabled);
assert_eq!(mediator_did.as_deref(), Some("did:peer:2.MEDIATOR"));
}
other => panic!("expected DIDComm first; got {other:?}"),
}
match &response.services[1] {
ServiceState::Rest { enabled, url } => {
assert!(!enabled, "REST is disabled in config");
assert_eq!(url.as_deref(), Some("https://vta.example.com"));
}
other => panic!("expected REST second; got {other:?}"),
}
match &response.services[2] {
ServiceState::Webauthn { enabled, url } => {
assert!(!enabled);
assert!(url.is_none());
}
other => panic!("expected WebAuthn third; got {other:?}"),
}
}
#[tokio::test]
async fn list_services_returns_didcomm_first_rest_second() {
use crate::test_support::test_app_config;
let dir = tempfile::tempdir().unwrap();
let vta_did = "did:webvh:scid:host:vta";
let mut cfg = test_app_config(dir.path().into());
cfg.services.rest = true;
cfg.services.didcomm = true;
cfg.vta_did = Some(vta_did.into());
cfg.config_path = dir.path().join("vta.toml");
let config = Arc::new(RwLock::new(cfg));
let store = Store::open(&VtiStoreConfig {
data_dir: dir.path().into(),
})
.unwrap();
let webvh_ks = store.keyspace(crate::keyspaces::WEBVH).unwrap();
let log_line = serde_json::json!({
"versionId": "1-test",
"versionTime": "2026-05-06T00:00:00Z",
"parameters": {},
"state": {
"id": vta_did,
"service": [
{
"id": format!("{vta_did}#vta-rest"),
"type": "VTARest",
"serviceEndpoint": "https://vta.example/api",
},
{
"id": format!("{vta_did}#vta-didcomm"),
"type": "DIDCommMessaging",
"serviceEndpoint": {
"uri": "did:peer:2.MEDIATOR",
"accept": ["didcomm/v2"],
"routingKeys": [],
},
},
],
},
});
let log = serde_json::to_string(&log_line).unwrap();
let now = chrono::Utc::now();
let record = vta_sdk::webvh::WebvhDidRecord {
did: vta_did.into(),
server_id: "test-server".into(),
mnemonic: String::new(),
scid: "scid".into(),
context_id: "vta".into(),
portable: false,
log_entry_count: 1,
pre_rotation_count: 0,
next_fragment_id: 1,
created_at: now,
updated_at: now,
};
webvh_store::store_did(&webvh_ks, &record).await.unwrap();
webvh_store::store_did_log(&webvh_ks, vta_did, &log)
.await
.unwrap();
let super_admin = AuthClaims::unsafe_local_cli_super_admin("test");
let response = list_services(&config, &webvh_ks, &super_admin)
.await
.unwrap();
assert_eq!(
response.services.len(),
3,
"expected one entry per kind (DIDComm + REST + WebAuthn); got {response:?}"
);
match &response.services[0] {
ServiceState::Didcomm {
enabled,
mediator_did,
..
} => {
assert!(enabled);
assert_eq!(mediator_did.as_deref(), Some("did:peer:2.MEDIATOR"));
}
other => panic!("expected DIDComm first; got {other:?}"),
}
match &response.services[1] {
ServiceState::Rest { enabled, url } => {
assert!(enabled);
assert_eq!(url.as_deref(), Some("https://vta.example/api"));
}
other => panic!("expected REST second; got {other:?}"),
}
match &response.services[2] {
ServiceState::Webauthn { enabled, url } => {
assert!(!enabled);
assert!(url.is_none());
}
other => panic!("expected WebAuthn third; got {other:?}"),
}
}
}