radrootsd

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

runtime.rs (41142B)


      1 use crate::app::identity_storage::DaemonIdentity;
      2 use anyhow::{Context, Result, bail};
      3 use jsonrpsee::server::ServerHandle;
      4 use std::time::Duration;
      5 use tracing::{info, warn};
      6 #[cfg(not(test))]
      7 use tracing_subscriber::EnvFilter;
      8 
      9 use crate::app::identity_storage::load_service_identity;
     10 use crate::app::{cli, config, paths};
     11 use crate::core::Radrootsd;
     12 use crate::core::transport_publish::{
     13     PublishPrincipalInit, TransportPublishStore, generate_bearer_token, hash_bearer_token,
     14     parse_explicit_transport_kind, parse_nostr_source_policy, parse_target_policy,
     15     write_token_file,
     16 };
     17 use crate::host_nostr::{ApplicationHandlerSpec, Kind, build_application_handler, build_profile};
     18 use crate::transport::jsonrpc;
     19 #[cfg(not(test))]
     20 use crate::transport::nostr::listener::spawn_nip46_listener;
     21 #[cfg(not(test))]
     22 use clap::Parser;
     23 use radroots_event::profile::AuthoredProfile;
     24 use std::path::PathBuf;
     25 
     26 #[cfg(test)]
     27 type RunLoadHookValue = Result<(cli::Args, config::Settings), String>;
     28 
     29 #[cfg(test)]
     30 type RunLoadHook = std::sync::Mutex<Option<RunLoadHookValue>>;
     31 
     32 #[cfg(test)]
     33 static RUN_LOAD_HOOK: std::sync::OnceLock<RunLoadHook> = std::sync::OnceLock::new();
     34 
     35 #[cfg(test)]
     36 static RUN_BOOTSTRAP_HOOK: std::sync::OnceLock<std::sync::Mutex<Option<Result<(), String>>>> =
     37     std::sync::OnceLock::new();
     38 
     39 #[cfg(test)]
     40 static RUN_WAIT_HOOK: std::sync::OnceLock<std::sync::Mutex<Option<RunWaitOutcome>>> =
     41     std::sync::OnceLock::new();
     42 
     43 #[cfg(test)]
     44 static RUN_START_RPC_HOOK: std::sync::OnceLock<
     45     std::sync::Mutex<Option<Result<ServerHandle, String>>>,
     46 > = std::sync::OnceLock::new();
     47 
     48 #[derive(Clone, Copy)]
     49 enum RunWaitOutcome {
     50     Shutdown,
     51     Stopped,
     52 }
     53 
     54 #[cfg(not(test))]
     55 fn init_logging(logs_dir: &std::path::Path, default_level: Option<&str>) -> Result<()> {
     56     std::fs::create_dir_all(logs_dir)
     57         .with_context(|| format!("create log directory {}", logs_dir.display()))?;
     58     let appender = tracing_appender::rolling::never(logs_dir, "radrootsd.log");
     59     let (writer, guard) = tracing_appender::non_blocking(appender);
     60     static LOG_GUARD: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
     61         std::sync::OnceLock::new();
     62     let filter = std::env::var("RADROOTS_LOG_LEVEL")
     63         .or_else(|_| std::env::var("RUST_LOG"))
     64         .ok()
     65         .or_else(|| default_level.map(str::to_owned))
     66         .unwrap_or_else(|| "info".to_owned());
     67     tracing_subscriber::fmt()
     68         .with_env_filter(EnvFilter::new(filter))
     69         .with_writer(writer)
     70         .try_init()
     71         .map_err(|error| anyhow::anyhow!("initialize logging: {error}"))?;
     72     LOG_GUARD
     73         .set(guard)
     74         .map_err(|_| anyhow::anyhow!("logging is already initialized"))?;
     75     Ok(())
     76 }
     77 
     78 #[cfg(not(test))]
     79 async fn shutdown_signal() {
     80     let ctrl_c = async {
     81         tokio::signal::ctrl_c()
     82             .await
     83             .expect("failed to install Ctrl+C handler");
     84     };
     85     #[cfg(unix)]
     86     let terminate = async {
     87         tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
     88             .expect("failed to install signal handler")
     89             .recv()
     90             .await;
     91     };
     92     #[cfg(not(unix))]
     93     let terminate = std::future::pending::<()>();
     94     tokio::select! {
     95         _ = ctrl_c => {},
     96         _ = terminate => {},
     97     }
     98 }
     99 
    100 #[derive(Debug, Clone, PartialEq, Eq)]
    101 struct RadrootsdRuntimeStartupReport {
    102     active_profile: String,
    103     config_path: PathBuf,
    104     config_path_source: String,
    105     canonical_config_path: PathBuf,
    106     logs_dir: PathBuf,
    107     logs_dir_source: String,
    108     canonical_logs_dir: PathBuf,
    109     identity_path: PathBuf,
    110     identity_path_source: String,
    111     canonical_identity_path: PathBuf,
    112     transport_publish_database_path: PathBuf,
    113     transport_publish_database_path_source: String,
    114     canonical_transport_publish_database_path: PathBuf,
    115     path_overrides: paths::RadrootsdRuntimePathOverrideContractOutput,
    116     default_shared_secret_backend: String,
    117     allowed_shared_secret_backends: Vec<String>,
    118 }
    119 
    120 #[cfg(test)]
    121 fn run_load_hook() -> &'static RunLoadHook {
    122     RUN_LOAD_HOOK.get_or_init(|| std::sync::Mutex::new(None))
    123 }
    124 
    125 #[cfg(test)]
    126 fn run_bootstrap_hook() -> &'static std::sync::Mutex<Option<Result<(), String>>> {
    127     RUN_BOOTSTRAP_HOOK.get_or_init(|| std::sync::Mutex::new(None))
    128 }
    129 
    130 #[cfg(test)]
    131 fn run_wait_hook() -> &'static std::sync::Mutex<Option<RunWaitOutcome>> {
    132     RUN_WAIT_HOOK.get_or_init(|| std::sync::Mutex::new(None))
    133 }
    134 
    135 #[cfg(test)]
    136 fn run_start_rpc_hook() -> &'static std::sync::Mutex<Option<Result<ServerHandle, String>>> {
    137     RUN_START_RPC_HOOK.get_or_init(|| std::sync::Mutex::new(None))
    138 }
    139 
    140 #[cfg(test)]
    141 fn take_load_hook_result() -> Option<Result<(cli::Args, config::Settings), String>> {
    142     run_load_hook()
    143         .lock()
    144         .unwrap_or_else(std::sync::PoisonError::into_inner)
    145         .take()
    146 }
    147 
    148 #[cfg(test)]
    149 fn take_bootstrap_hook_result() -> Option<Result<(), String>> {
    150     run_bootstrap_hook()
    151         .lock()
    152         .unwrap_or_else(std::sync::PoisonError::into_inner)
    153         .take()
    154 }
    155 
    156 #[cfg(not(test))]
    157 fn take_bootstrap_hook_result() -> Option<Result<(), String>> {
    158     None
    159 }
    160 
    161 #[cfg(test)]
    162 fn take_wait_hook_result() -> Option<RunWaitOutcome> {
    163     run_wait_hook()
    164         .lock()
    165         .unwrap_or_else(std::sync::PoisonError::into_inner)
    166         .take()
    167 }
    168 
    169 #[cfg(test)]
    170 fn take_start_rpc_hook_result() -> Option<Result<ServerHandle, String>> {
    171     run_start_rpc_hook()
    172         .lock()
    173         .unwrap_or_else(std::sync::PoisonError::into_inner)
    174         .take()
    175 }
    176 
    177 fn load_args_and_settings() -> Result<(cli::Args, config::Settings)> {
    178     #[cfg(test)]
    179     {
    180         if let Some(result) = take_load_hook_result() {
    181             return result.map_err(anyhow::Error::msg);
    182         }
    183         Err(anyhow::anyhow!("run loader hook not set"))
    184     }
    185 
    186     #[cfg(not(test))]
    187     {
    188         let args = cli::Args::try_parse()?;
    189         let config_path = args
    190             .service
    191             .config
    192             .clone()
    193             .map(Ok)
    194             .unwrap_or_else(paths::default_config_path_for_process)?;
    195         let settings =
    196             config::load_settings_from_path(&config_path).context("load configuration")?;
    197         init_logging(
    198             std::path::Path::new(settings.config.service.logs_dir.as_str()),
    199             None,
    200         )?;
    201         Ok((args, settings))
    202     }
    203 }
    204 
    205 fn runtime_startup_report(
    206     args: &cli::Args,
    207     settings: &config::Settings,
    208     contract: &paths::RadrootsdRuntimeContractOutput,
    209 ) -> RadrootsdRuntimeStartupReport {
    210     RadrootsdRuntimeStartupReport {
    211         active_profile: contract.active_profile.clone(),
    212         config_path: args
    213             .service
    214             .config
    215             .clone()
    216             .unwrap_or_else(|| contract.canonical_config_path.clone()),
    217         config_path_source: cli_or_profile_path_source(
    218             args.service.config.is_some(),
    219             &args
    220                 .service
    221                 .config
    222                 .clone()
    223                 .unwrap_or_else(|| contract.canonical_config_path.clone()),
    224             &contract.canonical_config_path,
    225         ),
    226         canonical_config_path: contract.canonical_config_path.clone(),
    227         logs_dir: PathBuf::from(settings.config.service.logs_dir.as_str()),
    228         logs_dir_source: config_or_profile_path_source(
    229             &PathBuf::from(settings.config.service.logs_dir.as_str()),
    230             &contract.canonical_logs_dir,
    231         ),
    232         canonical_logs_dir: contract.canonical_logs_dir.clone(),
    233         identity_path: args
    234             .service
    235             .identity
    236             .clone()
    237             .unwrap_or_else(|| contract.canonical_identity_path.clone()),
    238         identity_path_source: cli_or_profile_path_source(
    239             args.service.identity.is_some(),
    240             &args
    241                 .service
    242                 .identity
    243                 .clone()
    244                 .unwrap_or_else(|| contract.canonical_identity_path.clone()),
    245             &contract.canonical_identity_path,
    246         ),
    247         canonical_identity_path: contract.canonical_identity_path.clone(),
    248         transport_publish_database_path: settings.config.transport_publish.database_path.clone(),
    249         transport_publish_database_path_source: config_or_profile_path_source(
    250             &settings.config.transport_publish.database_path,
    251             &contract.canonical_transport_publish_database_path,
    252         ),
    253         canonical_transport_publish_database_path: contract
    254             .canonical_transport_publish_database_path
    255             .clone(),
    256         path_overrides: contract.path_overrides.clone(),
    257         default_shared_secret_backend: contract.default_shared_secret_backend.clone(),
    258         allowed_shared_secret_backends: contract.allowed_shared_secret_backends.clone(),
    259     }
    260 }
    261 
    262 fn cli_or_profile_path_source(
    263     is_cli_arg: bool,
    264     actual_path: &PathBuf,
    265     canonical_path: &PathBuf,
    266 ) -> String {
    267     if is_cli_arg {
    268         "cli_arg".to_owned()
    269     } else {
    270         config_or_profile_path_source(actual_path, canonical_path)
    271     }
    272 }
    273 
    274 fn config_or_profile_path_source(actual_path: &PathBuf, canonical_path: &PathBuf) -> String {
    275     if actual_path == canonical_path {
    276         "profile_default".to_owned()
    277     } else {
    278         "config_artifact".to_owned()
    279     }
    280 }
    281 
    282 #[cfg(not(test))]
    283 fn log_runtime_startup_report(report: &RadrootsdRuntimeStartupReport) {
    284     info!(
    285         active_profile = report.active_profile.as_str(),
    286         profile_source = report.path_overrides.profile_source.as_str(),
    287         root_source = report.path_overrides.root_source.as_str(),
    288         repo_local_root = ?report.path_overrides.repo_local_root,
    289         repo_local_root_source = ?report.path_overrides.repo_local_root_source,
    290         subordinate_path_override_source = report.path_overrides.subordinate_path_override_source.as_str(),
    291         config_path = %report.config_path.display(),
    292         config_path_source = report.config_path_source.as_str(),
    293         canonical_config_path = %report.canonical_config_path.display(),
    294         logs_dir = %report.logs_dir.display(),
    295         logs_dir_source = report.logs_dir_source.as_str(),
    296         canonical_logs_dir = %report.canonical_logs_dir.display(),
    297         identity_path = %report.identity_path.display(),
    298         identity_path_source = report.identity_path_source.as_str(),
    299         canonical_identity_path = %report.canonical_identity_path.display(),
    300         transport_publish_database_path = %report.transport_publish_database_path.display(),
    301         transport_publish_database_path_source = report.transport_publish_database_path_source.as_str(),
    302         canonical_transport_publish_database_path = %report.canonical_transport_publish_database_path.display(),
    303         default_shared_secret_backend = report.default_shared_secret_backend.as_str(),
    304         allowed_shared_secret_backends = ?report.allowed_shared_secret_backends,
    305         "radrootsd runtime contract"
    306     );
    307 }
    308 
    309 #[cfg_attr(coverage_nightly, coverage(off))]
    310 async fn bootstrap_presence(
    311     client: &crate::host_nostr::DaemonNostrClient,
    312     identity: &DaemonIdentity,
    313     profile: &AuthoredProfile,
    314     handler_spec: &ApplicationHandlerSpec,
    315 ) -> Result<()> {
    316     if let Some(result) = take_bootstrap_hook_result() {
    317         return result.map_err(anyhow::Error::msg);
    318     }
    319 
    320     let (profile_event, handler_event) =
    321         build_service_presence_events(identity, profile, handler_spec)?;
    322 
    323     client.connect().await;
    324     client.wait_for_connection(Duration::from_secs(5)).await;
    325     if !client
    326         .relays()
    327         .await
    328         .into_values()
    329         .any(|relay| relay.is_connected())
    330     {
    331         bail!("service presence requires at least one connected relay");
    332     }
    333 
    334     publish_presence_event(client, &profile_event, "service Profile").await?;
    335     publish_presence_event(client, &handler_event, "NIP-89 application handler").await?;
    336     Ok(())
    337 }
    338 
    339 fn build_service_presence_events(
    340     identity: &DaemonIdentity,
    341     profile: &AuthoredProfile,
    342     handler_spec: &ApplicationHandlerSpec,
    343 ) -> Result<(nostr::Event, nostr::Event)> {
    344     let profile_event = build_profile(profile)
    345         .context("build service Profile event")?
    346         .sign_with_keys(identity.keys())
    347         .context("sign service Profile event")?;
    348     let handler_event = build_application_handler(handler_spec)
    349         .context("build NIP-89 application handler event")?
    350         .sign_with_keys(identity.keys())
    351         .context("sign NIP-89 application handler event")?;
    352     Ok((profile_event, handler_event))
    353 }
    354 
    355 async fn publish_presence_event(
    356     client: &crate::host_nostr::DaemonNostrClient,
    357     event: &nostr::Event,
    358     event_name: &str,
    359 ) -> Result<()> {
    360     let output = client
    361         .send_event(event)
    362         .await
    363         .with_context(|| format!("publish {event_name}"))?;
    364     if output.success.is_empty() {
    365         bail!("{event_name} publication did not succeed on any relay");
    366     }
    367     Ok(())
    368 }
    369 
    370 #[cfg_attr(coverage_nightly, coverage(off))]
    371 async fn publish_service_presence(
    372     client: crate::host_nostr::DaemonNostrClient,
    373     identity: DaemonIdentity,
    374     profile: AuthoredProfile,
    375     metadata: crate::host_nostr::Metadata,
    376     service_cfg: config::NostrServiceConfig,
    377     nip46_config: config::Nip46Config,
    378 ) -> Result<()> {
    379     let kinds = service_presence_kinds();
    380     let mut handler_spec = ApplicationHandlerSpec::new(kinds)
    381         .with_metadata(metadata.clone())
    382         .with_extra_tags(service_cfg.nip89_extra_tags.clone())
    383         .with_relays(service_cfg.relays.clone());
    384     if let Some(identifier) = service_cfg.nip89_identifier.clone() {
    385         handler_spec = handler_spec.with_identifier(identifier);
    386     }
    387     if let Some(url) = nip46_config.nostrconnect_url.clone() {
    388         handler_spec = handler_spec.with_nostr_connect_url(url);
    389     }
    390     bootstrap_presence(&client, &identity, &profile, &handler_spec).await
    391 }
    392 
    393 #[cfg_attr(coverage_nightly, coverage(off))]
    394 async fn maybe_publish_service_presence(
    395     client: crate::host_nostr::DaemonNostrClient,
    396     identity: DaemonIdentity,
    397     profile: AuthoredProfile,
    398     metadata: crate::host_nostr::Metadata,
    399     service_cfg: config::NostrServiceConfig,
    400     nip46_config: config::Nip46Config,
    401 ) {
    402     #[cfg(test)]
    403     {
    404         let result = publish_service_presence(
    405             client,
    406             identity,
    407             profile,
    408             metadata,
    409             service_cfg,
    410             nip46_config,
    411         )
    412         .await;
    413         if let Err(err) = result {
    414             warn!("Failed to publish service presence on startup: {err}");
    415         } else {
    416             info!("Published service presence on startup");
    417         }
    418     }
    419 
    420     #[cfg(not(test))]
    421     tokio::spawn(async move {
    422         let result = publish_service_presence(
    423             client,
    424             identity,
    425             profile,
    426             metadata,
    427             service_cfg,
    428             nip46_config,
    429         )
    430         .await;
    431         if let Err(err) = result {
    432             warn!("Failed to publish service presence on startup: {err}");
    433         } else {
    434             info!("Published service presence on startup");
    435         }
    436     });
    437 }
    438 
    439 #[cfg(not(test))]
    440 #[cfg_attr(coverage_nightly, coverage(off))]
    441 fn spawn_nip46_listener_io(radrootsd: Radrootsd) {
    442     spawn_nip46_listener(radrootsd);
    443 }
    444 
    445 #[cfg(test)]
    446 fn spawn_nip46_listener_io(_radrootsd: Radrootsd) {}
    447 
    448 #[cfg(test)]
    449 async fn start_rpc_io(
    450     state: Radrootsd,
    451     addr: std::net::SocketAddr,
    452     rpc_cfg: &config::RpcConfig,
    453 ) -> Result<ServerHandle> {
    454     if let Some(result) = take_start_rpc_hook_result() {
    455         return result.map_err(anyhow::Error::msg);
    456     }
    457     jsonrpc::start_rpc(state, addr, rpc_cfg).await
    458 }
    459 
    460 #[cfg(not(test))]
    461 #[cfg_attr(coverage_nightly, coverage(off))]
    462 async fn start_rpc_io(
    463     state: Radrootsd,
    464     addr: std::net::SocketAddr,
    465     rpc_cfg: &config::RpcConfig,
    466 ) -> Result<ServerHandle> {
    467     jsonrpc::start_rpc(state, addr, rpc_cfg).await
    468 }
    469 
    470 #[cfg(test)]
    471 async fn wait_for_shutdown_or_stopped(handle: ServerHandle) -> RunWaitOutcome {
    472     if let Some(outcome) = take_wait_hook_result() {
    473         return outcome;
    474     }
    475     handle.stopped().await;
    476     RunWaitOutcome::Stopped
    477 }
    478 
    479 #[cfg(not(test))]
    480 #[cfg_attr(coverage_nightly, coverage(off))]
    481 async fn wait_for_shutdown_or_stopped(handle: ServerHandle) -> RunWaitOutcome {
    482     tokio::select! {
    483         _ = shutdown_signal() => RunWaitOutcome::Shutdown,
    484         _ = handle.stopped() => RunWaitOutcome::Stopped,
    485     }
    486 }
    487 
    488 async fn handle_command(command: cli::Command, settings: &config::Settings) -> Result<()> {
    489     match command {
    490         cli::Command::TransportPublish(command) => match command.command {
    491             cli::TransportPublishSubcommand::Principal(command) => match command.command {
    492                 cli::PrincipalSubcommand::Init(args) => {
    493                     let token = generate_bearer_token();
    494                     let token_hash = hash_bearer_token(token.as_str());
    495                     let store = TransportPublishStore::open(
    496                         settings.config.transport_publish.database_path.clone(),
    497                     )?;
    498                     let allowed_target_policies = args
    499                         .allowed_target_policy
    500                         .iter()
    501                         .map(|policy| parse_target_policy(policy.as_str()))
    502                         .collect::<Result<Vec<_>, _>>()?;
    503                     let allowed_explicit_transport_kinds = args
    504                         .allowed_explicit_transport_kind
    505                         .iter()
    506                         .map(|transport_kind| {
    507                             parse_explicit_transport_kind(transport_kind.as_str())
    508                         })
    509                         .collect::<Result<Vec<_>, _>>()?;
    510                     let allowed_nostr_source_policies = args
    511                         .allowed_nostr_source_policy
    512                         .iter()
    513                         .map(|policy| parse_nostr_source_policy(policy.as_str()))
    514                         .collect::<Result<Vec<_>, _>>()?;
    515                     let principal = store.create_principal(PublishPrincipalInit {
    516                         label: args.label,
    517                         token_hash,
    518                         allowed_pubkeys: args.allowed_pubkey,
    519                         allowed_kinds: args.allowed_kind,
    520                         allowed_target_policies,
    521                         allowed_explicit_transport_kinds,
    522                         allowed_nostr_source_policies,
    523                         allow_request_targets: args.allow_request_targets,
    524                         job_visibility: args.job_visibility.parse()?,
    525                         expires_at_unix: None,
    526                     })?;
    527                     write_token_file(&args.token_file, token.as_str())?;
    528                     println!(
    529                         "{}",
    530                         serde_json::json!({
    531                             "principal_id": principal.principal_id,
    532                             "label": principal.label,
    533                             "token_file": args.token_file,
    534                             "database_path": settings.config.transport_publish.database_path,
    535                         })
    536                     );
    537                     Ok(())
    538                 }
    539             },
    540         },
    541     }
    542 }
    543 
    544 pub async fn run() -> Result<()> {
    545     let (args, settings): (cli::Args, config::Settings) = load_args_and_settings()?;
    546     settings.validate()?;
    547     let authored_profile = settings.authored_profile()?;
    548 
    549     #[cfg(not(test))]
    550     {
    551         let contract = paths::runtime_contract_for_process().context("resolve runtime contract")?;
    552         let report = runtime_startup_report(&args, &settings, &contract);
    553         log_runtime_startup_report(&report);
    554     }
    555 
    556     if let Some(command) = args.command.clone() {
    557         return handle_command(command, &settings).await;
    558     }
    559 
    560     info!("Starting radrootsd");
    561 
    562     let identity = load_service_identity(
    563         args.service.identity.as_deref(),
    564         args.service.allow_generate_identity,
    565     )?;
    566     let radrootsd = Radrootsd::new(
    567         identity.clone(),
    568         settings.config.transport_publish.clone(),
    569         settings.config.nip46.clone(),
    570     );
    571     let radrootsd = radrootsd?;
    572 
    573     for relay in settings.config.service.relays.iter() {
    574         radrootsd.client.add_relay(relay).await?;
    575     }
    576 
    577     if !settings.config.service.relays.is_empty() {
    578         maybe_publish_service_presence(
    579             radrootsd.client.clone(),
    580             identity.clone(),
    581             authored_profile,
    582             settings.metadata.clone(),
    583             settings.config.service.clone(),
    584             settings.config.nip46.clone(),
    585         )
    586         .await;
    587 
    588         spawn_nip46_listener_io(radrootsd.clone());
    589     }
    590 
    591     let addr: std::net::SocketAddr = settings.config.rpc_addr().parse()?;
    592     let handle = start_rpc_io(radrootsd.clone(), addr, &settings.config.rpc).await?;
    593     info!("JSON-RPC listening on {addr}");
    594 
    595     let stop_handle = handle.clone();
    596 
    597     match wait_for_shutdown_or_stopped(handle).await {
    598         RunWaitOutcome::Shutdown => {
    599             info!("Shutting down…");
    600             let _ = stop_handle.stop();
    601         }
    602         RunWaitOutcome::Stopped => {}
    603     }
    604 
    605     Ok(())
    606 }
    607 
    608 fn service_presence_kinds() -> Vec<u32> {
    609     let mut kinds = vec![Kind::NostrConnect.as_u16() as u32];
    610     kinds.sort_unstable();
    611     kinds.dedup();
    612     kinds
    613 }
    614 
    615 #[cfg(test)]
    616 #[cfg_attr(coverage_nightly, coverage(off))]
    617 mod tests {
    618     use super::{
    619         RadrootsdRuntimeStartupReport, RunWaitOutcome, build_service_presence_events, run,
    620         run_bootstrap_hook, run_load_hook, run_start_rpc_hook, run_wait_hook,
    621         runtime_startup_report,
    622     };
    623     use crate::app::identity_storage::DaemonIdentity;
    624     use crate::app::{cli, config, paths};
    625     use crate::core::Radrootsd;
    626     use crate::host_nostr::{ApplicationHandlerSpec, Kind, Metadata};
    627     use crate::transport::jsonrpc;
    628     use std::path::Path;
    629     use std::path::PathBuf;
    630     use tokio::sync::{Mutex, MutexGuard};
    631 
    632     static TEST_LOCK: Mutex<()> = Mutex::const_new(());
    633 
    634     async fn test_guard() -> MutexGuard<'static, ()> {
    635         let guard = TEST_LOCK.lock().await;
    636         *run_load_hook()
    637             .lock()
    638             .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
    639         *run_bootstrap_hook()
    640             .lock()
    641             .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
    642         *run_wait_hook()
    643             .lock()
    644             .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
    645         *run_start_rpc_hook()
    646             .lock()
    647             .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
    648         guard
    649     }
    650 
    651     fn unique_identity_path(suffix: &str) -> PathBuf {
    652         let nanos = std::time::SystemTime::now()
    653             .duration_since(std::time::UNIX_EPOCH)
    654             .expect("time")
    655             .as_nanos();
    656         std::env::temp_dir().join(format!("radrootsd-{suffix}-{nanos}.secret.json"))
    657     }
    658 
    659     fn cleanup_identity_artifacts(path: &Path) {
    660         let _ = std::fs::remove_file(path);
    661         let _ = std::fs::remove_file(crate::app::identity_storage::encrypted_identity_key_path(
    662             path,
    663         ));
    664     }
    665 
    666     fn args_for_identity(path: PathBuf, allow_generate: bool) -> cli::Args {
    667         cli::Args {
    668             service: cli::ServiceCliArgs {
    669                 config: Some(PathBuf::from("config.toml")),
    670                 identity: Some(path),
    671                 allow_generate_identity: allow_generate,
    672             },
    673             command: None,
    674         }
    675     }
    676 
    677     fn settings_with_relays(relays: Vec<String>) -> config::Settings {
    678         let metadata: Metadata =
    679             serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata");
    680         config::Settings {
    681             metadata,
    682             config: config::Configuration {
    683                 service: config::NostrServiceConfig {
    684                     logs_dir: "logs".to_string(),
    685                     relays,
    686                     nip89_identifier: Some("radrootsd".to_string()),
    687                     nip89_extra_tags: Vec::new(),
    688                 },
    689                 rpc: config::RpcConfig {
    690                     addr: "127.0.0.1:0".to_string(),
    691                     ..config::RpcConfig::default()
    692                 },
    693                 rpc_addr: Some("127.0.0.1:0".to_string()),
    694                 nip46: config::Nip46Config::default(),
    695                 transport_publish: config::TransportPublishConfig::default(),
    696             },
    697         }
    698     }
    699 
    700     fn sample_runtime_contract() -> paths::RadrootsdRuntimeContractOutput {
    701         paths::RadrootsdRuntimeContractOutput {
    702             active_profile: "interactive_user".to_string(),
    703             allowed_profiles: vec![
    704                 "interactive_user".to_string(),
    705                 "service_host".to_string(),
    706                 "repo_local".to_string(),
    707             ],
    708             path_overrides: paths::RadrootsdRuntimePathOverrideContractOutput {
    709                 profile_source: "caller".to_string(),
    710                 root_source: "host_defaults".to_string(),
    711                 repo_local_root: None,
    712                 repo_local_root_source: None,
    713                 subordinate_path_override_source: "config_artifact".to_string(),
    714                 subordinate_path_override_keys: vec![
    715                     "config.service.logs_dir".to_string(),
    716                     "config.transport_publish.database_path".to_string(),
    717                 ],
    718             },
    719             default_shared_secret_backend: "encrypted_file".to_string(),
    720             allowed_shared_secret_backends: vec!["encrypted_file".to_string()],
    721             canonical_config_path: PathBuf::from(
    722                 "/home/treesap/.radroots/config/services/radrootsd/config.toml",
    723             ),
    724             canonical_logs_dir: PathBuf::from("/home/treesap/.radroots/logs/services/radrootsd"),
    725             canonical_identity_path: PathBuf::from(
    726                 "/home/treesap/.radroots/secrets/services/radrootsd/identity.secret.json",
    727             ),
    728             canonical_transport_publish_database_path: PathBuf::from(
    729                 "/home/treesap/.radroots/data/services/radrootsd/transport_publish.sqlite",
    730             ),
    731         }
    732     }
    733 
    734     async fn make_handle(settings: &config::Settings) -> jsonrpsee::server::ServerHandle {
    735         let identity = DaemonIdentity::generate();
    736         let state = Radrootsd::new(
    737             identity,
    738             settings.config.transport_publish.clone(),
    739             settings.config.nip46.clone(),
    740         )
    741         .expect("state");
    742         jsonrpc::start_rpc(
    743             state,
    744             "127.0.0.1:0".parse().expect("addr"),
    745             &settings.config.rpc,
    746         )
    747         .await
    748         .expect("rpc handle")
    749     }
    750 
    751     #[tokio::test]
    752     async fn run_returns_error_when_hook_is_missing() {
    753         let _guard = test_guard().await;
    754         let err = run().await.expect_err("missing loader hook should error");
    755         let msg = format!("{err:#}");
    756         assert!(msg.contains("run loader hook not set"));
    757     }
    758 
    759     #[tokio::test]
    760     async fn run_returns_error_when_identity_missing() {
    761         let _guard = test_guard().await;
    762         let args = args_for_identity(PathBuf::from("/tmp/radrootsd-missing.secret.json"), false);
    763         let settings = settings_with_relays(Vec::new());
    764         *run_load_hook()
    765             .lock()
    766             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings)));
    767         let err = run().await.expect_err("missing identity should error");
    768         let msg = format!("{err:#}");
    769         assert!(msg.contains("identity"));
    770     }
    771 
    772     #[tokio::test]
    773     async fn run_covers_shutdown_path_and_presence_success() {
    774         let _guard = test_guard().await;
    775         let path = unique_identity_path("shutdown");
    776         let args = args_for_identity(path.clone(), true);
    777         let settings = settings_with_relays(vec!["wss://relay.example.com".to_string()]);
    778         let handle = make_handle(&settings).await;
    779         *run_load_hook()
    780             .lock()
    781             .unwrap_or_else(std::sync::PoisonError::into_inner) =
    782             Some(Ok((args, settings.clone())));
    783         *run_start_rpc_hook()
    784             .lock()
    785             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle));
    786         *run_wait_hook()
    787             .lock()
    788             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown);
    789         *run_bootstrap_hook()
    790             .lock()
    791             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(()));
    792         assert!(run().await.is_ok());
    793         cleanup_identity_artifacts(&path);
    794     }
    795 
    796     #[tokio::test]
    797     async fn run_covers_stopped_path_and_presence_failure() {
    798         let _guard = test_guard().await;
    799         let path = unique_identity_path("stopped");
    800         let args = args_for_identity(path.clone(), true);
    801         let settings = settings_with_relays(vec!["wss://relay.example.com".to_string()]);
    802         let handle = make_handle(&settings).await;
    803         let _ = handle.stop();
    804         *run_load_hook()
    805             .lock()
    806             .unwrap_or_else(std::sync::PoisonError::into_inner) =
    807             Some(Ok((args, settings.clone())));
    808         *run_start_rpc_hook()
    809             .lock()
    810             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle));
    811         *run_wait_hook()
    812             .lock()
    813             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Stopped);
    814         *run_bootstrap_hook()
    815             .lock()
    816             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Err("boom".to_string()));
    817         assert!(run().await.is_ok());
    818         cleanup_identity_artifacts(&path);
    819     }
    820 
    821     #[tokio::test]
    822     async fn run_skips_presence_when_relays_empty() {
    823         let _guard = test_guard().await;
    824         let path = unique_identity_path("empty");
    825         let args = args_for_identity(path.clone(), true);
    826         let settings = settings_with_relays(Vec::new());
    827         let handle = make_handle(&settings).await;
    828         *run_load_hook()
    829             .lock()
    830             .unwrap_or_else(std::sync::PoisonError::into_inner) =
    831             Some(Ok((args, settings.clone())));
    832         *run_start_rpc_hook()
    833             .lock()
    834             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle));
    835         *run_wait_hook()
    836             .lock()
    837             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown);
    838         assert!(run().await.is_ok());
    839         cleanup_identity_artifacts(&path);
    840     }
    841 
    842     #[tokio::test]
    843     async fn run_returns_error_when_relay_is_invalid() {
    844         let _guard = test_guard().await;
    845         let path = unique_identity_path("invalid-relay");
    846         let args = args_for_identity(path.clone(), true);
    847         let settings = settings_with_relays(vec!["not-a-relay".to_string()]);
    848         *run_load_hook()
    849             .lock()
    850             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings)));
    851         let err = run().await.expect_err("invalid relay should error");
    852         let msg = format!("{err:#}");
    853         assert!(!msg.is_empty());
    854         cleanup_identity_artifacts(&path);
    855     }
    856 
    857     #[tokio::test]
    858     async fn run_returns_error_when_rpc_addr_is_invalid() {
    859         let _guard = test_guard().await;
    860         let path = unique_identity_path("invalid-rpc-addr");
    861         let args = args_for_identity(path.clone(), true);
    862         let mut settings = settings_with_relays(Vec::new());
    863         settings.config.rpc_addr = Some("not-an-addr".to_string());
    864         *run_load_hook()
    865             .lock()
    866             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings)));
    867         let err = run().await.expect_err("invalid rpc addr should error");
    868         let msg = format!("{err:#}");
    869         assert!(msg.contains("invalid"));
    870         cleanup_identity_artifacts(&path);
    871     }
    872 
    873     #[tokio::test]
    874     async fn run_returns_error_when_rpc_start_fails() {
    875         let _guard = test_guard().await;
    876         let path = unique_identity_path("rpc-start-fail");
    877         let args = args_for_identity(path.clone(), true);
    878         let settings = settings_with_relays(Vec::new());
    879         *run_load_hook()
    880             .lock()
    881             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings)));
    882         *run_start_rpc_hook()
    883             .lock()
    884             .unwrap_or_else(std::sync::PoisonError::into_inner) =
    885             Some(Err("rpc start failed".to_string()));
    886         let err = run().await.expect_err("rpc start hook should fail");
    887         let msg = format!("{err:#}");
    888         assert!(msg.contains("rpc start failed"));
    889         cleanup_identity_artifacts(&path);
    890     }
    891 
    892     #[tokio::test]
    893     async fn run_waits_for_stopped_when_wait_hook_is_not_set() {
    894         let _guard = test_guard().await;
    895         let path = unique_identity_path("wait-no-hook");
    896         let args = args_for_identity(path.clone(), true);
    897         let settings = settings_with_relays(Vec::new());
    898         let handle = make_handle(&settings).await;
    899         let _ = handle.stop();
    900         *run_load_hook()
    901             .lock()
    902             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings)));
    903         *run_start_rpc_hook()
    904             .lock()
    905             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle));
    906         assert!(run().await.is_ok());
    907         cleanup_identity_artifacts(&path);
    908     }
    909 
    910     #[tokio::test]
    911     async fn run_starts_rpc_when_start_hook_is_not_set() {
    912         let _guard = test_guard().await;
    913         let path = unique_identity_path("start-rpc-real");
    914         let args = args_for_identity(path.clone(), true);
    915         let settings = settings_with_relays(Vec::new());
    916         *run_load_hook()
    917             .lock()
    918             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings)));
    919         *run_wait_hook()
    920             .lock()
    921             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown);
    922         assert!(run().await.is_ok());
    923         cleanup_identity_artifacts(&path);
    924     }
    925 
    926     #[test]
    927     fn service_presence_kinds_include_nostr_connect_only() {
    928         let kinds = super::service_presence_kinds();
    929 
    930         assert!(kinds.contains(&(crate::host_nostr::Kind::NostrConnect.as_u16() as u32)));
    931         assert_eq!(kinds.len(), 1);
    932     }
    933 
    934     #[test]
    935     fn service_presence_events_use_strict_profile_and_nip89_contracts() {
    936         let identity = DaemonIdentity::generate();
    937         let settings = settings_with_relays(vec!["wss://relay.example.com".to_owned()]);
    938         let profile = settings.authored_profile().expect("authored profile");
    939         let handler_spec = ApplicationHandlerSpec::new(super::service_presence_kinds())
    940             .with_identifier("radrootsd")
    941             .with_metadata(settings.metadata)
    942             .with_relays(settings.config.service.relays);
    943 
    944         let (profile_event, handler_event) =
    945             build_service_presence_events(&identity, &profile, &handler_spec)
    946                 .expect("service presence events");
    947 
    948         assert_eq!(profile_event.kind, Kind::Metadata);
    949         assert!(profile_event.tags.is_empty());
    950         assert_eq!(profile_event.pubkey, identity.public_key());
    951         assert_eq!(
    952             serde_json::from_str::<serde_json::Value>(&profile_event.content)
    953                 .expect("profile JSON"),
    954             serde_json::json!({"name": "radrootsd-test"})
    955         );
    956         assert_eq!(
    957             handler_event.kind,
    958             Kind::Custom(radroots_event::envelope::kind::KIND_APPLICATION_HANDLER as u16)
    959         );
    960         assert_eq!(handler_event.pubkey, identity.public_key());
    961         assert!(
    962             handler_event.tags.iter().any(|tag| {
    963                 tag.as_slice() == ["d".to_owned(), "radrootsd".to_owned()].as_slice()
    964             })
    965         );
    966         assert!(handler_event.tags.iter().any(|tag| {
    967             tag.as_slice() == ["k".to_owned(), Kind::NostrConnect.as_u16().to_string()].as_slice()
    968         }));
    969         assert!(handler_event.tags.iter().any(|tag| {
    970             tag.as_slice() == ["relay".to_owned(), "wss://relay.example.com".to_owned()].as_slice()
    971         }));
    972         assert!(handler_event.verify().is_ok());
    973     }
    974 
    975     #[tokio::test]
    976     async fn bootstrap_presence_fails_closed_without_a_connected_relay() {
    977         let _guard = test_guard().await;
    978         let identity = DaemonIdentity::generate();
    979         let client = crate::host_nostr::DaemonNostrClient::from_identity(&identity);
    980         let settings = settings_with_relays(Vec::new());
    981         let profile = settings.authored_profile().expect("authored profile");
    982         let handler_spec = ApplicationHandlerSpec::new(super::service_presence_kinds())
    983             .with_identifier("radrootsd")
    984             .with_metadata(settings.metadata);
    985 
    986         let error = super::bootstrap_presence(&client, &identity, &profile, &handler_spec)
    987             .await
    988             .expect_err("missing relay connection must fail closed");
    989         assert!(error.to_string().contains("at least one connected relay"));
    990     }
    991 
    992     #[test]
    993     fn runtime_startup_report_prefers_explicit_cli_paths() {
    994         let args = cli::Args {
    995             service: cli::ServiceCliArgs {
    996                 config: Some(PathBuf::from("/tmp/radrootsd/config.toml")),
    997                 identity: Some(PathBuf::from("/tmp/radrootsd/identity.secret.json")),
    998                 allow_generate_identity: false,
    999             },
   1000             command: None,
   1001         };
   1002         let mut settings = settings_with_relays(Vec::new());
   1003         settings.config.service.logs_dir = "/tmp/radrootsd/logs".to_string();
   1004         settings.config.transport_publish.database_path =
   1005             PathBuf::from("/tmp/radrootsd/transport_publish.sqlite");
   1006 
   1007         let contract = sample_runtime_contract();
   1008         let report = runtime_startup_report(&args, &settings, &contract);
   1009 
   1010         assert_eq!(
   1011             report,
   1012             RadrootsdRuntimeStartupReport {
   1013                 active_profile: "interactive_user".to_string(),
   1014                 config_path: PathBuf::from("/tmp/radrootsd/config.toml"),
   1015                 config_path_source: "cli_arg".to_string(),
   1016                 canonical_config_path: PathBuf::from(
   1017                     "/home/treesap/.radroots/config/services/radrootsd/config.toml"
   1018                 ),
   1019                 logs_dir: PathBuf::from("/tmp/radrootsd/logs"),
   1020                 logs_dir_source: "config_artifact".to_string(),
   1021                 canonical_logs_dir: PathBuf::from(
   1022                     "/home/treesap/.radroots/logs/services/radrootsd"
   1023                 ),
   1024                 identity_path: PathBuf::from("/tmp/radrootsd/identity.secret.json"),
   1025                 identity_path_source: "cli_arg".to_string(),
   1026                 canonical_identity_path: PathBuf::from(
   1027                     "/home/treesap/.radroots/secrets/services/radrootsd/identity.secret.json"
   1028                 ),
   1029                 transport_publish_database_path: PathBuf::from(
   1030                     "/tmp/radrootsd/transport_publish.sqlite"
   1031                 ),
   1032                 transport_publish_database_path_source: "config_artifact".to_string(),
   1033                 canonical_transport_publish_database_path: PathBuf::from(
   1034                     "/home/treesap/.radroots/data/services/radrootsd/transport_publish.sqlite"
   1035                 ),
   1036                 path_overrides: sample_runtime_contract().path_overrides,
   1037                 default_shared_secret_backend: "encrypted_file".to_string(),
   1038                 allowed_shared_secret_backends: vec!["encrypted_file".to_string()],
   1039             }
   1040         );
   1041     }
   1042 
   1043     #[test]
   1044     fn runtime_startup_report_falls_back_to_canonical_contract_paths() {
   1045         let args = cli::Args {
   1046             service: cli::ServiceCliArgs {
   1047                 config: None,
   1048                 identity: None,
   1049                 allow_generate_identity: false,
   1050             },
   1051             command: None,
   1052         };
   1053         let contract = sample_runtime_contract();
   1054         let mut settings = settings_with_relays(Vec::new());
   1055         settings.config.service.logs_dir = contract.canonical_logs_dir.display().to_string();
   1056         settings.config.transport_publish.database_path =
   1057             contract.canonical_transport_publish_database_path.clone();
   1058 
   1059         let report = runtime_startup_report(&args, &settings, &contract);
   1060 
   1061         assert_eq!(report.config_path, contract.canonical_config_path);
   1062         assert_eq!(report.config_path_source, "profile_default");
   1063         assert_eq!(report.logs_dir, contract.canonical_logs_dir);
   1064         assert_eq!(report.logs_dir_source, "profile_default");
   1065         assert_eq!(report.identity_path, contract.canonical_identity_path);
   1066         assert_eq!(report.identity_path_source, "profile_default");
   1067         assert_eq!(
   1068             report.transport_publish_database_path,
   1069             contract.canonical_transport_publish_database_path
   1070         );
   1071         assert_eq!(
   1072             report.transport_publish_database_path_source,
   1073             "profile_default"
   1074         );
   1075         assert_eq!(report.path_overrides, contract.path_overrides);
   1076         assert_eq!(report.default_shared_secret_backend, "encrypted_file");
   1077         assert_eq!(
   1078             report.allowed_shared_secret_backends,
   1079             vec!["encrypted_file".to_string()]
   1080         );
   1081     }
   1082 }