cli

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

signer.rs (29101B)


      1 use crate::runtime::RuntimeError;
      2 use crate::runtime::account::AccountRuntimeFailure;
      3 use crate::runtime::account::{SHARED_ACCOUNT_STORE_SOURCE, empty_account_resolution_view};
      4 use crate::runtime::config::{
      5     CapabilityBindingTargetKind, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend,
      6 };
      7 use crate::runtime::sdk::{MYC_NIP46_SESSION_SECRET_SERVICE, myc_managed_account_ref_matches};
      8 use crate::view::runtime::{
      9     IdentityPublicView, LocalSignerStatusView, MycStatusView, SignerBindingStatusView,
     10     SignerStatusView, SignerWriteKindReadinessView,
     11 };
     12 use radroots_events::kinds::{
     13     KIND_FARM, KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST,
     14     KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_PROFILE,
     15 };
     16 use radroots_nostr_accounts::prelude::RadrootsNostrAccountStatus;
     17 use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions;
     18 use radroots_nostr_signer::prelude::{
     19     RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability,
     20     RadrootsNostrSignerCapability,
     21 };
     22 use radroots_sdk::radroots_sdk_myc_nip46_product_permission_strings;
     23 use std::str::FromStr;
     24 use url::Url;
     25 
     26 const SIGNER_BINDING_PROVIDER_RUNTIME_ID: &str = "myc";
     27 const SIGNER_BINDING_MODEL: &str = "session_authorized_remote_signer";
     28 
     29 #[derive(Debug, Clone, Copy)]
     30 struct CliWriteKind {
     31     command: &'static str,
     32     event_kind: u32,
     33 }
     34 
     35 #[derive(Debug, Clone)]
     36 pub enum ActorWriteBindingError {
     37     Unconfigured(String),
     38     Account(AccountRuntimeFailure),
     39 }
     40 
     41 impl ActorWriteBindingError {
     42     pub fn from_runtime(error: RuntimeError) -> Self {
     43         match error {
     44             RuntimeError::Account(failure) => Self::Account(failure),
     45             other => Self::Unconfigured(other.to_string()),
     46         }
     47     }
     48 
     49     pub fn reason(self) -> String {
     50         match self {
     51             Self::Unconfigured(reason) => reason,
     52             Self::Account(failure) => failure.to_string(),
     53         }
     54     }
     55 }
     56 
     57 pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView {
     58     match config.signer.backend {
     59         SignerBackend::Local => resolve_local_signer_status(config),
     60         SignerBackend::Myc => resolve_myc_signer_status(config),
     61     }
     62 }
     63 
     64 fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
     65     let (account_resolution, resolved_account_id) =
     66         match crate::runtime::account::resolve_account_resolution(config) {
     67             Ok(resolution) => (
     68                 crate::runtime::account::account_resolution_view(&resolution),
     69                 resolution
     70                     .resolved_account
     71                     .as_ref()
     72                     .map(|account| account.record.account_id.to_string()),
     73             ),
     74             Err(error) => {
     75                 let reason = error.to_string();
     76                 return SignerStatusView {
     77                     mode: config.signer.backend.as_str().to_owned(),
     78                     state: "error".to_owned(),
     79                     source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(),
     80                     signer_account_id: None,
     81                     account_resolution: empty_account_resolution_view(),
     82                     reason: Some(reason.clone()),
     83                     binding: disabled_binding_status(),
     84                     write_kinds: local_write_kind_readiness(false, Some(reason)),
     85                     local: None,
     86                     myc: None,
     87                 };
     88             }
     89         };
     90     let secret_backend = crate::runtime::account::secret_backend_status(config);
     91     if secret_backend.state == "unavailable" {
     92         let reason = secret_backend.reason.clone();
     93         return SignerStatusView {
     94             mode: config.signer.backend.as_str().to_owned(),
     95             state: "unavailable".to_owned(),
     96             source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(),
     97             signer_account_id: resolved_account_id.clone(),
     98             account_resolution: account_resolution.clone(),
     99             reason: reason.clone(),
    100             binding: disabled_binding_status(),
    101             write_kinds: local_write_kind_readiness(false, reason),
    102             local: None,
    103             myc: None,
    104         };
    105     }
    106 
    107     if secret_backend.state == "error" {
    108         let reason = secret_backend.reason.clone();
    109         return SignerStatusView {
    110             mode: config.signer.backend.as_str().to_owned(),
    111             state: "error".to_owned(),
    112             source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(),
    113             signer_account_id: resolved_account_id.clone(),
    114             account_resolution: account_resolution.clone(),
    115             reason: reason.clone(),
    116             binding: disabled_binding_status(),
    117             write_kinds: local_write_kind_readiness(false, reason),
    118             local: None,
    119             myc: None,
    120         };
    121     }
    122 
    123     let backend = secret_backend
    124         .active_backend
    125         .unwrap_or_else(|| "unknown".to_owned());
    126     let used_fallback = secret_backend.used_fallback;
    127 
    128     match crate::runtime::account::resolved_account_signing_status(config) {
    129         Ok(RadrootsNostrAccountStatus::Ready { account }) => {
    130             let capability = RadrootsNostrSignerCapability::LocalAccount(Box::new(
    131                 RadrootsNostrLocalSignerCapability::new(
    132                     account.account_id.clone(),
    133                     account.public_identity.clone(),
    134                     RadrootsNostrLocalSignerAvailability::SecretBacked,
    135                 ),
    136             ));
    137             let local = capability
    138                 .local_account()
    139                 .expect("local signer capability")
    140                 .clone();
    141             SignerStatusView {
    142                 mode: config.signer.backend.as_str().to_owned(),
    143                 state: "ready".to_owned(),
    144                 source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(),
    145                 signer_account_id: Some(local.account_id.to_string()),
    146                 account_resolution: account_resolution.clone(),
    147                 reason: None,
    148                 binding: disabled_binding_status(),
    149                 write_kinds: local_write_kind_readiness(true, None),
    150                 local: Some(LocalSignerStatusView {
    151                     account_id: local.account_id.to_string(),
    152                     public_identity: IdentityPublicView::from_public_identity(
    153                         &local.public_identity,
    154                     ),
    155                     availability: local_availability(local.availability).to_owned(),
    156                     secret_backed: local.is_secret_backed(),
    157                     backend: backend.clone(),
    158                     used_fallback,
    159                 }),
    160                 myc: None,
    161             }
    162         }
    163         Ok(RadrootsNostrAccountStatus::PublicOnly { account }) => {
    164             let reason = AccountRuntimeFailure::watch_only(&account.account_id).to_string();
    165             SignerStatusView {
    166                 mode: config.signer.backend.as_str().to_owned(),
    167                 state: "unconfigured".to_owned(),
    168                 source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(),
    169                 signer_account_id: Some(account.account_id.to_string()),
    170                 account_resolution: account_resolution.clone(),
    171                 reason: Some(reason.clone()),
    172                 binding: disabled_binding_status(),
    173                 write_kinds: local_write_kind_readiness(false, Some(reason)),
    174                 local: Some(LocalSignerStatusView {
    175                     account_id: account.account_id.to_string(),
    176                     public_identity: IdentityPublicView::from_public_identity(
    177                         &account.public_identity,
    178                     ),
    179                     availability: local_availability(
    180                         RadrootsNostrLocalSignerAvailability::PublicOnly,
    181                     )
    182                     .to_owned(),
    183                     secret_backed: false,
    184                     backend: backend.clone(),
    185                     used_fallback,
    186                 }),
    187                 myc: None,
    188             }
    189         }
    190         Ok(RadrootsNostrAccountStatus::NotConfigured) => SignerStatusView {
    191             mode: config.signer.backend.as_str().to_owned(),
    192             state: "unconfigured".to_owned(),
    193             source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(),
    194             signer_account_id: None,
    195             account_resolution: account_resolution.clone(),
    196             reason: crate::runtime::account::unresolved_account_reason(config).ok(),
    197             binding: disabled_binding_status(),
    198             write_kinds: local_write_kind_readiness(
    199                 false,
    200                 crate::runtime::account::unresolved_account_reason(config).ok(),
    201             ),
    202             local: None,
    203             myc: None,
    204         },
    205         Err(error) => {
    206             let reason = error.to_string();
    207             SignerStatusView {
    208                 mode: config.signer.backend.as_str().to_owned(),
    209                 state: "error".to_owned(),
    210                 source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(),
    211                 signer_account_id: resolved_account_id,
    212                 account_resolution,
    213                 reason: Some(reason.clone()),
    214                 binding: disabled_binding_status(),
    215                 write_kinds: local_write_kind_readiness(false, Some(reason)),
    216                 local: None,
    217                 myc: None,
    218             }
    219         }
    220     }
    221 }
    222 
    223 fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView {
    224     let (account_resolution, actor_account_id, actor_pubkey) =
    225         match crate::runtime::account::resolve_account_resolution(config) {
    226             Ok(resolution) => {
    227                 let actor_account_id = resolution
    228                     .resolved_account
    229                     .as_ref()
    230                     .map(|account| account.record.account_id.to_string());
    231                 let actor_pubkey = resolution
    232                     .resolved_account
    233                     .as_ref()
    234                     .map(|account| account.record.public_identity.public_key_hex.clone());
    235                 (
    236                     crate::runtime::account::account_resolution_view(&resolution),
    237                     actor_account_id,
    238                     actor_pubkey,
    239                 )
    240             }
    241             Err(_) => (empty_account_resolution_view(), None, None),
    242         };
    243     let readiness =
    244         myc_binding_readiness(config, actor_account_id.as_deref(), actor_pubkey.as_deref());
    245     SignerStatusView {
    246         mode: config.signer.backend.as_str().to_owned(),
    247         state: if readiness.ready {
    248             "ready"
    249         } else {
    250             "unconfigured"
    251         }
    252         .to_owned(),
    253         source: readiness.source.clone(),
    254         signer_account_id: None,
    255         account_resolution,
    256         reason: readiness.reason.clone(),
    257         binding: readiness.binding,
    258         write_kinds: myc_write_kind_readiness(readiness.ready, readiness.reason.clone()),
    259         local: None,
    260         myc: Some(MycStatusView {
    261             executable: config.myc.executable.display().to_string(),
    262             state: if readiness.ready {
    263                 "ready"
    264             } else {
    265                 "unconfigured"
    266             }
    267             .to_owned(),
    268             source: readiness.source,
    269             service_status: None,
    270             ready: readiness.ready,
    271             reason: readiness.reason,
    272             reasons: readiness.reasons,
    273             remote_session_count: usize::from(readiness.signer_session_ref.is_some()),
    274             local_signer: None,
    275             remote_sessions: Vec::new(),
    276             custody: None,
    277         }),
    278     }
    279 }
    280 
    281 fn disabled_binding_status() -> SignerBindingStatusView {
    282     SignerBindingStatusView {
    283         capability_id: SIGNER_REMOTE_NIP46_CAPABILITY.to_owned(),
    284         provider_runtime_id: SIGNER_BINDING_PROVIDER_RUNTIME_ID.to_owned(),
    285         binding_model: SIGNER_BINDING_MODEL.to_owned(),
    286         state: "disabled".to_owned(),
    287         source: "independent local signer mode".to_owned(),
    288         target_kind: None,
    289         target: None,
    290         managed_account_ref: None,
    291         signer_session_ref: None,
    292         resolved_session_ref: None,
    293         matched_session_count: None,
    294         reason: Some(
    295             "remote myc signer binding is disabled while cli signer mode is `local`".to_owned(),
    296         ),
    297     }
    298 }
    299 
    300 fn cli_write_kinds() -> [CliWriteKind; 12] {
    301     [
    302         CliWriteKind {
    303             command: "sync.push",
    304             event_kind: KIND_PROFILE,
    305         },
    306         CliWriteKind {
    307             command: "farm.publish",
    308             event_kind: KIND_FARM,
    309         },
    310         CliWriteKind {
    311             command: "listing.publish",
    312             event_kind: KIND_LISTING,
    313         },
    314         CliWriteKind {
    315             command: "listing.update",
    316             event_kind: KIND_LISTING,
    317         },
    318         CliWriteKind {
    319             command: "listing.archive",
    320             event_kind: KIND_LISTING,
    321         },
    322         CliWriteKind {
    323             command: "order.submit",
    324             event_kind: KIND_ORDER_REQUEST,
    325         },
    326         CliWriteKind {
    327             command: "order.accept",
    328             event_kind: KIND_ORDER_DECISION,
    329         },
    330         CliWriteKind {
    331             command: "order.decline",
    332             event_kind: KIND_ORDER_DECISION,
    333         },
    334         CliWriteKind {
    335             command: "order.cancel",
    336             event_kind: KIND_ORDER_CANCELLATION,
    337         },
    338         CliWriteKind {
    339             command: "order.revision.propose",
    340             event_kind: KIND_ORDER_REVISION_PROPOSAL,
    341         },
    342         CliWriteKind {
    343             command: "order.revision.accept",
    344             event_kind: KIND_ORDER_REVISION_DECISION,
    345         },
    346         CliWriteKind {
    347             command: "order.revision.decline",
    348             event_kind: KIND_ORDER_REVISION_DECISION,
    349         },
    350     ]
    351 }
    352 
    353 fn local_write_kind_readiness(
    354     ready: bool,
    355     reason: Option<String>,
    356 ) -> Vec<SignerWriteKindReadinessView> {
    357     cli_write_kinds()
    358         .iter()
    359         .map(|kind| SignerWriteKindReadinessView {
    360             command: kind.command.to_owned(),
    361             event_kind: kind.event_kind,
    362             permission: "local_account_secret".to_owned(),
    363             ready,
    364             reason: if ready { None } else { reason.clone() },
    365         })
    366         .collect()
    367 }
    368 
    369 fn local_availability(value: RadrootsNostrLocalSignerAvailability) -> &'static str {
    370     match value {
    371         RadrootsNostrLocalSignerAvailability::PublicOnly => "public_only",
    372         RadrootsNostrLocalSignerAvailability::SecretBacked => "secret_backed",
    373     }
    374 }
    375 
    376 #[derive(Debug, Clone)]
    377 struct MycBindingReadiness {
    378     binding: SignerBindingStatusView,
    379     ready: bool,
    380     source: String,
    381     reason: Option<String>,
    382     reasons: Vec<String>,
    383     signer_session_ref: Option<String>,
    384 }
    385 
    386 fn myc_binding_readiness(
    387     config: &RuntimeConfig,
    388     actor_account_id: Option<&str>,
    389     actor_pubkey: Option<&str>,
    390 ) -> MycBindingReadiness {
    391     let Some(binding) = config.capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY) else {
    392         let reason = "signer.remote_nip46 binding is missing".to_owned();
    393         return MycBindingReadiness {
    394             binding: missing_myc_binding_status(reason.clone()),
    395             ready: false,
    396             source: "no explicit capability binding".to_owned(),
    397             reason: Some(reason.clone()),
    398             reasons: vec![reason],
    399             signer_session_ref: None,
    400         };
    401     };
    402 
    403     let mut reasons = Vec::new();
    404     if binding.target_kind != CapabilityBindingTargetKind::ExplicitEndpoint {
    405         reasons.push(format!(
    406             "signer.remote_nip46 binding target_kind `{}` is not supported for CLI Myc signing; use `explicit_endpoint`",
    407             binding.target_kind.as_str()
    408         ));
    409     }
    410     if let Err(reason) = validate_myc_target(binding.target.as_str()) {
    411         reasons.push(reason);
    412     }
    413     if let Some(managed_account_ref) = binding.managed_account_ref.as_deref() {
    414         let managed_account_matches = actor_pubkey
    415             .map(|actor_pubkey| {
    416                 myc_managed_account_ref_matches(managed_account_ref, actor_account_id, actor_pubkey)
    417             })
    418             .unwrap_or_else(|| {
    419                 actor_account_id.is_some_and(|account_id| managed_account_ref == account_id)
    420             });
    421         if !managed_account_matches {
    422             let reason = if actor_account_id.is_none() && actor_pubkey.is_none() {
    423                 format!(
    424                     "signer.remote_nip46 managed_account_ref `{managed_account_ref}` cannot be evaluated because no actor account or pubkey resolved"
    425                 )
    426             } else {
    427                 format!(
    428                     "signer.remote_nip46 managed_account_ref `{managed_account_ref}` does not match actor account or pubkey"
    429                 )
    430             };
    431             reasons.push(reason);
    432         }
    433     }
    434     let signer_session_ref = binding.signer_session_ref.clone();
    435     if let Some(session_ref) = signer_session_ref.as_deref() {
    436         match crate::runtime::account::load_secret_backend_secret(
    437             config,
    438             session_ref,
    439             MYC_NIP46_SESSION_SECRET_SERVICE,
    440         ) {
    441             Ok(Some(secret)) if secret.trim().is_empty() => {
    442                 reasons.push(format!(
    443                     "signer.remote_nip46 signer_session_ref `{session_ref}` resolved to an empty client secret"
    444                 ));
    445             }
    446             Ok(Some(_)) => {}
    447             Ok(None) => {
    448                 reasons.push(format!(
    449                     "signer.remote_nip46 signer_session_ref `{session_ref}` was not found in the account secret backend"
    450                 ));
    451             }
    452             Err(error) => reasons.push(error.to_string()),
    453         }
    454     } else {
    455         reasons.push("signer.remote_nip46 signer_session_ref is missing".to_owned());
    456     }
    457 
    458     let ready = reasons.is_empty();
    459     let reason = reasons.first().cloned();
    460     let source = binding.source.as_str().to_owned();
    461     MycBindingReadiness {
    462         binding: SignerBindingStatusView {
    463             capability_id: binding.capability_id.clone(),
    464             provider_runtime_id: binding.provider_runtime_id.clone(),
    465             binding_model: binding.binding_model.clone(),
    466             state: if ready { "ready" } else { "unconfigured" }.to_owned(),
    467             source: source.clone(),
    468             target_kind: Some(binding.target_kind.as_str().to_owned()),
    469             target: Some(binding.target.clone()),
    470             managed_account_ref: binding.managed_account_ref.clone(),
    471             signer_session_ref: binding.signer_session_ref.clone(),
    472             resolved_session_ref: binding.signer_session_ref.clone().filter(|_| ready),
    473             matched_session_count: Some(usize::from(ready)),
    474             reason: reason.clone(),
    475         },
    476         ready,
    477         source,
    478         reason,
    479         reasons,
    480         signer_session_ref,
    481     }
    482 }
    483 
    484 fn missing_myc_binding_status(reason: String) -> SignerBindingStatusView {
    485     SignerBindingStatusView {
    486         capability_id: SIGNER_REMOTE_NIP46_CAPABILITY.to_owned(),
    487         provider_runtime_id: SIGNER_BINDING_PROVIDER_RUNTIME_ID.to_owned(),
    488         binding_model: SIGNER_BINDING_MODEL.to_owned(),
    489         state: "unconfigured".to_owned(),
    490         source: "no explicit capability binding".to_owned(),
    491         target_kind: None,
    492         target: None,
    493         managed_account_ref: None,
    494         signer_session_ref: None,
    495         resolved_session_ref: None,
    496         matched_session_count: Some(0),
    497         reason: Some(reason),
    498     }
    499 }
    500 
    501 fn validate_myc_target(value: &str) -> Result<(), String> {
    502     let trimmed = value.trim();
    503     if trimmed.starts_with("nostrconnect://") {
    504         return Err(
    505             "signer.remote_nip46 target must be a bunker URI or discovery URL; raw nostrconnect client URIs are signer-side only"
    506                 .to_owned(),
    507         );
    508     }
    509     let bunker_uri = if trimmed.starts_with("bunker://") {
    510         trimmed.to_owned()
    511     } else {
    512         let url = Url::parse(trimmed)
    513             .map_err(|error| format!("signer.remote_nip46 target is invalid: {error}"))?;
    514         url.query_pairs()
    515             .find(|(key, _)| key == "uri")
    516             .map(|(_, uri)| uri.into_owned())
    517             .ok_or_else(|| {
    518                 "signer.remote_nip46 discovery target is missing `uri` query parameter".to_owned()
    519             })?
    520     };
    521     match radroots_nostr_connect::prelude::RadrootsNostrConnectUri::parse(bunker_uri.as_str())
    522         .map_err(|error| format!("signer.remote_nip46 target is invalid: {error}"))?
    523     {
    524         radroots_nostr_connect::prelude::RadrootsNostrConnectUri::Bunker(_) => Ok(()),
    525         radroots_nostr_connect::prelude::RadrootsNostrConnectUri::Client(_) => Err(
    526             "signer.remote_nip46 target must resolve to a bunker URI; raw nostrconnect client URIs are signer-side only"
    527                 .to_owned(),
    528         ),
    529     }
    530 }
    531 
    532 fn myc_write_kind_readiness(
    533     ready: bool,
    534     reason: Option<String>,
    535 ) -> Vec<SignerWriteKindReadinessView> {
    536     myc_write_kind_readiness_for_permissions(ready, reason, sdk_myc_nip46_product_permissions())
    537 }
    538 
    539 fn sdk_myc_nip46_product_permissions() -> Result<RadrootsNostrConnectPermissions, String> {
    540     RadrootsNostrConnectPermissions::from_str(
    541         radroots_sdk_myc_nip46_product_permission_strings()
    542             .join(",")
    543             .as_str(),
    544     )
    545     .map_err(|error| format!("SDK Myc signer permissions are invalid: {error}"))
    546 }
    547 
    548 fn myc_write_kind_readiness_for_permissions(
    549     ready: bool,
    550     reason: Option<String>,
    551     permissions: Result<RadrootsNostrConnectPermissions, String>,
    552 ) -> Vec<SignerWriteKindReadinessView> {
    553     let permissions = match permissions {
    554         Ok(permissions) => permissions,
    555         Err(error) => {
    556             return cli_write_kinds()
    557                 .iter()
    558                 .map(|kind| SignerWriteKindReadinessView {
    559                     command: kind.command.to_owned(),
    560                     event_kind: kind.event_kind,
    561                     permission: sign_event_permission_for_kind(kind.event_kind),
    562                     ready: false,
    563                     reason: Some(error.clone()),
    564                 })
    565                 .collect();
    566         }
    567     };
    568     cli_write_kinds()
    569         .iter()
    570         .map(|kind| {
    571             let permission = sign_event_permission_for_kind(kind.event_kind);
    572             let permission_ready = ready && permissions.allows_sign_event_kind(kind.event_kind);
    573             SignerWriteKindReadinessView {
    574                 command: kind.command.to_owned(),
    575                 event_kind: kind.event_kind,
    576                 permission,
    577                 ready: permission_ready,
    578                 reason: if permission_ready {
    579                     None
    580                 } else {
    581                     reason.clone().or_else(|| {
    582                         Some(
    583                             "SDK Myc signer permission is not configured for this event kind"
    584                                 .to_owned(),
    585                         )
    586                     })
    587                 },
    588             }
    589         })
    590         .collect()
    591 }
    592 
    593 fn sign_event_permission_for_kind(event_kind: u32) -> String {
    594     format!("sign_event:{event_kind}")
    595 }
    596 
    597 #[cfg(test)]
    598 mod tests {
    599     use super::{
    600         KIND_FARM, KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST,
    601         KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_PROFILE, cli_write_kinds,
    602         myc_managed_account_ref_matches, myc_write_kind_readiness,
    603         myc_write_kind_readiness_for_permissions, sign_event_permission_for_kind,
    604     };
    605     use radroots_nostr_connect::prelude::{
    606         RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions,
    607     };
    608 
    609     const RESERVED_ORDER_KIND_3431: u32 = 3431;
    610 
    611     #[test]
    612     fn write_kind_readiness_matches_active_signed_mutations() {
    613         let commands: Vec<&str> = cli_write_kinds()
    614             .iter()
    615             .map(|write_kind| write_kind.command)
    616             .collect();
    617 
    618         assert_eq!(
    619             commands,
    620             [
    621                 "sync.push",
    622                 "farm.publish",
    623                 "listing.publish",
    624                 "listing.update",
    625                 "listing.archive",
    626                 "order.submit",
    627                 "order.accept",
    628                 "order.decline",
    629                 "order.cancel",
    630                 "order.revision.propose",
    631                 "order.revision.accept",
    632                 "order.revision.decline",
    633             ]
    634         );
    635         assert!(!commands.contains(&"signer.status.get"));
    636     }
    637 
    638     #[test]
    639     fn order_submit_readiness_uses_active_order_request_kind() {
    640         let write_kind = cli_write_kinds()
    641             .into_iter()
    642             .find(|kind| kind.command == "order.submit")
    643             .expect("order submit readiness");
    644 
    645         assert_eq!(write_kind.event_kind, KIND_ORDER_REQUEST);
    646         assert_ne!(write_kind.event_kind, RESERVED_ORDER_KIND_3431);
    647     }
    648 
    649     #[test]
    650     fn order_decision_readiness_uses_active_order_decision_kind() {
    651         for command in ["order.accept", "order.decline"] {
    652             let write_kind = cli_write_kinds()
    653                 .into_iter()
    654                 .find(|kind| kind.command == command)
    655                 .expect("order decision readiness");
    656 
    657             assert_eq!(write_kind.event_kind, KIND_ORDER_DECISION);
    658             assert_ne!(write_kind.event_kind, RESERVED_ORDER_KIND_3431);
    659         }
    660     }
    661 
    662     #[test]
    663     fn order_revision_readiness_uses_active_revision_kinds() {
    664         let proposal = cli_write_kinds()
    665             .into_iter()
    666             .find(|kind| kind.command == "order.revision.propose")
    667             .expect("order revision propose readiness");
    668 
    669         assert_eq!(proposal.event_kind, KIND_ORDER_REVISION_PROPOSAL);
    670         assert_ne!(proposal.event_kind, RESERVED_ORDER_KIND_3431);
    671 
    672         for command in ["order.revision.accept", "order.revision.decline"] {
    673             let write_kind = cli_write_kinds()
    674                 .into_iter()
    675                 .find(|kind| kind.command == command)
    676                 .expect("order revision decision readiness");
    677 
    678             assert_eq!(write_kind.event_kind, KIND_ORDER_REVISION_DECISION);
    679             assert_ne!(write_kind.event_kind, RESERVED_ORDER_KIND_3431);
    680         }
    681     }
    682 
    683     #[test]
    684     fn order_cancel_readiness_uses_order_cancellation_kind() {
    685         let cancel = cli_write_kinds()
    686             .into_iter()
    687             .find(|kind| kind.command == "order.cancel")
    688             .expect("order cancel readiness");
    689         assert_eq!(cancel.event_kind, KIND_ORDER_CANCELLATION);
    690         assert_ne!(cancel.event_kind, RESERVED_ORDER_KIND_3431);
    691     }
    692 
    693     #[test]
    694     fn myc_write_readiness_requires_exact_permissions() {
    695         let readiness = myc_write_kind_readiness(true, None);
    696         let sync = readiness
    697             .iter()
    698             .find(|kind| kind.command == "sync.push")
    699             .expect("sync readiness");
    700 
    701         assert_eq!(sync.event_kind, KIND_PROFILE);
    702         assert_eq!(sync.permission, "sign_event:0");
    703         assert!(!sync.ready);
    704         assert_eq!(
    705             sync.reason.as_deref(),
    706             Some("SDK Myc signer permission is not configured for this event kind")
    707         );
    708 
    709         for (command, event_kind) in [
    710             ("farm.publish", KIND_FARM),
    711             ("listing.publish", KIND_LISTING),
    712             ("order.submit", KIND_ORDER_REQUEST),
    713         ] {
    714             let entry = readiness
    715                 .iter()
    716                 .find(|kind| kind.command == command)
    717                 .expect("product write readiness");
    718 
    719             assert_eq!(entry.permission, sign_event_permission_for_kind(event_kind));
    720             assert!(entry.ready, "{command} should be ready");
    721             assert_eq!(entry.reason, None);
    722         }
    723     }
    724 
    725     #[test]
    726     fn myc_write_readiness_uses_typed_kind_permissions() {
    727         let readiness = myc_write_kind_readiness_for_permissions(
    728             true,
    729             None,
    730             Ok(RadrootsNostrConnectPermissions::from(vec![
    731                 RadrootsNostrConnectPermission::with_parameter(
    732                     RadrootsNostrConnectMethod::SignEvent,
    733                     format!("kind:{KIND_LISTING}"),
    734                 ),
    735             ])),
    736         );
    737         let listing = readiness
    738             .iter()
    739             .find(|kind| kind.command == "listing.publish")
    740             .expect("listing readiness");
    741         let farm = readiness
    742             .iter()
    743             .find(|kind| kind.command == "farm.publish")
    744             .expect("farm readiness");
    745 
    746         assert!(listing.ready);
    747         assert!(!farm.ready);
    748     }
    749 
    750     #[test]
    751     fn myc_managed_account_ref_matches_actor_account_id_or_pubkey() {
    752         let actor_account_id = Some("acct_farmer_market");
    753         let actor_pubkey = "02d67b520cb0b835a5ca6ddf78bf3bbfe636d31a523050efc01bf8cb0c680da09e";
    754 
    755         assert!(myc_managed_account_ref_matches(
    756             "acct_farmer_market",
    757             actor_account_id,
    758             actor_pubkey,
    759         ));
    760         assert!(myc_managed_account_ref_matches(
    761             actor_pubkey,
    762             actor_account_id,
    763             actor_pubkey,
    764         ));
    765         assert!(!myc_managed_account_ref_matches(
    766             "acct_other",
    767             actor_account_id,
    768             actor_pubkey,
    769         ));
    770     }
    771 }