cli

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

sdk.rs (56225B)


      1 #![allow(dead_code)]
      2 
      3 use std::fs;
      4 use std::future::Future;
      5 use std::path::PathBuf;
      6 use std::sync::Arc;
      7 use std::time::Duration;
      8 
      9 use radroots_authority::RadrootsLocalEventSigner;
     10 use radroots_identity::RadrootsIdentity;
     11 use radroots_nostr::prelude::{
     12     RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrKeys,
     13     RadrootsNostrKind, RadrootsNostrRelayPoolNotification, RadrootsNostrTimestamp,
     14     radroots_nostr_filter_tag,
     15 };
     16 use radroots_nostr_connect::prelude::{
     17     RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectBunkerUri,
     18     RadrootsNostrConnectClientTarget, RadrootsNostrConnectError, RadrootsNostrConnectUri,
     19 };
     20 use radroots_sdk::{
     21     RadrootsSdk, RadrootsSdkBuilder, RadrootsSdkError, RadrootsSdkLocalKeySigner,
     22     RadrootsSdkMycNip46RequestPolicy, RadrootsSdkMycNip46Signer, RadrootsSdkNip46Transport,
     23     RadrootsSdkNip46TransportFuture, RadrootsSdkSignerProvider, RadrootsSdkStorageConfig,
     24     SdkPublishTransport, SdkRelayUrlPolicy,
     25     adapters::radrootsd::{RadrootsdAuth, RadrootsdProxyConfig as SdkRadrootsdProxyConfig},
     26 };
     27 use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultOsKeyring};
     28 use tokio::runtime::{Builder as TokioRuntimeBuilder, Runtime};
     29 use tokio::sync::{Mutex, broadcast};
     30 use tokio::time::{Instant, timeout};
     31 use url::Url;
     32 
     33 use crate::runtime::RuntimeError;
     34 use crate::runtime::account;
     35 use crate::runtime::config::{
     36     CapabilityBindingTargetKind, PublishTransport, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY,
     37     SignerBackend,
     38 };
     39 
     40 const SDK_STORAGE_DIR_NAME: &str = "sdk";
     41 const RADROOTSD_PROXY_SECRET_SERVICE: &str = "org.radroots.cli.radrootsd-proxy";
     42 pub(crate) const MYC_NIP46_SESSION_SECRET_SERVICE: &str = "org.radroots.cli.myc-nip46-session";
     43 
     44 #[derive(Debug, thiserror::Error)]
     45 pub enum CliSdkAdapterError {
     46     #[error("{0}")]
     47     Runtime(#[from] RuntimeError),
     48     #[error("{0}")]
     49     Sdk(#[from] RadrootsSdkError),
     50 }
     51 
     52 #[derive(Debug, Clone, PartialEq, Eq)]
     53 pub struct CliSdkConfig {
     54     pub storage_root: PathBuf,
     55     pub relay_url_policy: SdkRelayUrlPolicy,
     56     pub relay_urls: Vec<String>,
     57     pub publish_transport: SdkPublishTransport,
     58 }
     59 
     60 impl CliSdkConfig {
     61     pub fn from_runtime_config(config: &RuntimeConfig) -> Result<Self, RuntimeError> {
     62         Ok(Self {
     63             storage_root: sdk_storage_root(config),
     64             relay_url_policy: sdk_relay_url_policy(config),
     65             relay_urls: config.relay.urls.clone(),
     66             publish_transport: sdk_publish_transport(config)?,
     67         })
     68     }
     69 
     70     pub fn builder(&self) -> RadrootsSdkBuilder {
     71         self.relay_urls.iter().fold(
     72             RadrootsSdk::builder()
     73                 .storage(RadrootsSdkStorageConfig::Directory(
     74                     self.storage_root.clone(),
     75                 ))
     76                 .relay_url_policy(self.relay_url_policy)
     77                 .publish_transport(self.publish_transport.clone()),
     78             |builder, relay_url| builder.relay_url(relay_url.clone()),
     79         )
     80     }
     81 }
     82 
     83 pub struct CliSdkSession {
     84     runtime: Runtime,
     85     sdk: RadrootsSdk,
     86     config: CliSdkConfig,
     87 }
     88 
     89 impl CliSdkSession {
     90     pub fn connect(config: &RuntimeConfig) -> Result<Self, CliSdkAdapterError> {
     91         let sdk_config = CliSdkConfig::from_runtime_config(config)?;
     92         let runtime = sdk_runtime()?;
     93         let sdk = runtime.block_on(sdk_config.builder().build())?;
     94         Ok(Self {
     95             runtime,
     96             sdk,
     97             config: sdk_config,
     98         })
     99     }
    100 
    101     pub fn connect_memory(config: &RuntimeConfig) -> Result<Self, CliSdkAdapterError> {
    102         let sdk_config = CliSdkConfig::from_runtime_config(config)?;
    103         let runtime = sdk_runtime()?;
    104         let sdk = runtime.block_on(memory_builder(&sdk_config).build())?;
    105         Ok(Self {
    106             runtime,
    107             sdk,
    108             config: sdk_config,
    109         })
    110     }
    111 
    112     pub fn connect_for_actor(
    113         config: &RuntimeConfig,
    114         actor_account_id: Option<&str>,
    115         actor_pubkey: &str,
    116         actor_label: &str,
    117     ) -> Result<Self, CliSdkAdapterError> {
    118         let sdk_config = CliSdkConfig::from_runtime_config(config)?;
    119         let signer_input =
    120             configured_signer_input(config, actor_account_id, actor_pubkey, actor_label)?;
    121         let runtime = sdk_runtime()?;
    122         let signer_provider = runtime.block_on(signer_provider(config, signer_input))?;
    123         let sdk = runtime.block_on(
    124             sdk_config
    125                 .builder()
    126                 .signer_provider(signer_provider)
    127                 .build(),
    128         )?;
    129         Ok(Self {
    130             runtime,
    131             sdk,
    132             config: sdk_config,
    133         })
    134     }
    135 
    136     pub fn connect_memory_for_actor(
    137         config: &RuntimeConfig,
    138         actor_account_id: Option<&str>,
    139         actor_pubkey: &str,
    140         actor_label: &str,
    141     ) -> Result<Self, CliSdkAdapterError> {
    142         let sdk_config = CliSdkConfig::from_runtime_config(config)?;
    143         let signer_input =
    144             configured_signer_input(config, actor_account_id, actor_pubkey, actor_label)?;
    145         let runtime = sdk_runtime()?;
    146         let signer_provider = runtime.block_on(signer_provider(config, signer_input))?;
    147         let sdk = runtime.block_on(
    148             memory_builder(&sdk_config)
    149                 .signer_provider(signer_provider)
    150                 .build(),
    151         )?;
    152         Ok(Self {
    153             runtime,
    154             sdk,
    155             config: sdk_config,
    156         })
    157     }
    158 
    159     pub fn sdk(&self) -> &RadrootsSdk {
    160         &self.sdk
    161     }
    162 
    163     pub fn config(&self) -> &CliSdkConfig {
    164         &self.config
    165     }
    166 
    167     pub fn block_on<F>(&self, future: F) -> F::Output
    168     where
    169         F: Future,
    170     {
    171         self.runtime.block_on(future)
    172     }
    173 }
    174 
    175 pub fn validate_configured_signer_for_actor(
    176     config: &RuntimeConfig,
    177     actor_account_id: Option<&str>,
    178     actor_pubkey: &str,
    179     actor_label: &str,
    180 ) -> Result<(), RuntimeError> {
    181     configured_signer_input(config, actor_account_id, actor_pubkey, actor_label).map(|_| ())
    182 }
    183 
    184 pub struct CliSdkLocalSigner {
    185     account_id: String,
    186     public_key_hex: String,
    187     signer: RadrootsLocalEventSigner,
    188 }
    189 
    190 impl CliSdkLocalSigner {
    191     pub fn from_runtime_config(config: &RuntimeConfig) -> Result<Self, RuntimeError> {
    192         let signing = account::resolve_local_signing_identity(config)?;
    193         let account_id = signing.account.record.account_id.to_string();
    194         let public_key_hex = signing
    195             .account
    196             .record
    197             .public_identity
    198             .public_key_hex
    199             .clone();
    200         let keys: RadrootsNostrKeys = signing.identity.into_keys();
    201         let signer = RadrootsLocalEventSigner::new(keys)
    202             .map_err(|error| RuntimeError::Config(error.to_string()))?;
    203         Ok(Self {
    204             account_id,
    205             public_key_hex,
    206             signer,
    207         })
    208     }
    209 
    210     pub fn account_id(&self) -> &str {
    211         self.account_id.as_str()
    212     }
    213 
    214     pub fn public_key_hex(&self) -> &str {
    215         self.public_key_hex.as_str()
    216     }
    217 
    218     pub fn signer(&self) -> &RadrootsLocalEventSigner {
    219         &self.signer
    220     }
    221 }
    222 
    223 enum CliSdkSignerInput {
    224     LocalKey(RadrootsNostrKeys),
    225     MycNip46 {
    226         client_keys: RadrootsNostrKeys,
    227         target: RadrootsNostrConnectClientTarget,
    228         actor_pubkey: String,
    229     },
    230 }
    231 
    232 fn configured_signer_input(
    233     config: &RuntimeConfig,
    234     actor_account_id: Option<&str>,
    235     actor_pubkey: &str,
    236     actor_label: &str,
    237 ) -> Result<CliSdkSignerInput, RuntimeError> {
    238     match config.signer.backend {
    239         SignerBackend::Local => {
    240             let keys = local_key_signer_input(config, actor_account_id, actor_pubkey, actor_label)?;
    241             Ok(CliSdkSignerInput::LocalKey(keys))
    242         }
    243         SignerBackend::Myc => myc_nip46_signer_input(config, actor_account_id, actor_pubkey),
    244     }
    245 }
    246 
    247 fn local_key_signer_input(
    248     config: &RuntimeConfig,
    249     actor_account_id: Option<&str>,
    250     actor_pubkey: &str,
    251     actor_label: &str,
    252 ) -> Result<RadrootsNostrKeys, RuntimeError> {
    253     let signing = match actor_account_id {
    254         Some(account_id) => {
    255             account::resolve_local_signing_identity_for_account(config, account_id)?
    256         }
    257         None => account::resolve_local_signing_identity(config)?,
    258     };
    259     let signer_pubkey = signing
    260         .account
    261         .record
    262         .public_identity
    263         .public_key_hex
    264         .as_str();
    265     if !signer_pubkey.eq_ignore_ascii_case(actor_pubkey) {
    266         return Err(account::AccountRuntimeFailure::mismatch(format!(
    267             "{actor_label} public key `{actor_pubkey}` does not match local signer account `{}` public key `{signer_pubkey}`",
    268             signing.account.record.account_id
    269         ))
    270         .into());
    271     }
    272     Ok(signing.identity.into_keys())
    273 }
    274 
    275 fn myc_nip46_signer_input(
    276     config: &RuntimeConfig,
    277     actor_account_id: Option<&str>,
    278     actor_pubkey: &str,
    279 ) -> Result<CliSdkSignerInput, RuntimeError> {
    280     let binding = config
    281         .capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY)
    282         .ok_or_else(|| RuntimeError::Config("signer.remote_nip46 binding is missing".to_owned()))?;
    283     if binding.target_kind != CapabilityBindingTargetKind::ExplicitEndpoint {
    284         return Err(RuntimeError::Config(format!(
    285             "signer.remote_nip46 binding target_kind `{}` is not supported for CLI Myc signing; use `explicit_endpoint`",
    286             binding.target_kind.as_str()
    287         )));
    288     }
    289     if let Some(managed_account_ref) = binding.managed_account_ref.as_deref() {
    290         if !myc_managed_account_ref_matches(managed_account_ref, actor_account_id, actor_pubkey) {
    291             return Err(RuntimeError::Config(format!(
    292                 "signer.remote_nip46 managed_account_ref `{managed_account_ref}` does not match actor account or pubkey"
    293             )));
    294         }
    295     }
    296     let signer_session_ref = binding.signer_session_ref.as_deref().ok_or_else(|| {
    297         RuntimeError::Config("signer.remote_nip46 signer_session_ref is missing".to_owned())
    298     })?;
    299     let secret =
    300         account::load_secret_backend_secret(config, signer_session_ref, MYC_NIP46_SESSION_SECRET_SERVICE)?
    301             .ok_or_else(|| {
    302                 RuntimeError::Config(format!(
    303                     "signer.remote_nip46 signer_session_ref `{signer_session_ref}` was not found in the account secret backend"
    304                 ))
    305             })?;
    306     let client_keys = RadrootsIdentity::from_secret_key_str(secret.trim())
    307         .map_err(|error| {
    308             RuntimeError::Config(format!(
    309                 "signer.remote_nip46 signer_session_ref `{signer_session_ref}` contains invalid client secret key material: {error}"
    310             ))
    311         })?
    312         .into_keys();
    313     let bunker = parse_myc_nip46_target(binding.target.as_str())?;
    314     let target =
    315         RadrootsNostrConnectClientTarget::new(bunker.remote_signer_public_key, bunker.relays);
    316     Ok(CliSdkSignerInput::MycNip46 {
    317         client_keys,
    318         target,
    319         actor_pubkey: actor_pubkey.to_owned(),
    320     })
    321 }
    322 
    323 pub(crate) fn myc_managed_account_ref_matches(
    324     managed_account_ref: &str,
    325     actor_account_id: Option<&str>,
    326     actor_pubkey: &str,
    327 ) -> bool {
    328     actor_account_id.is_some_and(|account_id| managed_account_ref == account_id)
    329         || managed_account_ref == actor_pubkey
    330 }
    331 
    332 async fn signer_provider(
    333     config: &RuntimeConfig,
    334     signer_input: CliSdkSignerInput,
    335 ) -> Result<RadrootsSdkSignerProvider, RuntimeError> {
    336     match signer_input {
    337         CliSdkSignerInput::LocalKey(keys) => {
    338             let signer = RadrootsSdkLocalKeySigner::new(keys)
    339                 .map_err(|error| RuntimeError::Config(error.to_string()))?;
    340             Ok(RadrootsSdkSignerProvider::LocalKey(signer))
    341         }
    342         CliSdkSignerInput::MycNip46 {
    343             client_keys,
    344             target,
    345             actor_pubkey,
    346         } => {
    347             let request_policy = myc_nip46_request_policy(config)?;
    348             let request_timeout = request_policy.request_timeout();
    349             let transport = Arc::new(
    350                 CliSdkNip46RelayTransport::connect(&client_keys, &target, request_timeout).await?,
    351             );
    352             let signer = RadrootsSdkMycNip46Signer::new_with_request_policy(
    353                 client_keys,
    354                 target,
    355                 actor_pubkey,
    356                 transport,
    357                 request_policy,
    358             )
    359             .map_err(|error| RuntimeError::Config(error.to_string()))?;
    360             Ok(RadrootsSdkSignerProvider::MycNip46(signer))
    361         }
    362     }
    363 }
    364 
    365 fn myc_nip46_request_policy(
    366     config: &RuntimeConfig,
    367 ) -> Result<RadrootsSdkMycNip46RequestPolicy, RuntimeError> {
    368     RadrootsSdkMycNip46RequestPolicy::new(Duration::from_millis(config.myc.status_timeout_ms))
    369         .map_err(|error| RuntimeError::Config(error.to_string()))
    370 }
    371 
    372 fn parse_myc_nip46_target(value: &str) -> Result<RadrootsNostrConnectBunkerUri, RuntimeError> {
    373     let trimmed = value.trim();
    374     if trimmed.starts_with("nostrconnect://") {
    375         return Err(RuntimeError::Config(
    376             "signer.remote_nip46 target must be a bunker URI or discovery URL; raw nostrconnect client URIs are signer-side only"
    377                 .to_owned(),
    378         ));
    379     }
    380     let bunker_uri = if trimmed.starts_with("bunker://") {
    381         trimmed.to_owned()
    382     } else {
    383         let url = Url::parse(trimmed).map_err(|error| {
    384             RuntimeError::Config(format!("signer.remote_nip46 target is invalid: {error}"))
    385         })?;
    386         url.query_pairs()
    387             .find(|(key, _)| key == "uri")
    388             .map(|(_, uri)| uri.into_owned())
    389             .ok_or_else(|| {
    390                 RuntimeError::Config(
    391                     "signer.remote_nip46 discovery target is missing `uri` query parameter"
    392                         .to_owned(),
    393                 )
    394             })?
    395     };
    396     match RadrootsNostrConnectUri::parse(bunker_uri.as_str()).map_err(|error| {
    397         RuntimeError::Config(format!("signer.remote_nip46 target is invalid: {error}"))
    398     })? {
    399         RadrootsNostrConnectUri::Bunker(bunker) => Ok(bunker),
    400         RadrootsNostrConnectUri::Client(_) => Err(RuntimeError::Config(
    401             "signer.remote_nip46 target must resolve to a bunker URI; raw nostrconnect client URIs are signer-side only"
    402                 .to_owned(),
    403         )),
    404     }
    405 }
    406 
    407 struct CliSdkNip46RelayTransport {
    408     client: RadrootsNostrClient,
    409     notifications: Mutex<broadcast::Receiver<RadrootsNostrRelayPoolNotification>>,
    410     request_timeout: Duration,
    411     deadline: Mutex<Option<Instant>>,
    412 }
    413 
    414 impl CliSdkNip46RelayTransport {
    415     async fn connect(
    416         client_keys: &RadrootsNostrKeys,
    417         target: &RadrootsNostrConnectClientTarget,
    418         request_timeout: Duration,
    419     ) -> Result<Self, RuntimeError> {
    420         if request_timeout.is_zero() {
    421             return Err(RuntimeError::Config(
    422                 "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS must be greater than zero".to_owned(),
    423             ));
    424         }
    425         let client = RadrootsNostrClient::new_signerless();
    426         for relay in &target.relays {
    427             client.add_relay(relay.as_str()).await.map_err(|error| {
    428                 RuntimeError::Network(format!(
    429                     "failed to add signer.remote_nip46 relay `{relay}`: {error}"
    430                 ))
    431             })?;
    432         }
    433         let connect_output = client.try_connect(request_timeout).await;
    434         if connect_output.success.is_empty() {
    435             let failures = connect_output
    436                 .failed
    437                 .iter()
    438                 .map(|(relay, error)| format!("{relay}: {error}"))
    439                 .collect::<Vec<_>>()
    440                 .join("; ");
    441             return Err(RuntimeError::Network(if failures.is_empty() {
    442                 "failed to connect to signer.remote_nip46 relays".to_owned()
    443             } else {
    444                 format!("failed to connect to signer.remote_nip46 relays: {failures}")
    445             }));
    446         }
    447         let filter = radroots_nostr_filter_tag(
    448             RadrootsNostrFilter::new()
    449                 .kind(RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND))
    450                 .since(RadrootsNostrTimestamp::now()),
    451             "p",
    452             vec![client_keys.public_key().to_hex()],
    453         )
    454         .map_err(|error| {
    455             RuntimeError::Config(format!(
    456                 "failed to build signer.remote_nip46 filter: {error}"
    457             ))
    458         })?;
    459         let notifications = client.notifications();
    460         let subscribe_output = client.subscribe(filter, None).await.map_err(|error| {
    461             RuntimeError::Network(format!(
    462                 "failed to subscribe to signer.remote_nip46 response relays: {error}"
    463             ))
    464         })?;
    465         validate_myc_response_subscription_acceptance(
    466             subscribe_output.success.len(),
    467             subscribe_output
    468                 .failed
    469                 .iter()
    470                 .map(|(relay, error)| (relay.to_string(), error.to_owned())),
    471         )?;
    472         Ok(Self {
    473             client,
    474             notifications: Mutex::new(notifications),
    475             request_timeout,
    476             deadline: Mutex::new(None),
    477         })
    478     }
    479 }
    480 
    481 fn validate_myc_response_subscription_acceptance<I>(
    482     success_count: usize,
    483     failed: I,
    484 ) -> Result<(), RuntimeError>
    485 where
    486     I: IntoIterator<Item = (String, String)>,
    487 {
    488     if success_count > 0 {
    489         return Ok(());
    490     }
    491     let failures = failed
    492         .into_iter()
    493         .map(|(relay, error)| format!("{relay}: {error}"))
    494         .collect::<Vec<_>>()
    495         .join("; ");
    496     Err(RuntimeError::Network(if failures.is_empty() {
    497         "signer.remote_nip46 response subscription was not accepted by any relay".to_owned()
    498     } else {
    499         format!(
    500             "signer.remote_nip46 response subscription was not accepted by any relay: {failures}"
    501         )
    502     }))
    503 }
    504 
    505 impl RadrootsSdkNip46Transport for CliSdkNip46RelayTransport {
    506     fn publish_request_event<'a>(
    507         &'a self,
    508         event: RadrootsNostrEvent,
    509     ) -> RadrootsSdkNip46TransportFuture<'a, ()> {
    510         Box::pin(async move {
    511             *self.deadline.lock().await = Some(Instant::now() + self.request_timeout);
    512             let output = self.client.send_event(&event).await.map_err(|error| {
    513                 RadrootsNostrConnectError::Transport {
    514                     reason: error.to_string(),
    515                 }
    516             })?;
    517             if output.success.is_empty() {
    518                 let failures = output
    519                     .failed
    520                     .iter()
    521                     .map(|(relay, error)| format!("{relay}: {error}"))
    522                     .collect::<Vec<_>>()
    523                     .join("; ");
    524                 return Err(RadrootsNostrConnectError::Transport {
    525                     reason: if failures.is_empty() {
    526                         "signer.remote_nip46 request event was not accepted by any relay".to_owned()
    527                     } else {
    528                         format!(
    529                             "signer.remote_nip46 request event was not accepted by any relay: {failures}"
    530                         )
    531                     },
    532                 });
    533             }
    534             Ok(())
    535         })
    536     }
    537 
    538     fn next_response_event<'a>(
    539         &'a self,
    540     ) -> RadrootsSdkNip46TransportFuture<'a, RadrootsNostrEvent> {
    541         Box::pin(async move {
    542             loop {
    543                 let Some(deadline) = *self.deadline.lock().await else {
    544                     return Err(RadrootsNostrConnectError::Transport {
    545                         reason: "signer.remote_nip46 request deadline is not initialized"
    546                             .to_owned(),
    547                     });
    548                 };
    549                 let now = Instant::now();
    550                 if now >= deadline {
    551                     return Err(RadrootsNostrConnectError::RequestTimedOut);
    552                 }
    553                 let remaining = deadline - now;
    554                 let mut notifications = self.notifications.lock().await;
    555                 let received = timeout(remaining, notifications.recv()).await;
    556                 drop(notifications);
    557                 let notification = match received {
    558                     Ok(Ok(notification)) => notification,
    559                     Ok(Err(broadcast::error::RecvError::Lagged(_))) => continue,
    560                     Ok(Err(broadcast::error::RecvError::Closed)) => {
    561                         return Err(RadrootsNostrConnectError::Transport {
    562                             reason: "signer.remote_nip46 relay notification stream closed"
    563                                 .to_owned(),
    564                         });
    565                     }
    566                     Err(_) => return Err(RadrootsNostrConnectError::RequestTimedOut),
    567                 };
    568                 let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else {
    569                     continue;
    570                 };
    571                 return Ok((*event).clone());
    572             }
    573         })
    574     }
    575 }
    576 
    577 pub fn sdk_storage_root(config: &RuntimeConfig) -> PathBuf {
    578     config.local.root.join(SDK_STORAGE_DIR_NAME)
    579 }
    580 
    581 pub(crate) fn sdk_runtime() -> Result<Runtime, RuntimeError> {
    582     TokioRuntimeBuilder::new_multi_thread()
    583         .enable_all()
    584         .build()
    585         .map_err(|error| {
    586             RuntimeError::Config(format!("failed to initialize SDK async runtime: {error}"))
    587         })
    588 }
    589 
    590 fn memory_builder(config: &CliSdkConfig) -> RadrootsSdkBuilder {
    591     config.relay_urls.iter().fold(
    592         RadrootsSdk::builder()
    593             .relay_url_policy(config.relay_url_policy)
    594             .publish_transport(config.publish_transport.clone()),
    595         |builder, relay_url| builder.relay_url(relay_url.clone()),
    596     )
    597 }
    598 
    599 pub fn sdk_relay_url_policy(config: &RuntimeConfig) -> SdkRelayUrlPolicy {
    600     if config
    601         .relay
    602         .urls
    603         .iter()
    604         .any(|relay_url| relay_url.starts_with("ws://"))
    605     {
    606         SdkRelayUrlPolicy::Localhost
    607     } else {
    608         SdkRelayUrlPolicy::Public
    609     }
    610 }
    611 
    612 pub fn sdk_relay_target_policy(config: &RuntimeConfig) -> radroots_sdk::SdkRelayTargetPolicy {
    613     match config.publish.transport {
    614         PublishTransport::DirectNostrRelay => {
    615             radroots_sdk::SdkRelayTargetPolicy::UseConfiguredRelays
    616         }
    617         PublishTransport::RadrootsdProxy => {
    618             radroots_sdk::SdkRelayTargetPolicy::use_publish_transport()
    619         }
    620     }
    621 }
    622 
    623 fn sdk_publish_transport(config: &RuntimeConfig) -> Result<SdkPublishTransport, RuntimeError> {
    624     match config.publish.transport {
    625         PublishTransport::DirectNostrRelay => Ok(SdkPublishTransport::DirectNostrRelay),
    626         PublishTransport::RadrootsdProxy => {
    627             let mut proxy_config =
    628                 SdkRadrootsdProxyConfig::new(config.publish.radrootsd_proxy.url.clone());
    629             if let Some(auth) = radrootsd_proxy_auth(config)? {
    630                 proxy_config = proxy_config.with_auth(auth);
    631             }
    632             Ok(SdkPublishTransport::RadrootsdProxy(proxy_config))
    633         }
    634     }
    635 }
    636 
    637 fn radrootsd_proxy_auth(config: &RuntimeConfig) -> Result<Option<RadrootsdAuth>, RuntimeError> {
    638     let proxy = &config.publish.radrootsd_proxy;
    639     let token = if let Some(path) = proxy.token_file.as_ref() {
    640         fs::read_to_string(path).map_err(|error| {
    641             RuntimeError::Config(format!(
    642                 "failed to read radrootsd proxy token file {}: {error}",
    643                 path.display()
    644             ))
    645         })?
    646     } else if let Some(secret_id) = proxy.token_secret_id.as_ref() {
    647         let vault = RadrootsSecretVaultOsKeyring::new(RADROOTSD_PROXY_SECRET_SERVICE);
    648         vault
    649             .load_secret(secret_id)
    650             .map_err(|error| {
    651                 RuntimeError::Config(format!(
    652                     "failed to load radrootsd proxy token secret `{secret_id}`: {error}"
    653                 ))
    654             })?
    655             .ok_or_else(|| {
    656                 RuntimeError::Config(format!(
    657                     "radrootsd proxy token secret `{secret_id}` was not found"
    658                 ))
    659             })?
    660     } else {
    661         return Ok(None);
    662     };
    663     let token = token.trim();
    664     if token.is_empty() {
    665         return Err(RuntimeError::Config(
    666             "radrootsd proxy bearer token is empty".to_owned(),
    667         ));
    668     }
    669     Ok(Some(RadrootsdAuth::BearerToken(token.to_owned())))
    670 }
    671 
    672 #[cfg(test)]
    673 mod tests {
    674     use std::collections::BTreeSet;
    675     use std::fs;
    676     use std::path::{Path, PathBuf};
    677     use std::time::Duration;
    678 
    679     use radroots_authority::RadrootsEventSigner;
    680     use radroots_runtime_paths::RadrootsMigrationReport;
    681     use radroots_sdk::{SdkStorageKind, StorageStatusRequest};
    682     use radroots_secret_vault::RadrootsSecretBackend;
    683     use tempfile::tempdir;
    684 
    685     use super::*;
    686     use crate::runtime::config::{
    687         AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig,
    688         LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat,
    689         PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, RelayConfig,
    690         RelayConfigSource, RelayPublishPolicy, RhiConfig, RpcConfig, SignerBackend, SignerConfig,
    691         Verbosity,
    692     };
    693 
    694     struct DirectRrRsDependency {
    695         section: &'static str,
    696         name: &'static str,
    697         owner: &'static str,
    698         reason: &'static str,
    699         lifecycle: &'static str,
    700     }
    701 
    702     struct LegacyDirectRelayConsumer {
    703         path: &'static str,
    704         required_tokens: &'static [&'static str],
    705         owner: &'static str,
    706         reason: &'static str,
    707         lifecycle: &'static str,
    708     }
    709 
    710     struct MigratedCliPathGuard {
    711         label: &'static str,
    712         path: &'static str,
    713         start: &'static str,
    714         end: &'static str,
    715         required_tokens: &'static [&'static str],
    716     }
    717 
    718     const DIRECT_RR_RS_DEPENDENCIES: &[DirectRrRsDependency] = &[
    719         DirectRrRsDependency {
    720             section: "dependencies",
    721             name: "radroots_authority",
    722             owner: "cli-sdk-adapter",
    723             reason: "local account signer materialization for SDK and remaining CLI-authored signing",
    724             lifecycle: "retain until all signed mutation construction moves behind SDK signer requests",
    725         },
    726         DirectRrRsDependency {
    727             section: "dependencies",
    728             name: "radroots_core",
    729             owner: "cli-drafts-and-rendering",
    730             reason: "CLI draft parsing, numeric validation, and display DTOs",
    731             lifecycle: "retain while CLI owns TOML draft UX and command rendering",
    732         },
    733         DirectRrRsDependency {
    734             section: "dependencies",
    735             name: "radroots_events",
    736             owner: "cli-drafts-and-non-migrated-workflows",
    737             reason: "event DTOs for local drafts, views, relay reads, and validation receipt surfaces",
    738             lifecycle: "retain until the remaining event-authoring and inspection surfaces migrate",
    739         },
    740         DirectRrRsDependency {
    741             section: "dependencies",
    742             name: "radroots_events_codec",
    743             owner: "cli-drafts-and-non-migrated-workflows",
    744             reason: "event encoding and decoding for farm, listing draft, order, sync pull, and validation inspection",
    745             lifecycle: "retain until those command families are SDK-backed",
    746         },
    747         DirectRrRsDependency {
    748             section: "dependencies",
    749             name: "radroots_identity",
    750             owner: "cli-account-and-signer-ux",
    751             reason: "account identity views, local signer materialization, and direct-relay workflows outside the migrated paths",
    752             lifecycle: "retain while CLI owns account selection and local identity custody UX",
    753         },
    754         DirectRrRsDependency {
    755             section: "dependencies",
    756             name: "radroots_local_events",
    757             owner: "cli-app-interop",
    758             reason: "shared local work and signed-event interop with the desktop app",
    759             lifecycle: "retain until a shared local-events SDK boundary replaces direct CLI access",
    760         },
    761         DirectRrRsDependency {
    762             section: "dependencies",
    763             name: "radroots_log",
    764             owner: "cli-runtime-shell",
    765             reason: "CLI logging initialization and file layout",
    766             lifecycle: "permanent CLI runtime ownership",
    767         },
    768         DirectRrRsDependency {
    769             section: "dependencies",
    770             name: "radroots_nostr",
    771             owner: "non-migrated-direct-relay-workflows",
    772             reason: "direct relay fetch/publish and event conversion for active non-migrated commands",
    773             lifecycle: "retain until direct relay command families migrate or are retired",
    774         },
    775         DirectRrRsDependency {
    776             section: "dependencies",
    777             name: "radroots_nostr_connect",
    778             owner: "sdk-myc-nip46-transport",
    779             reason: "CLI Myc signer target parsing and NIP-46 relay transport bridge for SDK signing",
    780             lifecycle: "retain while CLI owns signer backend wiring",
    781         },
    782         DirectRrRsDependency {
    783             section: "dependencies",
    784             name: "radroots_nostr_accounts",
    785             owner: "cli-account-store",
    786             reason: "CLI account selection, import, local signer status, and account persistence",
    787             lifecycle: "retain while CLI owns local account UX and storage",
    788         },
    789         DirectRrRsDependency {
    790             section: "dependencies",
    791             name: "radroots_nostr_signer",
    792             owner: "cli-signer-readiness",
    793             reason: "signer readiness reporting for active mutation command surfaces",
    794             lifecycle: "retain until signer readiness is fully SDK-owned",
    795         },
    796         DirectRrRsDependency {
    797             section: "dependencies",
    798             name: "radroots_replica_db",
    799             owner: "legacy-replica-and-market-projection",
    800             reason: "legacy derived replica status, export, market reads, sync pull, basket lookup, and order draft preflight",
    801             lifecycle: "transitional until those derived projection surfaces migrate",
    802         },
    803         DirectRrRsDependency {
    804             section: "dependencies",
    805             name: "radroots_replica_db_schema",
    806             owner: "legacy-replica-and-market-projection",
    807             reason: "typed query filters for legacy market, basket, and order lookup projections",
    808             lifecycle: "transitional until those derived projection surfaces migrate",
    809         },
    810         DirectRrRsDependency {
    811             section: "dependencies",
    812             name: "radroots_replica_sync",
    813             owner: "legacy-sync-pull-and-derived-replica",
    814             reason: "legacy relay ingest, sync pull, market refresh, and derived replica state reporting",
    815             lifecycle: "transitional until relay ingest and projection repair move behind SDK APIs",
    816         },
    817         DirectRrRsDependency {
    818             section: "dependencies",
    819             name: "radroots_runtime",
    820             owner: "cli-config",
    821             reason: "strict environment and config value parsing",
    822             lifecycle: "permanent CLI configuration ownership unless a shared runtime config crate replaces it",
    823         },
    824         DirectRrRsDependency {
    825             section: "dependencies",
    826             name: "radroots_runtime_paths",
    827             owner: "cli-runtime-paths",
    828             reason: "profile-aware CLI config, data, logs, and secrets path resolution",
    829             lifecycle: "permanent CLI runtime ownership",
    830         },
    831         DirectRrRsDependency {
    832             section: "dependencies",
    833             name: "radroots_secret_vault",
    834             owner: "cli-account-store",
    835             reason: "local account secret backend selection and readiness",
    836             lifecycle: "retain while CLI owns local account custody UX",
    837         },
    838         DirectRrRsDependency {
    839             section: "dependencies",
    840             name: "radroots_protected_store",
    841             owner: "cli-account-store",
    842             reason: "protected file secret vault selection for local account and Myc session material",
    843             lifecycle: "retain while CLI owns account and signer session custody UX",
    844         },
    845         DirectRrRsDependency {
    846             section: "dependencies",
    847             name: "radroots_sp1_host_trade",
    848             owner: "validation-receipts",
    849             reason: "validation receipt SP1 proof inspection and verification",
    850             lifecycle: "retain until validation receipt verification moves behind SDK APIs",
    851         },
    852         DirectRrRsDependency {
    853             section: "dependencies",
    854             name: "radroots_sql_core",
    855             owner: "legacy-replica-and-local-events",
    856             reason: "SQLite executor for legacy derived replica and shared local-events storage",
    857             lifecycle: "transitional until those storage surfaces move behind SDK or shared runtime APIs",
    858         },
    859         DirectRrRsDependency {
    860             section: "dependencies",
    861             name: "radroots_trade",
    862             owner: "cli-drafts-and-validation",
    863             reason: "listing draft validation, order economics, order reducer helpers, and validation receipt parsing",
    864             lifecycle: "retain until remaining trade validation and draft behavior migrates",
    865         },
    866     ];
    867 
    868     const LEGACY_DIRECT_RELAY_CONSUMERS: &[LegacyDirectRelayConsumer] = &[
    869         LegacyDirectRelayConsumer {
    870             path: "src/runtime/order.rs",
    871             required_tokens: &[
    872                 "legacy_order_preflight_relay_status",
    873                 "fetch_events_from_relays",
    874             ],
    875             owner: "order.status.relay-read",
    876             reason: "bounded order status and preflight reads still inspect configured relays outside SDK local storage",
    877             lifecycle: "retain until order relay reads migrate to SDK-backed query APIs",
    878         },
    879         LegacyDirectRelayConsumer {
    880             path: "src/runtime/sync.rs",
    881             required_tokens: &["fetch_events_from_relays", "pull_with_fetcher"],
    882             owner: "sync.pull-and-market-refresh",
    883             reason: "non-migrated relay ingest into the legacy derived replica",
    884             lifecycle: "retain until relay ingest and derived projection repair migrate to SDK APIs",
    885         },
    886         LegacyDirectRelayConsumer {
    887             path: "src/runtime/validation_receipt.rs",
    888             required_tokens: &["fetch_events_from_relays", "DirectRelayFetchReceipt"],
    889             owner: "validation.receipt.relay-reads",
    890             reason: "non-migrated validation receipt relay inspection",
    891             lifecycle: "retain until validation receipt inspection migrates to SDK APIs",
    892         },
    893     ];
    894 
    895     const MIGRATED_CLI_PATH_GUARDS: &[MigratedCliPathGuard] = &[
    896         MigratedCliPathGuard {
    897             label: "listing publish",
    898             path: "src/runtime/listing.rs",
    899             start: "pub fn publish_via_sdk(",
    900             end: "fn sdk_listing_publish_input(",
    901             required_tokens: &[
    902                 "session.sdk().listings().prepare_publish",
    903                 "session.sdk().listings().enqueue_publish",
    904                 "session.sdk().sync().push_outbox",
    905             ],
    906         },
    907         MigratedCliPathGuard {
    908             label: "farm publish",
    909             path: "src/runtime/farm.rs",
    910             start: "fn publish_via_sdk(",
    911             end: "#[derive(Debug, Clone)]\nstruct SdkFarmPublishInput",
    912             required_tokens: &[
    913                 "prepare_publish(FarmPreparePublishRequest::new",
    914                 "enqueue_publish(request)",
    915                 "session.sdk().sync().push_outbox",
    916             ],
    917         },
    918         MigratedCliPathGuard {
    919             label: "sync status",
    920             path: "src/runtime/sync.rs",
    921             start: "pub fn status(config: &RuntimeConfig) -> Result<SyncStatusView, CliSdkAdapterError>",
    922             end: "pub fn pull(",
    923             required_tokens: &["session.sdk().sync().status"],
    924         },
    925         MigratedCliPathGuard {
    926             label: "sync push",
    927             path: "src/runtime/sync.rs",
    928             start: "pub fn push(config: &RuntimeConfig) -> Result<SyncActionView, CliSdkAdapterError>",
    929             end: "pub fn watch(",
    930             required_tokens: &["session.sdk().sync().push_outbox", "PushOutboxRequest::new"],
    931         },
    932         MigratedCliPathGuard {
    933             label: "order status",
    934             path: "src/runtime/order.rs",
    935             start: "pub fn status(\n    config: &RuntimeConfig",
    936             end: "fn legacy_order_preflight_relay_status(",
    937             required_tokens: &["OrderStatusRequest::parse", "session.sdk().orders().status"],
    938         },
    939         MigratedCliPathGuard {
    940             label: "order SDK status adapter",
    941             path: "src/runtime/order/sdk_status.rs",
    942             start: "pub(super) fn sdk_order_status_view(",
    943             end: "fn sdk_event_id_string(",
    944             required_tokens: &[
    945                 "OrderStatusReceipt",
    946                 "OrderStatusView",
    947                 "OrderStatusLifecycleView",
    948                 "OrderStatusSdkReceiptView",
    949             ],
    950         },
    951         MigratedCliPathGuard {
    952             label: "order submit",
    953             path: "src/runtime/order.rs",
    954             start: "fn prepare_order_submit_via_sdk(",
    955             end: "fn enqueue_target_relays(",
    956             required_tokens: &[
    957                 "prepare_submit(OrderSubmitPrepareRequest::new",
    958                 "OrderSubmitEnqueueRequest::new",
    959                 "enqueue_submit_with_explicit_signer(request, &signer)",
    960                 "push_outbox(",
    961             ],
    962         },
    963         MigratedCliPathGuard {
    964             label: "order decision",
    965             path: "src/runtime/order.rs",
    966             start: "fn publish_order_decision(",
    967             end: "fn canonical_order_decision_payload(",
    968             required_tokens: &[
    969                 "OrderDecisionEnqueueRequest::new",
    970                 "ingest_request_evidence(OrderRequestEvidenceIngestRequest::new",
    971                 "enqueue_decision_with_explicit_signer(request, &signer)",
    972                 "push_outbox(",
    973             ],
    974         },
    975         MigratedCliPathGuard {
    976             label: "order lifecycle",
    977             path: "src/runtime/order.rs",
    978             start: "fn publish_order_revision(",
    979             end: "fn sdk_order_lifecycle_actor(",
    980             required_tokens: &[
    981                 "prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new",
    982                 "prepare_revision_decision(OrderRevisionDecisionPrepareRequest::new",
    983                 "prepare_cancellation(OrderCancellationPrepareRequest::new",
    984                 "ingest_order_evidence_events(&session, evidence_events)?",
    985                 "enqueue_revision_proposal_with_explicit_signer(request, &signer)",
    986                 "enqueue_revision_decision_with_explicit_signer(request, &signer)",
    987                 "enqueue_cancellation_with_explicit_signer(request, &signer)",
    988                 "push_one_sdk_outbox_event(&session, policy)?",
    989             ],
    990         },
    991         MigratedCliPathGuard {
    992             label: "store status",
    993             path: "src/runtime/store.rs",
    994             start: "pub fn status(config: &RuntimeConfig) -> Result<LocalStatusView, CliSdkAdapterError>",
    995             end: "fn legacy_replica_status(",
    996             required_tokens: &[
    997                 "session.sdk()",
    998                 "storage_status(StorageStatusRequest::new())",
    999                 "integrity(IntegrityRequest::new())",
   1000             ],
   1001         },
   1002         MigratedCliPathGuard {
   1003             label: "store backup",
   1004             path: "src/runtime/store.rs",
   1005             start: "pub fn backup(\n    config: &RuntimeConfig",
   1006             end: "pub fn backup_preflight(",
   1007             required_tokens: &["session.sdk().backup", "BackupRequest"],
   1008         },
   1009         MigratedCliPathGuard {
   1010             label: "store backup preflight",
   1011             path: "src/runtime/store.rs",
   1012             start: "pub fn backup_preflight(",
   1013             end: "pub fn restore(",
   1014             required_tokens: &[
   1015                 "storage_status(StorageStatusRequest::new())",
   1016                 "integrity(IntegrityRequest::new())",
   1017             ],
   1018         },
   1019         MigratedCliPathGuard {
   1020             label: "store restore",
   1021             path: "src/runtime/store.rs",
   1022             start: "pub fn restore(",
   1023             end: "pub fn export(",
   1024             required_tokens: &[
   1025                 "RestoreRequest::new",
   1026                 "sdk_runtime()",
   1027                 "RadrootsSdk::restore",
   1028             ],
   1029         },
   1030     ];
   1031 
   1032     const MIGRATED_PATH_DISALLOWED_TOKENS: &[&str] = &[
   1033         "fetch_events_from_relays",
   1034         "publish_parts_with_identity",
   1035         "publish_via_direct_relay",
   1036         "mutate_via_direct_relay",
   1037         "radroots_replica_pending_publish",
   1038         "radroots_replica_pending_publish_batch",
   1039         "radroots_replica_sync_status",
   1040         "ReplicaSql::new",
   1041         "SqliteExecutor::open(&config.local.replica_db_path)",
   1042         "outbox_idempotency_digest",
   1043         "canonical_target_relays",
   1044     ];
   1045 
   1046     #[test]
   1047     fn maps_runtime_config_to_sdk_builder_inputs() {
   1048         let root = tempdir().expect("tempdir");
   1049         let config = sample_config(
   1050             root.path(),
   1051             vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()],
   1052         );
   1053 
   1054         let sdk_config = CliSdkConfig::from_runtime_config(&config).expect("sdk config");
   1055 
   1056         assert_eq!(sdk_config.storage_root, config.local.root.join("sdk"));
   1057         assert_eq!(sdk_config.relay_url_policy, SdkRelayUrlPolicy::Public);
   1058         assert_eq!(
   1059             sdk_config.relay_urls,
   1060             vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()]
   1061         );
   1062     }
   1063 
   1064     #[test]
   1065     fn maps_localhost_ws_relays_to_localhost_sdk_policy() {
   1066         let root = tempdir().expect("tempdir");
   1067         let config = sample_config(root.path(), vec!["ws://127.0.0.1:8080".to_owned()]);
   1068 
   1069         assert_eq!(sdk_relay_url_policy(&config), SdkRelayUrlPolicy::Localhost);
   1070     }
   1071 
   1072     #[test]
   1073     fn materializes_local_account_signer_for_sdk_workflows() {
   1074         let root = tempdir().expect("tempdir");
   1075         let config = sample_config(root.path(), Vec::new());
   1076         let account = account::create_or_migrate_default_account(&config).expect("create account");
   1077 
   1078         let signer = CliSdkLocalSigner::from_runtime_config(&config).expect("sdk signer");
   1079 
   1080         assert_eq!(
   1081             signer.account_id(),
   1082             account.account.record.account_id.as_str()
   1083         );
   1084         assert_eq!(
   1085             signer.public_key_hex(),
   1086             account.account.record.public_identity.public_key_hex
   1087         );
   1088         assert_eq!(
   1089             signer.signer().pubkey().as_str(),
   1090             account.account.record.public_identity.public_key_hex
   1091         );
   1092     }
   1093 
   1094     #[test]
   1095     fn sdk_session_builds_once_and_runs_async_storage_smoke() {
   1096         let root = tempdir().expect("tempdir");
   1097         let config = sample_config(root.path(), Vec::new());
   1098         let session = CliSdkSession::connect(&config).expect("sdk session");
   1099 
   1100         let status = session
   1101             .block_on(session.sdk().storage_status(StorageStatusRequest::new()))
   1102             .expect("storage status");
   1103 
   1104         assert_eq!(session.config().storage_root, config.local.root.join("sdk"));
   1105         assert_eq!(status.storage, SdkStorageKind::Directory);
   1106         assert_eq!(status.event_store.total_events, 0);
   1107         assert_eq!(status.outbox.total_events, 0);
   1108     }
   1109 
   1110     #[test]
   1111     fn myc_request_policy_uses_cli_timeout_config() {
   1112         let root = tempdir().expect("tempdir");
   1113         let mut config = sample_config(root.path(), Vec::new());
   1114         config.myc.status_timeout_ms = 12_345;
   1115 
   1116         let policy = myc_nip46_request_policy(&config).expect("request policy");
   1117 
   1118         assert_eq!(policy.request_timeout(), Duration::from_millis(12_345));
   1119     }
   1120 
   1121     #[test]
   1122     fn myc_request_policy_rejects_zero_cli_timeout() {
   1123         let root = tempdir().expect("tempdir");
   1124         let mut config = sample_config(root.path(), Vec::new());
   1125         config.myc.status_timeout_ms = 0;
   1126 
   1127         let error = myc_nip46_request_policy(&config).expect_err("zero timeout");
   1128 
   1129         assert!(error.to_string().contains("must be greater than zero"));
   1130     }
   1131 
   1132     #[test]
   1133     fn myc_response_subscription_requires_relay_acceptance() {
   1134         let error = validate_myc_response_subscription_acceptance(
   1135             0,
   1136             [(
   1137                 "ws://127.0.0.1:8080".to_owned(),
   1138                 "subscription rejected".to_owned(),
   1139             )],
   1140         )
   1141         .expect_err("response subscription acceptance");
   1142 
   1143         assert!(
   1144             error
   1145                 .to_string()
   1146                 .contains("response subscription was not accepted by any relay")
   1147         );
   1148         assert!(error.to_string().contains("subscription rejected"));
   1149 
   1150         validate_myc_response_subscription_acceptance(1, std::iter::empty())
   1151             .expect("accepted response subscription");
   1152     }
   1153 
   1154     #[test]
   1155     fn sdk_sources_do_not_import_cli_types() {
   1156         let sdk_src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../sdk/crates/sdk/src");
   1157         let mut files = Vec::new();
   1158         collect_rs_files(sdk_src.as_path(), &mut files);
   1159         let forbidden = [
   1160             ("radroots_cli", "CLI crate identity"),
   1161             ("domains/radroots/cli", "CLI mount path"),
   1162             ("approval_token", "CLI approval-token UX"),
   1163             ("OutputEnvelope", "CLI output envelope"),
   1164             ("next_actions", "CLI next-action rendering"),
   1165             ("exit_code", "CLI exit-code contract"),
   1166             ("docs/", "repository docs path"),
   1167             ("radroots store", "CLI command string"),
   1168             ("radroots sync", "CLI command string"),
   1169             ("radroots listing", "CLI command string"),
   1170             ("radroots order", "CLI command string"),
   1171         ];
   1172 
   1173         for file in files {
   1174             let source = fs::read_to_string(&file).expect("read sdk source");
   1175             for (needle, description) in forbidden {
   1176                 assert!(
   1177                     !source.contains(needle),
   1178                     "SDK source contains {description} `{needle}` in {}",
   1179                     file.display()
   1180                 );
   1181             }
   1182         }
   1183     }
   1184 
   1185     #[test]
   1186     fn cli_direct_rr_rs_dependencies_are_classified() {
   1187         let manifest_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
   1188         let manifest = fs::read_to_string(&manifest_path).expect("read manifest");
   1189         let manifest = manifest.parse::<toml::Value>().expect("parse manifest");
   1190         let actual = direct_rr_rs_dependency_keys(&manifest);
   1191         let expected = DIRECT_RR_RS_DEPENDENCIES
   1192             .iter()
   1193             .map(direct_rr_rs_dependency_key)
   1194             .collect::<BTreeSet<_>>();
   1195 
   1196         assert_eq!(actual, expected);
   1197         for dependency in DIRECT_RR_RS_DEPENDENCIES {
   1198             assert!(!dependency.owner.trim().is_empty());
   1199             assert!(!dependency.reason.trim().is_empty());
   1200             assert!(!dependency.lifecycle.trim().is_empty());
   1201         }
   1202     }
   1203 
   1204     #[test]
   1205     fn legacy_direct_relay_consumers_are_explicitly_allowlisted() {
   1206         let actual = legacy_direct_relay_consumer_paths();
   1207         let expected = LEGACY_DIRECT_RELAY_CONSUMERS
   1208             .iter()
   1209             .map(|consumer| consumer.path.to_owned())
   1210             .collect::<BTreeSet<_>>();
   1211 
   1212         assert_eq!(actual, expected);
   1213         for consumer in LEGACY_DIRECT_RELAY_CONSUMERS {
   1214             let source = crate_source(consumer.path);
   1215             for token in consumer.required_tokens {
   1216                 assert!(
   1217                     source.contains(token),
   1218                     "{} does not contain legacy direct-relay token `{token}`",
   1219                     consumer.path
   1220                 );
   1221             }
   1222             assert!(!consumer.owner.trim().is_empty());
   1223             assert!(!consumer.reason.trim().is_empty());
   1224             assert!(!consumer.lifecycle.trim().is_empty());
   1225         }
   1226     }
   1227 
   1228     #[test]
   1229     fn migrated_cli_paths_are_guarded_against_direct_relay_and_legacy_canonical_use() {
   1230         for guard in MIGRATED_CLI_PATH_GUARDS {
   1231             let source = crate_source(guard.path);
   1232             assert_migrated_path(
   1233                 guard.label,
   1234                 source_segment(&source, guard.start, guard.end),
   1235                 guard.required_tokens,
   1236             );
   1237         }
   1238     }
   1239 
   1240     fn collect_rs_files(dir: &Path, files: &mut Vec<PathBuf>) {
   1241         for entry in fs::read_dir(dir).expect("read dir") {
   1242             let path = entry.expect("entry").path();
   1243             if path.is_dir() {
   1244                 collect_rs_files(path.as_path(), files);
   1245             } else if path.extension().and_then(|extension| extension.to_str()) == Some("rs") {
   1246                 files.push(path);
   1247             }
   1248         }
   1249     }
   1250 
   1251     fn direct_rr_rs_dependency_keys(manifest: &toml::Value) -> BTreeSet<String> {
   1252         ["dependencies", "dev-dependencies"]
   1253             .into_iter()
   1254             .flat_map(|section| {
   1255                 manifest
   1256                     .get(section)
   1257                     .and_then(toml::Value::as_table)
   1258                     .into_iter()
   1259                     .flat_map(move |dependencies| {
   1260                         dependencies.iter().filter_map(move |(name, value)| {
   1261                             dependency_path(value)
   1262                                 .filter(|path| {
   1263                                     path.contains("../lib/crates")
   1264                                         || path.contains("domains/radroots/lib/crates")
   1265                                 })
   1266                                 .map(|_| format!("{section}:{name}"))
   1267                         })
   1268                     })
   1269             })
   1270             .collect()
   1271     }
   1272 
   1273     fn direct_rr_rs_dependency_key(dependency: &DirectRrRsDependency) -> String {
   1274         format!("{}:{}", dependency.section, dependency.name)
   1275     }
   1276 
   1277     fn legacy_direct_relay_consumer_paths() -> BTreeSet<String> {
   1278         let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
   1279         let mut files = Vec::new();
   1280         collect_rs_files(manifest_dir.join("src/runtime").as_path(), &mut files);
   1281         files
   1282             .into_iter()
   1283             .filter(|file| {
   1284                 !matches!(
   1285                     file.file_name().and_then(|name| name.to_str()),
   1286                     Some("direct_relay.rs" | "sdk.rs")
   1287                 )
   1288             })
   1289             .filter_map(|file| {
   1290                 let source = fs::read_to_string(&file).expect("read runtime source");
   1291                 source
   1292                     .contains("use crate::runtime::direct_relay")
   1293                     .then(|| relative_source_path(manifest_dir, file.as_path()))
   1294             })
   1295             .collect()
   1296     }
   1297 
   1298     fn relative_source_path(root: &Path, path: &Path) -> String {
   1299         path.strip_prefix(root)
   1300             .expect("source path under manifest root")
   1301             .to_string_lossy()
   1302             .replace('\\', "/")
   1303     }
   1304 
   1305     fn dependency_path(value: &toml::Value) -> Option<&str> {
   1306         value
   1307             .as_table()
   1308             .and_then(|table| table.get("path"))
   1309             .and_then(toml::Value::as_str)
   1310     }
   1311 
   1312     fn crate_source(path: &str) -> String {
   1313         fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join(path)).expect("read source")
   1314     }
   1315 
   1316     fn source_segment<'a>(source: &'a str, start: &str, end: &str) -> &'a str {
   1317         let start_index = source.find(start).expect("source segment start");
   1318         let end_index = source[start_index..]
   1319             .find(end)
   1320             .map(|index| start_index + index)
   1321             .expect("source segment end");
   1322         &source[start_index..end_index]
   1323     }
   1324 
   1325     fn assert_migrated_path(label: &str, source: &str, required_tokens: &[&str]) {
   1326         for token in required_tokens {
   1327             assert!(
   1328                 source.contains(token),
   1329                 "{label} does not contain required SDK token `{token}`"
   1330             );
   1331         }
   1332 
   1333         for token in MIGRATED_PATH_DISALLOWED_TOKENS {
   1334             assert!(
   1335                 !source.contains(token),
   1336                 "{label} contains disallowed migrated-path token `{token}`"
   1337             );
   1338         }
   1339     }
   1340 
   1341     fn sample_config(root: &Path, relays: Vec<String>) -> RuntimeConfig {
   1342         let data = root.join("data");
   1343         let logs = root.join("logs");
   1344         let secrets = root.join("secrets");
   1345         RuntimeConfig {
   1346             output: OutputConfig {
   1347                 format: OutputFormat::Json,
   1348                 verbosity: Verbosity::Normal,
   1349                 color: false,
   1350                 dry_run: false,
   1351             },
   1352             interaction: InteractionConfig {
   1353                 input_enabled: false,
   1354                 assume_yes: false,
   1355                 stdin_tty: false,
   1356                 stdout_tty: false,
   1357                 prompts_allowed: false,
   1358                 confirmations_allowed: false,
   1359             },
   1360             paths: PathsConfig {
   1361                 profile: "interactive_user".to_owned(),
   1362                 profile_source: "test".to_owned(),
   1363                 allowed_profiles: vec!["interactive_user".to_owned(), "repo_local".to_owned()],
   1364                 root_source: "test".to_owned(),
   1365                 repo_local_root: None,
   1366                 repo_local_root_source: None,
   1367                 subordinate_path_override_source: "runtime_config".to_owned(),
   1368                 app_namespace: "apps/cli".to_owned(),
   1369                 shared_accounts_namespace: "shared/accounts".to_owned(),
   1370                 shared_identities_namespace: "shared/identities".to_owned(),
   1371                 app_config_path: root.join("config/apps/cli/config.toml"),
   1372                 workspace_config_path: None,
   1373                 app_data_root: data.join("apps/cli"),
   1374                 app_logs_root: logs.join("apps/cli"),
   1375                 shared_accounts_data_root: data.join("shared/accounts"),
   1376                 shared_accounts_secrets_root: secrets.join("shared/accounts"),
   1377                 default_identity_path: secrets.join("shared/identities/default.json"),
   1378             },
   1379             migration: MigrationConfig {
   1380                 report: RadrootsMigrationReport::empty(),
   1381             },
   1382             logging: LoggingConfig {
   1383                 filter: "info".to_owned(),
   1384                 directory: None,
   1385                 stdout: false,
   1386             },
   1387             account: AccountConfig {
   1388                 selector: None,
   1389                 store_path: data.join("shared/accounts/store.json"),
   1390                 secrets_dir: secrets.join("shared/accounts"),
   1391                 secret_backend: RadrootsSecretBackend::EncryptedFile,
   1392                 secret_fallback: None,
   1393             },
   1394             account_secret_contract: AccountSecretContractConfig {
   1395                 default_backend: "host_vault".to_owned(),
   1396                 default_fallback: Some("encrypted_file".to_owned()),
   1397                 allowed_backends: vec!["host_vault".to_owned(), "encrypted_file".to_owned()],
   1398                 host_vault_policy: Some("desktop".to_owned()),
   1399                 uses_protected_store: true,
   1400             },
   1401             identity: IdentityConfig {
   1402                 path: secrets.join("shared/identities/default.json"),
   1403             },
   1404             signer: SignerConfig {
   1405                 backend: SignerBackend::Local,
   1406             },
   1407             publish: PublishConfig {
   1408                 transport: PublishTransport::DirectNostrRelay,
   1409                 source: PublishTransportSource::Defaults,
   1410                 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(),
   1411             },
   1412             relay: RelayConfig {
   1413                 urls: relays,
   1414                 publish_policy: RelayPublishPolicy::Any,
   1415                 source: RelayConfigSource::Flags,
   1416             },
   1417             local: LocalConfig {
   1418                 root: data.join("apps/cli/replica"),
   1419                 replica_db_path: data.join("apps/cli/replica/replica.sqlite"),
   1420                 backups_dir: data.join("apps/cli/replica/backups"),
   1421                 exports_dir: data.join("apps/cli/replica/exports"),
   1422             },
   1423             myc: MycConfig {
   1424                 executable: PathBuf::from("myc"),
   1425                 status_timeout_ms: 2_000,
   1426             },
   1427             hyf: HyfConfig {
   1428                 enabled: false,
   1429                 executable: PathBuf::from("hyfd"),
   1430             },
   1431             rpc: RpcConfig {
   1432                 url: "http://127.0.0.1:7070".to_owned(),
   1433             },
   1434             rhi: RhiConfig {
   1435                 trusted_worker_pubkeys: Vec::new(),
   1436             },
   1437             capability_bindings: Vec::new(),
   1438         }
   1439     }
   1440 }