use serde::{Deserialize, Serialize};
use serde_json::Value;
use vta_sdk::client::VtaClient;
use crate::errors::OpenVTCError;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DisclosedClaim {
pub claim_type: String,
pub rung: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DisclosureRow {
pub disclosure_id: String,
pub context_id: String,
pub verifier_did: String,
pub persona_did: String,
pub claims: Vec<DisclosedClaim>,
pub purpose: Option<String>,
pub durable_credential_id: Option<String>,
pub disclosed_at: String,
}
impl DisclosureRow {
fn from_wire(value: &Value) -> Self {
let string_at = |key: &str| {
value
.get(key)
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_default()
};
Self {
disclosure_id: string_at("disclosureId"),
context_id: string_at("contextId"),
verifier_did: string_at("verifierDid"),
persona_did: string_at("personaDid"),
claims: value
.get("claims")
.and_then(Value::as_array)
.map(|claims| {
claims
.iter()
.map(|claim| DisclosedClaim {
claim_type: claim
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
rung: claim
.get("rung")
.and_then(Value::as_str)
.unwrap_or("whole")
.to_string(),
})
.collect()
})
.unwrap_or_default(),
purpose: value
.get("purpose")
.and_then(Value::as_str)
.map(str::to_string),
durable_credential_id: value
.get("durableCredentialId")
.and_then(Value::as_str)
.map(str::to_string),
disclosed_at: string_at("disclosedAt"),
}
}
#[must_use]
pub fn describe_claims(&self) -> String {
if self.claims.is_empty() {
return "no attributes recorded".to_string();
}
self.claims
.iter()
.map(|c| format!("{} ({})", c.claim_type, rung_label(&c.rung)))
.collect::<Vec<_>>()
.join(", ")
}
}
#[must_use]
fn rung_label(rung: &str) -> &str {
match rung {
"whole" => "whole",
"selectiveDisclosure" => "partly",
"derived" => "derived",
"predicate" => "yes/no only",
other => other,
}
}
pub const RCARD_RENDERER: &str = "rcard";
#[derive(Clone, Debug, PartialEq)]
pub struct ReleasedClaim {
pub claim_type: String,
pub value: Option<Value>,
pub provenance: Option<String>,
pub stale: bool,
}
impl ReleasedClaim {
fn from_wire(value: &Value) -> Self {
Self {
claim_type: value
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
value: value.get("value").cloned().filter(|v| !v.is_null()),
provenance: match value.get("provenance") {
Some(Value::String(kind)) => Some(kind.clone()),
Some(other) => other
.get("kind")
.and_then(Value::as_str)
.map(str::to_string),
None => None,
},
stale: value.get("stale").and_then(Value::as_bool).unwrap_or(false),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Preview {
pub preview_id: String,
pub claims: Vec<ReleasedClaim>,
pub expires_at: String,
}
pub async fn preview(
client: &VtaClient,
context_id: &str,
persona_did: &str,
verifier_did: &str,
requested_claims: Vec<String>,
purpose: &str,
) -> Result<Preview, OpenVTCError> {
let value = client
.persona_disclosure_preview(
context_id,
persona_did,
verifier_did,
requested_claims,
Some(purpose),
Some(RCARD_RENDERER),
)
.await
.map_err(|e| OpenVTCError::Vta(format!("persona disclosure preview failed: {e}")))?;
let preview_id = value
.get("previewId")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
.ok_or_else(|| OpenVTCError::Vta("the disclosure preview carried no previewId".into()))?
.to_string();
Ok(Preview {
preview_id,
claims: value
.get("claims")
.and_then(Value::as_array)
.map(|claims| claims.iter().map(ReleasedClaim::from_wire).collect())
.unwrap_or_default(),
expires_at: value
.get("expiresAt")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
})
}
#[derive(Clone, Debug, PartialEq)]
pub struct Presented {
pub disclosure_id: String,
pub claims: Vec<ReleasedClaim>,
}
#[derive(Debug, thiserror::Error)]
pub enum PresentError {
#[error("a claim in this disclosure needs your approval first")]
StepUpRequired,
#[error("{0}")]
Failed(String),
}
pub async fn present(
client: &VtaClient,
context_id: &str,
preview_id: &str,
challenge: Option<&str>,
) -> Result<Presented, PresentError> {
let value = client
.persona_disclosure_present(context_id, preview_id, challenge, None)
.await
.map_err(|e| {
let message = e.to_string();
if message.contains("stepUpRequired") {
PresentError::StepUpRequired
} else {
PresentError::Failed(format!("persona disclosure present failed: {message}"))
}
})?;
let claims = value
.get("artifact")
.map(artifact_claims)
.transpose()
.map_err(PresentError::Failed)?
.ok_or_else(|| PresentError::Failed("the disclosure carried no artifact".into()))?;
Ok(Presented {
disclosure_id: value
.get("disclosureId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
claims,
})
}
fn artifact_claims(artifact: &Value) -> Result<Vec<ReleasedClaim>, String> {
let parsed;
let document = match artifact {
Value::String(text) => {
parsed = serde_json::from_str::<Value>(text)
.map_err(|e| format!("the disclosure artifact is not JSON: {e}"))?;
&parsed
}
other => other,
};
let claims = document
.get("claims")
.and_then(Value::as_object)
.ok_or("the disclosure artifact carries no claims")?;
let mut numbered: Vec<(&String, &Value)> = claims.iter().collect();
numbered.sort_by(|a, b| a.0.cmp(b.0));
Ok(numbered
.into_iter()
.map(|(_, claim)| ReleasedClaim::from_wire(claim))
.collect())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Renderer {
pub id: String,
pub description: Option<String>,
pub drops: Vec<String>,
pub carries_predicates: bool,
pub canonical: bool,
pub used_here: bool,
}
pub async fn renderers(client: &VtaClient) -> Result<Vec<Renderer>, OpenVTCError> {
let value = client
.persona_renderers_list()
.await
.map_err(|e| OpenVTCError::Vta(format!("persona renderers list failed: {e}")))?;
let mut rows: Vec<Renderer> = value
.get("renderers")
.and_then(Value::as_array)
.map(|rows| {
rows.iter()
.map(|r| {
let id = r
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
Renderer {
used_here: id == RCARD_RENDERER,
id,
description: r
.get("description")
.and_then(Value::as_str)
.map(str::to_string),
drops: r
.get("drops")
.and_then(Value::as_array)
.map(|d| {
d.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default(),
carries_predicates: r
.get("canCarryPredicates")
.and_then(Value::as_bool)
.unwrap_or(false),
canonical: r.get("canonical").and_then(Value::as_bool).unwrap_or(false),
}
})
.collect()
})
.unwrap_or_default();
rows.sort_by(|a, b| {
b.used_here
.cmp(&a.used_here)
.then_with(|| b.canonical.cmp(&a.canonical))
.then_with(|| a.id.cmp(&b.id))
});
Ok(rows)
}
pub async fn history(
client: &VtaClient,
limit: std::num::NonZeroU64,
) -> Result<Vec<DisclosureRow>, OpenVTCError> {
let value = client
.persona_disclosure_history(None, None, None, None, Some(limit), None)
.await
.map_err(|e| OpenVTCError::Vta(format!("persona disclosure history failed: {e}")))?;
let mut rows: Vec<DisclosureRow> = value
.get("disclosures")
.and_then(Value::as_array)
.map(|rows| rows.iter().map(DisclosureRow::from_wire).collect())
.unwrap_or_default();
rows.sort_by(|a, b| b.disclosed_at.cmp(&a.disclosed_at));
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fact_carries_its_rung_into_the_row() {
let row = DisclosureRow::from_wire(&serde_json::json!({
"disclosureId": "01D",
"contextId": "ctx",
"verifierDid": "did:webvh:example.com:acme",
"claims": [
{ "type": "email.work", "rung": "whole" },
{ "type": "age.over18", "rung": "predicate" },
],
"disclosedAt": "2026-09-06T10:00:00Z",
}));
assert_eq!(
row.describe_claims(),
"email.work (whole), age.over18 (yes/no only)"
);
}
#[test]
fn an_unknown_rung_is_shown_verbatim() {
let row = DisclosureRow::from_wire(&serde_json::json!({
"claims": [{ "type": "email.work", "rung": "someFutureRung" }],
}));
assert_eq!(row.describe_claims(), "email.work (someFutureRung)");
}
#[test]
fn an_unrecorded_rung_reads_as_the_least_private_one() {
let row = DisclosureRow::from_wire(&serde_json::json!({
"claims": [{ "type": "email.work" }],
}));
assert_eq!(row.claims[0].rung, "whole");
}
#[test]
fn an_artifact_yields_its_claims_in_order() {
let artifact = serde_json::json!({
"type": ["VerifiableDataStructure", "RelationshipCard"],
"claims": {
"0001": { "type": "age.over18", "predicate": { "op": "gte" },
"provenance": { "kind": "credentialBacked", "credentialId": "c1",
"claimPath": "/age" } },
"0000": { "type": "name.legal", "value": "Alice Example",
"provenance": { "kind": "selfAsserted" } },
},
"unsigned": true,
})
.to_string();
let claims = artifact_claims(&Value::String(artifact)).unwrap();
assert_eq!(claims[0].claim_type, "name.legal");
assert_eq!(claims[0].value, Some(serde_json::json!("Alice Example")));
assert_eq!(claims[0].provenance.as_deref(), Some("selfAsserted"));
assert_eq!(claims[1].value, None);
assert_eq!(claims[1].provenance.as_deref(), Some("credentialBacked"));
}
#[test]
fn a_preview_claim_reads_its_provenance_kind() {
let claim = ReleasedClaim::from_wire(&serde_json::json!({
"type": "name.legal", "value": "Alice Example",
"provenance": "selfAsserted", "rung": "whole", "newToThisVerifier": true,
}));
assert_eq!(claim.provenance.as_deref(), Some("selfAsserted"));
assert!(!claim.stale);
}
#[test]
fn an_artifact_without_claims_is_refused() {
assert!(artifact_claims(&Value::String("{}".into())).is_err());
assert!(artifact_claims(&Value::String("not json".into())).is_err());
}
#[test]
fn a_factless_record_says_so() {
let row = DisclosureRow::default();
assert_eq!(row.describe_claims(), "no attributes recorded");
}
}