use didwebvh_rs::DIDWebVHState;
use didwebvh_rs::log_entry::LogEntry;
use didwebvh_rs::log_entry_state::{LogEntryState, LogEntryValidationStatus};
use didwebvh_rs::parameters::Parameters;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DidRecord {
pub owner: String,
pub mnemonic: String,
pub created_at: u64,
pub updated_at: u64,
pub version_count: u64,
#[serde(default)]
pub did_id: Option<String>,
#[serde(default)]
pub content_size: u64,
#[serde(default)]
pub disabled: bool,
#[serde(default)]
pub deleted_at: Option<u64>,
#[serde(default = "default_method")]
pub method: String,
#[serde(default)]
pub domain: String,
#[serde(default)]
pub services: Option<Vec<String>>,
#[serde(default)]
pub agent_names: Vec<AgentNameEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentNameEntry {
pub name: String,
pub enabled: bool,
pub created_at: u64,
}
fn default_method() -> String {
"webvh".to_string()
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LogEntryInfo {
pub version_id: Option<String>,
pub version_time: Option<String>,
pub state: Option<serde_json::Value>,
pub parameters: Option<serde_json::Value>,
}
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LogMetadata {
pub log_entry_count: u64,
pub latest_version_id: Option<String>,
pub latest_version_time: Option<String>,
pub method: Option<String>,
pub portable: bool,
pub pre_rotation: bool,
pub deactivated: bool,
pub ttl: Option<u32>,
pub witnesses: bool,
pub witness_count: u32,
pub witness_threshold: u32,
pub watchers: bool,
pub watcher_count: u32,
pub watcher_urls: Vec<String>,
}
pub fn did_key(mnemonic: &str) -> String {
format!("did:{mnemonic}")
}
pub fn content_log_key(mnemonic: &str) -> String {
format!("content:{mnemonic}:log")
}
pub fn content_witness_key(mnemonic: &str) -> String {
format!("content:{mnemonic}:witness")
}
pub fn owner_key(did: &str, mnemonic: &str) -> String {
format!("owner:{did}:{mnemonic}")
}
pub fn agent_name_key(domain: &str, name: &str) -> String {
format!("name:{domain}:{name}")
}
pub fn watcher_sync_key(mnemonic: &str) -> String {
format!("watcher_sync:{mnemonic}")
}
pub fn validate_did_jsonl(content: &str) -> Result<(), String> {
if content.is_empty() {
return Err("did.jsonl content cannot be empty".into());
}
let mut had_entry = false;
for (idx, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
LogEntry::deserialize_string(line, None)
.map_err(|e| format!("invalid log entry at line {}: {e}", idx + 1))?;
had_entry = true;
}
if !had_entry {
return Err("did.jsonl content has no entries".into());
}
match extract_did_id(content) {
Some(id) if id.starts_with("did:webvh:") => Ok(()),
Some(other) => Err(format!(
"did.jsonl latest entry must encode a did:webvh identifier (got {other})",
)),
None => Err("did.jsonl latest entry has no resolvable state.id".into()),
}
}
pub fn verify_did_log_proofs(content: &str) -> Result<(), String> {
validate_did_jsonl(content)?;
let mut state = DIDWebVHState::default();
let mut version = None;
for (idx, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let entry = LogEntry::deserialize_string(line, version)
.map_err(|e| format!("invalid log entry at line {}: {e}", idx + 1))?;
version = Some(entry.get_webvh_version());
let version_number = entry
.get_version_id_fields()
.map_err(|e| format!("invalid versionId at line {}: {e}", idx + 1))?
.0;
state.log_entries_mut().push(LogEntryState {
log_entry: entry,
version_number,
validation_status: LogEntryValidationStatus::NotValidated,
validated_parameters: Parameters::default(),
});
}
let report = state
.validate()
.map_err(|e| format!("proof verification failed: {e}"))?;
report
.assert_complete()
.map_err(|e| format!("proof verification failed (chain incomplete): {e}"))?;
Ok(())
}
pub fn validate_did_id_matches_request(
did_id: &str,
request_path: &str,
server_base_url: &str,
) -> Result<(), String> {
use didwebvh_rs::url::WebVHURL;
use url::Url;
let id_parsed = WebVHURL::parse_did_url(did_id)
.map_err(|e| format!("did_log's DID identifier {did_id} is not a valid did:webvh: {e}"))?;
let expected_str = format!(
"{}/{request_path}/did.jsonl",
server_base_url.trim_end_matches('/')
);
let expected_url = Url::parse(&expected_str).map_err(|e| {
format!(
"internal: server_base_url {server_base_url} cannot form a valid URL when combined \
with the requested path: {e}"
)
})?;
let expected_parsed = WebVHURL::parse_url(&expected_url).map_err(|e| {
format!(
"internal: the URL this server would publish at ({expected_str}) cannot be expressed \
as a webvh URL: {e}"
)
})?;
if id_parsed.domain != expected_parsed.domain || id_parsed.port != expected_parsed.port {
return Err(format!(
"did_log's DID host (domain={}, port={:?}) does not match this server's host \
(domain={}, port={:?})",
id_parsed.domain, id_parsed.port, expected_parsed.domain, expected_parsed.port,
));
}
if id_parsed.path != expected_parsed.path {
return Err(format!(
"did_log's DID path '{}' does not resolve at the requested path '{}' on this server \
(server would publish at '{}')",
id_parsed.path.trim_matches('/'),
request_path,
expected_parsed.path.trim_matches('/'),
));
}
Ok(())
}
pub fn extract_did_id(jsonl_content: &str) -> Option<String> {
let last_line = jsonl_content.lines().rfind(|l| !l.trim().is_empty())?;
let value: serde_json::Value = serde_json::from_str(last_line).ok()?;
value
.get("state")
.and_then(|state| state.get("id"))
.and_then(|id| id.as_str())
.filter(|s| s.starts_with("did:webvh:"))
.map(|s| s.to_string())
}
pub fn extract_service_types(jsonl_content: &str) -> Option<Vec<String>> {
let last_line = jsonl_content.lines().rfind(|l| !l.trim().is_empty())?;
let value: serde_json::Value = serde_json::from_str(last_line).ok()?;
let state = value.get("state")?;
Some(crate::did::service_types_from_doc(state))
}
pub fn extract_agent_names(jsonl_content: &str, domain: &str) -> Vec<String> {
let Some(last_line) = jsonl_content.lines().rfind(|l| !l.trim().is_empty()) else {
return Vec::new();
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(last_line) else {
return Vec::new();
};
let Some(entries) = value
.get("state")
.and_then(|s| s.get("alsoKnownAs"))
.and_then(|a| a.as_array())
else {
return Vec::new();
};
let mut names = Vec::new();
for entry in entries.iter().filter_map(|e| e.as_str()) {
let Ok(parsed) = agent_names::AgentName::parse(entry) else {
continue; };
if parsed.authority() != domain {
continue; }
let local = parsed.local_name().to_string();
if !names.contains(&local) {
names.push(local);
}
}
names
}
pub fn extract_log_metadata(jsonl_content: &str) -> LogMetadata {
let lines: Vec<&str> = jsonl_content.lines().collect();
let mut meta = LogMetadata {
log_entry_count: lines.len() as u64,
..Default::default()
};
let Some(last_line) = lines.last() else {
return meta;
};
let Ok(entry) = serde_json::from_str::<serde_json::Value>(last_line) else {
return meta;
};
meta.latest_version_id = entry
.get("versionId")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
meta.latest_version_time = entry
.get("versionTime")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(params) = entry.get("parameters") {
meta.method = params
.get("method")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
meta.portable = params
.get("portable")
.and_then(|v| v.as_bool())
.unwrap_or(false);
meta.pre_rotation = params
.get("nextKeyHashes")
.and_then(|v| v.as_array())
.is_some_and(|a| !a.is_empty());
meta.deactivated = params
.get("deactivated")
.and_then(|v| v.as_bool())
.unwrap_or(false);
meta.ttl = params.get("ttl").and_then(|v| v.as_u64()).map(|v| v as u32);
if let Some(witness) = params.get("witness") {
let threshold = witness
.get("threshold")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let count = witness
.get("witnesses")
.and_then(|v| v.as_array())
.map(|a| a.len() as u32)
.unwrap_or(0);
if count > 0 {
meta.witnesses = true;
meta.witness_count = count;
meta.witness_threshold = threshold;
}
}
if let Some(watchers_val) = params.get("watchers")
&& let Some(arr) = watchers_val.as_array()
&& !arr.is_empty()
{
meta.watchers = true;
meta.watcher_count = arr.len() as u32;
meta.watcher_urls = arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
}
meta
}
pub fn extract_did_web_document(jsonl_content: &str, expected_did_web: &str) -> Option<Vec<u8>> {
let last_line = jsonl_content.lines().last()?;
let entry: serde_json::Value = serde_json::from_str(last_line).ok()?;
let state = entry.get("state")?;
let webvh_id = state.get("id")?.as_str()?;
if !webvh_id.starts_with("did:webvh:") {
return None;
}
let also_known_as = state.get("alsoKnownAs")?.as_array()?;
let found = also_known_as
.iter()
.any(|v| v.as_str() == Some(expected_did_web));
if !found {
return None;
}
let state_json = serde_json::to_string(state).ok()?;
let rewritten = state_json.replace(webvh_id, expected_did_web);
Some(rewritten.into_bytes())
}
pub fn parse_log_entries(jsonl_content: &str) -> Vec<LogEntryInfo> {
jsonl_content
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| {
let value: serde_json::Value = serde_json::from_str(line).ok()?;
Some(LogEntryInfo {
version_id: value
.get("versionId")
.and_then(|v| v.as_str())
.map(String::from),
version_time: value
.get("versionTime")
.and_then(|v| v.as_str())
.map(String::from),
state: value.get("state").cloned(),
parameters: value.get("parameters").cloned(),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_did_record_deserialises_with_webvh_default_method() {
let legacy = r#"{
"owner": "did:example:owner",
"mnemonic": "tenant/user1",
"created_at": 1700000000,
"updated_at": 1700000000,
"version_count": 1,
"did_id": null,
"content_size": 0,
"disabled": false,
"deleted_at": null
}"#;
let rec: DidRecord = serde_json::from_str(legacy).unwrap();
assert_eq!(rec.method, "webvh");
assert_eq!(rec.domain, "");
}
#[test]
fn new_did_record_round_trips_method_and_domain() {
let original = DidRecord {
owner: "did:example:owner".into(),
mnemonic: "user1".into(),
created_at: 0,
updated_at: 0,
version_count: 1,
did_id: None,
content_size: 0,
disabled: false,
deleted_at: None,
method: "web".into(),
domain: "tenant-a.example.com".into(),
services: None,
agent_names: Vec::new(),
};
let json = serde_json::to_string(&original).unwrap();
let back: DidRecord = serde_json::from_str(&json).unwrap();
assert_eq!(back.method, "web");
assert_eq!(back.domain, "tenant-a.example.com");
}
#[test]
fn explicit_empty_domain_round_trips() {
let legacy = r#"{
"owner": "did:example:owner",
"mnemonic": "u",
"created_at": 0,
"updated_at": 0,
"version_count": 0,
"method": "webvh"
}"#;
let rec: DidRecord = serde_json::from_str(legacy).unwrap();
assert_eq!(rec.method, "webvh");
assert_eq!(rec.domain, "");
}
#[test]
fn extract_did_id_from_state_id() {
let jsonl = r#"{"versionId":"1-abc","parameters":{"method":"did:webvh:1.0"},"state":{"id":"did:webvh:abc123:example.com:test"}}"#;
assert_eq!(
extract_did_id(jsonl),
Some("did:webvh:abc123:example.com:test".to_string())
);
}
#[test]
fn extract_did_id_returns_none_without_state() {
let jsonl = r#"{"parameters":{"method":"did:webvh:1.0"}}"#;
assert_eq!(extract_did_id(jsonl), None);
}
#[test]
fn extract_did_id_returns_none_for_non_webvh() {
let jsonl = r#"{"state":{"id":"did:key:z6Mk..."}}"#;
assert_eq!(extract_did_id(jsonl), None);
}
#[test]
fn extract_did_id_uses_last_line() {
let jsonl = r#"{"state":{"id":"did:webvh:first:host:path"}}
{"state":{"id":"did:webvh:second:host:path"}}"#;
assert_eq!(
extract_did_id(jsonl),
Some("did:webvh:second:host:path".to_string())
);
}
#[test]
fn log_metadata_empty_content() {
let meta = extract_log_metadata("");
assert_eq!(meta.log_entry_count, 0);
assert_eq!(meta.latest_version_id, None);
}
#[test]
fn log_metadata_basic_entry() {
let jsonl = r#"{"versionId":"1-QmHash","versionTime":"2025-01-23T04:12:36Z","parameters":{"method":"did:webvh:1.0","portable":true}}"#;
let meta = extract_log_metadata(jsonl);
assert_eq!(meta.log_entry_count, 1);
assert_eq!(meta.latest_version_id.as_deref(), Some("1-QmHash"));
assert!(meta.portable);
}
#[test]
fn validate_jsonl_empty_rejected() {
assert!(validate_did_jsonl("").is_err());
}
#[test]
fn validate_jsonl_invalid_json_rejected() {
assert!(validate_did_jsonl("not json").is_err());
}
#[test]
fn did_record_deserialize_without_content_size() {
let json = r#"{"owner":"did:example:a","mnemonic":"test","created_at":100,"updated_at":100,"version_count":1}"#;
let record: DidRecord = serde_json::from_str(json).unwrap();
assert_eq!(record.content_size, 0);
assert!(record.did_id.is_none());
}
#[test]
fn parse_log_entries_works() {
let jsonl = r#"{"versionId":"1-abc","state":{"id":"test"},"parameters":{"method":"1.0"}}"#;
let entries = parse_log_entries(jsonl);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].version_id.as_deref(), Some("1-abc"));
}
}
#[cfg(test)]
mod agent_name_tests {
use super::*;
fn log_with_aka(aka: &[&str]) -> String {
let list = aka
.iter()
.map(|s| format!("\"{s}\""))
.collect::<Vec<_>>()
.join(",");
format!(
"{{\"versionId\":\"1\",\"state\":{{\"id\":\"did:webvh:x:host.example\",\"alsoKnownAs\":[{list}]}}}}"
)
}
#[test]
fn extracts_names_on_the_hosting_domain() {
let log = log_with_aka(&["https://host.example/@alice", "host.example/@bob"]);
let mut names = extract_agent_names(&log, "host.example");
names.sort();
assert_eq!(names, vec!["alice", "bob"]);
}
#[test]
fn ignores_names_on_other_domains() {
let log = log_with_aka(&["https://elsewhere.example/@alice"]);
assert!(extract_agent_names(&log, "host.example").is_empty());
}
#[test]
fn ignores_non_agent_name_entries() {
let log = log_with_aka(&[
"did:web:host.example:someone",
"https://host.example/@alice",
]);
assert_eq!(extract_agent_names(&log, "host.example"), vec!["alice"]);
}
#[test]
fn deduplicates() {
let log = log_with_aka(&["host.example/@alice", "https://host.example/@alice"]);
assert_eq!(extract_agent_names(&log, "host.example"), vec!["alice"]);
}
#[test]
fn no_also_known_as_is_empty() {
let log = "{\"versionId\":\"1\",\"state\":{\"id\":\"did:webvh:x:host.example\"}}";
assert!(extract_agent_names(log, "host.example").is_empty());
}
}