use std::sync::Arc;
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use trust_tasks_rs::{ErrorPayload, Payload, TrustTask};
use crate::error::TrqlError;
use crate::payloads::{
AuthorizationRequest, AuthorizationResponse, RecognitionRequest, RecognitionResponse,
};
use crate::transport::TrqlTransport;
const ERROR_SLUG: &str = "trust-task-error";
fn payload_build_error(err: impl std::fmt::Display) -> TrqlError {
TrqlError::Config(format!("could not build query payload: {err}"))
}
const TRQP_TUPLE: [&str; 4] = ["entity_id", "authority_id", "action", "resource"];
#[derive(Debug, Clone)]
pub struct TrqpQuery {
pub entity_id: String,
pub authority_id: String,
pub action: String,
pub resource: String,
pub time: Option<DateTime<Utc>>,
pub locator: Option<String>,
}
impl TrqpQuery {
pub fn new(
entity_id: impl Into<String>,
authority_id: impl Into<String>,
action: impl Into<String>,
resource: impl Into<String>,
) -> Self {
Self {
entity_id: entity_id.into(),
authority_id: authority_id.into(),
action: action.into(),
resource: resource.into(),
time: None,
locator: None,
}
}
pub fn at(mut self, time: DateTime<Utc>) -> Self {
self.time = Some(time);
self
}
pub fn locator(mut self, locator: impl Into<String>) -> Self {
self.locator = Some(locator.into());
self
}
fn has_context(&self) -> bool {
self.time.is_some() || self.locator.is_some()
}
}
pub struct TrqlClient {
transport: Arc<dyn TrqlTransport>,
registry_did: String,
client_did: Option<String>,
referred_by: Option<String>,
}
impl TrqlClient {
pub fn new(transport: Arc<dyn TrqlTransport>, registry_did: impl Into<String>) -> Self {
Self {
transport,
registry_did: registry_did.into(),
client_did: None,
referred_by: None,
}
}
pub fn with_client_did(mut self, did: impl Into<String>) -> Self {
self.client_did = Some(did.into());
self
}
pub fn referred_by(mut self, origin_did: impl Into<String>) -> Self {
self.referred_by = Some(origin_did.into());
self
}
pub async fn authorization(
&self,
query: TrqpQuery,
) -> Result<AuthorizationResponse, TrqlError> {
let context: Option<crate::payloads::AuthorizationQueryContext> = query
.has_context()
.then(|| {
crate::payloads::AuthorizationQueryContext::builder()
.time(query.time)
.locator(query.locator.clone())
.extra(std::collections::HashMap::new())
.try_into()
})
.transpose()
.map_err(payload_build_error)?;
let payload: AuthorizationRequest = AuthorizationRequest::builder()
.entity_id(query.entity_id.clone())
.authority_id(query.authority_id.clone())
.action(query.action.clone())
.resource(query.resource.clone())
.context(context)
.try_into()
.map_err(payload_build_error)?;
self.send_query(payload).await
}
pub async fn recognition(&self, query: TrqpQuery) -> Result<RecognitionResponse, TrqlError> {
let context: Option<crate::payloads::RecognitionQueryContext> = query
.has_context()
.then(|| {
crate::payloads::RecognitionQueryContext::builder()
.time(query.time)
.locator(query.locator.clone())
.extra(std::collections::HashMap::new())
.try_into()
})
.transpose()
.map_err(payload_build_error)?;
let payload: RecognitionRequest = RecognitionRequest::builder()
.entity_id(query.entity_id.clone())
.authority_id(query.authority_id.clone())
.action(query.action.clone())
.resource(query.resource.clone())
.context(context)
.try_into()
.map_err(payload_build_error)?;
self.send_query(payload).await
}
async fn send_query<Req, Resp>(&self, payload: Req) -> Result<Resp, TrqlError>
where
Req: Payload + Serialize,
Resp: DeserializeOwned,
{
let body = serde_json::to_value(&payload)
.map_err(|e| TrqlError::Contract(format!("request payload did not serialize: {e}")))?;
let asked = TRQP_TUPLE.map(|field| body.get(field).cloned().unwrap_or(Value::Null));
let id = new_task_id();
let mut request = TrustTask::new(id.clone(), Req::type_uri(), body);
request.recipient = Some(self.registry_did.clone());
request.issuer = self.client_did.clone();
request.issued_at = Some(Utc::now());
let request_slug = request.type_uri.slug().to_string();
let reply = self.transport.exchange(request).await?;
if reply.thread_id.as_deref() != Some(id.as_str()) {
return Err(TrqlError::Contract(format!(
"uncorrelated reply: threadId {:?} does not match request id {id}",
reply.thread_id
)));
}
if reply.type_uri.slug() == ERROR_SLUG {
let error: ErrorPayload = serde_json::from_value(reply.payload).map_err(|e| {
TrqlError::Contract(format!("trust-task-error payload did not parse: {e}"))
})?;
return Err(TrqlError::Rejected {
code: error.code,
retryable: error.retryable,
retry_after: error.retry_after,
message: error.message,
});
}
if !(reply.type_uri.is_response() && reply.type_uri.slug() == request_slug) {
return Err(TrqlError::Contract(format!(
"unexpected reply type `{}` to a `{request_slug}` request",
reply.type_uri
)));
}
self.check_answers_our_question(&asked, &reply.payload)?;
serde_json::from_value(reply.payload)
.map_err(|e| TrqlError::Contract(format!("response payload did not parse: {e}")))
}
fn check_answers_our_question(
&self,
asked: &[Value; TRQP_TUPLE.len()],
answered: &Value,
) -> Result<(), TrqlError> {
for (&field, asked) in TRQP_TUPLE.iter().zip(asked) {
let Some(answered) = answered.get(field) else {
continue;
};
if answered != asked {
return Err(TrqlError::AnswerMismatch {
field,
asked: render(asked),
answered: render(answered),
});
}
}
if let Some(origin) = self.referred_by.as_deref() {
let answered = answered.get("authority_id").and_then(Value::as_str);
if answered != Some(origin) {
return Err(TrqlError::ReferralNotClosed {
origin: origin.to_string(),
answered: answered.unwrap_or("<absent>").to_string(),
});
}
}
Ok(())
}
}
fn render(value: &Value) -> String {
match value.as_str() {
Some(s) => s.to_string(),
None => value.to_string(),
}
}
fn new_task_id() -> String {
format!("urn:uuid:{}", uuid_v4())
}
#[cfg(any(feature = "didcomm", feature = "tsp"))]
fn uuid_v4() -> String {
uuid::Uuid::new_v4().to_string()
}
#[cfg(not(any(feature = "didcomm", feature = "tsp")))]
fn uuid_v4() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{nanos:032x}-{n:016x}")
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use trust_tasks_rs::RejectReason;
struct MockTransport<F>(F)
where
F: Fn(TrustTask<Value>) -> Result<TrustTask<Value>, TrqlError> + Send + Sync;
#[async_trait::async_trait]
impl<F> TrqlTransport for MockTransport<F>
where
F: Fn(TrustTask<Value>) -> Result<TrustTask<Value>, TrqlError> + Send + Sync,
{
fn kind(&self) -> crate::TransportKind {
crate::TransportKind::Https
}
async fn exchange(&self, request: TrustTask<Value>) -> Result<TrustTask<Value>, TrqlError> {
(self.0)(request)
}
}
fn client_over<F>(f: F) -> TrqlClient
where
F: Fn(TrustTask<Value>) -> Result<TrustTask<Value>, TrqlError> + Send + Sync + 'static,
{
TrqlClient::new(Arc::new(MockTransport(f)), "did:example:registry")
}
fn authorized_response(request: &TrustTask<Value>, authorized: bool) -> TrustTask<Value> {
let payload = serde_json::json!({
"entity_id": request.payload["entity_id"],
"authority_id": request.payload["authority_id"],
"action": request.payload["action"],
"resource": request.payload["resource"],
"authorized": authorized,
"time_evaluated": "2026-07-16T00:00:00Z",
});
request.respond_with("urn:uuid:reply".to_string(), payload)
}
fn query() -> TrqpQuery {
TrqpQuery::new("did:example:e", "did:example:a", "issue", "vc")
}
#[tokio::test]
async fn authorization_round_trip_stamps_recipient_and_parses_reply() {
let client = client_over(|req| {
assert_eq!(req.recipient.as_deref(), Some("did:example:registry"));
assert!(req.issued_at.is_some());
assert_eq!(req.type_uri.slug(), "registry/authorization");
Ok(authorized_response(&req, true))
});
let response = client.authorization(query()).await.unwrap();
assert!(response.authorized);
assert_eq!(response.entity_id, "did:example:e");
}
#[tokio::test]
async fn uncorrelated_reply_is_a_contract_error() {
let client = client_over(|req| {
let mut reply = authorized_response(&req, true);
reply.thread_id = Some("urn:uuid:someone-else".to_string());
Ok(reply)
});
let err = client.authorization(query()).await.unwrap_err();
assert!(matches!(err, TrqlError::Contract(_)), "got: {err}");
assert!(!err.is_retryable());
}
#[tokio::test]
async fn error_document_maps_to_rejected_with_code() {
let client = client_over(|req| {
let error_doc = req.reject_with(
"urn:uuid:err".to_string(),
RejectReason::PermissionDenied {
reason: "not allowed".to_string(),
},
);
let as_value = serde_json::to_value(&error_doc).unwrap();
Ok(serde_json::from_value(as_value).unwrap())
});
let err = client.authorization(query()).await.unwrap_err();
match err {
TrqlError::Rejected { retryable, .. } => assert!(!retryable),
other => panic!("expected Rejected, got {other}"),
}
}
#[tokio::test]
async fn wrong_response_type_is_a_contract_error() {
let client = client_over(|req| {
let mut reply = authorized_response(&req, true);
reply.type_uri = "https://trusttasks.org/spec/registry/recognition/0.1#response"
.parse()
.unwrap();
Ok(reply)
});
let err = client.authorization(query()).await.unwrap_err();
assert!(matches!(err, TrqlError::Contract(_)), "got: {err}");
}
#[tokio::test]
async fn malformed_response_payload_is_a_contract_error_not_transport() {
let client = client_over(|req| {
let payload = serde_json::json!({ "unexpected": true });
Ok(req.respond_with("urn:uuid:reply".to_string(), payload))
});
let err = client.authorization(query()).await.unwrap_err();
assert!(matches!(err, TrqlError::Contract(_)), "got: {err}");
assert!(!err.is_retryable());
}
#[tokio::test]
async fn recognition_parses_recognized_flag() {
let client = client_over(|req| {
let payload = serde_json::json!({
"entity_id": req.payload["entity_id"],
"authority_id": req.payload["authority_id"],
"action": req.payload["action"],
"resource": req.payload["resource"],
"recognized": false,
"time_evaluated": "2026-07-16T00:00:00Z",
});
Ok(req.respond_with("urn:uuid:reply".to_string(), payload))
});
let response = client.recognition(query()).await.unwrap();
assert!(!response.recognized, "absence of trust reads as false");
}
#[tokio::test]
async fn query_context_is_sent_when_time_is_set() {
let at = "2026-01-01T00:00:00Z".parse().unwrap();
let client = client_over(|req| {
assert_eq!(
req.payload["context"]["time"], "2026-01-01T00:00:00Z",
"context.time must be carried on the wire"
);
Ok(authorized_response(&req, true))
});
client.authorization(query().at(at)).await.unwrap();
}
fn answer_substituting(
request: &TrustTask<Value>,
field: &str,
value: &str,
) -> TrustTask<Value> {
let mut reply = authorized_response(request, true);
reply.payload[field] = Value::String(value.to_string());
reply
}
#[tokio::test]
async fn answer_for_a_different_authority_is_rejected() {
let client = client_over(|req| {
Ok(answer_substituting(
&req,
"authority_id",
"did:example:other-authority",
))
});
let err = client.authorization(query()).await.unwrap_err();
match err {
TrqlError::AnswerMismatch {
field,
ref asked,
ref answered,
} => {
assert_eq!(field, "authority_id");
assert_eq!(asked, "did:example:a");
assert_eq!(answered, "did:example:other-authority");
}
other => panic!("expected AnswerMismatch, got {other}"),
}
assert!(!err.is_retryable(), "a substituted answer is not transient");
}
#[tokio::test]
async fn answer_for_a_different_entity_is_rejected() {
let client = client_over(|req| {
Ok(answer_substituting(
&req,
"entity_id",
"did:example:someone-else",
))
});
let err = client.authorization(query()).await.unwrap_err();
assert!(
matches!(
err,
TrqlError::AnswerMismatch {
field: "entity_id",
..
}
),
"got: {err}"
);
}
#[tokio::test]
async fn answer_for_a_different_action_or_resource_is_rejected() {
for (field, value) in [("action", "revoke"), ("resource", "some-other-repo")] {
let client = client_over(move |req| Ok(answer_substituting(&req, field, value)));
let err = client.authorization(query()).await.unwrap_err();
assert!(
matches!(err, TrqlError::AnswerMismatch { field: f, .. } if f == field),
"substituting {field} was not caught: {err}"
);
}
}
#[tokio::test]
async fn a_denial_must_also_answer_the_question_asked() {
let client = client_over(|req| {
let mut reply = authorized_response(&req, false);
reply.payload["authority_id"] = Value::String("did:example:other".to_string());
Ok(reply)
});
let err = client.authorization(query()).await.unwrap_err();
assert!(
matches!(err, TrqlError::AnswerMismatch { .. }),
"got: {err}"
);
}
#[tokio::test]
async fn recognition_answers_are_checked_too() {
let client = client_over(|req| {
let payload = serde_json::json!({
"entity_id": req.payload["entity_id"],
"authority_id": "did:example:other",
"action": req.payload["action"],
"resource": req.payload["resource"],
"recognized": true,
"time_evaluated": "2026-07-16T00:00:00Z",
});
Ok(req.respond_with("urn:uuid:reply".to_string(), payload))
});
let err = client.recognition(query()).await.unwrap_err();
assert!(
matches!(err, TrqlError::AnswerMismatch { .. }),
"got: {err}"
);
}
#[tokio::test]
async fn omitted_tuple_member_is_a_contract_error_not_a_mismatch() {
let client = client_over(|req| {
let mut reply = authorized_response(&req, true);
reply
.payload
.as_object_mut()
.expect("response payload is an object")
.remove("authority_id");
Ok(reply)
});
let err = client.authorization(query()).await.unwrap_err();
assert!(matches!(err, TrqlError::Contract(_)), "got: {err}");
}
#[tokio::test]
async fn referral_closes_when_the_registry_answers_for_the_origin() {
let client =
client_over(|req| Ok(authorized_response(&req, true))).referred_by("did:example:a");
let response = client.authorization(query()).await.unwrap();
assert!(response.authorized);
}
#[tokio::test]
async fn referral_that_the_answer_does_not_confirm_is_rejected() {
let client = client_over(|req| Ok(authorized_response(&req, true)))
.referred_by("did:example:referring-vtc");
let err = client.authorization(query()).await.unwrap_err();
match err {
TrqlError::ReferralNotClosed {
ref origin,
ref answered,
} => {
assert_eq!(origin, "did:example:referring-vtc");
assert_eq!(answered, "did:example:a");
}
other => panic!("expected ReferralNotClosed, got {other}"),
}
assert!(!err.is_retryable());
}
#[tokio::test]
async fn endpoint_path_is_unaffected_by_the_referral_check() {
let client = client_over(|req| Ok(authorized_response(&req, true)));
assert!(client.authorization(query()).await.unwrap().authorized);
}
}