use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::{HolderKey, VtcClient, VtcError};
pub use trust_tasks_rs::specs::git_ns as specs;
use specs::account::{
link::v0_1 as link, link_status::v0_1 as link_status, unlink::v0_1 as unlink,
};
use specs::drift::resolve::{v0_1 as drift_resolve, v0_3 as drift_resolve3};
use specs::namespace::{
bind::v0_1 as bind, list::v0_1 as namespace_list, reseat::v0_3 as reseat,
unbind::v0_1 as unbind,
};
pub const RESEAT_TYPE_URI: &str = <reseat::Payload as trust_tasks_rs::Payload>::TYPE_URI;
use specs::repo::{
adopt::v0_1 as adopt, archive::v0_1 as archive, create::v0_3 as create,
list::v0_1 as repo_list, transfer::v0_1 as transfer,
};
use specs::right::{
break_glass::v0_1 as break_glass, grant::v0_3 as grant, ratify::v0_1 as ratify,
revoke::v0_3 as revoke,
};
use specs::roles::reproject::v0_1 as reproject;
use specs::view::{v0_1 as view, v0_2 as view2, v0_4 as view4, v0_5 as view5};
pub const GIT_NS_VIEW_TYPE: &str = <view::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const GIT_NS_VIEW_V5_TYPE: &str = <view5::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const GIT_NS_NAMESPACE_LIST_TYPE: &str =
<namespace_list::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const GIT_NS_REPO_LIST_TYPE: &str = <repo_list::Payload as trust_tasks_rs::Payload>::TYPE_URI;
impl VtcClient {
pub async fn git_ns_task<P: Serialize, R: DeserializeOwned>(
&self,
type_uri: &str,
payload: &P,
key: &HolderKey,
) -> Result<R, VtcError> {
#[cfg(feature = "didcomm")]
if self.documents.is_some() {
let payload = serde_json::to_value(payload)
.map_err(|e| VtcError::Url(format!("serialise {type_uri} payload: {e}")))?;
return self
.git_ns_over_session(type_uri, payload, key.holder_did())
.await;
}
let doc = self.git_ns_sign(type_uri, payload, key).await?;
self.git_ns_post_signed(type_uri, &doc).await
}
pub async fn git_ns_sign<P: Serialize>(
&self,
type_uri: &str,
payload: &P,
key: &HolderKey,
) -> Result<String, VtcError> {
let payload = serde_json::to_value(payload)
.map_err(|e| VtcError::Url(format!("serialise {type_uri} payload: {e}")))?;
vta_sdk::trust_task_sign::build_signed_with(type_uri, payload, key, &self.vtc_did)
.await
.map_err(|e| VtcError::Signing(e.to_string()))
}
pub async fn git_ns_send_signed<R: DeserializeOwned>(
&self,
type_uri: &str,
doc: &str,
) -> Result<R, VtcError> {
#[cfg(feature = "didcomm")]
if self.documents.is_some() {
let parsed: Value = serde_json::from_str(doc)
.map_err(|e| VtcError::InvalidPayload(format!("not a Trust Task document: {e}")))?;
let member = |name: &str| parsed.get(name).and_then(Value::as_str);
if member("type") != Some(type_uri) {
return Err(VtcError::InvalidPayload(format!(
"the document is not a {type_uri} document"
)));
}
if member("recipient") != Some(self.vtc_did.as_str()) {
return Err(VtcError::InvalidPayload(format!(
"the document is not addressed to {}",
self.vtc_did
)));
}
let issuer = member("issuer")
.ok_or_else(|| VtcError::InvalidPayload("the document names no issuer".into()))?
.to_string();
let payload = parsed.get("payload").cloned().unwrap_or(Value::Null);
return self.git_ns_over_session(type_uri, payload, &issuer).await;
}
self.git_ns_post_signed(type_uri, doc).await
}
#[cfg(feature = "didcomm")]
async fn git_ns_over_session<R: DeserializeOwned>(
&self,
type_uri: &str,
payload: Value,
signer_did: &str,
) -> Result<R, VtcError> {
let (Some(documents), Some(session_did)) = (&self.documents, &self.session_did) else {
return Err(VtcError::Session("this client holds no session".into()));
};
let base = |d: &str| d.split('#').next().unwrap_or(d).to_string();
if base(signer_did) != base(session_did) {
return Err(VtcError::Signing(format!(
"{type_uri} is to be signed as {signer_did}, but this client's session is {session_did}; over a session the VTC accepts a document only from the DID that signed it"
)));
}
let reply = documents
.dispatch_trust_task_document(type_uri, payload, crate::SESSION_TIMEOUT_SECS)
.await
.map_err(|e| VtcError::Session(e.to_string()))?;
let refused = reply
.get("type")
.and_then(Value::as_str)
.is_some_and(|t| t.starts_with("https://trusttasks.org/spec/trust-task-error/"));
if refused {
return Err(VtcError::Refused {
document: reply.to_string(),
});
}
let payload = reply.get("payload").cloned().unwrap_or(Value::Null);
serde_json::from_value(payload).map_err(|e| VtcError::Http {
status: 200,
body: format!("{type_uri} response does not fit its schema: {e}"),
})
}
async fn git_ns_post_signed<R: DeserializeOwned>(
&self,
type_uri: &str,
doc: &str,
) -> Result<R, VtcError> {
let doc = doc.to_string();
if self.base_url.is_empty() {
return Err(VtcError::NoRestTransport("a git-ns task"));
}
let resp = self
.http
.post(format!("{}/trust-tasks", self.base_url))
.header("content-type", "application/json")
.body(doc)
.send()
.await?;
let status = resp.status().as_u16();
let text = resp.text().await?;
let doc: trust_tasks_rs::TrustTask<Value> =
serde_json::from_str(&text).map_err(|e| VtcError::Http {
status,
body: format!("not a Trust Task document ({e}): {text}"),
})?;
if doc.type_uri.slug() == "trust-task-error" || !(200..300).contains(&status) {
return Err(VtcError::Http { status, body: text });
}
serde_json::from_value(doc.payload).map_err(|e| VtcError::Http {
status,
body: format!("{type_uri} response does not fit its schema: {e}"),
})
}
pub async fn git_ns_bind(
&self,
forge: &str,
owner: &str,
mode: &str,
key: &HolderKey,
) -> Result<bind::Response, VtcError> {
let payload = serde_json::json!({ "forge": forge, "owner": owner, "mode": mode });
self.git_ns_task(
<bind::Payload as trust_tasks_rs::Payload>::TYPE_URI,
&payload,
key,
)
.await
}
pub async fn git_ns_unbind(
&self,
namespace: &str,
key: &HolderKey,
) -> Result<unbind::Response, VtcError> {
let payload = serde_json::json!({ "namespace": namespace });
self.git_ns_task(
<unbind::Payload as trust_tasks_rs::Payload>::TYPE_URI,
&payload,
key,
)
.await
}
pub async fn git_ns_create_repo(
&self,
payload: &create::Payload,
key: &HolderKey,
) -> Result<create::Response, VtcError> {
self.git_ns_task(
<create::Payload as trust_tasks_rs::Payload>::TYPE_URI,
payload,
key,
)
.await
}
pub async fn git_ns_adopt(
&self,
resource: &str,
owners: &[String],
key: &HolderKey,
) -> Result<adopt::Response, VtcError> {
let payload = serde_json::json!({ "resource": resource, "owners": owners });
self.git_ns_task(
<adopt::Payload as trust_tasks_rs::Payload>::TYPE_URI,
&payload,
key,
)
.await
}
pub async fn git_ns_transfer(
&self,
resource: &str,
to: &str,
key: &HolderKey,
) -> Result<transfer::Response, VtcError> {
let payload = serde_json::json!({ "resource": resource, "to": to });
self.git_ns_task(
<transfer::Payload as trust_tasks_rs::Payload>::TYPE_URI,
&payload,
key,
)
.await
}
pub async fn git_ns_archive(
&self,
resource: &str,
key: &HolderKey,
) -> Result<archive::Response, VtcError> {
let payload = serde_json::json!({ "resource": resource });
self.git_ns_task(
<archive::Payload as trust_tasks_rs::Payload>::TYPE_URI,
&payload,
key,
)
.await
}
pub async fn git_ns_grant(
&self,
payload: &grant::Payload,
key: &HolderKey,
) -> Result<grant::Response, VtcError> {
self.git_ns_task(
<grant::Payload as trust_tasks_rs::Payload>::TYPE_URI,
payload,
key,
)
.await
}
pub async fn git_ns_revoke(
&self,
payload: &revoke::Payload,
key: &HolderKey,
) -> Result<revoke::Response, VtcError> {
self.git_ns_task(
<revoke::Payload as trust_tasks_rs::Payload>::TYPE_URI,
payload,
key,
)
.await
}
pub async fn git_ns_ratify(
&self,
payload: &ratify::Payload,
key: &HolderKey,
) -> Result<ratify::Response, VtcError> {
self.git_ns_task(
<ratify::Payload as trust_tasks_rs::Payload>::TYPE_URI,
payload,
key,
)
.await
}
pub async fn git_ns_view_v4(
&self,
resource: Option<&str>,
key: &HolderKey,
) -> Result<view4::Response, VtcError> {
let payload = match resource {
Some(r) => serde_json::json!({ "resource": r }),
None => serde_json::json!({}),
};
self.git_ns_task(
<view4::Payload as trust_tasks_rs::Payload>::TYPE_URI,
&payload,
key,
)
.await
}
pub async fn git_ns_view_v5(
&self,
resource: Option<&str>,
administrator: bool,
break_glass: bool,
key: &HolderKey,
) -> Result<view4::Response, VtcError> {
let mut payload = serde_json::Map::new();
if let Some(r) = resource {
payload.insert("resource".into(), r.into());
}
if administrator {
payload.insert("scope".into(), "administrator".into());
}
if break_glass {
payload.insert("breakGlass".into(), true.into());
}
self.git_ns_task(GIT_NS_VIEW_V5_TYPE, &Value::Object(payload), key)
.await
}
pub async fn git_ns_namespace_list(
&self,
namespace: Option<&str>,
key: &HolderKey,
) -> Result<Value, VtcError> {
let payload = match namespace {
Some(n) => serde_json::json!({ "namespace": n }),
None => serde_json::json!({}),
};
self.git_ns_task(GIT_NS_NAMESPACE_LIST_TYPE, &payload, key)
.await
}
pub async fn git_ns_repo_list(
&self,
namespace: Option<&str>,
key: &HolderKey,
) -> Result<Value, VtcError> {
let payload = match namespace {
Some(n) => serde_json::json!({ "namespace": n }),
None => serde_json::json!({}),
};
self.git_ns_task(GIT_NS_REPO_LIST_TYPE, &payload, key).await
}
pub async fn git_ns_view(
&self,
resource: Option<&str>,
key: &HolderKey,
) -> Result<view::Response, VtcError> {
let payload = match resource {
Some(r) => serde_json::json!({ "resource": r }),
None => serde_json::json!({}),
};
self.git_ns_task(GIT_NS_VIEW_TYPE, &payload, key).await
}
pub async fn git_ns_view_v2(
&self,
resource: Option<&str>,
key: &HolderKey,
) -> Result<view2::Response, VtcError> {
let payload = match resource {
Some(r) => serde_json::json!({ "resource": r }),
None => serde_json::json!({}),
};
self.git_ns_task(
<view2::Payload as trust_tasks_rs::Payload>::TYPE_URI,
&payload,
key,
)
.await
}
pub async fn git_ns_drift_resolve_v3(
&self,
payload: &drift_resolve3::Payload,
key: &HolderKey,
) -> Result<drift_resolve3::Response, VtcError> {
self.git_ns_task(
<drift_resolve3::Payload as trust_tasks_rs::Payload>::TYPE_URI,
payload,
key,
)
.await
}
#[deprecated(
note = "a drift/resolve 0.1 adopt names no recipient and is refused; use git_ns_drift_resolve_v3"
)]
pub async fn git_ns_drift_resolve(
&self,
payload: &drift_resolve::Payload,
key: &HolderKey,
) -> Result<drift_resolve::Response, VtcError> {
self.git_ns_task(
<drift_resolve::Payload as trust_tasks_rs::Payload>::TYPE_URI,
payload,
key,
)
.await
}
pub async fn git_ns_reseat(
&self,
namespace: &str,
subject: &str,
statement: &str,
key: &HolderKey,
) -> Result<reseat::Response, VtcError> {
let payload = serde_json::json!({
"namespace": namespace,
"subject": subject,
"statement": statement,
});
self.git_ns_task(RESEAT_TYPE_URI, &payload, key).await
}
pub async fn git_ns_reproject(
&self,
resource: &str,
reason: Option<&str>,
key: &HolderKey,
) -> Result<reproject::Response, VtcError> {
let mut payload = serde_json::json!({ "resource": resource });
if let Some(r) = reason {
payload["reason"] = serde_json::json!(r);
}
self.git_ns_task(
<reproject::Payload as trust_tasks_rs::Payload>::TYPE_URI,
&payload,
key,
)
.await
}
pub async fn git_ns_link_account(
&self,
forge: &str,
key: &HolderKey,
) -> Result<link::Response, VtcError> {
let payload = serde_json::json!({ "forge": forge });
self.git_ns_task(
<link::Payload as trust_tasks_rs::Payload>::TYPE_URI,
&payload,
key,
)
.await
}
pub async fn git_ns_link_status(
&self,
link_id: &str,
key: &HolderKey,
) -> Result<link_status::Response, VtcError> {
let payload = serde_json::json!({ "linkId": link_id });
self.git_ns_task(
<link_status::Payload as trust_tasks_rs::Payload>::TYPE_URI,
&payload,
key,
)
.await
}
pub async fn git_ns_unlink_account(
&self,
forge: &str,
account_id: Option<&str>,
key: &HolderKey,
) -> Result<unlink::Response, VtcError> {
let mut payload = serde_json::json!({ "forge": forge });
if let Some(id) = account_id {
payload["accountId"] = serde_json::json!(id);
}
self.git_ns_task(
<unlink::Payload as trust_tasks_rs::Payload>::TYPE_URI,
&payload,
key,
)
.await
}
}
pub const GIT_NS_BREAK_GLASS_TYPE: &str =
<break_glass::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const STEP_UP_APPROVE_RESPONSE_TYPE: &str =
<trust_tasks_rs::specs::auth::step_up::approve_response::v0_4::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const STEP_UP_PASSKEY_REDEEM_START_TYPE: &str =
<trust_tasks_rs::specs::auth::passkey::enroll::redeem::start::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
fn refusal_document(err: &VtcError) -> Option<Value> {
let text = match err {
VtcError::Http { body, .. } => body,
VtcError::Refused { document } => document,
_ => return None,
};
serde_json::from_str(text).ok()
}
pub fn step_up_request(err: &VtcError) -> Option<Value> {
refusal_document(err)?
.pointer("/payload/details/stepUpRequest")
.cloned()
}
pub fn task_error(err: &VtcError) -> Option<(String, String)> {
let doc = refusal_document(err)?;
let code = doc.pointer("/payload/code")?.as_str()?.to_string();
let message = doc
.pointer("/payload/message")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
Some((code, message))
}
#[cfg(test)]
mod tests {
use super::*;
fn refusal(code: &str) -> String {
serde_json::json!({
"type": "https://trusttasks.org/spec/trust-task-error/0.1",
"payload": {
"code": code,
"message": "no",
"details": { "stepUpRequest": { "boundTo": "sha-256:abc" } },
},
})
.to_string()
}
#[test]
fn a_refusal_reads_alike_over_a_session_and_over_https() {
let over_session = VtcError::Refused {
document: refusal("git-ns:lastOwner"),
};
let over_https = VtcError::Http {
status: 403,
body: refusal("git-ns:lastOwner"),
};
for err in [&over_session, &over_https] {
assert_eq!(
task_error(err),
Some(("git-ns:lastOwner".to_string(), "no".to_string()))
);
assert_eq!(
step_up_request(err).and_then(|r| r["boundTo"].as_str().map(str::to_string)),
Some("sha-256:abc".to_string())
);
}
assert_eq!(task_error(&VtcError::Session("down".into())), None);
}
}