vtc-service 0.12.0

Service for Verifiable Trust Communities
//! `GET / PUT /v1/community/profile` handlers.
//!
//! Per spec §5.1:
//!
//! - **GET**: any authenticated session may read. Returns 404
//!   `community_not_initialised` when no profile has been written
//!   yet (pre-bootstrap state).
//! - **PUT**: requires `Admin` role. Rejects changes to
//!   `community_did` (immutable). Emits a `CommunityProfileUpdated`
//!   audit event listing only the field names that actually changed.
//!
//! The trust-task validation happens in
//! [`crate::routes::router`]'s `route_with_task` layer; reaching a
//! handler implies the header matched.

use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use chrono::{DateTime, Utc};
use serde::Serialize;
use tracing::info;
use vti_common::audit::{AuditEvent, CommunityProfileUpdatedData};
use vti_common::error::AppError;

use crate::community::{CommunityProfile, CommunityProfileUpdate, load_profile, store_profile};
use crate::registry::HealthStatus;
use crate::server::AppState;

/// GET response shape.
///
/// Wraps the persisted [`CommunityProfile`] + adds the live
/// `registryStatus` field surfaced by Phase 3 M3.2.
/// `registry_status` is read from `AppState` at request time,
/// not persisted on the profile row.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct CommunityProfileResponse {
    /// Nested, because `vtc/community/profile/show/0.1` requires a
    /// `profile` member and is `additionalProperties: false`. This was
    /// `#[serde(flatten)]` until #1094, which put every profile field at the
    /// top level — where the schema permits none of them.
    pub profile: ProfileWithStatus,
}

/// The persisted profile plus the one field that is computed per request.
///
/// `registryStatus` sits *inside* the profile object rather than beside it,
/// because that is where the canonical `CommunityProfile` component defines
/// it ("Populated on reads; not settable"). Un-flattening alone would have
/// left it a sibling of `profile`, which the response schema forbids.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct ProfileWithStatus {
    #[serde(flatten)]
    pub profile: CommunityProfile,
    /// Trust-registry reachability — `"active"` when the last
    /// health probe succeeded, `"degraded"` otherwise (probe
    /// never ran, last probe failed, or `registry.url` is
    /// unset). Spec §8.1.
    pub registry_status: HealthStatus,
}

/// Public read shape — the curated subset of [`CommunityProfile`]
/// exposed unauthenticated at `GET /v1/community/public-profile`.
///
/// Drops `extensions` (operator-defined JSON, not guaranteed
/// public-safe) and `registryStatus` (operational telemetry that
/// belongs behind admin auth). Adds `mediator_did` so the default
/// landing page can render the community's full identity in one
/// fetch.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct PublicCommunityProfile {
    pub community_did: String,
    pub name: String,
    pub description: String,
    pub logo_url: Option<String>,
    pub public_url: Option<String>,
    pub contact_email: Option<String>,
    pub language: String,
    pub created_at: DateTime<Utc>,
    /// What this community's governance asserts about personhood.
    ///
    /// Published **unauthenticated, on purpose**. DTG Credentials puts PHC
    /// status in "governance and trust registries, not credential structure",
    /// so the party who needs this is a verifier holding one of this
    /// community's VMCs — someone who is not a member, has no token, and may
    /// have no relationship with the community at all. Behind auth it would
    /// answer the question only for people who did not need to ask it.
    ///
    /// This is the *community's own claim about itself*. A verifier deciding
    /// how much it is worth reads the trust registry, which is what the spec
    /// calls authoritative; this is the self-published copy, and it is here so
    /// the claim is resolvable at all rather than so it is trusted.
    pub personhood: crate::community::PersonhoodGovernance,
    /// The community's DIDComm mediator DID, if one is configured.
    /// Sourced from the live daemon config (same as `/health`), not
    /// the persisted profile row.
    pub mediator_did: Option<String>,
    /// How to reach this community, and whether each route currently works.
    ///
    /// Every protocol is listed, each carrying `advertised` (the DID document
    /// offers it) and `serviceable` (this VTC can answer on it right now).
    /// Reachable means both.
    ///
    /// **The DID document remains authoritative.** This is a *view* of it,
    /// resolved at request time, published so a visitor or a monitor can
    /// validate connectivity without resolving the DID themselves — never an
    /// independent declaration. A client choosing a transport must still match
    /// on the document's service `type`; if the two ever disagree, the document
    /// wins and this field is the thing that is wrong.
    ///
    /// Empty when the community's own DID could not be resolved — reported as
    /// unknown rather than as "advertises nothing", which would be a lie a
    /// caller might act on.
    pub transports: Vec<crate::transport_capability::TransportStatus>,
}

/// Public GET handler. Trust-Task-exempt, no auth. Returns only the
/// curated subset of profile fields a visitor's browser should see.
/// GET /community/public-profile — curated public community profile.
/// Public, unauthenticated.
#[utoipa::path(
    get, path = "/community/public-profile", tag = "community",
    responses(
        (status = 200, description = "Public community profile", body = PublicCommunityProfile),
        (status = 404, description = "Community profile not initialised"),
    ),
)]
pub async fn get_public_profile(
    State(state): State<AppState>,
) -> Result<Json<PublicCommunityProfile>, AppError> {
    let profile = load_profile(&state.community_ks)
        .await?
        .ok_or_else(|| AppError::NotFound("community profile not initialised".into()))?;
    let mediator_did = state
        .config
        .read()
        .await
        .messaging
        .as_ref()
        .map(|m| m.mediator_did.clone());

    // Live mediator connectivity, off the delivery transport's re-falsifiable
    // signal — the same source `/health/diagnostics` reads, never a boot latch
    // (R6.2). A published "reachable" that cannot go false again would be worse
    // than publishing nothing.
    let messaging_connected = matches!(
        state.didcomm.get().map(|m| m.service.status()),
        Some(affinidi_messaging_delivery::MessagingStatus::Connected)
    );

    // Resolved rather than read from config or the local `did.jsonl`: the
    // document is the authority for what this community advertises, and it can
    // gain a service long after the VTC last wrote its own mirror — which is
    // exactly what happened to the deployment that motivated this
    // (`#tsp` arrived at DID log version 3). The resolver caches, so the common
    // case costs no network even though this endpoint is unauthenticated.
    let transports = match crate::transport_capability::resolved_capabilities(
        state.did_resolver.as_ref(),
        &profile.community_did,
    )
    .await
    {
        Some(caps) => {
            crate::transport_capability::public_transport_view_for_build(&caps, messaging_connected)
        }
        // Unknown, not "none". Publishing an empty advertised set would tell a
        // visitor this community offers no way in, which is a different — and
        // actionable — claim from "we could not check".
        None => Vec::new(),
    };

    Ok(Json(PublicCommunityProfile {
        community_did: profile.community_did,
        name: profile.name,
        description: profile.description,
        logo_url: profile.logo_url,
        public_url: profile.public_url,
        contact_email: profile.contact_email,
        language: profile.language,
        created_at: profile.created_at,
        personhood: profile.personhood,
        mediator_did,
        transports,
    }))
}

/// GET handler. Returns the singleton profile + the live
/// trust-registry status.
/// GET /community/profile — full community profile + live registry status.
/// Auth: any authenticated session.
/// `vtc/community/profile/show/0.1` — the profile and registry status. A
/// signed document served by the spine (`trust_tasks::community_tasks`).
pub(crate) async fn show_profile(state: &AppState) -> Result<CommunityProfileResponse, AppError> {
    let profile = load_profile(&state.community_ks)
        .await?
        .ok_or_else(|| AppError::NotFound("community profile not initialised".into()))?;
    let registry_status = state.registry_health.status().await;
    Ok(CommunityProfileResponse {
        profile: ProfileWithStatus {
            profile,
            registry_status,
        },
    })
}

/// PUT response shape — echoes the updated profile + the list of
/// fields that actually changed (operator-friendly + powers the
/// audit event emitter that lands alongside this in a follow-up).
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct UpdateProfileResponse {
    pub profile: CommunityProfile,
    pub fields_changed: Vec<String>,
}

/// `vtc/community/profile/update:validationFailed` — a supplied field failed
/// validation.
pub const PROFILE_UPDATE_ERR_VALIDATION_FAILED: &str =
    trust_tasks_rs::specs::vtc::community::profile::update::v0_1::error_codes::VALIDATION_FAILED
        .code;

/// Apply a profile patch on behalf of `actor_did` — the whole of the
/// operation, with no transport in it.
///
/// Both doors call this: the bearer REST route below, and the signed-document
/// arm in [`crate::trust_tasks`] (#1641 phase 2). Keeping the body here is what
/// stops the two answering differently — the field validation, the no-op
/// short-circuit, the fail-closed audit and the persisted row are decided once.
///
/// Audit is fail-closed: a change that can't be recorded (no `AuditWriter`)
/// returns 503 rather than persisting silently — matching the
/// `/v1/admin/config` doors so auditability doesn't depend on which surface the
/// admin used.
pub(crate) async fn update_profile_inner(
    state: &AppState,
    actor_did: &str,
    update: CommunityProfileUpdate,
) -> Result<UpdateProfileResponse, crate::error::TaskError> {
    use crate::error::TaskError;
    let mut profile = load_profile(&state.community_ks).await?.ok_or_else(|| {
        AppError::NotFound("community profile not initialised — cannot PUT before bootstrap".into())
    })?;

    let prior = profile.clone();
    // Every refusal `apply` makes is a field failing validation.
    let fields_changed = update.apply(&mut profile).map_err(|e| match e {
        e @ AppError::Validation(_) => TaskError::declared(PROFILE_UPDATE_ERR_VALIDATION_FAILED, e),
        e => TaskError::App(e),
    })?;

    if fields_changed.is_empty() {
        // Nothing changed — return 200 with the existing profile.
        // PUT semantics tolerate idempotent no-ops.
        return Ok(UpdateProfileResponse {
            profile,
            fields_changed,
        });
    }

    // Fail-closed: refuse to persist a change we can't audit.
    let audit_writer = state
        .audit_writer
        .as_ref()
        .ok_or_else(|| AppError::ServiceError {
            status: StatusCode::SERVICE_UNAVAILABLE,
            message: "audit writer not configured".into(),
        })?;

    store_profile(&state.community_ks, &profile).await?;
    info!(
        community_did = %profile.community_did,
        fields_changed = ?fields_changed,
        "community profile updated"
    );

    audit_writer
        .write(
            actor_did,
            None,
            AuditEvent::CommunityProfileUpdated(CommunityProfileUpdatedData {
                fields_changed: fields_changed.clone(),
                changes: crate::community::profile::profile_changes(Some(&prior), &profile),
            }),
        )
        .await?;

    Ok(UpdateProfileResponse {
        profile,
        fields_changed,
    })
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    // Behavioural coverage lives in `tests/community_profile.rs` — those
    // exercise the full router stack (Trust-Task header, AdminAuth
    // extractor, JSON body) via `Router::oneshot`. Unit tests for the
    // underlying storage + patch semantics live in
    // `crate::community::profile::tests`.
}