1use std::sync::Arc;
6
7use chrono::{DateTime, Utc};
8use serde::Serialize;
9use serde::de::DeserializeOwned;
10use serde_json::Value;
11use trust_tasks_rs::{ErrorPayload, Payload, TrustTask};
12
13use crate::error::TrqlError;
14use crate::payloads::{
15 AuthorizationRequest, AuthorizationResponse, RecognitionRequest, RecognitionResponse,
16};
17use crate::transport::TrqlTransport;
18
19const ERROR_SLUG: &str = "trust-task-error";
21
22fn payload_build_error(err: impl std::fmt::Display) -> TrqlError {
30 TrqlError::Config(format!("could not build query payload: {err}"))
31}
32
33const TRQP_TUPLE: [&str; 4] = ["entity_id", "authority_id", "action", "resource"];
39
40#[derive(Debug, Clone)]
43pub struct TrqpQuery {
44 pub entity_id: String,
46 pub authority_id: String,
48 pub action: String,
50 pub resource: String,
52 pub time: Option<DateTime<Utc>>,
54 pub locator: Option<String>,
56}
57
58impl TrqpQuery {
59 pub fn new(
61 entity_id: impl Into<String>,
62 authority_id: impl Into<String>,
63 action: impl Into<String>,
64 resource: impl Into<String>,
65 ) -> Self {
66 Self {
67 entity_id: entity_id.into(),
68 authority_id: authority_id.into(),
69 action: action.into(),
70 resource: resource.into(),
71 time: None,
72 locator: None,
73 }
74 }
75
76 pub fn at(mut self, time: DateTime<Utc>) -> Self {
78 self.time = Some(time);
79 self
80 }
81
82 pub fn locator(mut self, locator: impl Into<String>) -> Self {
84 self.locator = Some(locator.into());
85 self
86 }
87
88 fn has_context(&self) -> bool {
89 self.time.is_some() || self.locator.is_some()
90 }
91}
92
93pub struct TrqlClient {
100 transport: Arc<dyn TrqlTransport>,
101 registry_did: String,
102 client_did: Option<String>,
103 referred_by: Option<String>,
104}
105
106impl TrqlClient {
107 pub fn new(transport: Arc<dyn TrqlTransport>, registry_did: impl Into<String>) -> Self {
110 Self {
111 transport,
112 registry_did: registry_did.into(),
113 client_did: None,
114 referred_by: None,
115 }
116 }
117
118 pub fn with_client_did(mut self, did: impl Into<String>) -> Self {
122 self.client_did = Some(did.into());
123 self
124 }
125
126 pub fn referred_by(mut self, origin_did: impl Into<String>) -> Self {
151 self.referred_by = Some(origin_did.into());
152 self
153 }
154
155 pub async fn authorization(
161 &self,
162 query: TrqpQuery,
163 ) -> Result<AuthorizationResponse, TrqlError> {
164 let context: Option<crate::payloads::AuthorizationQueryContext> = query
165 .has_context()
166 .then(|| {
167 crate::payloads::AuthorizationQueryContext::builder()
168 .time(query.time)
169 .locator(query.locator.clone())
170 .extra(std::collections::HashMap::new())
171 .try_into()
172 })
173 .transpose()
174 .map_err(payload_build_error)?;
175 let payload: AuthorizationRequest = AuthorizationRequest::builder()
176 .entity_id(query.entity_id.clone())
177 .authority_id(query.authority_id.clone())
178 .action(query.action.clone())
179 .resource(query.resource.clone())
180 .context(context)
181 .try_into()
182 .map_err(payload_build_error)?;
183 self.send_query(payload).await
184 }
185
186 pub async fn recognition(&self, query: TrqpQuery) -> Result<RecognitionResponse, TrqlError> {
189 let context: Option<crate::payloads::RecognitionQueryContext> = query
190 .has_context()
191 .then(|| {
192 crate::payloads::RecognitionQueryContext::builder()
193 .time(query.time)
194 .locator(query.locator.clone())
195 .extra(std::collections::HashMap::new())
196 .try_into()
197 })
198 .transpose()
199 .map_err(payload_build_error)?;
200 let payload: RecognitionRequest = RecognitionRequest::builder()
201 .entity_id(query.entity_id.clone())
202 .authority_id(query.authority_id.clone())
203 .action(query.action.clone())
204 .resource(query.resource.clone())
205 .context(context)
206 .try_into()
207 .map_err(payload_build_error)?;
208 self.send_query(payload).await
209 }
210
211 async fn send_query<Req, Resp>(&self, payload: Req) -> Result<Resp, TrqlError>
218 where
219 Req: Payload + Serialize,
220 Resp: DeserializeOwned,
221 {
222 let body = serde_json::to_value(&payload)
223 .map_err(|e| TrqlError::Contract(format!("request payload did not serialize: {e}")))?;
224 let asked = TRQP_TUPLE.map(|field| body.get(field).cloned().unwrap_or(Value::Null));
227 let id = new_task_id();
228 let mut request = TrustTask::new(id.clone(), Req::type_uri(), body);
229 request.recipient = Some(self.registry_did.clone());
230 request.issuer = self.client_did.clone();
231 request.issued_at = Some(Utc::now());
232 let request_slug = request.type_uri.slug().to_string();
233
234 let reply = self.transport.exchange(request).await?;
235
236 if reply.thread_id.as_deref() != Some(id.as_str()) {
239 return Err(TrqlError::Contract(format!(
240 "uncorrelated reply: threadId {:?} does not match request id {id}",
241 reply.thread_id
242 )));
243 }
244
245 if reply.type_uri.slug() == ERROR_SLUG {
246 let error: ErrorPayload = serde_json::from_value(reply.payload).map_err(|e| {
247 TrqlError::Contract(format!("trust-task-error payload did not parse: {e}"))
248 })?;
249 return Err(TrqlError::Rejected {
250 code: error.code,
251 retryable: error.retryable,
252 retry_after: error.retry_after,
253 message: error.message,
254 });
255 }
256
257 if !(reply.type_uri.is_response() && reply.type_uri.slug() == request_slug) {
258 return Err(TrqlError::Contract(format!(
259 "unexpected reply type `{}` to a `{request_slug}` request",
260 reply.type_uri
261 )));
262 }
263
264 self.check_answers_our_question(&asked, &reply.payload)?;
265
266 serde_json::from_value(reply.payload)
267 .map_err(|e| TrqlError::Contract(format!("response payload did not parse: {e}")))
268 }
269
270 fn check_answers_our_question(
285 &self,
286 asked: &[Value; TRQP_TUPLE.len()],
287 answered: &Value,
288 ) -> Result<(), TrqlError> {
289 for (&field, asked) in TRQP_TUPLE.iter().zip(asked) {
290 let Some(answered) = answered.get(field) else {
291 continue;
292 };
293 if answered != asked {
294 return Err(TrqlError::AnswerMismatch {
295 field,
296 asked: render(asked),
297 answered: render(answered),
298 });
299 }
300 }
301
302 if let Some(origin) = self.referred_by.as_deref() {
308 let answered = answered.get("authority_id").and_then(Value::as_str);
309 if answered != Some(origin) {
310 return Err(TrqlError::ReferralNotClosed {
311 origin: origin.to_string(),
312 answered: answered.unwrap_or("<absent>").to_string(),
313 });
314 }
315 }
316
317 Ok(())
318 }
319}
320
321fn render(value: &Value) -> String {
328 match value.as_str() {
329 Some(s) => s.to_string(),
330 None => value.to_string(),
331 }
332}
333
334fn new_task_id() -> String {
336 format!("urn:uuid:{}", uuid_v4())
337}
338
339#[cfg(any(feature = "didcomm", feature = "tsp"))]
340fn uuid_v4() -> String {
341 uuid::Uuid::new_v4().to_string()
342}
343
344#[cfg(not(any(feature = "didcomm", feature = "tsp")))]
348fn uuid_v4() -> String {
349 use std::sync::atomic::{AtomicU64, Ordering};
350 use std::time::{SystemTime, UNIX_EPOCH};
351 static COUNTER: AtomicU64 = AtomicU64::new(0);
352 let nanos = SystemTime::now()
353 .duration_since(UNIX_EPOCH)
354 .map(|d| d.as_nanos())
355 .unwrap_or(0);
356 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
357 format!("{nanos:032x}-{n:016x}")
358}
359
360#[cfg(test)]
361mod tests {
362 #![allow(clippy::unwrap_used, clippy::expect_used)]
363
364 use super::*;
365 use trust_tasks_rs::RejectReason;
366
367 struct MockTransport<F>(F)
369 where
370 F: Fn(TrustTask<Value>) -> Result<TrustTask<Value>, TrqlError> + Send + Sync;
371
372 #[async_trait::async_trait]
373 impl<F> TrqlTransport for MockTransport<F>
374 where
375 F: Fn(TrustTask<Value>) -> Result<TrustTask<Value>, TrqlError> + Send + Sync,
376 {
377 fn kind(&self) -> crate::TransportKind {
378 crate::TransportKind::Https
379 }
380
381 async fn exchange(&self, request: TrustTask<Value>) -> Result<TrustTask<Value>, TrqlError> {
382 (self.0)(request)
383 }
384 }
385
386 fn client_over<F>(f: F) -> TrqlClient
387 where
388 F: Fn(TrustTask<Value>) -> Result<TrustTask<Value>, TrqlError> + Send + Sync + 'static,
389 {
390 TrqlClient::new(Arc::new(MockTransport(f)), "did:example:registry")
391 }
392
393 fn authorized_response(request: &TrustTask<Value>, authorized: bool) -> TrustTask<Value> {
394 let payload = serde_json::json!({
395 "entity_id": request.payload["entity_id"],
396 "authority_id": request.payload["authority_id"],
397 "action": request.payload["action"],
398 "resource": request.payload["resource"],
399 "authorized": authorized,
400 "time_evaluated": "2026-07-16T00:00:00Z",
401 });
402 request.respond_with("urn:uuid:reply".to_string(), payload)
403 }
404
405 fn query() -> TrqpQuery {
406 TrqpQuery::new("did:example:e", "did:example:a", "issue", "vc")
407 }
408
409 #[tokio::test]
410 async fn authorization_round_trip_stamps_recipient_and_parses_reply() {
411 let client = client_over(|req| {
412 assert_eq!(req.recipient.as_deref(), Some("did:example:registry"));
413 assert!(req.issued_at.is_some());
414 assert_eq!(req.type_uri.slug(), "registry/authorization");
415 Ok(authorized_response(&req, true))
416 });
417 let response = client.authorization(query()).await.unwrap();
418 assert!(response.authorized);
419 assert_eq!(response.entity_id, "did:example:e");
420 }
421
422 #[tokio::test]
423 async fn uncorrelated_reply_is_a_contract_error() {
424 let client = client_over(|req| {
425 let mut reply = authorized_response(&req, true);
426 reply.thread_id = Some("urn:uuid:someone-else".to_string());
427 Ok(reply)
428 });
429 let err = client.authorization(query()).await.unwrap_err();
430 assert!(matches!(err, TrqlError::Contract(_)), "got: {err}");
431 assert!(!err.is_retryable());
432 }
433
434 #[tokio::test]
435 async fn error_document_maps_to_rejected_with_code() {
436 let client = client_over(|req| {
437 let error_doc = req.reject_with(
438 "urn:uuid:err".to_string(),
439 RejectReason::PermissionDenied {
440 reason: "not allowed".to_string(),
441 },
442 );
443 let as_value = serde_json::to_value(&error_doc).unwrap();
445 Ok(serde_json::from_value(as_value).unwrap())
446 });
447 let err = client.authorization(query()).await.unwrap_err();
448 match err {
449 TrqlError::Rejected { retryable, .. } => assert!(!retryable),
450 other => panic!("expected Rejected, got {other}"),
451 }
452 }
453
454 #[tokio::test]
455 async fn wrong_response_type_is_a_contract_error() {
456 let client = client_over(|req| {
457 let mut reply = authorized_response(&req, true);
458 reply.type_uri = "https://trusttasks.org/spec/registry/recognition/0.1#response"
459 .parse()
460 .unwrap();
461 Ok(reply)
462 });
463 let err = client.authorization(query()).await.unwrap_err();
464 assert!(matches!(err, TrqlError::Contract(_)), "got: {err}");
465 }
466
467 #[tokio::test]
468 async fn malformed_response_payload_is_a_contract_error_not_transport() {
469 let client = client_over(|req| {
470 let payload = serde_json::json!({ "unexpected": true });
472 Ok(req.respond_with("urn:uuid:reply".to_string(), payload))
473 });
474 let err = client.authorization(query()).await.unwrap_err();
475 assert!(matches!(err, TrqlError::Contract(_)), "got: {err}");
476 assert!(!err.is_retryable());
477 }
478
479 #[tokio::test]
480 async fn recognition_parses_recognized_flag() {
481 let client = client_over(|req| {
482 let payload = serde_json::json!({
483 "entity_id": req.payload["entity_id"],
484 "authority_id": req.payload["authority_id"],
485 "action": req.payload["action"],
486 "resource": req.payload["resource"],
487 "recognized": false,
488 "time_evaluated": "2026-07-16T00:00:00Z",
489 });
490 Ok(req.respond_with("urn:uuid:reply".to_string(), payload))
491 });
492 let response = client.recognition(query()).await.unwrap();
493 assert!(!response.recognized, "absence of trust reads as false");
494 }
495
496 #[tokio::test]
497 async fn query_context_is_sent_when_time_is_set() {
498 let at = "2026-01-01T00:00:00Z".parse().unwrap();
499 let client = client_over(|req| {
500 assert_eq!(
501 req.payload["context"]["time"], "2026-01-01T00:00:00Z",
502 "context.time must be carried on the wire"
503 );
504 Ok(authorized_response(&req, true))
505 });
506 client.authorization(query().at(at)).await.unwrap();
507 }
508
509 fn answer_substituting(
514 request: &TrustTask<Value>,
515 field: &str,
516 value: &str,
517 ) -> TrustTask<Value> {
518 let mut reply = authorized_response(request, true);
519 reply.payload[field] = Value::String(value.to_string());
520 reply
521 }
522
523 #[tokio::test]
524 async fn answer_for_a_different_authority_is_rejected() {
525 let client = client_over(|req| {
526 Ok(answer_substituting(
527 &req,
528 "authority_id",
529 "did:example:other-authority",
530 ))
531 });
532 let err = client.authorization(query()).await.unwrap_err();
533 match err {
534 TrqlError::AnswerMismatch {
535 field,
536 ref asked,
537 ref answered,
538 } => {
539 assert_eq!(field, "authority_id");
540 assert_eq!(asked, "did:example:a");
541 assert_eq!(answered, "did:example:other-authority");
542 }
543 other => panic!("expected AnswerMismatch, got {other}"),
544 }
545 assert!(!err.is_retryable(), "a substituted answer is not transient");
546 }
547
548 #[tokio::test]
549 async fn answer_for_a_different_entity_is_rejected() {
550 let client = client_over(|req| {
551 Ok(answer_substituting(
552 &req,
553 "entity_id",
554 "did:example:someone-else",
555 ))
556 });
557 let err = client.authorization(query()).await.unwrap_err();
558 assert!(
559 matches!(
560 err,
561 TrqlError::AnswerMismatch {
562 field: "entity_id",
563 ..
564 }
565 ),
566 "got: {err}"
567 );
568 }
569
570 #[tokio::test]
571 async fn answer_for_a_different_action_or_resource_is_rejected() {
572 for (field, value) in [("action", "revoke"), ("resource", "some-other-repo")] {
573 let client = client_over(move |req| Ok(answer_substituting(&req, field, value)));
574 let err = client.authorization(query()).await.unwrap_err();
575 assert!(
576 matches!(err, TrqlError::AnswerMismatch { field: f, .. } if f == field),
577 "substituting {field} was not caught: {err}"
578 );
579 }
580 }
581
582 #[tokio::test]
585 async fn a_denial_must_also_answer_the_question_asked() {
586 let client = client_over(|req| {
587 let mut reply = authorized_response(&req, false);
588 reply.payload["authority_id"] = Value::String("did:example:other".to_string());
589 Ok(reply)
590 });
591 let err = client.authorization(query()).await.unwrap_err();
592 assert!(
593 matches!(err, TrqlError::AnswerMismatch { .. }),
594 "got: {err}"
595 );
596 }
597
598 #[tokio::test]
599 async fn recognition_answers_are_checked_too() {
600 let client = client_over(|req| {
601 let payload = serde_json::json!({
602 "entity_id": req.payload["entity_id"],
603 "authority_id": "did:example:other",
604 "action": req.payload["action"],
605 "resource": req.payload["resource"],
606 "recognized": true,
607 "time_evaluated": "2026-07-16T00:00:00Z",
608 });
609 Ok(req.respond_with("urn:uuid:reply".to_string(), payload))
610 });
611 let err = client.recognition(query()).await.unwrap_err();
612 assert!(
613 matches!(err, TrqlError::AnswerMismatch { .. }),
614 "got: {err}"
615 );
616 }
617
618 #[tokio::test]
622 async fn omitted_tuple_member_is_a_contract_error_not_a_mismatch() {
623 let client = client_over(|req| {
624 let mut reply = authorized_response(&req, true);
625 reply
626 .payload
627 .as_object_mut()
628 .expect("response payload is an object")
629 .remove("authority_id");
630 Ok(reply)
631 });
632 let err = client.authorization(query()).await.unwrap_err();
633 assert!(matches!(err, TrqlError::Contract(_)), "got: {err}");
634 }
635
636 #[tokio::test]
639 async fn referral_closes_when_the_registry_answers_for_the_origin() {
640 let client =
641 client_over(|req| Ok(authorized_response(&req, true))).referred_by("did:example:a");
642 let response = client.authorization(query()).await.unwrap();
643 assert!(response.authorized);
644 }
645
646 #[tokio::test]
651 async fn referral_that_the_answer_does_not_confirm_is_rejected() {
652 let client = client_over(|req| Ok(authorized_response(&req, true)))
653 .referred_by("did:example:referring-vtc");
654 let err = client.authorization(query()).await.unwrap_err();
655 match err {
656 TrqlError::ReferralNotClosed {
657 ref origin,
658 ref answered,
659 } => {
660 assert_eq!(origin, "did:example:referring-vtc");
661 assert_eq!(answered, "did:example:a");
662 }
663 other => panic!("expected ReferralNotClosed, got {other}"),
664 }
665 assert!(!err.is_retryable());
666 }
667
668 #[tokio::test]
671 async fn endpoint_path_is_unaffected_by_the_referral_check() {
672 let client = client_over(|req| Ok(authorized_response(&req, true)));
673 assert!(client.authorization(query()).await.unwrap().authorized);
674 }
675}