use did_hosting_common::did_ops::{self, DidRecord};
use did_hosting_common::didcomm_types::*;
use serde_json::json;
use tracing::{info, warn};
use crate::registry::{self, ServiceType};
use crate::server::AppState;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReportedDid {
pub did_id: Option<String>,
pub version_count: u64,
pub disabled: Option<bool>,
}
pub fn edge_is_current(record: &DidRecord, reported: Option<&ReportedDid>) -> bool {
reported.is_some_and(|have| {
have.did_id.is_some()
&& have.did_id == record.did_id
&& have.version_count >= record.version_count
&& have.disabled.is_none_or(|d| d == record.disabled)
})
}
pub fn parse_reported(body: &serde_json::Value) -> std::collections::HashMap<String, ReportedDid> {
body.get("preloaded_dids")
.and_then(|v| v.as_array())
.map(|entries| {
entries
.iter()
.filter_map(|e| {
let mnemonic = e.get("mnemonic")?.as_str()?.to_string();
let version_count = e.get("version_count")?.as_u64()?;
let did_id = e.get("did_id").and_then(|v| v.as_str()).map(String::from);
let disabled = e.get("disabled").and_then(|v| v.as_bool());
Some((
mnemonic,
ReportedDid {
did_id,
version_count,
disabled,
},
))
})
.collect()
})
.unwrap_or_default()
}
pub fn sync_all_dids_to_server(
state: &AppState,
server_did: String,
reported: std::collections::HashMap<String, ReportedDid>,
) {
let dids_ks = state.dids_ks.clone();
let registry_ks = state.registry_ks.clone();
let store = state.store.clone();
let notify = state.outbox_notify.clone();
tokio::spawn(async move {
let instance_id = server_did.replace(':', "_");
let batch = crate::registry::get_instance(®istry_ks, &instance_id)
.await
.ok()
.flatten()
.is_some_and(|inst| inst.sync_batch_capable);
let raw = match dids_ks.prefix_iter_raw("did:").await {
Ok(raw) => raw,
Err(e) => {
warn!(error = %e, "sync_all_dids: failed to iterate DIDs");
return;
}
};
let mut count = 0u64; let mut frames = 0u64; let mut published: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut pending: Vec<serde_json::Value> = Vec::new();
let mut pending_bytes = 0usize;
for (_key, value) in raw {
let record: DidRecord = match serde_json::from_slice(&value) {
Ok(r) => r,
Err(_) => continue,
};
if record.version_count == 0 {
continue;
}
published.insert(record.mnemonic.clone());
if edge_is_current(&record, reported.get(&record.mnemonic)) {
continue;
}
let log_content = match dids_ks
.get_raw(did_ops::content_log_key(&record.mnemonic))
.await
{
Ok(Some(bytes)) => match String::from_utf8(bytes) {
Ok(s) => s,
Err(_) => continue,
},
_ => continue,
};
let witness_content = match dids_ks
.get_raw(did_ops::content_witness_key(&record.mnemonic))
.await
{
Ok(Some(bytes)) => String::from_utf8(bytes).ok(),
_ => None,
};
let Some(body) = sync_update_body(&record, log_content, witness_content) else {
continue;
};
if !batch {
if let Err(e) =
crate::outbox::enqueue(&store, &server_did, MSG_SYNC_UPDATE, body).await
{
warn!(server_did = %server_did, mnemonic = %record.mnemonic, error = %e, "sync_all_dids: outbox enqueue failed");
} else {
count += 1;
frames += 1;
}
continue;
}
let sz = body.to_string().len();
if !pending.is_empty()
&& (pending.len() >= SYNC_BATCH_MAX_COUNT
|| pending_bytes + sz > SYNC_BATCH_MAX_BYTES)
{
let payload = json!({ "updates": std::mem::take(&mut pending) });
pending_bytes = 0;
if let Err(e) =
crate::outbox::enqueue(&store, &server_did, MSG_SYNC_BATCH, payload).await
{
warn!(server_did = %server_did, error = %e, "sync_all_dids: outbox batch enqueue failed");
} else {
frames += 1;
}
}
pending_bytes += sz;
pending.push(body);
count += 1;
}
if !pending.is_empty() {
let payload = json!({ "updates": pending });
if let Err(e) =
crate::outbox::enqueue(&store, &server_did, MSG_SYNC_BATCH, payload).await
{
warn!(server_did = %server_did, error = %e, "sync_all_dids: outbox batch enqueue failed");
} else {
frames += 1;
}
}
let mut deletes = 0u64;
for mnemonic in reported.keys().filter(|m| !published.contains(*m)) {
if let Err(e) = crate::outbox::enqueue(
&store,
&server_did,
MSG_SYNC_DELETE,
json!({ "mnemonic": mnemonic }),
)
.await
{
warn!(server_did = %server_did, %mnemonic, error = %e, "resync: delete enqueue failed");
} else {
deletes += 1;
}
}
if deletes > 0 {
notify.notify_one();
info!(server_did = %server_did, deletes, "resync: queued deletes the server missed");
}
if count > 0 {
notify.notify_one();
info!(
server_did = %server_did,
count,
frames,
batched = batch,
"initial DID sync queued for newly registered server"
);
}
});
}
pub(crate) fn sync_update_body(
record: &DidRecord,
log_content: String,
witness_content: Option<String>,
) -> Option<serde_json::Value> {
use trust_tasks_rs::specs::webvh::sync::update::v0_2 as update;
let did_id = record.did_id.clone().filter(|d| !d.is_empty())?;
let body = serde_json::to_value(did_hosting_common::DidSyncUpdate {
mnemonic: record.mnemonic.clone(),
did_id,
log_content,
witness_content: witness_content.filter(|w| !w.is_empty()),
version_count: record.version_count,
disabled: record.disabled,
})
.ok()?;
match serde_json::from_value::<update::Payload>(body.clone()) {
Ok(_) => Some(body),
Err(e) => {
warn!(mnemonic = %record.mnemonic, error = %e, "DID sync: slot does not fit sync/update/0.2; not sending it");
None
}
}
}
const SYNC_BATCH_MAX_COUNT: usize = 50;
const SYNC_BATCH_MAX_BYTES: usize = 512 * 1024;
pub fn notify_servers_did(state: &AppState, mnemonic: String) {
let watcher_peers = state.config.registry.watchers.clone();
let registry_ks = state.registry_ks.clone();
let dids_ks = state.dids_ks.clone();
let store = state.store.clone();
let notify = state.outbox_notify.clone();
tokio::spawn(async move {
info!(mnemonic = %mnemonic, "DID changed — queueing sync to servers");
let record = match dids_ks.get::<DidRecord>(did_ops::did_key(&mnemonic)).await {
Ok(Some(r)) => r,
Ok(None) => {
warn!(mnemonic = %mnemonic, "DID sync: record not found in store");
return;
}
Err(e) => {
warn!(mnemonic = %mnemonic, error = %e, "DID sync: failed to read record");
return;
}
};
let log_content = match dids_ks.get_raw(did_ops::content_log_key(&mnemonic)).await {
Ok(Some(bytes)) => match String::from_utf8(bytes) {
Ok(s) => s,
Err(_) => {
warn!(mnemonic = %mnemonic, "DID sync: invalid UTF-8 in log content");
return;
}
},
Ok(None) => {
warn!(mnemonic = %mnemonic, "DID sync: no log content found");
return;
}
Err(e) => {
warn!(mnemonic = %mnemonic, error = %e, "DID sync: failed to read log");
return;
}
};
let witness_content = match dids_ks
.get_raw(did_ops::content_witness_key(&mnemonic))
.await
{
Ok(Some(bytes)) => String::from_utf8(bytes).ok(),
_ => None,
};
let log_for_watchers = log_content.clone();
let Some(body) = sync_update_body(&record, log_content, witness_content) else {
return;
};
let watchers = watcher_dids_for(&watcher_peers, &log_for_watchers);
for watcher_did in &watchers {
match crate::outbox::enqueue(&store, watcher_did, MSG_SYNC_UPDATE, body.clone()).await {
Ok(_) => info!(watcher_did, mnemonic = %mnemonic, "DID sync: queued for watcher"),
Err(e) => warn!(
watcher_did,
mnemonic = %mnemonic,
error = %e,
"DID sync: outbox enqueue failed"
),
}
}
if let Err(e) = dids_ks
.insert(notified_watchers_key(&mnemonic), &watchers)
.await
{
warn!(mnemonic = %mnemonic, error = %e, "DID sync: failed to record notified watchers");
}
let servers = match get_active_servers(®istry_ks).await {
Some(s) => s,
None => {
if watchers.is_empty() {
warn!(mnemonic = %mnemonic, "DID sync: no active servers in registry");
} else {
notify.notify_one();
}
return;
}
};
for (server_did, instance_id) in &servers {
if let Err(e) =
crate::outbox::enqueue(&store, server_did, MSG_SYNC_UPDATE, body.clone()).await
{
warn!(
server_did,
instance_id,
mnemonic = %mnemonic,
error = %e,
"DID sync: outbox enqueue failed"
);
} else {
info!(
server_did,
instance_id,
mnemonic = %mnemonic,
"DID sync: queued for server"
);
}
}
notify.notify_one();
});
}
pub fn notify_servers_delete(state: &AppState, mnemonic: String) {
let registry_ks = state.registry_ks.clone();
let dids_ks = state.dids_ks.clone();
let store = state.store.clone();
let notify = state.outbox_notify.clone();
tokio::spawn(async move {
info!(mnemonic = %mnemonic, "DID deleted — queueing sync to servers");
let body = json!({ "mnemonic": mnemonic });
let watcher_dids: Vec<String> = dids_ks
.get(notified_watchers_key(&mnemonic))
.await
.ok()
.flatten()
.unwrap_or_default();
for watcher_did in &watcher_dids {
if let Err(e) =
crate::outbox::enqueue(&store, watcher_did, MSG_SYNC_DELETE, body.clone()).await
{
warn!(watcher_did, mnemonic = %mnemonic, error = %e, "DID delete sync: outbox enqueue failed");
}
}
if let Err(e) = dids_ks.remove(notified_watchers_key(&mnemonic)).await {
warn!(mnemonic = %mnemonic, error = %e, "DID delete sync: failed to clear notified-watchers record");
}
let servers = match get_active_servers(®istry_ks).await {
Some(s) => s,
None => {
if watcher_dids.is_empty() {
warn!(mnemonic = %mnemonic, "DID delete sync: no active servers in registry");
} else {
notify.notify_one();
}
return;
}
};
for (server_did, instance_id) in &servers {
if let Err(e) =
crate::outbox::enqueue(&store, server_did, MSG_SYNC_DELETE, body.clone()).await
{
warn!(
server_did,
instance_id,
mnemonic = %mnemonic,
error = %e,
"DID delete sync: outbox enqueue failed"
);
} else {
info!(
server_did,
instance_id,
mnemonic = %mnemonic,
"DID delete sync: queued for server"
);
}
}
notify.notify_one();
});
}
fn notified_watchers_key(mnemonic: &str) -> String {
format!("notified_watchers:{mnemonic}")
}
pub fn watchers_named(log: &str) -> Vec<String> {
let mut current: Vec<String> = Vec::new();
for line in log.lines().filter(|l| !l.trim().is_empty()) {
let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let Some(watchers) = entry.get("parameters").and_then(|p| p.get("watchers")) else {
continue;
};
current = watchers
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.map(|u| u.trim_end_matches('/').to_string())
.collect()
})
.unwrap_or_default();
}
current
}
pub fn watcher_dids_for(peers: &[crate::config::WatcherPeer], log: &str) -> Vec<String> {
let mut dids = Vec::new();
for url in watchers_named(log) {
match peers.iter().find(|p| p.url.trim_end_matches('/') == url) {
Some(peer) if !dids.contains(&peer.did) => dids.push(peer.did.clone()),
Some(_) => {}
None => {
warn!(watcher_url = %url, "DID sync: the log names a watcher with no configured DID; not pushing to it")
}
}
}
dids
}
pub fn is_configured_watcher(state: &AppState, did: &str) -> bool {
state.config.registry.watchers.iter().any(|p| p.did == did)
}
async fn get_active_servers(
registry_ks: &crate::store::KeyspaceHandle,
) -> Option<Vec<(String, String)>> {
let instances = match registry::list_instances(registry_ks).await {
Ok(i) => i,
Err(e) => {
warn!(error = %e, "server push: failed to list instances");
return None;
}
};
let servers: Vec<_> = instances
.into_iter()
.filter(|i| {
i.service_type == ServiceType::Server && i.status == registry::ServiceStatus::Active
})
.filter_map(|i| {
let did = i.metadata.get("did")?.as_str()?.to_string();
Some((did, i.instance_id))
})
.collect();
if servers.is_empty() {
None
} else {
Some(servers)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DomainOp {
Assign,
Unassign,
Purge,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DomainIntent {
pub domain: String,
pub op: DomainOp,
pub at: u64,
}
fn domain_intent_prefix(server_did: &str) -> String {
format!("domain-intent:{server_did}|")
}
fn domain_intent_key(server_did: &str, domain: &str) -> String {
format!("{}{domain}", domain_intent_prefix(server_did))
}
async fn record_domain_intent(
state: &AppState,
server_did: &str,
domain: &str,
op: DomainOp,
) -> Result<(), did_hosting_common::server::error::AppError> {
state
.registry_ks
.insert(
domain_intent_key(server_did, domain),
&DomainIntent {
domain: domain.to_string(),
op,
at: crate::auth::session::now_epoch(),
},
)
.await
}
pub async fn domain_intents(
state: &AppState,
server_did: &str,
) -> Result<Vec<DomainIntent>, did_hosting_common::server::error::AppError> {
Ok(state
.registry_ks
.prefix_iter_raw(domain_intent_prefix(server_did))
.await?
.into_iter()
.filter_map(|(_, v)| serde_json::from_slice(&v).ok())
.collect())
}
pub async fn settle_domain_intent(state: &AppState, server_did: &str, domain: &str, op: DomainOp) {
if op == DomainOp::Assign {
return;
}
let key = domain_intent_key(server_did, domain);
match state.registry_ks.get::<DomainIntent>(key.clone()).await {
Ok(Some(intent)) if intent.op == op => {
if let Err(e) = state.registry_ks.remove(key).await {
warn!(server_did, domain, error = %e, "failed to settle domain intent");
}
}
Ok(_) => {}
Err(e) => warn!(server_did, domain, error = %e, "failed to read domain intent"),
}
}
pub async fn resend_domain_intents(state: &AppState, server_did: &str) {
let intents = match domain_intents(state, server_did).await {
Ok(i) => i,
Err(e) => {
warn!(server_did, error = %e, "domain resync: failed to list domain intents");
return;
}
};
for intent in intents {
let ops: &[&str] = match intent.op {
DomainOp::Assign => &[MSG_REPLICA_DOMAIN_ASSIGN],
DomainOp::Unassign => &[MSG_REPLICA_DOMAIN_UNASSIGN],
DomainOp::Purge => &[MSG_REPLICA_DOMAIN_UNASSIGN, MSG_REPLICA_DOMAIN_PURGE],
};
for op in ops {
if let Err(e) = crate::outbox::enqueue(
&state.store,
server_did,
op,
json!({ "domain": intent.domain }),
)
.await
{
warn!(server_did, domain = %intent.domain, op, error = %e, "domain resync: enqueue failed");
}
}
}
state.outbox_notify.notify_one();
}
pub async fn send_domain_assign(
state: &AppState,
target_did: &str,
domain: &str,
) -> Result<(), did_hosting_common::server::error::AppError> {
record_domain_intent(state, target_did, domain, DomainOp::Assign).await?;
let dropped =
crate::outbox::drop_queued(&state.store, target_did, MSG_REPLICA_DOMAIN_PURGE, |body| {
body.get("domain").and_then(|d| d.as_str()) == Some(domain)
})
.await?;
if dropped > 0 {
info!(
target_did,
domain, dropped, "re-assign dropped a purge still queued for this server"
);
}
crate::outbox::enqueue_and_notify(
state,
target_did,
MSG_REPLICA_DOMAIN_ASSIGN,
json!({ "domain": domain }),
)
.await?;
Ok(())
}
pub async fn send_domain_purge(
state: &AppState,
target_did: &str,
domain: &str,
) -> Result<(), did_hosting_common::server::error::AppError> {
record_domain_intent(state, target_did, domain, DomainOp::Purge).await?;
crate::outbox::enqueue_and_notify(
state,
target_did,
MSG_REPLICA_DOMAIN_PURGE,
json!({ "domain": domain }),
)
.await?;
Ok(())
}
pub async fn send_domain_unassign(
state: &AppState,
target_did: &str,
domain: &str,
) -> Result<(), did_hosting_common::server::error::AppError> {
record_domain_intent(state, target_did, domain, DomainOp::Unassign).await?;
crate::outbox::enqueue_and_notify(
state,
target_did,
MSG_REPLICA_DOMAIN_UNASSIGN,
json!({ "domain": domain }),
)
.await?;
Ok(())
}
pub async fn send_domain_upsert(
state: &AppState,
target_did: &str,
entry: &did_hosting_common::server::domain::DomainEntry,
) -> Result<(), did_hosting_common::server::error::AppError> {
let body = json!({ "entry": did_hosting_common::server::domain::wire::to_spec(entry) });
crate::outbox::enqueue_and_notify(state, target_did, MSG_REPLICA_DOMAIN_UPSERT, body).await?;
Ok(())
}
pub fn sync_all_domains_to_server(state: &AppState, server_did: String) {
let state = state.clone();
tokio::spawn(async move {
sync_all_domains_now(&state, &server_did).await;
});
}
pub async fn sync_all_domains_now(state: &AppState, server_did: &str) {
match did_hosting_common::server::domain::list_domains(&state.store).await {
Ok(domains) => {
for entry in &domains {
if let Err(e) = send_domain_upsert(state, server_did, entry).await {
warn!(server_did, domain = %entry.name, error = %e, "domain resync: enqueue failed");
}
}
}
Err(e) => warn!(server_did, error = %e, "domain resync: list failed"),
}
resend_domain_intents(state, server_did).await;
}
pub async fn fanout_domain_upsert(
state: &AppState,
entry: &did_hosting_common::server::domain::DomainEntry,
) -> (usize, usize) {
let instances = match registry::list_instances(&state.registry_ks).await {
Ok(v) => v,
Err(e) => {
warn!(error = %e, "fanout_domain_upsert: failed to list registry — no servers notified");
return (0, 0);
}
};
let mut enqueued = 0;
let mut skipped = 0;
for instance in instances {
let did = match instance.metadata.get("did").and_then(|v| v.as_str()) {
Some(d) => d.to_string(),
None => {
skipped += 1;
continue;
}
};
match send_domain_upsert(state, &did, entry).await {
Ok(()) => enqueued += 1,
Err(e) => {
warn!(
target_did = %did,
domain = %entry.name,
error = %e,
"fanout_domain_upsert: enqueue failed"
);
skipped += 1;
}
}
}
(enqueued, skipped)
}
#[cfg(test)]
mod watcher_tests {
use super::*;
use crate::config::WatcherPeer;
fn log(params: &[serde_json::Value]) -> String {
params
.iter()
.map(|p| json!({ "versionId": "1-x", "parameters": p }).to_string())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn the_last_watchers_parameter_wins() {
let l = log(&[
json!({ "watchers": ["https://a.example/"] }),
json!({}),
json!({ "watchers": ["https://b.example", "https://c.example"] }),
]);
assert_eq!(
watchers_named(&l),
vec!["https://b.example", "https://c.example"]
);
let cleared = log(&[
json!({ "watchers": ["https://a.example"] }),
json!({ "watchers": null }),
]);
assert!(watchers_named(&cleared).is_empty());
}
#[test]
fn only_configured_watchers_are_pushed_to() {
let peers = vec![WatcherPeer {
url: "https://b.example/".into(),
did: "did:example:b".into(),
}];
let l = log(&[json!({ "watchers": ["https://a.example", "https://b.example"] })]);
assert_eq!(watcher_dids_for(&peers, &l), vec!["did:example:b"]);
}
}