use std::sync::Arc;
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use serde_json::Value as JsonValue;
use thiserror::Error;
use tokio::sync::RwLock;
use tracing::info;
use vti_common::seed_store::SeedStore;
use vti_common::telemetry::{SharedTelemetrySink, TelemetryEvent, TelemetryKind};
use vta_sdk::protocol::services::validate_service_url;
use crate::auth::AuthClaims;
use crate::config::AppConfig;
use crate::didcomm_bridge::DIDCommBridge;
use crate::error::AppError;
use crate::operations::did_webvh::{UpdateDidWebvhError, UpdateDidWebvhOptions, update_did_webvh};
use crate::operations::protocol::document::{
DocumentPatchError, current_rest_service, with_rest_service,
};
use crate::operations::protocol::snapshot::{self, RestSnapshot, ServiceConfigSnapshot};
use crate::operations::protocol::{OpContext, PROTOCOL_LOCK};
use crate::store::KeyspaceHandle;
#[derive(Debug, Clone)]
pub struct EnableRestParams {
pub url: String,
}
#[derive(Debug, Clone)]
pub struct EnableRestResult {
pub new_version_id: String,
pub url: String,
pub vta_did: String,
pub serverless: bool,
}
#[derive(Debug, Error)]
pub enum EnableRestError {
#[error("REST is already enabled. Use `services rest update --url <url>` to change the URL.")]
ServiceAlreadyEnabled,
#[error("invalid URL: {0}")]
Validation(String),
#[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("DID document patch failed: {0}")]
DocumentPatch(#[from] DocumentPatchError),
#[error("WebVH update failed: {0}")]
WebVHUpdate(#[from] UpdateDidWebvhError),
#[error("config persistence failed: {0}")]
ConfigPersistence(String),
#[error("auth: {0}")]
Auth(String),
#[error("storage error: {0}")]
Storage(String),
}
impl From<AppError> for EnableRestError {
fn from(value: AppError) -> Self {
Self::Storage(value.to_string())
}
}
impl From<crate::operations::protocol::preconditions::ProtocolPreconditionError>
for EnableRestError
{
fn from(value: crate::operations::protocol::preconditions::ProtocolPreconditionError) -> Self {
use crate::operations::protocol::preconditions::ProtocolPreconditionError as E;
match value {
E::VtaDidNotConfigured => Self::VtaDidNotConfigured,
E::VtaDidRecordMissing(s) => Self::VtaDidRecordMissing(s),
E::VtaDidLogMissing(s) => Self::VtaDidLogMissing(s),
E::EmptyLog => Self::EmptyLog,
E::Storage(s) | E::DocumentParse(s) => Self::Storage(s),
}
}
}
#[allow(clippy::too_many_arguments)]
pub async fn enable_rest(
config: &Arc<RwLock<AppConfig>>,
keys_ks: &KeyspaceHandle,
imported_ks: &KeyspaceHandle,
contexts_ks: &KeyspaceHandle,
webvh_ks: &KeyspaceHandle,
audit_ks: &KeyspaceHandle,
snapshot_ks: &KeyspaceHandle,
service_state_ks: &KeyspaceHandle,
seed_store: &dyn SeedStore,
did_resolver: &DIDCacheClient,
didcomm_bridge: &Arc<DIDCommBridge>,
telemetry: &SharedTelemetrySink,
auth: &AuthClaims,
params: EnableRestParams,
ctx: OpContext,
webvh_auth_locks: &crate::operations::did_webvh::WebvhAuthLocks,
channel: &str,
) -> Result<EnableRestResult, EnableRestError> {
auth.require_super_admin()
.map_err(|e| EnableRestError::Auth(e.to_string()))?;
let _guard = PROTOCOL_LOCK.lock().await;
let validated = validate_service_url(¶ms.url)
.map_err(|e| EnableRestError::Validation(e.to_string()))?;
let canonical_url = validated.to_string();
let (vta_did, scid, current_doc) = read_preconditions(config, webvh_ks).await?;
snapshot::write(
snapshot_ks,
ServiceConfigSnapshot::Rest(RestSnapshot::Disabled),
)
.await
.map_err(|e| EnableRestError::Storage(format!("snapshot write: {e}")))?;
let patched = with_rest_service(current_doc, &canonical_url)?;
let update_result = update_did_webvh(
keys_ks,
imported_ks,
contexts_ks,
webvh_ks,
audit_ks,
seed_store,
auth,
&scid,
UpdateDidWebvhOptions {
document: Some(patched),
..Default::default()
},
did_resolver,
didcomm_bridge,
Some(vta_did.as_str()),
webvh_auth_locks,
channel,
)
.await?;
crate::operations::protocol::runtime_state::set_rest_enabled(service_state_ks, true)
.await
.map_err(|e| EnableRestError::ConfigPersistence(format!("runtime state: {e}")))?;
{
let mut cfg = config.write().await;
cfg.services.rest = true;
}
let mut event = TelemetryEvent::new(TelemetryKind::ServicesRestEnable)
.with_field("channel", JsonValue::from(channel))
.with_field(
"new_version_id",
JsonValue::from(update_result.new_version_id.clone()),
)
.with_field("url", JsonValue::from(canonical_url.clone()));
if let Some(tag) = ctx.telemetry_triggered_by() {
event = event.with_field("triggered_by", JsonValue::from(tag));
}
let _ = telemetry.record(event).await;
info!(
channel,
url = %canonical_url,
new_version_id = %update_result.new_version_id,
vta_did = %vta_did,
"REST enabled"
);
Ok(EnableRestResult {
new_version_id: update_result.new_version_id,
url: canonical_url,
vta_did,
serverless: update_result.serverless,
})
}
async fn read_preconditions(
config: &Arc<RwLock<AppConfig>>,
webvh_ks: &KeyspaceHandle,
) -> Result<(String, String, JsonValue), EnableRestError> {
{
let cfg = config.read().await;
if cfg.services.rest {
return Err(EnableRestError::ServiceAlreadyEnabled);
}
}
let state = super::preconditions::load_vta_doc_state(config, webvh_ks).await?;
if current_rest_service(&state.current_doc).is_some() {
return Err(EnableRestError::ServiceAlreadyEnabled);
}
Ok((state.vta_did, state.scid, state.current_doc))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::operations::protocol::snapshot::ServiceKind;
use crate::store::Store;
use vti_common::config::StoreConfig as VtiStoreConfig;
struct TestFixture {
_dir: tempfile::TempDir,
config: Arc<RwLock<AppConfig>>,
store: Store,
}
impl TestFixture {
fn snapshot_ks(&self) -> KeyspaceHandle {
self.store.keyspace(snapshot::KEYSPACE_NAME).unwrap()
}
fn webvh_ks(&self) -> KeyspaceHandle {
self.store.keyspace("webvh").unwrap()
}
}
fn build_fixture(rest_initially: bool) -> TestFixture {
use crate::test_support::test_app_config;
let dir = tempfile::tempdir().unwrap();
let mut cfg = test_app_config(dir.path().into());
cfg.services.rest = rest_initially;
cfg.services.didcomm = true;
cfg.vta_did = Some("did:webvh:scid123:host:vta".into());
cfg.config_path = dir.path().join("vta.toml");
let initial = toml::to_string_pretty(&cfg).unwrap();
std::fs::write(&cfg.config_path, initial).unwrap();
let store = Store::open(&VtiStoreConfig {
data_dir: dir.path().into(),
})
.unwrap();
TestFixture {
_dir: dir,
config: Arc::new(RwLock::new(cfg)),
store,
}
}
#[tokio::test]
async fn read_preconditions_rejects_when_already_enabled() {
let fx = build_fixture(true);
let err = read_preconditions(&fx.config, &fx.webvh_ks())
.await
.unwrap_err();
assert!(matches!(err, EnableRestError::ServiceAlreadyEnabled));
}
#[tokio::test]
async fn read_preconditions_rejects_without_vta_did() {
let fx = build_fixture(false);
fx.config.write().await.vta_did = None;
let err = read_preconditions(&fx.config, &fx.webvh_ks())
.await
.unwrap_err();
assert!(matches!(err, EnableRestError::VtaDidNotConfigured));
}
#[tokio::test]
async fn enable_rest_url_validation_runs_before_persist() {
let fx = build_fixture(false);
let snapshot_ks = fx.snapshot_ks();
let validated = validate_service_url("http://insecure.example.com");
assert!(validated.is_err(), "http:// must be rejected");
assert!(
snapshot::read(&snapshot_ks, ServiceKind::Rest)
.await
.unwrap()
.is_none(),
"validation error must abort before snapshot write",
);
}
}