did-hosting-server 0.8.3

DID Hosting Server, handles management and serving of WebVH Content
Documentation
use crate::auth::AuthClaims;
use crate::did_ops::{self, LogEntryInfo, LogMetadata};
use crate::error::AppError;
use crate::server::AppState;
use crate::watcher_push::{self, WatcherSyncStatus};
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use did_hosting_common::DidListEntry;
use serde::{Deserialize, Serialize};

/// Strip leading slash from path-extracted mnemonics.
fn clean_mnemonic(m: &str) -> &str {
    m.trim_start_matches('/')
}

// ---------- GET /dids/{mnemonic} ----------

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DidDetailResponse {
    pub mnemonic: String,
    pub created_at: u64,
    pub updated_at: u64,
    pub version_count: u64,
    pub did_id: Option<String>,
    pub owner: String,
    pub disabled: bool,
    pub log: Option<LogMetadata>,
    pub watcher_sync: Option<Vec<WatcherSyncStatus>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub method: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub domain: Option<String>,
}

impl DidDetailResponse {
    fn from_record(
        record: did_ops::DidRecord,
        log: Option<LogMetadata>,
        watcher_sync: Option<Vec<WatcherSyncStatus>>,
    ) -> Self {
        let method = (!record.method.is_empty()).then(|| record.method.clone());
        let domain = (!record.domain.is_empty()).then(|| record.domain.clone());
        Self {
            mnemonic: record.mnemonic,
            created_at: record.created_at,
            updated_at: record.updated_at,
            version_count: record.version_count,
            did_id: record.did_id,
            owner: record.owner,
            disabled: record.disabled,
            log,
            watcher_sync,
            method,
            domain,
        }
    }
}

#[cfg_attr(feature = "openapi", utoipa::path(
    get,
    path = "/api/dids/{mnemonic}",
    tag = "dids",
    params(("mnemonic" = String, Path, description = "Slot path (mnemonic); may contain '/'")),
    responses(
        (status = 200, description = "DID metadata + log summary", content_type = "application/json"),
        (status = 401, description = "Missing/invalid bearer token"),
        (status = 403, description = "Caller not authorized for this slot"),
        (status = 404, description = "No such DID slot"),
    ),
    security(("bearer" = [])),
))]
pub async fn get_did(
    auth: AuthClaims,
    State(state): State<AppState>,
    Path(mnemonic): Path<String>,
) -> Result<Json<DidDetailResponse>, AppError> {
    let mnemonic = clean_mnemonic(&mnemonic);
    let result = did_ops::get_did_info(&auth, &state, mnemonic).await?;

    let watcher_sync: Option<Vec<WatcherSyncStatus>> = state
        .dids_ks
        .get(did_ops::watcher_sync_key(mnemonic))
        .await?;

    Ok(Json(DidDetailResponse::from_record(
        result.record,
        result.log_metadata,
        watcher_sync,
    )))
}

// ---------- GET /dids/{mnemonic}/log ----------

#[cfg_attr(feature = "openapi", utoipa::path(
    get,
    path = "/api/log/{mnemonic}",
    tag = "dids",
    params(("mnemonic" = String, Path, description = "Slot path (mnemonic); may contain '/'")),
    responses(
        (status = 200, description = "Parsed log entries (versionId, versionTime, state, parameters)", content_type = "application/json"),
        (status = 401, description = "Missing/invalid bearer token"),
        (status = 403, description = "Caller not authorized for this slot"),
        (status = 404, description = "No such DID slot"),
    ),
    security(("bearer" = [])),
))]
pub async fn get_did_log(
    auth: AuthClaims,
    State(state): State<AppState>,
    Path(mnemonic): Path<String>,
) -> Result<Json<Vec<LogEntryInfo>>, AppError> {
    let mnemonic = clean_mnemonic(&mnemonic);
    let entries = did_ops::get_did_log(&auth, &state, mnemonic).await?;
    Ok(Json(entries))
}

// ---------- PUT /dids/{mnemonic} ----------

#[cfg_attr(feature = "openapi", utoipa::path(
    put,
    path = "/api/dids/{mnemonic}",
    tag = "dids",
    params(("mnemonic" = String, Path, description = "Slot path (mnemonic); may contain '/'")),
    request_body(
        content = String,
        description = "did:webvh log — one JSON LogEntry per line (JSONL)",
        content_type = "application/jsonl+json",
    ),
    responses(
        (status = 204, description = "Published; content stored and watchers notified"),
        (status = 400, description = "Invalid did.jsonl, failed proof verification, or DID id/host mismatch"),
        (status = 401, description = "Missing/invalid bearer token"),
        (status = 403, description = "Caller not authorized for this slot"),
    ),
    security(("bearer" = [])),
))]
pub async fn upload_did(
    auth: AuthClaims,
    State(state): State<AppState>,
    Path(mnemonic): Path<String>,
    body: String,
) -> Result<StatusCode, AppError> {
    let mnemonic = clean_mnemonic(&mnemonic);
    did_ops::publish_did(&auth, &state, mnemonic, &body).await?;
    watcher_push::notify_watchers_did(
        &state.config,
        &state.http_client,
        &state.dids_ks,
        mnemonic.to_string(),
    );
    Ok(StatusCode::NO_CONTENT)
}

// ---------- PUT /dids/{mnemonic}/witness ----------

#[cfg_attr(feature = "openapi", utoipa::path(
    put,
    path = "/api/witness/{mnemonic}",
    tag = "dids",
    params(("mnemonic" = String, Path, description = "Slot path (mnemonic); may contain '/'")),
    request_body(
        content = String,
        description = "Witness-proof document (did-witness.json) for the slot's current log",
        content_type = "application/json",
    ),
    responses(
        (status = 204, description = "Witness proofs stored"),
        (status = 400, description = "Invalid witness document"),
        (status = 401, description = "Missing/invalid bearer token"),
        (status = 403, description = "Caller not authorized for this slot"),
    ),
    security(("bearer" = [])),
))]
pub async fn upload_witness(
    auth: AuthClaims,
    State(state): State<AppState>,
    Path(mnemonic): Path<String>,
    body: String,
) -> Result<StatusCode, AppError> {
    let mnemonic = clean_mnemonic(&mnemonic);
    did_ops::upload_witness(&auth, &state, mnemonic, &body).await?;
    watcher_push::notify_watchers_did(
        &state.config,
        &state.http_client,
        &state.dids_ks,
        mnemonic.to_string(),
    );
    Ok(StatusCode::NO_CONTENT)
}

// ---------- DELETE /dids/{mnemonic} ----------

#[cfg_attr(feature = "openapi", utoipa::path(
    delete,
    path = "/api/dids/{mnemonic}",
    tag = "dids",
    params(("mnemonic" = String, Path, description = "Slot path (mnemonic); may contain '/'")),
    responses(
        (status = 204, description = "Soft-deleted; content retained for the recovery window"),
        (status = 401, description = "Missing/invalid bearer token"),
        (status = 403, description = "Caller not authorized for this slot"),
        (status = 404, description = "No such DID slot"),
    ),
    security(("bearer" = [])),
))]
pub async fn delete_did(
    auth: AuthClaims,
    State(state): State<AppState>,
    Path(mnemonic): Path<String>,
) -> Result<StatusCode, AppError> {
    let mnemonic = clean_mnemonic(&mnemonic);
    did_ops::delete_did(&auth, &state, mnemonic).await?;
    watcher_push::notify_watchers_delete(
        &state.config,
        &state.http_client,
        &state.dids_ks,
        mnemonic.to_string(),
    );
    Ok(StatusCode::NO_CONTENT)
}

// ---------- PUT /dids/{mnemonic}/disable ----------

pub async fn disable_did(
    auth: AuthClaims,
    State(state): State<AppState>,
    Path(mnemonic): Path<String>,
) -> Result<StatusCode, AppError> {
    let mnemonic = clean_mnemonic(&mnemonic);
    did_ops::set_did_disabled(&auth, &state, mnemonic, true).await?;
    watcher_push::notify_watchers_did(
        &state.config,
        &state.http_client,
        &state.dids_ks,
        mnemonic.to_string(),
    );
    Ok(StatusCode::NO_CONTENT)
}

// ---------- PUT /dids/{mnemonic}/enable ----------

pub async fn enable_did(
    auth: AuthClaims,
    State(state): State<AppState>,
    Path(mnemonic): Path<String>,
) -> Result<StatusCode, AppError> {
    let mnemonic = clean_mnemonic(&mnemonic);
    did_ops::set_did_disabled(&auth, &state, mnemonic, false).await?;
    watcher_push::notify_watchers_did(
        &state.config,
        &state.http_client,
        &state.dids_ks,
        mnemonic.to_string(),
    );
    Ok(StatusCode::NO_CONTENT)
}

// ---------- GET /raw/{mnemonic} ----------

pub async fn get_raw_log(
    auth: AuthClaims,
    State(state): State<AppState>,
    Path(mnemonic): Path<String>,
) -> Result<
    (
        StatusCode,
        [(axum::http::HeaderName, &'static str); 1],
        String,
    ),
    AppError,
> {
    let mnemonic = clean_mnemonic(&mnemonic);
    let content = did_ops::get_raw_log(&auth, &state, mnemonic).await?;
    Ok((
        StatusCode::OK,
        [(
            axum::http::header::CONTENT_TYPE,
            "text/plain; charset=utf-8",
        )],
        content,
    ))
}

// ---------- GET /dids ----------

#[derive(Debug, Deserialize)]
pub struct ListDidsQuery {
    pub owner: Option<String>,
    pub limit: Option<usize>,
    pub offset: Option<usize>,
}

#[cfg_attr(feature = "openapi", utoipa::path(
    get,
    path = "/api/dids",
    tag = "dids",
    params(
        ("owner" = Option<String>, Query, description = "Filter by owner DID (admin only)"),
        ("limit" = Option<usize>, Query, description = "Page size"),
        ("offset" = Option<usize>, Query, description = "Page offset"),
    ),
    responses(
        (status = 200, description = "List of hosted DID slots", content_type = "application/json"),
        (status = 401, description = "Missing/invalid bearer token"),
    ),
    security(("bearer" = [])),
))]
pub async fn list_dids(
    auth: AuthClaims,
    State(state): State<AppState>,
    Query(query): Query<ListDidsQuery>,
) -> Result<Json<Vec<DidListEntry>>, AppError> {
    let entries = did_ops::list_dids(
        &auth,
        &state,
        query.owner.as_deref(),
        query.limit,
        query.offset,
    )
    .await?;
    Ok(Json(entries))
}