Skip to main content

trql_client/
client.rs

1//! The typed query client: builds request documents, delegates the exchange to
2//! a [`TrqlTransport`], and owns all document semantics (correlation, error
3//! mapping, payload typing) so every binding behaves identically.
4
5use 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
19/// Slug of the framework's reserved error document type.
20const ERROR_SLUG: &str = "trust-task-error";
21
22/// The TRQP 4-tuple members, named identically on requests and responses.
23///
24/// Both `registry/authorization/0.1` and `registry/recognition/0.1` require all
25/// four on the response, verbatim from the request, which is what makes an echo
26/// check possible without knowing which query is in flight.
27const TRQP_TUPLE: [&str; 4] = ["entity_id", "authority_id", "action", "resource"];
28
29/// The TRQP 4-tuple every registry query is keyed on, plus the optional
30/// evaluation context.
31#[derive(Debug, Clone)]
32pub struct TrqpQuery {
33    /// DID of the entity whose trust is being checked.
34    pub entity_id: String,
35    /// DID of the governing authority.
36    pub authority_id: String,
37    /// The action being checked (e.g. `issue`, `git.commit.sign`).
38    pub action: String,
39    /// The resource the action applies to.
40    pub resource: String,
41    /// Evaluate as of this instant instead of "now", when set.
42    pub time: Option<DateTime<Utc>>,
43    /// Authority-defined record-location hint.
44    pub locator: Option<String>,
45}
46
47impl TrqpQuery {
48    /// A query over the TRQP 4-tuple, evaluated at the registry's current time.
49    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    /// Request evaluation as of `time` instead of the registry's current time.
66    pub fn at(mut self, time: DateTime<Utc>) -> Self {
67        self.time = Some(time);
68        self
69    }
70
71    /// Attach an authority-defined locator hint.
72    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
82/// Transport-agnostic Trust Registry query client.
83///
84/// Holds one [`TrqlTransport`] and the registry's DID (the `recipient` stamped
85/// on every request — the registry rejects documents addressed to anyone
86/// else). Swapping HTTPS for DIDComm or TSP changes only the transport handed
87/// to [`TrqlClient::new`].
88pub 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    /// A client that queries the registry identified by `registry_did` over
97    /// `transport`.
98    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    /// Set the in-band `issuer` on outbound documents. Optional over HTTPS
108    /// (the registry treats queries as anonymous reads); DIDComm and TSP
109    /// authenticate the sender at the transport layer regardless.
110    pub fn with_client_did(mut self, did: impl Into<String>) -> Self {
111        self.client_did = Some(did.into());
112        self
113    }
114
115    /// Record that this client reached the registry by following a
116    /// [`crate::registry_referral`] in `origin_did`'s document, and hold every
117    /// answer to that provenance.
118    ///
119    /// This is step 5 of `DID_SERVICE_DISCOVERY.md` §4 — *closing the loop* —
120    /// made structural instead of advisory. With it set, an answer whose
121    /// `authority_id` is anything other than `origin_did` is rejected as
122    /// [`TrqlError::ReferralNotClosed`] rather than returned.
123    ///
124    /// A referral is a self-assertion: anyone can publish a DID document
125    /// naming any registry, so a followed referral establishes *where to ask*
126    /// and nothing about the answer. Authority flows registry → subject, and
127    /// only the registry answering for `origin_did` completes that direction.
128    ///
129    /// ```rust,ignore
130    /// let mut doc = resolve(vtc_did).await?;
131    /// let client = match registry_referral(&doc) {
132    ///     Some(registry) => {
133    ///         doc = resolve(&registry).await?;              // one hop
134    ///         TrqlClient::new(transport, registry).referred_by(vtc_did)
135    ///     }
136    ///     None => TrqlClient::new(transport, registry_did),
137    /// };
138    /// ```
139    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    /// Ask whether `entity` is authorized by `authority` for `action` on
145    /// `resource` (`registry/authorization/0.1`).
146    ///
147    /// A registry with no matching record answers `authorized: false` — absence
148    /// is a denial, not an error.
149    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    /// Ask whether `entity` is recognized by `authority` for `action` on
171    /// `resource` (`registry/recognition/0.1`).
172    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    /// Build the request document for `payload`, run one exchange, and
191    /// validate the reply: correlated (`threadId` == request `id`), then
192    /// either the matching `#response` document or a `trust-task-error`
193    /// mapped to [`TrqlError::Rejected`]. A `#response` must additionally
194    /// answer the tuple that was asked, and close any referral this client was
195    /// built with.
196    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        // Capture the tuple before `body` is moved into the request document;
204        // the reply is checked against what we actually put on the wire.
205        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        // Correlation is checked, never assumed: an uncorrelated document is
216        // someone else's reply and must not be interpreted as ours.
217        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    /// Reject a reply that answers a different question than the one asked, or
250    /// that fails to close the referral this client followed.
251    ///
252    /// Correlation (`threadId`) proves a reply belongs to our exchange but not
253    /// what it is an answer *about*. TRQP responses echo the queried tuple
254    /// precisely so the asker can tell; nothing checked that echo before, so a
255    /// registry — or anything able to shape its reply — could substitute the
256    /// subject of an authorization decision and be believed.
257    ///
258    /// A field the reply omits is *not* treated as a mismatch here: all four
259    /// members are required by the response schema, so absence fails in the
260    /// deserialization immediately below. That keeps a malformed payload
261    /// reported as a contract violation rather than as a substituted answer,
262    /// and either way the query fails closed.
263    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        // Closing the loop (DID_SERVICE_DISCOVERY.md §4 step 5). The echo check
282        // above proves the registry answered the authority we named; this
283        // proves the authority we named is the one that referred us here. A
284        // caller that follows a referral and then asks about some other
285        // authority has learned nothing about the referral.
286        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
300/// Render a tuple member for an error message.
301///
302/// These are strings on the wire, so the common case prints bare; anything
303/// else prints as the JSON it actually was, which is the detail an operator
304/// needs when a registry answers with the wrong shape rather than the wrong
305/// value.
306fn render(value: &Value) -> String {
307    match value.as_str() {
308        Some(s) => s.to_string(),
309        None => value.to_string(),
310    }
311}
312
313/// A fresh `urn:uuid:` document id.
314fn 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// Without the mediator transports the crate has no uuid dependency; derive the
324// id from entropy the std library already gives us. Uniqueness only needs to
325// hold within this process's in-flight queries.
326#[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    /// A transport that answers every request with a canned closure.
347    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            // The seam carries untyped documents; reserialize like a wire hop.
423            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            // `authorized` missing: the strict payload must fail to parse.
450            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    // --- answering the question that was asked ------------------------------
489
490    /// An otherwise well-formed, correctly correlated answer with one tuple
491    /// member rewritten.
492    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    /// A denial is the answer an attacker would most like to see substituted
562    /// in the other direction, so the check must not be limited to grants.
563    #[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    /// An omitted tuple member is a malformed payload, not a substituted
598    /// answer. It still fails — the response schema requires all four — but it
599    /// is reported as the contract violation it is.
600    #[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    // --- closing the referral loop (DID_SERVICE_DISCOVERY.md §4 step 5) -----
616
617    #[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    /// The registry echoes our query faithfully — so the tuple check passes —
626    /// but we asked about an authority other than the one whose document
627    /// referred us here. The referral is left unconfirmed, and an unconfirmed
628    /// referral is an unverified redirect.
629    #[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    /// Without `referred_by` nothing changes: the endpoint path (a registry
648    /// describing its own surface) never had a referral to close.
649    #[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}