cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

account.rs (30080B)


      1 use std::{fmt, path::Path, sync::Arc};
      2 
      3 use radroots_identity::{
      4     IdentityError, RadrootsIdentity, RadrootsIdentityPublic, load_identity_profile,
      5 };
      6 use radroots_nostr_accounts::prelude::{
      7     RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountsError,
      8     RadrootsNostrAccountsManager,
      9 };
     10 use radroots_protected_store::RadrootsProtectedFileSecretVault;
     11 use radroots_secret_vault::{
     12     RadrootsHostVaultCapabilities, RadrootsResolvedSecretBackend, RadrootsSecretBackend,
     13     RadrootsSecretBackendAvailability, RadrootsSecretBackendSelection, RadrootsSecretVault,
     14     RadrootsSecretVaultError, RadrootsSecretVaultOsKeyring,
     15 };
     16 
     17 use crate::runtime::RuntimeError;
     18 use crate::runtime::config::RuntimeConfig;
     19 use crate::view::runtime::{AccountResolutionView, AccountSummaryView};
     20 
     21 const HOST_VAULT_AVAILABILITY_OVERRIDE_ENV: &str = "RADROOTS_CLI_ACCOUNT_HOST_VAULT_AVAILABLE";
     22 const HOST_VAULT_SERVICE_NAME: &str = "org.radroots.cli.local-account";
     23 const HOST_VAULT_PROBE_SLOT: &str = "__radroots_cli_host_vault_probe__";
     24 pub const SHARED_ACCOUNT_STORE_SOURCE: &str = "shared account store ยท local first";
     25 
     26 #[derive(Debug, Clone, PartialEq, Eq)]
     27 pub enum AccountRuntimeFailure {
     28     Unresolved(AccountRuntimeFailureIssue),
     29     WatchOnly(AccountRuntimeFailureIssue),
     30     Mismatch(AccountRuntimeFailureIssue),
     31 }
     32 
     33 #[derive(Debug, Clone, PartialEq, Eq)]
     34 pub struct AccountRuntimeFailureIssue {
     35     message: String,
     36     detail_json: Option<String>,
     37 }
     38 
     39 impl AccountRuntimeFailureIssue {
     40     fn new(message: impl Into<String>) -> Self {
     41         Self {
     42             message: message.into(),
     43             detail_json: None,
     44         }
     45     }
     46 
     47     fn with_detail(message: impl Into<String>, detail: serde_json::Value) -> Self {
     48         Self {
     49             message: message.into(),
     50             detail_json: Some(detail.to_string()),
     51         }
     52     }
     53 
     54     pub fn message(&self) -> &str {
     55         self.message.as_str()
     56     }
     57 }
     58 
     59 impl AccountRuntimeFailure {
     60     pub fn unresolved(message: impl Into<String>) -> Self {
     61         Self::Unresolved(AccountRuntimeFailureIssue::new(message))
     62     }
     63 
     64     pub fn unresolved_with_detail(message: impl Into<String>, detail: serde_json::Value) -> Self {
     65         Self::Unresolved(AccountRuntimeFailureIssue::with_detail(message, detail))
     66     }
     67 
     68     pub fn watch_only(account_id: &radroots_identity::RadrootsIdentityId) -> Self {
     69         Self::WatchOnly(AccountRuntimeFailureIssue::new(format!(
     70             "resolved account `{account_id}` is watch_only and cannot sign because it is not secret-backed"
     71         )))
     72     }
     73 
     74     pub fn watch_only_with_detail(
     75         account_id: impl fmt::Display,
     76         detail: serde_json::Value,
     77     ) -> Self {
     78         Self::WatchOnly(AccountRuntimeFailureIssue::with_detail(
     79             format!(
     80                 "resolved account `{account_id}` is watch_only and cannot sign because it is not secret-backed"
     81             ),
     82             detail,
     83         ))
     84     }
     85 
     86     pub fn mismatch(message: impl Into<String>) -> Self {
     87         Self::Mismatch(AccountRuntimeFailureIssue::new(message))
     88     }
     89 
     90     pub fn mismatch_with_detail(message: impl Into<String>, detail: serde_json::Value) -> Self {
     91         Self::Mismatch(AccountRuntimeFailureIssue::with_detail(message, detail))
     92     }
     93 
     94     pub fn message(&self) -> &str {
     95         match self {
     96             Self::Unresolved(issue) | Self::WatchOnly(issue) | Self::Mismatch(issue) => {
     97                 issue.message.as_str()
     98             }
     99         }
    100     }
    101 
    102     pub fn detail_json(&self) -> Option<&str> {
    103         match self {
    104             Self::Unresolved(issue) | Self::WatchOnly(issue) | Self::Mismatch(issue) => {
    105                 issue.detail_json.as_deref()
    106             }
    107         }
    108     }
    109 }
    110 
    111 impl fmt::Display for AccountRuntimeFailure {
    112     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    113         formatter.write_str(self.message())
    114     }
    115 }
    116 
    117 impl std::error::Error for AccountRuntimeFailure {}
    118 
    119 #[derive(Debug, Clone)]
    120 pub struct AccountSnapshot {
    121     pub accounts: Vec<AccountRecordView>,
    122 }
    123 
    124 #[derive(Debug, Clone)]
    125 pub struct AccountRecordView {
    126     pub record: RadrootsNostrAccountRecord,
    127     pub is_default: bool,
    128     pub custody: AccountCustody,
    129     pub write_capable: bool,
    130 }
    131 
    132 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    133 pub enum AccountCustody {
    134     SecretBacked,
    135     WatchOnly,
    136 }
    137 
    138 impl AccountCustody {
    139     pub fn as_str(self) -> &'static str {
    140         match self {
    141             Self::SecretBacked => "secret_backed",
    142             Self::WatchOnly => "watch_only",
    143         }
    144     }
    145 
    146     pub fn signer_label(self) -> &'static str {
    147         match self {
    148             Self::SecretBacked => "local",
    149             Self::WatchOnly => "watch_only",
    150         }
    151     }
    152 }
    153 
    154 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    155 struct AccountRuntimeFacts {
    156     custody: AccountCustody,
    157     write_capable: bool,
    158 }
    159 
    160 #[derive(Debug, Clone)]
    161 pub struct AccountSecretBackendStatus {
    162     pub state: String,
    163     pub active_backend: Option<String>,
    164     pub used_fallback: bool,
    165     pub reason: Option<String>,
    166 }
    167 
    168 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    169 pub enum AccountCreateMode {
    170     Created,
    171     Migrated,
    172 }
    173 
    174 #[derive(Debug, Clone)]
    175 pub struct AccountCreateResult {
    176     pub mode: AccountCreateMode,
    177     pub account: AccountRecordView,
    178 }
    179 
    180 #[derive(Debug, Clone)]
    181 pub struct AccountClearDefaultResult {
    182     pub cleared_account: Option<AccountRecordView>,
    183     pub remaining_account_count: usize,
    184 }
    185 
    186 #[derive(Debug, Clone)]
    187 pub struct AccountRemoveResult {
    188     pub removed_account: AccountRecordView,
    189     pub default_cleared: bool,
    190     pub remaining_account_count: usize,
    191 }
    192 
    193 #[derive(Debug, Clone)]
    194 pub struct AccountRemovePreview {
    195     pub account: AccountRecordView,
    196     pub default_would_clear: bool,
    197     pub remaining_account_count: usize,
    198 }
    199 
    200 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    201 pub enum AccountResolutionSource {
    202     InvocationOverride,
    203     DefaultAccount,
    204     None,
    205 }
    206 
    207 impl AccountResolutionSource {
    208     pub fn as_str(self) -> &'static str {
    209         match self {
    210             Self::InvocationOverride => "invocation_override",
    211             Self::DefaultAccount => "default_account",
    212             Self::None => "none",
    213         }
    214     }
    215 }
    216 
    217 #[derive(Debug, Clone)]
    218 pub struct AccountResolution {
    219     pub source: AccountResolutionSource,
    220     pub resolved_account: Option<AccountRecordView>,
    221     pub default_account: Option<AccountRecordView>,
    222 }
    223 
    224 #[derive(Debug, Clone)]
    225 pub struct AccountSigningIdentity {
    226     pub account: AccountRecordView,
    227     pub identity: RadrootsIdentity,
    228 }
    229 
    230 pub fn create_or_migrate_default_account(
    231     config: &RuntimeConfig,
    232 ) -> Result<AccountCreateResult, RuntimeError> {
    233     let manager = account_manager(config)?;
    234     let existing = manager.list_accounts()?;
    235     let (mode, created_account_id) = if existing.is_empty() && config.identity.path.exists() {
    236         (
    237             AccountCreateMode::Migrated,
    238             manager.migrate_legacy_identity_file(&config.identity.path, None, false)?,
    239         )
    240     } else {
    241         (
    242             AccountCreateMode::Created,
    243             manager.generate_identity(None, false)?,
    244         )
    245     };
    246 
    247     let snapshot = snapshot(config)?;
    248     let account = snapshot_account(
    249         &snapshot,
    250         &created_account_id,
    251         "created account missing after account create",
    252     )?;
    253 
    254     Ok(AccountCreateResult { mode, account })
    255 }
    256 
    257 pub fn import_public_identity(
    258     config: &RuntimeConfig,
    259     path: &Path,
    260     make_default: bool,
    261 ) -> Result<AccountRecordView, RuntimeError> {
    262     let manager = account_manager(config)?;
    263     let public_identity = load_public_identity_for_import(path)?;
    264     let imported_account_id =
    265         manager.upsert_public_identity(public_identity, None, make_default)?;
    266     let snapshot = snapshot_from_manager(&manager)?;
    267     snapshot_account(
    268         &snapshot,
    269         &imported_account_id,
    270         "imported account missing after account import",
    271     )
    272 }
    273 
    274 pub fn preview_public_identity_import(
    275     config: &RuntimeConfig,
    276     path: &Path,
    277     make_default: bool,
    278 ) -> Result<AccountRecordView, RuntimeError> {
    279     let public_identity = load_public_identity_for_import(path)?;
    280     let manager = account_manager(config)?;
    281     let snapshot = snapshot_from_manager(&manager)?;
    282     if let Some(existing) = snapshot
    283         .accounts
    284         .iter()
    285         .find(|account| account.record.account_id == public_identity.id)
    286         .cloned()
    287     {
    288         let mut account = existing;
    289         if make_default {
    290             account.is_default = true;
    291         }
    292         return Ok(account);
    293     }
    294 
    295     Ok(AccountRecordView {
    296         record: RadrootsNostrAccountRecord::new(public_identity, None, 0),
    297         is_default: make_default,
    298         custody: AccountCustody::WatchOnly,
    299         write_capable: false,
    300     })
    301 }
    302 
    303 pub fn preview_identity_secret_attachment(
    304     config: &RuntimeConfig,
    305     selector: &str,
    306     path: &Path,
    307     make_default: bool,
    308 ) -> Result<AccountRecordView, RuntimeError> {
    309     let manager = account_manager(config)?;
    310     let snapshot = snapshot_from_manager(&manager)?;
    311     let mut account = resolve_selector_account(&manager, &snapshot, selector)?;
    312     let identity = load_secret_identity_for_attachment(path)?;
    313     validate_identity_secret_matches_account(&account.record, &identity)?;
    314     if make_default {
    315         account.is_default = true;
    316     }
    317     account.custody = AccountCustody::SecretBacked;
    318     account.write_capable = true;
    319     Ok(account)
    320 }
    321 
    322 pub fn attach_identity_secret(
    323     config: &RuntimeConfig,
    324     selector: &str,
    325     path: &Path,
    326     make_default: bool,
    327 ) -> Result<AccountRecordView, RuntimeError> {
    328     let manager = account_manager(config)?;
    329     let snapshot = snapshot_from_manager(&manager)?;
    330     let account = resolve_selector_account(&manager, &snapshot, selector)?;
    331     let identity = load_secret_identity_for_attachment(path)?;
    332     validate_identity_secret_matches_account(&account.record, &identity)?;
    333     let attached =
    334         manager.attach_identity_secret(&account.record.account_id, &identity, make_default)?;
    335     let snapshot = snapshot_from_manager(&manager)?;
    336     snapshot_account(
    337         &snapshot,
    338         &attached.account_id,
    339         "attached account missing after account attach-secret",
    340     )
    341 }
    342 
    343 pub fn snapshot(config: &RuntimeConfig) -> Result<AccountSnapshot, RuntimeError> {
    344     let manager = account_manager(config)?;
    345     snapshot_from_manager(&manager)
    346 }
    347 
    348 pub fn resolve_account(config: &RuntimeConfig) -> Result<Option<AccountRecordView>, RuntimeError> {
    349     Ok(resolve_account_resolution(config)?.resolved_account)
    350 }
    351 
    352 pub fn resolve_account_resolution(
    353     config: &RuntimeConfig,
    354 ) -> Result<AccountResolution, RuntimeError> {
    355     let manager = account_manager(config)?;
    356     let snapshot = snapshot_from_manager(&manager)?;
    357     let default_account = snapshot
    358         .accounts
    359         .iter()
    360         .find(|account| account.is_default)
    361         .cloned();
    362     if let Some(selector) = config.account.selector.as_deref() {
    363         let account = resolve_selector_account(&manager, &snapshot, selector)?;
    364         return Ok(AccountResolution {
    365             source: AccountResolutionSource::InvocationOverride,
    366             resolved_account: Some(account),
    367             default_account,
    368         });
    369     }
    370 
    371     Ok(AccountResolution {
    372         source: if default_account.is_some() {
    373             AccountResolutionSource::DefaultAccount
    374         } else {
    375             AccountResolutionSource::None
    376         },
    377         resolved_account: default_account.clone(),
    378         default_account,
    379     })
    380 }
    381 
    382 pub fn select_account(
    383     config: &RuntimeConfig,
    384     selector: &str,
    385 ) -> Result<AccountRecordView, RuntimeError> {
    386     let manager = account_manager(config)?;
    387     let snapshot = snapshot_from_manager(&manager)?;
    388     let account = resolve_selector_account(&manager, &snapshot, selector)?;
    389 
    390     manager.set_default_account(&account.record.account_id)?;
    391     let snapshot = snapshot_from_manager(&manager)?;
    392     snapshot
    393         .accounts
    394         .into_iter()
    395         .find(|candidate| candidate.record.account_id == account.record.account_id)
    396         .ok_or_else(|| {
    397             RuntimeError::Accounts(
    398                 radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::InvalidState(
    399                     "default account missing after account use".to_owned(),
    400                 ),
    401             )
    402         })
    403 }
    404 
    405 pub fn resolve_account_selector(
    406     config: &RuntimeConfig,
    407     selector: &str,
    408 ) -> Result<AccountRecordView, RuntimeError> {
    409     let manager = account_manager(config)?;
    410     let snapshot = snapshot_from_manager(&manager)?;
    411     resolve_selector_account(&manager, &snapshot, selector)
    412 }
    413 
    414 pub fn clear_default_account(
    415     config: &RuntimeConfig,
    416 ) -> Result<AccountClearDefaultResult, RuntimeError> {
    417     let manager = account_manager(config)?;
    418     let snapshot = snapshot_from_manager(&manager)?;
    419     let cleared_account = snapshot
    420         .accounts
    421         .iter()
    422         .find(|account| account.is_default)
    423         .cloned();
    424     manager.clear_default_account()?;
    425     let remaining_account_count = snapshot_from_manager(&manager)?.accounts.len();
    426     Ok(AccountClearDefaultResult {
    427         cleared_account,
    428         remaining_account_count,
    429     })
    430 }
    431 
    432 pub fn remove_account(
    433     config: &RuntimeConfig,
    434     selector: &str,
    435 ) -> Result<AccountRemoveResult, RuntimeError> {
    436     let manager = account_manager(config)?;
    437     let snapshot = snapshot_from_manager(&manager)?;
    438     let removed_account = resolve_selector_account(&manager, &snapshot, selector)?;
    439     let default_cleared = removed_account.is_default;
    440     manager.remove_account(&removed_account.record.account_id)?;
    441     let remaining_account_count = snapshot_from_manager(&manager)?.accounts.len();
    442     Ok(AccountRemoveResult {
    443         removed_account,
    444         default_cleared,
    445         remaining_account_count,
    446     })
    447 }
    448 
    449 pub fn preview_account_removal(
    450     config: &RuntimeConfig,
    451     selector: &str,
    452 ) -> Result<AccountRemovePreview, RuntimeError> {
    453     let manager = account_manager(config)?;
    454     let snapshot = snapshot_from_manager(&manager)?;
    455     let account = resolve_selector_account(&manager, &snapshot, selector)?;
    456     Ok(AccountRemovePreview {
    457         default_would_clear: account.is_default,
    458         remaining_account_count: snapshot.accounts.len().saturating_sub(1),
    459         account,
    460     })
    461 }
    462 
    463 pub fn resolved_account_signing_status(
    464     config: &RuntimeConfig,
    465 ) -> Result<RadrootsNostrAccountStatus, RuntimeError> {
    466     let manager = account_manager(config)?;
    467     let resolution = resolve_account_resolution(config)?;
    468     let Some(account) = resolution.resolved_account else {
    469         return Ok(RadrootsNostrAccountStatus::NotConfigured);
    470     };
    471 
    472     Ok(
    473         match manager.get_signing_identity(&account.record.account_id)? {
    474             Some(_) => RadrootsNostrAccountStatus::Ready {
    475                 account: account.record.clone(),
    476             },
    477             None => RadrootsNostrAccountStatus::PublicOnly {
    478                 account: account.record.clone(),
    479             },
    480         },
    481     )
    482 }
    483 
    484 pub fn resolve_local_signing_identity(
    485     config: &RuntimeConfig,
    486 ) -> Result<AccountSigningIdentity, RuntimeError> {
    487     let manager = account_manager(config)?;
    488     let resolution = resolve_account_resolution(config)?;
    489     let Some(account) = resolution.resolved_account else {
    490         return Err(AccountRuntimeFailure::unresolved(unresolved_account_reason(config)?).into());
    491     };
    492     let Some(identity) = manager.get_signing_identity(&account.record.account_id)? else {
    493         return Err(AccountRuntimeFailure::watch_only(&account.record.account_id).into());
    494     };
    495     Ok(AccountSigningIdentity { account, identity })
    496 }
    497 
    498 pub fn resolve_local_signing_identity_for_account(
    499     config: &RuntimeConfig,
    500     account_id: &str,
    501 ) -> Result<AccountSigningIdentity, RuntimeError> {
    502     let manager = account_manager(config)?;
    503     let snapshot = snapshot_from_manager(&manager)?;
    504     let Some(account) = snapshot
    505         .accounts
    506         .iter()
    507         .find(|account| account.record.account_id.as_str() == account_id)
    508         .cloned()
    509     else {
    510         return Err(AccountRuntimeFailure::unresolved(format!(
    511             "farm-bound seller account `{account_id}` is not present in the local account store"
    512         ))
    513         .into());
    514     };
    515     let Some(identity) = manager.get_signing_identity(&account.record.account_id)? else {
    516         return Err(AccountRuntimeFailure::watch_only(&account.record.account_id).into());
    517     };
    518     Ok(AccountSigningIdentity { account, identity })
    519 }
    520 
    521 pub fn account_summary_view(account: &AccountRecordView) -> AccountSummaryView {
    522     AccountSummaryView::from_account_runtime(
    523         &account.record,
    524         account.custody.signer_label(),
    525         account.custody.as_str(),
    526         account.write_capable,
    527         account.is_default,
    528     )
    529 }
    530 
    531 pub fn account_resolution_view(resolution: &AccountResolution) -> AccountResolutionView {
    532     AccountResolutionView {
    533         status: if resolution.resolved_account.is_some() {
    534             "resolved"
    535         } else {
    536             "unresolved"
    537         }
    538         .to_owned(),
    539         source: resolution.source.as_str().to_owned(),
    540         resolved_account: resolution
    541             .resolved_account
    542             .as_ref()
    543             .map(account_summary_view),
    544         default_account: resolution
    545             .default_account
    546             .as_ref()
    547             .map(account_summary_view),
    548     }
    549 }
    550 
    551 pub fn empty_account_resolution_view() -> AccountResolutionView {
    552     AccountResolutionView {
    553         status: "unresolved".to_owned(),
    554         source: AccountResolutionSource::None.as_str().to_owned(),
    555         resolved_account: None,
    556         default_account: None,
    557     }
    558 }
    559 
    560 pub fn unresolved_account_reason(config: &RuntimeConfig) -> Result<String, RuntimeError> {
    561     let snapshot = snapshot(config)?;
    562     Ok(if snapshot.accounts.is_empty() {
    563         format!(
    564             "no local accounts found in {}",
    565             config.account.store_path.display()
    566         )
    567     } else {
    568         format!(
    569             "accounts exist in {} but no default account is configured and no invocation override was provided",
    570             config.account.store_path.display()
    571         )
    572     })
    573 }
    574 
    575 pub fn secret_backend_status(config: &RuntimeConfig) -> AccountSecretBackendStatus {
    576     match resolve_secret_backend(config) {
    577         Ok(resolved) => AccountSecretBackendStatus {
    578             state: "ready".to_owned(),
    579             active_backend: Some(resolved.backend.kind().to_string()),
    580             used_fallback: resolved.used_fallback,
    581             reason: None,
    582         },
    583         Err(SecretBackendResolutionError::Unavailable(reason)) => AccountSecretBackendStatus {
    584             state: "unavailable".to_owned(),
    585             active_backend: None,
    586             used_fallback: false,
    587             reason: Some(reason),
    588         },
    589         Err(SecretBackendResolutionError::Invalid(reason)) => AccountSecretBackendStatus {
    590             state: "error".to_owned(),
    591             active_backend: None,
    592             used_fallback: false,
    593             reason: Some(reason),
    594         },
    595     }
    596 }
    597 
    598 pub fn load_secret_backend_secret(
    599     config: &RuntimeConfig,
    600     slot: &str,
    601     service_name: &str,
    602 ) -> Result<Option<String>, RuntimeError> {
    603     if slot.trim().is_empty() {
    604         return Err(RuntimeError::Config(
    605             "secret backend slot must not be empty".to_owned(),
    606         ));
    607     }
    608     let resolved = resolve_secret_backend(config).map_err(secret_backend_resolution_error)?;
    609     let vault = secret_vault_for_backend(config, resolved.backend, service_name)?;
    610     vault.load_secret(slot).map_err(|error| {
    611         RuntimeError::Config(format!(
    612             "failed to load secret `{slot}` from account secret backend `{}`: {error}",
    613             resolved.backend.kind()
    614         ))
    615     })
    616 }
    617 
    618 fn snapshot_from_manager(
    619     manager: &RadrootsNostrAccountsManager,
    620 ) -> Result<AccountSnapshot, RuntimeError> {
    621     let default_account_id = manager.default_account_id()?.map(|id| id.to_string());
    622     let mut accounts = Vec::new();
    623     for record in manager.list_accounts()? {
    624         let is_default = default_account_id
    625             .as_deref()
    626             .is_some_and(|default| default == record.account_id.as_str());
    627         let runtime = account_runtime_facts(manager, &record)?;
    628         accounts.push(AccountRecordView {
    629             record,
    630             is_default,
    631             custody: runtime.custody,
    632             write_capable: runtime.write_capable,
    633         });
    634     }
    635 
    636     Ok(AccountSnapshot { accounts })
    637 }
    638 
    639 fn snapshot_account(
    640     snapshot: &AccountSnapshot,
    641     account_id: &radroots_identity::RadrootsIdentityId,
    642     missing_message: &str,
    643 ) -> Result<AccountRecordView, RuntimeError> {
    644     snapshot
    645         .accounts
    646         .iter()
    647         .find(|account| account.record.account_id == *account_id)
    648         .cloned()
    649         .ok_or_else(|| {
    650             RuntimeError::Accounts(
    651                 radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::InvalidState(
    652                     missing_message.to_owned(),
    653                 ),
    654             )
    655         })
    656 }
    657 
    658 fn resolve_selector_account(
    659     manager: &RadrootsNostrAccountsManager,
    660     snapshot: &AccountSnapshot,
    661     selector: &str,
    662 ) -> Result<AccountRecordView, RuntimeError> {
    663     let record = manager
    664         .resolve_account_selector(selector)
    665         .map_err(|error| selector_runtime_error(selector, error))?;
    666     snapshot
    667         .accounts
    668         .iter()
    669         .find(|account| account.record.account_id == record.account_id)
    670         .cloned()
    671         .ok_or_else(|| {
    672             RuntimeError::Accounts(RadrootsNostrAccountsError::InvalidState(
    673                 "resolved account missing from snapshot".to_owned(),
    674             ))
    675         })
    676 }
    677 
    678 fn selector_runtime_error(selector: &str, error: RadrootsNostrAccountsError) -> RuntimeError {
    679     let normalized = selector.trim();
    680     match error {
    681         RadrootsNostrAccountsError::InvalidAccountSelector(reason) => RuntimeError::Config(reason),
    682         RadrootsNostrAccountsError::AccountNotFound(_) => {
    683             AccountRuntimeFailure::unresolved(format!(
    684                 "account selector `{normalized}` did not match any local account"
    685             ))
    686             .into()
    687         }
    688         RadrootsNostrAccountsError::AmbiguousAccountSelector(_) => {
    689             AccountRuntimeFailure::unresolved(format!(
    690                 "account selector `{normalized}` matched multiple local accounts; use account id or npub"
    691             ))
    692             .into()
    693         }
    694         other => RuntimeError::Accounts(other),
    695     }
    696 }
    697 
    698 fn account_runtime_facts(
    699     manager: &RadrootsNostrAccountsManager,
    700     record: &RadrootsNostrAccountRecord,
    701 ) -> Result<AccountRuntimeFacts, RuntimeError> {
    702     Ok(
    703         if manager.get_signing_identity(&record.account_id)?.is_some() {
    704             AccountRuntimeFacts {
    705                 custody: AccountCustody::SecretBacked,
    706                 write_capable: true,
    707             }
    708         } else {
    709             AccountRuntimeFacts {
    710                 custody: AccountCustody::WatchOnly,
    711                 write_capable: false,
    712             }
    713         },
    714     )
    715 }
    716 
    717 fn format_identity_error(error: IdentityError) -> String {
    718     match error {
    719         IdentityError::NotFound(path) => format!("path not found: {}", path.display()),
    720         other => other.to_string(),
    721     }
    722 }
    723 
    724 fn load_public_identity_for_import(path: &Path) -> Result<RadrootsIdentityPublic, RuntimeError> {
    725     load_identity_profile(path).map_err(|error| {
    726         RuntimeError::Config(format!(
    727             "failed to import account from {}: {}",
    728             path.display(),
    729             format_identity_error(error)
    730         ))
    731     })
    732 }
    733 
    734 fn load_secret_identity_for_attachment(path: &Path) -> Result<RadrootsIdentity, RuntimeError> {
    735     RadrootsIdentity::load_from_path_auto(path).map_err(|error| {
    736         RuntimeError::Config(format!(
    737             "failed to import account secret from {}: {}",
    738             path.display(),
    739             format_identity_error(error)
    740         ))
    741     })
    742 }
    743 
    744 fn validate_identity_secret_matches_account(
    745     record: &RadrootsNostrAccountRecord,
    746     identity: &RadrootsIdentity,
    747 ) -> Result<(), RuntimeError> {
    748     let secret_public_key_hex = identity.public_key_hex();
    749     if record
    750         .public_identity
    751         .public_key_hex
    752         .eq_ignore_ascii_case(secret_public_key_hex.as_str())
    753     {
    754         return Ok(());
    755     }
    756 
    757     Err(AccountRuntimeFailure::mismatch(format!(
    758         "account mismatch: resolved account `{}` public key `{}` does not match secret public key `{}`",
    759         record.account_id, record.public_identity.public_key_hex, secret_public_key_hex
    760     ))
    761     .into())
    762 }
    763 
    764 fn account_manager(config: &RuntimeConfig) -> Result<RadrootsNostrAccountsManager, RuntimeError> {
    765     let (manager, _) = RadrootsNostrAccountsManager::new_local_file_backed(
    766         config.account.store_path.as_path(),
    767         config.account.secrets_dir.as_path(),
    768         account_secret_backend_selection(config),
    769         secret_backend_availability()?,
    770         HOST_VAULT_SERVICE_NAME,
    771     )?;
    772     Ok(manager)
    773 }
    774 
    775 fn resolve_secret_backend(
    776     config: &RuntimeConfig,
    777 ) -> Result<RadrootsResolvedSecretBackend, SecretBackendResolutionError> {
    778     let availability = secret_backend_availability().map_err(|error| {
    779         SecretBackendResolutionError::Invalid(format!("account secret backend: {error}"))
    780     })?;
    781     RadrootsNostrAccountsManager::resolve_local_backend(
    782         account_secret_backend_selection(config),
    783         availability,
    784     )
    785     .map_err(|error| match error {
    786         RadrootsSecretVaultError::BackendUnavailable { .. }
    787         | RadrootsSecretVaultError::FallbackUnavailable { .. } => {
    788             SecretBackendResolutionError::Unavailable(format!("account secret backend: {error}"))
    789         }
    790         RadrootsSecretVaultError::FallbackDisallowed { .. }
    791         | RadrootsSecretVaultError::HostVaultPolicyUnsupported { .. } => {
    792             SecretBackendResolutionError::Invalid(format!("account secret backend: {error}"))
    793         }
    794     })
    795 }
    796 
    797 fn secret_backend_resolution_error(error: SecretBackendResolutionError) -> RuntimeError {
    798     match error {
    799         SecretBackendResolutionError::Unavailable(reason)
    800         | SecretBackendResolutionError::Invalid(reason) => RuntimeError::Config(reason),
    801     }
    802 }
    803 
    804 fn secret_vault_for_backend(
    805     config: &RuntimeConfig,
    806     backend: RadrootsSecretBackend,
    807     service_name: &str,
    808 ) -> Result<Arc<dyn RadrootsSecretVault>, RuntimeError> {
    809     match backend {
    810         RadrootsSecretBackend::HostVault(_) => {
    811             Ok(Arc::new(RadrootsSecretVaultOsKeyring::new(service_name)))
    812         }
    813         RadrootsSecretBackend::EncryptedFile => Ok(Arc::new(
    814             RadrootsProtectedFileSecretVault::new(config.account.secrets_dir.as_path()),
    815         )),
    816         RadrootsSecretBackend::ExternalCommand => Err(RuntimeError::Config(
    817             "external_command account secret backend is not supported for CLI signer sessions"
    818                 .to_owned(),
    819         )),
    820         RadrootsSecretBackend::Memory => Err(RuntimeError::Config(
    821             "memory account secret backend is not supported for persisted CLI signer sessions"
    822                 .to_owned(),
    823         )),
    824     }
    825 }
    826 
    827 fn account_secret_backend_selection(config: &RuntimeConfig) -> RadrootsSecretBackendSelection {
    828     RadrootsSecretBackendSelection {
    829         primary: config.account.secret_backend,
    830         fallback: config.account.secret_fallback,
    831     }
    832 }
    833 
    834 fn secret_backend_availability() -> Result<RadrootsSecretBackendAvailability, RuntimeError> {
    835     Ok(RadrootsSecretBackendAvailability {
    836         host_vault: host_vault_capabilities()?,
    837         encrypted_file: true,
    838         external_command: false,
    839         memory: true,
    840     })
    841 }
    842 
    843 fn host_vault_capabilities() -> Result<RadrootsHostVaultCapabilities, RuntimeError> {
    844     if let Some(available) = host_vault_availability_override()? {
    845         return Ok(match available {
    846             true => RadrootsHostVaultCapabilities::desktop_keyring(),
    847             false => RadrootsHostVaultCapabilities::unavailable(),
    848         });
    849     }
    850 
    851     let keyring = RadrootsSecretVaultOsKeyring::new(HOST_VAULT_SERVICE_NAME);
    852     match keyring.load_secret(HOST_VAULT_PROBE_SLOT) {
    853         Ok(_) => Ok(RadrootsHostVaultCapabilities::desktop_keyring()),
    854         Err(_) => Ok(RadrootsHostVaultCapabilities::unavailable()),
    855     }
    856 }
    857 
    858 fn host_vault_availability_override() -> Result<Option<bool>, RuntimeError> {
    859     let Ok(value) = std::env::var(HOST_VAULT_AVAILABILITY_OVERRIDE_ENV) else {
    860         return Ok(None);
    861     };
    862 
    863     parse_bool_value(HOST_VAULT_AVAILABILITY_OVERRIDE_ENV, value.trim()).map(Some)
    864 }
    865 
    866 fn parse_bool_value(key: &str, value: &str) -> Result<bool, RuntimeError> {
    867     match value.trim().to_ascii_lowercase().as_str() {
    868         "1" | "true" | "yes" | "on" => Ok(true),
    869         "0" | "false" | "no" | "off" => Ok(false),
    870         other => Err(RuntimeError::Config(format!(
    871             "{key} must be a boolean value, got `{other}`"
    872         ))),
    873     }
    874 }
    875 
    876 #[derive(Debug, Clone)]
    877 enum SecretBackendResolutionError {
    878     Unavailable(String),
    879     Invalid(String),
    880 }
    881 
    882 #[cfg(test)]
    883 mod tests {
    884     use radroots_protected_store::RadrootsProtectedFileSecretVault;
    885     use radroots_secret_vault::RadrootsSecretVault;
    886     use std::fs;
    887     use tempfile::tempdir;
    888 
    889     #[test]
    890     fn protected_file_vault_round_trips_secret() {
    891         let temp = tempdir().expect("tempdir");
    892         let vault = RadrootsProtectedFileSecretVault::new(temp.path());
    893 
    894         vault.store_secret("acct_demo", "deadbeef").expect("store");
    895         let loaded = vault.load_secret("acct_demo").expect("load");
    896         assert_eq!(loaded.as_deref(), Some("deadbeef"));
    897         let raw = fs::read_to_string(temp.path().join("acct_demo.secret.json")).expect("raw file");
    898         assert!(!raw.contains("deadbeef"));
    899     }
    900 
    901     #[test]
    902     fn protected_file_vault_removes_secret() {
    903         let temp = tempdir().expect("tempdir");
    904         let vault = RadrootsProtectedFileSecretVault::new(temp.path());
    905 
    906         vault.store_secret("acct_demo", "deadbeef").expect("store");
    907         vault.remove_secret("acct_demo").expect("remove");
    908         assert!(vault.load_secret("acct_demo").expect("load").is_none());
    909     }
    910 }