auths-cli 0.1.2

Command-line interface for Auths decentralized identity system
Documentation
//! Witness server and client management commands.

use std::net::SocketAddr;
use std::path::{Path, PathBuf};

use anyhow::{Result, anyhow};
use auths_utils::path::expand_tilde;
use clap::{Parser, Subcommand};

use auths_infra_http::HttpAsyncWitnessClient;
use auths_sdk::ports::IdentityStorage;
use auths_sdk::storage::RegistryIdentityStorage;
use auths_sdk::witness::AsyncWitnessProvider;
use auths_sdk::witness::{
    EquivocationDetection, IndependencePolicy, WitnessConfig, WitnessRef, honesty_ceiling,
};
use auths_sdk::witness::{
    WitnessServerConfig, WitnessServerState, generate_and_persist_witness_signer,
    load_witness_signer, run_server, witness_signer_from_seed_hex,
};

/// Manage identity witness servers.
#[derive(Parser, Debug, Clone)]
pub struct WitnessCommand {
    #[command(subcommand)]
    pub subcommand: WitnessSubcommand,
}

/// Witness subcommands.
#[derive(Subcommand, Debug, Clone)]
pub enum WitnessSubcommand {
    /// Start the witness HTTP server.
    #[command(visible_alias = "serve")]
    Start {
        /// Address to bind to (e.g., "127.0.0.1:3333").
        #[clap(long, default_value = "127.0.0.1:3333")]
        bind: SocketAddr,

        /// Path to the SQLite database for witness storage.
        #[clap(long, default_value = "witness.db")]
        db_path: PathBuf,

        /// Path to the persisted witness signing-key keystore. The advertised AID
        /// derives from this key and is stable across restarts. Without it the
        /// witness runs with an EPHEMERAL (unpinnable) identity. The
        /// `AUTHS_WITNESS_SEED` env var (hex seed) takes precedence for containers.
        #[clap(long, visible_alias = "id")]
        identity: Option<PathBuf>,

        /// Create the keystore at `--identity` if it does not exist. Without this,
        /// a missing keystore fails closed (never silently mints a fresh key).
        #[clap(long)]
        generate: bool,

        /// Signing curve for a newly generated identity: "p256" (default) or "ed25519".
        #[clap(long, default_value = "p256")]
        curve: String,
    },

    /// Add a witness URL to the identity configuration.
    Add {
        /// Witness server URL (e.g., "http://127.0.0.1:3333").
        #[clap(long)]
        url: String,
    },

    /// Remove a witness URL from the identity configuration.
    Remove {
        /// Witness server URL to remove.
        #[clap(long)]
        url: String,
    },

    /// List configured witnesses for the current identity.
    List,
}

/// Parse the `--curve` argument into a `CurveType`.
fn parse_curve_arg(curve: &str) -> Result<auths_crypto::CurveType> {
    match curve {
        "p256" | "P256" => Ok(auths_crypto::CurveType::P256),
        "ed25519" | "Ed25519" => Ok(auths_crypto::CurveType::Ed25519),
        other => Err(anyhow!(
            "unknown --curve '{other}'; expected 'p256' or 'ed25519'"
        )),
    }
}

/// Resolve the witness signing identity and build the server config.
///
/// Precedence: `AUTHS_WITNESS_SEED` env (container injection) → `--identity`
/// keystore (load, or create with `--generate`) → ephemeral (warned). A missing
/// `--identity` keystore without `--generate` fails closed — it never mints a
/// fresh key behind a path the operator meant to be stable.
fn build_witness_config(
    db_path: PathBuf,
    identity: Option<PathBuf>,
    generate: bool,
    curve: auths_crypto::CurveType,
) -> Result<WitnessServerConfig> {
    #[allow(clippy::disallowed_methods)]
    // Boundary: the CLI is where deployment env is read. A container/binary can
    // inject the witness signing seed here instead of mounting a keystore file.
    let env_seed = std::env::var("AUTHS_WITNESS_SEED").ok();

    if let Some(seed_hex) = env_seed {
        let signer = witness_signer_from_seed_hex(curve, &seed_hex)
            .map_err(|e| anyhow!("invalid AUTHS_WITNESS_SEED: {e}"))?;
        return WitnessServerConfig::from_signer(db_path, signer)
            .map_err(|e| anyhow!("witness config from injected seed: {e}"));
    }

    if let Some(identity_path) = identity {
        let path =
            expand_tilde(&identity_path).map_err(|e| anyhow!("invalid --identity path: {e}"))?;
        let signer = if path.exists() {
            load_witness_signer(&path).map_err(|e| anyhow!("{e}"))?
        } else if generate {
            let signer =
                generate_and_persist_witness_signer(&path, curve).map_err(|e| anyhow!("{e}"))?;
            println!("Generated new witness identity at {}", path.display());
            signer
        } else {
            return Err(anyhow!(
                "no witness identity at {}; pass --generate to create one \
                 (refusing to mint an ephemeral key for a --identity path)",
                path.display()
            ));
        };
        return WitnessServerConfig::from_signer(db_path, signer)
            .map_err(|e| anyhow!("witness config: {e}"));
    }

    eprintln!(
        "warning: starting with an EPHEMERAL witness identity (new AID each launch, \
         not pinnable); pass --identity <path> --generate for a stable identity"
    );
    WitnessServerConfig::with_generated_keypair(db_path, curve)
        .map_err(|e| anyhow!("Failed to generate witness keypair: {e}"))
}

/// Handle witness commands.
pub fn handle_witness(cmd: WitnessCommand, repo_opt: Option<PathBuf>) -> Result<()> {
    match cmd.subcommand {
        WitnessSubcommand::Start {
            bind,
            db_path,
            identity,
            generate,
            curve,
        } => {
            let curve = parse_curve_arg(&curve)?;
            let cfg = build_witness_config(db_path, identity, generate, curve)?;
            let rt = tokio::runtime::Runtime::new()?;
            rt.block_on(async {
                let state = WitnessServerState::new(cfg)
                    .map_err(|e| anyhow::anyhow!("Failed to create witness state: {}", e))?;

                println!(
                    "Witness server started at {} (identity: {})",
                    bind,
                    state.witness_did()
                );

                run_server(state, bind)
                    .await
                    .map_err(|e| anyhow::anyhow!("Server error: {}", e))?;

                Ok(())
            })
        }

        WitnessSubcommand::Add { url } => {
            let repo_path = resolve_repo_path(repo_opt)?;
            let parsed_url: url::Url = url
                .parse()
                .map_err(|e| anyhow!("Invalid witness URL '{}': {}", url, e))?;
            let mut config = load_witness_config(&repo_path)?;
            // A witness is its AID, not its URL: resolve the witness's identity
            // from its `/health` and pin `(url, aid)`. The AID is what gets
            // designated in `b[]` and what receipt signatures are verified
            // against. Refuse to pin a witness we can't identify.
            let rt = tokio::runtime::Runtime::new()?;
            let aid = rt
                .block_on(async {
                    let client = HttpAsyncWitnessClient::new(
                        parsed_url.to_string(),
                        config.threshold.max(1),
                    );
                    client.witness_aid().await
                })
                .map_err(|e| {
                    anyhow!(
                        "Could not resolve witness identity from {}/health: {}",
                        parsed_url,
                        e
                    )
                })?;
            if !config.pin(WitnessRef {
                url: parsed_url.clone(),
                aid: aid.clone(),
                // Independence attributes are populated in the witness config
                // (operator/org/jurisdiction/infrastructure); untagged ⇒ the
                // independence gate fails closed for this witness.
                operator_info: None,
            }) {
                println!("Witness already configured (aid {}): {}", aid.as_str(), url);
                return Ok(());
            }
            if config.threshold == 0 {
                config.threshold = 1;
            }
            save_witness_config(&repo_path, &config)?;
            println!("Added witness: {} (aid {})", url, aid.as_str());
            println!(
                "  Witnesses: {}, required: {}",
                config.witnesses.len(),
                config.threshold
            );
            Ok(())
        }

        WitnessSubcommand::Remove { url } => {
            let repo_path = resolve_repo_path(repo_opt)?;
            let parsed_url: url::Url = url
                .parse()
                .map_err(|e| anyhow!("Invalid witness URL '{}': {}", url, e))?;
            let mut config = load_witness_config(&repo_path)?;
            if !config.remove_url(&parsed_url) {
                println!("Witness not found: {}", url);
                return Ok(());
            }
            // Adjust threshold if needed
            if config.threshold > config.witnesses.len() {
                config.threshold = config.witnesses.len();
            }
            save_witness_config(&repo_path, &config)?;
            println!("Removed witness: {}", url);
            println!(
                "  Remaining witnesses: {}, required: {}",
                config.witnesses.len(),
                config.threshold
            );
            Ok(())
        }

        WitnessSubcommand::List => {
            let repo_path = resolve_repo_path(repo_opt)?;
            let config = load_witness_config(&repo_path)?;
            if config.witnesses.is_empty() {
                println!("No witnesses configured.");
                return Ok(());
            }
            println!("Configured witnesses:");
            for (i, w) in config.witnesses.iter().enumerate() {
                println!("  {}. {}  (aid {})", i + 1, w.url, w.aid.as_str());
            }
            println!(
                "\nRequired: {}/{} (policy: {:?})",
                config.threshold,
                config.witnesses.len(),
                config.policy
            );

            // Honest current truth — the single shared ceiling, never re-derived.
            // Equivocation detection is `Sampled` until the W.3 gossip layer lands.
            let independence = config.roster_independence(&IndependencePolicy::default());
            let ceiling = honesty_ceiling(&independence, EquivocationDetection::Sampled);
            let status = if ceiling.policy_met { "MET" } else { "FAILING" };
            println!("\nIndependence: {status}{}", ceiling.label);
            if !ceiling.shortfalls.is_empty() {
                println!("  shortfall: {}", ceiling.shortfalls.join(", "));
            }
            Ok(())
        }
    }
}

/// Resolve the identity repo path (defaults to ~/.auths).
///
/// Expands leading `~/` so paths from clap defaults work correctly.
fn resolve_repo_path(repo_opt: Option<PathBuf>) -> Result<PathBuf> {
    if let Some(path) = repo_opt {
        return Ok(expand_tilde(&path)?);
    }
    let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
    Ok(home.join(".auths"))
}

/// Load witness config from identity metadata.
fn load_witness_config(repo_path: &Path) -> Result<WitnessConfig> {
    let storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
    let identity = storage.load_identity()?;

    if let Some(ref metadata) = identity.metadata
        && let Some(wc) = metadata.get("witness_config")
    {
        let config: WitnessConfig = serde_json::from_value(wc.clone())?;
        return Ok(config);
    }
    Ok(WitnessConfig::default())
}

/// Save witness config into identity metadata.
fn save_witness_config(repo_path: &Path, config: &WitnessConfig) -> Result<()> {
    let storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
    let mut identity = storage.load_identity()?;

    let metadata = identity
        .metadata
        .get_or_insert_with(|| serde_json::json!({}));
    if let Some(obj) = metadata.as_object_mut() {
        obj.insert("witness_config".to_string(), serde_json::to_value(config)?);
    }

    storage.create_identity(identity.controller_did.as_str(), identity.metadata)?;
    Ok(())
}

impl crate::commands::executable::ExecutableCommand for WitnessCommand {
    fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
        handle_witness(self.clone(), ctx.repo_path.clone())
    }
}