vta-service 0.62.0

Service for Verifiable Trust Agents operating in Verifiable Trust Communities
//! The witnessed-party tasks — `vta/witness/open/0.2`, `vta/witness/present/0.2` and
//! `vta/witness/accept/0.2` (trust-tasks-tf #779, #799; rules in `vta/_shared/0.6/CONVENTIONS.md`).
//! The 0.1 versions are not served: they conducted the session under `witness/session/0.1` and
//! carried the member presentation in a vendor extension, which 0.2 replaces.
//! `vta/witness/list/0.1` (trust-tasks-tf #800) reads back what `accept` stored.
//!
//! The engine is [`crate::operations::witness`]; this slice is the dispatch surface over it:
//!
//! - **Capability-gated** before anything is read: `witness-request` (registered upstream as
//!   `witnessRequest`) for all three. It is not `sign`, and `sign` does not satisfy it
//!   (conventions §4).
//! - **Context-gated** in the engine: the keys of the session's DIDs, the credentials and the
//!   session record are all found only among the contexts the caller may act in.
//! - **Audited** with the caller, the context, the session and the witness — never a
//!   presentation, a challenge or a member DID (`vta/witness/accept/0.2`, *Retention*).
//!
//! Every refusal is the code the task declares for it, or the framework's standard code where
//! the conventions say so (§7), so it survives TSP, DIDComm and HTTPS alike as a typed
//! `trust-task-error`.

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,
};

/// The task-declared code for each engine refusal a task can reach.
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,
};

/// Answer a refusal with the code the task declares, or the standard one the conventions name.
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),
        // A refusal the task declares no code for is a defect in this slice: say so rather
        // than invent a code no consumer can look up.
        (None, _) => reject_with(
            doc,
            RejectReason::InternalError {
                reason: format!("{} has no code for this refusal: {message}", doc.type_uri),
            },
        ),
    }
}

/// Answer with `value` rendered through the task's generated response type `R`, so a response
/// that drifted from its schema fails here rather than at a consumer.
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
}

/// `vta/witness/open/0.2`.
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
            },
        ),
    }
}

/// `vta/witness/present/0.2`.
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();
    // The witness's answer exactly as handed back — never a re-serialisation of a parsed copy.
    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
            },
        ),
    }
}

/// `vta/witness/accept/0.2`.
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
            },
        ),
    }
}

/// `vta/witness/list/0.1` — the VWCs `accept` stored, each with its evidence as stored.
///
/// Gated like the rest of the family (`witness-request`, never `sign`). The engine confines a
/// page to contexts the caller may act in, so a filter naming a DID the caller cannot reach
/// answers an empty page, never a refusal. Audited with the caller and the filters' presence —
/// never a DID beyond the caller's own.
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;