use affinidi_tdk::secrets_resolver::secrets::Secret;
use chrono::Utc;
use didwebvh_rs::log_entry::LogEntryMethods;
use didwebvh_rs::multibase_type::Multibase;
use didwebvh_rs::update::{UpdateDIDConfig, update_did};
use vta_support::version_time::next_version_time;
use super::errors::UpdateDidWebvhError;
use super::keys::{
derive_secret_for_handle, install_derived_webvh_keys, load_active_update_key,
load_pre_rotation_signing_key, peek_webvh_keys,
};
use super::options::{UpdateDidWebvhOptions, UpdateDidWebvhResult};
use super::plan::UpdatePlan;
use super::state::{find_record_by_scid, state_from_jsonl, state_to_jsonl};
use super::validate::{validate_document_for_update, validate_watchers, validate_witnesses};
use crate::audit;
use crate::auth::AuthClaims;
use crate::keys::paths::peek_path_counter;
use crate::operations::did_webvh::concurrency::{DID_UPDATE_LOCKS, RecordSnapshot};
use crate::operations::did_webvh::webvh_keys::{self, WebvhKeyHandle, WebvhKeyRole};
use crate::webvh_store;
pub async fn plan_did_webvh_update(
deps: &super::super::WebvhDeps<'_>,
auth: &AuthClaims,
scid: &str,
opts: UpdateDidWebvhOptions,
) -> Result<UpdatePlan, UpdateDidWebvhError> {
match run_update(
deps,
auth,
scid,
opts,
None,
"plan",
Mode::Plan,
PublishTarget::DidLog,
)
.await?
{
Outcome::Planned(plan) => Ok(plan),
Outcome::Executed(_) => Err(UpdateDidWebvhError::Library(
"plan mode committed an update".into(),
)),
}
}
pub async fn update_did_webvh(
deps: &super::super::WebvhDeps<'_>,
auth: &AuthClaims,
scid: &str,
opts: UpdateDidWebvhOptions,
vta_did: Option<&str>,
channel: &str,
) -> Result<UpdateDidWebvhResult, UpdateDidWebvhError> {
match run_update(
deps,
auth,
scid,
opts,
vta_did,
channel,
Mode::Execute,
PublishTarget::DidLog,
)
.await?
{
Outcome::Executed(result) => Ok(result),
Outcome::Planned(_) => Err(UpdateDidWebvhError::Library(
"execute mode returned a plan".into(),
)),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentNameVerb {
Set,
Remove,
Enable,
Disable,
}
impl AgentNameVerb {
pub fn as_str(self) -> &'static str {
match self {
Self::Set => "set",
Self::Remove => "remove",
Self::Enable => "enable",
Self::Disable => "disable",
}
}
pub fn host_endpoint(self) -> &'static str {
match self {
Self::Set | Self::Enable | Self::Disable => "update",
Self::Remove => "remove",
}
}
pub fn host_state(self) -> Option<&'static str> {
match self {
Self::Set | Self::Enable => Some("active"),
Self::Disable => Some("parked"),
Self::Remove => None,
}
}
pub fn claims_name(self) -> bool {
matches!(self, Self::Set | Self::Enable)
}
}
async fn hosted_agent_name_context(
deps: &super::super::WebvhDeps<'_>,
auth: &AuthClaims,
did: &str,
) -> Result<
(
vta_sdk::webvh::WebvhDidRecord,
vta_sdk::webvh::WebvhServerRecord,
String,
),
UpdateDidWebvhError,
> {
let record = find_record_by_scid(deps.webvh_ks, did)
.await?
.ok_or_else(|| UpdateDidWebvhError::NotFound(format!("DID {did} not found")))?;
auth.require_context(&record.context_id)
.map_err(|e| UpdateDidWebvhError::Forbidden(e.to_string()))?;
if record.server_id == "serverless" {
return Err(UpdateDidWebvhError::InvalidDocument(
"agent names require a hosted DID; this DID is serverless \
(register it with a server first)"
.to_string(),
));
}
let server = webvh_store::get_server(deps.webvh_ks, &record.server_id)
.await
.map_err(|e| UpdateDidWebvhError::Persistence(format!("get_server: {e}")))?
.ok_or_else(|| {
UpdateDidWebvhError::Publish(format!(
"webvh server `{}` referenced by DID is missing",
record.server_id
))
})?;
let domain = domain_from_webvh_did(&record.did).ok_or_else(|| {
UpdateDidWebvhError::Library(format!("cannot derive domain from DID {}", record.did))
})?;
Ok((record, server, domain))
}
pub async fn list_agent_names(
deps: &super::super::WebvhDeps<'_>,
auth: &AuthClaims,
did: &str,
vta_did: Option<&str>,
) -> Result<(String, Vec<crate::webvh_client::AgentNameEntryWire>), UpdateDidWebvhError> {
let (record, server, domain) = hosted_agent_name_context(deps, auth, did).await?;
let vta_did = vta_did.ok_or_else(|| {
UpdateDidWebvhError::Library("no VTA DID configured for hosting auth".to_string())
})?;
let names = super::super::list_agent_names_on_server(
deps,
vta_did,
&server,
&record.mnemonic,
Some(&domain),
)
.await
.map_err(|e| UpdateDidWebvhError::Publish(format!("list_agent_names: {e}")))?;
Ok((record.did, names))
}
pub async fn check_agent_name(
deps: &super::super::WebvhDeps<'_>,
auth: &AuthClaims,
did: &str,
name: &str,
vta_did: Option<&str>,
) -> Result<crate::webvh_client::AgentNameAvailabilityWire, UpdateDidWebvhError> {
let (_record, server, domain) = hosted_agent_name_context(deps, auth, did).await?;
let vta_did = vta_did.ok_or_else(|| {
UpdateDidWebvhError::Library("no VTA DID configured for hosting auth".to_string())
})?;
super::super::check_agent_name_on_server(deps, vta_did, &server, name, Some(&domain))
.await
.map_err(|e| UpdateDidWebvhError::Publish(format!("check_agent_name: {e}")))
}
pub async fn agent_name_op(
deps: &super::super::WebvhDeps<'_>,
auth: &AuthClaims,
did: &str,
name: &str,
verb: AgentNameVerb,
vta_did: Option<&str>,
channel: &str,
) -> Result<UpdateDidWebvhResult, UpdateDidWebvhError> {
let record = find_record_by_scid(deps.webvh_ks, did)
.await?
.ok_or_else(|| UpdateDidWebvhError::NotFound(format!("DID {did} not found")))?;
if record.server_id == "serverless" {
return Err(UpdateDidWebvhError::Publish(
"agent names require a hosted DID; this DID is serverless \
(register it with a server first)"
.to_string(),
));
}
let did_log = webvh_store::get_did_log(deps.webvh_ks, &record.did)
.await
.map_err(|e| UpdateDidWebvhError::Persistence(format!("get_did_log: {e}")))?
.ok_or_else(|| {
UpdateDidWebvhError::NotFound(format!("no did.jsonl stored for {}", record.did))
})?;
let mut document =
crate::operations::protocol::document::current_document_from_log(&did_log)
.map_err(|e| UpdateDidWebvhError::Library(format!("read current document: {e}")))?;
let domain = domain_from_webvh_did(&record.did).ok_or_else(|| {
UpdateDidWebvhError::Library(format!("cannot derive domain from DID {}", record.did))
})?;
edit_agent_name(&mut document, &domain, name, verb.claims_name());
let opts = UpdateDidWebvhOptions {
document: Some(document),
label: Some(format!("agent-name/{}", verb.as_str())),
..Default::default()
};
match run_update(
deps,
auth,
&record.scid,
opts,
vta_did,
channel,
Mode::Execute,
PublishTarget::AgentName {
name: name.to_string(),
verb,
},
)
.await?
{
Outcome::Executed(result) => Ok(result),
Outcome::Planned(_) => Err(UpdateDidWebvhError::Library(
"execute mode returned a plan".into(),
)),
}
}
fn domain_from_webvh_did(did: &str) -> Option<String> {
let rest = did.strip_prefix("did:webvh:")?;
let host = rest.split(':').nth(1)?;
if host.is_empty() {
return None;
}
Some(host.replace("%3A", ":").replace("%3a", ":"))
}
fn edit_agent_name(document: &mut serde_json::Value, domain: &str, name: &str, claim: bool) {
let entry = format!("https://{domain}/@{name}");
let Some(obj) = document.as_object_mut() else {
return;
};
if claim {
let arr = obj
.entry("alsoKnownAs")
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
if let Some(list) = arr.as_array_mut()
&& !list.iter().any(|v| is_agent_name(v, domain, name))
{
list.push(serde_json::Value::String(entry));
}
} else if let Some(serde_json::Value::Array(list)) = obj.get_mut("alsoKnownAs") {
list.retain(|v| !is_agent_name(v, domain, name));
if list.is_empty() {
obj.remove("alsoKnownAs");
}
}
}
fn is_agent_name(v: &serde_json::Value, domain: &str, name: &str) -> bool {
let Some(s) = v.as_str() else {
return false;
};
let no_scheme = s
.strip_prefix("https://")
.or_else(|| s.strip_prefix("http://"))
.unwrap_or(s);
let Some((host, rest)) = no_scheme.split_once("/@") else {
return false;
};
let local = rest.split('/').next().unwrap_or("");
host.eq_ignore_ascii_case(domain) && local == name
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Plan,
Execute,
}
enum Outcome {
Planned(UpdatePlan),
Executed(UpdateDidWebvhResult),
}
enum PublishTarget {
DidLog,
AgentName { name: String, verb: AgentNameVerb },
}
fn caller_is_merely_ahead_of_an_unpublished_head(
hosted: bool,
caller_read_a_real_version: bool,
confirmed: Option<&str>,
expected: &str,
) -> bool {
if !hosted || !caller_read_a_real_version {
return false;
}
match confirmed {
None => true,
Some(c) => c == expected,
}
}
async fn run_update(
deps: &super::super::WebvhDeps<'_>,
auth: &AuthClaims,
scid: &str,
opts: UpdateDidWebvhOptions,
vta_did: Option<&str>,
channel: &str,
mode: Mode,
publish: PublishTarget,
) -> Result<Outcome, UpdateDidWebvhError> {
let super::super::WebvhDeps {
keys_ks,
contexts_ks,
webvh_ks,
audit,
seed_store,
did_resolver,
..
} = *deps;
let record = find_record_by_scid(webvh_ks, scid)
.await?
.ok_or_else(|| UpdateDidWebvhError::NotFound(format!("SCID {scid} not found")))?;
let head_before_lock = record.log_entry_count;
let _update_guard = match mode {
Mode::Plan => None,
Mode::Execute => Some(DID_UPDATE_LOCKS.acquire(&record.did).await),
};
let mut record = match mode {
Mode::Plan => record,
Mode::Execute => webvh_store::get_did(webvh_ks, &record.did)
.await
.map_err(|e| UpdateDidWebvhError::Persistence(format!("get_did: {e}")))?
.ok_or_else(|| {
UpdateDidWebvhError::NotFound(format!(
"DID {} disappeared while waiting to update",
record.did
))
})?,
};
if mode == Mode::Execute
&& opts.document.is_some()
&& opts.expected_version_id.is_none()
&& record.log_entry_count != head_before_lock
{
return Err(UpdateDidWebvhError::Conflict(format!(
"DID {} moved from {head_before_lock} to {} log entries while this update \
waited its turn, and the supplied document was built from the older \
version — applying it would discard the intervening change. Re-read the \
DID document, re-apply the edit, and send it with `expectedVersionId` set \
to the version you read.",
record.did, record.log_entry_count
)));
}
let canonical_scid = record.scid.clone();
let scid = canonical_scid.as_str();
let initial_log_entry_count = record.log_entry_count;
let snapshot = RecordSnapshot::capture(&record);
let requester_authorized =
auth.require_admin().is_ok() && auth.has_context_access(&record.context_id);
match mode {
Mode::Plan => auth.require_read().map_err(|e| {
UpdateDidWebvhError::Forbidden(format!("read access required to plan an update: {e}"))
})?,
Mode::Execute if !requester_authorized => {
return Err(UpdateDidWebvhError::Forbidden(format!(
"caller is not authorized to update DIDs in context `{}`, and no consented delegation conferred it",
record.context_id
)));
}
Mode::Execute => {}
}
let new_doc = match opts.document {
Some(doc) => Some(validate_document_for_update(doc, &record.did)?),
None => None,
};
if let Some(ref w) = opts.witnesses {
validate_witnesses(w, did_resolver).await?;
}
if let Some(ref watch) = opts.watchers {
validate_watchers(watch)?;
}
let did_log = webvh_store::get_did_log(webvh_ks, &record.did)
.await
.map_err(|e| UpdateDidWebvhError::Persistence(format!("get_did_log: {e}")))?
.ok_or_else(|| {
UpdateDidWebvhError::Library(format!("DID log missing for {}", record.did))
})?;
let state = state_from_jsonl(&did_log)?;
let last_state = state.log_entries().last().ok_or_else(|| {
UpdateDidWebvhError::Library(format!("DID {} has no log entries", record.did))
})?;
if let Some(expected) = opts.expected_version_id.as_deref() {
let latest = last_state.get_version_id();
if latest != expected {
let hosted = record.server_id != "serverless";
let caller_read_a_real_version = state
.log_entries()
.iter()
.any(|e| e.get_version_id() == expected);
let confirmed = if hosted {
webvh_store::get_published_version(webvh_ks, &record.did)
.await
.map_err(|e| {
UpdateDidWebvhError::Persistence(format!("get_published_version: {e}"))
})?
} else {
None
};
if !caller_is_merely_ahead_of_an_unpublished_head(
hosted,
caller_read_a_real_version,
confirmed.as_deref(),
expected,
) {
return Err(UpdateDidWebvhError::Conflict(format!(
"DID {} has been updated since you read it (expected versionId `{expected}`, \
current is `{latest}`). Re-fetch the document and re-apply your edits.",
record.did
)));
}
tracing::warn!(
did = %record.did,
caller_expected = %expected,
local_head = %latest,
"caller is in step with what the host last confirmed but our local head is \
ahead — an earlier publish never landed; continuing so the reconcile can heal it"
);
}
}
let last_params = last_state.validated_parameters.clone();
let last_update_keys: Vec<Multibase> = (*last_params.active_update_keys).clone();
let prior_version_id = last_state.get_version_id().to_string();
let prior_document = last_state.log_entry.get_state().clone();
let prior_version_time = last_state.log_entry.get_version_time();
let last_next_key_hashes: Vec<String> = last_params
.next_key_hashes
.as_ref()
.map(|arc| arc.iter().map(|m| m.as_ref().to_string()).collect())
.unwrap_or_default();
let pre_rotation_active = !last_next_key_hashes.is_empty();
if mode == Mode::Execute && record.server_id != "serverless" {
let confirmed = webvh_store::get_published_version(webvh_ks, &record.did)
.await
.map_err(|e| UpdateDidWebvhError::Persistence(format!("get_published_version: {e}")))?;
if confirmed.as_deref() != Some(prior_version_id.as_str()) {
let server = webvh_store::get_server(webvh_ks, &record.server_id)
.await
.map_err(|e| UpdateDidWebvhError::Persistence(format!("get_server: {e}")))?
.ok_or_else(|| {
UpdateDidWebvhError::Publish(format!(
"webvh server `{}` referenced by DID is missing",
record.server_id
))
})?;
let vta_did_ref = vta_did.ok_or_else(|| {
UpdateDidWebvhError::Publish(
"VTA DID is not configured — cannot authenticate to webvh hosting \
server to reconcile a pending publish."
.to_string(),
)
})?;
tracing::info!(
did = %record.did,
local_head = %prior_version_id,
?confirmed,
"reconcile: re-publishing an unconfirmed local head before updating"
);
super::super::publish_log_to_server(
deps,
vta_did_ref,
&server,
&record.mnemonic,
&did_log,
None,
)
.await
.map_err(|e| UpdateDidWebvhError::Publish(format!("reconcile publish_did: {e}")))?;
webvh_store::set_published_version(webvh_ks, &record.did, &prior_version_id)
.await
.map_err(|e| {
UpdateDidWebvhError::Persistence(format!("set_published_version: {e}"))
})?;
}
}
let pre_rotation_count = opts.pre_rotation_count.unwrap_or(record.pre_rotation_count);
let sends_next_key_hashes = opts.pre_rotation_count.is_some() || record.pre_rotation_count > 0;
let commits_next_key_hashes = sends_next_key_hashes && pre_rotation_count > 0;
let activates_pre_rotation = !pre_rotation_active && commits_next_key_hashes;
let context = crate::contexts::get_context(contexts_ks, &record.context_id)
.await
.map_err(|e| UpdateDidWebvhError::Persistence(format!("get_context: {e}")))?
.ok_or_else(|| {
UpdateDidWebvhError::Library(format!(
"context `{}` referenced by DID is missing",
record.context_id
))
})?;
let path_counter_pin = peek_path_counter(keys_ks, &context.base_path)
.await
.map_err(|e| UpdateDidWebvhError::Persistence(format!("peek_path_counter: {e}")))?;
let auth_count: u32 =
u32::from(new_doc.is_some() && !pre_rotation_active && !activates_pre_rotation);
let total_keys = auth_count + pre_rotation_count;
let derived_all = match mode {
Mode::Plan => peek_webvh_keys(keys_ks, seed_store, &context.base_path, total_keys).await?,
Mode::Execute => {
super::keys::derive_webvh_keys_block(
keys_ks,
seed_store,
&context.base_path,
total_keys,
Some(path_counter_pin),
)
.await?
}
};
let (auth_slice, pre_slice) = derived_all.split_at(auth_count as usize);
let (derived_auth, derived_pre_rotation) = (auth_slice.to_vec(), pre_slice.to_vec());
tracing::info!(
scid,
did = %record.did,
pre_rotation_active,
next_key_hashes_count = last_next_key_hashes.len(),
update_keys_count = last_update_keys.len(),
"update_did_webvh: resolving signing key"
);
let signing_handle = if pre_rotation_active {
load_pre_rotation_signing_key(
keys_ks,
seed_store,
&context.base_path,
scid,
&last_next_key_hashes,
)
.await?
} else {
load_active_update_key(
keys_ks,
seed_store,
&context.base_path,
scid,
&last_update_keys,
)
.await?
};
tracing::info!(
scid,
signing_pubkey = %signing_handle.public_key,
signing_hash = %signing_handle.hash,
signing_role = ?signing_handle.role,
signing_version = %signing_handle.version_id,
"update_did_webvh: signing key resolved"
);
let signing_secret = derive_secret_for_handle(keys_ks, seed_store, &signing_handle).await?;
let mut builder = UpdateDIDConfig::<Secret, Secret>::builder_generic()
.state(state)
.signing_key(signing_secret)
.version_time(next_version_time(Some(prior_version_time)).await);
let set_update_keys: Option<Vec<Multibase>> = if !derived_auth.is_empty() {
Some(
derived_auth
.iter()
.map(|k| Multibase::from(k.public_key.clone()))
.collect(),
)
} else if pre_rotation_active {
Some(vec![Multibase::from(signing_handle.public_key.clone())])
} else {
None
};
fn effective_update_keys(set: &Option<Vec<Multibase>>, previous: &[Multibase]) -> Vec<String> {
set.as_deref()
.unwrap_or(previous)
.iter()
.map(|k| k.as_ref().to_string())
.collect()
}
if let Some(doc) = new_doc {
builder = builder.document(doc);
}
if let Some(ref keys) = set_update_keys {
builder = builder.update_keys(keys.clone());
}
if sends_next_key_hashes {
let hashes: Vec<Multibase> = derived_pre_rotation
.iter()
.map(|k| Multibase::from(k.hash.clone()))
.collect();
builder = builder.next_key_hashes(hashes);
}
if let Some(w) = opts.witnesses.clone() {
builder = builder.witness(w);
}
if let Some(watch) = opts.watchers.clone() {
builder = builder.watchers(watch);
}
if let Some(t) = opts.ttl {
builder = builder.ttl(t);
}
let cfg = builder
.build()
.map_err(|e| UpdateDidWebvhError::Library(format!("build update config: {e}")))?;
let result = update_did(cfg)
.await
.map_err(|e| UpdateDidWebvhError::Rejected(e.to_string()))?;
let new_log_entry = result.log_entry();
let new_version_id = new_log_entry
.get_version_id_fields()
.map(|(n, h)| format!("{n}-{h}"))
.map_err(|e| UpdateDidWebvhError::Library(format!("read version id: {e}")))?;
let new_scid = new_log_entry.get_scid().unwrap_or_default().to_string();
let new_log_entry_str = serde_json::to_string(new_log_entry)
.map_err(|e| UpdateDidWebvhError::Persistence(format!("serialize new entry: {e}")))?;
let current = webvh_store::get_did(webvh_ks, &record.did)
.await
.map_err(|e| UpdateDidWebvhError::Persistence(format!("get_did: {e}")))?
.ok_or_else(|| {
UpdateDidWebvhError::NotFound(format!("DID {} disappeared mid-update", record.did))
})?;
snapshot
.assert_unchanged(¤t)
.map_err(|race| UpdateDidWebvhError::Conflict(race.to_string()))?;
if mode == Mode::Plan {
return Ok(Outcome::Planned(UpdatePlan {
did: record.did.clone(),
scid: scid.to_string(),
prior_version_id,
new_version_id: new_version_id.clone(),
prior_document,
new_document: new_log_entry.get_state().clone(),
prior_update_keys: last_update_keys
.iter()
.map(|k| k.as_ref().to_string())
.collect(),
new_update_keys: effective_update_keys(&set_update_keys, &last_update_keys),
pre_rotation_count,
new_next_key_hashes: derived_pre_rotation
.iter()
.map(|k| k.hash.clone())
.collect(),
base_path: context.base_path.clone(),
path_counter_pin,
subject_context: record.context_id.clone(),
requester_authorized,
}));
}
let new_log_jsonl = state_to_jsonl(result.state())?;
webvh_store::store_did_log(webvh_ks, &record.did, &new_log_jsonl)
.await
.map_err(|e| UpdateDidWebvhError::Persistence(format!("store_did_log: {e}")))?;
super::super::refresh_resolver_doc_from_log(did_resolver, &record.did, &new_log_jsonl, channel)
.await;
if !derived_auth.is_empty() {
install_derived_webvh_keys(
keys_ks,
scid,
&new_version_id,
WebvhKeyRole::UpdateKey,
&derived_auth,
"update key",
)
.await?;
}
if !derived_pre_rotation.is_empty() {
install_derived_webvh_keys(
keys_ks,
scid,
&new_version_id,
WebvhKeyRole::PreRotation,
&derived_pre_rotation,
"pre-rotation key",
)
.await?;
}
if pre_rotation_active {
let revealed = WebvhKeyHandle {
scid: scid.to_string(),
version_id: new_version_id.clone(),
hash: signing_handle.hash.clone(),
public_key: signing_handle.public_key.clone(),
derivation_path: signing_handle.derivation_path.clone(),
seed_id: signing_handle.seed_id,
role: WebvhKeyRole::UpdateKey,
label: format!(
"revealed pre-rotation key (was version {})",
signing_handle.version_id
),
created_at: Utc::now(),
};
webvh_keys::install(keys_ks, &revealed)
.await
.map_err(|e| UpdateDidWebvhError::Persistence(format!("install revealed key: {e}")))?;
}
if let Some(prev) = result
.state()
.log_entries()
.iter()
.rev()
.nth(1)
.map(|e| {
e.log_entry
.get_version_id_fields()
.map(|(n, h)| format!("{n}-{h}"))
})
.transpose()
.unwrap_or(None)
{
webvh_keys::supersede_keys_for_version(keys_ks, scid, &prev)
.await
.map_err(|e| UpdateDidWebvhError::Persistence(format!("supersede: {e}")))?;
}
record.log_entry_count += 1;
record.pre_rotation_count = derived_pre_rotation.len() as u32;
record.updated_at = Utc::now();
webvh_store::store_did(webvh_ks, &record)
.await
.map_err(|e| UpdateDidWebvhError::Persistence(format!("store_did: {e}")))?;
if record.server_id != "serverless" {
let server = webvh_store::get_server(webvh_ks, &record.server_id)
.await
.map_err(|e| UpdateDidWebvhError::Persistence(format!("get_server: {e}")))?
.ok_or_else(|| {
UpdateDidWebvhError::Publish(format!(
"webvh server `{}` referenced by DID is missing",
record.server_id
))
})?;
let vta_did = vta_did.ok_or_else(|| {
UpdateDidWebvhError::Publish(
"VTA DID is not configured — cannot authenticate to webvh hosting server. \
Complete `vta setup` before publishing to a server-managed DID."
.to_string(),
)
})?;
match &publish {
PublishTarget::DidLog => {
super::super::publish_log_to_server(
deps,
vta_did,
&server,
&record.mnemonic,
&new_log_jsonl,
None,
)
.await
.map_err(|e| UpdateDidWebvhError::Publish(format!("publish_did: {e}")))?;
}
PublishTarget::AgentName { name, verb } => {
let domain = domain_from_webvh_did(&record.did);
super::super::agent_name_op_on_server(
deps,
vta_did,
&server,
*verb,
&record.mnemonic,
name,
&new_log_jsonl,
domain.as_deref(),
)
.await
.map_err(|e| UpdateDidWebvhError::Publish(format!("agent_name: {e}")))?;
}
}
webvh_store::set_published_version(webvh_ks, &record.did, &new_version_id)
.await
.map_err(|e| UpdateDidWebvhError::Persistence(format!("set_published_version: {e}")))?;
}
let resource = format!(
"did:webvh:{scid} v{} → v{}",
initial_log_entry_count, record.log_entry_count
);
let label = opts.label.as_deref().unwrap_or("update");
if let Err(e) = audit::record(
audit,
&format!("did.update:{label}"),
&auth.did,
Some(&resource),
"success",
Some(channel),
Some(&record.context_id),
)
.await
{
tracing::warn!(
channel,
did = %record.did,
error = %e,
"did.update audit emission failed; update committed"
);
}
tracing::info!(
channel,
did = %record.did,
scid = %scid,
new_version_id = %new_version_id,
label = ?opts.label,
"did:webvh updated"
);
let update_keys_count = effective_update_keys(&set_update_keys, &last_update_keys).len() as u32;
Ok(Outcome::Executed(UpdateDidWebvhResult {
did: record.did.clone(),
new_version_id,
new_scid,
new_log_entry: new_log_entry_str,
update_keys_count,
pre_rotation_key_count: derived_pre_rotation.len() as u32,
serverless: record.server_id == "serverless",
}))
}
#[cfg(test)]
mod agent_name_tests {
use super::{AgentNameVerb, domain_from_webvh_did, edit_agent_name, is_agent_name};
use serde_json::{Value, json};
#[test]
fn verbs_keep_distinct_operator_facing_names() {
assert_eq!(AgentNameVerb::Set.as_str(), "set");
assert_eq!(AgentNameVerb::Remove.as_str(), "remove");
assert_eq!(AgentNameVerb::Enable.as_str(), "enable");
assert_eq!(AgentNameVerb::Disable.as_str(), "disable");
}
#[test]
fn verbs_map_onto_the_hosts_two_tasks() {
assert_eq!(AgentNameVerb::Set.host_endpoint(), "update");
assert_eq!(AgentNameVerb::Enable.host_endpoint(), "update");
assert_eq!(AgentNameVerb::Disable.host_endpoint(), "update");
assert_eq!(AgentNameVerb::Remove.host_endpoint(), "remove");
assert_eq!(AgentNameVerb::Set.host_state(), Some("active"));
assert_eq!(AgentNameVerb::Enable.host_state(), Some("active"));
assert_eq!(AgentNameVerb::Disable.host_state(), Some("parked"));
assert_eq!(AgentNameVerb::Remove.host_state(), None);
}
#[test]
fn host_state_agrees_with_the_claim_direction() {
for verb in [
AgentNameVerb::Set,
AgentNameVerb::Remove,
AgentNameVerb::Enable,
AgentNameVerb::Disable,
] {
match verb.host_state() {
Some("active") => assert!(
verb.claims_name(),
"{} asks for `active` so its document must claim the name",
verb.as_str()
),
Some("parked") | None => assert!(
!verb.claims_name(),
"{} takes the name out of service so its document must not claim it",
verb.as_str()
),
other => panic!("{} has an unknown host state {other:?}", verb.as_str()),
}
}
}
#[test]
fn claim_direction_matches_the_hosts_rule() {
assert!(AgentNameVerb::Set.claims_name());
assert!(AgentNameVerb::Enable.claims_name());
assert!(!AgentNameVerb::Remove.claims_name());
assert!(!AgentNameVerb::Disable.claims_name());
}
#[test]
fn edited_document_matches_the_verbs_claim_direction() {
for verb in [
AgentNameVerb::Set,
AgentNameVerb::Remove,
AgentNameVerb::Enable,
AgentNameVerb::Disable,
] {
let mut doc = json!({ "alsoKnownAs": ["https://example.com/@alice"] });
edit_agent_name(&mut doc, "example.com", "alice", verb.claims_name());
let claimed = doc
.get("alsoKnownAs")
.and_then(|v| v.as_array())
.is_some_and(|l| l.iter().any(|v| is_agent_name(v, "example.com", "alice")));
assert_eq!(
claimed,
verb.claims_name(),
"{} must leave the document {} the name",
verb.as_str(),
if verb.claims_name() {
"claiming"
} else {
"not claiming"
}
);
let mut doc = json!({});
edit_agent_name(&mut doc, "example.com", "alice", verb.claims_name());
let claimed = doc
.get("alsoKnownAs")
.and_then(|v| v.as_array())
.is_some_and(|l| l.iter().any(|v| is_agent_name(v, "example.com", "alice")));
assert_eq!(claimed, verb.claims_name(), "{}", verb.as_str());
}
}
#[test]
fn domain_parses_host_and_decodes_port() {
assert_eq!(
domain_from_webvh_did("did:webvh:QmScid:example.com:alice").as_deref(),
Some("example.com")
);
assert_eq!(
domain_from_webvh_did("did:webvh:QmScid:localhost%3A8534:staff:bob").as_deref(),
Some("localhost:8534")
);
assert_eq!(domain_from_webvh_did("did:key:z6Mk").as_deref(), None);
assert_eq!(domain_from_webvh_did("did:webvh:QmScid").as_deref(), None);
}
#[test]
fn is_agent_name_matches_host_ci_local_exact() {
let v = |s: &str| Value::String(s.to_string());
assert!(is_agent_name(
&v("https://example.com/@alice"),
"example.com",
"alice"
));
assert!(is_agent_name(
&v("example.com/@alice"),
"EXAMPLE.com",
"alice"
));
assert!(!is_agent_name(
&v("https://example.com/@Alice"),
"example.com",
"alice"
));
assert!(!is_agent_name(
&v("https://other.com/@alice"),
"example.com",
"alice"
));
assert!(!is_agent_name(
&v("did:web:example.com"),
"example.com",
"alice"
));
}
#[test]
fn enable_adds_canonical_entry_idempotently() {
let mut doc = json!({ "id": "did:webvh:x:example.com:me" });
edit_agent_name(&mut doc, "example.com", "alice", true);
assert_eq!(
doc["alsoKnownAs"],
json!(["https://example.com/@alice"]),
"enable creates the array with the canonical entry"
);
edit_agent_name(&mut doc, "example.com", "alice", true);
assert_eq!(doc["alsoKnownAs"], json!(["https://example.com/@alice"]));
}
#[test]
fn enable_preserves_unrelated_also_known_as() {
let mut doc = json!({ "alsoKnownAs": ["did:web:example.com", "https://example.com/@bob"] });
edit_agent_name(&mut doc, "example.com", "alice", true);
assert_eq!(
doc["alsoKnownAs"],
json!([
"did:web:example.com",
"https://example.com/@bob",
"https://example.com/@alice"
])
);
}
#[test]
fn disable_removes_the_name_in_any_form_and_prunes_empty() {
let mut doc = json!({ "alsoKnownAs": ["example.com/@alice"] });
edit_agent_name(&mut doc, "example.com", "alice", false);
assert!(
doc.get("alsoKnownAs").is_none(),
"an emptied alsoKnownAs is dropped, not left as []"
);
let mut doc = json!({
"alsoKnownAs": ["https://example.com/@alice", "https://example.com/@bob", "did:web:x"]
});
edit_agent_name(&mut doc, "example.com", "alice", false);
assert_eq!(
doc["alsoKnownAs"],
json!(["https://example.com/@bob", "did:web:x"])
);
}
}
#[cfg(test)]
mod concurrency_precondition {
use super::caller_is_merely_ahead_of_an_unpublished_head as merely_ahead;
const V1: &str = "1-QmUCAL";
const V2: &str = "2-QmXAXx";
#[test]
fn a_caller_in_step_with_the_confirmed_publish_is_not_stale() {
assert!(merely_ahead(true, true, Some(V1), V1));
}
#[test]
fn an_absent_marker_counts_as_nothing_published_beyond() {
assert!(merely_ahead(true, true, None, V1));
}
#[test]
fn a_caller_behind_the_confirmed_publish_is_still_stale() {
assert!(!merely_ahead(true, true, Some(V2), V1));
}
#[test]
fn a_version_absent_from_our_chain_is_never_excused() {
assert!(!merely_ahead(true, false, None, "9-QmInvented"));
assert!(!merely_ahead(true, false, Some(V1), "9-QmInvented"));
}
#[test]
fn a_serverless_did_is_never_excused() {
assert!(!merely_ahead(false, true, None, V1));
assert!(!merely_ahead(false, true, Some(V1), V1));
}
}
#[cfg(test)]
mod agent_name_host_contract {
use super::edit_agent_name;
const DOMAIN: &str = "webvh.storm.ws";
fn claims(doc: &serde_json::Value) -> Vec<String> {
doc.get("alsoKnownAs")
.and_then(|a| a.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
#[test]
fn what_we_write_is_what_the_host_parses() {
let mut doc = serde_json::json!({});
edit_agent_name(&mut doc, DOMAIN, "ops", true);
let entries = claims(&doc);
assert_eq!(entries.len(), 1, "expected exactly one claim: {entries:?}");
let parsed = agent_names::AgentName::parse(&entries[0])
.expect("the host must be able to parse what we emit");
assert_eq!(
parsed.authority(),
DOMAIN,
"the host keeps only entries whose authority matches the domain \
it serves; a mismatch means the name is never indexed"
);
assert_eq!(parsed.local_name(), "ops");
}
#[test]
fn a_removed_claim_leaves_nothing_for_the_host_to_index() {
let mut doc = serde_json::json!({});
edit_agent_name(&mut doc, DOMAIN, "ops", true);
edit_agent_name(&mut doc, DOMAIN, "ops", false);
assert!(
claims(&doc).is_empty(),
"a released name must leave no claim behind — the host rebuilds \
its index from the document on every publish"
);
}
#[test]
fn an_unrelated_also_known_as_entry_survives_and_is_ignored() {
let mut doc = serde_json::json!({ "alsoKnownAs": ["did:web:other.example"] });
edit_agent_name(&mut doc, DOMAIN, "ops", true);
let entries = claims(&doc);
assert!(entries.iter().any(|e| e == "did:web:other.example"));
assert_eq!(entries.len(), 2);
edit_agent_name(&mut doc, DOMAIN, "ops", false);
assert_eq!(
claims(&doc),
vec!["did:web:other.example".to_string()],
"removing our name must not disturb identifiers we do not own"
);
}
#[test]
fn a_name_on_another_domain_is_not_ours_to_serve() {
let mut doc = serde_json::json!({});
edit_agent_name(&mut doc, DOMAIN, "ops", true);
let parsed = agent_names::AgentName::parse(&claims(&doc)[0]).unwrap();
assert_ne!(parsed.authority(), "evil.example");
}
}