use serde_json::Value;
use vta_sdk::protocol::matching::{Protocol, ServiceCapabilities};
use vti_common::error::{AppError, bad_gateway_error};
use crate::operations::room_issuance::{SigningContext, VtaKeySigner};
pub const OUTBOUND_SUPPORTED: [Protocol; 1] = [Protocol::Rest];
fn pick_transport(caps: &ServiceCapabilities, host: &str) -> Result<(Protocol, String), AppError> {
for protocol in Protocol::PREFERENCE_ORDER {
if !OUTBOUND_SUPPORTED.contains(&protocol) {
continue;
}
if let Some(endpoint) = caps.endpoint(protocol) {
return Ok((protocol, endpoint.to_string()));
}
}
let advertised: Vec<&str> = Protocol::PREFERENCE_ORDER
.iter()
.filter(|p| caps.endpoint(**p).is_some())
.map(|p| p.as_str())
.collect();
let ours: Vec<&str> = OUTBOUND_SUPPORTED.iter().map(|p| p.as_str()).collect();
Err(AppError::Validation(format!(
"no transport in common with room host `{host}`: it advertises [{}] and this agent can \
initiate [{}]. This is not a host that cannot be reached — it is one this agent cannot \
yet start a conversation with, which is a gap in the agent rather than in the host.",
if advertised.is_empty() {
"nothing".to_string()
} else {
advertised.join(", ")
},
ours.join(", "),
)))
}
pub fn build_room_task(task: &str, host: &str, issuer: &str, payload: Value) -> Value {
serde_json::json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": task,
"recipient": host,
"issuer": issuer,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": payload,
})
}
pub fn read_reply(doc: &Value, expected: &str, host: &str) -> Result<Value, AppError> {
let doc_type = doc.get("type").and_then(Value::as_str).unwrap_or_default();
if doc_type == expected {
return Ok(doc.get("payload").cloned().unwrap_or(Value::Null));
}
if doc_type.starts_with("https://trusttasks.org/spec/trust-task-error/") {
let payload = doc.get("payload").cloned().unwrap_or(Value::Null);
let code = payload
.get("code")
.and_then(Value::as_str)
.unwrap_or("unspecified");
let reason = payload
.get("reason")
.or_else(|| payload.get("message"))
.and_then(Value::as_str)
.unwrap_or("no reason given");
return Err(AppError::Forbidden(format!(
"room host `{host}` refused: {code}: {reason}"
)));
}
Err(AppError::Internal(format!(
"room host `{host}` answered `{doc_type}` where `{expected}` was expected; a reply that \
threads to our request but answers a different task is a contract break rather than a \
task failure"
)))
}
pub async fn send_room_task(
ctx: SigningContext<'_>,
resolver: &affinidi_did_resolver_cache_sdk::DIDCacheClient,
host: &str,
signing_key_id: &str,
issuer: &str,
verification_method: &str,
task: &str,
response_task: &str,
payload: Value,
) -> Result<Value, AppError> {
let resolved = resolver.resolve(host).await.map_err(|e| {
AppError::Validation(format!(
"the room host `{host}` does not resolve, so there is nothing to send to: {e}"
))
})?;
let doc_value = serde_json::to_value(&resolved.doc)
.map_err(|e| AppError::Internal(format!("serialise the host's DID document: {e}")))?;
let caps = ServiceCapabilities::from_did_document(&doc_value);
let (protocol, endpoint) = pick_transport(&caps, host)?;
let mut document = build_room_task(task, host, issuer, payload);
let signer = VtaKeySigner::new(ctx, signing_key_id, verification_method);
let proof = affinidi_data_integrity::DataIntegrityProof::sign(
&document,
&signer,
affinidi_data_integrity::SignOptions::new(),
)
.await
.map_err(|e| {
let mut cause = String::new();
let mut src: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e);
while let Some(inner) = src {
cause = format!("{cause}: {inner}");
src = inner.source();
}
AppError::Internal(format!(
"sign the request to room host `{host}`: {e}{cause}"
))
})?;
document["proof"] = serde_json::to_value(proof)
.map_err(|e| AppError::Internal(format!("serialise the proof: {e}")))?;
match protocol {
Protocol::Rest => {
let url = format!("{}/trust-tasks", endpoint.trim_end_matches('/'));
let response = vta_sdk::http::rest_client()
.post(&url)
.header("content-type", "application/json")
.json(&document)
.send()
.await
.map_err(|e| {
bad_gateway_error(format!("room host `{host}` at {url} did not answer: {e}"))
})?;
let body = response.text().await.map_err(|e| {
bad_gateway_error(format!("room host `{host}` sent an unreadable body: {e}"))
})?;
let reply: Value = serde_json::from_str(&body).map_err(|e| {
bad_gateway_error(format!(
"room host `{host}` sent a body that is not a Trust-Task document: {e}: {body}"
))
})?;
read_reply(&reply, response_task, host)
}
Protocol::Tsp | Protocol::Didcomm => Err(AppError::Internal(format!(
"{} is named in OUTBOUND_SUPPORTED but has no send path here",
protocol.as_str()
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn caps_from(services: Value) -> ServiceCapabilities {
ServiceCapabilities::from_did_document(&serde_json::json!({ "service": services }))
}
#[test]
fn a_host_serving_rest_is_reachable() {
let caps = caps_from(serde_json::json!([{
"id": "#rest", "type": "VTARest", "serviceEndpoint": "https://host.example"
}]));
let (protocol, endpoint) = pick_transport(&caps, "did:example:host").expect("reachable");
assert_eq!(protocol, Protocol::Rest);
assert_eq!(endpoint, "https://host.example");
}
#[test]
fn a_tsp_only_host_is_refused_naming_both_sides() {
let caps = caps_from(serde_json::json!([{
"id": "#tsp", "type": "TSPTransport", "serviceEndpoint": "did:example:mediator"
}]));
let err = pick_transport(&caps, "did:example:host").expect_err("no common transport");
let msg = err.to_string();
assert!(
msg.contains("tsp"),
"must name what the host advertises: {msg}"
);
assert!(
msg.contains("rest"),
"must name what this agent can do: {msg}"
);
assert!(
msg.contains("gap in the agent"),
"must say whose limitation it is: {msg}"
);
}
#[test]
fn a_host_advertising_nothing_says_so() {
let err = pick_transport(&caps_from(serde_json::json!([])), "did:example:host")
.expect_err("nothing advertised");
assert!(err.to_string().contains("nothing"));
}
#[test]
fn the_document_is_addressed_and_attributed() {
let doc = build_room_task(
"https://trusttasks.org/spec/rooms/epoch/chain/0.1",
"did:example:host",
"did:example:agent",
serde_json::json!({ "roomId": "did:example:room" }),
);
assert_eq!(doc["recipient"], "did:example:host");
assert_eq!(doc["issuer"], "did:example:agent");
assert_eq!(doc["payload"]["roomId"], "did:example:room");
assert!(doc["id"].as_str().unwrap().starts_with("urn:uuid:"));
}
#[test]
fn a_response_yields_its_payload() {
let reply = serde_json::json!({
"type": "https://trusttasks.org/spec/rooms/epoch/chain/0.1#response",
"payload": { "links": [] }
});
let got = read_reply(
&reply,
"https://trusttasks.org/spec/rooms/epoch/chain/0.1#response",
"did:example:host",
)
.expect("a response");
assert_eq!(got["links"], serde_json::json!([]));
}
#[test]
fn a_refusal_carries_the_hosts_own_code_and_reason() {
let reply = serde_json::json!({
"type": "https://trusttasks.org/spec/trust-task-error/0.5",
"payload": { "code": "private-tier-not-enabled", "reason": "this community has not enabled private rooms" }
});
let err = read_reply(&reply, "irrelevant", "did:example:host").expect_err("a refusal");
let msg = err.to_string();
assert!(msg.contains("private-tier-not-enabled"), "{msg}");
assert!(msg.contains("has not enabled private rooms"), "{msg}");
assert!(
matches!(err, AppError::Forbidden(_)),
"a refusal, not a 502"
);
}
#[test]
fn a_reply_answering_a_different_task_is_a_contract_break() {
let reply =
serde_json::json!({ "type": "https://trusttasks.org/spec/rooms/create/0.1#response" });
let err = read_reply(
&reply,
"https://trusttasks.org/spec/rooms/epoch/chain/0.1#response",
"did:example:host",
)
.expect_err("wrong task");
assert!(err.to_string().contains("contract break"));
}
}