use serde_json::Value;
#[cfg(feature = "webvh")]
use vti_common::auth::extractor::AuthClaims;
#[cfg(feature = "webvh")]
use vti_common::error::AppError;
#[cfg(feature = "webvh")]
use crate::server::AppState;
const ANCHOR_SERVICE_TYPE: &str = "RoomEpochAnchor";
const ANCHOR_FRAGMENT: &str = "#epoch-anchor";
#[cfg(feature = "webvh")]
#[derive(Debug, Clone)]
pub struct Anchor {
pub epoch: u64,
pub epoch_authenticator: Vec<u8>,
pub head_version: u64,
pub data_commitment: Option<String>,
pub record_count: Option<u64>,
}
#[cfg(feature = "webvh")]
#[derive(Debug, Clone)]
pub struct Published {
pub anchored: Value,
pub version_id: String,
}
#[cfg(feature = "webvh")]
fn service_entry(room_id: &str, anchor: &Anchor) -> Value {
let mut endpoint = serde_json::Map::new();
endpoint.insert("epoch".into(), serde_json::json!(anchor.epoch));
endpoint.insert(
"epochAuthenticator".into(),
serde_json::json!(vti_rooms::merkle::to_multibase(&sha2_digest(
&anchor.epoch_authenticator
))),
);
endpoint.insert("headVersion".into(), serde_json::json!(anchor.head_version));
if let (Some(root), Some(count)) = (&anchor.data_commitment, anchor.record_count) {
endpoint.insert("dataCommitment".into(), serde_json::json!(root));
endpoint.insert("recordCount".into(), serde_json::json!(count));
}
serde_json::json!({
"id": format!("{room_id}{ANCHOR_FRAGMENT}"),
"type": ANCHOR_SERVICE_TYPE,
"serviceEndpoint": Value::Object(endpoint),
})
}
#[cfg(feature = "webvh")]
fn sha2_digest(bytes: &[u8]) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(bytes);
h.finalize().into()
}
#[cfg(any(feature = "webvh", test))]
fn is_witnessed(log: Option<&str>) -> bool {
let Some(text) = log else {
return false;
};
text.lines().any(|line| {
let Ok(entry) = serde_json::from_str::<Value>(line) else {
return false;
};
entry
.get("parameters")
.and_then(|p| p.get("witness"))
.and_then(|w| w.get("witnesses"))
.and_then(|w| w.as_array())
.is_some_and(|w| !w.is_empty())
})
}
#[cfg(feature = "webvh")]
pub async fn publish(
state: &AppState,
auth: &AuthClaims,
room_id: &str,
_signing_key_id: &str,
anchor: Anchor,
) -> Result<Published, AppError> {
let current = crate::operations::did_webvh::get_did_webvh(
&state.webvh_ks,
auth,
room_id,
"trust-task",
true,
)
.await?;
if !is_witnessed(current.log.as_deref()) {
return Err(AppError::Validation(format!(
"room `{room_id}` is configured with no witnesses, so an entry in its log would be \
its controller's own word. An anchor nobody co-signed looks like an anchor and \
carries none of the property, which is worse than not having one — a member \
checking it would believe they had checked something. Configure witnesses for \
this room's DID; retrying will not help."
)));
}
let resolver = state
.did_resolver
.as_ref()
.ok_or_else(|| AppError::Validation("this agent has no DID resolver configured".into()))?;
let resolved = resolver
.resolve(room_id)
.await
.map_err(|e| AppError::Validation(format!("room `{room_id}` does not resolve: {e}")))?;
let mut document = serde_json::to_value(&resolved.doc)
.map_err(|e| AppError::Internal(format!("serialise the room's document: {e}")))?;
let entry = service_entry(room_id, &anchor);
let anchored = entry["serviceEndpoint"].clone();
match document.get_mut("service").and_then(Value::as_array_mut) {
Some(list) => {
list.retain(|s| s.get("type").and_then(Value::as_str) != Some(ANCHOR_SERVICE_TYPE));
list.push(entry);
}
None => document["service"] = Value::Array(vec![entry]),
}
let deps = crate::operations::did_webvh::WebvhDeps::from_app_state(state, resolver);
let vta_did = state.config.read().await.vta_did.clone();
let result = crate::operations::did_webvh::update_did_webvh(
&deps,
auth,
room_id,
crate::operations::did_webvh::UpdateDidWebvhOptions {
document: Some(document),
label: Some(format!("anchor {room_id}")),
..Default::default()
},
vta_did.as_deref(),
"trust-task",
)
.await
.map_err(AppError::from)?;
let version_id = serde_json::to_value(&result)
.ok()
.and_then(|v| {
v.get("versionId")
.or_else(|| v.get("version_id"))
.and_then(Value::as_str)
.map(str::to_string)
})
.unwrap_or_default();
Ok(Published {
anchored,
version_id,
})
}
#[cfg(all(test, feature = "webvh"))]
mod tests {
use super::*;
#[test]
fn an_unwitnessed_log_is_refused_rather_than_written_to() {
assert!(!is_witnessed(None));
assert!(!is_witnessed(Some("")));
assert!(!is_witnessed(Some(r#"{"parameters":{}}"#)));
assert!(!is_witnessed(Some(
r#"{"parameters":{"witness":{"witnesses":[]}}}"#
)));
assert!(is_witnessed(Some(
r#"{"parameters":{"witness":{"witnesses":[{"id":"did:key:zW"}]}}}"#
)));
assert!(is_witnessed(Some(
"{\"parameters\":{\"witness\":{\"witnesses\":[{\"id\":\"did:key:zW\"}]}}}\n{\"parameters\":{}}"
)));
}
#[test]
fn a_commitment_is_published_with_its_count_or_not_at_all() {
let with = service_entry(
"did:webvh:example.com:rooms:r",
&Anchor {
epoch: 7,
epoch_authenticator: vec![1, 2, 3],
head_version: 412,
data_commitment: Some("zQm".into()),
record_count: Some(118),
},
);
assert!(with["serviceEndpoint"]["dataCommitment"].is_string());
assert_eq!(with["serviceEndpoint"]["recordCount"], 118);
let half = service_entry(
"did:webvh:example.com:rooms:r",
&Anchor {
epoch: 7,
epoch_authenticator: vec![1, 2, 3],
head_version: 412,
data_commitment: Some("zQm".into()),
record_count: None,
},
);
assert!(half["serviceEndpoint"].get("dataCommitment").is_none());
assert!(half["serviceEndpoint"].get("recordCount").is_none());
}
#[test]
fn the_authenticator_is_published_as_a_digest_multibase() {
let entry = service_entry(
"did:webvh:example.com:rooms:r",
&Anchor {
epoch: 7,
epoch_authenticator: vec![9; 32],
head_version: 1,
data_commitment: None,
record_count: None,
},
);
let value = entry["serviceEndpoint"]["epochAuthenticator"]
.as_str()
.expect("a string");
assert!(value.starts_with('z'), "base58btc multibase: {value}");
assert!(vti_rooms::merkle::from_multibase(value).is_ok());
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnchorVerdict {
Agrees,
Ahead,
Behind,
Conflict,
None,
NotChecked,
}
impl AnchorVerdict {
#[must_use]
pub fn as_wire(self) -> &'static str {
match self {
Self::Agrees => "agrees",
Self::Ahead => "ahead",
Self::Behind => "behind",
Self::Conflict => "conflict",
Self::None => "none",
Self::NotChecked => "notChecked",
}
}
}
fn anchor_of(document: &Value) -> Option<&Value> {
document
.get("service")?
.as_array()?
.iter()
.find(|s| s.get("type").and_then(Value::as_str) == Some(ANCHOR_SERVICE_TYPE))?
.get("serviceEndpoint")
}
#[must_use]
pub fn compare_to_anchor(
document: &Value,
served_version: u64,
served_root: Option<&str>,
) -> AnchorVerdict {
let Some(anchor) = anchor_of(document) else {
return AnchorVerdict::None;
};
let Some(anchored_version) = anchor.get("headVersion").and_then(Value::as_u64) else {
return AnchorVerdict::None;
};
match served_version.cmp(&anchored_version) {
std::cmp::Ordering::Greater => AnchorVerdict::Ahead,
std::cmp::Ordering::Less => AnchorVerdict::Behind,
std::cmp::Ordering::Equal => {
let anchored_root = anchor.get("dataCommitment").and_then(Value::as_str);
match (anchored_root, served_root) {
(Some(a), Some(s)) if a != s => AnchorVerdict::Conflict,
(Some(_), Some(_)) => AnchorVerdict::Agrees,
_ => AnchorVerdict::Ahead,
}
}
}
}
#[cfg(test)]
mod comparison_tests {
use super::*;
fn room_with(anchor: Value) -> Value {
serde_json::json!({
"id": "did:webvh:example.com:rooms:r",
"service": [
{ "id": "#tsp", "type": "TSPTransport", "serviceEndpoint": "https://x" },
{ "id": "#epoch-anchor", "type": ANCHOR_SERVICE_TYPE, "serviceEndpoint": anchor },
]
})
}
const A: &str = "zQmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR";
const B: &str = "zQmXo1sV5aJ7bT2kQdF9wRnPzYcH4uMgLtEjV6NrBqWsDpK";
#[test]
fn a_host_serving_older_than_the_anchor_is_caught_by_a_first_time_reader() {
let doc = room_with(serde_json::json!({ "headVersion": 412, "dataCommitment": A }));
assert_eq!(compare_to_anchor(&doc, 400, Some(A)), AnchorVerdict::Behind);
}
#[test]
fn the_same_version_with_a_different_root_is_a_conflict() {
let doc = room_with(serde_json::json!({ "headVersion": 412, "dataCommitment": A }));
assert_eq!(
compare_to_anchor(&doc, 412, Some(B)),
AnchorVerdict::Conflict
);
assert_eq!(compare_to_anchor(&doc, 412, Some(A)), AnchorVerdict::Agrees);
}
#[test]
fn a_room_past_its_anchor_is_ahead_and_that_is_normal() {
let doc = room_with(serde_json::json!({ "headVersion": 412, "dataCommitment": A }));
assert_eq!(compare_to_anchor(&doc, 500, Some(B)), AnchorVerdict::Ahead);
}
#[test]
fn a_version_only_anchor_never_reports_agreement_about_a_root() {
let doc = room_with(serde_json::json!({ "headVersion": 412 }));
assert_eq!(compare_to_anchor(&doc, 412, Some(A)), AnchorVerdict::Ahead);
}
#[test]
fn a_room_with_no_anchor_is_none_rather_than_a_finding() {
let bare = serde_json::json!({ "id": "did:webvh:example.com:rooms:r" });
assert_eq!(compare_to_anchor(&bare, 412, Some(A)), AnchorVerdict::None);
let other = serde_json::json!({
"service": [{ "id": "#tsp", "type": "TSPTransport", "serviceEndpoint": "https://x" }]
});
assert_eq!(compare_to_anchor(&other, 412, Some(A)), AnchorVerdict::None);
}
}