vtc-service 0.12.0

Service for Verifiable Trust Communities
//! `vtc/admin/bootstrap/0.1` — finalises the install flow by
//! writing the first admin ACL entry and emitting
//! `CommunityInstalled`.
//!
//! Implements **M0.6.2** of the VTC MVP Phase 0 plan. Consumes the
//! setup-session JWT minted by `vtc/install/claim/finish/0.2`
//! (M0.5.2). The token carries:
//!
//! - `sub` — the candidate admin `did:key`
//! - `install_jti` — the install-token `jti` it was derived from
//!
//! The earlier "carve-out" lockdown is gone — additional admin
//! invites mint their own per-row install tokens guarded by an
//! out-of-band claim secret, so the bootstrap call no longer needs
//! to slam the install surface shut afterwards. The
//! `claim_finish` ceremony already consumed this row's token; this
//! handler's job is just the first-admin ACL grant + audit event.
//!
//! Pre-session, like `claim_start`/`claim_finish`: called from
//! `trust_tasks::install_tasks`, no REST route mounts it.

use std::sync::Arc;

use crate::acl::{VtcAclEntry, VtcRole, list_acl_entries, store_acl_entry};
use axum::http::StatusCode;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tracing::info;
use uuid::Uuid;
use vti_common::audit::{AuditEvent, AuditWriter, CommunityInstalledData};
use vti_common::auth::passkey::store::get_passkey_user_by_did;
use vti_common::error::AppError;

use crate::acl::admin::{AdminEntry, RegisteredPasskey, store_admin_entry};
use crate::community::{CommunityProfile, load_profile, store_profile};
use crate::error::TaskError;
use crate::install::InstallTokenSigner;
use crate::server::AppState;

#[derive(Deserialize, utoipa::ToSchema)]
#[schema(as = AdminBootstrapRequest)]
#[serde(rename_all = "camelCase")]
pub struct BootstrapRequest {
    pub setup_session_token: String,
}

/// Written by hand so the setup-session token never reaches a log: a derived `Debug` would print it.
impl std::fmt::Debug for BootstrapRequest {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("BootstrapRequest")
            .field("setup_session_token", &"<redacted>")
            .finish()
    }
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct BootstrapResponse {
    pub admin_did: String,
    /// `event_id` of the persisted `CommunityInstalled` audit
    /// envelope. The caller can echo this in operator-facing UI so
    /// the install URL → bootstrap → audit-row chain is traceable.
    pub event_id: Uuid,
}

/// `vtc/admin/bootstrap:invalidToken` — the setup-session token is missing,
/// malformed, expired, or not one this community signed.
pub const BOOTSTRAP_ERR_INVALID_TOKEN: &str =
    trust_tasks_rs::specs::vtc::admin::bootstrap::v0_1::error_codes::INVALID_TOKEN.code;
/// `vtc/admin/bootstrap:alreadyBootstrapped` — the community already has an
/// admin; bootstrap is single-use.
pub const BOOTSTRAP_ERR_ALREADY_BOOTSTRAPPED: &str =
    trust_tasks_rs::specs::vtc::admin::bootstrap::v0_1::error_codes::ALREADY_BOOTSTRAPPED.code;

/// `vtc/admin/bootstrap/0.1` — finalises the install flow. Pre-session: the
/// setup-session token is the credential. Called from
/// `trust_tasks::install_tasks`; no REST route mounts it.
pub(crate) async fn bootstrap(
    state: &AppState,
    req: BootstrapRequest,
) -> Result<BootstrapResponse, TaskError> {
    let signer = require_install_signer(state)?;
    let audit_writer = require_audit_writer(state)?;

    // `decode_session` answers `Unauthorized` for every failure — bad
    // signature, wrong audience, expired — which is `invalidToken`.
    let claims = signer
        .decode_session(&req.setup_session_token)
        .map_err(|e| TaskError::declared(BOOTSTRAP_ERR_INVALID_TOKEN, e))?;
    let admin_did = claims.sub;
    let install_jti = claims.install_jti;

    // Defence-in-depth: refuse if any administrator already exists.
    // The install carve-out should make this impossible (the second
    // install-token claim would fail), but a misconfigured backup
    // restore could still land us here.
    for entry in list_acl_entries(&state.acl_ks).await? {
        if entry.admin.is_administrator() {
            return Err(TaskError::declared(
                BOOTSTRAP_ERR_ALREADY_BOOTSTRAPPED,
                AppError::Conflict(
                    "an admin already exists; refusing to bootstrap a second one".into(),
                ),
            ));
        }
    }

    // M0.5.2 wrote the PasskeyUser at claim/finish 0.2; look it up so
    // the first RegisteredPasskey carries the same credential id
    // the passkey-login flow will eventually match against. A claim/finish
    // 0.3 instead parked a step-up approver for this token, which is written
    // only now, beside the administrator it belongs to (claim/finish 0.3
    // item 6) — so an abandoned claim leaves no factor behind.
    let passkey_user = get_passkey_user_by_did(&state.passkey_ks, &admin_did).await?;
    let pending_approver = if passkey_user.is_none() {
        crate::step_up_approver::take_pending_install(state, &install_jti, &admin_did).await?
    } else {
        None
    };
    if passkey_user.is_none() && pending_approver.is_none() {
        return Err(AppError::Unauthorized(
            "no passkey or step-up approver registered for the candidate admin DID — run the \
             install claim first"
                .into(),
        )
        .into());
    }
    let first_cred_hex = match passkey_user.as_ref() {
        Some(user) => {
            let first_cred = user.credentials.first().ok_or_else(|| {
                AppError::Internal("admin passkey user has no credentials persisted".into())
            })?;
            Some(hex::encode(<_ as AsRef<[u8]>>::as_ref(
                first_cred.cred_id(),
            )))
        }
        None => None,
    };
    if let Some(p) = pending_approver.as_ref() {
        crate::acl::approver::check_bindable(
            &state.step_up_approvers_ks,
            &p.approver_did,
            &admin_did,
            None,
        )
        .await
        .map_err(|e| {
            AppError::Conflict(format!(
                "the claimed step-up approver cannot be bound: {e:?}"
            ))
        })?;
    }

    // claim_finish already wrote the AdminEntry for the install
    // credential. Only fall back to building one here if it's
    // somehow missing — recovery against a partial install where
    // the AdminEntry write failed but the PasskeyUser succeeded.
    let now = Utc::now();
    if crate::acl::admin::get_admin_entry(&state.passkey_ks, &admin_did)
        .await?
        .is_none()
    {
        // The install ceremony has no operator label channel; the operator
        // relabels their device later via `PATCH /v1/admin/passkeys/{id}`
        // (M0.6.3). Until then we ship a placeholder rather than an empty
        // string so admin UIs don't render blank. A wallet founder (claim 0.3)
        // has no passkey here at all: their factor is the step-up approver.
        let passkeys = first_cred_hex
            .map(|credential_id| RegisteredPasskey {
                credential_id,
                label: "install".into(),
                transports: Vec::new(),
                registered_at: now,
                last_used_at: None,
            })
            .into_iter()
            .collect();
        let admin_entry = AdminEntry {
            did: admin_did.clone(),
            passkeys,
            extensions: serde_json::Value::Null,
            created_at: now,
        };
        store_admin_entry(&state.passkey_ks, &admin_entry).await?;
    }

    let acl_entry = VtcAclEntry {
        did: admin_did.clone(),
        role: VtcRole::Admin,
        label: Some("first admin (install bootstrap)".into()),
        // `vtc-admin-roles.md` §9: the founder is a community administrator
        // with the full ceiling, derived from no granter.
        admin: crate::acl::AdminAuthority::community_admin(),
        delegated_by: None,
        created_at: now_unix(),
        created_by: "did:key:vtc-install".into(),
        updated_at: None,
        updated_by: None,
        expires_at: None,
        resource_grants: Vec::new(),
        label_set_by_subject: false,
        suspension: None,
    };
    store_acl_entry(&state.acl_ks, &acl_entry).await?;

    // The founder's step-up approver, claimed with the install token (R1,
    // `enrolledVia: install`) — bound now that its subject is an administrator.
    if let Some(p) = pending_approver.as_ref() {
        crate::step_up_approver::bind_installed(state, &install_jti, p).await?;
    }

    // The second unrestricted admin `vtc setup` was given, installed in the same
    // step as the first (VTI-APV-014): making anyone an unrestricted admin needs
    // another's consent, so a community installed with one could never grant
    // remotely. Taken once — a later bootstrap is refused above anyway.
    let co_admin = match state.install_store.take_co_admin().await? {
        Some(did) if did != admin_did => {
            let entry = VtcAclEntry {
                did: did.clone(),
                role: VtcRole::Admin,
                label: Some("co-admin (install bootstrap)".into()),
                admin: crate::acl::AdminAuthority::community_admin(),
                delegated_by: None,
                created_at: now_unix(),
                created_by: "did:key:vtc-install".into(),
                updated_at: None,
                updated_by: None,
                expires_at: None,
                resource_grants: Vec::new(),
                label_set_by_subject: false,
                suspension: None,
            };
            store_acl_entry(&state.acl_ks, &entry).await?;
            // The sister record a promotion writes, so the co-admin can enrol a
            // passkey later for the console. Consenting needs none.
            if crate::acl::admin::get_admin_entry(&state.passkey_ks, &did)
                .await?
                .is_none()
            {
                store_admin_entry(
                    &state.passkey_ks,
                    &AdminEntry {
                        did: did.clone(),
                        passkeys: Vec::new(),
                        extensions: serde_json::Value::Null,
                        created_at: now,
                    },
                )
                .await?;
            }
            Some(entry)
        }
        _ => None,
    };

    // Initialise the singleton community profile if not already present.
    // Per spec §5.1, `community_did` is immutable from this point — so
    // we only lock it in when `vtc_did` is actually configured. The
    // operator fills in `name` / `description` / etc. afterwards via
    // `PUT /v1/community/profile`.
    let vtc_did = state.config.read().await.vtc_did.clone();
    if let Some(did) = vtc_did.as_deref()
        && load_profile(&state.community_ks).await?.is_none()
    {
        let profile = CommunityProfile::new(did, "");
        store_profile(&state.community_ks, &profile).await?;
    }

    let community_did = vtc_did.unwrap_or_else(|| "did:key:vtc-uninitialised".to_string());

    let envelope = audit_writer
        .write(
            &admin_did,
            None,
            AuditEvent::CommunityInstalled(CommunityInstalledData {
                community_did,
                install_token_jti: install_jti,
            }),
        )
        .await?;

    info!(%admin_did, event_id = %envelope.event_id, "community installed");

    if let Some(entry) = co_admin {
        audit_writer
            .write(
                "did:key:vtc-install",
                Some(&entry.did),
                AuditEvent::AclGranted(vti_common::audit::AclChangeData {
                    did: entry.did.clone(),
                    role: "community-admin".into(),
                    contexts: entry.capability_list(),
                    expires_at: None,
                }),
            )
            .await?;
        info!(co_admin = %entry.did, "co-admin installed beside the first admin");
    }

    Ok(BootstrapResponse {
        admin_did,
        event_id: envelope.event_id,
    })
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn require_install_signer(state: &AppState) -> Result<&Arc<InstallTokenSigner>, AppError> {
    state
        .install_signer
        .as_ref()
        .ok_or_else(|| AppError::ServiceError {
            status: StatusCode::SERVICE_UNAVAILABLE,
            message: "install signer not configured (run setup first)".into(),
        })
}

fn require_audit_writer(state: &AppState) -> Result<&AuditWriter, AppError> {
    state
        .audit_writer
        .as_ref()
        .ok_or_else(|| AppError::ServiceError {
            status: StatusCode::SERVICE_UNAVAILABLE,
            message: "audit writer not configured (run setup first)".into(),
        })
}

fn now_unix() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}