use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::{AdminAuth, AuthClaims, StepUpAuth};
use crate::error::AppError;
use crate::server::AppState;
use did_hosting_common::server::acl;
use did_hosting_common::server::auth::session::now_epoch;
use did_hosting_common::server::domain::{
self, DomainBranding, DomainEntry, DomainQuota, DomainScope, DomainStatus, DomainUrlScheme,
normalize_domain_name,
};
use did_hosting_common::server::pending_purge;
#[derive(Debug, Serialize)]
pub struct DomainListResponse {
pub domains: Vec<DomainEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
pub async fn list_domains(
auth: AdminAuth,
State(state): State<AppState>,
) -> Result<Json<DomainListResponse>, AppError> {
let mut domains = domain::list_domains(&state.store).await?;
domains.sort_by(|a, b| a.name.cmp(&b.name));
let default = domain::get_default_domain(&state.store).await?;
info!(caller = %auth.0.did, count = domains.len(), "admin listed domains");
Ok(Json(DomainListResponse { domains, default }))
}
pub async fn list_my_domains(
auth: AuthClaims,
State(state): State<AppState>,
) -> Result<Json<DomainListResponse>, AppError> {
let resp = fetch_me_domains_for_caller(&auth, &state).await?;
Ok(Json(resp))
}
pub(crate) async fn fetch_me_domains_for_caller(
auth: &AuthClaims,
state: &AppState,
) -> Result<DomainListResponse, AppError> {
let all = domain::list_domains(&state.store).await?;
let scope = match acl::get_acl_entry(&state.acl_ks, &auth.did).await? {
Some(entry) => entry.domains,
None => DomainScope::All,
};
let role_overrides_scope = matches!(
auth.role,
crate::acl::Role::Admin | crate::acl::Role::Service
);
let mut domains: Vec<DomainEntry> = if role_overrides_scope {
all
} else {
all.into_iter().filter(|d| scope.allows(&d.name)).collect()
};
domains.sort_by(|a, b| a.name.cmp(&b.name));
let default = match scope.default_domain() {
Some(d) => Some(d.to_string()),
None => domain::get_default_domain(&state.store).await?,
};
info!(
caller = %auth.did,
count = domains.len(),
"caller listed scoped domains"
);
Ok(DomainListResponse { domains, default })
}
#[derive(Debug, Deserialize)]
pub struct CreateDomainRequest {
pub name: String,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub scheme: Option<DomainUrlScheme>,
#[serde(default)]
pub branding: Option<DomainBranding>,
#[serde(default)]
pub witnesses: Option<Vec<String>>,
#[serde(default)]
pub watchers: Option<Vec<String>>,
#[serde(default)]
pub quota: Option<DomainQuota>,
#[serde(default)]
pub well_known_enabled: bool,
#[serde(default)]
pub set_as_default: bool,
}
pub async fn create_domain_route(
auth: AdminAuth,
State(state): State<AppState>,
Json(req): Json<CreateDomainRequest>,
) -> Result<(StatusCode, Json<DomainEntry>), AppError> {
let canonical = normalize_domain_name(&req.name)?;
let entry = DomainEntry {
name: canonical.clone(),
label: req.label,
scheme: req.scheme.unwrap_or(DomainUrlScheme::Https),
status: DomainStatus::Active,
created_at: now_epoch(),
default_domain: false,
branding: req.branding,
witnesses: req.witnesses,
watchers: req.watchers,
quota: req.quota,
well_known_enabled: req.well_known_enabled,
disabled_at: None,
purge_at: None,
};
domain::create_domain(&state.store, &entry).await?;
if req.set_as_default {
domain::set_default_domain(&state.store, &canonical).await?;
}
let stored = domain::get_domain(&state.store, &canonical)
.await?
.ok_or_else(|| AppError::Internal(format!("domain '{canonical}' missing after create")))?;
let (sent, failed) = crate::server_push::fanout_domain_upsert(&state, &stored).await;
info!(
caller = %auth.0.did,
domain = %canonical,
set_as_default = req.set_as_default,
fanout_sent = sent,
fanout_failed = failed,
"domain created"
);
Ok((StatusCode::CREATED, Json(stored)))
}
#[derive(Debug, Deserialize)]
pub struct UpdateDomainRequest {
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub scheme: Option<DomainUrlScheme>,
#[serde(default)]
pub branding: Option<DomainBranding>,
#[serde(default)]
pub witnesses: Option<Vec<String>>,
#[serde(default)]
pub watchers: Option<Vec<String>>,
#[serde(default)]
pub quota: Option<DomainQuota>,
#[serde(default)]
pub well_known_enabled: Option<bool>,
}
pub async fn update_domain_route(
auth: AdminAuth,
State(state): State<AppState>,
Path(name): Path<String>,
Json(req): Json<UpdateDomainRequest>,
) -> Result<Json<DomainEntry>, AppError> {
let canonical = normalize_domain_name(&name)?;
let mut entry = domain::get_domain(&state.store, &canonical)
.await?
.ok_or_else(|| AppError::NotFound(format!("domain '{canonical}'")))?;
if let Some(label) = req.label {
entry.label = Some(label);
}
if let Some(scheme) = req.scheme {
entry.scheme = scheme;
}
if let Some(branding) = req.branding {
entry.branding = Some(branding);
}
if let Some(witnesses) = req.witnesses {
entry.witnesses = Some(witnesses);
}
if let Some(watchers) = req.watchers {
entry.watchers = Some(watchers);
}
if let Some(quota) = req.quota {
entry.quota = Some(quota);
}
if let Some(wk) = req.well_known_enabled {
entry.well_known_enabled = wk;
}
domain::update_domain(&state.store, &canonical, &entry).await?;
let (sent, failed) = crate::server_push::fanout_domain_upsert(&state, &entry).await;
info!(
caller = %auth.0.did,
domain = %canonical,
fanout_sent = sent,
fanout_failed = failed,
"domain updated"
);
Ok(Json(entry))
}
pub async fn disable_domain_route(
auth: AdminAuth,
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<DomainEntry>, AppError> {
let canonical = normalize_domain_name(&name)?;
let grace_seconds = pending_purge::parse_grace_string(
&state.config.hosting.disable_purge_grace,
)
.map_err(|e| {
AppError::Internal(format!(
"config [hosting] disable_purge_grace='{}' is invalid: {e}",
state.config.hosting.disable_purge_grace
))
})?;
domain::disable_domain(
&state.store,
&canonical,
now_epoch(),
grace_seconds,
&auth.0.did,
)
.await?;
let entry = domain::get_domain(&state.store, &canonical)
.await?
.ok_or_else(|| AppError::Internal(format!("domain '{canonical}' missing after disable")))?;
let (sent, failed) = crate::server_push::fanout_domain_upsert(&state, &entry).await;
info!(
caller = %auth.0.did,
domain = %canonical,
grace_seconds,
fanout_sent = sent,
fanout_failed = failed,
"domain disabled (soft-delete scheduled)"
);
Ok(Json(entry))
}
pub async fn enable_domain_route(
auth: AdminAuth,
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<DomainEntry>, AppError> {
let canonical = normalize_domain_name(&name)?;
domain::enable_domain(&state.store, &canonical).await?;
let entry = domain::get_domain(&state.store, &canonical)
.await?
.ok_or_else(|| AppError::Internal(format!("domain '{canonical}' missing after enable")))?;
let (sent, failed) = crate::server_push::fanout_domain_upsert(&state, &entry).await;
info!(
caller = %auth.0.did,
domain = %canonical,
fanout_sent = sent,
fanout_failed = failed,
"domain enabled"
);
Ok(Json(entry))
}
#[derive(Debug, Deserialize)]
pub struct DeleteDomainQuery {
#[serde(default)]
pub purge_servers: bool,
pub confirm: Option<String>,
}
pub async fn delete_domain_route(
auth: StepUpAuth,
State(state): State<AppState>,
Path(name): Path<String>,
Query(opts): Query<DeleteDomainQuery>,
) -> Result<StatusCode, AppError> {
let canonical = normalize_domain_name(&name)?;
let confirm = opts
.confirm
.as_deref()
.ok_or_else(|| {
AppError::Validation(
"missing required `confirm=<domain>` query param — see API docs for the typo guard"
.into(),
)
})
.and_then(normalize_domain_name)?;
if confirm != canonical {
return Err(AppError::Validation(format!(
"confirm '{confirm}' does not match path '{canonical}'"
)));
}
let entry = domain::get_domain(&state.store, &canonical)
.await?
.ok_or_else(|| AppError::NotFound(format!("domain '{canonical}'")))?;
if entry.status == DomainStatus::Active {
return Err(AppError::Conflict(format!(
"cannot delete '{canonical}' — domain is Active; disable it first"
)));
}
let mut purged_servers: u32 = 0;
if opts.purge_servers {
match crate::registry::list_instances(&state.registry_ks).await {
Ok(instances) => {
for inst in instances {
if !inst.served_domains.iter().any(|d| d == &canonical) {
continue;
}
let Some(target_did) = inst.metadata.get("did").and_then(|v| v.as_str()) else {
warn!(
instance_id = %inst.instance_id,
"purge fanout: instance has no `did` in metadata — skipping"
);
continue;
};
match crate::server_push::send_domain_purge(&state, target_did, &canonical)
.await
{
Ok(()) => {
purged_servers += 1;
info!(
instance_id = %inst.instance_id,
target_did,
domain = %canonical,
"purge fanout: send_domain_purge dispatched"
);
}
Err(e) => warn!(
instance_id = %inst.instance_id,
target_did,
error = %e,
"purge fanout: send_domain_purge failed; continuing"
),
}
}
}
Err(e) => warn!(
error = %e,
"purge fanout: failed to list registry instances; skipping fanout"
),
}
}
domain::delete_domain_record(&state.store, &canonical).await?;
let _ = pending_purge::cancel(&state.store, &canonical).await;
info!(
caller = %auth.0.did,
acr = %auth.0.acr,
domain = %canonical,
purge_servers = opts.purge_servers,
purged_servers,
"domain force-deleted (admin override of grace window)"
);
Ok(StatusCode::NO_CONTENT)
}
pub async fn set_default_domain_route(
auth: AdminAuth,
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<DomainEntry>, AppError> {
let canonical = normalize_domain_name(&name)?;
domain::set_default_domain(&state.store, &canonical).await?;
let entry = domain::get_domain(&state.store, &canonical)
.await?
.ok_or_else(|| {
AppError::Internal(format!("domain '{canonical}' missing after set-default"))
})?;
let (sent, failed) = crate::server_push::fanout_domain_upsert(&state, &entry).await;
info!(
caller = %auth.0.did,
domain = %canonical,
fanout_sent = sent,
fanout_failed = failed,
"domain set as default"
);
Ok(Json(entry))
}