myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

cli.rs (54110B)


      1 use std::collections::BTreeMap;
      2 use std::path::{Path, PathBuf};
      3 use std::time::Duration;
      4 
      5 use clap::{Args, Parser, Subcommand, ValueEnum};
      6 use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions;
      7 use radroots_nostr_signer::prelude::{
      8     RadrootsNostrSignerBackend, RadrootsNostrSignerConnectionId,
      9     RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerRequestAuditRecord,
     10 };
     11 use serde::Serialize;
     12 use zeroize::Zeroizing;
     13 
     14 use crate::app::MycRuntime;
     15 use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
     16 use crate::config::{MycConfig, MycTransportDeliveryPolicy};
     17 use crate::control::{accept_client_uri, authorize_auth_challenge, parse_permission_values};
     18 use crate::discovery::{
     19     MycDiscoveryContext, MycDiscoveryRepairSummary, diff_live_nip89, fetch_live_nip89,
     20     publish_nip89_event, refresh_nip89, verify_bundle,
     21 };
     22 use crate::error::MycError;
     23 use crate::logging;
     24 use crate::operability::{
     25     MycAuditDecisionCounts, MycOperationOutcomeCounts, MycStatusFullOutput, MycStatusSignerOutput,
     26     MycStatusSummaryOutput, collect_metrics, collect_status_full, collect_status_signer,
     27     collect_status_summary, increment_outcome_counts, is_aggregate_publish_operation,
     28     operation_kind_label, render_metrics_text,
     29 };
     30 use crate::persistence::{
     31     MycPersistenceImportSelection, backup_persistence, import_json_to_sqlite, restore_backup,
     32     verify_restored_state,
     33 };
     34 
     35 #[derive(Debug, Parser)]
     36 #[command(name = "myc")]
     37 #[command(about = "Mycorrhiza NIP-46 signer service")]
     38 pub struct MycCli {
     39     #[arg(long = "env-file", global = true)]
     40     env_file: Option<PathBuf>,
     41     #[command(subcommand)]
     42     command: Option<MycCommand>,
     43 }
     44 
     45 #[derive(Debug, Subcommand)]
     46 pub enum MycCommand {
     47     Run,
     48     Status {
     49         #[arg(long, value_enum, default_value_t = MycStatusView::Summary)]
     50         view: MycStatusView,
     51     },
     52     Metrics {
     53         #[arg(long, value_enum, default_value_t = MycMetricsFormat::Prometheus)]
     54         format: MycMetricsFormat,
     55     },
     56     Persistence {
     57         #[command(subcommand)]
     58         command: MycPersistenceCommand,
     59     },
     60     Custody {
     61         #[command(subcommand)]
     62         command: MycCustodyCommand,
     63     },
     64     Connections {
     65         #[command(subcommand)]
     66         command: MycConnectionsCommand,
     67     },
     68     Audit {
     69         #[command(subcommand)]
     70         command: MycAuditCommand,
     71     },
     72     Auth {
     73         #[command(subcommand)]
     74         command: MycAuthCommand,
     75     },
     76     Connect {
     77         #[command(subcommand)]
     78         command: MycConnectCommand,
     79     },
     80     Discovery {
     81         #[command(subcommand)]
     82         command: MycDiscoveryCommand,
     83     },
     84 }
     85 
     86 #[derive(Debug, Subcommand)]
     87 pub enum MycConnectionsCommand {
     88     List,
     89     Approve(MycConnectionApprovalArgs),
     90     Reject(MycConnectionReasonArgs),
     91     Revoke(MycConnectionReasonArgs),
     92 }
     93 
     94 #[derive(Debug, Subcommand)]
     95 pub enum MycPersistenceCommand {
     96     Backup {
     97         #[arg(long)]
     98         out: PathBuf,
     99     },
    100     Restore {
    101         #[arg(long)]
    102         from: PathBuf,
    103     },
    104     ImportJsonToSqlite {
    105         #[arg(long)]
    106         signer_state: bool,
    107         #[arg(long)]
    108         runtime_audit: bool,
    109     },
    110     VerifyRestore,
    111 }
    112 
    113 #[derive(Debug, Subcommand)]
    114 pub enum MycCustodyCommand {
    115     Status {
    116         #[arg(long, value_enum)]
    117         role: MycCustodyRole,
    118     },
    119     List {
    120         #[arg(long, value_enum)]
    121         role: MycCustodyRole,
    122     },
    123     Generate {
    124         #[arg(long, value_enum)]
    125         role: MycCustodyRole,
    126         #[arg(long)]
    127         label: Option<String>,
    128         #[arg(long)]
    129         select: bool,
    130     },
    131     ImportFile {
    132         #[arg(long, value_enum)]
    133         role: MycCustodyRole,
    134         #[arg(long)]
    135         path: PathBuf,
    136         #[arg(long)]
    137         label: Option<String>,
    138         #[arg(long)]
    139         select: bool,
    140     },
    141     ExportNip49 {
    142         #[arg(long, value_enum)]
    143         role: MycCustodyRole,
    144         #[arg(long)]
    145         out: PathBuf,
    146         #[arg(long)]
    147         password_env: String,
    148     },
    149     ImportNip49 {
    150         #[arg(long, value_enum)]
    151         role: MycCustodyRole,
    152         #[arg(long)]
    153         path: PathBuf,
    154         #[arg(long)]
    155         password_env: String,
    156         #[arg(long)]
    157         label: Option<String>,
    158     },
    159     Rotate {
    160         #[arg(long, value_enum)]
    161         role: MycCustodyRole,
    162     },
    163     Select {
    164         #[arg(long, value_enum)]
    165         role: MycCustodyRole,
    166         #[arg(long)]
    167         account_id: String,
    168     },
    169     Remove {
    170         #[arg(long, value_enum)]
    171         role: MycCustodyRole,
    172         #[arg(long)]
    173         account_id: String,
    174     },
    175 }
    176 
    177 #[derive(Debug, Subcommand)]
    178 pub enum MycAuditCommand {
    179     List {
    180         #[arg(long)]
    181         connection_id: Option<String>,
    182         #[arg(long)]
    183         attempt_id: Option<String>,
    184         #[arg(long, value_enum, default_value_t = MycAuditScope::All)]
    185         scope: MycAuditScope,
    186         #[arg(long)]
    187         limit: Option<usize>,
    188     },
    189     Summary {
    190         #[arg(long)]
    191         connection_id: Option<String>,
    192         #[arg(long)]
    193         attempt_id: Option<String>,
    194         #[arg(long, value_enum, default_value_t = MycAuditScope::All)]
    195         scope: MycAuditScope,
    196         #[arg(long)]
    197         limit: Option<usize>,
    198     },
    199     LatestDiscoveryRepair {
    200         #[arg(long, value_enum, default_value_t = MycDiscoveryRepairAttemptView::Summary)]
    201         view: MycDiscoveryRepairAttemptView,
    202     },
    203     DiscoveryRepairAttempt {
    204         #[arg(long)]
    205         attempt_id: String,
    206         #[arg(long, value_enum, default_value_t = MycDiscoveryRepairAttemptView::Summary)]
    207         view: MycDiscoveryRepairAttemptView,
    208     },
    209 }
    210 
    211 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
    212 pub enum MycAuditScope {
    213     All,
    214     Request,
    215     Operation,
    216 }
    217 
    218 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
    219 pub enum MycDiscoveryRepairAttemptView {
    220     Summary,
    221     Records,
    222 }
    223 
    224 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
    225 pub enum MycStatusView {
    226     Signer,
    227     Summary,
    228     Full,
    229 }
    230 
    231 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
    232 pub enum MycMetricsFormat {
    233     Json,
    234     Prometheus,
    235 }
    236 
    237 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
    238 pub enum MycCustodyRole {
    239     Signer,
    240     User,
    241     DiscoveryApp,
    242 }
    243 
    244 #[derive(Debug, Subcommand)]
    245 pub enum MycAuthCommand {
    246     Require {
    247         #[arg(long)]
    248         connection_id: String,
    249         #[arg(long)]
    250         url: String,
    251     },
    252     Authorize {
    253         #[arg(long)]
    254         connection_id: String,
    255     },
    256 }
    257 
    258 #[derive(Debug, Subcommand)]
    259 pub enum MycConnectCommand {
    260     Accept {
    261         #[arg(long)]
    262         uri: String,
    263     },
    264 }
    265 
    266 #[derive(Debug, Subcommand)]
    267 pub enum MycDiscoveryCommand {
    268     RenderNip05 {
    269         #[arg(long)]
    270         out: Option<PathBuf>,
    271         #[arg(long)]
    272         stdout: bool,
    273     },
    274     RenderNip89,
    275     PublishNip89,
    276     ExportBundle {
    277         #[arg(long)]
    278         out: PathBuf,
    279     },
    280     VerifyBundle {
    281         #[arg(long)]
    282         dir: PathBuf,
    283     },
    284     InspectLiveNip89,
    285     DiffLiveNip89,
    286     RefreshNip89 {
    287         #[arg(long)]
    288         force: bool,
    289     },
    290 }
    291 
    292 #[derive(Debug, Args)]
    293 pub struct MycConnectionApprovalArgs {
    294     #[arg(long)]
    295     connection_id: String,
    296     #[arg(long = "grant")]
    297     grants: Vec<String>,
    298 }
    299 
    300 #[derive(Debug, Args)]
    301 pub struct MycConnectionReasonArgs {
    302     #[arg(long)]
    303     connection_id: String,
    304     #[arg(long)]
    305     reason: Option<String>,
    306 }
    307 
    308 #[derive(Debug, Serialize, PartialEq, Eq)]
    309 pub struct MycAuditListOutput {
    310     pub signer_request_audit: Vec<RadrootsNostrSignerRequestAuditRecord>,
    311     pub runtime_operation_audit: Vec<MycOperationAuditRecord>,
    312 }
    313 
    314 #[derive(Debug, Serialize, PartialEq, Eq)]
    315 pub struct MycAuditSummaryOutput {
    316     pub record_limit: usize,
    317     pub signer_request_total: usize,
    318     pub signer_request_decisions: MycAuditDecisionCounts,
    319     pub runtime_operation_total: usize,
    320     pub runtime_operation_outcomes: MycOperationOutcomeCounts,
    321     pub runtime_operation_by_kind: BTreeMap<String, MycOperationOutcomeCounts>,
    322     pub runtime_aggregate_publish_rejection_count: usize,
    323     pub runtime_repair_success_count: usize,
    324     pub runtime_repair_rejection_count: usize,
    325     pub runtime_unavailable_count: usize,
    326     pub runtime_replay_restore_count: usize,
    327 }
    328 
    329 #[derive(Debug, Serialize, PartialEq, Eq)]
    330 pub struct MycDiscoveryRepairAttemptRecordsOutput {
    331     pub attempt_id: String,
    332     pub runtime_operation_audit: Vec<MycOperationAuditRecord>,
    333 }
    334 
    335 #[derive(Debug, Serialize, PartialEq, Eq)]
    336 pub struct MycDiscoveryRepairAttemptSummaryOutput {
    337     pub attempt_id: String,
    338     pub record_count: usize,
    339     pub started_at_unix: u64,
    340     pub finished_at_unix: u64,
    341     #[serde(skip_serializing_if = "Option::is_none")]
    342     pub compare_outcome: Option<MycOperationAuditOutcome>,
    343     #[serde(skip_serializing_if = "Option::is_none")]
    344     pub refresh_outcome: Option<MycOperationAuditOutcome>,
    345     #[serde(skip_serializing_if = "Option::is_none")]
    346     pub aggregate_publish_outcome: Option<MycOperationAuditOutcome>,
    347     #[serde(skip_serializing_if = "Option::is_none")]
    348     pub aggregate_publish_relay_count: Option<usize>,
    349     #[serde(skip_serializing_if = "Option::is_none")]
    350     pub aggregate_publish_acknowledged_relay_count: Option<usize>,
    351     #[serde(skip_serializing_if = "Option::is_none")]
    352     pub aggregate_publish_relay_outcome_summary: Option<String>,
    353     #[serde(skip_serializing_if = "Option::is_none")]
    354     pub aggregate_publish_delivery_policy: Option<MycTransportDeliveryPolicy>,
    355     #[serde(skip_serializing_if = "Option::is_none")]
    356     pub aggregate_publish_required_acknowledged_relay_count: Option<usize>,
    357     #[serde(skip_serializing_if = "Option::is_none")]
    358     pub aggregate_publish_attempt_count: Option<usize>,
    359     pub repair_summary: MycDiscoveryRepairSummary,
    360     pub planned_repair_relays: Vec<String>,
    361     pub blocked_relays: Vec<String>,
    362     #[serde(skip_serializing_if = "Option::is_none")]
    363     pub blocked_reason: Option<String>,
    364     pub failed_relays: Vec<String>,
    365     pub remaining_repair_relays: Vec<String>,
    366 }
    367 
    368 #[derive(Debug, Serialize, PartialEq, Eq)]
    369 #[serde(untagged)]
    370 pub enum MycDiscoveryRepairAttemptOutput {
    371     Summary(MycDiscoveryRepairAttemptSummaryOutput),
    372     Records(MycDiscoveryRepairAttemptRecordsOutput),
    373 }
    374 
    375 #[derive(Debug, Serialize, PartialEq, Eq)]
    376 #[serde(untagged)]
    377 pub enum MycStatusOutput {
    378     Signer(MycStatusSignerOutput),
    379     Summary(MycStatusSummaryOutput),
    380     Full(MycStatusFullOutput),
    381 }
    382 
    383 pub async fn run_from_env() -> Result<(), MycError> {
    384     let cli = MycCli::parse();
    385     let config = load_config(cli.env_file.as_deref())?;
    386 
    387     match cli.command.unwrap_or(MycCommand::Run) {
    388         MycCommand::Run => {
    389             logging::init_logging(&config.logging)?;
    390             MycRuntime::bootstrap(config)?.run().await
    391         }
    392         MycCommand::Status { view } => {
    393             let runtime = MycRuntime::bootstrap(config)?;
    394             let output = match view {
    395                 MycStatusView::Signer => MycStatusOutput::Signer(collect_status_signer(&runtime)?),
    396                 MycStatusView::Summary => {
    397                     MycStatusOutput::Summary(collect_status_summary(&runtime).await?)
    398                 }
    399                 MycStatusView::Full => MycStatusOutput::Full(collect_status_full(&runtime).await?),
    400             };
    401             print_json(&output)
    402         }
    403         MycCommand::Metrics { format } => {
    404             let runtime = MycRuntime::bootstrap(config)?;
    405             let output = collect_metrics(&runtime)?;
    406             match format {
    407                 MycMetricsFormat::Json => print_json(&output),
    408                 MycMetricsFormat::Prometheus => {
    409                     print_text(&render_metrics_text(&output));
    410                     Ok(())
    411                 }
    412             }
    413         }
    414         MycCommand::Persistence { command } => match command {
    415             MycPersistenceCommand::Backup { out } => {
    416                 let output = backup_persistence(&config, out)?;
    417                 print_json(&output)
    418             }
    419             MycPersistenceCommand::Restore { from } => {
    420                 let output = restore_backup(&config, from)?;
    421                 print_json(&output)
    422             }
    423             MycPersistenceCommand::ImportJsonToSqlite {
    424                 signer_state,
    425                 runtime_audit,
    426             } => {
    427                 let output = import_json_to_sqlite(
    428                     &config,
    429                     MycPersistenceImportSelection::new(signer_state, runtime_audit),
    430                 )?;
    431                 print_json(&output)
    432             }
    433             MycPersistenceCommand::VerifyRestore => {
    434                 let output = verify_restored_state(&config)?;
    435                 print_json(&output)
    436             }
    437         },
    438         MycCommand::Custody { command } => {
    439             let provider = custody_provider_for_command(&config, &command)?;
    440             match command {
    441                 MycCustodyCommand::Status { .. } => print_json(&provider.status_output()),
    442                 MycCustodyCommand::List { .. } => print_json(&provider.list_managed_accounts()?),
    443                 MycCustodyCommand::Generate { label, select, .. } => {
    444                     let output = provider.generate_managed_account(label, select)?;
    445                     print_json(&output)
    446                 }
    447                 MycCustodyCommand::ImportFile {
    448                     path,
    449                     label,
    450                     select,
    451                     ..
    452                 } => {
    453                     let output = provider.import_managed_account_file(path, label, select)?;
    454                     print_json(&output)
    455                 }
    456                 MycCustodyCommand::ExportNip49 {
    457                     out, password_env, ..
    458                 } => {
    459                     let password = read_secret_env(password_env.as_str(), "custody export-nip49")?;
    460                     let output = provider.export_nip49(out, password.as_str())?;
    461                     print_json(&output)
    462                 }
    463                 MycCustodyCommand::ImportNip49 {
    464                     path,
    465                     password_env,
    466                     label,
    467                     ..
    468                 } => {
    469                     let password = read_secret_env(password_env.as_str(), "custody import-nip49")?;
    470                     let output = provider.import_nip49(path, password.as_str(), label)?;
    471                     print_json(&output)
    472                 }
    473                 MycCustodyCommand::Rotate { .. } => {
    474                     let output = provider.rotate_secret_storage()?;
    475                     print_json(&output)
    476                 }
    477                 MycCustodyCommand::Select { account_id, .. } => {
    478                     let output = provider.select_managed_account(account_id.as_str())?;
    479                     print_json(&output)
    480                 }
    481                 MycCustodyCommand::Remove { account_id, .. } => {
    482                     let output = provider.remove_managed_account(account_id.as_str())?;
    483                     print_json(&output)
    484                 }
    485             }
    486         }
    487         MycCommand::Connections { command } => {
    488             let runtime = MycRuntime::bootstrap(config)?;
    489             let backend = runtime.signer_backend();
    490             match command {
    491                 MycConnectionsCommand::List => print_json(&backend.list_connections()?),
    492                 MycConnectionsCommand::Approve(args) => {
    493                     let connection_id = parse_connection_id(&args.connection_id)?;
    494                     let granted_permissions = granted_permissions_for_approval(
    495                         runtime.signer_context().policy(),
    496                         &backend.list_connections()?,
    497                         &connection_id,
    498                         &args.grants,
    499                     )?;
    500                     let connection =
    501                         backend.approve_connection(&connection_id, granted_permissions)?;
    502                     print_json(&connection)
    503                 }
    504                 MycConnectionsCommand::Reject(args) => {
    505                     let connection_id = parse_connection_id(&args.connection_id)?;
    506                     let connection = backend.reject_connection(&connection_id, args.reason)?;
    507                     print_json(&connection)
    508                 }
    509                 MycConnectionsCommand::Revoke(args) => {
    510                     let connection_id = parse_connection_id(&args.connection_id)?;
    511                     let connection = backend.revoke_connection(&connection_id, args.reason)?;
    512                     print_json(&connection)
    513                 }
    514             }
    515         }
    516         MycCommand::Audit { command } => {
    517             let runtime = MycRuntime::bootstrap(config)?;
    518             let manager = runtime.signer_manager()?;
    519             match command {
    520                 MycAuditCommand::List {
    521                     connection_id,
    522                     attempt_id,
    523                     scope,
    524                     limit,
    525                 } => {
    526                     let output = load_audit_output(
    527                         &runtime,
    528                         &manager,
    529                         connection_id.as_deref(),
    530                         attempt_id.as_deref(),
    531                         scope,
    532                         limit,
    533                     )?;
    534                     print_json(&output)
    535                 }
    536                 MycAuditCommand::Summary {
    537                     connection_id,
    538                     attempt_id,
    539                     scope,
    540                     limit,
    541                 } => {
    542                     let output = summarize_audit_output(
    543                         &runtime,
    544                         &manager,
    545                         connection_id.as_deref(),
    546                         attempt_id.as_deref(),
    547                         scope,
    548                         limit,
    549                     )?;
    550                     print_json(&output)
    551                 }
    552                 MycAuditCommand::LatestDiscoveryRepair { view } => {
    553                     let output = load_latest_discovery_repair_attempt_output(&runtime, view)?;
    554                     print_json(&output)
    555                 }
    556                 MycAuditCommand::DiscoveryRepairAttempt { attempt_id, view } => {
    557                     let output =
    558                         load_discovery_repair_attempt_output(&runtime, attempt_id.as_str(), view)?;
    559                     print_json(&output)
    560                 }
    561             }
    562         }
    563         MycCommand::Auth { command } => {
    564             let runtime = MycRuntime::bootstrap(config)?;
    565             let backend = runtime.signer_backend();
    566             match command {
    567                 MycAuthCommand::Require { connection_id, url } => {
    568                     let connection_id = parse_connection_id(&connection_id)?;
    569                     let connection = backend.require_auth_challenge(&connection_id, &url)?;
    570                     print_json(&connection)
    571                 }
    572                 MycAuthCommand::Authorize { connection_id } => {
    573                     let connection_id = parse_connection_id(&connection_id)?;
    574                     let replayed = authorize_auth_challenge(&runtime, &connection_id).await?;
    575                     print_json(&replayed)
    576                 }
    577             }
    578         }
    579         MycCommand::Connect { command } => {
    580             let runtime = MycRuntime::bootstrap(config)?;
    581             match command {
    582                 MycConnectCommand::Accept { uri } => {
    583                     let accepted = accept_client_uri(&runtime, &uri).await?;
    584                     print_json(&accepted)
    585                 }
    586             }
    587         }
    588         MycCommand::Discovery { command } => match command {
    589             MycDiscoveryCommand::VerifyBundle { dir } => {
    590                 let output = verify_bundle(dir)?;
    591                 print_json(&output)
    592             }
    593             MycDiscoveryCommand::InspectLiveNip89 => {
    594                 let runtime = MycRuntime::bootstrap(config.clone())?;
    595                 let output = fetch_live_nip89(&runtime).await?;
    596                 print_json(&output)
    597             }
    598             MycDiscoveryCommand::DiffLiveNip89 => {
    599                 let runtime = MycRuntime::bootstrap(config.clone())?;
    600                 let output = diff_live_nip89(&runtime).await?;
    601                 print_json(&output)
    602             }
    603             MycDiscoveryCommand::RefreshNip89 { force } => {
    604                 let runtime = MycRuntime::bootstrap(config.clone())?;
    605                 let output = refresh_nip89(&runtime, force).await?;
    606                 print_json(&output)
    607             }
    608             MycDiscoveryCommand::RenderNip05 { out, stdout } => {
    609                 let runtime = MycRuntime::bootstrap(config.clone())?;
    610                 if stdout && out.is_some() {
    611                     return Err(MycError::InvalidOperation(
    612                         "discovery render-nip05 cannot use --stdout and --out together".to_owned(),
    613                     ));
    614                 }
    615                 let context = MycDiscoveryContext::from_runtime(&runtime)?;
    616                 if stdout || (out.is_none() && context.nip05_output_path().is_none()) {
    617                     println!("{}", context.render_nip05_json_pretty()?);
    618                     Ok(())
    619                 } else {
    620                     let output = context.write_nip05_document(
    621                             out.as_deref().or(context.nip05_output_path()).ok_or_else(|| {
    622                                 MycError::InvalidOperation(
    623                                     "discovery render-nip05 requires --out or discovery.nip05_output_path"
    624                                         .to_owned(),
    625                                 )
    626                             })?,
    627                         )?;
    628                     print_json(&output)
    629                 }
    630             }
    631             MycDiscoveryCommand::RenderNip89 => {
    632                 let runtime = MycRuntime::bootstrap(config.clone())?;
    633                 let output = MycDiscoveryContext::from_runtime(&runtime)?.render_nip89_output()?;
    634                 print_json(&output)
    635             }
    636             MycDiscoveryCommand::PublishNip89 => {
    637                 let runtime = MycRuntime::bootstrap(config.clone())?;
    638                 let output = publish_nip89_event(&runtime).await?;
    639                 print_json(&output)
    640             }
    641             MycDiscoveryCommand::ExportBundle { out } => {
    642                 let runtime = MycRuntime::bootstrap(config)?;
    643                 let output = MycDiscoveryContext::from_runtime(&runtime)?.write_bundle(out)?;
    644                 print_json(&output)
    645             }
    646         },
    647     }
    648 }
    649 
    650 fn load_config(path: Option<&Path>) -> Result<MycConfig, MycError> {
    651     match path {
    652         Some(path) => MycConfig::load_from_env_path(path),
    653         None => MycConfig::load_from_default_env_path(),
    654     }
    655 }
    656 
    657 fn custody_provider_for_command(
    658     config: &MycConfig,
    659     command: &MycCustodyCommand,
    660 ) -> Result<crate::custody::MycIdentityProvider, MycError> {
    661     let role = match command {
    662         MycCustodyCommand::Status { role }
    663         | MycCustodyCommand::List { role }
    664         | MycCustodyCommand::Generate { role, .. }
    665         | MycCustodyCommand::ImportFile { role, .. }
    666         | MycCustodyCommand::ExportNip49 { role, .. }
    667         | MycCustodyCommand::ImportNip49 { role, .. }
    668         | MycCustodyCommand::Rotate { role }
    669         | MycCustodyCommand::Select { role, .. }
    670         | MycCustodyCommand::Remove { role, .. } => *role,
    671     };
    672 
    673     custody_provider_for_role(config, role)
    674 }
    675 
    676 fn custody_provider_for_role(
    677     config: &MycConfig,
    678     role: MycCustodyRole,
    679 ) -> Result<crate::custody::MycIdentityProvider, MycError> {
    680     match role {
    681         MycCustodyRole::Signer => crate::custody::MycIdentityProvider::from_source(
    682             "signer",
    683             config.paths.signer_identity_source(),
    684             Duration::from_secs(config.custody.external_command_timeout_secs),
    685         ),
    686         MycCustodyRole::User => crate::custody::MycIdentityProvider::from_source(
    687             "user",
    688             config.paths.user_identity_source(),
    689             Duration::from_secs(config.custody.external_command_timeout_secs),
    690         ),
    691         MycCustodyRole::DiscoveryApp => {
    692             let Some(source) = config.discovery.app_identity_source() else {
    693                 return Err(MycError::InvalidOperation(
    694                     "discovery app identity is not separately configured; it currently reuses the signer identity".to_owned(),
    695                 ));
    696             };
    697             crate::custody::MycIdentityProvider::from_source(
    698                 "discovery app",
    699                 source,
    700                 Duration::from_secs(config.custody.external_command_timeout_secs),
    701             )
    702         }
    703     }
    704 }
    705 
    706 fn parse_connection_id(value: &str) -> Result<RadrootsNostrSignerConnectionId, MycError> {
    707     Ok(RadrootsNostrSignerConnectionId::parse(value)?)
    708 }
    709 
    710 fn granted_permissions_for_approval(
    711     policy: &crate::policy::MycPolicyContext,
    712     connections: &[RadrootsNostrSignerConnectionRecord],
    713     connection_id: &RadrootsNostrSignerConnectionId,
    714     grants: &[String],
    715 ) -> Result<RadrootsNostrConnectPermissions, MycError> {
    716     if !grants.is_empty() {
    717         return policy.validate_operator_grants(parse_permission_values(grants)?);
    718     }
    719 
    720     let connection = connections
    721         .iter()
    722         .find(|connection| &connection.connection_id == connection_id)
    723         .ok_or_else(|| {
    724             MycError::InvalidOperation(format!("connection `{connection_id}` was not found"))
    725         })?;
    726     policy.validate_operator_grants(connection.requested_permissions.clone())
    727 }
    728 
    729 fn load_audit_output(
    730     runtime: &MycRuntime,
    731     manager: &radroots_nostr_signer::prelude::RadrootsNostrSignerManager,
    732     connection_id: Option<&str>,
    733     attempt_id: Option<&str>,
    734     scope: MycAuditScope,
    735     limit: Option<usize>,
    736 ) -> Result<MycAuditListOutput, MycError> {
    737     if connection_id.is_some() && attempt_id.is_some() {
    738         return Err(MycError::InvalidOperation(
    739             "audit commands cannot filter by both connection_id and attempt_id".to_owned(),
    740         ));
    741     }
    742     if attempt_id.is_some() && scope == MycAuditScope::Request {
    743         return Err(MycError::InvalidOperation(
    744             "audit attempt lookup only supports operation or all scope".to_owned(),
    745         ));
    746     }
    747 
    748     let limit = audit_read_limit(runtime, limit);
    749     let connection_id = connection_id.map(parse_connection_id).transpose()?;
    750     let signer_request_audit = match (scope, connection_id.as_ref()) {
    751         (MycAuditScope::Operation, _) => Vec::new(),
    752         (_, Some(connection_id)) => manager
    753             .audit_records_for_connection(connection_id)?
    754             .into_iter()
    755             .rev()
    756             .take(limit)
    757             .collect::<Vec<_>>()
    758             .into_iter()
    759             .rev()
    760             .collect(),
    761         (_, None) => manager
    762             .list_audit_records()?
    763             .into_iter()
    764             .rev()
    765             .take(limit)
    766             .collect::<Vec<_>>()
    767             .into_iter()
    768             .rev()
    769             .collect(),
    770     };
    771     let runtime_operation_audit = match (scope, connection_id.as_ref(), attempt_id) {
    772         (MycAuditScope::Request, _, _) => Vec::new(),
    773         (_, Some(connection_id), _) => runtime
    774             .operation_audit_store()
    775             .list_for_connection_with_limit(connection_id, limit)?,
    776         (_, None, Some(attempt_id)) => runtime
    777             .operation_audit_store()
    778             .list_for_attempt_id_with_limit(attempt_id, limit)?,
    779         (_, None, None) => runtime.operation_audit_store().list_with_limit(limit)?,
    780     };
    781 
    782     Ok(MycAuditListOutput {
    783         signer_request_audit,
    784         runtime_operation_audit,
    785     })
    786 }
    787 
    788 fn summarize_audit_output(
    789     runtime: &MycRuntime,
    790     manager: &radroots_nostr_signer::prelude::RadrootsNostrSignerManager,
    791     connection_id: Option<&str>,
    792     attempt_id: Option<&str>,
    793     scope: MycAuditScope,
    794     limit: Option<usize>,
    795 ) -> Result<MycAuditSummaryOutput, MycError> {
    796     let record_limit = audit_read_limit(runtime, limit);
    797     let audit = load_audit_output(
    798         runtime,
    799         manager,
    800         connection_id,
    801         attempt_id,
    802         scope,
    803         Some(record_limit),
    804     )?;
    805     let mut signer_request_decisions = MycAuditDecisionCounts::default();
    806     for record in &audit.signer_request_audit {
    807         match record.decision {
    808             radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision::Allowed => {
    809                 signer_request_decisions.allowed += 1;
    810             }
    811             radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision::Denied => {
    812                 signer_request_decisions.denied += 1;
    813             }
    814             radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision::Challenged => {
    815                 signer_request_decisions.challenged += 1;
    816             }
    817         }
    818     }
    819 
    820     let mut runtime_operation_outcomes = MycOperationOutcomeCounts::default();
    821     let mut runtime_operation_by_kind = BTreeMap::new();
    822     let mut runtime_aggregate_publish_rejection_count = 0;
    823     let mut runtime_repair_success_count = 0;
    824     let mut runtime_repair_rejection_count = 0;
    825     let mut runtime_unavailable_count = 0;
    826     let mut runtime_replay_restore_count = 0;
    827     for record in &audit.runtime_operation_audit {
    828         increment_outcome_counts(&mut runtime_operation_outcomes, record.outcome);
    829         let key = operation_kind_label(record.operation);
    830         increment_outcome_counts(
    831             runtime_operation_by_kind.entry(key).or_default(),
    832             record.outcome,
    833         );
    834         if is_aggregate_publish_operation(record.operation)
    835             && record.outcome == MycOperationAuditOutcome::Rejected
    836         {
    837             runtime_aggregate_publish_rejection_count += 1;
    838         }
    839         if record.operation == MycOperationAuditKind::DiscoveryHandlerRepair {
    840             match record.outcome {
    841                 MycOperationAuditOutcome::Succeeded => runtime_repair_success_count += 1,
    842                 MycOperationAuditOutcome::Rejected => runtime_repair_rejection_count += 1,
    843                 _ => {}
    844             }
    845         }
    846         if record.outcome == MycOperationAuditOutcome::Unavailable {
    847             runtime_unavailable_count += 1;
    848         }
    849         if record.operation == MycOperationAuditKind::AuthReplayRestore
    850             && record.outcome == MycOperationAuditOutcome::Restored
    851         {
    852             runtime_replay_restore_count += 1;
    853         }
    854     }
    855 
    856     Ok(MycAuditSummaryOutput {
    857         record_limit,
    858         signer_request_total: audit.signer_request_audit.len(),
    859         signer_request_decisions,
    860         runtime_operation_total: audit.runtime_operation_audit.len(),
    861         runtime_operation_outcomes,
    862         runtime_operation_by_kind,
    863         runtime_aggregate_publish_rejection_count,
    864         runtime_repair_success_count,
    865         runtime_repair_rejection_count,
    866         runtime_unavailable_count,
    867         runtime_replay_restore_count,
    868     })
    869 }
    870 
    871 fn load_latest_discovery_repair_attempt_output(
    872     runtime: &MycRuntime,
    873     view: MycDiscoveryRepairAttemptView,
    874 ) -> Result<MycDiscoveryRepairAttemptOutput, MycError> {
    875     let attempt_id = runtime
    876         .operation_audit_store()
    877         .latest_attempt_id_for_operation(MycOperationAuditKind::DiscoveryHandlerRefresh)?
    878         .ok_or_else(|| {
    879             MycError::InvalidOperation("no discovery repair attempts have been recorded".to_owned())
    880         })?;
    881     load_discovery_repair_attempt_output(runtime, attempt_id.as_str(), view)
    882 }
    883 
    884 fn load_discovery_repair_attempt_output(
    885     runtime: &MycRuntime,
    886     attempt_id: &str,
    887     view: MycDiscoveryRepairAttemptView,
    888 ) -> Result<MycDiscoveryRepairAttemptOutput, MycError> {
    889     let records = runtime
    890         .operation_audit_store()
    891         .list_for_attempt_id(attempt_id)?;
    892     if records.is_empty() {
    893         return Err(MycError::InvalidOperation(format!(
    894             "discovery repair attempt `{attempt_id}` was not found"
    895         )));
    896     }
    897 
    898     match view {
    899         MycDiscoveryRepairAttemptView::Summary => Ok(MycDiscoveryRepairAttemptOutput::Summary(
    900             MycDiscoveryRepairAttemptSummaryOutput::from_records(attempt_id, &records)?,
    901         )),
    902         MycDiscoveryRepairAttemptView::Records => Ok(MycDiscoveryRepairAttemptOutput::Records(
    903             MycDiscoveryRepairAttemptRecordsOutput {
    904                 attempt_id: attempt_id.to_owned(),
    905                 runtime_operation_audit: records,
    906             },
    907         )),
    908     }
    909 }
    910 
    911 fn audit_read_limit(runtime: &MycRuntime, limit: Option<usize>) -> usize {
    912     limit.unwrap_or(runtime.operation_audit_store().config().default_read_limit)
    913 }
    914 
    915 impl MycDiscoveryRepairAttemptSummaryOutput {
    916     fn from_records(
    917         attempt_id: &str,
    918         records: &[MycOperationAuditRecord],
    919     ) -> Result<Self, MycError> {
    920         let Some(first_record) = records.first() else {
    921             return Err(MycError::InvalidOperation(format!(
    922                 "discovery repair attempt `{attempt_id}` had no records"
    923             )));
    924         };
    925         let finished_at_unix = records
    926             .last()
    927             .map(|record| record.recorded_at_unix)
    928             .unwrap_or(first_record.recorded_at_unix);
    929         let compare_outcome = records.iter().find_map(|record| {
    930             (record.operation == MycOperationAuditKind::DiscoveryHandlerCompare)
    931                 .then_some(record.outcome)
    932         });
    933         let refresh_outcome = records.iter().rev().find_map(|record| {
    934             (record.operation == MycOperationAuditKind::DiscoveryHandlerRefresh)
    935                 .then_some(record.outcome)
    936         });
    937         let refresh_record = records
    938             .iter()
    939             .rev()
    940             .find(|record| record.operation == MycOperationAuditKind::DiscoveryHandlerRefresh);
    941         let publish_record = records
    942             .iter()
    943             .rev()
    944             .find(|record| record.operation == MycOperationAuditKind::DiscoveryHandlerPublish);
    945 
    946         let mut repair_summary = MycDiscoveryRepairSummary::default();
    947         let mut failed_relays = Vec::new();
    948         for record in records
    949             .iter()
    950             .filter(|record| record.operation == MycOperationAuditKind::DiscoveryHandlerRepair)
    951         {
    952             match record.outcome {
    953                 MycOperationAuditOutcome::Succeeded => repair_summary.repaired += 1,
    954                 MycOperationAuditOutcome::Rejected => {
    955                     repair_summary.failed += 1;
    956                     if let Some(relay_url) = record.relay_url.clone() {
    957                         failed_relays.push(relay_url);
    958                     }
    959                 }
    960                 MycOperationAuditOutcome::Matched => repair_summary.unchanged += 1,
    961                 MycOperationAuditOutcome::Skipped => repair_summary.skipped += 1,
    962                 _ => {}
    963             }
    964         }
    965         failed_relays.sort();
    966         failed_relays.dedup();
    967         let planned_repair_relays = refresh_record
    968             .map(|record| record.planned_repair_relays.clone())
    969             .unwrap_or_default();
    970         let blocked_relays = refresh_record
    971             .map(|record| record.blocked_relays.clone())
    972             .unwrap_or_default();
    973         let blocked_reason = refresh_record.and_then(|record| record.blocked_reason.clone());
    974         let remaining_repair_relays = if !failed_relays.is_empty() {
    975             failed_relays.clone()
    976         } else if matches!(
    977             refresh_outcome,
    978             Some(
    979                 MycOperationAuditOutcome::Unavailable
    980                     | MycOperationAuditOutcome::Conflicted
    981                     | MycOperationAuditOutcome::Rejected
    982             )
    983         ) {
    984             planned_repair_relays.clone()
    985         } else {
    986             Vec::new()
    987         };
    988 
    989         Ok(Self {
    990             attempt_id: attempt_id.to_owned(),
    991             record_count: records.len(),
    992             started_at_unix: first_record.recorded_at_unix,
    993             finished_at_unix,
    994             compare_outcome,
    995             refresh_outcome,
    996             aggregate_publish_outcome: publish_record.map(|record| record.outcome),
    997             aggregate_publish_relay_count: publish_record.map(|record| record.relay_count),
    998             aggregate_publish_acknowledged_relay_count: publish_record
    999                 .map(|record| record.acknowledged_relay_count),
   1000             aggregate_publish_relay_outcome_summary: publish_record
   1001                 .map(|record| record.relay_outcome_summary.clone()),
   1002             aggregate_publish_delivery_policy: publish_record
   1003                 .and_then(|record| record.delivery_policy),
   1004             aggregate_publish_required_acknowledged_relay_count: publish_record
   1005                 .and_then(|record| record.required_acknowledged_relay_count),
   1006             aggregate_publish_attempt_count: publish_record
   1007                 .and_then(|record| record.publish_attempt_count),
   1008             repair_summary,
   1009             planned_repair_relays,
   1010             blocked_relays,
   1011             blocked_reason,
   1012             failed_relays: failed_relays.clone(),
   1013             remaining_repair_relays,
   1014         })
   1015     }
   1016 }
   1017 
   1018 fn print_json<T>(value: &T) -> Result<(), MycError>
   1019 where
   1020     T: Serialize,
   1021 {
   1022     println!("{}", serde_json::to_string_pretty(value)?);
   1023     Ok(())
   1024 }
   1025 
   1026 fn print_text(value: &str) {
   1027     println!("{value}");
   1028 }
   1029 
   1030 fn read_secret_env(name: &str, operation: &str) -> Result<Zeroizing<String>, MycError> {
   1031     let value = std::env::var(name).map_err(|_| {
   1032         MycError::InvalidOperation(format!(
   1033             "{operation} requires environment variable `{name}` to be set"
   1034         ))
   1035     })?;
   1036     if value.is_empty() {
   1037         return Err(MycError::InvalidOperation(format!(
   1038             "{operation} requires environment variable `{name}` to be non-empty"
   1039         )));
   1040     }
   1041     Ok(Zeroizing::new(value))
   1042 }
   1043 
   1044 #[cfg(test)]
   1045 mod tests {
   1046     use std::path::PathBuf;
   1047 
   1048     use clap::Parser;
   1049     use nostr::Timestamp;
   1050     use radroots_identity::RadrootsIdentity;
   1051     use radroots_nostr_connect::prelude::RadrootsNostrConnectRequest;
   1052     use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionDraft;
   1053     use serde_json::json;
   1054 
   1055     use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
   1056     use crate::config::MycConfig;
   1057 
   1058     use super::{
   1059         MycAuditScope, MycCli, MycCommand, MycCustodyCommand, MycCustodyRole, MycStatusView,
   1060         granted_permissions_for_approval, load_audit_output, summarize_audit_output,
   1061     };
   1062     use crate::app::MycRuntime;
   1063 
   1064     fn write_identity(path: &std::path::Path, secret_key: &str) {
   1065         let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity");
   1066         crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity");
   1067     }
   1068 
   1069     fn runtime() -> MycRuntime {
   1070         runtime_with_config(|_| {})
   1071     }
   1072 
   1073     fn runtime_with_config<F>(configure: F) -> MycRuntime
   1074     where
   1075         F: FnOnce(&mut MycConfig),
   1076     {
   1077         let temp = tempfile::tempdir().expect("tempdir").keep();
   1078         let mut config = MycConfig::default();
   1079         config.audit.default_read_limit = 2;
   1080         config.paths.state_dir = PathBuf::from(&temp).join("state");
   1081         config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json");
   1082         config.paths.user_identity_path = PathBuf::from(&temp).join("user.json");
   1083         configure(&mut config);
   1084         write_identity(
   1085             &config.paths.signer_identity_path,
   1086             "1111111111111111111111111111111111111111111111111111111111111111",
   1087         );
   1088         write_identity(
   1089             &config.paths.user_identity_path,
   1090             "2222222222222222222222222222222222222222222222222222222222222222",
   1091         );
   1092         MycRuntime::bootstrap(config).expect("runtime")
   1093     }
   1094 
   1095     #[test]
   1096     fn granted_permissions_for_approval_respects_policy_ceiling() {
   1097         let runtime = runtime_with_config(|config| {
   1098             config.policy.permission_ceiling = "nip04_encrypt".parse().expect("permission ceiling");
   1099         });
   1100         let manager = runtime.signer_manager().expect("manager");
   1101         let connection = manager
   1102             .register_connection(
   1103                 RadrootsNostrSignerConnectionDraft::new(
   1104                     nostr::Keys::generate().public_key(),
   1105                     runtime.user_public_identity(),
   1106                 )
   1107                 .with_requested_permissions(
   1108                     "nip44_encrypt".parse().expect("requested permissions"),
   1109                 ),
   1110             )
   1111             .expect("register connection");
   1112 
   1113         let error = granted_permissions_for_approval(
   1114             runtime.signer_context().policy(),
   1115             &manager.list_connections().expect("connections"),
   1116             &connection.connection_id,
   1117             &[],
   1118         )
   1119         .expect_err("requested permissions outside policy should be rejected");
   1120 
   1121         assert!(
   1122             error
   1123                 .to_string()
   1124                 .contains("granted permissions exceed the configured policy ceiling")
   1125         );
   1126     }
   1127 
   1128     #[test]
   1129     fn audit_output_surfaces_both_request_and_operation_records() {
   1130         let runtime = runtime();
   1131         let manager = runtime.signer_manager().expect("manager");
   1132         let connection = manager
   1133             .register_connection(RadrootsNostrSignerConnectionDraft::new(
   1134                 nostr::Keys::generate().public_key(),
   1135                 runtime.user_public_identity(),
   1136             ))
   1137             .expect("register connection");
   1138         let request_evaluation = manager
   1139             .evaluate_request(
   1140                 &connection.connection_id,
   1141                 radroots_nostr_connect::prelude::RadrootsNostrConnectRequestMessage::new(
   1142                     "request-1",
   1143                     RadrootsNostrConnectRequest::Ping,
   1144                 ),
   1145             )
   1146             .expect("record audit");
   1147         runtime.record_operation_audit(&MycOperationAuditRecord::new(
   1148             MycOperationAuditKind::AuthReplayRestore,
   1149             MycOperationAuditOutcome::Restored,
   1150             Some(&connection.connection_id),
   1151             Some(request_evaluation.audit.request_id.as_str()),
   1152             1,
   1153             0,
   1154             "restored pending auth challenge after replay failure",
   1155         ));
   1156 
   1157         let output = load_audit_output(
   1158             &runtime,
   1159             &manager,
   1160             Some(connection.connection_id.as_str()),
   1161             None,
   1162             MycAuditScope::All,
   1163             None,
   1164         )
   1165         .expect("load audit output");
   1166 
   1167         assert_eq!(output.signer_request_audit, vec![request_evaluation.audit]);
   1168         assert_eq!(output.runtime_operation_audit.len(), 1);
   1169         assert_eq!(
   1170             output.runtime_operation_audit[0].operation,
   1171             MycOperationAuditKind::AuthReplayRestore
   1172         );
   1173     }
   1174 
   1175     #[test]
   1176     fn audit_summary_counts_recent_failures_and_restores() {
   1177         let runtime = runtime();
   1178         let manager = runtime.signer_manager().expect("manager");
   1179         let connection = manager
   1180             .register_connection(RadrootsNostrSignerConnectionDraft::new(
   1181                 nostr::Keys::generate().public_key(),
   1182                 runtime.user_public_identity(),
   1183             ))
   1184             .expect("register connection");
   1185 
   1186         let denied = manager
   1187             .evaluate_request(
   1188                 &connection.connection_id,
   1189                 radroots_nostr_connect::prelude::RadrootsNostrConnectRequestMessage::new(
   1190                     "request-1",
   1191                     RadrootsNostrConnectRequest::SignEvent(
   1192                         serde_json::from_value(json!({
   1193                             "pubkey": runtime.user_identity().public_key().to_hex(),
   1194                             "created_at": Timestamp::from(1).as_secs(),
   1195                             "kind": 1,
   1196                             "tags": [],
   1197                             "content": "hello"
   1198                         }))
   1199                         .expect("unsigned event"),
   1200                     ),
   1201                 ),
   1202             )
   1203             .expect("denied request");
   1204         let challenged = manager
   1205             .require_auth_challenge(&connection.connection_id, "https://auth.example")
   1206             .expect("require auth challenge");
   1207         let challenged_eval = manager
   1208             .evaluate_request(
   1209                 &challenged.connection_id,
   1210                 radroots_nostr_connect::prelude::RadrootsNostrConnectRequestMessage::new(
   1211                     "request-2",
   1212                     RadrootsNostrConnectRequest::Ping,
   1213                 ),
   1214             )
   1215             .expect("challenged request");
   1216 
   1217         runtime.record_operation_audit(&MycOperationAuditRecord::new(
   1218             MycOperationAuditKind::ListenerResponsePublish,
   1219             MycOperationAuditOutcome::Rejected,
   1220             Some(&connection.connection_id),
   1221             Some("request-1"),
   1222             1,
   1223             0,
   1224             "listener publish rejected",
   1225         ));
   1226         runtime.record_operation_audit(&MycOperationAuditRecord::new(
   1227             MycOperationAuditKind::AuthReplayRestore,
   1228             MycOperationAuditOutcome::Restored,
   1229             Some(&connection.connection_id),
   1230             Some("request-2"),
   1231             1,
   1232             0,
   1233             "restored pending auth challenge after replay failure",
   1234         ));
   1235         runtime.record_operation_audit(&MycOperationAuditRecord::new(
   1236             MycOperationAuditKind::ConnectAcceptPublish,
   1237             MycOperationAuditOutcome::Succeeded,
   1238             Some(&connection.connection_id),
   1239             Some("request-3"),
   1240             1,
   1241             1,
   1242             "publish succeeded",
   1243         ));
   1244 
   1245         let summary = summarize_audit_output(
   1246             &runtime,
   1247             &manager,
   1248             Some(connection.connection_id.as_str()),
   1249             None,
   1250             MycAuditScope::All,
   1251             None,
   1252         )
   1253         .expect("summary");
   1254 
   1255         assert_eq!(summary.record_limit, 2);
   1256         assert_eq!(summary.signer_request_total, 2);
   1257         assert_eq!(summary.signer_request_decisions.denied, 1);
   1258         assert_eq!(summary.signer_request_decisions.challenged, 1);
   1259         assert_eq!(summary.runtime_operation_total, 2);
   1260         assert_eq!(summary.runtime_operation_outcomes.succeeded, 1);
   1261         assert_eq!(summary.runtime_operation_outcomes.restored, 1);
   1262         assert_eq!(summary.runtime_aggregate_publish_rejection_count, 0);
   1263         assert_eq!(summary.runtime_repair_success_count, 0);
   1264         assert_eq!(summary.runtime_repair_rejection_count, 0);
   1265         assert_eq!(summary.runtime_unavailable_count, 0);
   1266         assert_eq!(summary.runtime_replay_restore_count, 1);
   1267         assert_eq!(
   1268             summary
   1269                 .runtime_operation_by_kind
   1270                 .get("auth_replay_restore")
   1271                 .expect("restore kind")
   1272                 .restored,
   1273             1
   1274         );
   1275         assert_eq!(
   1276             summary
   1277                 .runtime_operation_by_kind
   1278                 .get("connect_accept_publish")
   1279                 .expect("connect kind")
   1280                 .succeeded,
   1281             1
   1282         );
   1283         assert_eq!(denied.audit.request_id.as_str(), "request-1");
   1284         assert_eq!(challenged_eval.audit.request_id.as_str(), "request-2");
   1285     }
   1286 
   1287     #[test]
   1288     fn audit_summary_separates_repair_rejections_from_aggregate_publish_rejections() {
   1289         let runtime = runtime();
   1290         let manager = runtime.signer_manager().expect("manager");
   1291         let connection = manager
   1292             .register_connection(RadrootsNostrSignerConnectionDraft::new(
   1293                 nostr::Keys::generate().public_key(),
   1294                 runtime.user_public_identity(),
   1295             ))
   1296             .expect("register connection");
   1297 
   1298         runtime.record_operation_audit(&MycOperationAuditRecord::new(
   1299             MycOperationAuditKind::DiscoveryHandlerPublish,
   1300             MycOperationAuditOutcome::Succeeded,
   1301             Some(&connection.connection_id),
   1302             Some("request-1"),
   1303             2,
   1304             1,
   1305             "1/2 relays acknowledged publish; failures: relay-b: blocked",
   1306         ));
   1307         runtime.record_operation_audit(
   1308             &MycOperationAuditRecord::new(
   1309                 MycOperationAuditKind::DiscoveryHandlerRepair,
   1310                 MycOperationAuditOutcome::Succeeded,
   1311                 Some(&connection.connection_id),
   1312                 Some("request-1"),
   1313                 1,
   1314                 1,
   1315                 "relay repaired",
   1316             )
   1317             .with_relay_url("wss://relay-a.example.com"),
   1318         );
   1319         runtime.record_operation_audit(
   1320             &MycOperationAuditRecord::new(
   1321                 MycOperationAuditKind::DiscoveryHandlerRepair,
   1322                 MycOperationAuditOutcome::Rejected,
   1323                 Some(&connection.connection_id),
   1324                 Some("request-1"),
   1325                 1,
   1326                 0,
   1327                 "blocked by relay",
   1328             )
   1329             .with_relay_url("wss://relay-b.example.com"),
   1330         );
   1331         runtime.record_operation_audit(&MycOperationAuditRecord::new(
   1332             MycOperationAuditKind::ListenerResponsePublish,
   1333             MycOperationAuditOutcome::Rejected,
   1334             Some(&connection.connection_id),
   1335             Some("request-2"),
   1336             1,
   1337             0,
   1338             "listener publish rejected",
   1339         ));
   1340 
   1341         let summary = summarize_audit_output(
   1342             &runtime,
   1343             &manager,
   1344             Some(connection.connection_id.as_str()),
   1345             None,
   1346             MycAuditScope::Operation,
   1347             Some(10),
   1348         )
   1349         .expect("summary");
   1350 
   1351         assert_eq!(summary.runtime_operation_total, 4);
   1352         assert_eq!(summary.runtime_aggregate_publish_rejection_count, 1);
   1353         assert_eq!(summary.runtime_repair_success_count, 1);
   1354         assert_eq!(summary.runtime_repair_rejection_count, 1);
   1355         assert_eq!(summary.runtime_replay_restore_count, 0);
   1356         assert_eq!(
   1357             summary
   1358                 .runtime_operation_by_kind
   1359                 .get("discovery_handler_publish")
   1360                 .expect("publish kind")
   1361                 .succeeded,
   1362             1
   1363         );
   1364         assert_eq!(
   1365             summary
   1366                 .runtime_operation_by_kind
   1367                 .get("discovery_handler_repair")
   1368                 .expect("repair kind")
   1369                 .succeeded,
   1370             1
   1371         );
   1372         assert_eq!(
   1373             summary
   1374                 .runtime_operation_by_kind
   1375                 .get("discovery_handler_repair")
   1376                 .expect("repair kind")
   1377                 .rejected,
   1378             1
   1379         );
   1380     }
   1381 
   1382     #[test]
   1383     fn parses_signer_status_view() {
   1384         let cli = MycCli::try_parse_from(["myc", "status", "--view", "signer"])
   1385             .expect("parse signer status");
   1386 
   1387         assert!(matches!(
   1388             cli.command,
   1389             Some(MycCommand::Status {
   1390                 view: MycStatusView::Signer
   1391             })
   1392         ));
   1393     }
   1394 
   1395     #[test]
   1396     fn parses_custody_list_command() {
   1397         let status = MycCli::try_parse_from(["myc", "custody", "status", "--role", "signer"])
   1398             .expect("parse custody status");
   1399         assert!(matches!(
   1400             status.command,
   1401             Some(MycCommand::Custody {
   1402                 command: MycCustodyCommand::Status {
   1403                     role: MycCustodyRole::Signer
   1404                 }
   1405             })
   1406         ));
   1407 
   1408         let cli = MycCli::try_parse_from(["myc", "custody", "list", "--role", "signer"])
   1409             .expect("parse custody list");
   1410 
   1411         assert!(matches!(
   1412             cli.command,
   1413             Some(MycCommand::Custody {
   1414                 command: MycCustodyCommand::List {
   1415                     role: MycCustodyRole::Signer
   1416                 }
   1417             })
   1418         ));
   1419     }
   1420 
   1421     #[test]
   1422     fn parses_custody_generate_and_import_commands() {
   1423         let generate = MycCli::try_parse_from([
   1424             "myc", "custody", "generate", "--role", "user", "--label", "primary", "--select",
   1425         ])
   1426         .expect("parse custody generate");
   1427         assert!(matches!(
   1428             generate.command,
   1429             Some(MycCommand::Custody {
   1430                 command: MycCustodyCommand::Generate {
   1431                     role: MycCustodyRole::User,
   1432                     select: true,
   1433                     ..
   1434                 }
   1435             })
   1436         ));
   1437 
   1438         let import = MycCli::try_parse_from([
   1439             "myc",
   1440             "custody",
   1441             "import-file",
   1442             "--role",
   1443             "discovery-app",
   1444             "--path",
   1445             "/tmp/discovery.json",
   1446         ])
   1447         .expect("parse custody import");
   1448         assert!(matches!(
   1449             import.command,
   1450             Some(MycCommand::Custody {
   1451                 command: MycCustodyCommand::ImportFile {
   1452                     role: MycCustodyRole::DiscoveryApp,
   1453                     select: false,
   1454                     ..
   1455                 }
   1456             })
   1457         ));
   1458 
   1459         let export_nip49 = MycCli::try_parse_from([
   1460             "myc",
   1461             "custody",
   1462             "export-nip49",
   1463             "--role",
   1464             "signer",
   1465             "--out",
   1466             "/tmp/signer.ncryptsec",
   1467             "--password-env",
   1468             "MYC_TEST_PASSWORD",
   1469         ])
   1470         .expect("parse custody export-nip49");
   1471         assert!(matches!(
   1472             export_nip49.command,
   1473             Some(MycCommand::Custody {
   1474                 command: MycCustodyCommand::ExportNip49 {
   1475                     role: MycCustodyRole::Signer,
   1476                     ..
   1477                 }
   1478             })
   1479         ));
   1480 
   1481         let import_nip49 = MycCli::try_parse_from([
   1482             "myc",
   1483             "custody",
   1484             "import-nip49",
   1485             "--role",
   1486             "user",
   1487             "--path",
   1488             "/tmp/user.ncryptsec",
   1489             "--password-env",
   1490             "MYC_TEST_PASSWORD",
   1491             "--label",
   1492             "migrated",
   1493         ])
   1494         .expect("parse custody import-nip49");
   1495         assert!(matches!(
   1496             import_nip49.command,
   1497             Some(MycCommand::Custody {
   1498                 command: MycCustodyCommand::ImportNip49 {
   1499                     role: MycCustodyRole::User,
   1500                     ..
   1501                 }
   1502             })
   1503         ));
   1504 
   1505         let rotate =
   1506             MycCli::try_parse_from(["myc", "custody", "rotate", "--role", "discovery-app"])
   1507                 .expect("parse custody rotate");
   1508         assert!(matches!(
   1509             rotate.command,
   1510             Some(MycCommand::Custody {
   1511                 command: MycCustodyCommand::Rotate {
   1512                     role: MycCustodyRole::DiscoveryApp
   1513                 }
   1514             })
   1515         ));
   1516     }
   1517 }