use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use crate::credentials::vm_resolver::{DidVmResolver, check_issuer_binding};
use crate::routing::rate_limit::{RELATIONSHIPS_LIMITER, RateLimited};
use axum::http::StatusCode;
use chrono::Utc;
use dtg_credentials::IssuerScope;
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use tracing::info;
use trust_tasks_rs::TrustTask as TrustTaskDoc;
use uuid::Uuid;
use vti_common::audit::{
AuditEvent, VrcLifecycleData, VrcPublishedData, VrcRevokedData, VrcSupersededData,
};
use vti_common::error::AppError;
use crate::acl::get_acl_entry;
use crate::error::TaskError;
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, issuer_counterparties_besides,
store_relationship,
};
use crate::server::AppState;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IdentifierForm {
Attributed,
Pairwise,
}
impl IdentifierForm {
fn from_scope(scope: IssuerScope) -> Self {
match scope {
IssuerScope::Pairwise => IdentifierForm::Pairwise,
IssuerScope::Directed | IssuerScope::Public => IdentifierForm::Attributed,
}
}
fn as_str(self) -> &'static str {
match self {
IdentifierForm::Attributed => "attributed",
IdentifierForm::Pairwise => "pairwise",
}
}
}
use trust_tasks_rs::specs::vtc::relationships as relationships_spec;
pub const PUBLISH_ERR_VRC_INVALID: &str =
relationships_spec::publish::v0_2::error_codes::VRC_INVALID.code;
pub const PUBLISH_ERR_SUBJECT_NOT_MEMBER: &str =
relationships_spec::publish::v0_2::error_codes::SUBJECT_NOT_MEMBER.code;
pub const REVOKE_ERR_NOT_FOUND: &str =
relationships_spec::revoke::v0_1::error_codes::NOT_FOUND.code;
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct PublishBody {
pub vrc: JsonValue,
#[serde(default)]
pub pop: Option<JsonValue>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct PublishResponse {
pub id: Uuid,
pub issuer_did: String,
pub subject_did: String,
pub vrc_digest_multibase: String,
}
pub(crate) async fn publish_inner(
state: &AppState,
doc: &TrustTaskDoc<JsonValue>,
signer_did: &str,
now: chrono::DateTime<Utc>,
) -> Result<(StatusCode, PublishResponse), PublishError> {
let signer_did = signer_did.to_string();
enforce_publish_rate_limit(state, &signer_did).await?;
let body: PublishBody = serde_json::from_value(doc.payload.clone())
.map_err(|e| AppError::Validation(format!("invalid publish payload: {e}")))?;
let vrc = &body.vrc;
let issuer_did = extract_did_field(vrc, "issuer")?;
let subject_did = extract_subject_id(vrc)?;
check_vrc_shape(vrc, now)?;
if body.pop.is_none() && issuer_did != signer_did {
return Err(TaskError::declared(
PUBLISH_ERR_VRC_INVALID,
AppError::Forbidden(format!(
"VRC issuer ({issuer_did}) is not the document signer and no publish \
authorization (`pop`) was supplied — a VRC issued under a \
relationship DID must carry proof the caller controls it"
)),
)
.into());
}
let resolver = state.did_resolver.as_ref().cloned().ok_or_else(|| {
AppError::Internal("DID resolver not configured — VRC publish requires it".into())
})?;
verify_di_proof(vrc, &issuer_did, &resolver)
.await
.map_err(|e| {
TaskError::declared(
PUBLISH_ERR_VRC_INVALID,
AppError::Validation(format!("VrcProofInvalid: {e}")),
)
})?;
let vrc_digest_multibase = crate::credentials::ingress::digest_multibase(vrc)?;
if let Some(pop) = &body.pop {
let vrc_digest = crate::credentials::ingress::digest_multibase(vrc)?;
verify_publish_authorization(pop, &issuer_did, &doc.id, &vrc_digest, &resolver)
.await
.map_err(|e| {
TaskError::declared(
PUBLISH_ERR_VRC_INVALID,
AppError::Forbidden(format!("VrcPublishAuthorizationInvalid: {e}")),
)
})?;
} else if issuer_did != signer_did {
return Err(AppError::Forbidden(
"VRC issuer is not the document signer and no publish \
authorization (`pop`) was supplied"
.into(),
)
.into());
}
let issuer_scope = vrc_issuer_scope(vrc)?;
let identifier_form = IdentifierForm::from_scope(issuer_scope);
if identifier_form == IdentifierForm::Pairwise && issuer_did == signer_did {
return Err(TaskError::declared(
PUBLISH_ERR_VRC_INVALID,
AppError::Validation(format!(
"VRC declares issuerScope `pairwise` but is issued under your membership DID \
{issuer_did}, which the whole community recognises. Issue a pairwise edge \
under a relationship DID (with `pop`), or declare `public`"
)),
)
.into());
}
if identifier_form == IdentifierForm::Pairwise {
let others = issuer_counterparties_besides(
&state.relationships_ks,
&state.relationships_by_did_ks,
&issuer_did,
&subject_did,
)
.await?;
if !others.is_empty() {
return Err(AppError::Validation(format!(
"relationship DID {issuer_did} already has an edge to {} other \
counterpart(y|ies) — a relationship DID must be unique to one \
counterparty. Mint a new one for this relationship, or issue \
under your membership DID to publish an attributed edge",
others.len()
))
.into());
}
}
let member_current = is_current_member(state, &signer_did).await?;
let issuer_current = is_current_member(state, &issuer_did).await?;
let subject_current = is_current_member(state, &subject_did).await?;
if identifier_form == IdentifierForm::Attributed
&& issuer_did == signer_did
&& !subject_current
&& get_acl_entry(&state.acl_ks, &subject_did).await?.is_none()
{
return Err(TaskError::declared(
PUBLISH_ERR_SUBJECT_NOT_MEMBER,
AppError::Validation(format!(
"subject DID {subject_did} is not a current community member"
)),
)
.into());
}
let policy_input = json!({
"vrc": vrc,
"authenticated_member": { "did": signer_did, "is_current": member_current },
"identifier_form": identifier_form.as_str(),
"issuer_scope": issuer_scope.as_str(),
"issuer": { "did": issuer_did, "is_current": issuer_current },
"subject": { "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(),
)
.into());
}
if let Some(existing) = find_by_hash(&state.relationships_ks, &vrc_digest_multibase).await? {
return Ok((
StatusCode::OK,
PublishResponse {
id: existing.id,
issuer_did: existing.issuer_did,
subject_did: existing.subject_did,
vrc_digest_multibase: existing.vrc_digest_multibase,
},
));
}
let id = Uuid::new_v4();
let rel = Relationship {
id,
issuer_did: issuer_did.clone(),
subject_did: subject_did.clone(),
vrc_jsonld: vrc.clone(),
vrc_digest_multibase: vrc_digest_multibase.clone(),
created_at: Utc::now(),
persona: None,
lifecycle: crate::relationships::LifecycleLog::default(),
};
store_relationship(
&state.relationships_ks,
&state.relationships_by_did_ks,
&rel,
)
.await?;
let superseded = crate::relationships::supersede_prior_edges(
&state.relationships_ks,
&state.relationships_by_did_ks,
&issuer_did,
&subject_did,
id,
&vrc_digest_multibase,
now,
)
.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(
&signer_did,
Some(&subject_did),
AuditEvent::VrcPublished(VrcPublishedData {
vrc_id: id.to_string(),
subject_did: Some(subject_did.clone()),
edge_type,
}),
)
.await?;
}
if let Some(writer) = state.audit_writer.as_ref() {
for displaced in &superseded {
writer
.write(
&signer_did,
Some(&subject_did),
AuditEvent::VrcSuperseded(VrcSupersededData {
vrc_id: displaced.to_string(),
superseded_by_digest_multibase: vrc_digest_multibase.clone(),
}),
)
.await?;
}
}
info!(
vrc_id = %id,
issuer = %issuer_did,
subject = %subject_did,
superseded = superseded.len(),
"VRC published"
);
Ok((
StatusCode::CREATED,
PublishResponse {
id,
issuer_did,
subject_did,
vrc_digest_multibase,
},
))
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
#[schema(as = RelationshipRevokeResponse)]
pub struct RevokeResponse {
pub id: String,
}
const REVOKE_AUTHORIZATION_TYPE: &str = "VrcRevokeAuthorization";
pub(crate) async fn verify_revoke_authorization(
pop: &JsonValue,
issuer_did: &str,
expected_document_id: &str,
expected_relationship_id: &str,
resolver: &DIDCacheClient,
) -> Result<(), String> {
let field = |name: &str| -> Result<String, String> {
pop.get(name)
.and_then(|v| v.as_str())
.map(str::to_string)
.ok_or_else(|| format!("authorization missing `{name}`"))
};
let ty = field("type")?;
if ty != REVOKE_AUTHORIZATION_TYPE {
return Err(format!(
"authorization `type` must be `{REVOKE_AUTHORIZATION_TYPE}`, got `{ty}`"
));
}
if field("documentId")? != expected_document_id {
return Err("authorization is bound to a different document".into());
}
if field("relationship")? != expected_relationship_id {
return Err("authorization is bound to a different edge".into());
}
verify_di_proof(pop, issuer_did, resolver).await
}
pub(crate) async fn revoke_authorized(
state: &AppState,
actor_did: &str,
rel: &Relationship,
revoked_by: &'static str,
) -> Result<RevokeResponse, AppError> {
let id = rel.id;
delete_relationship(&state.relationships_ks, &state.relationships_by_did_ks, id).await?;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
actor_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(RevokeResponse { id: id.to_string() })
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum EdgeLifecycleVerb {
Suspend,
Restore,
}
impl EdgeLifecycleVerb {
fn as_str(self) -> &'static str {
match self {
Self::Suspend => "suspend",
Self::Restore => "restore",
}
}
fn event(self, reason: Option<String>) -> crate::relationships::LifecycleEventKind {
match self {
Self::Suspend => crate::relationships::LifecycleEventKind::Suspended { reason },
Self::Restore => crate::relationships::LifecycleEventKind::Restored { reason },
}
}
fn audit(self, data: VrcLifecycleData) -> AuditEvent {
match self {
Self::Suspend => AuditEvent::VrcSuspended(data),
Self::Restore => AuditEvent::VrcRestored(data),
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct LifecycleResponse {
pub id: Uuid,
pub state: crate::relationships::InForce,
}
pub(crate) async fn record_lifecycle_as(
state: &AppState,
actor: &str,
capacity: &'static str,
rel: &Relationship,
verb: EdgeLifecycleVerb,
reason: Option<String>,
) -> Result<LifecycleResponse, AppError> {
let now = Utc::now();
let id = rel.id;
let updated = crate::relationships::record_lifecycle_event(
&state.relationships_ks,
&state.relationships_by_did_ks,
id,
verb.event(reason.clone()),
now,
)
.await?;
if let Some(writer) = state.audit_writer.as_ref() {
let event = verb.audit(VrcLifecycleData {
vrc_id: id.to_string(),
recorded_by: capacity.into(),
reason,
});
writer
.write(actor, Some(&updated.subject_did), event)
.await?;
}
info!(vrc_id = %id, capacity, verb = verb.as_str(), "VRC lifecycle event recorded");
Ok(LifecycleResponse {
id,
state: updated.in_force_at(now),
})
}
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())
})
}
fn check_vrc_shape(vrc: &JsonValue, now: chrono::DateTime<Utc>) -> Result<(), AppError> {
crate::credentials::ingress::require_dtg_type(
vrc,
dtg_credentials::DTGCredentialType::Relationship,
now,
"this endpoint publishes relationship edges",
)
}
fn vrc_issuer_scope(vrc: &JsonValue) -> Result<IssuerScope, AppError> {
vrc.get("issuerScope")
.and_then(JsonValue::as_str)
.ok_or_else(|| AppError::Validation("VRC carries no `issuerScope`".into()))?
.parse::<IssuerScope>()
.map_err(|e| AppError::Validation(format!("VRC `issuerScope`: {e}")))
}
async fn enforce_publish_rate_limit(state: &AppState, did: &str) -> Result<(), RateLimited> {
state
.publish_rate_limiter
.check_and_record(did, Utc::now())
.await
.map_err(|retry_after_secs| {
RateLimited::new(
RELATIONSHIPS_LIMITER,
retry_after_secs,
format!(
"a member may publish at most {} relationship credentials per {}s",
crate::relationships::rate_limit::MAX_PER_WINDOW,
crate::relationships::rate_limit::WINDOW_SECS,
),
)
})
}
#[derive(Debug)]
pub enum PublishError {
App(TaskError),
RateLimited(RateLimited),
}
impl From<AppError> for PublishError {
fn from(e: AppError) -> Self {
Self::App(e.into())
}
}
impl From<TaskError> for PublishError {
fn from(e: TaskError) -> Self {
Self::App(e)
}
}
impl From<RateLimited> for PublishError {
fn from(e: RateLimited) -> Self {
Self::RateLimited(e)
}
}
impl axum::response::IntoResponse for PublishError {
fn into_response(self) -> axum::response::Response {
match self {
Self::App(e) => e.into_response(),
Self::RateLimited(r) => r.into_response(),
}
}
}
const PUBLISH_AUTHORIZATION_TYPE: &str = "VrcPublishAuthorization";
async fn verify_publish_authorization(
pop: &JsonValue,
issuer_did: &str,
expected_document_id: &str,
expected_vrc_digest: &str,
resolver: &DIDCacheClient,
) -> Result<(), String> {
let field = |name: &str| -> Result<String, String> {
pop.get(name)
.and_then(|v| v.as_str())
.map(str::to_string)
.ok_or_else(|| format!("authorization missing `{name}`"))
};
let ty = field("type")?;
if ty != PUBLISH_AUTHORIZATION_TYPE {
return Err(format!(
"authorization `type` must be `{PUBLISH_AUTHORIZATION_TYPE}`, got `{ty}`"
));
}
if field("documentId")? != expected_document_id {
return Err("authorization is bound to a different document".into());
}
if field("vrcDigestMultibase")? != expected_vrc_digest {
return Err("authorization is bound to a different credential".into());
}
verify_di_proof(pop, issuer_did, resolver).await
}
async fn verify_di_proof(
doc: &JsonValue,
controller_did: &str,
resolver: &DIDCacheClient,
) -> Result<(), String> {
let vrc = doc;
let proof_value = vrc
.get("proof")
.ok_or_else(|| "document missing proof".to_string())?;
let proofs = crate::credentials::proof_set::proof_set(proof_value)
.map_err(|e| format!("parse proof: {e}"))?;
for proof in &proofs {
check_issuer_binding(&proof.verification_method, controller_did)
.map_err(|e| e.to_string())?;
}
let mut vrc_without_proof = vrc.clone();
if let Some(obj) = vrc_without_proof.as_object_mut() {
obj.remove("proof");
}
let vm_resolver = DidVmResolver::new(Some(resolver.clone()));
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,
&vrc_without_proof,
&vm_resolver,
vti_common::auth::ProofPurpose::AssertionMethod,
)
.await;
outcomes.push((did, r));
}
crate::credentials::proof_set::accept_all(&outcomes).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))
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct GraphNode {
pub did: String,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct GraphHalf {
pub id: String,
pub issuer_did: String,
pub subject_did: String,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub persona_did: Option<String>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct GraphEdge {
pub endpoints: Vec<String>,
pub halves: Vec<GraphHalf>,
pub complete: bool,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct RelationshipsGraph {
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
}
#[derive(Debug, Clone)]
pub struct MembershipHalf {
pub id: String,
pub issuer_did: String,
pub subject_did: String,
pub created_at: chrono::DateTime<Utc>,
pub in_force: bool,
}
pub fn membership_halves(
community_did: &str,
member: &crate::members::Member,
now: chrono::DateTime<Utc>,
) -> Vec<MembershipHalf> {
fn window_stands(body: Option<&JsonValue>, now: chrono::DateTime<Utc>) -> bool {
let Some(body) = body else {
return true;
};
match crate::credentials::ingress::validity_window(body, "MembershipCredential") {
Ok(window) => matches!(
window.state_at(now),
crate::credentials::lifecycle::InForce::Yes
),
Err(_) => false,
}
}
let mut halves = Vec::with_capacity(2);
if let Some(id) = &member.current_vmc_id {
halves.push(MembershipHalf {
id: id.clone(),
issuer_did: community_did.to_string(),
subject_did: member.did.clone(),
created_at: member.joined_at,
in_force: window_stands(member.current_vmc.as_ref(), now),
});
}
if let Some(id) = &member.member_vmc_id {
halves.push(MembershipHalf {
id: id.clone(),
issuer_did: member.did.clone(),
subject_did: community_did.to_string(),
created_at: member.member_vmc_received_at.unwrap_or(member.joined_at),
in_force: member.member_vmc_bound && window_stands(member.member_vmc.as_ref(), now),
});
}
halves
}
fn build_graph(
mut rels: Vec<Relationship>,
memberships: Vec<MembershipHalf>,
now: chrono::DateTime<Utc>,
) -> RelationshipsGraph {
rels.sort_by_key(|r| (r.created_at, r.id));
let mut nodes = std::collections::BTreeSet::new();
let mut pairs: std::collections::BTreeMap<(String, String), Vec<(GraphHalf, bool)>> =
std::collections::BTreeMap::new();
for r in rels {
nodes.insert(r.issuer_did.clone());
nodes.insert(r.subject_did.clone());
let key = if r.issuer_did <= r.subject_did {
(r.issuer_did.clone(), r.subject_did.clone())
} else {
(r.subject_did.clone(), r.issuer_did.clone())
};
let in_force = r.in_force_at(now).is_in_force();
let persona_did = r.persona.map(|p| p.persona_did);
pairs.entry(key).or_default().push((
GraphHalf {
id: r.id.to_string(),
issuer_did: r.issuer_did,
subject_did: r.subject_did,
created_at: r.created_at.to_rfc3339(),
persona_did,
},
in_force,
));
}
for half in memberships {
nodes.insert(half.issuer_did.clone());
nodes.insert(half.subject_did.clone());
let key = if half.issuer_did <= half.subject_did {
(half.issuer_did.clone(), half.subject_did.clone())
} else {
(half.subject_did.clone(), half.issuer_did.clone())
};
let in_force = half.in_force;
pairs.entry(key).or_default().push((
GraphHalf {
id: half.id,
issuer_did: half.issuer_did,
subject_did: half.subject_did,
created_at: half.created_at.to_rfc3339(),
persona_did: None,
},
in_force,
));
}
let edges = pairs
.into_iter()
.map(|((lo, hi), mut halves)| {
halves.sort_by(|a, b| {
a.0.created_at
.cmp(&b.0.created_at)
.then(a.0.id.cmp(&b.0.id))
});
let live = |from: &String, to: &String| {
halves
.iter()
.any(|(h, ok)| *ok && &h.issuer_did == from && &h.subject_did == to)
};
let complete = lo != hi && live(&lo, &hi) && live(&hi, &lo);
GraphEdge {
endpoints: vec![lo, hi],
halves: halves.into_iter().map(|(h, _)| h).collect(),
complete,
}
})
.collect();
RelationshipsGraph {
nodes: nodes.into_iter().map(|did| GraphNode { did }).collect(),
edges,
}
}
pub(crate) async fn graph(state: &AppState) -> Result<RelationshipsGraph, AppError> {
let now = Utc::now();
let rels = crate::relationships::list_all(&state.relationships_ks).await?;
let community_did = state.config.read().await.vtc_did.clone();
let memberships = match community_did {
Some(community_did) if !community_did.is_empty() => {
crate::members::list_members(&state.members_ks)
.await?
.iter()
.filter(|m| !m.is_removed())
.flat_map(|m| membership_halves(&community_did, m, now))
.collect()
}
_ => Vec::new(),
};
Ok(build_graph(rels, memberships, now))
}
#[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 accepts_what_the_catalog_mints_as_a_vrc_and_nothing_else() {
use dtg_credentials::DTGCredential;
use crate::test_support::dtg_json as body;
let now = Utc::now();
let vrc = DTGCredential::new_vrc(
"did:peer:2.zR1".into(),
IssuerScope::Pairwise,
"did:peer:2.zR2".into(),
now,
None,
);
check_vrc_shape(&body(&vrc), now).expect("a catalog-minted VRC must be publishable");
let vmc = DTGCredential::new_vmc(
"did:web:community.example".into(),
"did:key:zMember".into(),
now,
None,
false,
);
let err = check_vrc_shape(&body(&vmc), now).expect_err("a VMC must not publish as a VRC");
assert!(
format!("{err:?}").contains("relationship edges"),
"unexpected rejection: {err:?}"
);
}
#[test]
fn refuses_a_vrc_whose_validity_window_has_passed() {
use chrono::Duration;
use dtg_credentials::DTGCredential;
use crate::test_support::dtg_json as body;
let now = Utc::now();
let expired = DTGCredential::new_vrc(
"did:peer:2.zR1".into(),
IssuerScope::Pairwise,
"did:peer:2.zR2".into(),
now - Duration::days(30),
Some(now - Duration::days(1)),
);
let err = check_vrc_shape(&body(&expired), now)
.expect_err("an expired VRC must not enter the graph");
assert!(
format!("{err:?}").contains("expired at"),
"unexpected rejection: {err:?}"
);
check_vrc_shape(&body(&expired), now - Duration::days(2))
.expect("the same VRC inside its window is publishable");
}
const A: &str = "did:key:zAlice";
const B: &str = "did:key:zBob";
const C: &str = "did:key:zCarol";
fn row(issuer: &str, subject: &str, minute: u32) -> Relationship {
use chrono::TimeZone;
let valid_from = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let vrc = dtg_credentials::DTGCredential::new_vrc(
issuer.to_string(),
IssuerScope::Public,
subject.to_string(),
valid_from,
None,
);
let vrc_jsonld = serde_json::to_value(vrc.credential()).expect("VRC serialises");
let vrc_digest_multibase =
crate::credentials::ingress::digest_multibase(&vrc_jsonld).unwrap();
Relationship {
id: Uuid::new_v4(),
issuer_did: issuer.into(),
subject_did: subject.into(),
vrc_jsonld,
vrc_digest_multibase,
created_at: Utc.with_ymd_and_hms(2026, 1, 1, 0, minute, 0).unwrap(),
persona: None,
lifecycle: crate::relationships::LifecycleLog::default(),
}
}
fn at(day: u32) -> chrono::DateTime<Utc> {
use chrono::TimeZone;
Utc.with_ymd_and_hms(2026, 2, day, 0, 0, 0).unwrap()
}
fn member_row(did: &str, ack: Option<bool>) -> crate::members::Member {
use chrono::TimeZone;
let mut m = crate::members::Member::fresh(did);
m.joined_at = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let grant = dtg_credentials::DTGCredential::new_vmc(
COMMUNITY.to_string(),
did.to_string(),
m.joined_at,
None,
false,
)
.with_id("urn:uuid:grant-1");
let grant_json = crate::test_support::dtg_json(&grant);
let role_vac = serde_json::json!({ "id": "urn:uuid:vac-1" });
m.record_issued_credentials(grant_json.clone(), role_vac);
if let Some(bound) = ack {
let ack = dtg_credentials::DTGCredential::new_member_vmc_for(
&grant_json,
did,
IssuerScope::Directed,
m.joined_at,
None,
)
.expect("acknowledgement builds")
.with_id("urn:uuid:ack-1");
let ack_json = serde_json::to_value(ack.credential()).expect("ack serialises");
m.record_member_vmc("urn:uuid:ack-1", ack_json, bound);
m.member_vmc_received_at = Some(Utc.with_ymd_and_hms(2026, 1, 1, 0, 5, 0).unwrap());
}
m
}
const COMMUNITY: &str = "did:web:community.example";
#[test]
fn an_acknowledged_membership_is_one_complete_edge() {
let member = member_row(A, Some(true));
let g = build_graph(vec![], membership_halves(COMMUNITY, &member, at(1)), at(1));
assert_eq!(g.edges.len(), 1, "grant + acknowledgement are ONE edge");
assert_eq!(
g.edges[0].endpoints,
{
let mut e = vec![COMMUNITY.to_string(), A.to_string()];
e.sort();
e
},
"endpoints are DID-sorted, as for any edge"
);
assert_eq!(g.edges[0].halves.len(), 2);
assert!(g.edges[0].complete);
assert_eq!(g.nodes.len(), 2, "the community is a node in its own graph");
}
#[test]
fn an_unacknowledged_membership_is_a_half_edge() {
let member = member_row(A, None);
let g = build_graph(vec![], membership_halves(COMMUNITY, &member, at(1)), at(1));
assert_eq!(g.edges.len(), 1);
assert_eq!(g.edges[0].halves.len(), 1);
assert!(!g.edges[0].complete);
assert_eq!(g.edges[0].halves[0].issuer_did, COMMUNITY);
}
#[test]
fn an_unbound_acknowledgement_does_not_complete_the_edge() {
let member = member_row(A, Some(false));
let g = build_graph(vec![], membership_halves(COMMUNITY, &member, at(1)), at(1));
assert_eq!(g.edges.len(), 1);
assert_eq!(
g.edges[0].halves.len(),
2,
"both halves are listed; a stored credential is not hidden"
);
assert!(
!g.edges[0].complete,
"an unverified digest is not the member's consent to THIS membership"
);
}
#[test]
fn membership_and_relationship_edges_coexist() {
let member = member_row(A, Some(true));
let g = build_graph(
vec![row(A, B, 0), row(B, A, 1)],
membership_halves(COMMUNITY, &member, at(1)),
at(1),
);
assert_eq!(g.edges.len(), 2, "A—B and A—community are separate edges");
assert!(g.edges.iter().all(|e| e.complete));
assert_eq!(g.nodes.len(), 3, "A, B, and the community");
let membership = g
.edges
.iter()
.find(|e| e.endpoints.contains(&COMMUNITY.to_string()))
.expect("the membership edge");
assert_eq!(membership.halves.len(), 2);
}
#[test]
fn an_expired_grant_does_not_complete_the_edge() {
use chrono::TimeZone;
let mut member = member_row(A, Some(true));
if let Some(grant) = member.current_vmc.as_mut() {
grant["validUntil"] = serde_json::Value::String(
Utc.with_ymd_and_hms(2026, 1, 2, 0, 0, 0)
.unwrap()
.to_rfc3339(),
);
}
let g = build_graph(vec![], membership_halves(COMMUNITY, &member, at(9)), at(9));
assert_eq!(g.edges[0].halves.len(), 2, "both are still listed");
assert!(!g.edges[0].complete);
}
#[test]
fn a_pre_digest_row_renders_as_an_incomplete_edge() {
let mut member = crate::members::Member::fresh(A);
member.current_vmc_id = Some("urn:uuid:legacy-grant".into());
member.member_vmc_id = Some("urn:uuid:legacy-ack".into());
let g = build_graph(vec![], membership_halves(COMMUNITY, &member, at(1)), at(1));
assert_eq!(g.edges[0].halves.len(), 2);
assert!(!g.edges[0].complete);
}
#[test]
fn a_reciprocated_pair_is_one_complete_edge() {
let g = build_graph(vec![row(A, B, 0), row(B, A, 1)], vec![], at(1));
assert_eq!(g.edges.len(), 1, "two VRCs one each way are ONE edge");
assert_eq!(g.edges[0].endpoints, vec![A.to_string(), B.to_string()]);
assert_eq!(g.edges[0].halves.len(), 2);
assert!(g.edges[0].complete);
assert_eq!(g.nodes.len(), 2);
}
#[test]
fn an_unreciprocated_vrc_is_a_half_edge() {
let g = build_graph(vec![row(A, B, 0)], vec![], at(1));
assert_eq!(g.edges.len(), 1);
assert_eq!(g.edges[0].halves.len(), 1);
assert!(
!g.edges[0].complete,
"B has never acknowledged this edge, so it is not complete"
);
assert_eq!(g.nodes.len(), 2);
}
#[test]
fn repeated_vrcs_in_one_direction_do_not_complete_an_edge() {
let g = build_graph(
vec![row(A, B, 0), row(A, B, 1), row(A, B, 2)],
vec![],
at(1),
);
assert_eq!(g.edges.len(), 1);
assert_eq!(g.edges[0].halves.len(), 3);
assert!(!g.edges[0].complete);
}
#[test]
fn pair_identity_does_not_depend_on_publish_order() {
let forward = build_graph(vec![row(A, B, 0), row(B, A, 1)], vec![], at(1));
let reverse = build_graph(vec![row(B, A, 0), row(A, B, 1)], vec![], at(1));
assert_eq!(forward.edges[0].endpoints, reverse.edges[0].endpoints);
assert!(forward.edges[0].complete && reverse.edges[0].complete);
}
#[test]
fn a_self_issued_vrc_is_never_complete() {
let g = build_graph(vec![row(A, A, 0)], vec![], at(1));
assert_eq!(g.edges.len(), 1);
assert_eq!(g.edges[0].endpoints, vec![A.to_string(), A.to_string()]);
assert!(!g.edges[0].complete);
}
#[test]
fn distinct_pairs_stay_distinct_and_ordering_is_stable() {
let rows = vec![row(B, C, 2), row(A, B, 0), row(B, A, 1)];
let g = build_graph(rows, vec![], at(1));
assert_eq!(g.edges.len(), 2);
assert_eq!(g.edges[0].endpoints, vec![A.to_string(), B.to_string()]);
assert_eq!(g.edges[1].endpoints, vec![B.to_string(), C.to_string()]);
assert!(g.edges[0].complete);
assert!(!g.edges[1].complete);
assert_eq!(g.edges[0].halves[0].issuer_did, A);
assert_eq!(g.edges[0].halves[1].issuer_did, B);
}
fn row_expiring(issuer: &str, subject: &str, minute: u32, until_day: u32) -> Relationship {
use chrono::TimeZone;
let mut rel = row(issuer, subject, minute);
let until = Utc.with_ymd_and_hms(2026, 2, until_day, 0, 0, 0).unwrap();
rel.vrc_jsonld["validUntil"] = json!(until.to_rfc3339());
rel
}
#[test]
fn an_expired_reciprocal_no_longer_completes_an_edge() {
let rows = || vec![row(A, B, 0), row_expiring(B, A, 1, 10)];
let live = build_graph(rows(), vec![], at(9));
assert!(
live.edges[0].complete,
"both halves in date: this is a mutual relationship"
);
let lapsed = build_graph(rows(), vec![], at(11));
assert!(
!lapsed.edges[0].complete,
"B's half has expired, so B no longer consents to anything"
);
assert_eq!(
lapsed.edges[0].halves.len(),
2,
"the expired half is still shown — it was published, and hiding it \
would make a lapsed edge look like one that never existed"
);
}
#[test]
fn a_suspended_half_does_not_complete_an_edge() {
let mut suspended = row(B, A, 1);
suspended
.lifecycle
.record(
crate::relationships::LifecycleEventKind::Suspended { reason: None },
at(5),
)
.expect("first event on a fresh log");
assert!(
!build_graph(vec![row(A, B, 0), suspended.clone()], vec![], at(6)).edges[0].complete,
"a suspended half is not consent"
);
assert!(
build_graph(vec![row(A, B, 0), suspended], vec![], at(4)).edges[0].complete,
"the suspension must not apply before it was recorded"
);
}
#[test]
fn a_restored_half_completes_the_edge_again() {
let mut half = row(B, A, 1);
let kinds = crate::relationships::LifecycleEventKind::Suspended { reason: None };
half.lifecycle.record(kinds, at(5)).unwrap();
half.lifecycle
.record(
crate::relationships::LifecycleEventKind::Restored { reason: None },
at(7),
)
.unwrap();
assert!(!build_graph(vec![row(A, B, 0), half.clone()], vec![], at(6)).edges[0].complete);
assert!(build_graph(vec![row(A, B, 0), half], vec![], at(8)).edges[0].complete);
}
#[test]
fn a_superseded_half_is_displaced_by_its_replacement() {
let mut old_half = row(A, B, 0);
old_half
.lifecycle
.record(
crate::relationships::LifecycleEventKind::Superseded {
by: "zReplacement".into(),
},
at(5),
)
.unwrap();
let replacement = row(A, B, 2);
let g = build_graph(
vec![old_half.clone(), replacement, row(B, A, 1)],
vec![],
at(6),
);
assert_eq!(g.edges[0].halves.len(), 3);
assert!(g.edges[0].complete, "the replacement carries A's direction");
let without = build_graph(vec![old_half, row(B, A, 1)], vec![], at(6));
assert!(!without.edges[0].complete);
}
#[test]
fn an_unreadable_window_is_not_in_force() {
let mut broken = row(B, A, 1);
broken.vrc_jsonld["validUntil"] = json!("whenever");
assert!(matches!(
broken.in_force_at(at(1)),
crate::relationships::InForce::Indeterminate { .. }
));
assert!(!build_graph(vec![row(A, B, 0), broken], vec![], at(1)).edges[0].complete);
}
}