Skip to main content

trust_tasks_capability_client/
lib.rs

1//! Client-side wire helpers for the **capability** Trust Task families —
2//! `governance/capability/*` (enable / disable / list a community capability)
3//! and `git-trust/*` (grant / revoke commit-signing trust).
4//!
5//! This crate owns the *documents*, not a transport: it builds request
6//! documents, parses inbound envelope replies, classifies them, and (behind
7//! the `signing` feature) attaches a Data-Integrity proof. Each consumer keeps
8//! its own send/receive plumbing but shares this wire layer, so a capability
9//! producer (a community service) and a management UI cannot drift on the
10//! contract.
11//!
12//! ## Layers
13//!
14//! - **Envelope**: capability documents travel as the `trust-tasks-didcomm`
15//!   binding envelope ([`TRUST_TASK_ENVELOPE_TYPE`]); [`parse_envelope_document`]
16//!   turns an inbound body into `(threadId, document)`.
17//! - **Builders**: [`build_document`] plus the family-specific
18//!   [`build_list_document`], [`build_toggle_document`],
19//!   [`build_git_trust_grant`], [`build_git_trust_revoke`].
20//! - **Replies**: [`classify_git_trust_reply`] (for grant/revoke writers) and
21//!   [`parse_capability_reply`] (for governance management UIs).
22//!
23//! Signing is deliberately **not** here — it is a thin Data-Integrity call
24//! each consumer makes with its own signer (a service reuses its credential
25//! signer; a client signs with the persona key), so this crate stays free of
26//! any crypto dependency. Sign the built document over its canonical form
27//! (the document minus its `proof` member, `eddsa-jcs-2022`) and set the
28//! `proof` member.
29
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32use trust_tasks_rs::TrustTask;
33use uuid::Uuid;
34
35/// The `trust-tasks-didcomm` binding envelope type (what a registry's DIDComm
36/// Trust Task handler listens for).
37pub const TRUST_TASK_ENVELOPE_TYPE: &str = "https://trusttasks.org/binding/didcomm/0.1/envelope";
38
39/// `governance/capability/*` type URIs.
40pub const CAPABILITY_LIST_TYPE: &str = "https://trusttasks.org/spec/governance/capability/list/0.1";
41pub const CAPABILITY_ENABLE_TYPE: &str =
42    "https://trusttasks.org/spec/governance/capability/enable/0.1";
43pub const CAPABILITY_DISABLE_TYPE: &str =
44    "https://trusttasks.org/spec/governance/capability/disable/0.1";
45
46/// `git-trust/*` type URIs.
47pub const GIT_TRUST_GRANT_TYPE: &str = "https://trusttasks.org/spec/git-trust/grant/0.1";
48pub const GIT_TRUST_REVOKE_TYPE: &str = "https://trusttasks.org/spec/git-trust/revoke/0.1";
49
50/// Errors from document construction.
51#[derive(Debug, thiserror::Error)]
52pub enum CapabilityClientError {
53    #[error("capability document error: {0}")]
54    Document(String),
55}
56
57// --- builders ----------------------------------------------------------------
58
59/// Build a capability Trust Task addressed `issuer` → `recipient`.
60pub fn build_document(
61    issuer_did: &str,
62    recipient_did: &str,
63    type_uri: &str,
64    payload: Value,
65) -> TrustTask<Value> {
66    let type_uri = type_uri
67        .parse()
68        .unwrap_or_else(|_| unreachable!("static capability type URIs are valid"));
69    let mut doc = TrustTask::new(format!("urn:uuid:{}", Uuid::new_v4()), type_uri, payload);
70    doc.issuer = Some(issuer_did.to_string());
71    doc.recipient = Some(recipient_did.to_string());
72    doc.issued_at = Some(chrono::Utc::now());
73    doc
74}
75
76/// Build a `governance/capability/list` request (status `all`).
77pub fn build_list_document(issuer_did: &str, vtc_did: &str) -> TrustTask<Value> {
78    build_document(
79        issuer_did,
80        vtc_did,
81        CAPABILITY_LIST_TYPE,
82        serde_json::json!({ "status": "all" }),
83    )
84}
85
86/// Build a `governance/capability/enable` or `/disable` request. On enable,
87/// `config.authority` defaults to the community's own DID — the community is
88/// the authority its capability records are issued under.
89pub fn build_toggle_document(
90    issuer_did: &str,
91    vtc_did: &str,
92    slug: &str,
93    version: &str,
94    enable: bool,
95) -> TrustTask<Value> {
96    if enable {
97        build_document(
98            issuer_did,
99            vtc_did,
100            CAPABILITY_ENABLE_TYPE,
101            serde_json::json!({
102                "capability": slug,
103                "version": version,
104                "config": { "authority": vtc_did },
105            }),
106        )
107    } else {
108        build_document(
109            issuer_did,
110            vtc_did,
111            CAPABILITY_DISABLE_TYPE,
112            serde_json::json!({ "capability": slug }),
113        )
114    }
115}
116
117/// Build a `git-trust/grant`: grant `subject` commit-signing trust for
118/// `resource` (an org or `org/repo` slug).
119pub fn build_git_trust_grant(
120    authority_did: &str,
121    registry_did: &str,
122    subject_did: &str,
123    resource: &str,
124) -> TrustTask<Value> {
125    build_document(
126        authority_did,
127        registry_did,
128        GIT_TRUST_GRANT_TYPE,
129        serde_json::json!({ "subject": subject_did, "resource": resource }),
130    )
131}
132
133/// Build a `git-trust/revoke`.
134pub fn build_git_trust_revoke(
135    authority_did: &str,
136    registry_did: &str,
137    subject_did: &str,
138    resource: &str,
139    reason: Option<&str>,
140) -> TrustTask<Value> {
141    let mut payload = serde_json::json!({ "subject": subject_did, "resource": resource });
142    if let Some(reason) = reason {
143        payload["reason"] = serde_json::json!(reason);
144    }
145    build_document(authority_did, registry_did, GIT_TRUST_REVOKE_TYPE, payload)
146}
147
148// --- envelope parsing --------------------------------------------------------
149
150/// Parse a DIDComm envelope body into `(threadId, document)`. `None` when the
151/// body is not a threaded Trust Task document.
152pub fn parse_envelope_document(body: &Value) -> Option<(String, TrustTask<Value>)> {
153    let doc: TrustTask<Value> = serde_json::from_value(body.clone()).ok()?;
154    let thid = doc.thread_id.clone()?;
155    Some((thid, doc))
156}
157
158// --- git-trust write replies (grant/revoke producers) ------------------------
159
160/// The classification of a `git-trust` write reply.
161///
162/// `IdempotentSuccess` is load-bearing for redelivery-safe writers:
163/// `already_granted`/`not_granted` rejections mean the desired end state
164/// already holds, so the write is done, not failed.
165#[derive(Debug, Clone, PartialEq)]
166pub enum WriteOutcome {
167    /// The `#response` document acknowledged the write.
168    Success,
169    /// Rejected because the end state already holds.
170    IdempotentSuccess,
171    /// Any other rejection: the machine-readable code and human detail.
172    Rejected {
173        code: String,
174        message: Option<String>,
175    },
176}
177
178/// Classify the reply to a `git-trust/grant` or `git-trust/revoke` write.
179/// `None` when `doc` is not a reply to this family.
180pub fn classify_git_trust_reply(doc: &TrustTask<Value>) -> Option<WriteOutcome> {
181    let slug = doc.type_uri.slug();
182    if slug == "trust-task-error" {
183        let (code, message) = error_code_and_message(doc);
184        let reason = message.as_deref().unwrap_or("");
185        if code == "taskFailed"
186            && (reason.contains("already_granted:") || reason.contains("not_granted:"))
187        {
188            return Some(WriteOutcome::IdempotentSuccess);
189        }
190        return Some(WriteOutcome::Rejected { code, message });
191    }
192    if doc.type_uri.is_response() && matches!(slug, "git-trust/grant" | "git-trust/revoke") {
193        return Some(WriteOutcome::Success);
194    }
195    None
196}
197
198// --- governance/capability replies (management UIs) --------------------------
199
200/// One capability entry as rendered by a management UI.
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202pub struct CapabilitySummary {
203    pub slug: String,
204    pub title: Option<String>,
205    pub version: String,
206    pub enabled: bool,
207    pub enabled_at: Option<String>,
208    pub delegate: Option<String>,
209    /// The full manifest, for a detail view.
210    pub manifest: Value,
211}
212
213/// The classification of a `governance/capability/*` reply.
214#[derive(Debug, Clone, PartialEq)]
215pub enum CapabilityReply {
216    /// A `list` response: the community's capabilities.
217    Listing(Vec<CapabilitySummary>),
218    /// An `enable`/`disable` acknowledgement.
219    Toggled { capability: String, enabled: bool },
220    /// A `trust-task-error` document.
221    Rejected {
222        code: String,
223        message: Option<String>,
224    },
225}
226
227/// Parse an inbound envelope body directly into `(threadId, reply)` — the
228/// entry point for a UI's inbound dispatch, which holds only a `Value`.
229pub fn parse_envelope_reply(body: &Value) -> Option<(String, CapabilityReply)> {
230    let (thid, doc) = parse_envelope_document(body)?;
231    let reply = parse_capability_reply(&doc)?;
232    Some((thid, reply))
233}
234
235/// Classify a `governance/capability/*` reply document. `None` when it is not
236/// part of this family.
237pub fn parse_capability_reply(doc: &TrustTask<Value>) -> Option<CapabilityReply> {
238    let slug = doc.type_uri.slug();
239    if slug == "trust-task-error" {
240        let (code, message) = error_code_and_message(doc);
241        return Some(CapabilityReply::Rejected { code, message });
242    }
243    if !doc.type_uri.is_response() {
244        return None;
245    }
246    match slug {
247        "governance/capability/list" => {
248            let entries = doc
249                .payload
250                .get("capabilities")
251                .and_then(Value::as_array)
252                .map(|entries| entries.iter().filter_map(summary_of).collect())
253                .unwrap_or_default();
254            Some(CapabilityReply::Listing(entries))
255        }
256        "governance/capability/enable" | "governance/capability/disable" => {
257            Some(CapabilityReply::Toggled {
258                capability: doc
259                    .payload
260                    .get("capability")
261                    .and_then(Value::as_str)
262                    .unwrap_or_default()
263                    .to_string(),
264                enabled: doc
265                    .payload
266                    .get("enabled")
267                    .and_then(Value::as_bool)
268                    .unwrap_or(false),
269            })
270        }
271        _ => None,
272    }
273}
274
275fn error_code_and_message(doc: &TrustTask<Value>) -> (String, Option<String>) {
276    let code = doc
277        .payload
278        .get("code")
279        .and_then(Value::as_str)
280        .unwrap_or("unknown")
281        .to_string();
282    let message = doc
283        .payload
284        .get("message")
285        .and_then(Value::as_str)
286        .map(str::to_string);
287    (code, message)
288}
289
290fn summary_of(entry: &Value) -> Option<CapabilitySummary> {
291    let manifest = entry.get("manifest")?.clone();
292    Some(CapabilitySummary {
293        slug: manifest.get("capability")?.as_str()?.to_string(),
294        title: manifest
295            .get("title")
296            .and_then(Value::as_str)
297            .map(str::to_string),
298        version: manifest
299            .get("version")
300            .and_then(Value::as_str)
301            .unwrap_or("?")
302            .to_string(),
303        enabled: entry
304            .get("enabled")
305            .and_then(Value::as_bool)
306            .unwrap_or(false),
307        enabled_at: entry
308            .get("enabledAt")
309            .and_then(Value::as_str)
310            .map(str::to_string),
311        delegate: entry
312            .get("delegate")
313            .and_then(Value::as_str)
314            .map(str::to_string),
315        manifest,
316    })
317}
318
319#[cfg(test)]
320mod tests {
321    #![allow(clippy::unwrap_used, clippy::expect_used)]
322
323    use super::*;
324    use trust_tasks_rs::RejectReason;
325
326    #[test]
327    fn builders_are_addressed_and_typed() {
328        let list = build_list_document("did:example:me", "did:example:vtc");
329        assert_eq!(list.type_uri.slug(), "governance/capability/list");
330        assert_eq!(list.issuer.as_deref(), Some("did:example:me"));
331        assert_eq!(list.payload["status"], "all");
332
333        let enable = build_toggle_document(
334            "did:example:me",
335            "did:example:vtc",
336            "git-trust",
337            "0.1",
338            true,
339        );
340        assert_eq!(enable.payload["config"]["authority"], "did:example:vtc");
341        let disable = build_toggle_document(
342            "did:example:me",
343            "did:example:vtc",
344            "git-trust",
345            "0.1",
346            false,
347        );
348        assert_eq!(disable.type_uri.slug(), "governance/capability/disable");
349
350        let grant = build_git_trust_grant("did:a", "did:r", "did:s", "openvtc");
351        assert_eq!(grant.type_uri.slug(), "git-trust/grant");
352        assert_eq!(grant.payload["subject"], "did:s");
353        let revoke = build_git_trust_revoke("did:a", "did:r", "did:s", "openvtc", Some("ended"));
354        assert_eq!(revoke.payload["reason"], "ended");
355    }
356
357    fn reserialize(doc: &trust_tasks_rs::ErrorResponse) -> TrustTask<Value> {
358        serde_json::from_value(serde_json::to_value(doc).unwrap()).unwrap()
359    }
360
361    #[test]
362    fn git_trust_reply_classification() {
363        let grant = build_git_trust_grant("did:a", "did:r", "did:s", "org");
364        let ok = grant.respond_with(
365            "urn:uuid:r".to_string(),
366            serde_json::json!({ "granted": true }),
367        );
368        assert_eq!(classify_git_trust_reply(&ok), Some(WriteOutcome::Success));
369
370        let already = reserialize(&grant.reject_with(
371            "urn:uuid:e".to_string(),
372            RejectReason::TaskFailed {
373                reason: "already_granted: exists".to_string(),
374                details: None,
375            },
376        ));
377        assert_eq!(
378            classify_git_trust_reply(&already),
379            Some(WriteOutcome::IdempotentSuccess)
380        );
381
382        let denied = reserialize(&grant.reject_with(
383            "urn:uuid:e2".to_string(),
384            RejectReason::PermissionDenied {
385                reason: "no".to_string(),
386            },
387        ));
388        assert!(matches!(
389            classify_git_trust_reply(&denied),
390            Some(WriteOutcome::Rejected { .. })
391        ));
392    }
393
394    #[test]
395    fn governance_reply_classification() {
396        let list = build_list_document("did:me", "did:vtc");
397        let reply = list.respond_with(
398            "urn:uuid:r".to_string(),
399            serde_json::json!({ "capabilities": [{
400                "manifest": { "capability": "git-trust", "version": "0.1", "title": "Git Commit Trust" },
401                "enabled": true, "enabledAt": "2026-07-18T00:00:00Z"
402            }]}),
403        );
404        let Some(CapabilityReply::Listing(items)) = parse_capability_reply(&reply) else {
405            panic!("expected listing");
406        };
407        assert_eq!(items.len(), 1);
408        assert_eq!(items[0].slug, "git-trust");
409        assert!(items[0].enabled);
410
411        let toggle = build_toggle_document("did:me", "did:vtc", "git-trust", "0.1", true);
412        let ack = toggle.respond_with(
413            "urn:uuid:t".to_string(),
414            serde_json::json!({ "capability": "git-trust", "enabled": true }),
415        );
416        assert_eq!(
417            parse_capability_reply(&ack),
418            Some(CapabilityReply::Toggled {
419                capability: "git-trust".to_string(),
420                enabled: true
421            })
422        );
423    }
424
425    #[test]
426    fn envelope_parse_requires_thread_id() {
427        let grant = build_git_trust_grant("did:a", "did:r", "did:s", "org");
428        let reply = grant.respond_with("urn:uuid:r".to_string(), serde_json::json!({}));
429        let body = serde_json::to_value(&reply).unwrap();
430        let (thid, _) = parse_envelope_document(&body).unwrap();
431        assert_eq!(thid, grant.id);
432        assert!(parse_envelope_document(&serde_json::to_value(&grant).unwrap()).is_none());
433    }
434}