use bip39::Language;
use did_hosting_common::did_ops::{
self, AgentNameEntry, DidRecord, LogEntryInfo, LogMetadata, agent_name_key, content_log_key,
content_witness_key, did_key, owner_key,
};
use did_hosting_common::method::publication::Publication;
use did_hosting_common::server::acl::validate_did_format;
use did_hosting_common::server::error::AgentNameError;
use did_hosting_common::server::identity::mnemonic_from_did;
use did_hosting_common::server::mnemonic::{
validate_agent_name, validate_agent_name_binding, validate_custom_path, validate_mnemonic,
};
use did_hosting_common::{CheckNameResponse, DidListEntry, RequestUriResponse};
use rand::random_range;
use tracing::{debug, info, warn};
use crate::auth::AuthClaims;
use crate::error::AppError;
use crate::server::AppState;
use crate::store::KeyspaceHandle;
fn invalidate_cached_content(state: &AppState, mnemonic: &str) {
if let Some(invalidate) = &state.cache_invalidate {
invalidate(&content_log_key(mnemonic));
invalidate(&content_witness_key(mnemonic));
}
}
async fn check_did_host_safety(
state: &AppState,
auth: &AuthClaims,
did_id: &str,
) -> Result<(), AppError> {
use did_hosting_common::server::acl::{AclEntry, get_acl_entry};
use did_hosting_common::server::domain::DomainScope;
let acl_entry = match get_acl_entry(&state.acl_ks, &auth.did).await? {
Some(e) => e,
None => AclEntry {
did: auth.did.clone(),
role: auth.role.clone(),
label: None,
created_at: 0,
max_total_size: None,
max_did_count: None,
domains: DomainScope::All,
},
};
did_hosting_common::server::domain::assert_did_host_allowed_when_domains_configured(
&state.store,
&acl_entry,
did_id,
)
.await
}
pub use did_hosting_common::did_ops::{
extract_did_id, extract_log_metadata, extract_service_types,
};
fn publication_error(e: did_hosting_common::method::MethodError) -> AppError {
use did_hosting_common::server::error::ValidationKind;
AppError::validation(ValidationKind::InvalidLog, e.to_string())
}
fn random_mnemonic() -> String {
let wordlist = Language::English.word_list();
let w1 = wordlist[random_range(0..wordlist.len())];
let w2 = wordlist[random_range(0..wordlist.len())];
format!("{w1}-{w2}")
}
async fn generate_unique_mnemonic(dids_ks: &KeyspaceHandle) -> Result<String, AppError> {
for _ in 0..100 {
let mnemonic = random_mnemonic();
let key = format!("did:{mnemonic}");
if !dids_ks.contains_key(key).await? {
return Ok(mnemonic);
}
}
Err(AppError::Internal(
"failed to generate unique mnemonic after 100 attempts".into(),
))
}
async fn is_path_available(dids_ks: &KeyspaceHandle, path: &str) -> Result<bool, AppError> {
Ok(!dids_ks.contains_key(format!("did:{path}")).await?)
}
async fn get_authorized_record(
dids_ks: &KeyspaceHandle,
mnemonic: &str,
auth: &AuthClaims,
) -> Result<DidRecord, AppError> {
use crate::acl::Role;
let record: DidRecord = dids_ks
.get(did_key(mnemonic))
.await?
.ok_or_else(|| AppError::NotFound(format!("DID not found: {mnemonic}")))?;
if record.owner != auth.did && auth.role != Role::Admin {
warn!(
caller = %auth.did,
owner = %record.owner,
mnemonic = %mnemonic,
"access denied: not the owner of this DID"
);
return Err(AppError::Forbidden("not the owner of this DID".into()));
}
Ok(record)
}
async fn owner_usage(state: &AppState, owner: &str) -> Result<(u64, u64), AppError> {
let prefix = format!("owner:{owner}:");
let raw = state.dids_ks.prefix_iter_raw(prefix).await?;
let mut count: u64 = 0;
let mut total_size: u64 = 0;
for (_key, value) in raw {
let mnemonic = String::from_utf8(value)
.map_err(|e| AppError::Internal(format!("invalid mnemonic bytes: {e}")))?;
if let Some(record) = state.dids_ks.get::<DidRecord>(did_key(&mnemonic)).await? {
if record.owner != owner {
continue;
}
count += 1;
total_size = total_size.saturating_add(record.content_size);
}
}
Ok((count, total_size))
}
async fn check_did_count_limit(auth: &AuthClaims, state: &AppState) -> Result<(), AppError> {
use crate::acl::Role;
if auth.role == Role::Admin {
return Ok(());
}
let Some(entry) =
did_hosting_common::server::acl::get_acl_entry(&state.acl_ks, &auth.did).await?
else {
return Ok(());
};
let max = entry.effective_max_did_count(u64::MAX);
if max == u64::MAX {
return Ok(());
}
let (count, _) = owner_usage(state, &auth.did).await?;
if count >= max {
warn!(did = %auth.did, count, max, "DID count quota exceeded (control)");
return Err(AppError::QuotaExceeded(format!(
"DID count limit reached ({max})"
)));
}
Ok(())
}
async fn check_total_size_limit(
auth: &AuthClaims,
state: &AppState,
old_size: u64,
new_size: u64,
) -> Result<(), AppError> {
use crate::acl::Role;
if auth.role == Role::Admin {
return Ok(());
}
let Some(entry) =
did_hosting_common::server::acl::get_acl_entry(&state.acl_ks, &auth.did).await?
else {
return Ok(());
};
let max = entry.effective_max_total_size(u64::MAX);
if max == u64::MAX {
return Ok(());
}
let (_, total) = owner_usage(state, &auth.did).await?;
let proposed = total.saturating_sub(old_size).saturating_add(new_size);
if proposed > max {
warn!(did = %auth.did, proposed, max, "total size quota exceeded (control)");
return Err(AppError::QuotaExceeded(format!(
"total content size limit reached ({max} bytes)"
)));
}
Ok(())
}
async fn resolve_path_for_create(
state: &AppState,
custom_path: &str,
auth: &AuthClaims,
force: bool,
conflict_msg: &str,
) -> Result<String, AppError> {
use crate::acl::Role;
if is_path_available(&state.dids_ks, custom_path).await? {
return Ok(custom_path.to_string());
}
if !force {
return Err(AppError::Conflict(conflict_msg.to_string()));
}
let existing: DidRecord = state
.dids_ks
.get(did_key(custom_path))
.await?
.ok_or_else(|| AppError::Internal("path conflict but record missing".into()))?;
if existing.owner != auth.did && auth.role != Role::Admin {
warn!(
caller = %auth.did,
owner = %existing.owner,
mnemonic = %custom_path,
"force replace denied: not the owner or admin"
);
return Err(AppError::Forbidden(
"force replace requires admin or current owner".into(),
));
}
let mut batch = state.store.batch();
batch.remove(&state.dids_ks, content_log_key(custom_path));
batch.remove(&state.dids_ks, content_witness_key(custom_path));
if existing.owner != auth.did {
batch.remove(&state.dids_ks, owner_key(&existing.owner, custom_path));
}
batch.commit().await?;
info!(
caller = %auth.did,
prev_owner = %existing.owner,
mnemonic = %custom_path,
"force-replacing existing DID"
);
Ok(custom_path.to_string())
}
pub async fn create_did(
auth: &AuthClaims,
state: &AppState,
path: Option<&str>,
force: bool,
domain: Option<&str>,
) -> Result<RequestUriResponse, AppError> {
use crate::acl::Role;
use crate::auth::session::now_epoch;
let mnemonic = match path {
Some(custom_path) if custom_path == ".well-known" => {
if auth.role != Role::Admin {
return Err(AppError::Forbidden(
"only admins can create the root DID".into(),
));
}
resolve_path_for_create(
state,
custom_path,
auth,
force,
"root DID (.well-known) already exists",
)
.await?
}
Some(custom_path) => {
validate_custom_path(custom_path)?;
let conflict_msg = format!("path '{custom_path}' is already taken");
resolve_path_for_create(state, custom_path, auth, force, &conflict_msg).await?
}
None => generate_unique_mnemonic(&state.dids_ks).await?,
};
let now = now_epoch();
let record = DidRecord {
owner: auth.did.clone(),
mnemonic: mnemonic.clone(),
created_at: now,
updated_at: now,
version_count: 0,
did_id: None,
content_size: 0,
disabled: false,
deleted_at: None,
method: "webvh".to_string(),
domain: domain.unwrap_or("").to_string(),
services: None,
agent_names: Vec::new(),
};
let adds_slot = match state.dids_ks.get::<DidRecord>(did_key(&mnemonic)).await? {
Some(existing) => existing.owner != auth.did,
None => true,
};
if adds_slot {
check_did_count_limit(auth, state).await?;
}
let mut batch = state.store.batch();
batch.insert(&state.dids_ks, did_key(&mnemonic), &record)?;
batch.insert_raw(
&state.dids_ks,
owner_key(&auth.did, &mnemonic),
mnemonic.as_bytes().to_vec(),
);
batch.commit().await?;
let base_url = state
.config
.did_hosting_url
.as_deref()
.or(state.config.public_url.as_deref())
.unwrap_or("http://localhost");
let did_url = format!("{base_url}/{mnemonic}/did.jsonl");
info!(did = %auth.did, mnemonic = %mnemonic, "DID URI created on control plane");
Ok(RequestUriResponse { mnemonic, did_url })
}
pub async fn register_did_atomic(
auth: &AuthClaims,
state: &AppState,
path: &str,
did_log: &str,
force: bool,
domain: Option<&str>,
) -> Result<RequestUriResponse, AppError> {
use crate::acl::Role;
use crate::auth::session::now_epoch;
if path == ".well-known" && auth.role != Role::Admin {
return Err(AppError::Forbidden(
"only admins can register the root DID".into(),
));
}
let submitted_method = did_hosting_common::method::detect_method(did_log.as_bytes());
if submitted_method == Some("webs") {
did_hosting_common::server::mnemonic::validate_webs_mnemonic(path)?;
} else {
validate_mnemonic(path)?;
}
let _path_guard = state.path_locks.guard(path).await;
let existing_log = state.dids_ks.get_raw(content_log_key(path)).await?;
let existing_witness = state.dids_ks.get_raw(content_witness_key(path)).await?;
let publication = did_hosting_common::method::publication::verify_publication(
domain.unwrap_or_default(),
path,
did_log.as_bytes(),
existing_log.as_deref(),
existing_witness.as_deref(),
)
.map_err(publication_error)?;
let server_base_url = state
.config
.did_hosting_url
.as_deref()
.or(state.config.public_url.as_deref())
.ok_or_else(|| {
AppError::Internal(
"server has neither did_hosting_url nor public_url configured; cannot validate DID host"
.into(),
)
})?;
let did_id = publication.did_id.clone().ok_or_else(|| {
AppError::Validation("did_log's latest entry has no resolvable did:webvh state.id".into())
})?;
check_did_host_safety(state, auth, &did_id).await?;
if publication.method != "webs" {
did_ops::validate_did_id_matches_request(&did_id, path, server_base_url)
.map_err(AppError::Validation)?;
}
let existing: Option<DidRecord> = state.dids_ks.get(did_key(path)).await?;
let owner_changed = match &existing {
Some(rec) if rec.owner == auth.did => false,
Some(rec) => {
if auth.role != Role::Admin {
warn!(
caller = %auth.did,
owner = %rec.owner,
path = %path,
"atomic-register denied: not the owner of this slot"
);
return Err(AppError::Forbidden(
"slot is owned by a different DID".into(),
));
}
if !force {
warn!(
caller = %auth.did,
owner = %rec.owner,
path = %path,
"atomic-register denied: admin takeover requires force=true"
);
return Err(AppError::Forbidden(
"admin takeover of a slot owned by another DID requires force=true".into(),
));
}
true
}
None => false,
};
if existing.is_none() {
check_did_count_limit(auth, state).await?;
}
let old_content_size = existing.as_ref().map(|r| r.content_size).unwrap_or(0);
check_total_size_limit(auth, state, old_content_size, did_log.len() as u64).await?;
let now = now_epoch();
let (created_at, version_count) = match (&existing, owner_changed) {
(Some(rec), false) => (rec.created_at, rec.version_count + 1),
_ => (now, 1),
};
let mut new_record = DidRecord {
owner: auth.did.clone(),
mnemonic: path.to_string(),
created_at,
updated_at: now,
version_count,
did_id: Some(did_id.clone()),
content_size: did_log.len() as u64,
disabled: false,
deleted_at: None,
method: publication.method.to_string(),
domain: if publication.method == "webs" {
did_hosting_common::server::domain::extract_did_host(&did_id).unwrap_or_default()
} else {
String::new()
},
services: publication.services(),
agent_names: existing
.as_ref()
.map(|r| r.agent_names.clone())
.unwrap_or_default(),
};
let reg_domain =
did_hosting_common::server::domain::extract_did_host(&did_id).unwrap_or_default();
let (claimed, released) = reconcile_agent_names(
state,
&mut new_record,
path,
&publication.agent_names(®_domain),
®_domain,
now,
)
.await?;
let mut batch = state.store.batch();
batch.insert_raw(
&state.dids_ks,
content_log_key(path),
did_log.as_bytes().to_vec(),
);
if owner_changed {
let prev = existing
.as_ref()
.expect("owner_changed => existing record present");
batch.remove(&state.dids_ks, content_witness_key(path));
batch.remove(&state.dids_ks, owner_key(&prev.owner, path));
}
batch.insert(&state.dids_ks, did_key(path), &new_record)?;
batch.insert_raw(
&state.dids_ks,
owner_key(&auth.did, path),
path.as_bytes().to_vec(),
);
for name in &claimed {
batch.insert_raw(
&state.dids_ks,
agent_name_key(®_domain, name),
path.as_bytes().to_vec(),
);
}
for name in &released {
batch.remove(&state.dids_ks, agent_name_key(®_domain, name));
}
batch.commit().await?;
invalidate_cached_content(state, path);
state.stats_collector.record_update(path);
let did_url = format!("{}/{path}/did.jsonl", server_base_url.trim_end_matches('/'));
info!(
did = %auth.did,
path = %path,
version = version_count,
owner_changed,
"DID atomically registered on control plane"
);
Ok(RequestUriResponse {
mnemonic: path.to_string(),
did_url,
})
}
async fn prepare_republish(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
did_log: &str,
request_domain: Option<&str>,
) -> Result<(DidRecord, String, Publication), AppError> {
use crate::auth::session::now_epoch;
let record_method = state
.dids_ks
.get::<DidRecord>(did_key(mnemonic))
.await?
.map(|r| r.method);
if record_method.as_deref() == Some("webs") {
did_hosting_common::server::mnemonic::validate_webs_mnemonic(mnemonic)?;
} else {
validate_mnemonic(mnemonic)?;
}
let mut record = get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
if let Some(submitted) = did_hosting_common::method::detect_method(did_log.as_bytes())
&& submitted != record.method
{
return Err(AppError::Validation(format!(
"slot '{mnemonic}' hosts a did:{} DID; it cannot be republished as did:{submitted}",
record.method,
)));
}
let existing = state
.dids_ks
.get_raw(content_log_key(mnemonic).as_str())
.await?;
let existing_witness = state
.dids_ks
.get_raw(content_witness_key(mnemonic).as_str())
.await?;
let publication = did_hosting_common::method::publication::verify_publication(
&record.domain,
mnemonic,
did_log.as_bytes(),
existing.as_deref(),
existing_witness.as_deref(),
)
.map_err(publication_error)?;
let new_size = did_log.len() as u64;
let did_id_val = publication.did_id.clone();
check_total_size_limit(auth, state, record.content_size, new_size).await?;
if let Some(did_id) = did_id_val.as_deref() {
check_did_host_safety(state, auth, did_id).await?;
}
if let Some(requested) = request_domain.filter(|d| !d.is_empty())
&& let Some(did_id) = did_id_val.as_deref()
{
let host = did_hosting_common::server::domain::extract_did_host(did_id)?;
if !requested.eq_ignore_ascii_case(&host) {
return Err(AppError::Validation(format!(
"did-management:unknown_domain — requested domain `{requested}` does \
not match the DID's host `{host}`",
)));
}
}
record.updated_at = now_epoch();
record.version_count += 1;
record.did_id = did_id_val.clone();
record.content_size = new_size;
record.services = publication.services();
if record.domain.is_empty()
&& let Some(did_id) = did_id_val.as_deref()
&& let Ok(host) = did_hosting_common::server::domain::extract_did_host(did_id)
{
record.domain = host;
}
let domain = if !record.domain.is_empty() {
record.domain.clone()
} else {
did_id_val
.as_deref()
.and_then(|d| did_hosting_common::server::domain::extract_did_host(d).ok())
.unwrap_or_default()
};
Ok((record, domain, publication))
}
async fn reconcile_agent_names(
state: &AppState,
record: &mut DidRecord,
mnemonic: &str,
claims: &[String],
domain: &str,
now: u64,
) -> Result<(Vec<String>, Vec<String>), AppError> {
let mut claimed = Vec::new();
for name in claims.iter().cloned() {
match validate_agent_name_binding(&name, mnemonic) {
Ok(()) => {}
Err(AppError::AgentName(AgentNameError::Reserved)) => {
warn!(
mnemonic = %mnemonic,
name = %name,
"publish claims a reserved agent name; refusing"
);
return Err(AgentNameError::Reserved.into());
}
Err(_) => {
debug!(
mnemonic = %mnemonic,
name = %name,
"alsoKnownAs entry is not a valid agent name; not registering"
);
continue;
}
}
if let Some(bytes) = state.dids_ks.get_raw(agent_name_key(domain, &name)).await?
&& bytes != mnemonic.as_bytes()
{
warn!(
mnemonic = %mnemonic,
name = %name,
"publish claims an agent name held by another DID; refusing"
);
return Err(AgentNameError::Taken.into());
}
claimed.push(name);
}
let is_claimed = |n: &str| claimed.iter().any(|c| c == n);
let mut released = Vec::new();
for entry in record.agent_names.iter().filter(|e| e.enabled) {
if is_claimed(&entry.name) {
continue;
}
let ours = state
.dids_ks
.get_raw(agent_name_key(domain, &entry.name))
.await?
.is_some_and(|bytes| bytes == mnemonic.as_bytes());
if ours {
released.push(entry.name.clone());
}
}
let mut next: Vec<AgentNameEntry> = record
.agent_names
.iter()
.filter(|e| !e.enabled && !is_claimed(&e.name))
.cloned()
.collect();
for name in &claimed {
let created_at = record
.agent_names
.iter()
.find(|e| &e.name == name)
.map(|e| e.created_at)
.unwrap_or(now);
next.push(AgentNameEntry {
name: name.clone(),
enabled: true,
created_at,
});
}
record.agent_names = next;
Ok((claimed, released))
}
pub async fn publish_did(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
did_log: &str,
request_domain: Option<&str>,
) -> Result<(), AppError> {
let _guard = state.path_locks.guard(mnemonic).await;
let (mut record, domain, publication) =
prepare_republish(auth, state, mnemonic, did_log, request_domain).await?;
let new_size = record.content_size;
let now = record.updated_at;
let (claimed, released) = reconcile_agent_names(
state,
&mut record,
mnemonic,
&publication.agent_names(&domain),
&domain,
now,
)
.await?;
let mut batch = state.store.batch();
batch.insert_raw(
&state.dids_ks,
content_log_key(mnemonic),
did_log.as_bytes().to_vec(),
);
batch.insert(&state.dids_ks, did_key(mnemonic), &record)?;
for name in &claimed {
batch.insert_raw(
&state.dids_ks,
agent_name_key(&domain, name),
mnemonic.as_bytes().to_vec(),
);
}
for name in &released {
batch.remove(&state.dids_ks, agent_name_key(&domain, name));
}
batch.commit().await?;
invalidate_cached_content(state, mnemonic);
state.stats_collector.record_update(mnemonic);
info!(
did = %auth.did,
mnemonic = %mnemonic,
size = new_size,
version = record.version_count,
"did.jsonl published on control plane"
);
crate::identity_rotation::on_did_published(state, mnemonic).await;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AgentNameOp {
Activate,
Remove,
Park,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub enum AgentNameState {
Active,
Parked,
}
impl AgentNameOp {
fn requires_claim(self) -> bool {
matches!(self, AgentNameOp::Activate)
}
}
pub async fn update_agent_name(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
name: &str,
did_log: &str,
request_domain: Option<&str>,
desired: AgentNameState,
) -> Result<DidRecord, AppError> {
let op = match desired {
AgentNameState::Active => AgentNameOp::Activate,
AgentNameState::Parked => AgentNameOp::Park,
};
apply_agent_name_op(auth, state, mnemonic, name, did_log, request_domain, op).await
}
pub async fn remove_agent_name(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
name: &str,
did_log: &str,
request_domain: Option<&str>,
) -> Result<DidRecord, AppError> {
apply_agent_name_op(
auth,
state,
mnemonic,
name,
did_log,
request_domain,
AgentNameOp::Remove,
)
.await
}
enum IndexWrite {
Insert,
Remove,
Keep,
}
#[allow(clippy::too_many_arguments)]
async fn apply_agent_name_op(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
name: &str,
did_log: &str,
request_domain: Option<&str>,
op: AgentNameOp,
) -> Result<DidRecord, AppError> {
use crate::auth::session::now_epoch;
validate_agent_name_binding(name, mnemonic)?;
let name = name.strip_prefix('@').unwrap_or(name).to_string();
let _guard = state.path_locks.guard(mnemonic).await;
let (mut record, domain, publication) =
prepare_republish(auth, state, mnemonic, did_log, request_domain).await?;
let names = publication.agent_names(&domain);
let claimed = names.iter().any(|n| n == &name);
if claimed != op.requires_claim() {
return Err(AgentNameError::AlsoKnownAsMismatch.into());
}
let index_key = agent_name_key(&domain, &name);
let index_write = match op {
AgentNameOp::Activate => {
if let Some(bytes) = state.dids_ks.get_raw(index_key.clone()).await?
&& bytes != mnemonic.as_bytes()
{
return Err(AgentNameError::Taken.into());
}
upsert_agent_name(&mut record.agent_names, &name, now_epoch());
IndexWrite::Insert
}
AgentNameOp::Park => {
let entry = record
.agent_names
.iter_mut()
.find(|e| e.name == name)
.ok_or(AgentNameError::NotFound)?;
entry.enabled = false;
IndexWrite::Keep
}
AgentNameOp::Remove => {
let before = record.agent_names.len();
record.agent_names.retain(|e| e.name != name);
if record.agent_names.len() == before {
return Err(AgentNameError::NotFound.into());
}
IndexWrite::Remove
}
};
let mut batch = state.store.batch();
batch.insert_raw(
&state.dids_ks,
content_log_key(mnemonic),
did_log.as_bytes().to_vec(),
);
batch.insert(&state.dids_ks, did_key(mnemonic), &record)?;
match index_write {
IndexWrite::Insert => {
batch.insert_raw(&state.dids_ks, index_key, mnemonic.as_bytes().to_vec())
}
IndexWrite::Remove => batch.remove(&state.dids_ks, index_key),
IndexWrite::Keep => {}
}
batch.commit().await?;
state.stats_collector.record_update(mnemonic);
info!(
did = %auth.did,
mnemonic = %mnemonic,
name = %name,
?op,
version = record.version_count,
"agent name updated on control plane"
);
crate::identity_rotation::on_did_published(state, mnemonic).await;
Ok(record)
}
fn upsert_agent_name(names: &mut Vec<AgentNameEntry>, name: &str, now: u64) {
if let Some(entry) = names.iter_mut().find(|e| e.name == name) {
entry.enabled = true;
} else {
names.push(AgentNameEntry {
name: name.to_string(),
enabled: true,
created_at: now,
});
}
}
pub async fn upload_witness(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
witness_entry: &serde_json::Map<String, serde_json::Value>,
) -> Result<(), AppError> {
validate_mnemonic(mnemonic)?;
let _path_guard = state.path_locks.guard(mnemonic).await;
get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
use did_hosting_common::server::error::ValidationKind;
let version_id = witness_entry
.get("versionId")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AppError::validation(
ValidationKind::InvalidWitness,
"witness object must have a string \"versionId\"",
)
})?
.to_string();
let proof = witness_entry.get("proof").cloned().ok_or_else(|| {
AppError::validation(
ValidationKind::InvalidWitness,
"witness object must have a \"proof\"",
)
})?;
let log_content = state
.dids_ks
.get_raw(content_log_key(mnemonic))
.await?
.ok_or_else(|| {
AppError::validation(
ValidationKind::InvalidWitness,
"no published log exists for this DID yet",
)
})?;
let log_content = String::from_utf8(log_content)
.map_err(|e| AppError::Internal(format!("stored did:webvh log is not valid UTF-8: {e}")))?;
did_hosting_common::did_ops::verify_witness_proof_for_version(
&log_content,
&version_id,
&proof,
)
.map_err(|e| AppError::validation(ValidationKind::InvalidWitness, e))?;
let existing = state.dids_ks.get_raw(content_witness_key(mnemonic)).await?;
let mut entries: Vec<serde_json::Value> = existing
.and_then(|bytes| serde_json::from_slice::<Vec<serde_json::Value>>(&bytes).ok())
.unwrap_or_default();
match entries
.iter_mut()
.find(|e| e.get("versionId").and_then(|v| v.as_str()) == Some(version_id.as_str()))
{
Some(entry) => match entry.get_mut("proof").and_then(|p| p.as_array_mut()) {
Some(proofs) => proofs.push(proof),
None => {
entry["proof"] = serde_json::json!([proof]);
}
},
None => entries.push(serde_json::json!({ "versionId": version_id, "proof": [proof] })),
}
let witness_content = serde_json::to_string(&entries)
.map_err(|e| AppError::Internal(format!("failed to serialise did-witness.json: {e}")))?;
state
.dids_ks
.insert_raw(content_witness_key(mnemonic), witness_content.into_bytes())
.await?;
invalidate_cached_content(state, mnemonic);
info!(did = %auth.did, mnemonic = %mnemonic, %version_id, "did-witness.json uploaded on control plane");
Ok(())
}
pub async fn get_did_info(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
) -> Result<(DidRecord, Option<LogMetadata>), AppError> {
validate_mnemonic(mnemonic)?;
let record = get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
let log_metadata = match state.dids_ks.get_raw(content_log_key(mnemonic)).await? {
Some(bytes) => {
let content = String::from_utf8(bytes).unwrap_or_default();
Some(extract_log_metadata(&content))
}
None => None,
};
debug!(did = %auth.did, mnemonic = %mnemonic, "DID info retrieved from control plane");
Ok((record, log_metadata))
}
pub async fn get_did_log(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
) -> Result<Vec<LogEntryInfo>, AppError> {
validate_mnemonic(mnemonic)?;
get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
let bytes = state
.dids_ks
.get_raw(content_log_key(mnemonic))
.await?
.ok_or_else(|| AppError::NotFound("no log content for this DID".into()))?;
let content = String::from_utf8(bytes)
.map_err(|e| AppError::Internal(format!("invalid log bytes: {e}")))?;
Ok(did_ops::parse_log_entries(&content))
}
pub async fn get_raw_log(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
) -> Result<String, AppError> {
validate_mnemonic(mnemonic)?;
get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
let bytes = state
.dids_ks
.get_raw(content_log_key(mnemonic))
.await?
.ok_or_else(|| AppError::NotFound("no log content for this DID".into()))?;
String::from_utf8(bytes).map_err(|e| AppError::Internal(format!("invalid log bytes: {e}")))
}
pub async fn list_dids(
auth: &AuthClaims,
state: &AppState,
requested_owner: Option<&str>,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<Vec<DidListEntry>, AppError> {
use crate::acl::Role;
if auth.role == Role::Admin && requested_owner.is_none() {
return list_all_dids(state).await;
}
let target_owner = if auth.role == Role::Admin {
requested_owner.unwrap_or(&auth.did)
} else {
&auth.did
};
let prefix = format!("owner:{target_owner}:");
let raw = state.dids_ks.prefix_iter_raw(prefix).await?;
let mut entries = Vec::with_capacity(raw.len());
for (_key, value) in raw {
let mnemonic = String::from_utf8(value)
.map_err(|e| AppError::Internal(format!("invalid mnemonic bytes: {e}")))?;
if let Some(record) = state.dids_ks.get::<DidRecord>(did_key(&mnemonic)).await? {
if record.owner != target_owner {
continue;
}
let stats_key = format!("stats:{mnemonic}");
let did_stats: did_hosting_common::DidStats =
state.stats_ks.get(stats_key).await?.unwrap_or_default();
let agent_names = record.agent_names.clone();
entries.push(DidListEntry {
mnemonic: record.mnemonic,
owner: record.owner,
created_at: record.created_at,
updated_at: record.updated_at,
version_count: record.version_count,
did_id: record.did_id,
total_resolves: did_stats.total_resolves,
disabled: record.disabled,
method: (!record.method.is_empty()).then(|| record.method.clone()),
domain: (!record.domain.is_empty()).then(|| record.domain.clone()),
agent_names,
services: record.services,
});
}
}
const MAX_LIST_LIMIT: usize = 1000;
let offset = offset.unwrap_or(0);
let limit = limit.unwrap_or(MAX_LIST_LIMIT).min(MAX_LIST_LIMIT);
let total = entries.len();
let entries: Vec<_> = entries.into_iter().skip(offset).take(limit).collect();
info!(did = %auth.did, owner = %target_owner, total, returned = entries.len(), "DIDs listed on control plane");
Ok(entries)
}
async fn list_all_dids(state: &AppState) -> Result<Vec<DidListEntry>, AppError> {
let raw = state.dids_ks.prefix_iter_raw("did:").await?;
let mut entries = Vec::with_capacity(raw.len());
for (_key, value) in raw {
let record: DidRecord = match serde_json::from_slice(&value) {
Ok(r) => r,
Err(_) => continue,
};
let stats_key = format!("stats:{}", record.mnemonic);
let did_stats: did_hosting_common::DidStats =
state.stats_ks.get(stats_key).await?.unwrap_or_default();
let agent_names = record.agent_names.clone();
entries.push(DidListEntry {
mnemonic: record.mnemonic,
owner: record.owner,
created_at: record.created_at,
updated_at: record.updated_at,
version_count: record.version_count,
did_id: record.did_id,
total_resolves: did_stats.total_resolves,
disabled: record.disabled,
method: (!record.method.is_empty()).then(|| record.method.clone()),
domain: (!record.domain.is_empty()).then(|| record.domain.clone()),
agent_names,
services: record.services,
});
}
info!(
count = entries.len(),
"all DIDs listed (admin) on control plane"
);
Ok(entries)
}
pub async fn list_dids_page(
auth: &AuthClaims,
replica: bool,
state: &AppState,
requested_owner: Option<&str>,
domain: Option<&str>,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<(Vec<(DidRecord, u64)>, u64), AppError> {
use crate::acl::Role;
const MAX_LIST_LIMIT: usize = 1000;
let mut records: Vec<DidRecord> =
if (auth.role == Role::Admin || replica) && requested_owner.is_none() {
let raw = state.dids_ks.prefix_iter_raw("did:").await?;
raw.into_iter()
.filter_map(|(_, value)| serde_json::from_slice::<DidRecord>(&value).ok())
.collect()
} else {
let target_owner = if auth.role == Role::Admin {
requested_owner.unwrap_or(&auth.did)
} else {
&auth.did
};
let raw = state
.dids_ks
.prefix_iter_raw(format!("owner:{target_owner}:"))
.await?;
let mut out = Vec::with_capacity(raw.len());
for (_key, value) in raw {
let mnemonic = String::from_utf8(value)
.map_err(|e| AppError::Internal(format!("invalid mnemonic bytes: {e}")))?;
if let Some(record) = state.dids_ks.get::<DidRecord>(did_key(&mnemonic)).await?
&& record.owner == target_owner
{
out.push(record);
}
}
out
};
if let Some(domain) = domain {
records.retain(|r| {
let host = if r.domain.is_empty() {
r.did_id
.as_deref()
.and_then(|d| did_hosting_common::server::domain::extract_did_host(d).ok())
.unwrap_or_default()
} else {
r.domain.clone()
};
host.eq_ignore_ascii_case(domain)
});
}
records.sort_by(|a, b| a.mnemonic.cmp(&b.mnemonic));
let total = records.len() as u64;
let offset = offset.unwrap_or(0);
let limit = limit.unwrap_or(MAX_LIST_LIMIT).min(MAX_LIST_LIMIT);
let mut page = Vec::with_capacity(limit.min(records.len()));
for record in records.into_iter().skip(offset).take(limit) {
let stats: did_hosting_common::DidStats = state
.stats_ks
.get(format!("stats:{}", record.mnemonic))
.await?
.unwrap_or_default();
page.push((record, stats.total_resolves));
}
info!(did = %auth.did, total, returned = page.len(), "DID page listed on control plane");
Ok((page, total))
}
pub(crate) fn ensure_slot_domain_matches(
record: &DidRecord,
request_domain: Option<&str>,
) -> Result<(), AppError> {
let Some(requested) = request_domain.filter(|d| !d.is_empty()) else {
return Ok(());
};
let slot_domain = if !record.domain.is_empty() {
record.domain.clone()
} else {
record
.did_id
.as_deref()
.and_then(|d| did_hosting_common::server::domain::extract_did_host(d).ok())
.unwrap_or_default()
};
if !slot_domain.is_empty() && !requested.eq_ignore_ascii_case(&slot_domain) {
return Err(AppError::Validation(format!(
"did-management:unknown_domain — requested domain `{requested}` does \
not match the slot's domain `{slot_domain}`",
)));
}
Ok(())
}
pub async fn delete_did(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
request_domain: Option<&str>,
) -> Result<Option<String>, AppError> {
validate_mnemonic(mnemonic)?;
let record = get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
let did_id = record.did_id.clone();
if let Some(did_id) = did_id.as_deref() {
check_did_host_safety(state, auth, did_id).await?;
}
ensure_slot_domain_matches(&record, request_domain)?;
let mut batch = state.store.batch();
batch.remove(&state.dids_ks, did_key(mnemonic));
batch.remove(&state.dids_ks, content_log_key(mnemonic));
batch.remove(&state.dids_ks, content_witness_key(mnemonic));
batch.remove(&state.dids_ks, owner_key(&record.owner, mnemonic));
batch.commit().await?;
invalidate_cached_content(state, mnemonic);
info!(did = %auth.did, mnemonic = %mnemonic, "DID deleted on control plane");
Ok(did_id)
}
pub async fn change_did_owner(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
new_owner: &str,
) -> Result<DidRecord, AppError> {
use did_hosting_common::server::acl::get_acl_entry;
use crate::auth::session::now_epoch;
validate_mnemonic(mnemonic)?;
let _path_guard = state.path_locks.guard(mnemonic).await;
let mut record = get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
if let Some(did_id) = record.did_id.as_deref() {
check_did_host_safety(state, auth, did_id).await?;
}
let new_owner = resolve_did_or_agent_name(state, new_owner).await?;
if record.owner == new_owner {
return Ok(record);
}
if get_acl_entry(&state.acl_ks, &new_owner).await?.is_none() {
return Err(AppError::Validation(format!(
"new owner '{new_owner}' is not in the ACL — add them first"
)));
}
let prev_owner = std::mem::replace(&mut record.owner, new_owner.clone());
record.updated_at = now_epoch();
let mut batch = state.store.batch();
batch.insert(&state.dids_ks, did_key(mnemonic), &record)?;
batch.remove(&state.dids_ks, owner_key(&prev_owner, mnemonic));
batch.insert_raw(
&state.dids_ks,
owner_key(&new_owner, mnemonic),
mnemonic.as_bytes().to_vec(),
);
batch.commit().await?;
info!(
caller = %auth.did,
prev_owner = %prev_owner,
new_owner = %new_owner,
mnemonic = %mnemonic,
"DID owner changed on control plane"
);
Ok(record)
}
pub async fn set_did_disabled(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
disabled: bool,
) -> Result<(), AppError> {
validate_mnemonic(mnemonic)?;
let mut record = get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
if let Some(did_id) = record.did_id.as_deref() {
check_did_host_safety(state, auth, did_id).await?;
}
record.disabled = disabled;
state.dids_ks.insert(did_key(mnemonic), &record).await?;
invalidate_cached_content(state, mnemonic);
info!(
did = %auth.did,
mnemonic = %mnemonic,
disabled,
"DID disabled state updated on control plane"
);
Ok(())
}
pub async fn rollback_did(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
) -> Result<(DidRecord, Option<LogMetadata>), AppError> {
let (record, metadata, _) = rollback_did_to(auth, state, mnemonic, None).await?;
Ok((record, metadata))
}
pub async fn rollback_did_to(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
target_version: Option<u64>,
) -> Result<(DidRecord, Option<LogMetadata>, u64), AppError> {
use crate::auth::session::now_epoch;
validate_mnemonic(mnemonic)?;
let mut record = get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
if let Some(did_id) = record.did_id.as_deref() {
check_did_host_safety(state, auth, did_id).await?;
}
let bytes = state
.dids_ks
.get_raw(content_log_key(mnemonic))
.await?
.ok_or_else(|| AppError::NotFound("no log content for this DID".into()))?;
let content = String::from_utf8(bytes)
.map_err(|e| AppError::Internal(format!("invalid log bytes: {e}")))?;
let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
let keep = match target_version {
None => {
if lines.len() < 2 {
return Err(AppError::Validation(
"cannot rollback: DID log must have at least 2 entries".into(),
));
}
lines.len() - 1
}
Some(target) => {
if target < 1 || target as usize > lines.len() {
return Err(AppError::Validation(format!(
"invalid target version {target}: the log holds {} entries",
lines.len()
)));
}
target as usize
}
};
let removed = (lines.len() - keep) as u64;
if removed == 0 {
let metadata = Some(extract_log_metadata(&content));
return Ok((record, metadata, 0));
}
let truncated_lines = &lines[..keep];
let truncated = truncated_lines.join("\n");
let new_did_id = extract_did_id(&truncated);
let new_size = truncated.len() as u64;
record.version_count = truncated_lines.len() as u64;
record.did_id = new_did_id;
record.content_size = new_size;
record.updated_at = now_epoch();
record.services = extract_service_types(&truncated);
let mut batch = state.store.batch();
batch.insert_raw(
&state.dids_ks,
content_log_key(mnemonic),
truncated.as_bytes().to_vec(),
);
batch.insert(&state.dids_ks, did_key(mnemonic), &record)?;
batch.remove(&state.dids_ks, content_witness_key(mnemonic));
batch.commit().await?;
invalidate_cached_content(state, mnemonic);
let log_metadata = Some(extract_log_metadata(&truncated));
info!(
did = %auth.did,
mnemonic = %mnemonic,
remaining = truncated_lines.len(),
removed,
"DID log rolled back on control plane"
);
Ok((record, log_metadata, removed))
}
pub async fn check_name(state: &AppState, path: &str) -> Result<CheckNameResponse, AppError> {
validate_custom_path(path)?;
let available = is_path_available(&state.dids_ks, path).await?;
Ok(CheckNameResponse {
available,
path: path.to_string(),
})
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentNameAvailability {
pub name: String,
pub domain: String,
pub available: bool,
pub reserved: bool,
}
pub async fn check_agent_name(
state: &AppState,
domain: &str,
name: &str,
) -> Result<AgentNameAvailability, AppError> {
let bare = name.strip_prefix('@').unwrap_or(name).to_string();
let reserved = if bare.is_empty() {
true
} else {
match validate_agent_name(&bare) {
Ok(()) => false,
Err(AppError::AgentName(AgentNameError::Reserved)) => true,
Err(e) => return Err(e),
}
};
let taken = state
.dids_ks
.get_raw(agent_name_key(domain, &bare))
.await?
.is_some();
Ok(AgentNameAvailability {
available: !reserved && !taken,
reserved,
name: bare,
domain: domain.to_string(),
})
}
pub async fn list_agent_names(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
request_domain: Option<&str>,
) -> Result<(String, Vec<AgentNameEntry>), AppError> {
validate_mnemonic(mnemonic)?;
let record = get_authorized_record(&state.dids_ks, mnemonic, auth).await?;
ensure_slot_domain_matches(&record, request_domain)?;
let domain = if !record.domain.is_empty() {
record.domain.clone()
} else {
record
.did_id
.as_deref()
.and_then(|d| did_hosting_common::server::domain::extract_did_host(d).ok())
.unwrap_or_default()
};
debug!(
did = %auth.did,
mnemonic = %mnemonic,
count = record.agent_names.len(),
"agent names listed"
);
Ok((domain, record.agent_names))
}
pub const MAX_RESOLVE_DIDS: usize = 64;
pub async fn resolve_did_or_agent_name(state: &AppState, input: &str) -> Result<String, AppError> {
let trimmed = input.trim();
if !agent_names::AgentName::looks_like_agent_name(trimmed) {
return validate_did_format(trimmed);
}
let name = agent_names::AgentName::parse(trimmed)
.map_err(|e| AppError::Validation(format!("'{trimmed}' is not a valid agent name: {e}")))?;
match resolve_agent_name_to_did(state, name.authority(), name.local_name()).await? {
Some(did) => Ok(did),
None => Err(AppError::Validation(format!(
"agent name '{}' does not resolve to a DID hosted here — bind it first, \
or use the DID directly",
name.without_scheme()
))),
}
}
pub async fn resolve_agent_name_to_did(
state: &AppState,
domain: &str,
name: &str,
) -> Result<Option<String>, AppError> {
if !state.config.features.agent_names {
return Ok(None);
}
let bare = name.strip_prefix('@').unwrap_or(name);
let Some(mnemonic) = state
.dids_ks
.get_raw(agent_name_key(domain, bare))
.await?
.and_then(|b| String::from_utf8(b).ok())
else {
return Ok(None);
};
let Some(record) = state.dids_ks.get::<DidRecord>(did_key(&mnemonic)).await? else {
return Ok(None);
};
if record.disabled || record.deleted_at.is_some() {
return Ok(None);
}
if !record
.agent_names
.iter()
.any(|e| e.name == bare && e.enabled)
{
return Ok(None);
}
Ok(record.did_id)
}
pub async fn resolve_agent_names(
state: &AppState,
reader: &AuthClaims,
dids: &[String],
) -> Result<std::collections::HashMap<String, Vec<String>>, AppError> {
use std::collections::HashMap;
if !state.config.features.agent_names {
return Ok(HashMap::new());
}
if dids.len() > MAX_RESOLVE_DIDS {
return Err(AppError::Validation(format!(
"at most {MAX_RESOLVE_DIDS} DIDs per resolve request, got {}",
dids.len()
)));
}
let mut out: HashMap<String, Vec<String>> = HashMap::new();
for did in dids {
if out.contains_key(did) {
continue;
}
let Some(mnemonic) = mnemonic_from_did(did) else {
continue;
};
let Some(record) = state
.dids_ks
.get::<DidRecord>(did_key(&mnemonic))
.await
.ok()
.flatten()
else {
continue;
};
if record.did_id.as_deref() != Some(did.as_str())
|| record.disabled
|| record.deleted_at.is_some()
{
continue;
}
if reader.role != crate::acl::Role::Admin && record.owner != reader.did {
continue;
}
let names: Vec<String> = record
.agent_names
.iter()
.filter(|e| e.enabled)
.map(|e| e.name.clone())
.collect();
if !names.is_empty() {
out.insert(did.clone(), names);
}
}
Ok(out)
}
#[cfg(test)]
mod tests_atomic {
use super::*;
use did_hosting_common::server::store::{
KS_ACL, KS_DIDS, KS_REGISTRY, KS_SESSIONS, KS_STATS, KS_TIMESERIES,
};
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use affinidi_tdk::secrets_resolver::secrets::Secret;
use did_hosting_common::DidRegisterRequest;
use did_hosting_common::did::{DidDocumentOptions, build_did_document, create_log_entry};
use did_hosting_common::server::config::{
AuthConfig, FeaturesConfig, LogConfig, SecretsConfig, ServerConfig, StoreConfig, VtaConfig,
};
use did_hosting_common::server::stats_collector::StatsCollector;
use did_hosting_common::server::store::Store;
use crate::acl::Role;
use crate::config::{AppConfig, RegistryConfig};
use crate::server::AppState;
async fn build_test_did_log(_scid: &str, host_encoded: &str, path: &str) -> String {
let signing = Secret::generate_ed25519(None, None);
let signing_pub_mb = signing
.get_public_keymultibase()
.expect("signing public key multibase");
let doc = build_did_document(
host_encoded,
path,
&signing_pub_mb,
&DidDocumentOptions::default(),
);
let (_scid, jsonl) = create_log_entry(&doc, &signing)
.await
.expect("create_log_entry");
jsonl
}
async fn build_test_did_log_with_transports(
host_encoded: &str,
path: &str,
tsp: bool,
didcomm: bool,
) -> String {
const MED: &str = "did:webvh:QmMED:mediator.example.com";
let signing = Secret::generate_ed25519(None, None);
let signing_pub_mb = signing
.get_public_keymultibase()
.expect("signing public key multibase");
let doc = build_did_document(
host_encoded,
path,
&signing_pub_mb,
&DidDocumentOptions {
key_agreement_multibase: None,
mediator_endpoint: didcomm.then_some(MED),
tsp_endpoint: tsp.then_some(MED),
},
);
let (_scid, jsonl) = create_log_entry(&doc, &signing)
.await
.expect("create_log_entry");
jsonl
}
async fn stored_services(state: &AppState, path: &str) -> Option<Vec<String>> {
let record: DidRecord = state
.dids_ks
.get(did_key(path))
.await
.unwrap()
.expect("record");
record.services
}
async fn test_state() -> (AppState, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let store_config = StoreConfig {
data_dir: PathBuf::from(dir.path()),
..StoreConfig::default()
};
let store = Store::open(&store_config).await.expect("open store");
let sessions_ks = store.keyspace(KS_SESSIONS).expect("sessions ks");
let acl_ks = store.keyspace(KS_ACL).expect("acl ks");
let registry_ks = store.keyspace(KS_REGISTRY).expect("registry ks");
let dids_ks = store.keyspace(KS_DIDS).expect("dids ks");
let stats_ks = store.keyspace(KS_STATS).expect("stats ks");
let config = AppConfig {
features: FeaturesConfig::default(),
server_did: Some("did:webvh:test:control.example.com".into()),
mediator_did: None,
public_url: Some("http://control.test".into()),
did_hosting_url: Some("http://control.test".into()),
server: ServerConfig::default(),
log: LogConfig::default(),
store: store_config,
fjall: Default::default(),
auth: AuthConfig::default(),
secrets: SecretsConfig::default(),
vta: VtaConfig::default(),
registry: RegistryConfig::default(),
trust_tasks: Default::default(),
hosting: Default::default(),
identity: Default::default(),
config_path: PathBuf::new(),
};
let state = AppState {
store: store.clone(),
sessions_ks,
acl_ks,
registry_ks,
dids_ks,
config: Arc::new(config),
did_resolver: None,
secrets_resolver: None,
identity: None,
trust_tasks_verifier: None,
jwt_keys: None,
webauthn: None,
http_client: reqwest::Client::new(),
didcomm_service: Arc::new(OnceLock::new()),
stats_collector: Arc::new(StatsCollector::new()),
stats_ks: stats_ks.clone(),
timeseries_ks: store.keyspace(KS_TIMESERIES).expect("timeseries ks"),
signing_key_bytes: None,
replay_cache: Arc::new(crate::replay::ReplayCache::new()),
path_locks: crate::path_locks::PathLocks::new(),
acl_locks: did_hosting_common::server::path_locks::PathLocks::new(),
pending_challenges: Arc::new(crate::pending_challenges::PendingChallengeTracker::new()),
ip_rate_limiter: Arc::new(crate::rate_limit::IpRateLimiter::new()),
redeem_rate_limiter: Arc::new(crate::rate_limit::SourceRateLimiter::new()),
large_document_budget: Arc::new(
did_hosting_common::server::trust_tasks::size::LargeDocumentBudget::new(),
),
outbox_notify: Arc::new(tokio::sync::Notify::new()),
cache_invalidate: None,
oob: Default::default(),
};
(state, dir)
}
fn owner_auth(did: &str) -> AuthClaims {
AuthClaims {
did: did.to_string(),
role: Role::Owner,
session_pubkey_b58btc: None,
session_id: String::new(),
amr: vec!["did".to_string()],
acr: "aal1".to_string(),
}
}
fn admin_auth(did: &str) -> AuthClaims {
AuthClaims {
did: did.to_string(),
role: Role::Admin,
session_pubkey_b58btc: None,
session_id: String::new(),
amr: vec!["did".to_string()],
acr: "aal1".to_string(),
}
}
#[tokio::test]
async fn fresh_slot_succeeds_and_writes_atomically() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "alpha";
let did_log = build_test_did_log("scid-alpha", "control.test", path).await;
let result = register_did_atomic(&owner_auth(owner), &state, path, &did_log, false, None)
.await
.expect("fresh-slot register should succeed");
assert_eq!(result.mnemonic, path);
assert_eq!(result.did_url, "http://control.test/alpha/did.jsonl");
let record: DidRecord = state
.dids_ks
.get(did_key(path))
.await
.unwrap()
.expect("record");
assert_eq!(record.owner, owner);
assert_eq!(record.version_count, 1);
let did_id = record.did_id.as_deref().expect("did_id present");
assert!(
did_id.starts_with("did:webvh:") && did_id.ends_with(":control.test:alpha"),
"did_id must be did:webvh:<scid>:control.test:alpha; got {did_id}"
);
let log = state
.dids_ks
.get_raw(content_log_key(path))
.await
.unwrap()
.expect("log");
assert_eq!(log, did_log.as_bytes());
let owner_idx = state.dids_ks.get_raw(owner_key(owner, path)).await.unwrap();
assert!(owner_idx.is_some(), "owner index must be written");
}
#[tokio::test]
async fn mutations_call_the_cache_invalidate_hook_when_one_is_installed() {
let (mut state, _dir) = test_state().await;
let calls: Arc<std::sync::Mutex<Vec<String>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
let recorded = calls.clone();
state.cache_invalidate = Some(Arc::new(move |key: &str| {
recorded.lock().unwrap().push(key.to_string());
}));
let owner = "did:example:owner";
let path = "cache-hook";
let did_log = build_test_did_log("scid-cache-hook", "control.test", path).await;
let expected_log_key = content_log_key(path);
let expected_witness_key = content_witness_key(path);
let count_of = |calls: &[String], key: &str| calls.iter().filter(|k| *k == key).count();
register_did_atomic(&owner_auth(owner), &state, path, &did_log, false, None)
.await
.expect("register");
{
let calls = calls.lock().unwrap();
assert_eq!(count_of(&calls, &expected_log_key), 1, "register: log key");
assert_eq!(
count_of(&calls, &expected_witness_key),
1,
"register: witness key"
);
}
publish_did(&owner_auth(owner), &state, path, &did_log, None)
.await
.expect("publish");
assert_eq!(
count_of(&calls.lock().unwrap(), &expected_log_key),
2,
"publish must invalidate too"
);
set_did_disabled(&owner_auth(owner), &state, path, true)
.await
.expect("disable");
assert_eq!(
count_of(&calls.lock().unwrap(), &expected_log_key),
3,
"disable must invalidate too"
);
delete_did(&owner_auth(owner), &state, path, None)
.await
.expect("delete");
assert_eq!(
count_of(&calls.lock().unwrap(), &expected_log_key),
4,
"delete must invalidate too"
);
assert!(
calls
.lock()
.unwrap()
.iter()
.all(|k| *k == expected_log_key || *k == expected_witness_key),
"every call must invalidate this mnemonic's own content keys"
);
}
#[tokio::test]
async fn owner_re_register_is_idempotent() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "beta";
let log_v1 = build_test_did_log("scid-beta", "control.test", path).await;
let r1 = register_did_atomic(&owner_auth(owner), &state, path, &log_v1, false, None)
.await
.unwrap();
let rec_v1: DidRecord = state.dids_ks.get(did_key(path)).await.unwrap().unwrap();
let log_v2 = build_test_did_log("scid-beta", "control.test", path).await;
let r2 = register_did_atomic(&owner_auth(owner), &state, path, &log_v2, false, None)
.await
.expect("idempotent re-register should succeed without force");
assert_eq!(r1.mnemonic, r2.mnemonic);
let rec_v2: DidRecord = state.dids_ks.get(did_key(path)).await.unwrap().unwrap();
assert_eq!(rec_v2.owner, owner);
assert_eq!(rec_v2.version_count, rec_v1.version_count + 1);
assert_eq!(
rec_v2.created_at, rec_v1.created_at,
"owner re-register must preserve created_at"
);
}
#[tokio::test]
async fn other_owner_without_admin_forbidden() {
let (state, _dir) = test_state().await;
let path = "gamma";
let owner_a = "did:example:owner-a";
let owner_b = "did:example:owner-b";
let log = build_test_did_log("scid-gamma", "control.test", path).await;
register_did_atomic(&owner_auth(owner_a), &state, path, &log, false, None)
.await
.unwrap();
let err = register_did_atomic(&owner_auth(owner_b), &state, path, &log, false, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::Forbidden(_)));
let err = register_did_atomic(&owner_auth(owner_b), &state, path, &log, true, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::Forbidden(_)));
}
#[tokio::test]
async fn admin_takeover_requires_force() {
let (state, _dir) = test_state().await;
let path = "delta";
let log = build_test_did_log("scid-delta", "control.test", path).await;
register_did_atomic(
&owner_auth("did:example:owner-a"),
&state,
path,
&log,
false,
None,
)
.await
.unwrap();
let err = register_did_atomic(
&admin_auth("did:example:admin"),
&state,
path,
&log,
false,
None,
)
.await
.unwrap_err();
assert!(matches!(err, AppError::Forbidden(ref m) if m.contains("force")));
}
#[tokio::test]
async fn admin_takeover_with_force_succeeds() {
let (state, _dir) = test_state().await;
let path = "epsilon";
let owner_a = "did:example:owner-a";
let admin = "did:example:admin";
let log = build_test_did_log("scid-epsilon", "control.test", path).await;
register_did_atomic(&owner_auth(owner_a), &state, path, &log, false, None)
.await
.unwrap();
state
.dids_ks
.insert_raw(content_witness_key(path), b"prior-witness".to_vec())
.await
.unwrap();
register_did_atomic(&admin_auth(admin), &state, path, &log, true, None)
.await
.expect("admin force takeover should succeed");
let rec: DidRecord = state.dids_ks.get(did_key(path)).await.unwrap().unwrap();
assert_eq!(rec.owner, admin);
assert_eq!(rec.version_count, 1, "takeover resets version_count");
assert!(
state
.dids_ks
.get_raw(owner_key(owner_a, path))
.await
.unwrap()
.is_none(),
"old owner-index entry must be removed on takeover"
);
assert!(
state
.dids_ks
.get_raw(owner_key(admin, path))
.await
.unwrap()
.is_some(),
"new owner-index entry must be present after takeover"
);
assert!(
state
.dids_ks
.get_raw(content_witness_key(path))
.await
.unwrap()
.is_none(),
"prior witness must be cleared on takeover (signed for prior DID)"
);
}
#[tokio::test]
async fn mismatched_did_path_rejected() {
let (state, _dir) = test_state().await;
let log = build_test_did_log("scid-x", "control.test", "wrong-path").await;
let err = register_did_atomic(
&owner_auth("did:example:owner"),
&state,
"right-path",
&log,
false,
None,
)
.await
.unwrap_err();
assert!(matches!(err, AppError::Validation(ref m) if m.contains("path")));
assert!(
state
.dids_ks
.get_raw(did_key("right-path"))
.await
.unwrap()
.is_none(),
"no record must be written when validation fails"
);
}
#[tokio::test]
async fn mismatched_did_host_rejected() {
let (state, _dir) = test_state().await;
let log = build_test_did_log("scid-x", "other-host.example", "valid-path").await;
let err = register_did_atomic(
&owner_auth("did:example:owner"),
&state,
"valid-path",
&log,
false,
None,
)
.await
.unwrap_err();
assert!(matches!(err, AppError::Validation(ref m) if m.contains("host")));
}
async fn seed_active_domain(state: &AppState, name: &str) -> String {
use did_hosting_common::server::domain::{
create_domain,
types::{DomainEntry, DomainStatus, DomainUrlScheme},
};
let entry = DomainEntry {
name: name.into(),
label: None,
scheme: DomainUrlScheme::Https,
status: DomainStatus::Active,
created_at: 0,
default_domain: true,
branding: None,
witnesses: None,
watchers: None,
quota: None,
well_known_enabled: false,
disabled_at: None,
purge_at: None,
};
create_domain(&state.store, &entry).await.unwrap();
name.into()
}
async fn seed_caller_acl(
state: &AppState,
did: &str,
role: Role,
scope: did_hosting_common::server::domain::DomainScope,
) {
use crate::acl::store_acl_entry;
use did_hosting_common::server::acl::AclEntry;
let entry = AclEntry {
did: did.into(),
role,
label: None,
created_at: 0,
max_total_size: None,
max_did_count: None,
domains: scope,
};
store_acl_entry(&state.acl_ks, &entry).await.unwrap();
}
#[tokio::test]
async fn register_with_domain_seeded_owner_scoped_to_matching_host() {
use did_hosting_common::server::domain::DomainScope;
let (state, _dir) = test_state().await;
let owner = "did:example:scoped";
seed_active_domain(&state, "control.test").await;
seed_caller_acl(
&state,
owner,
Role::Owner,
DomainScope::Allowed {
domains: vec!["control.test".into()],
},
)
.await;
let did_log = build_test_did_log("scid-ok", "control.test", "alpha").await;
let result =
register_did_atomic(&owner_auth(owner), &state, "alpha", &did_log, false, None)
.await
.expect("scoped owner on matching host must succeed");
assert_eq!(result.mnemonic, "alpha");
}
#[tokio::test]
async fn register_acl_rejects_host_outside_allowed_scope() {
use did_hosting_common::server::domain::DomainScope;
let (state, _dir) = test_state().await;
let owner = "did:example:scoped";
seed_active_domain(&state, "control.test").await;
{
use did_hosting_common::server::domain::{
create_domain,
types::{DomainEntry, DomainStatus, DomainUrlScheme},
};
create_domain(
&state.store,
&DomainEntry {
name: "domain-b.example".into(),
label: None,
scheme: DomainUrlScheme::Https,
status: DomainStatus::Active,
created_at: 0,
default_domain: false,
branding: None,
witnesses: None,
watchers: None,
quota: None,
well_known_enabled: false,
disabled_at: None,
purge_at: None,
},
)
.await
.unwrap();
}
seed_caller_acl(
&state,
owner,
Role::Owner,
DomainScope::Allowed {
domains: vec!["control.test".into()],
},
)
.await;
let did_log = build_test_did_log("scid-evil", "domain-b.example", "alpha").await;
let err = register_did_atomic(&owner_auth(owner), &state, "alpha", &did_log, false, None)
.await
.expect_err("ACL must reject host outside scope");
assert!(
matches!(err, AppError::Forbidden(_)),
"expected Forbidden, got {err:?}"
);
assert!(
state
.dids_ks
.get_raw(did_key("alpha"))
.await
.unwrap()
.is_none(),
"no record may be written when ACL rejects the host"
);
}
#[tokio::test]
async fn register_admin_can_write_any_active_domain() {
use did_hosting_common::server::domain::DomainScope;
let (state, _dir) = test_state().await;
let admin = "did:example:admin";
seed_active_domain(&state, "control.test").await;
seed_caller_acl(
&state,
admin,
Role::Admin,
DomainScope::Allowed {
domains: vec!["irrelevant.example".into()],
},
)
.await;
let did_log = build_test_did_log("scid-admin", "control.test", "alpha").await;
register_did_atomic(&admin_auth(admin), &state, "alpha", &did_log, false, None)
.await
.expect("admin role overrides ACL domain scope");
}
#[tokio::test]
async fn invalid_did_log_rejected() {
let (state, _dir) = test_state().await;
let err = register_did_atomic(
&owner_auth("did:example:owner"),
&state,
"any-path",
"not valid jsonl",
false,
None,
)
.await
.unwrap_err();
assert!(matches!(err, AppError::Validation(_)));
}
#[tokio::test]
async fn register_root_did_at_well_known_as_admin() {
let (state, _dir) = test_state().await;
let did_log = build_test_did_log("scid-root", "control.test", "").await;
register_did_atomic(
&admin_auth("did:example:admin"),
&state,
".well-known",
&did_log,
false,
None,
)
.await
.expect("admin may register the root DID at the .well-known slot");
let record: DidRecord = state
.dids_ks
.get(did_key(".well-known"))
.await
.unwrap()
.expect("root slot record written");
assert_eq!(record.mnemonic, ".well-known");
assert_eq!(
record.did_id.as_deref(),
extract_did_id(&did_log).as_deref(),
"the root slot must hold the registered root DID"
);
}
#[tokio::test]
async fn register_root_did_forbidden_for_non_admin() {
let (state, _dir) = test_state().await;
let did_log = build_test_did_log("scid-root", "control.test", "").await;
let err = register_did_atomic(
&owner_auth("did:example:owner"),
&state,
".well-known",
&did_log,
false,
None,
)
.await
.unwrap_err();
assert!(
matches!(err, AppError::Forbidden(_)),
"non-admin root register must be Forbidden, got {err:?}"
);
}
#[tokio::test]
async fn register_rejects_other_dotted_paths_even_for_admin() {
let (state, _dir) = test_state().await;
let did_log = build_test_did_log("scid-dot", "control.test", "hidden").await;
let err = register_did_atomic(
&admin_auth("did:example:admin"),
&state,
".hidden",
&did_log,
false,
None,
)
.await
.unwrap_err();
assert!(
matches!(err, AppError::Validation(_)),
"dotted non-root path must still be rejected, got {err:?}"
);
}
#[test]
fn did_register_request_round_trip_defaults_force_false() {
let raw = r#"{"path":"alpha","did_log":"line"}"#;
let req: DidRegisterRequest = serde_json::from_str(raw).unwrap();
assert_eq!(req.path, "alpha");
assert_eq!(req.did_log.as_deref(), Some("line"));
assert!(!req.force, "force must default to false");
let (method, payload) = req.resolve().unwrap();
assert_eq!(method, "webvh");
assert_eq!(payload, b"line");
}
#[tokio::test]
async fn register_did_atomic_records_update_stats() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "stats-fresh";
let did_log = build_test_did_log("scid-stats", "control.test", path).await;
let before = state.stats_collector.get_aggregate().total_updates;
register_did_atomic(&owner_auth(owner), &state, path, &did_log, false, None)
.await
.unwrap();
let after = state.stats_collector.get_aggregate().total_updates;
assert_eq!(
after,
before + 1,
"fresh atomic register must advance total_updates by 1"
);
register_did_atomic(&owner_auth(owner), &state, path, &did_log, false, None)
.await
.unwrap();
let after_two = state.stats_collector.get_aggregate().total_updates;
assert_eq!(
after_two,
after + 1,
"idempotent re-register must also advance total_updates"
);
}
#[tokio::test]
async fn publish_did_records_update_stats() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "stats-publish";
let did_log = build_test_did_log("scid-publish", "control.test", path).await;
let baseline = state.stats_collector.get_aggregate().total_updates;
create_did(&owner_auth(owner), &state, Some(path), false, None)
.await
.unwrap();
assert_eq!(
state.stats_collector.get_aggregate().total_updates,
baseline,
"create_did is a slot reservation; must NOT count as an update"
);
publish_did(&owner_auth(owner), &state, path, &did_log, None)
.await
.unwrap();
assert_eq!(
state.stats_collector.get_aggregate().total_updates,
baseline + 1,
"publish_did must record an update on success"
);
publish_did(&owner_auth(owner), &state, path, &did_log, None)
.await
.unwrap();
assert_eq!(
state.stats_collector.get_aggregate().total_updates,
baseline + 2,
"subsequent publishes must keep advancing total_updates"
);
}
#[tokio::test]
async fn publish_cross_checks_explicit_domain_against_did_host() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "domain-xcheck";
let did_log = build_test_did_log("scid-domain", "control.test", path).await;
create_did(&owner_auth(owner), &state, Some(path), false, None)
.await
.unwrap();
publish_did(&owner_auth(owner), &state, path, &did_log, None)
.await
.expect("omitted domain still publishes");
publish_did(
&owner_auth(owner),
&state,
path,
&did_log,
Some("control.test"),
)
.await
.expect("matching domain publishes");
let err = publish_did(
&owner_auth(owner),
&state,
path,
&did_log,
Some("evil.example"),
)
.await
.unwrap_err();
assert!(
matches!(err, AppError::Validation(ref m) if m.contains("unknown_domain")),
"mismatched domain must be rejected as unknown_domain, got {err:?}"
);
}
#[tokio::test]
async fn failed_publish_does_not_record_update() {
let (state, _dir) = test_state().await;
let owner_a = "did:example:owner-a";
let owner_b = "did:example:owner-b";
let path = "stats-fail";
let did_log = build_test_did_log("scid-fail", "control.test", path).await;
create_did(&owner_auth(owner_a), &state, Some(path), false, None)
.await
.unwrap();
let baseline = state.stats_collector.get_aggregate().total_updates;
let err = publish_did(&owner_auth(owner_b), &state, path, &did_log, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::Forbidden(_)));
assert_eq!(
state.stats_collector.get_aggregate().total_updates,
baseline,
"auth-denied publish must NOT record an update"
);
let err = publish_did(&owner_auth(owner_a), &state, path, "not-jsonl", None)
.await
.unwrap_err();
assert!(matches!(err, AppError::Validation(_)));
assert_eq!(
state.stats_collector.get_aggregate().total_updates,
baseline,
"validation-failed publish must NOT record an update"
);
}
#[tokio::test]
async fn concurrent_register_serialises() {
let (state, _dir) = test_state().await;
let owner_a = "did:example:owner-a".to_string();
let path = "race-path";
let log_a = build_test_did_log("scid-race", "control.test", path).await;
let log_b = log_a.clone();
let auth_a = owner_auth(&owner_a);
let auth_b = owner_auth(&owner_a);
let state_a = state.clone();
let state_b = state.clone();
let path_a = path.to_string();
let path_b = path.to_string();
let task_a = tokio::spawn(async move {
register_did_atomic(&auth_a, &state_a, &path_a, &log_a, false, None).await
});
let task_b = tokio::spawn(async move {
register_did_atomic(&auth_b, &state_b, &path_b, &log_b, false, None).await
});
let r_a = task_a.await.unwrap();
let r_b = task_b.await.unwrap();
assert!(
r_a.is_ok() && r_b.is_ok(),
"both same-owner registers should succeed; got a={r_a:?}, b={r_b:?}"
);
let record: DidRecord = state
.dids_ks
.get(did_key(path))
.await
.unwrap()
.expect("record present after race");
assert_eq!(record.owner, owner_a);
assert_eq!(
record.version_count, 2,
"both register calls must have committed in sequence; got version_count={}",
record.version_count
);
}
#[tokio::test]
async fn create_did_persists_caller_supplied_domain() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "alpha-domain";
create_did(
&owner_auth(owner),
&state,
Some(path),
false,
Some("tenant.example"),
)
.await
.expect("create_did with domain should succeed");
let record: DidRecord = state
.dids_ks
.get(did_key(path))
.await
.unwrap()
.expect("record");
assert_eq!(
record.domain, "tenant.example",
"domain on the DidRecord MUST equal the caller-supplied value"
);
}
#[tokio::test]
async fn create_did_with_no_domain_leaves_field_empty() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "alpha-no-domain";
create_did(&owner_auth(owner), &state, Some(path), false, None)
.await
.expect("create_did without domain should succeed");
let record: DidRecord = state
.dids_ks
.get(did_key(path))
.await
.unwrap()
.expect("record");
assert_eq!(
record.domain, "",
"domain on the DidRecord MUST be empty when create_did was called with None"
);
}
#[tokio::test]
async fn publish_did_backfills_empty_domain_from_did_id() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "alpha-backfill";
let did_log = build_test_did_log("scid-backfill", "control.test", path).await;
create_did(&owner_auth(owner), &state, Some(path), false, None)
.await
.unwrap();
let before: DidRecord = state
.dids_ks
.get(did_key(path))
.await
.unwrap()
.expect("record");
assert_eq!(before.domain, "", "test precondition: domain starts empty");
publish_did(&owner_auth(owner), &state, path, &did_log, None)
.await
.unwrap();
let after: DidRecord = state
.dids_ks
.get(did_key(path))
.await
.unwrap()
.expect("record");
assert_eq!(
after.domain, "control.test",
"publish_did must populate domain from the did_id host segment when the persisted value is empty"
);
publish_did(&owner_auth(owner), &state, path, &did_log, None)
.await
.unwrap();
let after_again: DidRecord = state
.dids_ks
.get(did_key(path))
.await
.unwrap()
.expect("record");
assert_eq!(
after_again.domain, "control.test",
"publish_did backfill must be idempotent — a populated domain stays put"
);
}
#[tokio::test]
async fn create_did_leaves_services_unknown() {
let (state, _dir) = test_state().await;
create_did(
&owner_auth("did:example:owner"),
&state,
Some("slot"),
false,
None,
)
.await
.unwrap();
assert_eq!(stored_services(&state, "slot").await, None);
}
#[tokio::test]
async fn publish_did_caches_advertised_services() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "with-transports";
let did_log = build_test_did_log_with_transports("control.test", path, true, true).await;
create_did(&owner_auth(owner), &state, Some(path), false, None)
.await
.unwrap();
assert_eq!(
stored_services(&state, path).await,
None,
"precondition: no document yet"
);
publish_did(&owner_auth(owner), &state, path, &did_log, None)
.await
.unwrap();
assert_eq!(
stored_services(&state, path).await,
Some(vec![
"TSPTransport".to_string(),
"DIDCommMessaging".to_string()
])
);
}
#[tokio::test]
async fn publish_did_caches_empty_services_for_bare_document() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "bare";
let did_log = build_test_did_log("scid", "control.test", path).await;
create_did(&owner_auth(owner), &state, Some(path), false, None)
.await
.unwrap();
publish_did(&owner_auth(owner), &state, path, &did_log, None)
.await
.unwrap();
assert_eq!(stored_services(&state, path).await, Some(vec![]));
}
#[tokio::test]
async fn publish_did_retracts_a_dropped_service() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "drops-tsp";
create_did(&owner_auth(owner), &state, Some(path), false, None)
.await
.unwrap();
let both = build_test_did_log_with_transports("control.test", path, true, true).await;
publish_did(&owner_auth(owner), &state, path, &both, None)
.await
.unwrap();
assert!(
stored_services(&state, path)
.await
.unwrap()
.contains(&"TSPTransport".to_string()),
"precondition: TSP advertised"
);
let didcomm_only =
build_test_did_log_with_transports("control.test", path, false, true).await;
publish_did(&owner_auth(owner), &state, path, &didcomm_only, None)
.await
.unwrap();
assert_eq!(
stored_services(&state, path).await,
Some(vec!["DIDCommMessaging".to_string()]),
"TSPTransport must be retracted once the document stops advertising it"
);
}
#[tokio::test]
async fn publish_did_backfills_services_on_legacy_record() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "legacy";
let did_log = build_test_did_log_with_transports("control.test", path, true, false).await;
create_did(&owner_auth(owner), &state, Some(path), false, None)
.await
.unwrap();
let mut rec: DidRecord = state.dids_ks.get(did_key(path)).await.unwrap().unwrap();
rec.services = None;
state.dids_ks.insert(did_key(path), &rec).await.unwrap();
publish_did(&owner_auth(owner), &state, path, &did_log, None)
.await
.unwrap();
assert_eq!(
stored_services(&state, path).await,
Some(vec!["TSPTransport".to_string()])
);
}
#[tokio::test]
async fn list_dids_surfaces_cached_services() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "listed";
let did_log = build_test_did_log_with_transports("control.test", path, true, true).await;
create_did(&owner_auth(owner), &state, Some(path), false, None)
.await
.unwrap();
publish_did(&owner_auth(owner), &state, path, &did_log, None)
.await
.unwrap();
let entries = list_dids(&owner_auth(owner), &state, None, None, None)
.await
.expect("list_dids");
let entry = entries
.iter()
.find(|e| e.mnemonic == path)
.expect("published DID in list");
assert_eq!(
entry.services,
Some(vec![
"TSPTransport".to_string(),
"DIDCommMessaging".to_string()
])
);
}
async fn build_test_did_log_with_names(host: &str, path: &str, names: &[&str]) -> String {
let signing = Secret::generate_ed25519(None, None);
let signing_pub_mb = signing
.get_public_keymultibase()
.expect("signing public key multibase");
let mut doc =
build_did_document(host, path, &signing_pub_mb, &DidDocumentOptions::default());
if !names.is_empty() {
let aka: Vec<String> = names.iter().map(|n| format!("{host}/@{n}")).collect();
doc["alsoKnownAs"] = serde_json::json!(aka);
}
let (_scid, jsonl) = create_log_entry(&doc, &signing)
.await
.expect("create_log_entry");
jsonl
}
async fn get_record(state: &AppState, path: &str) -> DidRecord {
state
.dids_ks
.get(did_key(path))
.await
.unwrap()
.expect("record")
}
async fn index_target(state: &AppState, domain: &str, name: &str) -> Option<String> {
state
.dids_ks
.get_raw(agent_name_key(domain, name))
.await
.unwrap()
.map(|b| String::from_utf8(b).unwrap())
}
async fn register_owned(state: &AppState, owner: &str, path: &str) {
let log = build_test_did_log("scid", "control.test", path).await;
register_did_atomic(&owner_auth(owner), state, path, &log, false, None)
.await
.expect("register");
}
async fn set_agent_name(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
name: &str,
did_log: &str,
domain: Option<&str>,
) -> Result<DidRecord, AppError> {
update_agent_name(
auth,
state,
mnemonic,
name,
did_log,
domain,
AgentNameState::Active,
)
.await
}
async fn enable_agent_name(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
name: &str,
did_log: &str,
domain: Option<&str>,
) -> Result<DidRecord, AppError> {
set_agent_name(auth, state, mnemonic, name, did_log, domain).await
}
async fn disable_agent_name(
auth: &AuthClaims,
state: &AppState,
mnemonic: &str,
name: &str,
did_log: &str,
domain: Option<&str>,
) -> Result<DidRecord, AppError> {
update_agent_name(
auth,
state,
mnemonic,
name,
did_log,
domain,
AgentNameState::Parked,
)
.await
}
#[tokio::test]
async fn set_agent_name_binds_when_document_claims_it() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "slot-set";
register_owned(&state, owner, path).await;
let before = get_record(&state, path).await.version_count;
let log = build_test_did_log_with_names("control.test", path, &["alice"]).await;
let record = set_agent_name(&owner_auth(owner), &state, path, "alice", &log, None)
.await
.expect("set should bind a claimed name");
assert!(
record
.agent_names
.iter()
.any(|e| e.name == "alice" && e.enabled),
"alice must be present and enabled"
);
assert_eq!(
record.version_count,
before + 1,
"publish must bump version"
);
assert_eq!(
index_target(&state, "control.test", "alice")
.await
.as_deref(),
Some(path),
"index must point at the slot"
);
let record = get_record(&state, path).await;
assert!(record.agent_names.iter().any(|e| e.name == "alice"));
}
#[tokio::test]
async fn set_agent_name_rejects_and_commits_nothing_on_mismatch() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "slot-mismatch";
register_owned(&state, owner, path).await;
let before = get_record(&state, path).await.version_count;
let log = build_test_did_log_with_names("control.test", path, &["bob"]).await;
let err = set_agent_name(&owner_auth(owner), &state, path, "alice", &log, None)
.await
.unwrap_err();
assert!(matches!(
err,
AppError::AgentName(AgentNameError::AlsoKnownAsMismatch)
));
let record = get_record(&state, path).await;
assert!(
record.agent_names.is_empty(),
"no registry entry may be written on a rejected set"
);
assert_eq!(
record.version_count, before,
"a rejected set must not advance the version"
);
assert_eq!(
index_target(&state, "control.test", "alice").await,
None,
"no index entry may leak on a rejected set (atomicity)"
);
}
#[tokio::test]
async fn set_agent_name_rejects_reserved() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "slot-reserved";
register_owned(&state, owner, path).await;
let log = build_test_did_log_with_names("control.test", path, &["admin"]).await;
let err = set_agent_name(&owner_auth(owner), &state, path, "admin", &log, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::AgentName(AgentNameError::Reserved)));
}
#[tokio::test]
async fn set_agent_name_collision_is_taken_but_same_owner_refreshes() {
let (state, _dir) = test_state().await;
let owner_a = "did:example:a";
let owner_b = "did:example:b";
register_owned(&state, owner_a, "slot-a").await;
register_owned(&state, owner_b, "slot-b").await;
let log_a = build_test_did_log_with_names("control.test", "slot-a", &["alice"]).await;
set_agent_name(
&owner_auth(owner_a),
&state,
"slot-a",
"alice",
&log_a,
None,
)
.await
.expect("first bind");
let log_b = build_test_did_log_with_names("control.test", "slot-b", &["alice"]).await;
let err = set_agent_name(
&owner_auth(owner_b),
&state,
"slot-b",
"alice",
&log_b,
None,
)
.await
.unwrap_err();
assert!(matches!(err, AppError::AgentName(AgentNameError::Taken)));
let log_a2 = build_test_did_log_with_names("control.test", "slot-a", &["alice"]).await;
set_agent_name(
&owner_auth(owner_a),
&state,
"slot-a",
"alice",
&log_a2,
None,
)
.await
.expect("idempotent re-set by the same owner");
assert_eq!(
index_target(&state, "control.test", "alice")
.await
.as_deref(),
Some("slot-a")
);
}
async fn seed_record_on_host(state: &AppState, owner: &str, path: &str, host: &str) {
let record = DidRecord {
owner: owner.to_string(),
mnemonic: path.to_string(),
created_at: 0,
updated_at: 0,
version_count: 1,
did_id: Some(format!("did:webvh:seed:{host}:{path}")),
content_size: 0,
disabled: false,
deleted_at: None,
method: "webvh".to_string(),
domain: host.to_string(),
services: None,
agent_names: Vec::new(),
};
state
.dids_ks
.insert(did_key(path), &record)
.await
.expect("seed record");
}
#[tokio::test]
async fn set_agent_name_is_domain_scoped() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
for (host, path) in [("one.example", "slot-one"), ("two.example", "slot-two")] {
seed_record_on_host(&state, owner, path, host).await;
let named = build_test_did_log_with_names(host, path, &["alice"]).await;
set_agent_name(&owner_auth(owner), &state, path, "alice", &named, None)
.await
.expect("bind alice on this domain");
}
assert_eq!(
index_target(&state, "one.example", "alice")
.await
.as_deref(),
Some("slot-one")
);
assert_eq!(
index_target(&state, "two.example", "alice")
.await
.as_deref(),
Some("slot-two")
);
}
#[tokio::test]
async fn set_agent_name_requires_owner() {
let (state, _dir) = test_state().await;
register_owned(&state, "did:example:owner", "slot-auth").await;
let log = build_test_did_log_with_names("control.test", "slot-auth", &["alice"]).await;
let err = set_agent_name(
&owner_auth("did:example:intruder"),
&state,
"slot-auth",
"alice",
&log,
None,
)
.await
.unwrap_err();
assert!(matches!(err, AppError::Forbidden(_)));
}
#[tokio::test]
async fn disable_agent_name_parks_but_keeps_reservation() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "slot-disable";
register_owned(&state, owner, path).await;
let set_log = build_test_did_log_with_names("control.test", path, &["alice"]).await;
set_agent_name(&owner_auth(owner), &state, path, "alice", &set_log, None)
.await
.expect("set");
let still_claims = build_test_did_log_with_names("control.test", path, &["alice"]).await;
let err = disable_agent_name(
&owner_auth(owner),
&state,
path,
"alice",
&still_claims,
None,
)
.await
.unwrap_err();
assert!(matches!(
err,
AppError::AgentName(AgentNameError::AlsoKnownAsMismatch)
));
let dropped = build_test_did_log_with_names("control.test", path, &[]).await;
let record = disable_agent_name(&owner_auth(owner), &state, path, "alice", &dropped, None)
.await
.expect("disable");
assert!(
record
.agent_names
.iter()
.any(|e| e.name == "alice" && !e.enabled),
"alice must remain present but disabled"
);
assert_eq!(
index_target(&state, "control.test", "alice")
.await
.as_deref(),
Some(path),
"a parked name keeps its index entry (stays reserved)"
);
let dropped2 = build_test_did_log_with_names("control.test", path, &[]).await;
let record = disable_agent_name(&owner_auth(owner), &state, path, "alice", &dropped2, None)
.await
.expect("idempotent park");
assert!(
record
.agent_names
.iter()
.any(|e| e.name == "alice" && !e.enabled)
);
}
#[tokio::test]
async fn enable_agent_name_resumes_a_parked_name() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "slot-enable";
register_owned(&state, owner, path).await;
let set_log = build_test_did_log_with_names("control.test", path, &["alice"]).await;
set_agent_name(&owner_auth(owner), &state, path, "alice", &set_log, None)
.await
.expect("set");
let claims = build_test_did_log_with_names("control.test", path, &["alice"]).await;
let record = enable_agent_name(&owner_auth(owner), &state, path, "alice", &claims, None)
.await
.expect("idempotent activate");
assert!(
record
.agent_names
.iter()
.any(|e| e.name == "alice" && e.enabled)
);
let dropped = build_test_did_log_with_names("control.test", path, &[]).await;
disable_agent_name(&owner_auth(owner), &state, path, "alice", &dropped, None)
.await
.expect("disable");
let reclaim = build_test_did_log_with_names("control.test", path, &["alice"]).await;
let record = enable_agent_name(&owner_auth(owner), &state, path, "alice", &reclaim, None)
.await
.expect("enable");
assert!(
record
.agent_names
.iter()
.any(|e| e.name == "alice" && e.enabled)
);
}
#[tokio::test]
async fn park_agent_name_unknown_is_not_found() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "slot-park-unknown";
register_owned(&state, owner, path).await;
let dropped = build_test_did_log_with_names("control.test", path, &[]).await;
let err = disable_agent_name(&owner_auth(owner), &state, path, "ghost", &dropped, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::AgentName(AgentNameError::NotFound)));
}
#[tokio::test]
async fn remove_agent_name_releases_the_name() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "slot-remove";
register_owned(&state, owner, path).await;
let set_log = build_test_did_log_with_names("control.test", path, &["alice"]).await;
set_agent_name(&owner_auth(owner), &state, path, "alice", &set_log, None)
.await
.expect("set");
let still_claims = build_test_did_log_with_names("control.test", path, &["alice"]).await;
let err = remove_agent_name(
&owner_auth(owner),
&state,
path,
"alice",
&still_claims,
None,
)
.await
.unwrap_err();
assert!(matches!(
err,
AppError::AgentName(AgentNameError::AlsoKnownAsMismatch)
));
let dropped = build_test_did_log_with_names("control.test", path, &[]).await;
let record = remove_agent_name(&owner_auth(owner), &state, path, "alice", &dropped, None)
.await
.expect("remove");
assert!(
!record.agent_names.iter().any(|e| e.name == "alice"),
"alice must be gone from the registry"
);
assert_eq!(
index_target(&state, "control.test", "alice").await,
None,
"the index entry must be retired so the name is free"
);
let dropped2 = build_test_did_log_with_names("control.test", path, &[]).await;
let err = remove_agent_name(&owner_auth(owner), &state, path, "alice", &dropped2, None)
.await
.unwrap_err();
assert!(matches!(err, AppError::AgentName(AgentNameError::NotFound)));
}
#[tokio::test]
async fn check_agent_name_reports_availability() {
let (state, _dir) = test_state().await;
let r = check_agent_name(&state, "control.test", "admin")
.await
.unwrap();
assert!(!r.available && r.reserved);
let r = check_agent_name(&state, "control.test", "alice")
.await
.unwrap();
assert!(r.available && !r.reserved);
let owner = "did:example:owner";
register_owned(&state, owner, "slot-chk").await;
let log = build_test_did_log_with_names("control.test", "slot-chk", &["alice"]).await;
set_agent_name(&owner_auth(owner), &state, "slot-chk", "alice", &log, None)
.await
.unwrap();
let r = check_agent_name(&state, "control.test", "alice")
.await
.unwrap();
assert!(!r.available && !r.reserved);
let r = check_agent_name(&state, "other.test", "alice")
.await
.unwrap();
assert!(r.available);
assert!(check_agent_name(&state, "control.test", "a").await.is_err());
}
#[tokio::test]
async fn publish_registers_a_claimed_name_so_park_works() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "slot-recon";
register_owned(&state, owner, path).await;
let bind = build_test_did_log_with_names("control.test", path, &["alice"]).await;
publish_did(&owner_auth(owner), &state, path, &bind, None)
.await
.expect("publish");
let rec = get_record(&state, path).await;
assert!(
rec.agent_names
.iter()
.any(|e| e.name == "alice" && e.enabled),
"a name claimed by a plain publish must be registered enabled"
);
assert_eq!(
index_target(&state, "control.test", "alice")
.await
.as_deref(),
Some(path),
"plain publish must write the name index"
);
let park = build_test_did_log_with_names("control.test", path, &[]).await;
let rec = disable_agent_name(&owner_auth(owner), &state, path, "alice", &park, None)
.await
.expect("park a publish-bound name");
assert!(
rec.agent_names
.iter()
.any(|e| e.name == "alice" && !e.enabled)
);
}
#[tokio::test]
async fn publish_releases_a_dropped_name() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "slot-rel";
register_owned(&state, owner, path).await;
let bind = build_test_did_log_with_names("control.test", path, &["alice"]).await;
publish_did(&owner_auth(owner), &state, path, &bind, None)
.await
.unwrap();
let drop = build_test_did_log_with_names("control.test", path, &[]).await;
publish_did(&owner_auth(owner), &state, path, &drop, None)
.await
.unwrap();
let rec = get_record(&state, path).await;
assert!(!rec.agent_names.iter().any(|e| e.name == "alice"));
assert_eq!(index_target(&state, "control.test", "alice").await, None);
}
#[tokio::test]
async fn publish_preserves_a_parked_name() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "slot-park";
register_owned(&state, owner, path).await;
let bind = build_test_did_log_with_names("control.test", path, &["alice"]).await;
publish_did(&owner_auth(owner), &state, path, &bind, None)
.await
.unwrap();
let park = build_test_did_log_with_names("control.test", path, &[]).await;
disable_agent_name(&owner_auth(owner), &state, path, "alice", &park, None)
.await
.unwrap();
let other = build_test_did_log_with_names("control.test", path, &[]).await;
publish_did(&owner_auth(owner), &state, path, &other, None)
.await
.unwrap();
let rec = get_record(&state, path).await;
assert!(
rec.agent_names
.iter()
.any(|e| e.name == "alice" && !e.enabled),
"a parked name survives a plain publish that omits it"
);
assert_eq!(
index_target(&state, "control.test", "alice")
.await
.as_deref(),
Some(path),
"a parked name keeps its index reservation"
);
}
#[tokio::test]
async fn publish_cannot_capture_a_reserved_name() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "slot-pub-reserved";
register_owned(&state, owner, path).await;
let before = get_record(&state, path).await.version_count;
let log = build_test_did_log_with_names("control.test", path, &["admin"]).await;
let err = publish_did(&owner_auth(owner), &state, path, &log, None)
.await
.unwrap_err();
assert!(
matches!(err, AppError::AgentName(AgentNameError::Reserved)),
"expected Reserved, got {err:?}"
);
assert_eq!(
index_target(&state, "control.test", "admin").await,
None,
"a refused publish must not write the index"
);
assert_eq!(
get_record(&state, path).await.version_count,
before,
"a refused publish must not advance the version"
);
}
#[tokio::test]
async fn set_binds_the_community_name_on_the_root_slot() {
let (state, _dir) = test_state().await;
let admin = "did:example:admin";
let log = build_test_did_log("scid", "control.test", ".well-known").await;
register_did_atomic(&admin_auth(admin), &state, ".well-known", &log, false, None)
.await
.expect("admin registers the root DID");
let log = build_test_did_log_with_names("control.test", ".well-known", &[""]).await;
let record = set_agent_name(&admin_auth(admin), &state, ".well-known", "", &log, None)
.await
.expect("set should bind the community name on the root slot");
assert!(
record
.agent_names
.iter()
.any(|e| e.name.is_empty() && e.enabled),
"the community name must be present and enabled"
);
assert_eq!(
index_target(&state, "control.test", "").await.as_deref(),
Some(".well-known"),
"index must point at the root slot"
);
}
#[tokio::test]
async fn set_refuses_the_community_name_on_a_tenant_slot() {
let (state, _dir) = test_state().await;
let admin = "did:example:admin";
let path = "slot-set-community";
register_owned(&state, "did:example:owner", path).await;
let log = build_test_did_log_with_names("control.test", path, &[""]).await;
let err = set_agent_name(&admin_auth(admin), &state, path, "", &log, None)
.await
.unwrap_err();
assert!(
matches!(err, AppError::AgentName(AgentNameError::Reserved)),
"expected Reserved, got {err:?}"
);
assert_eq!(
index_target(&state, "control.test", "").await,
None,
"a refused set must not write the community index entry"
);
}
#[tokio::test]
async fn publish_cannot_capture_the_community_name() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "slot-pub-community";
register_owned(&state, owner, path).await;
let before = get_record(&state, path).await.version_count;
let log = build_test_did_log_with_names("control.test", path, &[""]).await;
let err = publish_did(&owner_auth(owner), &state, path, &log, None)
.await
.unwrap_err();
assert!(
matches!(err, AppError::AgentName(AgentNameError::Reserved)),
"expected Reserved, got {err:?}"
);
assert_eq!(
index_target(&state, "control.test", "").await,
None,
"a refused publish must not write the community index entry"
);
assert_eq!(
get_record(&state, path).await.version_count,
before,
"a refused publish must not advance the version"
);
}
#[tokio::test]
async fn root_did_may_publish_the_community_name() {
let (state, _dir) = test_state().await;
let admin = "did:example:admin";
let log = build_test_did_log("scid", "control.test", ".well-known").await;
register_did_atomic(&admin_auth(admin), &state, ".well-known", &log, false, None)
.await
.expect("admin registers the root DID");
let log = build_test_did_log_with_names("control.test", ".well-known", &[""]).await;
publish_did(&admin_auth(admin), &state, ".well-known", &log, None)
.await
.expect("the root DID may claim its domain's community name");
assert_eq!(
index_target(&state, "control.test", "").await.as_deref(),
Some(".well-known"),
"the community name should index to the root slot"
);
}
#[tokio::test]
async fn publish_cannot_hijack_a_name_held_by_another_did() {
let (state, _dir) = test_state().await;
let owner_a = "did:example:a";
let owner_b = "did:example:b";
register_owned(&state, owner_a, "slot-a").await;
register_owned(&state, owner_b, "slot-b").await;
let log_a = build_test_did_log_with_names("control.test", "slot-a", &["alice"]).await;
publish_did(&owner_auth(owner_a), &state, "slot-a", &log_a, None)
.await
.expect("owner A binds alice");
let log_b = build_test_did_log_with_names("control.test", "slot-b", &["alice"]).await;
let err = publish_did(&owner_auth(owner_b), &state, "slot-b", &log_b, None)
.await
.unwrap_err();
assert!(
matches!(err, AppError::AgentName(AgentNameError::Taken)),
"expected Taken, got {err:?}"
);
assert_eq!(
index_target(&state, "control.test", "alice")
.await
.as_deref(),
Some("slot-a"),
"the name must still resolve to its holder"
);
assert!(
get_record(&state, "slot-a")
.await
.agent_names
.iter()
.any(|e| e.name == "alice" && e.enabled),
"the holder's registry entry must be untouched"
);
assert!(
!get_record(&state, "slot-b")
.await
.agent_names
.iter()
.any(|e| e.name == "alice"),
"the hijacker must not end up registered"
);
publish_did(&owner_auth(owner_a), &state, "slot-a", &log_a, None)
.await
.expect("re-publishing one's own name must still work");
}
#[tokio::test]
async fn publish_cannot_hijack_a_parked_name() {
let (state, _dir) = test_state().await;
let owner_a = "did:example:a";
let owner_b = "did:example:b";
register_owned(&state, owner_a, "slot-a").await;
register_owned(&state, owner_b, "slot-b").await;
let bind = build_test_did_log_with_names("control.test", "slot-a", &["alice"]).await;
publish_did(&owner_auth(owner_a), &state, "slot-a", &bind, None)
.await
.unwrap();
let park = build_test_did_log_with_names("control.test", "slot-a", &[]).await;
disable_agent_name(&owner_auth(owner_a), &state, "slot-a", "alice", &park, None)
.await
.expect("park");
let log_b = build_test_did_log_with_names("control.test", "slot-b", &["alice"]).await;
let err = publish_did(&owner_auth(owner_b), &state, "slot-b", &log_b, None)
.await
.unwrap_err();
assert!(
matches!(err, AppError::AgentName(AgentNameError::Taken)),
"expected Taken, got {err:?}"
);
assert!(
get_record(&state, "slot-a")
.await
.agent_names
.iter()
.any(|e| e.name == "alice" && !e.enabled),
"the parked reservation must survive the attempt"
);
}
#[tokio::test]
async fn register_cannot_capture_a_held_or_reserved_name() {
let (state, _dir) = test_state().await;
let owner_a = "did:example:a";
let owner_b = "did:example:b";
register_owned(&state, owner_a, "slot-a").await;
let bind = build_test_did_log_with_names("control.test", "slot-a", &["alice"]).await;
publish_did(&owner_auth(owner_a), &state, "slot-a", &bind, None)
.await
.unwrap();
let log_b = build_test_did_log_with_names("control.test", "slot-new", &["alice"]).await;
let err = register_did_atomic(
&owner_auth(owner_b),
&state,
"slot-new",
&log_b,
false,
None,
)
.await
.unwrap_err();
assert!(
matches!(err, AppError::AgentName(AgentNameError::Taken)),
"expected Taken, got {err:?}"
);
assert_eq!(
index_target(&state, "control.test", "alice")
.await
.as_deref(),
Some("slot-a"),
);
let log_r = build_test_did_log_with_names("control.test", "slot-res", &["support"]).await;
let err = register_did_atomic(
&owner_auth(owner_b),
&state,
"slot-res",
&log_r,
false,
None,
)
.await
.unwrap_err();
assert!(
matches!(err, AppError::AgentName(AgentNameError::Reserved)),
"expected Reserved, got {err:?}"
);
}
#[tokio::test]
async fn publish_skips_an_unserveable_name_instead_of_failing() {
let (state, _dir) = test_state().await;
let owner = "did:example:owner";
let path = "slot-lax";
register_owned(&state, owner, path).await;
let log = build_test_did_log_with_names("control.test", path, &["Bob", "alice"]).await;
publish_did(&owner_auth(owner), &state, path, &log, None)
.await
.expect("an unserveable entry must not fail the publish");
let rec = get_record(&state, path).await;
assert!(
rec.agent_names.iter().any(|e| e.name == "alice"),
"the valid name still registers"
);
assert!(
!rec.agent_names.iter().any(|e| e.name == "Bob"),
"the invalid one must not enter the registry"
);
assert_eq!(
index_target(&state, "control.test", "Bob").await,
None,
"nor the index"
);
}
#[tokio::test]
async fn publish_release_does_not_retire_another_dids_index() {
let (state, _dir) = test_state().await;
let owner_a = "did:example:a";
let owner_b = "did:example:b";
register_owned(&state, owner_a, "slot-a").await;
register_owned(&state, owner_b, "slot-b").await;
let bind = build_test_did_log_with_names("control.test", "slot-a", &["alice"]).await;
publish_did(&owner_auth(owner_a), &state, "slot-a", &bind, None)
.await
.unwrap();
let mut rec_b = get_record(&state, "slot-b").await;
rec_b.agent_names.push(AgentNameEntry {
name: "alice".to_string(),
enabled: true,
created_at: 0,
});
state
.dids_ks
.insert(did_key("slot-b"), &rec_b)
.await
.unwrap();
let drop = build_test_did_log_with_names("control.test", "slot-b", &[]).await;
publish_did(&owner_auth(owner_b), &state, "slot-b", &drop, None)
.await
.unwrap();
assert!(
!get_record(&state, "slot-b")
.await
.agent_names
.iter()
.any(|e| e.name == "alice"),
"B drops its stale entry"
);
assert_eq!(
index_target(&state, "control.test", "alice")
.await
.as_deref(),
Some("slot-a"),
"A's index entry must survive B's release"
);
}
fn race_signing_key() -> Secret {
let mut key = Secret::generate_ed25519(None, None);
let pk = key.get_public_keymultibase().unwrap();
key.id = format!("did:key:{pk}#{pk}");
key
}
fn race_witness_identity() -> (Secret, String) {
let key = race_signing_key();
let witness_did = key.id.split('#').next().unwrap().to_string();
(key, witness_did)
}
fn race_doc_with_key(did: &str, key: &Secret) -> serde_json::Value {
let pk = key.get_public_keymultibase().unwrap();
serde_json::json!({
"id": did,
"@context": ["https://www.w3.org/ns/did/v1"],
"verificationMethod": [{
"id": format!("{did}#key-0"),
"type": "Multikey",
"publicKeyMultibase": pk,
"controller": did
}],
"authentication": [format!("{did}#key-0")],
"assertionMethod": [format!("{did}#key-0")],
})
}
async fn race_sign_witness_proof(
witness_key: &Secret,
version_id: &str,
) -> affinidi_data_integrity::DataIntegrityProof {
affinidi_data_integrity::DataIntegrityProof::sign(
&serde_json::json!({ "versionId": version_id }),
witness_key,
affinidi_data_integrity::SignOptions::new(),
)
.await
.expect("sign witness proof")
}
async fn race_witnessed_log_and_extension(witness_did: &str) -> (String, String, String) {
use didwebvh_rs::Multibase;
use didwebvh_rs::parameters::Parameters;
use didwebvh_rs::witness::{Witness, Witnesses};
let key = race_signing_key();
let params = Parameters {
update_keys: Some(Arc::new(vec![Multibase::new(
key.get_public_keymultibase().unwrap(),
)])),
portable: Some(false),
witness: Some(Arc::new(Witnesses::Value {
threshold: 1,
witnesses: vec![Witness {
id: Multibase::new(witness_did.to_string()),
}],
})),
..Default::default()
};
let doc = race_doc_with_key("did:webvh:{SCID}:control.test:race", &key);
let base_time = (chrono::Utc::now() - chrono::Duration::seconds(10)).fixed_offset();
let mut state = didwebvh_rs::DIDWebVHState::default();
state
.create_log_entry(Some(base_time), &doc, ¶ms, &key)
.await
.expect("genesis entry");
let genesis_version_id = state
.log_entries()
.last()
.unwrap()
.get_version_id()
.to_string();
let genesis_doc = state.log_entries().last().unwrap().get_state().clone();
let to_jsonl = |state: &didwebvh_rs::DIDWebVHState| {
state
.log_entries()
.iter()
.map(|e| serde_json::to_string(&e.log_entry).unwrap())
.collect::<Vec<_>>()
.join("\n")
};
let genesis_jsonl = to_jsonl(&state);
state
.create_log_entry(
Some(base_time + chrono::Duration::seconds(1)),
&genesis_doc,
&Parameters::default(),
&key,
)
.await
.expect("second entry");
let extended_jsonl = to_jsonl(&state);
(genesis_jsonl, extended_jsonl, genesis_version_id)
}
#[tokio::test]
async fn register_atomic_rereads_state_under_the_path_lock_not_before_it() {
let (state, _dir) = test_state().await;
let owner = "did:example:race-owner";
let path = "race";
let (witness_key, witness_did) = race_witness_identity();
let (genesis_jsonl, extended_jsonl, genesis_version_id) =
race_witnessed_log_and_extension(&witness_did).await;
register_did_atomic(
&owner_auth(owner),
&state,
path,
&genesis_jsonl,
false,
None,
)
.await
.expect("witnessed genesis registers with no proof yet");
let proof = race_sign_witness_proof(&witness_key, &genesis_version_id).await;
let witness_entry = serde_json::json!({
"versionId": genesis_version_id,
"proof": serde_json::to_value(&proof).unwrap(),
});
upload_witness(
&owner_auth(owner),
&state,
path,
witness_entry.as_object().unwrap(),
)
.await
.expect("witness proof verifies and is stored");
let guard = state.path_locks.guard(path).await;
let state_clone = state.clone();
let owner_clone = owner.to_string();
let path_clone = path.to_string();
let extended = extended_jsonl.clone();
let task = tokio::spawn(async move {
register_did_atomic(
&owner_auth(&owner_clone),
&state_clone,
&path_clone,
&extended,
false,
None,
)
.await
});
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert!(
!task.is_finished(),
"the spawned call must block on our guard"
);
state
.dids_ks
.insert_raw(content_log_key(path), b"1\n2\n3\n4\n5\n".to_vec())
.await
.unwrap();
drop(guard);
let err = tokio::time::timeout(std::time::Duration::from_secs(5), task)
.await
.expect("spawned call must finish once the guard is dropped")
.unwrap()
.expect_err(
"a read taken under the lock must see the inflated previously_published count \
and demand a witness proof for the new entry, which was never provided",
);
assert!(
err.to_string().contains("threshold"),
"expected a witness-threshold refusal, got: {err}"
);
}
#[cfg(feature = "method-webs")]
mod webs {
use super::*;
const KERI: &[u8] =
include_bytes!("../../did-hosting-common/tests/fixtures/ENro7uf0.keri.cesr");
const AID: &str = "ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe";
const DOMAIN: &str = "control.test";
fn keri() -> String {
String::from_utf8(KERI.to_vec()).expect("CESR is UTF-8 text")
}
async fn record_for(state: &AppState, path: &str) -> DidRecord {
state
.dids_ks
.get(did_key(path))
.await
.unwrap()
.expect("record")
}
#[tokio::test]
async fn registers_a_webs_did_and_stores_its_key_event_log() {
let (state, _dir) = test_state().await;
let owner = "did:example:webs-owner";
register_did_atomic(
&owner_auth(owner),
&state,
AID,
&keri(),
false,
Some(DOMAIN),
)
.await
.expect("a verifying key event log registers");
let record = record_for(&state, AID).await;
assert_eq!(
record.method, "webs",
"tagged by the verifier that accepted it"
);
assert_eq!(record.owner, owner);
assert_eq!(
record.did_id.as_deref(),
Some(format!("did:webs:{DOMAIN}:{AID}").as_str()),
"the identifier is built from the slot, since a KEL carries no domain",
);
assert_eq!(
record.domain, DOMAIN,
"a did:webs record must carry its domain — its DID cannot be rebuilt without it",
);
let stored = state
.dids_ks
.get_raw(content_log_key(AID).as_str())
.await
.unwrap()
.expect("stored log");
assert_eq!(stored, KERI);
}
#[tokio::test]
async fn refuses_a_log_published_to_another_aids_slot() {
let (state, _dir) = test_state().await;
let other = "EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let err = register_did_atomic(
&owner_auth("did:example:webs-owner"),
&state,
other,
&keri(),
false,
Some(DOMAIN),
)
.await
.expect_err("the key event log does not establish this slot's AID");
assert!(
matches!(err, AppError::Validation(_)),
"expected a validation refusal, got {err:?}",
);
}
#[tokio::test]
async fn refuses_a_tampered_key_event_log() {
let (state, _dir) = test_state().await;
let mut tampered = KERI.to_vec();
let pos = tampered
.windows(3)
.position(|w| w == b"\"s\"")
.expect("inception has an `s` field");
tampered[pos + 5] = b'9';
let tampered = String::from_utf8(tampered).unwrap();
let err = register_did_atomic(
&owner_auth("did:example:webs-owner"),
&state,
AID,
&tampered,
false,
Some(DOMAIN),
)
.await
.expect_err("a tampered log must never be stored");
assert!(matches!(err, AppError::Validation(_)));
assert!(
state
.dids_ks
.get_raw(content_log_key(AID).as_str())
.await
.unwrap()
.is_none(),
"a refused publication must leave no content behind",
);
}
#[tokio::test]
async fn a_webs_slot_cannot_be_republished_as_webvh() {
let (state, _dir) = test_state().await;
let owner = "did:example:webs-owner";
register_did_atomic(
&owner_auth(owner),
&state,
AID,
&keri(),
false,
Some(DOMAIN),
)
.await
.expect("register");
let webvh_log = concat!(
r#"{"versionId":"1-zQmTest","versionTime":"2025-01-01T00:00:00Z","#,
r#""state":{"id":"did:webvh:zQmTest:control.test:other"}}"#,
);
let err = publish_did(&owner_auth(owner), &state, AID, webvh_log, None)
.await
.expect_err("a did:webs slot must not accept a did:webvh log");
assert!(
err.to_string().contains("cannot be republished"),
"expected a method-change refusal, got: {err}",
);
}
#[tokio::test]
async fn the_shared_path_grammar_would_have_rejected_this_slot() {
assert!(
did_hosting_common::server::mnemonic::validate_custom_path(AID).is_err(),
"guard: if the shared grammar starts accepting AIDs, revisit the split",
);
let (state, _dir) = test_state().await;
register_did_atomic(
&owner_auth("did:example:webs-owner"),
&state,
AID,
&keri(),
false,
Some(DOMAIN),
)
.await
.expect("the method-aware validator must accept an AID slot");
}
#[tokio::test]
async fn refuses_a_webs_registration_without_a_domain() {
let (state, _dir) = test_state().await;
let err = register_did_atomic(
&owner_auth("did:example:webs-owner"),
&state,
AID,
&keri(),
false,
None,
)
.await
.expect_err("a did:webs DID cannot be built without its domain");
assert!(matches!(err, AppError::Validation(_)));
}
}
}