use crate::didcomm_bridge::DIDCommBridge;
use crate::error::AppError;
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,
server_did: &'a str,
}
impl<'a> WebvhDIDCommClient<'a> {
pub fn new(bridge: &'a DIDCommBridge, server_did: &'a str) -> Self {
Self { bridge, server_did }
}
pub async fn request_uri(
&self,
path: Option<&str>,
domain: Option<&str>,
) -> Result<RequestUriResponse, AppError> {
let body = build_check_name_body(path, domain);
let response = self
.bridge
.send_and_wait(
self.server_did,
TASK_DID_CHECK_NAME,
serde_json::Value::Object(body),
TASK_DID_CHECK_NAME_RESPONSE,
TASK_DID_PROBLEM_REPORT,
30,
)
.await?;
parse_check_name_response(response.body)
}
pub async fn register_did_atomic(
&self,
path: &str,
did_log: &str,
force: bool,
domain: Option<&str>,
) -> Result<RequestUriResponse, AppError> {
let response = self
.bridge
.send_and_wait(
self.server_did,
TASK_DID_REGISTER,
serde_json::Value::Object(build_register_body(path, did_log, force, domain)),
TASK_DID_REGISTER_RESPONSE,
TASK_DID_PROBLEM_REPORT,
30,
)
.await?;
let record = response
.body
.get("record")
.cloned()
.or_else(|| {
Some(response.body.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.bridge
.send_and_wait(
self.server_did,
TASK_DID_DELETE,
serde_json::Value::Object(body),
TASK_DID_DELETE_RESPONSE,
TASK_DID_PROBLEM_REPORT,
30,
)
.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.bridge
.send_and_wait(
self.server_did,
task,
serde_json::Value::Object(body),
response_task,
TASK_DID_PROBLEM_REPORT,
30,
)
.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 resp = self
.bridge
.send_and_wait(
self.server_did,
TASK_AGENT_NAME_LIST,
serde_json::Value::Object(body),
TASK_AGENT_NAME_LIST_RESPONSE,
TASK_DID_PROBLEM_REPORT,
30,
)
.await?;
let Some(names) = resp.body.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 resp = self
.bridge
.send_and_wait(
self.server_did,
TASK_AGENT_NAME_CHECK,
serde_json::Value::Object(body),
TASK_AGENT_NAME_CHECK_RESPONSE,
TASK_DID_PROBLEM_REPORT,
30,
)
.await?;
serde_json::from_value(resp.body)
.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}");
}
}