#[cfg(feature = "didcomm")]
use std::time::Duration;
use affinidi_data_integrity::{DataIntegrityProof, SignOptions, crypto_suites::CryptoSuite};
use serde_json::{Value, json};
use vti_common::error::AppError;
#[cfg(feature = "didcomm")]
const CONSENT_PUSH_DELIVER_BY_SECS: u64 = 300;
use crate::policy::consent::PendingTaskConsent;
use crate::policy::effects::Effect;
use crate::policy::types::TaskClass;
use crate::server::AppState;
#[cfg(feature = "didcomm")]
use trust_tasks_didcomm::ENVELOPE_TYPE as TRUST_TASK_ENVELOPE_TYPE;
pub(super) const TASK_CONSENT_REQUEST_0_1: &str =
"https://trusttasks.org/spec/task-consent/request/0.1";
#[cfg(feature = "didcomm")]
pub(super) const TASK_CONSENT_GRANTED_0_1: &str =
"https://trusttasks.org/spec/task-consent/granted/0.1";
pub(super) async fn mint_signed_requests(
state: &AppState,
pending: &PendingTaskConsent,
members: &[String],
class: TaskClass,
effects: &[Effect],
subject: Option<&str>,
origin: Option<&str>,
) -> Result<Vec<Value>, AppError> {
let vta_did =
state.config.read().await.vta_did.clone().ok_or_else(|| {
AppError::Internal("VTA DID not configured; cannot sign consent".into())
})?;
let secret =
crate::operations::credentials::load_vta_issuer_secret(state, &vta_did, "task-consent")
.await?;
let class_value = serde_json::to_value(class)
.map_err(|e| AppError::Internal(format!("serialize task class: {e}")))?;
let expires_at = chrono::DateTime::from_timestamp(pending.expires_at as i64, 0)
.ok_or_else(|| AppError::Internal("consent expiry out of range".into()))?
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let mut signed = Vec::new();
for approver in members {
if pending.exclude_requester && approver == &pending.requester_did {
continue;
}
let mut payload = json!({
"challenge": pending.challenge,
"taskType": pending.type_uri,
"payloadDigest": pending.wire_digest,
"sideEffects": class_value.get("sideEffects"),
"exposure": class_value.get("exposure"),
"effects": effects,
"requester": pending.requester_did,
"approverSet": pending.approver_set,
"minApprovals": pending.min_approvals,
"excludeRequester": pending.exclude_requester,
"expiresAt": expires_at,
});
if let Some(s) = subject {
payload["subject"] = json!(s);
}
if let Some(o) = origin {
payload["origin"] = json!(o);
}
if let Some(pin) = &pending.state_pin {
payload["statePin"] = serde_json::to_value(pin)
.map_err(|e| AppError::Internal(format!("serialize state pin: {e}")))?;
}
let unsigned = json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": TASK_CONSENT_REQUEST_0_1,
"issuer": vta_did,
"recipient": approver,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": payload,
});
let proof = DataIntegrityProof::sign(
&unsigned,
&secret,
SignOptions::new()
.with_proof_purpose("assertionMethod")
.with_cryptosuite(CryptoSuite::EddsaJcs2022),
)
.await
.map_err(|e| AppError::Internal(format!("sign task-consent request: {e}")))?;
let mut doc = unsigned;
doc["proof"] = serde_json::to_value(&proof)
.map_err(|e| AppError::Internal(format!("serialize proof: {e}")))?;
signed.push(doc);
}
Ok(signed)
}
pub(super) async fn push_signed_requests(state: &AppState, requests: &[Value]) {
for request in requests {
let Some(approver) = request.get("recipient").and_then(Value::as_str) else {
continue;
};
push_one(state, approver, request).await;
}
}
async fn push_one(
state: &AppState,
approver: &str,
#[cfg_attr(not(any(feature = "didcomm", feature = "tsp")), allow(unused))] request: &Value,
) {
let configured_mediator = {
let cfg = state.config.read().await;
cfg.messaging.as_ref().map(|m| m.mediator_did.clone())
};
let mediator_did = super::step_up::approver_mediator(approver, configured_mediator.as_deref());
#[cfg_attr(not(any(feature = "didcomm", feature = "tsp")), allow(unused))]
let Some(mediator_did) = mediator_did else {
tracing::warn!(
approver = %approver,
configured_mediator = ?configured_mediator,
"no mediator route for consent approver — NOT notifying; the approver \
learns of this request only if the requester relays it (a CLI cannot). \
A did:key approver routes via the VTA's own [messaging] mediator_did: \
unset config, or a non-did:key approver, produces this."
);
return;
};
#[cfg(feature = "tsp")]
if super::step_up::try_push_over_tsp(state, approver, &mediator_did, request).await {
tracing::info!(
approver = %approver, mediator = %mediator_did, transport = "tsp",
"consent request pushed to approver"
);
#[cfg(feature = "didcomm")]
super::step_up::trigger_gateway_wake(state, approver, &mediator_did).await;
return;
}
tracing::info!(
approver = %approver, mediator = %mediator_did, transport = "didcomm",
"pushing consent request to approver"
);
#[cfg(feature = "didcomm")]
{
#[cfg(feature = "webvh")]
{
let pending = crate::messaging::registry::PendingResponse {
recipient_did: approver.to_string(),
message_type: TRUST_TASK_ENVELOPE_TYPE.to_string(),
body: request.clone(),
thread_id: request
.get("id")
.and_then(|v| v.as_str())
.map(str::to_string),
};
if let Err(e) = state
.mediator_registry
.buffer_outbound(&mediator_did, pending)
.await
{
tracing::warn!(
error = %e, approver = %approver, mediator = %mediator_did,
"failed to buffer task-consent request; relay fallback applies"
);
}
}
if let Err(e) = state
.didcomm_bridge
.send_guaranteed(
"vta-main",
approver,
TRUST_TASK_ENVELOPE_TYPE,
request.clone(),
request
.get("id")
.and_then(|v| v.as_str())
.map(str::to_string),
Duration::from_secs(CONSENT_PUSH_DELIVER_BY_SECS),
)
.await
{
tracing::warn!(
error = %e, approver = %approver,
"task-consent request enqueue failed; relay fallback applies"
);
}
super::step_up::trigger_gateway_wake(state, approver, &mediator_did).await;
}
}
pub(super) async fn push_granted(
state: &AppState,
#[cfg_attr(not(feature = "didcomm"), allow(unused))] requester: &str,
#[cfg_attr(not(feature = "didcomm"), allow(unused))] wire_digest: &str,
#[cfg_attr(not(feature = "didcomm"), allow(unused))] correlator: &str,
#[cfg_attr(not(feature = "didcomm"), allow(unused))] type_uri: &str,
) {
let mediator_did = {
let cfg = state.config.read().await;
super::step_up::approver_mediator(
requester,
cfg.messaging.as_ref().map(|m| m.mediator_did.as_str()),
)
};
#[cfg_attr(not(feature = "didcomm"), allow(unused))]
let Some(mediator_did) = mediator_did else {
tracing::debug!(
requester = %requester,
"no mediator route for consent requester; skipping granted notice (it will re-submit on its own)"
);
return;
};
#[cfg(feature = "didcomm")]
{
let mut body = serde_json::json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": TASK_CONSENT_GRANTED_0_1,
"threadId": correlator,
"recipient": requester,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": {
"status": "granted",
"payloadDigest": wire_digest,
"taskType": type_uri,
},
});
if let Some(vta_did) = state.config.read().await.vta_did.clone() {
body["issuer"] = serde_json::json!(vta_did);
}
#[cfg(feature = "webvh")]
{
let pending = crate::messaging::registry::PendingResponse {
message_type: TRUST_TASK_ENVELOPE_TYPE.to_string(),
recipient_did: requester.to_string(),
body: body.clone(),
thread_id: Some(correlator.to_string()),
};
if let Err(e) = state
.mediator_registry
.buffer_outbound(&mediator_did, pending)
.await
{
tracing::warn!(
error = %e, requester = %requester, mediator = %mediator_did,
"failed to buffer granted notice; requester falls back to re-submit"
);
}
}
if let Err(e) = state
.didcomm_bridge
.send_guaranteed(
"vta-main",
requester,
TRUST_TASK_ENVELOPE_TYPE,
body,
Some(format!("granted:{wire_digest}")),
Duration::from_secs(CONSENT_PUSH_DELIVER_BY_SECS),
)
.await
{
tracing::warn!(
error = %e, requester = %requester,
"granted notice enqueue failed; requester falls back to re-submit"
);
}
super::step_up::trigger_gateway_wake(state, requester, &mediator_did).await;
}
}
#[cfg(all(test, feature = "didcomm", feature = "webvh"))]
mod tests {
use crate::messaging::registry::MediatorBinding;
const MEDIATOR: &str = "did:example:mediator";
const REQUESTER: &str = "did:key:zRequester";
#[tokio::test]
async fn granted_notice_is_pushed_as_an_envelope() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
state
.mediator_registry
.record_activate(MediatorBinding {
mediator_did: MEDIATOR.into(),
endpoint: "https://mediator.test".into(),
})
.await;
{
let mut cfg = state.config.write().await;
cfg.messaging = Some(vti_common::config::MessagingConfig {
mediator_url: String::new(),
mediator_did: MEDIATOR.into(),
mediator_host: None,
setup_acl: false,
drain_inbox_on_start: false,
});
}
super::push_granted(
&state,
REQUESTER,
"digest-abc",
"urn:uuid:correlator-abc",
"https://example.org/task/1.0",
)
.await;
let pushed = state.mediator_registry.take_outbound(MEDIATOR).await;
assert_eq!(pushed.len(), 1, "the requester is notified exactly once");
assert_eq!(
pushed[0].message_type,
trust_tasks_didcomm::ENVELOPE_TYPE,
"the DIDComm message must carry the binding's envelope type"
);
assert_eq!(
pushed[0].body.get("type").and_then(|t| t.as_str()),
Some(super::TASK_CONSENT_GRANTED_0_1),
"the task type belongs in the enveloped document, not on the envelope"
);
assert_eq!(pushed[0].recipient_did, REQUESTER);
assert_eq!(
pushed[0].body["payload"]["payloadDigest"].as_str(),
Some("digest-abc")
);
}
#[tokio::test]
async fn the_notice_threads_on_the_correlator_not_the_digest() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
state
.mediator_registry
.record_activate(MediatorBinding {
mediator_did: MEDIATOR.into(),
endpoint: "https://mediator.test".into(),
})
.await;
{
let mut cfg = state.config.write().await;
cfg.messaging = Some(vti_common::config::MessagingConfig {
mediator_url: String::new(),
mediator_did: MEDIATOR.into(),
mediator_host: None,
setup_acl: false,
drain_inbox_on_start: false,
});
}
super::push_granted(
&state,
REQUESTER,
"digest-abc",
"urn:uuid:correlator-abc",
"https://example.org/task/1.0",
)
.await;
let pushed = state.mediator_registry.take_outbound(MEDIATOR).await;
let one = pushed.first().expect("a notice was pushed");
assert_eq!(
one.thread_id.as_deref(),
Some("urn:uuid:correlator-abc"),
"the envelope must thread on the minted correlator"
);
assert_eq!(
one.body["threadId"], "urn:uuid:correlator-abc",
"and so must the document: {}",
one.body
);
assert_eq!(
one.body["payload"]["payloadDigest"], "digest-abc",
"the body still carries the digest, so a requester matching on it \
is unaffected: {}",
one.body
);
}
}