use affinidi_messaging_didcomm::Message;
use affinidi_messaging_didcomm_service::{
DIDCommResponse, DIDCommServiceError, Extension, HandlerContext, MESSAGE_PICKUP_STATUS_TYPE,
MessagePolicy, MiddlewareResult, Next, Router, TRUST_PING_TYPE, handler_fn, ignore_handler,
middleware_fn, trust_ping_handler,
};
use serde_json::{Value, json};
use tracing::{debug, info, warn};
use did_hosting_common::didcomm_types::*;
use did_hosting_common::server::problem_report::log_problem_report;
use did_hosting_common::server::replay::ReplayCache;
use did_hosting_common::server::trust_tasks::{TransportBoundVerifier, verify_sender_bound};
use crate::server::AppState;
pub const CONTROL_PLANE_OPS: &[&str] = &[
MSG_SYNC_UPDATE,
MSG_SYNC_BATCH,
MSG_SYNC_DELETE,
MSG_REPLICA_DOMAIN_ASSIGN,
MSG_REPLICA_DOMAIN_UNASSIGN,
MSG_REPLICA_DOMAIN_PURGE,
MSG_REPLICA_DOMAIN_UPSERT,
MSG_SERVER_METRICS,
];
pub const MSG_SERVER_METRICS: &str =
"https://trusttasks.org/spec/did-management/server/metrics/0.1";
fn not_authorized_code(type_uri: &str) -> Option<trust_tasks_rs::DeclaredErrorCode> {
use trust_tasks_rs::specs::did_management::replica::domain::{assign, purge, unassign, upsert};
Some(match type_uri {
MSG_SYNC_UPDATE => sync_update::error_codes::NOT_AUTHORIZED,
MSG_SYNC_BATCH => sync_batch::error_codes::NOT_AUTHORIZED,
MSG_SYNC_DELETE => sync_delete::error_codes::NOT_AUTHORIZED,
MSG_REPLICA_DOMAIN_ASSIGN => assign::v0_1::error_codes::NOT_AUTHORIZED,
MSG_REPLICA_DOMAIN_UNASSIGN => unassign::v0_1::error_codes::NOT_AUTHORIZED,
MSG_REPLICA_DOMAIN_PURGE => purge::v0_1::error_codes::NOT_AUTHORIZED,
MSG_REPLICA_DOMAIN_UPSERT => upsert::v0_1::error_codes::NOT_AUTHORIZED,
_ => return None,
})
}
static REPLAY_CACHE: std::sync::LazyLock<ReplayCache> = std::sync::LazyLock::new(ReplayCache::new);
#[derive(Debug)]
pub struct VerifiedControlPlane {
pub did: String,
pub issued_at: u64,
}
pub fn build_verifier(
did_resolver: Option<&affinidi_did_resolver_cache_sdk::DIDCacheClient>,
) -> Option<std::sync::Arc<TransportBoundVerifier>> {
did_hosting_common::server::trust_tasks::build_verifier(did_resolver)
}
pub fn state_verifier(state: &AppState) -> Option<std::sync::Arc<TransportBoundVerifier>> {
state.trust_tasks_verifier.clone()
}
#[derive(Debug)]
pub enum Refusal {
Unverified(trust_tasks_rs::RejectReason),
NotAuthorized {
issuer: String,
},
}
impl Refusal {
pub fn reject_reason(&self) -> trust_tasks_rs::RejectReason {
match self {
Refusal::Unverified(reason) => reason.clone(),
Refusal::NotAuthorized { issuer } => trust_tasks_rs::RejectReason::PermissionDenied {
reason: format!("{issuer} is not this server's control plane"),
},
}
}
}
pub async fn verify_control_plane<P>(
state: &AppState,
transport_sender: Option<&str>,
doc: &trust_tasks_rs::TrustTask<P>,
verifier: &TransportBoundVerifier,
) -> Result<VerifiedControlPlane, Refusal>
where
P: serde::Serialize + Send + Sync,
{
use trust_tasks_rs::RejectReason;
let control_did = state.config.control_did.as_deref().ok_or_else(|| {
Refusal::Unverified(RejectReason::PermissionDenied {
reason: "this server has no configured control plane".into(),
})
})?;
let my_did = state.config.server_did.as_deref().ok_or_else(|| {
Refusal::Unverified(RejectReason::PermissionDenied {
reason: "this server has no configured DID".into(),
})
})?;
if doc.issuer.as_deref() != Some(control_did) {
return Err(Refusal::Unverified(RejectReason::PermissionDenied {
reason: "issuer is not this server's configured control plane".into(),
}));
}
let did = verify_sender_bound(doc, None, transport_sender, my_did, verifier)
.await
.map_err(|e| {
if e.is_transient() {
warn!(
control_did,
type_uri = %doc.type_uri,
error = %e,
"control-plane document not verified: the signer's DID could not be \
resolved from this server (is its did.jsonl reachable?); it will be re-sent"
);
} else {
warn!(
sender = transport_sender.unwrap_or("unknown"),
type_uri = %doc.type_uri,
error = %e,
"control-plane document rejected: it does not verify"
);
}
Refusal::Unverified(e.reject_reason())
})?;
if did != control_did {
warn!(
issuer = %did,
control_did,
type_uri = %doc.type_uri,
"control-plane document refused: signed by a DID that is not this server's control plane"
);
return Err(Refusal::NotAuthorized { issuer: did });
}
match REPLAY_CACHE.check(&did, &doc.id) {
Ok(()) => {}
Err(did_hosting_common::server::replay::ReplayError::Duplicate) => {
warn!(did, doc_id = %doc.id, "control-plane document rejected: replay");
return Err(Refusal::Unverified(RejectReason::IdConflict));
}
Err(did_hosting_common::server::replay::ReplayError::Full) => {
warn!(did, doc_id = %doc.id, "control-plane document deferred: replay cache full");
return Err(Refusal::Unverified(RejectReason::Unavailable {
retry_after: None,
}));
}
}
let issued_at = doc
.issued_at
.map(|t| t.timestamp().max(0) as u64)
.unwrap_or_default();
Ok(VerifiedControlPlane { did, issued_at })
}
#[derive(Debug)]
pub enum ControlPlaneReply {
Reply(trust_tasks_rs::TrustTask<Value>),
Unverified(trust_tasks_rs::TrustTask<Value>),
}
impl ControlPlaneReply {
pub fn into_document(self) -> trust_tasks_rs::TrustTask<Value> {
match self {
ControlPlaneReply::Reply(d) | ControlPlaneReply::Unverified(d) => d,
}
}
}
#[derive(Debug)]
pub(crate) enum OpError {
Refused(String),
Declared(trust_tasks_rs::DeclaredErrorCode, String),
Transient(String),
}
impl From<&str> for OpError {
fn from(e: &str) -> Self {
OpError::Refused(e.to_string())
}
}
impl From<String> for OpError {
fn from(e: String) -> Self {
OpError::Refused(e)
}
}
impl From<crate::error::AppError> for OpError {
fn from(e: crate::error::AppError) -> Self {
use crate::error::AppError;
match e {
AppError::Io(_)
| AppError::Store(_)
| AppError::SecretStore(_)
| AppError::Internal(_) => OpError::Transient(e.to_string()),
other => OpError::Refused(other.to_string()),
}
}
}
impl OpError {
fn reject_reason(self) -> trust_tasks_rs::ErrorPayload {
match self {
OpError::Refused(reason) => trust_tasks_rs::RejectReason::TaskFailed {
reason,
details: None,
}
.into(),
OpError::Declared(code, message) => {
trust_tasks_rs::ErrorPayload::from(code).with_message(message)
}
OpError::Transient(reason) => {
warn!(%reason, "control-plane op not applied (transient); the control plane will re-send it");
trust_tasks_rs::RejectReason::InternalError { reason }.into()
}
}
}
}
pub async fn dispatch_control_plane_op(
state: &AppState,
transport_sender: Option<&str>,
doc: trust_tasks_rs::TrustTask<Value>,
verifier: &TransportBoundVerifier,
) -> Option<ControlPlaneReply> {
let type_uri = doc.type_uri.to_string();
if !CONTROL_PLANE_OPS.contains(&type_uri.as_str()) {
return None;
}
let reply_id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
let control = match verify_control_plane(state, transport_sender, &doc, verifier).await {
Ok(c) => c,
Err(Refusal::NotAuthorized { issuer }) => {
let message = format!("{issuer} is not this server's control plane");
let payload = match not_authorized_code(&type_uri) {
Some(code) => trust_tasks_rs::ErrorPayload::from(code).with_message(message),
None => trust_tasks_rs::RejectReason::PermissionDenied { reason: message }.into(),
};
return Some(ControlPlaneReply::Reply(error_value(
doc.reject_with(reply_id, payload),
)));
}
Err(refusal) => {
return Some(ControlPlaneReply::Unverified(error_value(
doc.reject_with(reply_id, refusal.reject_reason()),
)));
}
};
let result = match type_uri.as_str() {
MSG_SYNC_UPDATE => do_sync_update(&control, state, &doc.payload).await,
MSG_SYNC_BATCH => do_sync_batch(&control, state, &doc.payload).await,
MSG_SYNC_DELETE => do_sync_delete(&control, state, &doc.payload).await,
MSG_REPLICA_DOMAIN_ASSIGN => do_domain_assign(&control, state, &doc.payload).await,
MSG_REPLICA_DOMAIN_UNASSIGN => do_domain_unassign(&control, state, &doc.payload).await,
MSG_REPLICA_DOMAIN_PURGE => do_domain_purge(&control, state, &doc.payload).await,
MSG_REPLICA_DOMAIN_UPSERT => do_domain_upsert(&control, state, &doc.payload).await,
MSG_SERVER_METRICS => do_server_metrics(state, &doc.payload).await,
_ => unreachable!("CONTROL_PLANE_OPS gates this match"),
};
Some(ControlPlaneReply::Reply(match result {
Ok((ack_type, body)) => {
let reply = doc.respond_with(reply_id, body);
debug_assert_eq!(reply.type_uri.to_string(), ack_type);
reply
}
Err(e) => error_value(doc.reject_with(reply_id, e.reject_reason())),
}))
}
fn error_value(err: trust_tasks_rs::ErrorResponse) -> trust_tasks_rs::TrustTask<Value> {
let value = serde_json::to_value(&err).expect("error document serialises");
serde_json::from_value(value).expect("error document re-reads as a TrustTask")
}
pub async fn seal_reply(
state: &AppState,
reply: trust_tasks_rs::TrustTask<Value>,
) -> Option<Value> {
let (Some(identity), Some(server_did)) = (
state.identity.as_deref(),
state.config.server_did.as_deref(),
) else {
warn!("cannot sign reply: service identity or server_did not loaded");
return None;
};
let secret = match did_hosting_common::server::trust_tasks::identity_signing_secret(
identity, server_did,
) {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "cannot sign reply");
return None;
}
};
match did_hosting_common::server::trust_tasks::sign_document(&reply, &secret).await {
Ok(signed) => serde_json::to_value(&signed).ok(),
Err(e) => {
warn!(error = %e, "cannot sign reply");
None
}
}
}
pub fn build_server_router(state: AppState) -> Result<Router, DIDCommServiceError> {
Ok(Router::new()
.extension(state)
.route(TRUST_PING_TYPE, handler_fn(trust_ping_handler))?
.route(MESSAGE_PICKUP_STATUS_TYPE, handler_fn(ignore_handler))?
.route(
trust_tasks_didcomm::ENVELOPE_TYPE,
handler_fn(handle_trust_tasks_envelope),
)?
.fallback(handler_fn(handle_fallback))
.layer(
MessagePolicy::new()
.require_encrypted(true)
.require_sender_did(true),
)
.layer(middleware_fn(filtered_request_logging)))
}
async fn filtered_request_logging(
ctx: HandlerContext,
message: Message,
meta: affinidi_messaging_didcomm::UnpackMetadata,
next: Next,
) -> MiddlewareResult {
const QUIET: &[&str] = &[
MESSAGE_PICKUP_STATUS_TYPE,
trust_tasks_didcomm::ENVELOPE_TYPE,
];
let msg_type = message.typ.clone();
let result = next.run(ctx, message, meta).await;
if !QUIET.iter().any(|t| msg_type == *t) {
let status = match &result {
Ok(Some(_)) => "ok(response)",
Ok(None) => "ok(empty)",
Err(_) => "error",
};
info!(message_type = %msg_type, status, "DIDComm request processed");
}
result
}
async fn handle_trust_tasks_envelope(
ctx: HandlerContext,
message: Message,
Extension(state): Extension<AppState>,
) -> Result<Option<DIDCommResponse>, DIDCommServiceError> {
let Some((typ, body)) =
run_trust_tasks_envelope(&state, ctx.sender_did.as_deref(), &message).await
else {
return Ok(None);
};
Ok(Some(
DIDCommResponse::new(typ, body).thid(message.id.clone()),
))
}
pub async fn run_trust_tasks_envelope(
state: &AppState,
sender: Option<&str>,
message: &Message,
) -> Option<(String, Value)> {
let doc: trust_tasks_rs::TrustTask<Value> = match serde_json::from_value(message.body.clone()) {
Ok(d) => d,
Err(e) => {
warn!(
sender,
error = %e,
"trust-tasks envelope: inner body did not parse as TrustTask<Value>"
);
return None;
}
};
let reply = dispatch_inbound_document(state, sender, doc).await?;
Some((trust_tasks_didcomm::ENVELOPE_TYPE.to_string(), reply))
}
pub async fn dispatch_inbound_document(
state: &AppState,
sender: Option<&str>,
doc: trust_tasks_rs::TrustTask<Value>,
) -> Option<Value> {
let doc = crate::replication::deliver_reply(doc)?;
let type_uri = doc.type_uri.to_string();
if CONTROL_PLANE_OPS.contains(&type_uri.as_str()) {
let Some(verifier) = state_verifier(state) else {
warn!(%type_uri, "control-plane document refused: no DID resolver configured to verify it");
return None;
};
return match dispatch_control_plane_op(state, sender, doc, &verifier).await? {
ControlPlaneReply::Reply(reply) => seal_reply(state, reply).await,
ControlPlaneReply::Unverified(_) => None,
};
}
if crate::trust_tasks_infra::owns(&type_uri) {
let reply = crate::trust_tasks_infra::dispatch(state, sender, doc).await?;
return seal_reply(state, reply).await;
}
warn!(
sender,
%type_uri,
"trust task of a type this server does not implement"
);
None
}
async fn handle_fallback(
ctx: HandlerContext,
message: Message,
) -> Result<Option<DIDCommResponse>, DIDCommServiceError> {
let sender = ctx.sender_did.as_deref();
if log_problem_report("server", sender, &message) {
return Ok(None);
}
warn!(
sender = sender.unwrap_or("unknown"),
msg_type = %message.typ,
"unknown message type — ignoring"
);
Ok(None)
}
use trust_tasks_rs::specs::webvh::sync::{
batch::v0_1 as sync_batch, delete::v0_2 as sync_delete, update::v0_2 as sync_update,
};
fn payload<P: serde::de::DeserializeOwned>(body: &Value, what: &str) -> Result<P, OpError> {
serde_json::from_value(body.clone())
.map_err(|e| OpError::Refused(format!("{what} payload does not fit its schema: {e}")))
}
fn reply<R: serde::Serialize + serde::de::DeserializeOwned>(
body: Value,
what: &str,
) -> Result<Value, OpError> {
let typed: R = serde_json::from_value(body)
.map_err(|e| OpError::Transient(format!("{what} reply does not fit its schema: {e}")))?;
serde_json::to_value(typed).map_err(|e| OpError::Transient(e.to_string()))
}
async fn do_sync_update(
control: &VerifiedControlPlane,
state: &AppState,
body: &Value,
) -> Result<(String, Value), OpError> {
let update: sync_update::Payload = payload(body, "sync/update")?;
let entry = sync_entry(
update.mnemonic.to_string(),
update.did_id.to_string(),
update.log_content.to_string(),
update.witness_content.map(|w| w.to_string()),
update.version_count.get(),
update.disabled,
);
let status = apply_sync_entry(state, &entry).await?;
debug!(did = %control.did, mnemonic = %entry.mnemonic, ?status, "applied sync update");
Ok((
MSG_SYNC_UPDATE_ACK.to_string(),
reply::<sync_update::Response>(
json!({ "mnemonic": entry.mnemonic, "status": status_str(status) }),
"sync/update",
)?,
))
}
async fn do_sync_batch(
control: &VerifiedControlPlane,
state: &AppState,
body: &Value,
) -> Result<(String, Value), OpError> {
let batch: sync_batch::Payload = payload(body, "sync/batch")?;
let count = batch.updates.len();
let mut results = Vec::with_capacity(count);
let mut transient: Option<String> = None;
for update in batch.updates {
let entry = sync_entry(
update.mnemonic.to_string(),
update.did_id.to_string(),
update.log_content.to_string(),
update.witness_content.map(|w| w.to_string()),
update.version_count.get(),
update.disabled,
);
match apply_sync_entry(state, &entry).await {
Ok(status) => {
results.push(json!({ "mnemonic": entry.mnemonic, "status": status_str(status) }))
}
Err(OpError::Declared(code, e)) => {
warn!(mnemonic = %entry.mnemonic, error = %e, "sync-batch: entry refused");
results.push(
json!({ "mnemonic": entry.mnemonic, "status": "refused", "code": code.code }),
);
}
Err(OpError::Refused(e)) => {
warn!(mnemonic = %entry.mnemonic, error = %e, "sync-batch: entry refused");
results.push(json!({
"mnemonic": entry.mnemonic,
"status": "refused",
"code": sync_update::error_codes::INVALID_LOG.code,
}));
}
Err(OpError::Transient(e)) => {
warn!(mnemonic = %entry.mnemonic, error = %e, "sync-batch: entry could not be applied just now");
transient.get_or_insert(e);
}
}
}
if let Some(e) = transient {
return Err(OpError::Transient(format!(
"a sync-batch entry could not be applied just now: {e}"
)));
}
debug!(did = %control.did, count, "applied DID sync batch from control plane");
Ok((
MSG_SYNC_BATCH_ACK.to_string(),
reply::<sync_batch::Response>(json!({ "results": results }), "sync/batch")?,
))
}
fn sync_entry(
mnemonic: String,
did_id: String,
log_content: String,
witness_content: Option<String>,
version_count: u64,
disabled: bool,
) -> did_hosting_common::DidSyncUpdate {
did_hosting_common::DidSyncUpdate {
mnemonic,
did_id,
log_content,
witness_content,
version_count,
disabled,
}
}
fn status_str(status: crate::control_register::SyncApplied) -> &'static str {
match status {
crate::control_register::SyncApplied::Applied => "applied",
crate::control_register::SyncApplied::Unchanged => "unchanged",
}
}
async fn apply_sync_entry(
state: &AppState,
update: &did_hosting_common::DidSyncUpdate,
) -> Result<crate::control_register::SyncApplied, OpError> {
use crate::control_register::{SyncApplied, apply_single_update};
let entries = update
.log_content
.lines()
.filter(|l| !l.trim().is_empty())
.count() as u64;
if entries != update.version_count {
return Err(OpError::Declared(
sync_update::error_codes::INVALID_LOG,
format!(
"versionCount is {} but logContent holds {entries} entries",
update.version_count
),
));
}
let _guard = state.sync_lock.lock().await;
let status = apply_single_update(
&state.dids_ks,
&state.store,
update,
&state.did_cache,
state.config.public_url.as_deref(),
)
.await
.map_err(sync_refusal)?;
if status == SyncApplied::Applied {
crate::identity_rotation::on_did_published(state, &update.mnemonic).await;
}
Ok(status)
}
fn sync_refusal(e: crate::error::AppError) -> OpError {
use crate::error::AppError;
match e {
AppError::Validation(message) => {
let code = if message.contains("deactivated") {
sync_update::error_codes::DEACTIVATED
} else if message.contains("not an extension")
|| message.contains("rollback")
|| message.contains("high-water")
|| message.contains("refusing to replace")
{
sync_update::error_codes::HISTORY_REWRITE
} else {
sync_update::error_codes::INVALID_LOG
};
OpError::Declared(code, message)
}
other => OpError::from(other),
}
}
async fn do_sync_delete(
control: &VerifiedControlPlane,
state: &AppState,
body: &Value,
) -> Result<(String, Value), OpError> {
use crate::did_ops;
let delete: sync_delete::Payload = payload(body, "sync/delete")?;
let mnemonic = delete.mnemonic.as_str();
let _guard = state.sync_lock.lock().await;
let record: Option<did_ops::DidRecord> = state
.dids_ks
.get(did_ops::did_key(mnemonic))
.await
.map_err(OpError::from)?;
let status = if let Some(record) = record {
let host = record
.did_id
.as_deref()
.and_then(|d| did_hosting_common::server::domain::safety::extract_did_host(d).ok())
.unwrap_or_default();
let mut batch = state.store.batch();
batch.remove(&state.dids_ks, did_ops::did_key(mnemonic));
batch.remove(&state.dids_ks, did_ops::content_log_key(mnemonic));
batch.remove(&state.dids_ks, did_ops::content_witness_key(mnemonic));
batch.remove(&state.dids_ks, did_ops::owner_key(&record.owner, mnemonic));
batch.remove(&state.dids_ks, did_ops::watcher_sync_key(mnemonic));
for name in &record.agent_names {
batch.remove(
&state.dids_ks,
did_hosting_common::did_ops::agent_name_key(&host, &name.name),
);
}
batch.commit().await.map_err(OpError::from)?;
state
.did_cache
.invalidate(&did_ops::content_log_key(mnemonic));
info!(did = %control.did, mnemonic = %mnemonic, "deleted DID via sync from control plane");
"deleted"
} else {
info!(mnemonic = %mnemonic, "sync delete: DID not held here");
"absent"
};
Ok((
MSG_SYNC_DELETE_ACK.to_string(),
reply::<sync_delete::Response>(
json!({ "mnemonic": mnemonic, "status": status }),
"sync/delete",
)?,
))
}
use trust_tasks_rs::specs::did_management::replica::domain::{
assign::v0_1 as replica_assign, purge::v0_1 as replica_purge,
unassign::v0_1 as replica_unassign, upsert::v0_1 as replica_upsert,
};
fn canonical_domain(name: &str, what: &str) -> Result<String, OpError> {
let canonical = did_hosting_common::server::domain::normalize_domain_name(name)
.map_err(|e| OpError::Refused(format!("{what}: `{name}` is not a domain name: {e}")))?;
if canonical != name {
return Err(OpError::Refused(format!(
"{what}: `{name}` is not in canonical form (expected `{canonical}`)"
)));
}
Ok(canonical)
}
fn rfc3339(secs: u64) -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::<chrono::Utc>::from_timestamp(secs as i64, 0).unwrap_or_default()
}
async fn do_domain_assign(
control: &VerifiedControlPlane,
state: &AppState,
body: &Value,
) -> Result<(String, Value), OpError> {
use did_hosting_common::server::assignment::record_assignment;
use did_hosting_common::server::pending_purge::{self, CancelOutcome};
let directive: replica_assign::Payload = payload(body, "replica/domain/assign")?;
let domain = canonical_domain(&directive.domain, "replica/domain/assign")?;
let now = did_hosting_common::server::auth::session::now_epoch();
record_assignment(&state.store, &domain, &control.did, now)
.await
.map_err(OpError::from)?;
if let CancelOutcome::Removed(prev) = pending_purge::cancel(&state.store, &domain)
.await
.map_err(OpError::from)?
{
info!(
did = %control.did,
domain = %domain,
scheduled_at = prev.scheduled_at,
grace_seconds = prev.grace_seconds,
"domain re-assign cancelled pending purge — data retained"
);
}
info!(did = %control.did, domain = %domain, "domain assigned");
Ok((
MSG_REPLICA_DOMAIN_ASSIGN_ACK.to_string(),
reply::<replica_assign::Response>(
json!({ "domain": domain, "status": "applied" }),
"replica/domain/assign",
)?,
))
}
fn unassign_grace_seconds(state: &AppState) -> u64 {
use did_hosting_common::server::pending_purge::parse_grace_string;
parse_grace_string(&state.config.hosting.unassigned_purge_grace).unwrap_or_else(|e| {
warn!(
error = %e,
config = %state.config.hosting.unassigned_purge_grace,
"unassigned_purge_grace unparseable; defaulting to 2h"
);
2 * 60 * 60
})
}
async fn do_domain_unassign(
control: &VerifiedControlPlane,
state: &AppState,
body: &Value,
) -> Result<(String, Value), OpError> {
use did_hosting_common::server::assignment::{UnassignOutcome, unassign};
use did_hosting_common::server::pending_purge;
let directive: replica_unassign::Payload = payload(body, "replica/domain/unassign")?;
let domain = canonical_domain(&directive.domain, "replica/domain/unassign")?;
let now = did_hosting_common::server::auth::session::now_epoch();
let outcome = unassign(&state.store, &domain)
.await
.map_err(OpError::from)?;
let purge_at = match outcome {
UnassignOutcome::Removed(_) => {
let grace_seconds = unassign_grace_seconds(state);
pending_purge::schedule(
&state.store,
&domain,
now,
grace_seconds,
"grace-expired",
&control.did,
)
.await
.map_err(OpError::from)?;
info!(did = %control.did, domain = %domain, grace_seconds, "domain unassigned; purge scheduled");
now.saturating_add(grace_seconds)
}
UnassignOutcome::Missing => {
match pending_purge::get(&state.store, &domain)
.await
.map_err(OpError::from)?
{
Some(existing) => existing.scheduled_at.saturating_add(existing.grace_seconds),
None => now,
}
}
};
Ok((
MSG_REPLICA_DOMAIN_UNASSIGN_ACK.to_string(),
reply::<replica_unassign::Response>(
json!({ "domain": domain, "status": "scheduled", "purgeAt": rfc3339(purge_at) }),
"replica/domain/unassign",
)?,
))
}
async fn do_domain_purge(
control: &VerifiedControlPlane,
state: &AppState,
body: &Value,
) -> Result<(String, Value), OpError> {
use did_hosting_common::server::assignment;
use did_hosting_common::server::domain_purge::purge_domain_dids;
use did_hosting_common::server::pending_purge;
let directive: replica_purge::Payload = payload(body, "replica/domain/purge")?;
let domain = canonical_domain(&directive.domain, "replica/domain/purge")?;
if let Some(current) = assignment::get(&state.store, &domain)
.await
.map_err(OpError::from)?
&& control.issued_at < current.assigned_at
{
warn!(
did = %control.did,
domain = %domain,
issued_at = control.issued_at,
assigned_at = current.assigned_at,
"replica/domain/purge refused: issued before the current assignment"
);
return Err(OpError::Declared(
replica_purge::error_codes::STALE_PURGE,
"the purge predates this server's current assignment of the domain; nothing was deleted"
.into(),
));
}
let report = purge_domain_dids(&state.store, &domain, "admin-immediate")
.await
.map_err(OpError::from)?;
if report.failed > 0 {
return Err(OpError::Transient(format!(
"{} slot(s) on {domain} could not be deleted just now",
report.failed
)));
}
state.did_cache.clear();
pending_purge::cancel(&state.store, &domain)
.await
.map_err(OpError::from)?;
info!(
did = %control.did,
domain = %domain,
removed = report.deleted,
skipped_no_domain = report.skipped_no_domain,
"domain purged on the control plane's directive"
);
Ok((
MSG_REPLICA_DOMAIN_PURGE_ACK.to_string(),
reply::<replica_purge::Response>(
json!({ "domain": domain, "status": "purged", "removed": report.deleted }),
"replica/domain/purge",
)?,
))
}
async fn do_domain_upsert(
control: &VerifiedControlPlane,
state: &AppState,
body: &Value,
) -> Result<(String, Value), OpError> {
use did_hosting_common::server::domain::{
DISABLE_PURGE_REASON, DomainStatus, create_domain, get_domain, normalize_domain_name,
update_domain, wire,
};
use did_hosting_common::server::pending_purge;
let directive: replica_upsert::Payload = payload(body, "replica/domain/upsert")?;
let name = directive.entry.name.clone();
let canonical = normalize_domain_name(&name).map_err(|e| {
OpError::Declared(
replica_upsert::error_codes::NON_CANONICAL_NAME,
format!("`{name}` is not a domain name: {e}"),
)
})?;
if canonical != name {
return Err(OpError::Declared(
replica_upsert::error_codes::NON_CANONICAL_NAME,
format!("`{name}` is not in canonical form (expected `{canonical}`)"),
));
}
let entry_value =
serde_json::to_value(&directive.entry).map_err(|e| OpError::Transient(e.to_string()))?;
let entry = wire::from_spec(&entry_value)
.map_err(|e| OpError::Refused(format!("replica/domain/upsert: {e}")))?;
let existed = get_domain(&state.store, &canonical)
.await
.map_err(OpError::from)?
.is_some();
if existed {
update_domain(&state.store, &canonical, &entry)
.await
.map_err(OpError::from)?;
} else {
create_domain(&state.store, &entry)
.await
.map_err(OpError::from)?;
}
match entry.status {
DomainStatus::Active => {
pending_purge::cancel(&state.store, &canonical)
.await
.map_err(OpError::from)?;
}
DomainStatus::Disabled => match (entry.disabled_at, entry.purge_at) {
(Some(disabled_at), Some(purge_at)) => {
pending_purge::schedule(
&state.store,
&canonical,
disabled_at,
purge_at.saturating_sub(disabled_at),
DISABLE_PURGE_REASON,
&control.did,
)
.await
.map_err(OpError::from)?;
}
_ => warn!(
domain = %canonical,
"replica/domain/upsert: status=disabled without disabledAt/purgeAt — no purge scheduled"
),
},
}
info!(
did = %control.did,
domain = %canonical,
action = if existed { "updated" } else { "created" },
status = ?entry.status,
"domain entry replicated"
);
Ok((
MSG_REPLICA_DOMAIN_UPSERT_ACK.to_string(),
reply::<replica_upsert::Response>(
json!({ "name": canonical, "status": "applied" }),
"replica/domain/upsert",
)?,
))
}
async fn do_server_metrics(state: &AppState, body: &Value) -> Result<(String, Value), OpError> {
use trust_tasks_rs::specs::did_management::server::metrics::v0_1 as metrics;
let _: metrics::Payload = payload(body, "server/metrics")?;
let now = did_hosting_common::server::auth::session::now_epoch();
let status = &state.replication;
let bound = state.config.replication.staleness_bound_secs;
let gauge = |name: &str, value: u64| json!({ "name": name, "value": value });
let mut gauges = vec![
gauge(
"did_hosting_replication_reconcile_age_seconds",
status.age(now),
),
gauge("did_hosting_replication_staleness_bound_seconds", bound),
gauge(
"did_hosting_replication_fresh",
u64::from(status.is_fresh(now, bound)),
),
gauge("did_hosting_replication_stale_slots", status.stale_slots()),
];
if let Some(at) = status.last_reconciled_at() {
gauges.push(gauge("did_hosting_replication_last_reconciled_at", at));
}
#[allow(unused_mut)]
let mut counters = vec![gauge(
"did_hosting_replication_repaired_total",
status.repaired_total(),
)];
#[cfg(feature = "metrics")]
counters.extend(
did_hosting_common::server::metrics::counters()
.into_iter()
.map(|(name, value)| json!({ "name": name, "value": value })),
);
Ok((
format!("{MSG_SERVER_METRICS}#response"),
reply::<metrics::Response>(
json!({
"snapshot": {
"takenAt": chrono::Utc::now(),
"counters": counters,
"gauges": gauges,
"histograms": [],
}
}),
"server/metrics",
)?,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::AppError;
fn payload_of(err: OpError) -> Value {
let doc: trust_tasks_rs::TrustTask<Value> = serde_json::from_value(json!({
"id": "urn:uuid:00000000-0000-0000-0000-000000000001",
"type": MSG_SYNC_UPDATE,
"payload": {},
}))
.unwrap();
serde_json::to_value(doc.reject_with("urn:uuid:x", err.reject_reason())).unwrap()["payload"]
.clone()
}
#[test]
fn storage_failures_are_retryable() {
for e in [
AppError::Store("disk full".into()),
AppError::Internal("lock poisoned".into()),
AppError::Io(std::io::Error::other("eio")),
] {
let err = OpError::from(e);
assert!(matches!(err, OpError::Transient(_)), "{err:?}");
assert_eq!(payload_of(err)["retryable"], true);
}
}
#[test]
fn refusals_are_final() {
for err in [
OpError::from(AppError::Validation("not an extension".into())),
OpError::from("missing 'mnemonic'"),
] {
assert!(matches!(err, OpError::Refused(_)), "{err:?}");
assert_eq!(payload_of(err)["retryable"], false);
}
}
}