use affinidi_data_integrity::{DataIntegrityProof, VerifyOptions};
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use sha2::{Digest, Sha256};
use tracing::info;
use uuid::Uuid;
use vti_common::audit::{AuditEvent, VrcPublishedData, VrcRevokedData};
use vti_common::error::AppError;
use crate::acl::get_acl_entry;
use crate::auth::AuthClaims;
use crate::members::get_member;
use crate::policy::{
PolicyPurpose, compile as compile_policy, evaluate as evaluate_policy, get_active_policy_id,
get_policy,
};
use crate::relationships::{
Relationship, delete_relationship, find_by_hash, get_relationship, store_relationship,
};
use crate::server::AppState;
#[derive(Debug, Deserialize)]
pub struct PublishBody {
pub vrc: JsonValue,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PublishResponse {
pub id: Uuid,
pub issuer_did: String,
pub subject_did: String,
pub vrc_sha256: String,
}
pub async fn publish(
auth: AuthClaims,
State(state): State<AppState>,
Json(body): Json<PublishBody>,
) -> Result<(StatusCode, Json<PublishResponse>), AppError> {
let vrc = &body.vrc;
let issuer_did = extract_did_field(vrc, "issuer")?;
let subject_did = extract_subject_id(vrc)?;
if auth.did != issuer_did {
return Err(AppError::Forbidden(format!(
"session DID ({}) does not match VRC issuer ({issuer_did}) — \
VRCs are self-issued; the VTC never mints them on a member's behalf",
auth.did
)));
}
let resolver = state.did_resolver.as_ref().cloned().ok_or_else(|| {
AppError::Internal("DID resolver not configured — VRC publish requires it".into())
})?;
verify_vc_proof(vrc, &issuer_did, &resolver)
.await
.map_err(|e| AppError::Validation(format!("VrcProofInvalid: {e}")))?;
let issuer_current = is_current_member(&state, &issuer_did).await?;
let subject_current = is_current_member(&state, &subject_did).await?;
if !subject_current {
if get_acl_entry(&state.acl_ks, &subject_did).await?.is_none() {
return Err(AppError::Validation(format!(
"subject DID {subject_did} is not a current community member"
)));
}
}
let policy_input = json!({
"vrc": vrc,
"issuer_member": { "did": issuer_did, "is_current": issuer_current },
"subject_member": { "did": subject_did, "is_current": subject_current },
"action": "publish",
});
let allow = evaluate_relationships_policy(&state, &policy_input).await?;
if !allow {
return Err(AppError::Forbidden(
"RelationshipPolicyDenied: active relationships.rego rejected the publish".into(),
));
}
let canon = canonicalise(vrc);
let digest = Sha256::digest(canon.as_bytes());
let vrc_sha256 = hex::encode(digest);
if let Some(existing) = find_by_hash(&state.relationships_ks, &vrc_sha256).await? {
return Ok((
StatusCode::OK,
Json(PublishResponse {
id: existing.id,
issuer_did: existing.issuer_did,
subject_did: existing.subject_did,
vrc_sha256: existing.vrc_sha256,
}),
));
}
let id = Uuid::new_v4();
let rel = Relationship {
id,
issuer_did: issuer_did.clone(),
subject_did: subject_did.clone(),
vrc_jsonld: vrc.clone(),
vrc_sha256: vrc_sha256.clone(),
created_at: Utc::now(),
};
store_relationship(
&state.relationships_ks,
&state.relationships_by_did_ks,
&rel,
)
.await?;
let edge_type = vrc
.pointer("/credentialSubject/endorsement/type")
.and_then(|v| v.as_str())
.unwrap_or("recognition")
.to_string();
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
&issuer_did,
Some(&subject_did),
AuditEvent::VrcPublished(VrcPublishedData {
vrc_id: id.to_string(),
subject_did: Some(subject_did.clone()),
edge_type,
}),
)
.await?;
}
info!(
vrc_id = %id,
issuer = %issuer_did,
subject = %subject_did,
"VRC published"
);
Ok((
StatusCode::CREATED,
Json(PublishResponse {
id,
issuer_did,
subject_did,
vrc_sha256,
}),
))
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RevokeResponse {
pub id: String,
}
pub async fn revoke(
auth: AuthClaims,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<(StatusCode, Json<RevokeResponse>), AppError> {
let rel = get_relationship(&state.relationships_ks, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("VRC {id} not found")))?;
let is_issuer = auth.did == rel.issuer_did;
let is_admin = auth.role == vti_common::acl::Role::Admin;
if !is_issuer && !is_admin {
return Err(AppError::Forbidden(
"only the issuer or an admin can revoke a VRC".into(),
));
}
delete_relationship(&state.relationships_ks, &state.relationships_by_did_ks, id).await?;
let revoked_by = if is_issuer { "issuer" } else { "admin" };
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
&auth.did,
Some(&rel.subject_did),
AuditEvent::VrcRevoked(VrcRevokedData {
vrc_id: id.to_string(),
revoked_by: revoked_by.into(),
}),
)
.await?;
}
info!(vrc_id = %id, revoked_by, "VRC revoked");
Ok((StatusCode::OK, Json(RevokeResponse { id: id.to_string() })))
}
fn extract_did_field(vrc: &JsonValue, field: &str) -> Result<String, AppError> {
let v = vrc
.get(field)
.ok_or_else(|| AppError::Validation(format!("VRC missing {field}")))?;
match v {
JsonValue::String(s) => Ok(s.clone()),
JsonValue::Object(o) => o
.get("id")
.and_then(|x| x.as_str())
.map(str::to_string)
.ok_or_else(|| AppError::Validation(format!("VRC.{field}.id missing or not a string"))),
_ => Err(AppError::Validation(format!(
"VRC.{field} is neither a string nor an object"
))),
}
}
fn extract_subject_id(vrc: &JsonValue) -> Result<String, AppError> {
vrc.pointer("/credentialSubject/id")
.and_then(|v| v.as_str())
.map(str::to_string)
.ok_or_else(|| {
AppError::Validation("VRC.credentialSubject.id missing or not a string".into())
})
}
async fn verify_vc_proof(
vrc: &JsonValue,
issuer_did: &str,
resolver: &DIDCacheClient,
) -> Result<(), String> {
let proof_value = vrc
.get("proof")
.ok_or_else(|| "VRC missing proof".to_string())?;
let proof: DataIntegrityProof =
serde_json::from_value(proof_value.clone()).map_err(|e| format!("parse proof: {e}"))?;
let mut vrc_without_proof = vrc.clone();
if let Some(obj) = vrc_without_proof.as_object_mut() {
obj.remove("proof");
}
let verification_method = proof_value
.get("verificationMethod")
.and_then(|v| v.as_str())
.ok_or_else(|| "proof missing verificationMethod".to_string())?;
let resolved = resolver
.resolve(issuer_did)
.await
.map_err(|e| format!("resolve {issuer_did}: {e}"))?;
let vm = resolved
.doc
.verification_method
.iter()
.find(|m| m.id.as_str() == verification_method)
.ok_or_else(|| format!("verificationMethod {verification_method} not on {issuer_did}"))?;
let pubkey = vm
.get_public_key_bytes()
.map_err(|e| format!("extract pubkey: {e}"))?;
proof
.verify_with_public_key(&vrc_without_proof, &pubkey, VerifyOptions::new())
.map_err(|e| format!("verify: {e}"))?;
Ok(())
}
async fn is_current_member(state: &AppState, did: &str) -> Result<bool, AppError> {
let acl = get_acl_entry(&state.acl_ks, did).await?;
if acl.is_none() {
return Ok(false);
}
let member = get_member(&state.members_ks, did).await?;
Ok(member.is_some_and(|m| !m.is_removed()))
}
async fn evaluate_relationships_policy(
state: &AppState,
input: &JsonValue,
) -> Result<bool, AppError> {
let Some(id) =
get_active_policy_id(&state.active_policies_ks, PolicyPurpose::Relationships).await?
else {
return Ok(false);
};
let policy = get_policy(&state.policies_ks, id)
.await?
.ok_or_else(|| AppError::Internal(format!("active relationships policy {id} not found")))?;
let compiled = compile_policy(&policy.rego_source, policy.id)?;
let result = evaluate_policy(&compiled, "data.vtc.relationships.allow", input.clone())?;
Ok(result
.pointer("/result/0/expressions/0/value")
.and_then(|v| v.as_bool())
.unwrap_or(false))
}
fn canonicalise(v: &JsonValue) -> String {
fn into_sorted(v: JsonValue) -> JsonValue {
match v {
JsonValue::Object(m) => {
let mut sorted: std::collections::BTreeMap<String, JsonValue> =
std::collections::BTreeMap::new();
for (k, val) in m {
sorted.insert(k, into_sorted(val));
}
serde_json::to_value(sorted).expect("sorted object is JSON-able")
}
JsonValue::Array(arr) => JsonValue::Array(arr.into_iter().map(into_sorted).collect()),
other => other,
}
}
serde_json::to_string(&into_sorted(v.clone())).unwrap_or_else(|_| "{}".into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_did_field_handles_string_form() {
let vrc = json!({ "issuer": "did:key:zA" });
assert_eq!(extract_did_field(&vrc, "issuer").unwrap(), "did:key:zA");
}
#[test]
fn extract_did_field_handles_object_form() {
let vrc = json!({ "issuer": { "id": "did:key:zA", "name": "x" } });
assert_eq!(extract_did_field(&vrc, "issuer").unwrap(), "did:key:zA");
}
#[test]
fn extract_did_field_rejects_missing() {
let vrc = json!({});
assert!(extract_did_field(&vrc, "issuer").is_err());
}
#[test]
fn extract_subject_id_extracts_nested() {
let vrc = json!({
"credentialSubject": { "id": "did:key:zSubject", "role": "member" }
});
assert_eq!(extract_subject_id(&vrc).unwrap(), "did:key:zSubject");
}
#[test]
fn canonicalise_is_key_order_stable() {
let a = json!({ "b": 1, "a": 2, "c": { "y": 5, "x": 4 } });
let b = json!({ "a": 2, "c": { "x": 4, "y": 5 }, "b": 1 });
assert_eq!(canonicalise(&a), canonicalise(&b));
}
}