radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

runtime.rs (35802B)


      1 use anyhow::Result;
      2 use jsonrpsee::server::ServerHandle;
      3 use radroots_identity::RadrootsIdentity;
      4 use std::time::Duration;
      5 use tracing::{info, warn};
      6 
      7 use crate::app::identity_storage::load_service_identity;
      8 use crate::app::{cli, config, paths};
      9 use crate::core::Radrootsd;
     10 use crate::transport::jsonrpc;
     11 #[cfg(not(test))]
     12 use crate::transport::nostr::listener::spawn_nip46_listener;
     13 #[cfg(not(test))]
     14 use anyhow::Context;
     15 #[cfg(not(test))]
     16 use clap::Parser;
     17 use radroots_events::profile::RadrootsProfileType;
     18 use radroots_nostr::prelude::{
     19     RadrootsNostrApplicationHandlerSpec, RadrootsNostrKind,
     20     radroots_nostr_bootstrap_service_presence,
     21 };
     22 use std::path::PathBuf;
     23 
     24 #[cfg(test)]
     25 static RUN_LOAD_HOOK: std::sync::OnceLock<
     26     std::sync::Mutex<Option<Result<(cli::Args, config::Settings), String>>>,
     27 > = std::sync::OnceLock::new();
     28 
     29 #[cfg(test)]
     30 static RUN_BOOTSTRAP_HOOK: std::sync::OnceLock<std::sync::Mutex<Option<Result<(), String>>>> =
     31     std::sync::OnceLock::new();
     32 
     33 #[cfg(test)]
     34 static RUN_WAIT_HOOK: std::sync::OnceLock<std::sync::Mutex<Option<RunWaitOutcome>>> =
     35     std::sync::OnceLock::new();
     36 
     37 #[cfg(test)]
     38 static RUN_START_RPC_HOOK: std::sync::OnceLock<
     39     std::sync::Mutex<Option<Result<ServerHandle, String>>>,
     40 > = std::sync::OnceLock::new();
     41 
     42 #[derive(Clone, Copy)]
     43 enum RunWaitOutcome {
     44     Shutdown,
     45     Stopped,
     46 }
     47 
     48 #[derive(Debug, Clone, PartialEq, Eq)]
     49 struct RadrootsdRuntimeStartupReport {
     50     active_profile: String,
     51     config_path: PathBuf,
     52     config_path_source: String,
     53     canonical_config_path: PathBuf,
     54     logs_dir: PathBuf,
     55     logs_dir_source: String,
     56     canonical_logs_dir: PathBuf,
     57     identity_path: PathBuf,
     58     identity_path_source: String,
     59     canonical_identity_path: PathBuf,
     60     publish_proxy_database_path: PathBuf,
     61     publish_proxy_database_path_source: String,
     62     canonical_publish_proxy_database_path: PathBuf,
     63     path_overrides: paths::RadrootsdRuntimePathOverrideContractOutput,
     64     migration: paths::RadrootsdRuntimeMigrationContractOutput,
     65     default_shared_secret_backend: String,
     66     allowed_shared_secret_backends: Vec<String>,
     67 }
     68 
     69 #[cfg(test)]
     70 fn run_load_hook()
     71 -> &'static std::sync::Mutex<Option<Result<(cli::Args, config::Settings), String>>> {
     72     RUN_LOAD_HOOK.get_or_init(|| std::sync::Mutex::new(None))
     73 }
     74 
     75 #[cfg(test)]
     76 fn run_bootstrap_hook() -> &'static std::sync::Mutex<Option<Result<(), String>>> {
     77     RUN_BOOTSTRAP_HOOK.get_or_init(|| std::sync::Mutex::new(None))
     78 }
     79 
     80 #[cfg(test)]
     81 fn run_wait_hook() -> &'static std::sync::Mutex<Option<RunWaitOutcome>> {
     82     RUN_WAIT_HOOK.get_or_init(|| std::sync::Mutex::new(None))
     83 }
     84 
     85 #[cfg(test)]
     86 fn run_start_rpc_hook() -> &'static std::sync::Mutex<Option<Result<ServerHandle, String>>> {
     87     RUN_START_RPC_HOOK.get_or_init(|| std::sync::Mutex::new(None))
     88 }
     89 
     90 #[cfg(test)]
     91 fn take_load_hook_result() -> Option<Result<(cli::Args, config::Settings), String>> {
     92     run_load_hook()
     93         .lock()
     94         .unwrap_or_else(std::sync::PoisonError::into_inner)
     95         .take()
     96 }
     97 
     98 #[cfg(test)]
     99 fn take_bootstrap_hook_result() -> Option<Result<(), String>> {
    100     run_bootstrap_hook()
    101         .lock()
    102         .unwrap_or_else(std::sync::PoisonError::into_inner)
    103         .take()
    104 }
    105 
    106 #[cfg(not(test))]
    107 fn take_bootstrap_hook_result() -> Option<Result<(), String>> {
    108     None
    109 }
    110 
    111 #[cfg(test)]
    112 fn take_wait_hook_result() -> Option<RunWaitOutcome> {
    113     run_wait_hook()
    114         .lock()
    115         .unwrap_or_else(std::sync::PoisonError::into_inner)
    116         .take()
    117 }
    118 
    119 #[cfg(test)]
    120 fn take_start_rpc_hook_result() -> Option<Result<ServerHandle, String>> {
    121     run_start_rpc_hook()
    122         .lock()
    123         .unwrap_or_else(std::sync::PoisonError::into_inner)
    124         .take()
    125 }
    126 
    127 fn load_args_and_settings() -> Result<(cli::Args, config::Settings)> {
    128     #[cfg(test)]
    129     {
    130         if let Some(result) = take_load_hook_result() {
    131             return result.map_err(anyhow::Error::msg);
    132         }
    133         return Err(anyhow::anyhow!("run loader hook not set"));
    134     }
    135 
    136     #[cfg(not(test))]
    137     {
    138         let args = cli::Args::try_parse().map_err(radroots_runtime::RuntimeCliError::from)?;
    139         let config_path = args
    140             .service
    141             .config
    142             .clone()
    143             .map(Ok)
    144             .unwrap_or_else(paths::default_config_path_for_process)?;
    145         let settings =
    146             config::load_settings_from_path(&config_path).context("load configuration")?;
    147         radroots_runtime::init_with_logs_dir(
    148             std::path::Path::new(settings.config.service.logs_dir.as_str()),
    149             None,
    150         )?;
    151         Ok((args, settings))
    152     }
    153 }
    154 
    155 fn runtime_startup_report(
    156     args: &cli::Args,
    157     settings: &config::Settings,
    158     contract: &paths::RadrootsdRuntimeContractOutput,
    159     migration: paths::RadrootsdRuntimeMigrationContractOutput,
    160 ) -> RadrootsdRuntimeStartupReport {
    161     RadrootsdRuntimeStartupReport {
    162         active_profile: contract.active_profile.clone(),
    163         config_path: args
    164             .service
    165             .config
    166             .clone()
    167             .unwrap_or_else(|| contract.canonical_config_path.clone()),
    168         config_path_source: cli_or_profile_path_source(
    169             args.service.config.is_some(),
    170             &args
    171                 .service
    172                 .config
    173                 .clone()
    174                 .unwrap_or_else(|| contract.canonical_config_path.clone()),
    175             &contract.canonical_config_path,
    176         ),
    177         canonical_config_path: contract.canonical_config_path.clone(),
    178         logs_dir: PathBuf::from(settings.config.service.logs_dir.as_str()),
    179         logs_dir_source: config_or_profile_path_source(
    180             &PathBuf::from(settings.config.service.logs_dir.as_str()),
    181             &contract.canonical_logs_dir,
    182         ),
    183         canonical_logs_dir: contract.canonical_logs_dir.clone(),
    184         identity_path: args
    185             .service
    186             .identity
    187             .clone()
    188             .unwrap_or_else(|| contract.canonical_identity_path.clone()),
    189         identity_path_source: cli_or_profile_path_source(
    190             args.service.identity.is_some(),
    191             &args
    192                 .service
    193                 .identity
    194                 .clone()
    195                 .unwrap_or_else(|| contract.canonical_identity_path.clone()),
    196             &contract.canonical_identity_path,
    197         ),
    198         canonical_identity_path: contract.canonical_identity_path.clone(),
    199         publish_proxy_database_path: settings.config.publish_proxy.database_path.clone(),
    200         publish_proxy_database_path_source: config_or_profile_path_source(
    201             &settings.config.publish_proxy.database_path,
    202             &contract.canonical_publish_proxy_database_path,
    203         ),
    204         canonical_publish_proxy_database_path: contract
    205             .canonical_publish_proxy_database_path
    206             .clone(),
    207         path_overrides: contract.path_overrides.clone(),
    208         migration,
    209         default_shared_secret_backend: contract.default_shared_secret_backend.clone(),
    210         allowed_shared_secret_backends: contract.allowed_shared_secret_backends.clone(),
    211     }
    212 }
    213 
    214 fn cli_or_profile_path_source(
    215     is_cli_arg: bool,
    216     actual_path: &PathBuf,
    217     canonical_path: &PathBuf,
    218 ) -> String {
    219     if is_cli_arg {
    220         "cli_arg".to_owned()
    221     } else {
    222         config_or_profile_path_source(actual_path, canonical_path)
    223     }
    224 }
    225 
    226 fn config_or_profile_path_source(actual_path: &PathBuf, canonical_path: &PathBuf) -> String {
    227     if actual_path == canonical_path {
    228         "profile_default".to_owned()
    229     } else {
    230         "config_artifact".to_owned()
    231     }
    232 }
    233 
    234 #[cfg(not(test))]
    235 fn log_runtime_startup_report(report: &RadrootsdRuntimeStartupReport) {
    236     info!(
    237         active_profile = report.active_profile.as_str(),
    238         profile_source = report.path_overrides.profile_source.as_str(),
    239         root_source = report.path_overrides.root_source.as_str(),
    240         repo_local_root = ?report.path_overrides.repo_local_root,
    241         repo_local_root_source = ?report.path_overrides.repo_local_root_source,
    242         subordinate_path_override_source = report.path_overrides.subordinate_path_override_source.as_str(),
    243         migration_posture = report.migration.posture.as_str(),
    244         migration_state = report.migration.state.as_str(),
    245         migration_detected_legacy_paths = report.migration.detected_legacy_paths.len(),
    246         silent_startup_relocation = report.migration.silent_startup_relocation,
    247         config_path = %report.config_path.display(),
    248         config_path_source = report.config_path_source.as_str(),
    249         canonical_config_path = %report.canonical_config_path.display(),
    250         logs_dir = %report.logs_dir.display(),
    251         logs_dir_source = report.logs_dir_source.as_str(),
    252         canonical_logs_dir = %report.canonical_logs_dir.display(),
    253         identity_path = %report.identity_path.display(),
    254         identity_path_source = report.identity_path_source.as_str(),
    255         canonical_identity_path = %report.canonical_identity_path.display(),
    256         publish_proxy_database_path = %report.publish_proxy_database_path.display(),
    257         publish_proxy_database_path_source = report.publish_proxy_database_path_source.as_str(),
    258         canonical_publish_proxy_database_path = %report.canonical_publish_proxy_database_path.display(),
    259         default_shared_secret_backend = report.default_shared_secret_backend.as_str(),
    260         allowed_shared_secret_backends = ?report.allowed_shared_secret_backends,
    261         "radrootsd runtime contract"
    262     );
    263 }
    264 
    265 #[cfg_attr(coverage_nightly, coverage(off))]
    266 async fn bootstrap_presence(
    267     client: &radroots_nostr::prelude::RadrootsNostrClient,
    268     identity: &RadrootsIdentity,
    269     metadata: &radroots_nostr::prelude::RadrootsNostrMetadata,
    270     handler_spec: &RadrootsNostrApplicationHandlerSpec,
    271 ) -> Result<()> {
    272     let bootstrap_result: Result<()> = match take_bootstrap_hook_result() {
    273         Some(result) => result.map_err(anyhow::Error::msg),
    274         None => radroots_nostr_bootstrap_service_presence(
    275             client,
    276             identity,
    277             Some(RadrootsProfileType::Radrootsd),
    278             metadata,
    279             handler_spec,
    280             Duration::from_secs(5),
    281         )
    282         .await
    283         .map(|_| ())
    284         .map_err(anyhow::Error::from),
    285     };
    286     bootstrap_result?;
    287     Ok(())
    288 }
    289 
    290 #[cfg_attr(coverage_nightly, coverage(off))]
    291 async fn publish_service_presence(
    292     client: radroots_nostr::prelude::RadrootsNostrClient,
    293     identity: RadrootsIdentity,
    294     metadata: radroots_nostr::prelude::RadrootsNostrMetadata,
    295     service_cfg: radroots_runtime::RadrootsNostrServiceConfig,
    296     nip46_config: config::Nip46Config,
    297 ) -> Result<()> {
    298     let kinds = service_presence_kinds();
    299     let handler_spec = RadrootsNostrApplicationHandlerSpec {
    300         kinds,
    301         identifier: service_cfg.nip89_identifier.clone(),
    302         metadata: Some(metadata.clone()),
    303         extra_tags: service_cfg.nip89_extra_tags.clone(),
    304         relays: service_cfg.relays.clone(),
    305         nostrconnect_url: nip46_config.nostrconnect_url.clone(),
    306     };
    307     bootstrap_presence(&client, &identity, &metadata, &handler_spec).await
    308 }
    309 
    310 #[cfg_attr(coverage_nightly, coverage(off))]
    311 async fn maybe_publish_service_presence(
    312     client: radroots_nostr::prelude::RadrootsNostrClient,
    313     identity: RadrootsIdentity,
    314     metadata: radroots_nostr::prelude::RadrootsNostrMetadata,
    315     service_cfg: radroots_runtime::RadrootsNostrServiceConfig,
    316     nip46_config: config::Nip46Config,
    317 ) {
    318     #[cfg(test)]
    319     {
    320         let result =
    321             publish_service_presence(client, identity, metadata, service_cfg, nip46_config).await;
    322         if let Err(err) = result {
    323             warn!("Failed to publish service presence on startup: {err}");
    324         } else {
    325             info!("Published service presence on startup");
    326         }
    327         return;
    328     }
    329 
    330     #[cfg(not(test))]
    331     tokio::spawn(async move {
    332         let result =
    333             publish_service_presence(client, identity, metadata, service_cfg, nip46_config).await;
    334         if let Err(err) = result {
    335             warn!("Failed to publish service presence on startup: {err}");
    336         } else {
    337             info!("Published service presence on startup");
    338         }
    339     });
    340 }
    341 
    342 #[cfg(not(test))]
    343 #[cfg_attr(coverage_nightly, coverage(off))]
    344 fn spawn_nip46_listener_io(radrootsd: Radrootsd) {
    345     spawn_nip46_listener(radrootsd);
    346 }
    347 
    348 #[cfg(test)]
    349 fn spawn_nip46_listener_io(_radrootsd: Radrootsd) {}
    350 
    351 #[cfg(test)]
    352 async fn start_rpc_io(
    353     state: Radrootsd,
    354     addr: std::net::SocketAddr,
    355     rpc_cfg: &config::RpcConfig,
    356 ) -> Result<ServerHandle> {
    357     if let Some(result) = take_start_rpc_hook_result() {
    358         return result.map_err(anyhow::Error::msg);
    359     }
    360     jsonrpc::start_rpc(state, addr, rpc_cfg).await
    361 }
    362 
    363 #[cfg(not(test))]
    364 #[cfg_attr(coverage_nightly, coverage(off))]
    365 async fn start_rpc_io(
    366     state: Radrootsd,
    367     addr: std::net::SocketAddr,
    368     rpc_cfg: &config::RpcConfig,
    369 ) -> Result<ServerHandle> {
    370     jsonrpc::start_rpc(state, addr, rpc_cfg).await
    371 }
    372 
    373 #[cfg(test)]
    374 async fn wait_for_shutdown_or_stopped(handle: ServerHandle) -> RunWaitOutcome {
    375     if let Some(outcome) = take_wait_hook_result() {
    376         return outcome;
    377     }
    378     handle.stopped().await;
    379     RunWaitOutcome::Stopped
    380 }
    381 
    382 #[cfg(not(test))]
    383 #[cfg_attr(coverage_nightly, coverage(off))]
    384 async fn wait_for_shutdown_or_stopped(handle: ServerHandle) -> RunWaitOutcome {
    385     tokio::select! {
    386         _ = radroots_runtime::shutdown_signal() => RunWaitOutcome::Shutdown,
    387         _ = handle.stopped() => RunWaitOutcome::Stopped,
    388     }
    389 }
    390 
    391 async fn handle_command(command: cli::Command, settings: &config::Settings) -> Result<()> {
    392     match command {
    393         cli::Command::PublishProxy(command) => match command.command {
    394             cli::PublishProxySubcommand::Principal(command) => match command.command {
    395                 cli::PrincipalSubcommand::Init(args) => {
    396                     let token = crate::core::publish_proxy::generate_bearer_token();
    397                     let token_hash = crate::core::publish_proxy::hash_bearer_token(token.as_str());
    398                     let store = crate::core::publish_proxy::PublishProxyStore::open(
    399                         settings.config.publish_proxy.database_path.clone(),
    400                     )?;
    401                     let principal = store.create_principal(
    402                         crate::core::publish_proxy::PublishPrincipalInit {
    403                             label: args.label,
    404                             token_hash,
    405                             allowed_pubkeys: args.allowed_pubkey,
    406                             allowed_kinds: args.allowed_kind,
    407                             allowed_relay_policies: args
    408                                 .allowed_relay_policy
    409                                 .iter()
    410                                 .map(|policy| {
    411                                     crate::core::publish_proxy::parse_relay_policy(policy.as_str())
    412                                 })
    413                                 .collect::<Result<Vec<_>, _>>()?,
    414                             allow_request_relays: args.allow_request_relays,
    415                             job_visibility: args.job_visibility.parse()?,
    416                             expires_at_unix: None,
    417                         },
    418                     )?;
    419                     crate::core::publish_proxy::write_token_file(&args.token_file, token.as_str())?;
    420                     println!(
    421                         "{}",
    422                         serde_json::json!({
    423                             "principal_id": principal.principal_id,
    424                             "label": principal.label,
    425                             "token_file": args.token_file,
    426                             "database_path": settings.config.publish_proxy.database_path,
    427                         })
    428                     );
    429                     Ok(())
    430                 }
    431             },
    432         },
    433     }
    434 }
    435 
    436 pub async fn run() -> Result<()> {
    437     let (args, settings): (cli::Args, config::Settings) = load_args_and_settings()?;
    438     settings.config.validate()?;
    439 
    440     #[cfg(not(test))]
    441     {
    442         let contract = paths::runtime_contract_for_process().context("resolve runtime contract")?;
    443         let migration =
    444             paths::runtime_migration_for_process(&contract).context("inspect runtime migration")?;
    445         let report = runtime_startup_report(&args, &settings, &contract, migration);
    446         log_runtime_startup_report(&report);
    447     }
    448 
    449     if let Some(command) = args.command.clone() {
    450         return handle_command(command, &settings).await;
    451     }
    452 
    453     info!("Starting radrootsd");
    454 
    455     let identity = load_service_identity(
    456         args.service.identity.as_deref(),
    457         args.service.allow_generate_identity,
    458     )?;
    459     let radrootsd = Radrootsd::new(
    460         identity.clone(),
    461         settings.metadata.clone(),
    462         settings.config.publish_proxy.clone(),
    463         settings.config.nip46.clone(),
    464     );
    465     let radrootsd = radrootsd?;
    466 
    467     for relay in settings.config.service.relays.iter() {
    468         radrootsd.client.add_relay(relay).await?;
    469     }
    470 
    471     if !settings.config.service.relays.is_empty() {
    472         maybe_publish_service_presence(
    473             radrootsd.client.clone(),
    474             identity.clone(),
    475             settings.metadata.clone(),
    476             settings.config.service.clone(),
    477             settings.config.nip46.clone(),
    478         )
    479         .await;
    480 
    481         spawn_nip46_listener_io(radrootsd.clone());
    482     }
    483 
    484     let addr: std::net::SocketAddr = settings.config.rpc_addr().parse()?;
    485     let handle = start_rpc_io(radrootsd.clone(), addr, &settings.config.rpc).await?;
    486     info!("JSON-RPC listening on {addr}");
    487 
    488     let stop_handle = handle.clone();
    489 
    490     match wait_for_shutdown_or_stopped(handle).await {
    491         RunWaitOutcome::Shutdown => {
    492             info!("Shutting down…");
    493             let _ = stop_handle.stop();
    494         }
    495         RunWaitOutcome::Stopped => {}
    496     }
    497 
    498     Ok(())
    499 }
    500 
    501 fn service_presence_kinds() -> Vec<u32> {
    502     let mut kinds = vec![RadrootsNostrKind::NostrConnect.as_u16() as u32];
    503     kinds.sort_unstable();
    504     kinds.dedup();
    505     kinds
    506 }
    507 
    508 #[cfg(test)]
    509 #[cfg_attr(coverage_nightly, coverage(off))]
    510 mod tests {
    511     use super::{
    512         RadrootsdRuntimeStartupReport, RunWaitOutcome, run, run_bootstrap_hook, run_load_hook,
    513         run_start_rpc_hook, run_wait_hook, runtime_startup_report,
    514     };
    515     use crate::app::{cli, config, paths};
    516     use crate::core::Radrootsd;
    517     use crate::transport::jsonrpc;
    518     use radroots_identity::RadrootsIdentity;
    519     use radroots_nostr::prelude::RadrootsNostrMetadata;
    520     use std::path::Path;
    521     use std::path::PathBuf;
    522     use std::sync::{Mutex, MutexGuard};
    523 
    524     static TEST_LOCK: Mutex<()> = Mutex::new(());
    525 
    526     fn test_guard() -> MutexGuard<'static, ()> {
    527         let guard = TEST_LOCK
    528             .lock()
    529             .unwrap_or_else(std::sync::PoisonError::into_inner);
    530         *run_load_hook()
    531             .lock()
    532             .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
    533         *run_bootstrap_hook()
    534             .lock()
    535             .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
    536         *run_wait_hook()
    537             .lock()
    538             .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
    539         *run_start_rpc_hook()
    540             .lock()
    541             .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
    542         guard
    543     }
    544 
    545     fn unique_identity_path(suffix: &str) -> PathBuf {
    546         let nanos = std::time::SystemTime::now()
    547             .duration_since(std::time::UNIX_EPOCH)
    548             .expect("time")
    549             .as_nanos();
    550         std::env::temp_dir().join(format!("radrootsd-{suffix}-{nanos}.secret.json"))
    551     }
    552 
    553     fn cleanup_identity_artifacts(path: &Path) {
    554         let _ = std::fs::remove_file(path);
    555         let _ = std::fs::remove_file(crate::app::identity_storage::encrypted_identity_key_path(
    556             path,
    557         ));
    558     }
    559 
    560     fn args_for_identity(path: PathBuf, allow_generate: bool) -> cli::Args {
    561         cli::Args {
    562             service: radroots_runtime::RadrootsServiceCliArgs {
    563                 config: Some(PathBuf::from("config.toml")),
    564                 identity: Some(path),
    565                 allow_generate_identity: allow_generate,
    566             },
    567             command: None,
    568         }
    569     }
    570 
    571     fn settings_with_relays(relays: Vec<String>) -> config::Settings {
    572         let metadata: RadrootsNostrMetadata =
    573             serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata");
    574         config::Settings {
    575             metadata,
    576             config: config::Configuration {
    577                 service: radroots_runtime::RadrootsNostrServiceConfig {
    578                     logs_dir: "logs".to_string(),
    579                     relays,
    580                     nip89_identifier: Some("radrootsd".to_string()),
    581                     nip89_extra_tags: Vec::new(),
    582                 },
    583                 rpc: config::RpcConfig {
    584                     addr: "127.0.0.1:0".to_string(),
    585                     ..config::RpcConfig::default()
    586                 },
    587                 rpc_addr: Some("127.0.0.1:0".to_string()),
    588                 nip46: config::Nip46Config::default(),
    589                 publish_proxy: config::PublishProxyConfig::default(),
    590                 obsolete_bridge_config_present: false,
    591             },
    592         }
    593     }
    594 
    595     fn sample_runtime_contract() -> paths::RadrootsdRuntimeContractOutput {
    596         paths::RadrootsdRuntimeContractOutput {
    597             active_profile: "interactive_user".to_string(),
    598             allowed_profiles: vec![
    599                 "interactive_user".to_string(),
    600                 "service_host".to_string(),
    601                 "repo_local".to_string(),
    602             ],
    603             path_overrides: paths::RadrootsdRuntimePathOverrideContractOutput {
    604                 profile_source: "caller".to_string(),
    605                 root_source: "host_defaults".to_string(),
    606                 repo_local_root: None,
    607                 repo_local_root_source: None,
    608                 subordinate_path_override_source: "config_artifact".to_string(),
    609                 subordinate_path_override_keys: vec![
    610                     "config.service.logs_dir".to_string(),
    611                     "config.publish_proxy.database_path".to_string(),
    612                 ],
    613             },
    614             default_shared_secret_backend: "encrypted_file".to_string(),
    615             allowed_shared_secret_backends: vec!["encrypted_file".to_string()],
    616             migration: paths::RadrootsdRuntimeMigrationContractOutput {
    617                 posture: "explicit_operator_import_required".to_string(),
    618                 state: "ready".to_string(),
    619                 silent_startup_relocation: false,
    620                 compatibility_window: "detect_and_report_only".to_string(),
    621                 detected_legacy_paths: Vec::new(),
    622             },
    623             canonical_config_path: PathBuf::from(
    624                 "/home/treesap/.radroots/config/services/radrootsd/config.toml",
    625             ),
    626             canonical_logs_dir: PathBuf::from("/home/treesap/.radroots/logs/services/radrootsd"),
    627             canonical_identity_path: PathBuf::from(
    628                 "/home/treesap/.radroots/secrets/services/radrootsd/identity.secret.json",
    629             ),
    630             canonical_publish_proxy_database_path: PathBuf::from(
    631                 "/home/treesap/.radroots/data/services/radrootsd/publish_proxy.sqlite",
    632             ),
    633         }
    634     }
    635 
    636     async fn make_handle(settings: &config::Settings) -> jsonrpsee::server::ServerHandle {
    637         let identity = RadrootsIdentity::generate();
    638         let state = Radrootsd::new(
    639             identity,
    640             settings.metadata.clone(),
    641             settings.config.publish_proxy.clone(),
    642             settings.config.nip46.clone(),
    643         )
    644         .expect("state");
    645         jsonrpc::start_rpc(
    646             state,
    647             "127.0.0.1:0".parse().expect("addr"),
    648             &settings.config.rpc,
    649         )
    650         .await
    651         .expect("rpc handle")
    652     }
    653 
    654     #[tokio::test]
    655     async fn run_returns_error_when_hook_is_missing() {
    656         let _guard = test_guard();
    657         let err = run().await.expect_err("missing loader hook should error");
    658         let msg = format!("{err:#}");
    659         assert!(msg.contains("run loader hook not set"));
    660     }
    661 
    662     #[tokio::test]
    663     async fn run_returns_error_when_identity_missing() {
    664         let _guard = test_guard();
    665         let args = args_for_identity(PathBuf::from("/tmp/radrootsd-missing.secret.json"), false);
    666         let settings = settings_with_relays(Vec::new());
    667         *run_load_hook()
    668             .lock()
    669             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings)));
    670         let err = run().await.expect_err("missing identity should error");
    671         let msg = format!("{err:#}");
    672         assert!(msg.contains("identity"));
    673     }
    674 
    675     #[tokio::test]
    676     async fn run_covers_shutdown_path_and_presence_success() {
    677         let _guard = test_guard();
    678         let path = unique_identity_path("shutdown");
    679         let args = args_for_identity(path.clone(), true);
    680         let settings = settings_with_relays(vec!["wss://relay.example.com".to_string()]);
    681         let handle = make_handle(&settings).await;
    682         *run_load_hook()
    683             .lock()
    684             .unwrap_or_else(std::sync::PoisonError::into_inner) =
    685             Some(Ok((args, settings.clone())));
    686         *run_start_rpc_hook()
    687             .lock()
    688             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle));
    689         *run_wait_hook()
    690             .lock()
    691             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown);
    692         *run_bootstrap_hook()
    693             .lock()
    694             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(()));
    695         assert!(run().await.is_ok());
    696         cleanup_identity_artifacts(&path);
    697     }
    698 
    699     #[tokio::test]
    700     async fn run_covers_stopped_path_and_presence_failure() {
    701         let _guard = test_guard();
    702         let path = unique_identity_path("stopped");
    703         let args = args_for_identity(path.clone(), true);
    704         let settings = settings_with_relays(vec!["wss://relay.example.com".to_string()]);
    705         let handle = make_handle(&settings).await;
    706         let _ = handle.stop();
    707         *run_load_hook()
    708             .lock()
    709             .unwrap_or_else(std::sync::PoisonError::into_inner) =
    710             Some(Ok((args, settings.clone())));
    711         *run_start_rpc_hook()
    712             .lock()
    713             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle));
    714         *run_wait_hook()
    715             .lock()
    716             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Stopped);
    717         *run_bootstrap_hook()
    718             .lock()
    719             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Err("boom".to_string()));
    720         assert!(run().await.is_ok());
    721         cleanup_identity_artifacts(&path);
    722     }
    723 
    724     #[tokio::test]
    725     async fn run_skips_presence_when_relays_empty() {
    726         let _guard = test_guard();
    727         let path = unique_identity_path("empty");
    728         let args = args_for_identity(path.clone(), true);
    729         let settings = settings_with_relays(Vec::new());
    730         let handle = make_handle(&settings).await;
    731         *run_load_hook()
    732             .lock()
    733             .unwrap_or_else(std::sync::PoisonError::into_inner) =
    734             Some(Ok((args, settings.clone())));
    735         *run_start_rpc_hook()
    736             .lock()
    737             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle));
    738         *run_wait_hook()
    739             .lock()
    740             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown);
    741         assert!(run().await.is_ok());
    742         cleanup_identity_artifacts(&path);
    743     }
    744 
    745     #[tokio::test]
    746     async fn run_returns_error_when_relay_is_invalid() {
    747         let _guard = test_guard();
    748         let path = unique_identity_path("invalid-relay");
    749         let args = args_for_identity(path.clone(), true);
    750         let settings = settings_with_relays(vec!["not-a-relay".to_string()]);
    751         *run_load_hook()
    752             .lock()
    753             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings)));
    754         let err = run().await.expect_err("invalid relay should error");
    755         let msg = format!("{err:#}");
    756         assert!(!msg.is_empty());
    757         cleanup_identity_artifacts(&path);
    758     }
    759 
    760     #[tokio::test]
    761     async fn run_returns_error_when_rpc_addr_is_invalid() {
    762         let _guard = test_guard();
    763         let path = unique_identity_path("invalid-rpc-addr");
    764         let args = args_for_identity(path.clone(), true);
    765         let mut settings = settings_with_relays(Vec::new());
    766         settings.config.rpc_addr = Some("not-an-addr".to_string());
    767         *run_load_hook()
    768             .lock()
    769             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings)));
    770         let err = run().await.expect_err("invalid rpc addr should error");
    771         let msg = format!("{err:#}");
    772         assert!(msg.contains("invalid"));
    773         cleanup_identity_artifacts(&path);
    774     }
    775 
    776     #[tokio::test]
    777     async fn run_returns_error_when_rpc_start_fails() {
    778         let _guard = test_guard();
    779         let path = unique_identity_path("rpc-start-fail");
    780         let args = args_for_identity(path.clone(), true);
    781         let settings = settings_with_relays(Vec::new());
    782         *run_load_hook()
    783             .lock()
    784             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings)));
    785         *run_start_rpc_hook()
    786             .lock()
    787             .unwrap_or_else(std::sync::PoisonError::into_inner) =
    788             Some(Err("rpc start failed".to_string()));
    789         let err = run().await.expect_err("rpc start hook should fail");
    790         let msg = format!("{err:#}");
    791         assert!(msg.contains("rpc start failed"));
    792         cleanup_identity_artifacts(&path);
    793     }
    794 
    795     #[tokio::test]
    796     async fn run_waits_for_stopped_when_wait_hook_is_not_set() {
    797         let _guard = test_guard();
    798         let path = unique_identity_path("wait-no-hook");
    799         let args = args_for_identity(path.clone(), true);
    800         let settings = settings_with_relays(Vec::new());
    801         let handle = make_handle(&settings).await;
    802         let _ = handle.stop();
    803         *run_load_hook()
    804             .lock()
    805             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings)));
    806         *run_start_rpc_hook()
    807             .lock()
    808             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle));
    809         assert!(run().await.is_ok());
    810         cleanup_identity_artifacts(&path);
    811     }
    812 
    813     #[tokio::test]
    814     async fn run_starts_rpc_when_start_hook_is_not_set() {
    815         let _guard = test_guard();
    816         let path = unique_identity_path("start-rpc-real");
    817         let args = args_for_identity(path.clone(), true);
    818         let settings = settings_with_relays(Vec::new());
    819         *run_load_hook()
    820             .lock()
    821             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings)));
    822         *run_wait_hook()
    823             .lock()
    824             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown);
    825         assert!(run().await.is_ok());
    826         cleanup_identity_artifacts(&path);
    827     }
    828 
    829     #[test]
    830     fn service_presence_kinds_include_nostr_connect_only() {
    831         let kinds = super::service_presence_kinds();
    832 
    833         assert!(
    834             kinds.contains(
    835                 &(radroots_nostr::prelude::RadrootsNostrKind::NostrConnect.as_u16() as u32)
    836             )
    837         );
    838         assert_eq!(kinds.len(), 1);
    839     }
    840 
    841     #[test]
    842     fn runtime_startup_report_prefers_explicit_cli_paths() {
    843         let args = cli::Args {
    844             service: radroots_runtime::RadrootsServiceCliArgs {
    845                 config: Some(PathBuf::from("/tmp/radrootsd/config.toml")),
    846                 identity: Some(PathBuf::from("/tmp/radrootsd/identity.secret.json")),
    847                 allow_generate_identity: false,
    848             },
    849             command: None,
    850         };
    851         let mut settings = settings_with_relays(Vec::new());
    852         settings.config.service.logs_dir = "/tmp/radrootsd/logs".to_string();
    853         settings.config.publish_proxy.database_path =
    854             PathBuf::from("/tmp/radrootsd/publish_proxy.sqlite");
    855 
    856         let contract = sample_runtime_contract();
    857         let report =
    858             runtime_startup_report(&args, &settings, &contract, contract.migration.clone());
    859 
    860         assert_eq!(
    861             report,
    862             RadrootsdRuntimeStartupReport {
    863                 active_profile: "interactive_user".to_string(),
    864                 config_path: PathBuf::from("/tmp/radrootsd/config.toml"),
    865                 config_path_source: "cli_arg".to_string(),
    866                 canonical_config_path: PathBuf::from(
    867                     "/home/treesap/.radroots/config/services/radrootsd/config.toml"
    868                 ),
    869                 logs_dir: PathBuf::from("/tmp/radrootsd/logs"),
    870                 logs_dir_source: "config_artifact".to_string(),
    871                 canonical_logs_dir: PathBuf::from(
    872                     "/home/treesap/.radroots/logs/services/radrootsd"
    873                 ),
    874                 identity_path: PathBuf::from("/tmp/radrootsd/identity.secret.json"),
    875                 identity_path_source: "cli_arg".to_string(),
    876                 canonical_identity_path: PathBuf::from(
    877                     "/home/treesap/.radroots/secrets/services/radrootsd/identity.secret.json"
    878                 ),
    879                 publish_proxy_database_path: PathBuf::from("/tmp/radrootsd/publish_proxy.sqlite"),
    880                 publish_proxy_database_path_source: "config_artifact".to_string(),
    881                 canonical_publish_proxy_database_path: PathBuf::from(
    882                     "/home/treesap/.radroots/data/services/radrootsd/publish_proxy.sqlite"
    883                 ),
    884                 path_overrides: sample_runtime_contract().path_overrides,
    885                 migration: sample_runtime_contract().migration,
    886                 default_shared_secret_backend: "encrypted_file".to_string(),
    887                 allowed_shared_secret_backends: vec!["encrypted_file".to_string()],
    888             }
    889         );
    890     }
    891 
    892     #[test]
    893     fn runtime_startup_report_falls_back_to_canonical_contract_paths() {
    894         let args = cli::Args {
    895             service: radroots_runtime::RadrootsServiceCliArgs {
    896                 config: None,
    897                 identity: None,
    898                 allow_generate_identity: false,
    899             },
    900             command: None,
    901         };
    902         let contract = sample_runtime_contract();
    903         let mut settings = settings_with_relays(Vec::new());
    904         settings.config.service.logs_dir = contract.canonical_logs_dir.display().to_string();
    905         settings.config.publish_proxy.database_path =
    906             contract.canonical_publish_proxy_database_path.clone();
    907 
    908         let report =
    909             runtime_startup_report(&args, &settings, &contract, contract.migration.clone());
    910 
    911         assert_eq!(report.config_path, contract.canonical_config_path);
    912         assert_eq!(report.config_path_source, "profile_default");
    913         assert_eq!(report.logs_dir, contract.canonical_logs_dir);
    914         assert_eq!(report.logs_dir_source, "profile_default");
    915         assert_eq!(report.identity_path, contract.canonical_identity_path);
    916         assert_eq!(report.identity_path_source, "profile_default");
    917         assert_eq!(
    918             report.publish_proxy_database_path,
    919             contract.canonical_publish_proxy_database_path
    920         );
    921         assert_eq!(report.publish_proxy_database_path_source, "profile_default");
    922         assert_eq!(report.path_overrides, contract.path_overrides);
    923         assert_eq!(report.migration, contract.migration);
    924         assert_eq!(report.default_shared_secret_backend, "encrypted_file");
    925         assert_eq!(
    926             report.allowed_shared_secret_backends,
    927             vec!["encrypted_file".to_string()]
    928         );
    929     }
    930 }