use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use trust_tasks_rs::specs::vta::witness as spec;
use trust_tasks_rs::{DeclaredErrorCode, RejectReason, TrustTask};
use vti_common::acl::Capability;
use crate::auth::AuthClaims;
use crate::operations::witness::{self as engine, WitnessError};
use crate::server::AppState;
use super::helpers::{
TRANSPORT_TRUST_TASK, TrustTaskOutcome, app_error_to_reject, parse_payload, reject_declared,
reject_with, require_capability, success_response,
};
struct Codes {
not_found: Option<DeclaredErrorCode>,
credential_mismatch: Option<DeclaredErrorCode>,
witness_unresolvable: Option<DeclaredErrorCode>,
session_unknown: Option<DeclaredErrorCode>,
response_invalid: Option<DeclaredErrorCode>,
vwc_invalid: Option<DeclaredErrorCode>,
}
const NONE: Codes = Codes {
not_found: None,
credential_mismatch: None,
witness_unresolvable: None,
session_unknown: None,
response_invalid: None,
vwc_invalid: None,
};
fn refuse(doc: &TrustTask<Value>, e: WitnessError, codes: &Codes) -> TrustTaskOutcome {
let message = e.to_string();
let declared = match &e {
WitnessError::NotFound(_) => codes.not_found,
WitnessError::CredentialMismatch(_) => codes.credential_mismatch,
WitnessError::WitnessUnresolvable(_) => codes.witness_unresolvable,
WitnessError::SessionUnknown => codes.session_unknown,
WitnessError::ResponseInvalid(_) => codes.response_invalid,
WitnessError::VwcInvalid(_) => codes.vwc_invalid,
WitnessError::PermissionDenied(_) => {
return reject_with(doc, RejectReason::PermissionDenied { reason: message });
}
WitnessError::Malformed(_) => {
return reject_with(doc, RejectReason::MalformedRequest { reason: message });
}
WitnessError::App(_) => None,
};
match (declared, e) {
(Some(code), _) => reject_declared(doc, code, message),
(None, WitnessError::App(a)) => app_error_to_reject(doc, a),
(None, _) => reject_with(
doc,
RejectReason::InternalError {
reason: format!("{} has no code for this refusal: {message}", doc.type_uri),
},
),
}
}
fn respond<R: DeserializeOwned + Serialize>(
doc: &TrustTask<Value>,
value: Value,
) -> TrustTaskOutcome {
match serde_json::from_value::<R>(value) {
Ok(r) => success_response(doc, r),
Err(e) => reject_with(
doc,
RejectReason::InternalError {
reason: format!("{} response does not match its schema: {e}", doc.type_uri),
},
),
}
}
fn str_of<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
v.get(key).and_then(Value::as_str)
}
async fn audit(
state: &AppState,
action: &str,
auth: &AuthClaims,
session_id: &str,
context: Option<&str>,
witness: &str,
) {
let detail = format!("witness {witness}");
if let Err(e) = crate::audit::record_with_detail(
&state.audit_sink,
action,
&auth.did,
Some(session_id),
"success",
Some(TRANSPORT_TRUST_TASK),
context,
Some(&detail),
)
.await
{
tracing::warn!(error = %e, action, "audit record failed for a witnessed-party task");
}
}
async fn gate(
state: &AppState,
auth: &AuthClaims,
doc: &TrustTask<Value>,
) -> Result<(), TrustTaskOutcome> {
require_capability(
state,
auth,
doc,
Capability::WitnessRequest,
"acting as the principal's witnessed party",
)
.await
}
pub(super) async fn handle_open(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
use spec::open::v0_2 as t;
if let Err(r) = gate(state, auth, &doc).await {
return r;
}
if let Err(r) = parse_payload::<t::Payload>(&doc) {
return r;
}
let p = &doc.payload;
let membership = p.get("membership");
let req = engine::OpenRequest {
witness: str_of(p, "witness").unwrap_or_default(),
relationship_did: str_of(p, "relationshipDid").unwrap_or_default(),
counterparty_did: str_of(p, "counterpartyDid").unwrap_or_default(),
relationship_thread_id: str_of(p, "relationshipThreadId").unwrap_or_default(),
relationship_credential_id: str_of(p, "relationshipCredentialId").unwrap_or_default(),
membership_credential_id: membership.and_then(|m| str_of(m, "credentialId")),
member_did: membership.and_then(|m| str_of(m, "memberDid")),
store_as: str_of(p, "storeAs"),
};
let witness = req.witness.to_string();
match engine::open(state, auth, req).await {
Ok(opened) => {
audit(
state,
"witness.open",
auth,
&opened.session_id,
opened.context_id.as_deref(),
&witness,
)
.await;
respond::<t::Response>(
&doc,
json!({
"sessionId": opened.session_id,
"document": opened.document,
"expiresAt": opened.expires_at,
}),
)
}
Err(e) => refuse(
&doc,
e,
&Codes {
not_found: Some(t::error_codes::NOT_FOUND),
credential_mismatch: Some(t::error_codes::CREDENTIAL_MISMATCH),
witness_unresolvable: Some(t::error_codes::WITNESS_UNRESOLVABLE),
..NONE
},
),
}
}
pub(super) async fn handle_present(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
use spec::present::v0_2 as t;
if let Err(r) = gate(state, auth, &doc).await {
return r;
}
if let Err(r) = parse_payload::<t::Payload>(&doc) {
return r;
}
let session_id = str_of(&doc.payload, "sessionId")
.unwrap_or_default()
.to_string();
let response = doc.payload.get("response").cloned().unwrap_or(Value::Null);
match engine::present(state, auth, &session_id, &response).await {
Ok(presented) => {
audit(
state,
"witness.present",
auth,
&session_id,
presented.context_id.as_deref(),
&presented.witness,
)
.await;
respond::<t::Response>(
&doc,
json!({ "sessionId": session_id, "document": presented.document }),
)
}
Err(e) => refuse(
&doc,
e,
&Codes {
not_found: Some(t::error_codes::NOT_FOUND),
credential_mismatch: Some(t::error_codes::CREDENTIAL_MISMATCH),
session_unknown: Some(t::error_codes::SESSION_UNKNOWN),
response_invalid: Some(t::error_codes::RESPONSE_INVALID),
..NONE
},
),
}
}
pub(super) async fn handle_accept(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
use spec::accept::v0_2 as t;
if let Err(r) = gate(state, auth, &doc).await {
return r;
}
if let Err(r) = parse_payload::<t::Payload>(&doc) {
return r;
}
let session_id = str_of(&doc.payload, "sessionId")
.unwrap_or_default()
.to_string();
let response = doc.payload.get("response").cloned().unwrap_or(Value::Null);
match engine::accept(state, auth, &session_id, &response).await {
Ok(accepted) => {
if !accepted.repeated {
audit(
state,
"witness.accept",
auth,
&session_id,
accepted.context_id.as_deref(),
&accepted.witness,
)
.await;
}
respond::<t::Response>(
&doc,
json!({
"sessionId": session_id,
"credentialId": accepted.credential_id,
"witness": accepted.witness,
"vwcDigestMultibase": accepted.vwc_digest_multibase,
"evidence": {
"session": accepted.session_document,
"outcome": accepted.outcome,
},
}),
)
}
Err(e) => refuse(
&doc,
e,
&Codes {
session_unknown: Some(t::error_codes::SESSION_UNKNOWN),
response_invalid: Some(t::error_codes::RESPONSE_INVALID),
vwc_invalid: Some(t::error_codes::VWC_INVALID),
..NONE
},
),
}
}
pub(super) async fn handle_list(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
use spec::list::v0_1 as t;
if let Err(r) = gate(state, auth, &doc).await {
return r;
}
if let Err(r) = parse_payload::<t::Payload>(&doc) {
return r;
}
let p = &doc.payload;
let req = engine::ListRequest {
relationship_did: str_of(p, "relationshipDid"),
witness: str_of(p, "witness"),
page_size: p
.get("pageSize")
.and_then(Value::as_u64)
.map(|n| n as usize),
cursor: str_of(p, "cursor"),
};
match engine::list(state, auth, req).await {
Ok(page) => {
if let Err(e) = crate::audit::record_with_detail(
&state.audit_sink,
"witness.list",
&auth.did,
None,
"success",
Some(TRANSPORT_TRUST_TASK),
None,
Some(&format!("{} witnessings", page.witnessings.len())),
)
.await
{
tracing::warn!(error = %e, "audit record failed for witness.list");
}
let witnessings: Vec<Value> = page
.witnessings
.into_iter()
.map(|w| {
json!({
"credentialId": w.credential_id,
"witness": w.witness,
"relationshipDid": w.relationship_did,
"vwcDigestMultibase": w.vwc_digest_multibase,
"acceptedAt": w.accepted_at,
"evidence": { "session": w.session, "outcome": w.outcome },
})
})
.collect();
let mut body = json!({ "witnessings": witnessings, "truncated": page.truncated });
if let Some(c) = page.cursor {
body["cursor"] = json!(c);
}
respond::<t::Response>(&doc, body)
}
Err(engine::ListError::CursorInvalid) => reject_declared(
&doc,
t::error_codes::CURSOR_INVALID,
"this cursor cannot be honoured; list again from the first page, without a cursor",
),
Err(engine::ListError::App(e)) => app_error_to_reject(&doc, e),
}
}
#[cfg(test)]
#[path = "witness_tests.rs"]
mod tests;