use crate::didcomm_bridge::DIDCommBridge;
use crate::error::{AppError, bad_gateway_error};
use crate::webvh_client::RequestUriResponse;
const TASK_DID_CHECK_NAME: &str = "https://trusttasks.org/spec/did-management/did/check-name/0.1";
const TASK_DID_CHECK_NAME_RESPONSE: &str =
"https://trusttasks.org/spec/did-management/did/check-name/0.1#response";
const TASK_DID_REGISTER: &str = "https://trusttasks.org/spec/did-management/did/register/0.1";
const TASK_DID_REGISTER_RESPONSE: &str =
"https://trusttasks.org/spec/did-management/did/register/0.1#response";
const TASK_DID_DELETE: &str = "https://trusttasks.org/spec/did-management/did/delete/0.1";
const TASK_DID_DELETE_RESPONSE: &str =
"https://trusttasks.org/spec/did-management/did/delete/0.1#response";
const TASK_DID_PROBLEM_REPORT: &str =
"https://trusttasks.org/spec/did-management/did/problem-report/0.1";
const TASK_AGENT_NAME_UPDATE: &str =
"https://trusttasks.org/spec/did-management/agent-name/update/0.1";
const TASK_AGENT_NAME_UPDATE_RESPONSE: &str =
"https://trusttasks.org/spec/did-management/agent-name/update/0.1#response";
const TASK_AGENT_NAME_REMOVE: &str =
"https://trusttasks.org/spec/did-management/agent-name/remove/0.1";
const TASK_AGENT_NAME_REMOVE_RESPONSE: &str =
"https://trusttasks.org/spec/did-management/agent-name/remove/0.1#response";
const TASK_AGENT_NAME_LIST: &str = "https://trusttasks.org/spec/did-management/agent-name/list/0.1";
const TASK_AGENT_NAME_LIST_RESPONSE: &str =
"https://trusttasks.org/spec/did-management/agent-name/list/0.1#response";
const TASK_AGENT_NAME_CHECK: &str =
"https://trusttasks.org/spec/did-management/agent-name/check/0.1";
const TASK_AGENT_NAME_CHECK_RESPONSE: &str =
"https://trusttasks.org/spec/did-management/agent-name/check/0.1#response";
fn build_check_name_body(
path: Option<&str>,
domain: Option<&str>,
) -> serde_json::Map<String, serde_json::Value> {
let mut body = serde_json::Map::new();
if let Some(p) = path {
body.insert("path".to_string(), serde_json::Value::String(p.to_string()));
}
body.insert("reserve".to_string(), serde_json::Value::Bool(true));
if let Some(d) = domain {
body.insert(
"domain".to_string(),
serde_json::Value::String(d.to_string()),
);
}
body
}
fn build_register_body(
path: &str,
did_log: &str,
force: bool,
domain: Option<&str>,
) -> serde_json::Map<String, serde_json::Value> {
let mut body = serde_json::Map::new();
body.insert(
"path".to_string(),
serde_json::Value::String(path.to_string()),
);
body.insert(
"method".to_string(),
serde_json::Value::String("webvh".to_string()),
);
body.insert(
"didData".to_string(),
serde_json::Value::String(did_log.to_string()),
);
body.insert("force".to_string(), serde_json::Value::Bool(force));
if let Some(d) = domain {
body.insert(
"domain".to_string(),
serde_json::Value::String(d.to_string()),
);
}
body
}
fn parse_check_name_response(body: serde_json::Value) -> Result<RequestUriResponse, AppError> {
let reserved = body
.get("reserved")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !reserved {
let available = body
.get("available")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !available {
return Err(AppError::Conflict(
"webvh path already taken on the hosting server — choose a different \
WEBVH_PATH, or omit it for a server-assigned path"
.to_string(),
));
}
return Err(AppError::Internal(format!(
"remote refused reservation despite the path being available \
(available={available}); check-name with reserve=true expected to succeed"
)));
}
let record = body.get("record").cloned().unwrap_or_else(|| body.clone());
let mnemonic = record
.get("mnemonic")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::Internal("check-name response missing `mnemonic`".to_string()))?
.to_string();
let did_url = record
.get("didUrl")
.or_else(|| record.get("did_url"))
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::Internal("check-name response missing `didUrl`".to_string()))?
.to_string();
Ok(RequestUriResponse { mnemonic, did_url })
}
pub struct WebvhDIDCommClient<'a> {
bridge: &'a DIDCommBridge,
resolver: &'a affinidi_did_resolver_cache_sdk::DIDCacheClient,
server_did: String,
#[cfg(feature = "tsp")]
tsp: Option<crate::operations::outbound::TspSender>,
}
const TRUST_TASK_ERROR_PREFIX: &str = "https://trusttasks.org/spec/trust-task-error/";
fn build_envelope_document(
task: &str,
recipient: &str,
issuer: Option<String>,
payload: serde_json::Value,
) -> serde_json::Value {
let mut doc = serde_json::json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": task,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"recipient": recipient,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": payload,
});
if let Some(vta_did) = issuer {
doc["issuer"] = serde_json::Value::String(vta_did);
}
doc
}
fn unwrap_envelope_reply(
doc: serde_json::Value,
expected: &str,
) -> Result<serde_json::Value, AppError> {
let doc_type = doc.get("type").and_then(|v| v.as_str()).unwrap_or_default();
if doc_type == TASK_DID_PROBLEM_REPORT {
let payload = doc.get("payload").unwrap_or(&serde_json::Value::Null);
let code = payload
.get("code")
.and_then(|v| v.as_str())
.unwrap_or("e.p.did.unknown");
let comment = payload
.get("comment")
.and_then(|v| v.as_str())
.unwrap_or_default();
return Err(crate::didcomm_bridge::problem_report_to_app_error(
code, comment,
));
}
if doc_type.starts_with(TRUST_TASK_ERROR_PREFIX) {
let payload = doc.get("payload").unwrap_or(&serde_json::Value::Null);
let code = payload
.get("code")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let message = payload
.get("message")
.and_then(|v| v.as_str())
.unwrap_or_default();
return Err(bad_gateway_error(format!(
"hosting peer refused the trust-task envelope: {message} [{code}]"
)));
}
if doc_type != expected {
return Err(bad_gateway_error(format!(
"unexpected response document type: expected {expected}, got {doc_type}"
)));
}
Ok(doc
.get("payload")
.cloned()
.unwrap_or(serde_json::Value::Null))
}
impl<'a> WebvhDIDCommClient<'a> {
pub fn new(
bridge: &'a DIDCommBridge,
resolver: &'a affinidi_did_resolver_cache_sdk::DIDCacheClient,
server_did: impl Into<String>,
#[cfg(feature = "tsp")] tsp: Option<crate::operations::outbound::TspSender>,
) -> Self {
Self {
bridge,
resolver,
server_did: server_did.into(),
#[cfg(feature = "tsp")]
tsp,
}
}
async fn send_task(
&self,
task: &str,
response_task: &str,
payload: serde_json::Value,
) -> Result<serde_json::Value, AppError> {
let doc = build_envelope_document(task, &self.server_did, self.bridge.vta_did(), payload);
let reply = crate::operations::outbound::Outbound::from_parts(
self.resolver,
self.bridge,
#[cfg(feature = "tsp")]
self.tsp.clone(),
)
.send(
&self.server_did,
doc,
crate::operations::outbound::ReplyTrust::TransportAuthenticated,
)
.await?;
unwrap_envelope_reply(reply, response_task)
}
pub async fn request_uri(
&self,
path: Option<&str>,
domain: Option<&str>,
) -> Result<RequestUriResponse, AppError> {
let body = build_check_name_body(path, domain);
let payload = self
.send_task(
TASK_DID_CHECK_NAME,
TASK_DID_CHECK_NAME_RESPONSE,
serde_json::Value::Object(body),
)
.await?;
parse_check_name_response(payload)
}
pub async fn register_did_atomic(
&self,
path: &str,
did_log: &str,
force: bool,
domain: Option<&str>,
) -> Result<RequestUriResponse, AppError> {
let payload = self
.send_task(
TASK_DID_REGISTER,
TASK_DID_REGISTER_RESPONSE,
serde_json::Value::Object(build_register_body(path, did_log, force, domain)),
)
.await?;
let record = payload
.get("record")
.cloned()
.or_else(|| {
Some(payload.clone())
})
.unwrap_or(serde_json::Value::Null);
let mnemonic = record
.get("mnemonic")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::Internal("register response missing `mnemonic`".to_string()))?
.to_string();
let did_url = record
.get("didUrl")
.or_else(|| record.get("did_url"))
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::Internal("register response missing `didUrl`".to_string()))?
.to_string();
Ok(RequestUriResponse { mnemonic, did_url })
}
pub async fn publish_did(
&self,
mnemonic: &str,
log_content: &str,
domain: Option<&str>,
) -> Result<(), AppError> {
self.register_did_atomic(mnemonic, log_content, false, domain)
.await
.map(|_| ())
}
pub async fn delete_did(&self, mnemonic: &str, domain: Option<&str>) -> Result<(), AppError> {
let mut body = serde_json::Map::new();
body.insert(
"mnemonic".to_string(),
serde_json::Value::String(mnemonic.to_string()),
);
if let Some(d) = domain {
body.insert(
"domain".to_string(),
serde_json::Value::String(d.to_string()),
);
}
self.send_task(
TASK_DID_DELETE,
TASK_DID_DELETE_RESPONSE,
serde_json::Value::Object(body),
)
.await?;
Ok(())
}
async fn agent_name_verb(
&self,
task: &str,
response_task: &str,
mnemonic: &str,
name: &str,
state: Option<&str>,
did_log: &str,
domain: Option<&str>,
) -> Result<(), AppError> {
let mut body = serde_json::Map::new();
body.insert("mnemonic".to_string(), serde_json::json!(mnemonic));
body.insert("name".to_string(), serde_json::json!(name));
body.insert("didLog".to_string(), serde_json::json!(did_log));
if let Some(s) = state {
body.insert("state".to_string(), serde_json::json!(s));
}
if let Some(d) = domain {
body.insert("domain".to_string(), serde_json::json!(d));
}
self.send_task(task, response_task, serde_json::Value::Object(body))
.await?;
Ok(())
}
pub async fn update_agent_name(
&self,
mnemonic: &str,
name: &str,
state: &str,
did_log: &str,
domain: Option<&str>,
) -> Result<(), AppError> {
self.agent_name_verb(
TASK_AGENT_NAME_UPDATE,
TASK_AGENT_NAME_UPDATE_RESPONSE,
mnemonic,
name,
Some(state),
did_log,
domain,
)
.await
}
pub async fn remove_agent_name(
&self,
mnemonic: &str,
name: &str,
did_log: &str,
domain: Option<&str>,
) -> Result<(), AppError> {
self.agent_name_verb(
TASK_AGENT_NAME_REMOVE,
TASK_AGENT_NAME_REMOVE_RESPONSE,
mnemonic,
name,
None,
did_log,
domain,
)
.await
}
pub async fn list_agent_names(
&self,
mnemonic: &str,
domain: Option<&str>,
) -> Result<Vec<crate::webvh_client::AgentNameEntryWire>, AppError> {
let mut body = serde_json::Map::new();
body.insert("mnemonic".to_string(), serde_json::json!(mnemonic));
if let Some(d) = domain {
body.insert("domain".to_string(), serde_json::json!(d));
}
let payload = self
.send_task(
TASK_AGENT_NAME_LIST,
TASK_AGENT_NAME_LIST_RESPONSE,
serde_json::Value::Object(body),
)
.await?;
let Some(names) = payload.get("agentNames") else {
return Ok(Vec::new());
};
serde_json::from_value(names.clone())
.map_err(|e| AppError::Internal(format!("agent-name list response parse error: {e}")))
}
pub async fn check_agent_name(
&self,
name: &str,
domain: Option<&str>,
) -> Result<crate::webvh_client::AgentNameAvailabilityWire, AppError> {
let mut body = serde_json::Map::new();
body.insert("name".to_string(), serde_json::json!(name));
if let Some(d) = domain {
body.insert("domain".to_string(), serde_json::json!(d));
}
let payload = self
.send_task(
TASK_AGENT_NAME_CHECK,
TASK_AGENT_NAME_CHECK_RESPONSE,
serde_json::Value::Object(body),
)
.await?;
serde_json::from_value(payload)
.map_err(|e| AppError::Internal(format!("agent-name check response parse error: {e}")))
}
}
#[cfg(test)]
mod tests {
use super::{
TASK_AGENT_NAME_LIST, TASK_AGENT_NAME_REMOVE, TASK_AGENT_NAME_UPDATE, TASK_DID_CHECK_NAME,
TASK_DID_DELETE, TASK_DID_REGISTER, build_check_name_body, build_register_body,
parse_check_name_response,
};
#[test]
fn no_task_constant_names_a_retired_verb() {
const RETIRED: &[&str] = &[
"did/publish/",
"agent-name/set/",
"agent-name/enable/",
"agent-name/disable/",
];
for task in [
TASK_DID_CHECK_NAME,
TASK_DID_REGISTER,
TASK_DID_DELETE,
TASK_AGENT_NAME_UPDATE,
TASK_AGENT_NAME_REMOVE,
TASK_AGENT_NAME_LIST,
] {
for retired in RETIRED {
assert!(
!task.contains(retired),
"{task} names `{retired}`, retired by did-hosting 0.8.3 — \
the host will drop it silently and the caller will hang 30s"
);
}
}
}
#[test]
fn publish_registers_the_owned_slot_without_forcing() {
let body = build_register_body("brave-otter", "<jsonl>", false, None);
assert_eq!(body.get("path"), Some(&serde_json::json!("brave-otter")));
assert_eq!(body.get("didData"), Some(&serde_json::json!("<jsonl>")));
assert_eq!(body.get("force"), Some(&serde_json::json!(false)));
assert_eq!(body.get("method"), Some(&serde_json::json!("webvh")));
assert!(!body.contains_key("domain"));
assert!(!body.contains_key("mnemonic"));
}
#[test]
fn register_body_includes_domain_when_present() {
let body = build_register_body("brave-otter", "<jsonl>", true, Some("acme.example.com"));
assert_eq!(
body.get("domain"),
Some(&serde_json::json!("acme.example.com"))
);
assert_eq!(body.get("force"), Some(&serde_json::json!(true)));
}
#[test]
fn auto_assign_omits_path() {
let body = build_check_name_body(None, None);
assert!(
!body.contains_key("path"),
"auto-assign must omit `path`; got {body:?}"
);
assert_eq!(body.get("reserve"), Some(&serde_json::json!(true)));
}
#[test]
fn explicit_path_is_sent() {
let body = build_check_name_body(Some("alice"), None);
assert_eq!(body.get("path"), Some(&serde_json::json!("alice")));
assert_eq!(body.get("reserve"), Some(&serde_json::json!(true)));
}
#[test]
fn well_known_is_sent_as_path() {
let body = build_check_name_body(Some(".well-known"), None);
assert_eq!(body.get("path"), Some(&serde_json::json!(".well-known")));
}
#[test]
fn domain_included_only_when_present() {
let with = build_check_name_body(Some("alice"), Some("acme.example.com"));
assert_eq!(
with.get("domain"),
Some(&serde_json::json!("acme.example.com"))
);
let without = build_check_name_body(Some("alice"), None);
assert!(!without.contains_key("domain"));
}
#[test]
fn parses_spec_record_shaped_response() {
let body = serde_json::json!({
"available": true,
"reserved": true,
"record": {
"mnemonic": "brave-otter",
"owner": "did:key:z6MkAlice",
"createdAt": "2026-06-04T10:00:01Z",
"updatedAt": "2026-06-04T10:00:01Z",
"versionCount": 0,
"domain": "did.example.com",
"didUrl": "https://did.example.com/brave-otter/did.jsonl",
"disabled": false
}
});
let resp = parse_check_name_response(body).expect("spec record parses");
assert_eq!(resp.mnemonic, "brave-otter");
assert_eq!(
resp.did_url,
"https://did.example.com/brave-otter/did.jsonl"
);
}
#[test]
fn parses_legacy_flat_response() {
let body = serde_json::json!({
"available": true,
"reserved": true,
"mnemonic": "alice",
"did_url": "https://did.example.com/alice/did.jsonl"
});
let resp = parse_check_name_response(body).expect("legacy flat parses");
assert_eq!(resp.mnemonic, "alice");
assert_eq!(resp.did_url, "https://did.example.com/alice/did.jsonl");
}
#[test]
fn not_reserved_and_unavailable_is_a_conflict() {
let body = serde_json::json!({ "available": false, "reserved": false });
let err = parse_check_name_response(body).expect_err("must error");
assert!(
matches!(err, crate::error::AppError::Conflict(_)),
"taken path must be a 409 conflict, got: {err:?}"
);
assert!(
err.to_string().contains("taken"),
"conflict should explain the path is taken: {err}"
);
}
#[test]
fn not_reserved_but_available_is_an_internal_anomaly() {
let body = serde_json::json!({ "available": true, "reserved": false });
let err = parse_check_name_response(body).expect_err("must error");
assert!(
matches!(err, crate::error::AppError::Internal(_)),
"free-but-ungranted slot must be a 500 anomaly, got: {err:?}"
);
assert!(
err.to_string().contains("available=true"),
"anomaly should surface availability: {err}"
);
}
#[test]
fn reserved_without_did_url_errors() {
let body = serde_json::json!({
"reserved": true,
"record": { "mnemonic": "alice" }
});
let err = parse_check_name_response(body).expect_err("must error");
assert!(err.to_string().contains("didUrl"), "got: {err}");
}
}
#[cfg(test)]
mod envelope_binding_tests {
use super::{
TASK_DID_CHECK_NAME, TASK_DID_CHECK_NAME_RESPONSE, TASK_DID_PROBLEM_REPORT,
build_envelope_document, unwrap_envelope_reply,
};
use crate::error::AppError;
use serde_json::json;
const SERVER: &str = "did:webvh:example.com:control";
const VTA: &str = "did:webvh:example.com:vta";
#[test]
fn the_task_type_is_in_the_document() {
let doc = build_envelope_document(
TASK_DID_CHECK_NAME,
SERVER,
Some(VTA.to_string()),
json!({ "path": "bob", "reserve": true }),
);
assert_eq!(doc["type"], TASK_DID_CHECK_NAME);
assert_eq!(doc["recipient"], SERVER);
assert_eq!(doc["issuer"], VTA);
assert_eq!(doc["payload"]["path"], "bob");
assert_eq!(doc["payload"]["reserve"], true);
assert!(
doc["id"]
.as_str()
.unwrap_or_default()
.starts_with("urn:uuid:"),
"the document id is the thread anchor and must be a urn:uuid"
);
}
#[test]
fn an_unknown_issuer_is_omitted_not_guessed() {
let doc = build_envelope_document(TASK_DID_CHECK_NAME, SERVER, None, json!({}));
assert!(
doc.get("issuer").is_none(),
"an unknown issuer must be absent, not empty or invented: {doc}"
);
}
#[test]
fn a_response_document_yields_its_payload() {
let reply = json!({
"id": "urn:uuid:2",
"type": TASK_DID_CHECK_NAME_RESPONSE,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": { "available": true, "reserved": true, "record": { "mnemonic": "bob" } },
});
let payload = unwrap_envelope_reply(reply, TASK_DID_CHECK_NAME_RESPONSE)
.expect("a matching response document unwraps");
assert_eq!(payload["available"], true);
assert_eq!(payload["record"]["mnemonic"], "bob");
}
#[test]
fn an_enveloped_problem_report_keeps_its_typed_meaning() {
let reply = json!({
"id": "urn:uuid:3",
"type": TASK_DID_PROBLEM_REPORT,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": { "code": "e.p.did.path-unavailable", "comment": "taken" },
});
let err = unwrap_envelope_reply(reply, TASK_DID_CHECK_NAME_RESPONSE)
.expect_err("a problem report is an error, not a payload");
assert!(
matches!(err, AppError::Conflict(_)),
"path-unavailable must stay a 409, got: {err:?}"
);
}
#[test]
fn a_trust_task_error_is_a_bad_gateway_at_either_version() {
for version in ["0.1", "0.2"] {
let reply = json!({
"id": "urn:uuid:4",
"type": format!("https://trusttasks.org/spec/trust-task-error/{version}"),
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": { "code": "malformedRequest", "message": "nope" },
});
let err = unwrap_envelope_reply(reply, TASK_DID_CHECK_NAME_RESPONSE)
.expect_err("a framework error is not a payload");
let msg = format!("{err:?}");
assert!(
msg.contains("refused the trust-task envelope"),
"trust-task-error/{version} must surface as an envelope refusal, got: {msg}"
);
}
}
#[test]
fn a_mismatched_response_type_is_refused() {
let reply = json!({
"id": "urn:uuid:5",
"type": "https://trusttasks.org/spec/did-management/did/delete/0.1#response",
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": { "deleted": true },
});
let err = unwrap_envelope_reply(reply, TASK_DID_CHECK_NAME_RESPONSE)
.expect_err("an off-task response must not be accepted");
let msg = format!("{err:?}");
assert!(
msg.contains("unexpected response document type"),
"got: {msg}"
);
}
}