use serde::{Deserialize, Serialize};
use crate::error::AppError;
use crate::store::{KeyspaceHandle, Store};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WatcherRecord {
pub mnemonic: String,
pub did_id: String,
pub source_did: String,
pub version_count: u64,
pub updated_at: u64,
pub disabled: bool,
}
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")
}
fn high_water_key(scid: &str) -> String {
format!("hw:id:{scid}")
}
pub async fn get_record(
ks: &KeyspaceHandle,
mnemonic: &str,
) -> Result<Option<WatcherRecord>, AppError> {
ks.get(did_key(mnemonic)).await
}
pub async fn list_records(ks: &KeyspaceHandle) -> Result<Vec<WatcherRecord>, AppError> {
let entries = ks.prefix_iter_raw("did:").await?;
let mut records = Vec::new();
for (_key, value) in entries {
if let Ok(record) = serde_json::from_slice::<WatcherRecord>(&value) {
records.push(record);
}
}
Ok(records)
}
#[derive(Debug, Clone)]
pub struct SyncEntry {
pub mnemonic: String,
pub did_id: String,
pub log_content: String,
pub witness_content: Option<String>,
pub version_count: u64,
pub disabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SyncRefusal {
InvalidLog(String),
HistoryRewrite(String),
Deactivated(String),
NotOwner(String),
Transient(String),
}
impl From<AppError> for SyncRefusal {
fn from(e: AppError) -> Self {
SyncRefusal::Transient(e.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncApplied {
Applied,
Unchanged,
}
fn webvh_scid(did_id: &str) -> Option<String> {
did_id
.strip_prefix("did:webvh:")?
.split(':')
.next()
.filter(|s| !s.is_empty())
.map(str::to_string)
}
pub async fn apply_sync(
store: &Store,
ks: &KeyspaceHandle,
source_did: &str,
entry: &SyncEntry,
) -> Result<SyncApplied, SyncRefusal> {
use did_hosting_common::did_ops::{
extract_did_id, validate_did_id_matches_request, verify_did_log_and_witness_proofs,
verify_log_extends,
};
did_hosting_common::server::mnemonic::validate_mnemonic(&entry.mnemonic)
.map_err(|e| SyncRefusal::InvalidLog(format!("mnemonic: {e}")))?;
let entries = entry
.log_content
.lines()
.filter(|l| !l.trim().is_empty())
.count() as u64;
if entries != entry.version_count {
return Err(SyncRefusal::InvalidLog(format!(
"versionCount is {} but logContent holds {entries} entries",
entry.version_count
)));
}
match extract_did_id(&entry.log_content) {
Some(id) if id == entry.did_id => {}
other => {
return Err(SyncRefusal::InvalidLog(format!(
"the update names {} but its log establishes {}",
entry.did_id,
other.as_deref().unwrap_or("no DID")
)));
}
}
let scid = webvh_scid(&entry.did_id).ok_or_else(|| {
SyncRefusal::InvalidLog(format!("{} is not a did:webvh identifier", entry.did_id))
})?;
let host = did_hosting_common::server::domain::safety::extract_did_host(&entry.did_id)
.map_err(|e| SyncRefusal::InvalidLog(e.to_string()))?;
validate_did_id_matches_request(&entry.did_id, &entry.mnemonic, &format!("https://{host}"))
.map_err(SyncRefusal::InvalidLog)?;
verify_did_log_and_witness_proofs(&entry.log_content, entry.witness_content.as_deref())
.map_err(SyncRefusal::InvalidLog)?;
let held: Option<WatcherRecord> = ks.get(did_key(&entry.mnemonic)).await?;
let held_log = ks
.get_raw(content_log_key(&entry.mnemonic))
.await?
.map(|b| String::from_utf8_lossy(&b).into_owned());
if let Some(prev) = held.as_ref()
&& prev.source_did != source_did
&& webvh_scid(&prev.did_id).as_deref() != Some(scid.as_str())
{
return Err(SyncRefusal::NotOwner(format!(
"slot {} mirrors a DID another source syncs",
entry.mnemonic
)));
}
let same_did_held = held_log.clone().filter(|_| {
held.as_ref().and_then(|r| webvh_scid(&r.did_id)).as_deref() == Some(scid.as_str())
});
let high_water = ks
.get_raw(high_water_key(&scid))
.await?
.map(|b| String::from_utf8_lossy(&b).into_owned());
for previous in [same_did_held, high_water].into_iter().flatten() {
verify_log_extends(Some(&previous), &entry.log_content).map_err(|e| {
if e.contains("deactivated") {
SyncRefusal::Deactivated(e)
} else {
SyncRefusal::HistoryRewrite(e)
}
})?;
}
let held_witness = ks
.get_raw(content_witness_key(&entry.mnemonic))
.await?
.map(|w| String::from_utf8_lossy(&w).into_owned());
if let Some(prev) = held.as_ref()
&& prev.did_id == entry.did_id
&& prev.disabled == entry.disabled
&& held_log.as_deref() == Some(entry.log_content.as_str())
&& held_witness == entry.witness_content
{
return Ok(SyncApplied::Unchanged);
}
let record = WatcherRecord {
mnemonic: entry.mnemonic.clone(),
did_id: entry.did_id.clone(),
source_did: source_did.to_string(),
version_count: entry.version_count,
updated_at: did_hosting_common::server::auth::session::now_epoch(),
disabled: entry.disabled,
};
let mut batch = store.batch();
batch.insert(ks, did_key(&entry.mnemonic), &record)?;
batch.insert_raw(
ks,
content_log_key(&entry.mnemonic),
entry.log_content.as_bytes().to_vec(),
);
match entry.witness_content.as_deref() {
Some(w) => batch.insert_raw(
ks,
content_witness_key(&entry.mnemonic),
w.as_bytes().to_vec(),
),
None => batch.remove(ks, content_witness_key(&entry.mnemonic)),
}
batch.insert_raw(
ks,
high_water_key(&scid),
entry.log_content.as_bytes().to_vec(),
);
batch.commit().await?;
Ok(SyncApplied::Applied)
}
pub async fn delete_record(
store: &Store,
ks: &KeyspaceHandle,
mnemonic: &str,
source_did: &str,
) -> Result<bool, AppError> {
let held: Option<WatcherRecord> = ks.get(did_key(mnemonic)).await?;
if held.is_none_or(|r| r.source_did != source_did) {
return Ok(false);
}
let mut batch = store.batch();
batch.remove(ks, did_key(mnemonic));
batch.remove(ks, content_log_key(mnemonic));
batch.remove(ks, content_witness_key(mnemonic));
batch.commit().await?;
Ok(true)
}