use serde_json::Value as JsonValue;
use tracing::info;
use uuid::Uuid;
use vti_common::audit::{AuditEvent, MembershipReciprocatedData};
use vti_common::error::AppError;
use vta_sdk::protocols::members::MEMBERSHIP_CREDENTIAL_TYPE;
use crate::credentials::vm_resolver::{DidVmResolver, check_issuer_binding};
use crate::error::TaskError;
use crate::join::{JoinStatus, get_join_request};
use crate::members::{get_member, store_member};
use crate::server::AppState;
use trust_tasks_rs::specs::vtc::members::vmc::v0_1::error_codes as vmc_codes;
pub const VMC_ERR_SUBJECT_MISMATCH: &str = vmc_codes::SUBJECT_MISMATCH.code;
pub const VMC_ERR_NOT_A_MEMBER: &str = vmc_codes::NOT_A_MEMBER.code;
pub const VMC_ERR_INVALID_CREDENTIAL: &str = vmc_codes::INVALID_CREDENTIAL.code;
pub const VMC_ERR_REQUEST_NOT_FOUND: &str = vmc_codes::REQUEST_NOT_FOUND.code;
pub const VMC_ERR_REQUEST_NOT_APPROVED: &str = vmc_codes::REQUEST_NOT_APPROVED.code;
pub const VMC_ERR_REQUEST_APPLICANT_MISMATCH: &str = vmc_codes::REQUEST_APPLICANT_MISMATCH.code;
pub struct MemberVmcOutcome {
pub member_did: String,
pub vmc_id: String,
pub recorded: bool,
pub request_id: Option<Uuid>,
}
pub async fn receive_member_vmc_inner(
state: &AppState,
member_did: String,
vc: JsonValue,
request_id: Option<Uuid>,
) -> Result<MemberVmcOutcome, TaskError> {
let community_did = state
.config
.read()
.await
.vtc_did
.clone()
.filter(|d| !d.is_empty())
.ok_or_else(|| {
AppError::Internal("VTC DID not configured — cannot accept a member VMC".into())
})?;
let vmc_id = verify_member_vmc(state, &vc, &member_did, &community_did).await?;
if let Some(req_id) = request_id {
let req = get_join_request(&state.join_requests_ks, req_id)
.await?
.ok_or_else(|| {
TaskError::declared(
VMC_ERR_REQUEST_NOT_FOUND,
AppError::NotFound(format!("join request not found: {req_id}")),
)
})?;
if req.applicant_did != member_did {
return Err(TaskError::declared(
VMC_ERR_REQUEST_APPLICANT_MISMATCH,
AppError::Validation(format!(
"join request {req_id} does not belong to the delivering member"
)),
));
}
if req.status != JoinStatus::Approved {
return Err(TaskError::declared(
VMC_ERR_REQUEST_NOT_APPROVED,
AppError::Conflict(format!(
"join request {req_id} is {:?}; only an Approved request has a membership to reciprocate",
req.status
)),
));
}
}
let edit_guard = crate::members::storage::edit_lock().await;
let mut member = get_member(&state.members_ks, &member_did)
.await?
.filter(|m| !m.is_removed())
.ok_or_else(|| {
TaskError::declared(
VMC_ERR_NOT_A_MEMBER,
AppError::NotFound(format!("no active member: {member_did}")),
)
})?;
let bound = check_acknowledgement_binding(&vc, member.current_vmc.as_ref(), &member_did)?;
if member.member_vmc_id.as_deref() == Some(vmc_id.as_str()) {
return Ok(MemberVmcOutcome {
member_did,
vmc_id,
recorded: false,
request_id,
});
}
member.record_member_vmc(vmc_id.clone(), vc, bound);
if request_id.is_some() {
member.record_reciprocation(vmc_id.clone());
}
store_member(&state.members_ks, &member).await?;
drop(edit_guard);
if let Some(req_id) = request_id {
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
audit_writer
.write(
&member_did,
Some(&member_did),
AuditEvent::MembershipReciprocated(MembershipReciprocatedData {
request_id: req_id.to_string(),
vmc_id: vmc_id.clone(),
reciprocal_vc_id: vmc_id.clone(),
}),
)
.await?;
}
info!(
member = %member_did,
vmc_id = %vmc_id,
closed_request = ?request_id,
"stored member-issued VMC (member → community half of the pair)"
);
Ok(MemberVmcOutcome {
member_did,
vmc_id,
recorded: true,
request_id,
})
}
fn check_acknowledgement_binding(
vc: &JsonValue,
grant: Option<&JsonValue>,
member_did: &str,
) -> Result<bool, AppError> {
let claimed = vc
.get("credentialSubject")
.and_then(|s| s.get("digestMultibase"))
.and_then(JsonValue::as_str);
let (Some(claimed), Some(grant)) = (claimed, grant) else {
tracing::warn!(
member = %member_did,
digest_present = claimed.is_some(),
grant_stored = grant.is_some(),
"member VMC stored without a verified digest — the membership edge is \
not complete; ask the member to re-issue with `request-vmc`"
);
return Ok(false);
};
let grant_scope = grant.get("issuerScope").and_then(JsonValue::as_str);
if grant_scope != Some(dtg_credentials::IssuerScope::Public.as_str()) {
return Err(AppError::Validation(format!(
"the membership credential this community holds for {member_did} does not \
declare issuerScope `public` (found {grant_scope:?}), so no acknowledgement can \
complete an edge against it. Renew the membership, then re-issue the \
acknowledgement against the new grant."
)));
}
let expected = crate::credentials::ingress::dtg_credential_digest_multibase(grant)?;
let matches = dtg_credentials::digests_match(claimed, &expected).map_err(|e| {
AppError::Validation(format!(
"member vmc digest is not a valid multibase digest: {e}"
))
})?;
if !matches {
return Err(AppError::Validation(format!(
"member vmc `credentialSubject.digestMultibase` does not match the membership \
credential this community issued to {member_did} — it acknowledges a different \
grant. Re-issue against the current one."
)));
}
Ok(true)
}
async fn verify_member_vmc(
state: &AppState,
vc: &JsonValue,
member_did: &str,
community_did: &str,
) -> Result<String, TaskError> {
let invalid =
|m: String| TaskError::declared(VMC_ERR_INVALID_CREDENTIAL, AppError::Validation(m));
let obj = vc
.as_object()
.ok_or_else(|| invalid("member vmc is not a JSON object".into()))?;
let issuer = match obj.get("issuer") {
Some(JsonValue::String(s)) => s.clone(),
Some(JsonValue::Object(o)) => o
.get("id")
.and_then(JsonValue::as_str)
.unwrap_or_default()
.to_string(),
_ => String::new(),
};
if issuer != member_did {
return Err(invalid(format!(
"member vmc issuer `{issuer}` is not the member `{member_did}`"
)));
}
match crate::credentials::ingress::classify_dtg(vc) {
Ok(dtg_credentials::DTGCredentialType::Membership) => {}
Ok(other) => {
return Err(invalid(format!(
"member vmc must be a `{MEMBERSHIP_CREDENTIAL_TYPE}`; got a {other}"
)));
}
Err(e) => return Err(invalid(format!("member vmc: {e}"))),
}
let subject_id = obj
.get("credentialSubject")
.and_then(JsonValue::as_object)
.and_then(|s| s.get("id"))
.and_then(JsonValue::as_str)
.unwrap_or_default();
if subject_id != community_did {
return Err(TaskError::declared(
VMC_ERR_SUBJECT_MISMATCH,
AppError::Validation(format!(
"member vmc subject `{subject_id}` is not this community `{community_did}`"
)),
));
}
let proof_value = obj
.get("proof")
.ok_or_else(|| invalid("member vmc has no issuer `proof`".into()))?;
let proofs = crate::credentials::proof_set::proof_set(proof_value)
.map_err(|e| invalid(format!("member vmc proof is not Data-Integrity: {e}")))?;
for proof in &proofs {
check_issuer_binding(&proof.verification_method, member_did)
.map_err(|e| TaskError::declared(VMC_ERR_INVALID_CREDENTIAL, e))?;
}
let resolver = DidVmResolver::new(state.did_resolver.clone());
let mut unsigned = vc.clone();
if let Some(o) = unsigned.as_object_mut() {
o.remove("proof");
}
let mut outcomes: Vec<(String, Result<(), String>)> = Vec::with_capacity(proofs.len());
for proof in &proofs {
let did = crate::credentials::proof_set::proof_signer_did(proof).to_string();
let r = crate::credentials::proof_set::verify_one(
proof,
&unsigned,
&resolver,
vti_common::auth::ProofPurpose::AssertionMethod,
)
.await;
outcomes.push((did, r));
}
crate::credentials::proof_set::accept_all(&outcomes)
.map_err(|e| invalid(format!("member vmc issuer proof did not verify: {e}")))?;
obj.get("id")
.and_then(JsonValue::as_str)
.map(str::to_string)
.ok_or_else(|| invalid("member vmc has no top-level `id`".into()))
}
#[cfg(test)]
mod binding_tests {
use super::*;
fn pair() -> (JsonValue, JsonValue) {
use chrono::TimeZone;
let valid_from = chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let grant = dtg_credentials::DTGCredential::new_vmc(
"did:web:community.example".to_string(),
"did:key:zMember".to_string(),
valid_from,
None,
false,
)
.with_id("urn:uuid:grant-1");
let grant_wire = crate::test_support::dtg_json(&grant);
let ack = dtg_credentials::DTGCredential::new_member_vmc_for(
&grant_wire,
"did:key:zMember",
dtg_credentials::IssuerScope::Directed,
valid_from,
None,
)
.expect("acknowledgement builds")
.with_id("urn:uuid:ack-1");
(grant_wire, crate::test_support::dtg_json(&ack))
}
#[test]
fn an_acknowledgement_of_our_grant_binds() {
let (grant, ack) = pair();
assert!(
check_acknowledgement_binding(&ack, Some(&grant), "did:key:zMember").unwrap(),
"the catalog's digest must verify against this service's"
);
}
#[test]
fn a_grant_that_is_not_public_completes_no_edge() {
let (mut grant, ack) = pair();
grant.as_object_mut().unwrap().remove("issuerScope");
let err = check_acknowledgement_binding(&ack, Some(&grant), "did:key:zMember")
.expect_err("a pre-v1 grant cannot be acknowledged");
assert!(format!("{err:?}").contains("issuerScope"), "{err:?}");
grant["issuerScope"] = serde_json::json!("directed");
check_acknowledgement_binding(&ack, Some(&grant), "did:key:zMember")
.expect_err("a community grant is always public");
}
#[test]
fn a_working_draft_01_digest_member_binds_nothing() {
let (grant, ack) = pair();
let mut legacy_ack = ack.clone();
let subject = legacy_ack
.get_mut("credentialSubject")
.and_then(JsonValue::as_object_mut)
.expect("the acknowledgement has a subject");
let digest = subject.remove("digestMultibase").unwrap();
subject.insert("digest".into(), digest);
assert!(
!check_acknowledgement_binding(&legacy_ack, Some(&grant), "did:key:zMember").unwrap()
);
}
#[test]
fn a_re_signed_grant_still_satisfies_an_existing_acknowledgement() {
let (mut grant, ack) = pair();
grant["proof"] = serde_json::json!({
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"proofValue": "zSomeOtherSignatureEntirely"
});
assert!(check_acknowledgement_binding(&ack, Some(&grant), "did:key:zMember").unwrap());
}
#[test]
fn an_acknowledgement_of_a_different_grant_is_refused() {
use chrono::TimeZone;
let (_, ack) = pair();
let renewed = dtg_credentials::DTGCredential::new_vmc(
"did:web:community.example".to_string(),
"did:key:zMember".to_string(),
chrono::Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap(),
None,
false,
)
.with_id("urn:uuid:grant-2");
let renewed = crate::test_support::dtg_json(&renewed);
let err = check_acknowledgement_binding(&ack, Some(&renewed), "did:key:zMember")
.expect_err("a mismatched digest must be refused");
assert!(
format!("{err:?}").contains("acknowledges a different grant"),
"unexpected error: {err:?}"
);
}
#[test]
fn an_acknowledgement_without_a_digest_is_stored_unbound() {
let (grant, _) = pair();
let legacy = serde_json::json!({
"@context": [
dtg_credentials::W3C_VC_V2_CONTEXT,
dtg_credentials::DTG_CONTEXT_V1
],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"id": "urn:uuid:unbound-ack",
"issuer": "did:key:zMember",
"issuerScope": "directed",
"validFrom": "2026-01-01T00:00:00Z",
"credentialSubject": { "id": "did:web:community.example" }
});
assert!(
!check_acknowledgement_binding(&legacy, Some(&grant), "did:key:zMember").unwrap(),
"no digest to check, so nothing was verified"
);
}
#[test]
fn an_acknowledgement_with_no_stored_grant_is_stored_unbound() {
let (_, ack) = pair();
assert!(!check_acknowledgement_binding(&ack, None, "did:key:zMember").unwrap());
}
}