use serde_json::Value;
use super::{MethodError, detect_method};
#[derive(Debug, Clone)]
pub struct Publication {
pub method: &'static str,
pub did_id: Option<String>,
pub document: Option<Value>,
pub content: Vec<u8>,
}
impl Publication {
pub fn services(&self) -> Option<Vec<String>> {
self.document
.as_ref()
.map(crate::did::service_types_from_doc)
}
pub fn agent_names(&self, domain: &str) -> Vec<String> {
self.document
.as_ref()
.map(|doc| crate::did_ops::agent_names_from_document(doc, domain))
.unwrap_or_default()
}
}
#[cfg_attr(
any(not(feature = "method-webs"), not(feature = "method-webvh")),
allow(unused_variables)
)]
pub fn verify_publication(
domain: &str,
mnemonic: &str,
content: &[u8],
existing: Option<&[u8]>,
existing_witness: Option<&[u8]>,
) -> Result<Publication, MethodError> {
let method = detect_method(content).ok_or_else(|| {
MethodError::Malformed(
"content matches no DID method enabled on this deployment — expected a \
did:webvh log, a did:web document, or a did:webs keri.cesr stream"
.into(),
)
})?;
match method {
#[cfg(feature = "method-webvh")]
"webvh" => verify_webvh(content, existing, existing_witness),
#[cfg(feature = "method-web")]
"web" => verify_web(content),
#[cfg(feature = "method-webs")]
"webs" => verify_webs(domain, mnemonic, content, existing),
other => Err(MethodError::Malformed(format!(
"no verifier for method '{other}'"
))),
}
}
#[cfg(feature = "method-webvh")]
fn verify_webvh(
content: &[u8],
existing: Option<&[u8]>,
existing_witness: Option<&[u8]>,
) -> Result<Publication, MethodError> {
let text = std::str::from_utf8(content)
.map_err(|e| MethodError::Validation(format!("did.jsonl is not valid UTF-8: {e}")))?;
let previously_published = existing
.map(|bytes| String::from_utf8_lossy(bytes))
.map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
.unwrap_or(0);
let existing_witness_text = existing_witness.map(|w| String::from_utf8_lossy(w));
crate::did_ops::verify_did_log_for_publish(
text,
existing_witness_text.as_deref(),
previously_published,
)
.map_err(MethodError::Validation)?;
let document = text
.lines()
.rfind(|l| !l.trim().is_empty())
.and_then(|line| serde_json::from_str::<Value>(line).ok())
.and_then(|v| v.get("state").cloned());
Ok(Publication {
method: "webvh",
did_id: crate::did_ops::extract_did_id(text),
document,
content: content.to_vec(),
})
}
#[cfg(feature = "method-web")]
fn verify_web(content: &[u8]) -> Result<Publication, MethodError> {
use super::DidMethod;
super::web::Web.validate(content)?;
let document: Value = serde_json::from_slice(content)
.map_err(|e| MethodError::Validation(format!("did.json is not valid JSON: {e}")))?;
let did_id = document
.get("id")
.and_then(|i| i.as_str())
.map(str::to_string);
Ok(Publication {
method: "web",
did_id,
document: Some(document),
content: content.to_vec(),
})
}
#[cfg(feature = "method-webs")]
fn verify_webs(
domain: &str,
mnemonic: &str,
content: &[u8],
existing: Option<&[u8]>,
) -> Result<Publication, MethodError> {
use super::webs::Webs;
if domain.is_empty() {
return Err(MethodError::Validation(
"did:webs requires a hosting domain: the key event log establishes an AID, \
not a DID, so the domain cannot be recovered from the content"
.into(),
));
}
let did_domain = domain.replace(':', "%3A");
let did_id = format!("did:webs:{did_domain}:{}", mnemonic.replace('/', ":"));
if let Some(existing) = existing.filter(|e| !e.is_empty()) {
Webs::verify_continuation(&did_id, existing, content)?;
}
let derived = Webs::verify_artifacts(&did_id, content, None)?;
let document: Value = serde_json::from_slice(&derived)
.map_err(|e| MethodError::Validation(format!("derived document is not valid JSON: {e}")))?;
Ok(Publication {
method: "webs",
did_id: Some(did_id),
document: Some(document),
content: content.to_vec(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "method-webs")]
const KERI: &[u8] = include_bytes!("../../tests/fixtures/ENro7uf0.keri.cesr");
#[cfg(feature = "method-webs")]
const AID: &str = "ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe";
#[cfg(feature = "method-webs")]
const DOMAIN: &str = "did-webs-service%3a7676";
#[cfg(feature = "method-webs")]
#[test]
fn verifies_a_webs_publication_and_builds_its_did() {
let p =
verify_publication(DOMAIN, AID, KERI, None, None).expect("reference artifacts verify");
assert_eq!(p.method, "webs");
assert_eq!(
p.did_id.as_deref(),
Some(format!("did:webs:{DOMAIN}:{AID}").as_str())
);
assert_eq!(p.content, KERI, "the CESR stream is what gets stored");
assert!(p.document.is_some());
}
#[cfg(feature = "method-webs")]
#[test]
fn refuses_a_webs_publication_to_a_slot_that_is_not_its_aid() {
let err = verify_publication(DOMAIN, "EAnotherAidEntirely00000000000", KERI, None, None)
.expect_err("a KEL may only be published to its own AID's slot");
assert!(matches!(err, MethodError::Validation(_)));
}
#[cfg(feature = "method-webs")]
#[test]
fn refuses_a_webs_publication_with_no_domain() {
let err = verify_publication("", AID, KERI, None, None)
.expect_err("a did:webs DID cannot be built without its domain");
assert!(matches!(err, MethodError::Validation(_)));
assert!(err.to_string().contains("domain"));
}
#[cfg(feature = "method-webs")]
#[test]
fn refuses_a_webs_update_that_rewinds_the_log() {
let second_event = KERI
.windows(4)
.skip(1)
.position(|w| w == br#"{"v""#)
.map(|p| p + 1)
.expect("stream has more than one message");
let truncated = &KERI[..second_event];
let err = verify_publication(DOMAIN, AID, truncated, Some(KERI), None)
.expect_err("an update may not rewind the hosted key event log");
assert!(err.to_string().contains("rewinds"));
}
#[cfg(feature = "method-webs")]
#[test]
fn a_webs_document_yields_services_and_no_agent_names() {
let p = verify_publication(DOMAIN, AID, KERI, None, None).unwrap();
assert_eq!(p.services(), Some(vec![]));
assert!(p.agent_names(DOMAIN).is_empty());
}
#[cfg(feature = "method-webvh")]
#[test]
fn rejects_content_no_method_recognises() {
let err = verify_publication("example.com", "alice", b"nonsense", None, None)
.expect_err("must reject");
assert!(matches!(err, MethodError::Malformed(_)));
}
}
#[cfg(all(test, feature = "method-webs"))]
mod webs_domain_encoding_tests {
use super::*;
const KERI: &[u8] = include_bytes!("../../tests/fixtures/ENro7uf0.keri.cesr");
const AID: &str = "ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe";
#[test]
fn a_ported_host_becomes_a_single_did_label() {
let p = verify_publication("did-webs-service:7676", AID, KERI, None, None)
.expect("the reference artifacts are published on a ported host");
assert_eq!(
p.did_id.as_deref(),
Some(format!("did:webs:did-webs-service%3A7676:{AID}").as_str()),
"the port separator must be percent-encoded into one label",
);
let doc = p.document.expect("document");
assert_eq!(
doc["id"].as_str(),
Some(format!("did:webs:did-webs-service%3A7676:{AID}").as_str()),
);
}
#[test]
fn an_already_encoded_host_is_left_alone() {
let a = verify_publication("did-webs-service:7676", AID, KERI, None, None).unwrap();
let b = verify_publication("did-webs-service%3A7676", AID, KERI, None, None).unwrap();
assert_eq!(a.did_id, b.did_id);
}
#[test]
fn a_plain_host_is_untouched() {
let p = verify_publication("hosting.example.com", AID, KERI, None, None).unwrap();
assert_eq!(
p.did_id.as_deref(),
Some(format!("did:webs:hosting.example.com:{AID}").as_str()),
);
}
}