1use serde::{Deserialize, Serialize};
31use serde_json::Value;
32use trust_tasks_rs::TrustTask;
33use uuid::Uuid;
34
35pub const TRUST_TASK_ENVELOPE_TYPE: &str = "https://trusttasks.org/binding/didcomm/0.1/envelope";
38
39pub 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
46pub 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#[derive(Debug, thiserror::Error)]
52pub enum CapabilityClientError {
53 #[error("capability document error: {0}")]
54 Document(String),
55}
56
57pub 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
76pub 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
86pub 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
117pub 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
133pub 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
148pub 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#[derive(Debug, Clone, PartialEq)]
166pub enum WriteOutcome {
167 Success,
169 IdempotentSuccess,
171 Rejected {
173 code: String,
174 message: Option<String>,
175 },
176}
177
178pub 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#[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 pub manifest: Value,
211}
212
213#[derive(Debug, Clone, PartialEq)]
215pub enum CapabilityReply {
216 Listing(Vec<CapabilitySummary>),
218 Toggled { capability: String, enabled: bool },
220 Rejected {
222 code: String,
223 message: Option<String>,
224 },
225}
226
227pub 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
235pub 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}