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