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_PUBLISH: &str = "https://trusttasks.org/spec/did-management/did/publish/0.1";
const TASK_DID_PUBLISH_RESPONSE: &str =
"https://trusttasks.org/spec/did-management/did/publish/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";
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 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 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()),
);
}
let response = self
.bridge
.send_and_wait(
self.server_did,
TASK_DID_REGISTER,
serde_json::Value::Object(body),
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> {
let mut body = serde_json::Map::new();
body.insert(
"mnemonic".to_string(),
serde_json::Value::String(mnemonic.to_string()),
);
body.insert(
"method".to_string(),
serde_json::Value::String("webvh".to_string()),
);
body.insert(
"didData".to_string(),
serde_json::Value::String(log_content.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_PUBLISH,
serde_json::Value::Object(body),
TASK_DID_PUBLISH_RESPONSE,
TASK_DID_PROBLEM_REPORT,
30,
)
.await?;
Ok(())
}
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(())
}
}
#[cfg(test)]
mod tests {
use super::{build_check_name_body, parse_check_name_response};
use serde_json::json;
#[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(&json!(true)));
}
#[test]
fn explicit_path_is_sent() {
let body = build_check_name_body(Some("alice"), None);
assert_eq!(body.get("path"), Some(&json!("alice")));
assert_eq!(body.get("reserve"), Some(&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(&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(&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 = 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 = 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 = 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 = 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 = 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}");
}
}