use std::sync::atomic::{AtomicBool, Ordering};
use affinidi_messaging_didcomm_service::DIDCommService;
use did_hosting_common::DidSyncUpdate;
use did_hosting_common::did_ops::{
AgentNameEntry, DidRecord, agent_name_key, content_log_key, content_witness_key, did_key,
extract_agent_names, extract_service_types, owner_key,
};
use did_hosting_common::didcomm_types::MSG_SERVER_REGISTER;
use did_hosting_common::server::didcomm_profile::TransportFallback;
use did_hosting_common::server::domain::safety::extract_did_host;
use did_hosting_common::server::domain::{DomainStatus, list_domains};
use did_hosting_common::server::mnemonic::validate_agent_name_binding;
use did_hosting_common::server::trust_tasks::send::{
Retry, build_signed_request, send_trust_task_with_retry,
};
use serde_json::json;
use tracing::{info, warn};
use crate::server::AppState;
use crate::store::{KeyspaceHandle, Store};
static REGISTERED: AtomicBool = AtomicBool::new(false);
pub fn is_registered() -> bool {
REGISTERED.load(Ordering::Relaxed)
}
pub async fn register_via_didcomm(state: &AppState, didcomm_svc: &DIDCommService) {
let server_did = match &state.config.server_did {
Some(did) => did.clone(),
None => {
warn!("cannot register: server_did not configured");
return;
}
};
let control_did = match &state.config.control_did {
Some(did) => did.clone(),
None => {
info!("no control_did configured — skipping registration");
return;
}
};
info!(
server_did = %server_did,
control_did = %control_did,
"registering with control plane via DIDComm"
);
let public_url = state.config.public_url.clone().unwrap_or_default();
if let Err(e) = didcomm_svc
.wait_connected("server", std::time::Duration::from_secs(30))
.await
{
warn!(error = %e, "timed out waiting for mediator connection — skipping registration");
return;
}
let enabled_methods: Vec<&str> = did_hosting_common::method::enabled_methods().to_vec();
let served_domains: Vec<String> = match list_domains(&state.store).await {
Ok(entries) => entries
.into_iter()
.filter(|d| matches!(d.status, DomainStatus::Active))
.map(|d| d.name)
.collect(),
Err(e) => {
warn!(
error = %e,
"failed to list local domains for registration — aborting this register attempt; retry loop will pick it up"
);
return;
}
};
let preloaded_dids: Vec<serde_json::Value> = match state.dids_ks.prefix_iter_raw("did:").await {
Ok(raw) => raw
.into_iter()
.filter_map(|(_k, v)| serde_json::from_slice::<DidRecord>(&v).ok())
.filter(|r| r.version_count > 0)
.map(|r| {
json!({
"mnemonic": r.mnemonic,
"did_id": r.did_id,
"version_count": r.version_count,
"disabled": r.disabled,
})
})
.collect(),
Err(e) => {
warn!(error = %e, "failed to enumerate local DIDs for registration — control plane will full-sync");
Vec::new()
}
};
let body = json!({
"public_url": public_url,
"label": "did-hosting-server",
"enabled_methods": enabled_methods,
"served_domains": served_domains,
"protocol_version": "1.0",
"trust_task_capable": true,
"sync_batch": true,
"preloaded_dids": preloaded_dids,
});
let fallback = TransportFallback::from_config(
state.config.mediator_did.as_deref(),
state.config.features.tsp,
);
let signer = match state.identity.as_deref() {
Some(identity) => {
match did_hosting_common::server::trust_tasks::identity_signing_secret(
identity,
&server_did,
) {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "cannot register: no signing key for this server's DID");
return;
}
}
}
None => {
warn!(
"cannot register: service identity not loaded, so the registration cannot be signed"
);
return;
}
};
let outcome = match build_signed_request(
MSG_SERVER_REGISTER,
&server_did,
&control_did,
body,
&signer,
)
.await
{
Ok(doc) => send_trust_task_with_retry(
didcomm_svc,
"server",
&server_did,
&control_did,
&doc,
&fallback,
state.did_resolver.as_ref(),
Retry {
attempts: 10,
delay: std::time::Duration::from_secs(5),
},
)
.await
.map(|transport| {
info!(?transport, "server registration sent as trust task");
}),
Err(e) => Err(e),
};
match outcome {
Ok(()) => {
REGISTERED.store(true, Ordering::Relaxed);
info!(control_did = %control_did, "server registered with control plane");
}
Err(e) => {
warn!(
error = %e,
"server registration failed — will accept sync but may not receive pushes"
);
}
}
}
pub async fn apply_did_updates(
dids_ks: &KeyspaceHandle,
store: &Store,
updates: &[DidSyncUpdate],
did_cache: &crate::cache::ContentCache,
public_url: Option<&str>,
) {
for update in updates {
if let Err(e) = apply_single_update(dids_ks, store, update, did_cache, public_url).await {
warn!(
mnemonic = %update.mnemonic,
error = %e,
"failed to apply DID sync update"
);
}
}
}
struct VerifiedUpdate {
identity: String,
#[cfg(feature = "method-webs")]
webs_document: Option<serde_json::Value>,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct SlotHighWater {
identity: String,
did_id: String,
method: String,
}
fn high_water_identity_key(identity: &str) -> String {
format!("hw:id:{identity}")
}
fn high_water_slot_key(mnemonic: &str) -> String {
format!("hw:slot:{mnemonic}")
}
async fn verify_update(
dids_ks: &KeyspaceHandle,
store: &Store,
update: &DidSyncUpdate,
synced_method: &str,
public_url: Option<&str>,
) -> Result<VerifiedUpdate, crate::error::AppError> {
use crate::error::AppError;
verify_binding(store, update, synced_method, public_url).await?;
match synced_method {
"webvh" => did_hosting_common::did_ops::verify_did_log_and_witness_proofs(
&update.log_content,
update.witness_content.as_deref(),
)
.map_err(AppError::Validation)?,
#[cfg(feature = "method-webs")]
_ => {}
#[cfg(not(feature = "method-webs"))]
_ => {
return Err(AppError::Validation(format!(
"this server cannot verify {synced_method} logs"
)));
}
}
#[cfg(feature = "method-webs")]
let webs_document = if synced_method == "webs" {
let derived = did_hosting_common::method::webs::Webs::verify_artifacts(
&update.did_id,
update.log_content.as_bytes(),
None,
)
.map_err(|e| AppError::Validation(e.to_string()))?;
Some(
serde_json::from_slice::<serde_json::Value>(&derived)
.map_err(|e| AppError::Internal(e.to_string()))?,
)
} else {
None
};
let identity = verify_history(
dids_ks,
&update.mnemonic,
&update.did_id,
&update.log_content,
synced_method,
)
.await?;
Ok(VerifiedUpdate {
identity,
#[cfg(feature = "method-webs")]
webs_document,
})
}
pub(crate) async fn verify_history(
dids_ks: &KeyspaceHandle,
mnemonic: &str,
did_id: &str,
log_content: &str,
method: &str,
) -> Result<String, crate::error::AppError> {
use crate::error::AppError;
use did_hosting_common::did_ops::verify_log_extends;
let held = dids_ks
.get_raw(content_log_key(mnemonic))
.await?
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
let held_record: Option<DidRecord> = dids_ks.get(did_key(mnemonic)).await?;
let slot_mark: Option<SlotHighWater> = dids_ks.get(high_water_slot_key(mnemonic)).await?;
for held_method in held_record
.as_ref()
.map(|r| r.method.as_str())
.into_iter()
.chain(slot_mark.as_ref().map(|m| m.method.as_str()))
{
if held_method != method {
return Err(AppError::Validation(format!(
"slot {mnemonic} holds a {held_method} DID; refusing to replace it with a {method} log"
)));
}
}
let identity = match method {
"webvh" => webvh_scid(did_id).ok_or_else(|| {
AppError::Validation(format!("{did_id} is not a did:webvh identifier"))
})?,
_ => did_id.to_string(),
};
let high_water = dids_ks
.get_raw(high_water_identity_key(&identity))
.await?
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
let held_identity =
held_record
.as_ref()
.and_then(|r| r.did_id.as_deref())
.map(|id| match method {
"webvh" => webvh_scid(id).unwrap_or_default(),
_ => id.to_string(),
});
let same_did_held = held.filter(|_| held_identity.as_deref() == Some(identity.as_str()));
for previous in [same_did_held, high_water].into_iter().flatten() {
match method {
"webvh" => {
verify_log_extends(Some(&previous), log_content).map_err(AppError::Validation)?
}
#[cfg(feature = "method-webs")]
_ => did_hosting_common::method::webs::Webs::verify_continuation(
did_id,
previous.as_bytes(),
log_content.as_bytes(),
)
.map_err(|e| AppError::Validation(e.to_string()))?,
#[cfg(not(feature = "method-webs"))]
_ => {
return Err(AppError::Validation(format!(
"this server cannot verify {method} logs"
)));
}
}
}
Ok(identity)
}
pub(crate) fn stage_high_water(
batch: &mut crate::store::WriteBatch,
dids_ks: &KeyspaceHandle,
identity: &str,
mnemonic: &str,
did_id: &str,
method: &str,
log_content: &str,
) -> Result<(), crate::error::AppError> {
batch.insert_raw(
dids_ks,
high_water_identity_key(identity),
log_content.as_bytes().to_vec(),
);
batch.insert(
dids_ks,
high_water_slot_key(mnemonic),
&SlotHighWater {
identity: identity.to_string(),
did_id: did_id.to_string(),
method: method.to_string(),
},
)?;
Ok(())
}
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)
}
async fn verify_binding(
store: &Store,
update: &DidSyncUpdate,
synced_method: &str,
public_url: Option<&str>,
) -> Result<(), crate::error::AppError> {
use crate::error::AppError;
if synced_method == "webvh" {
match did_hosting_common::did_ops::extract_did_id(&update.log_content) {
Some(id) if id == update.did_id => {}
other => {
return Err(AppError::Validation(format!(
"sync update names {} but its log establishes {}",
update.did_id,
other.as_deref().unwrap_or("no DID")
)));
}
}
}
let mut bases: Vec<String> = public_url.map(str::to_string).into_iter().collect();
for entry in did_hosting_common::server::assignment::list(store).await? {
let host = entry.domain.split('/').next().unwrap_or(&entry.domain);
bases.push(format!("https://{host}"));
bases.push(format!("https://{}", entry.domain));
}
if bases.is_empty() {
return Err(AppError::Validation(
"this server has no public URL or assigned domain to bind synced DIDs to".into(),
));
}
let bound = match synced_method {
"webvh" => bases.iter().any(|base| {
did_hosting_common::did_ops::validate_did_id_matches_request(
&update.did_id,
&update.mnemonic,
base,
)
.is_ok()
}),
_ => {
let host = extract_did_host(&update.did_id).unwrap_or_default();
let suffix = format!(":{}", update.mnemonic.replace('/', ":"));
update.did_id.ends_with(&suffix)
&& bases.iter().any(|base| {
url::Url::parse(base).ok().is_some_and(|u| {
let authority = match u.port() {
Some(p) => format!("{}:{p}", u.host_str().unwrap_or_default()),
None => u.host_str().unwrap_or_default().to_string(),
};
authority == host
})
})
}
};
if !bound {
return Err(AppError::Validation(format!(
"{} does not resolve at slot {} on a host this server serves",
update.did_id, update.mnemonic
)));
}
Ok(())
}
pub async fn apply_single_update(
dids_ks: &KeyspaceHandle,
store: &Store,
update: &DidSyncUpdate,
did_cache: &crate::cache::ContentCache,
public_url: Option<&str>,
) -> Result<SyncApplied, crate::error::AppError> {
use did_hosting_common::server::auth::session::now_epoch;
let now = now_epoch();
let did_host = extract_did_host(&update.did_id).unwrap_or_default();
let synced_method =
did_hosting_common::method::detect_method(update.log_content.as_bytes()).unwrap_or("webvh");
let verified = verify_update(dids_ks, store, update, synced_method, public_url).await?;
#[cfg(feature = "method-webs")]
let webs_document = verified.webs_document;
let previous: Option<DidRecord> = dids_ks.get(did_key(&update.mnemonic)).await.ok().flatten();
if let Some(prev) = previous.as_ref()
&& prev.did_id.as_deref() == Some(update.did_id.as_str())
&& prev.disabled == update.disabled
&& dids_ks
.get_raw(content_log_key(&update.mnemonic))
.await?
.is_some_and(|held| held == update.log_content.as_bytes())
&& dids_ks
.get_raw(content_witness_key(&update.mnemonic))
.await?
.map(|w| String::from_utf8_lossy(&w).into_owned())
== update.witness_content
{
return Ok(SyncApplied::Unchanged);
}
#[cfg(feature = "method-webs")]
let (synced_services, synced_names) = match webs_document.as_ref() {
Some(doc) => (
Some(did_hosting_common::did::service_types_from_doc(doc)),
did_hosting_common::did_ops::agent_names_from_document(doc, &did_host),
),
None => (
extract_service_types(&update.log_content),
extract_agent_names(&update.log_content, &did_host),
),
};
#[cfg(not(feature = "method-webs"))]
let (synced_services, synced_names) = (
extract_service_types(&update.log_content),
extract_agent_names(&update.log_content, &did_host),
);
let record = DidRecord {
owner: "system".to_string(),
mnemonic: update.mnemonic.clone(),
created_at: now,
updated_at: now,
version_count: update.version_count,
did_id: Some(update.did_id.clone()),
content_size: update.log_content.len() as u64,
disabled: update.disabled,
deleted_at: None,
method: synced_method.to_string(),
domain: did_host.clone(),
services: synced_services,
agent_names: synced_names
.into_iter()
.filter(|name| validate_agent_name_binding(name, &update.mnemonic).is_ok())
.map(|name| AgentNameEntry {
name,
enabled: true,
created_at: now,
})
.collect(),
};
let mut batch = store.batch();
batch.insert(dids_ks, did_key(&update.mnemonic), &record)?;
if let Some(prev) = previous.as_ref() {
for old in &prev.agent_names {
if !record.agent_names.iter().any(|n| n.name == old.name) {
batch.remove(dids_ks, agent_name_key(&did_host, &old.name));
}
}
}
for entry in &record.agent_names {
batch.insert_raw(
dids_ks,
agent_name_key(&did_host, &entry.name),
update.mnemonic.as_bytes().to_vec(),
);
}
batch.insert_raw(
dids_ks,
content_log_key(&update.mnemonic),
update.log_content.as_bytes().to_vec(),
);
batch.insert_raw(
dids_ks,
owner_key("system", &update.mnemonic),
update.mnemonic.as_bytes().to_vec(),
);
match update.witness_content {
Some(ref witness) => batch.insert_raw(
dids_ks,
content_witness_key(&update.mnemonic),
witness.as_bytes().to_vec(),
),
None => batch.remove(dids_ks, content_witness_key(&update.mnemonic)),
}
stage_high_water(
&mut batch,
dids_ks,
&verified.identity,
&update.mnemonic,
&update.did_id,
synced_method,
&update.log_content,
)?;
batch.commit().await?;
did_cache.invalidate(&content_log_key(&update.mnemonic));
info!(
mnemonic = %update.mnemonic,
did = %update.did_id,
disabled = update.disabled,
"applied DID sync update from control plane"
);
Ok(SyncApplied::Applied)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncApplied {
Applied,
Unchanged,
}