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 }