radrootsd

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

commit 902a330a0d2e84226e5ee721013f6ca32e58c04f
parent 0c2674a0be83d35b7e171e79873db4e37359dc03
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Apr 2026 17:08:46 +0000

config: align runtime contract surfaces

Diffstat:
Mconfig.toml | 11+++++++++++
Msrc/app/config.rs | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app/runtime.rs | 172++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 303 insertions(+), 2 deletions(-)

diff --git a/config.toml b/config.toml @@ -3,6 +3,17 @@ # launch with RADROOTSD_PATHS_PROFILE=service_host # manual operator runs may instead place the same file at: # ~/.radroots/config/services/radrootsd/config.toml +# when path-like values are omitted, the active profile derives: +# interactive_user: +# logs_dir = ~/.radroots/logs/services/radrootsd +# service identity = ~/.radroots/secrets/services/radrootsd/identity.secret.json +# bridge state = ~/.radroots/data/services/radrootsd/bridge/bridge-jobs.json +# service_host: +# logs_dir = /var/log/radroots/services/radrootsd +# service identity = /etc/radroots/secrets/services/radrootsd/identity.secret.json +# bridge state = /var/lib/radroots/services/radrootsd/bridge/bridge-jobs.json +# the canonical live service identity is always an encrypted local envelope +# only override logs_dir or config.bridge.state_path intentionally [metadata] name = "radrootsd" diff --git a/src/app/config.rs b/src/app/config.rs @@ -13,6 +13,9 @@ const BRIDGE_STATE_DIR_NAME: &str = "bridge"; const BRIDGE_STATE_FILE_NAME: &str = "bridge-jobs.json"; const RADROOTSD_PATHS_PROFILE_ENV: &str = "RADROOTSD_PATHS_PROFILE"; const RADROOTSD_PATHS_REPO_LOCAL_ROOT_ENV: &str = "RADROOTSD_PATHS_REPO_LOCAL_ROOT"; +const RADROOTSD_DEFAULT_SHARED_SECRET_BACKEND: &str = "encrypted_file"; +const RADROOTSD_ALLOWED_PROFILES: [&str; 3] = ["interactive_user", "service_host", "repo_local"]; +const RADROOTSD_ALLOWED_SHARED_SECRET_BACKENDS: [&str; 1] = ["encrypted_file"]; fn default_rpc_addr() -> String { "127.0.0.1:7070".to_string() @@ -92,6 +95,18 @@ struct RadrootsdRuntimePaths { bridge_state_path: PathBuf, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RadrootsdRuntimeContractOutput { + pub active_profile: String, + pub allowed_profiles: Vec<String>, + pub default_shared_secret_backend: String, + pub allowed_shared_secret_backends: Vec<String>, + pub canonical_config_path: PathBuf, + pub canonical_logs_dir: PathBuf, + pub canonical_identity_path: PathBuf, + pub canonical_bridge_state_path: PathBuf, +} + #[derive(Debug, Deserialize, Clone, Default)] struct RawServiceConfig { #[serde(default)] @@ -290,6 +305,39 @@ pub fn default_identity_path_for_process() -> Result<PathBuf> { Ok(default_runtime_paths_for_process()?.identity_path) } +pub fn runtime_contract_for_process() -> Result<RadrootsdRuntimeContractOutput> { + let (profile, repo_local_root) = process_path_selection()?; + runtime_contract_with_resolver( + &RadrootsPathResolver::current(), + profile, + repo_local_root.as_deref(), + ) +} + +fn runtime_contract_with_resolver( + resolver: &RadrootsPathResolver, + profile: RadrootsPathProfile, + repo_local_root: Option<&Path>, +) -> Result<RadrootsdRuntimeContractOutput> { + let paths = resolve_runtime_paths_with_resolver(resolver, profile, repo_local_root)?; + Ok(RadrootsdRuntimeContractOutput { + active_profile: profile.to_string(), + allowed_profiles: RADROOTSD_ALLOWED_PROFILES + .into_iter() + .map(str::to_owned) + .collect(), + default_shared_secret_backend: RADROOTSD_DEFAULT_SHARED_SECRET_BACKEND.to_owned(), + allowed_shared_secret_backends: RADROOTSD_ALLOWED_SHARED_SECRET_BACKENDS + .into_iter() + .map(str::to_owned) + .collect(), + canonical_config_path: paths.config_path, + canonical_logs_dir: paths.logs_dir, + canonical_identity_path: paths.identity_path, + canonical_bridge_state_path: paths.bridge_state_path, + }) +} + fn load_settings_from_path_with_resolver( path: &Path, resolver: &RadrootsPathResolver, @@ -489,6 +537,7 @@ mod tests { use super::{ BridgeConfig, BridgeDeliveryPolicy, Configuration, Nip46Config, RpcConfig, load_settings_from_path_with_resolver, resolve_runtime_paths_with_resolver, + runtime_contract_with_resolver, }; use radroots_runtime::RadrootsNostrServiceConfig; use radroots_runtime_paths::{ @@ -725,4 +774,77 @@ bearer_token = "change-me" ) ); } + + #[test] + fn runtime_contract_output_matches_interactive_user_contract() { + let contract = runtime_contract_with_resolver( + &linux_resolver("/home/treesap"), + RadrootsPathProfile::InteractiveUser, + None, + ) + .expect("interactive-user contract"); + + assert_eq!(contract.active_profile, "interactive_user"); + assert_eq!( + contract.allowed_profiles, + vec![ + "interactive_user".to_owned(), + "service_host".to_owned(), + "repo_local".to_owned(), + ] + ); + assert_eq!(contract.default_shared_secret_backend, "encrypted_file"); + assert_eq!( + contract.allowed_shared_secret_backends, + vec!["encrypted_file".to_owned()] + ); + assert_eq!( + contract.canonical_config_path, + PathBuf::from("/home/treesap/.radroots/config/services/radrootsd/config.toml") + ); + assert_eq!( + contract.canonical_logs_dir, + PathBuf::from("/home/treesap/.radroots/logs/services/radrootsd") + ); + assert_eq!( + contract.canonical_identity_path, + PathBuf::from( + "/home/treesap/.radroots/secrets/services/radrootsd/identity.secret.json" + ) + ); + assert_eq!( + contract.canonical_bridge_state_path, + PathBuf::from( + "/home/treesap/.radroots/data/services/radrootsd/bridge/bridge-jobs.json" + ) + ); + } + + #[test] + fn runtime_contract_output_matches_service_host_contract() { + let contract = runtime_contract_with_resolver( + &linux_resolver("/home/treesap"), + RadrootsPathProfile::ServiceHost, + None, + ) + .expect("service-host contract"); + + assert_eq!(contract.active_profile, "service_host"); + assert_eq!( + contract.canonical_config_path, + PathBuf::from("/etc/radroots/services/radrootsd/config.toml") + ); + assert_eq!( + contract.canonical_logs_dir, + PathBuf::from("/var/log/radroots/services/radrootsd") + ); + assert_eq!( + contract.canonical_identity_path, + PathBuf::from("/etc/radroots/secrets/services/radrootsd/identity.secret.json") + ); + assert_eq!( + contract.canonical_bridge_state_path, + PathBuf::from("/var/lib/radroots/services/radrootsd/bridge/bridge-jobs.json") + ); + } } diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -20,6 +20,7 @@ use radroots_nostr::prelude::{ RadrootsNostrApplicationHandlerSpec, RadrootsNostrKind, radroots_nostr_bootstrap_service_presence, }; +use std::path::PathBuf; #[cfg(test)] static RUN_LOAD_HOOK: std::sync::OnceLock< @@ -45,6 +46,21 @@ enum RunWaitOutcome { Stopped, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct RadrootsdRuntimeStartupReport { + active_profile: String, + config_path: PathBuf, + canonical_config_path: PathBuf, + logs_dir: PathBuf, + canonical_logs_dir: PathBuf, + identity_path: PathBuf, + canonical_identity_path: PathBuf, + bridge_state_path: PathBuf, + canonical_bridge_state_path: PathBuf, + default_shared_secret_backend: String, + allowed_shared_secret_backends: Vec<String>, +} + #[cfg(test)] fn run_load_hook() -> &'static std::sync::Mutex<Option<Result<(cli::Args, config::Settings), String>>> { @@ -123,11 +139,60 @@ fn load_args_and_settings() -> Result<(cli::Args, config::Settings)> { .unwrap_or_else(config::default_config_path_for_process)?; let settings = config::load_settings_from_path(&config_path).context("load configuration")?; - radroots_runtime::init_with(settings.config.service.logs_dir.as_str(), None)?; + radroots_runtime::init_with_logs_dir( + std::path::Path::new(settings.config.service.logs_dir.as_str()), + None, + )?; Ok((args, settings)) } } +fn runtime_startup_report( + args: &cli::Args, + settings: &config::Settings, + contract: &config::RadrootsdRuntimeContractOutput, +) -> RadrootsdRuntimeStartupReport { + RadrootsdRuntimeStartupReport { + active_profile: contract.active_profile.clone(), + config_path: args + .service + .config + .clone() + .unwrap_or_else(|| contract.canonical_config_path.clone()), + canonical_config_path: contract.canonical_config_path.clone(), + logs_dir: PathBuf::from(settings.config.service.logs_dir.as_str()), + canonical_logs_dir: contract.canonical_logs_dir.clone(), + identity_path: args + .service + .identity + .clone() + .unwrap_or_else(|| contract.canonical_identity_path.clone()), + canonical_identity_path: contract.canonical_identity_path.clone(), + bridge_state_path: settings.config.bridge.state_path.clone(), + canonical_bridge_state_path: contract.canonical_bridge_state_path.clone(), + default_shared_secret_backend: contract.default_shared_secret_backend.clone(), + allowed_shared_secret_backends: contract.allowed_shared_secret_backends.clone(), + } +} + +#[cfg(not(test))] +fn log_runtime_startup_report(report: &RadrootsdRuntimeStartupReport) { + info!( + active_profile = report.active_profile.as_str(), + config_path = %report.config_path.display(), + canonical_config_path = %report.canonical_config_path.display(), + logs_dir = %report.logs_dir.display(), + canonical_logs_dir = %report.canonical_logs_dir.display(), + identity_path = %report.identity_path.display(), + canonical_identity_path = %report.canonical_identity_path.display(), + bridge_state_path = %report.bridge_state_path.display(), + canonical_bridge_state_path = %report.canonical_bridge_state_path.display(), + default_shared_secret_backend = report.default_shared_secret_backend.as_str(), + allowed_shared_secret_backends = ?report.allowed_shared_secret_backends, + "radrootsd runtime contract" + ); +} + #[cfg_attr(coverage_nightly, coverage(off))] async fn bootstrap_presence( client: &radroots_nostr::prelude::RadrootsNostrClient, @@ -274,6 +339,14 @@ pub async fn run() -> Result<()> { let (args, settings): (cli::Args, config::Settings) = load_args_and_settings()?; settings.config.validate()?; + #[cfg(not(test))] + { + let contract = + config::runtime_contract_for_process().context("resolve runtime contract")?; + let report = runtime_startup_report(&args, &settings, &contract); + log_runtime_startup_report(&report); + } + info!("Starting radrootsd"); let identity = load_service_identity( @@ -337,7 +410,8 @@ fn service_presence_kinds(bridge_config: &config::BridgeConfig) -> Vec<u32> { #[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::{ - RunWaitOutcome, run, run_bootstrap_hook, run_load_hook, run_start_rpc_hook, run_wait_hook, + RadrootsdRuntimeStartupReport, RunWaitOutcome, run, run_bootstrap_hook, run_load_hook, + run_start_rpc_hook, run_wait_hook, runtime_startup_report, }; use crate::app::{cli, config}; use crate::core::Radrootsd; @@ -418,6 +492,29 @@ mod tests { } } + fn sample_runtime_contract() -> config::RadrootsdRuntimeContractOutput { + config::RadrootsdRuntimeContractOutput { + active_profile: "interactive_user".to_string(), + allowed_profiles: vec![ + "interactive_user".to_string(), + "service_host".to_string(), + "repo_local".to_string(), + ], + default_shared_secret_backend: "encrypted_file".to_string(), + allowed_shared_secret_backends: vec!["encrypted_file".to_string()], + canonical_config_path: PathBuf::from( + "/home/treesap/.radroots/config/services/radrootsd/config.toml", + ), + canonical_logs_dir: PathBuf::from("/home/treesap/.radroots/logs/services/radrootsd"), + canonical_identity_path: PathBuf::from( + "/home/treesap/.radroots/secrets/services/radrootsd/identity.secret.json", + ), + canonical_bridge_state_path: PathBuf::from( + "/home/treesap/.radroots/data/services/radrootsd/bridge/bridge-jobs.json", + ), + } + } + async fn make_handle(settings: &config::Settings) -> jsonrpsee::server::ServerHandle { let identity = RadrootsIdentity::generate(); let state = Radrootsd::new( @@ -640,4 +737,75 @@ mod tests { ); assert!(kinds.contains(&KIND_LISTING)); } + + #[test] + fn runtime_startup_report_prefers_explicit_cli_paths() { + let args = cli::Args { + service: radroots_runtime::RadrootsServiceCliArgs { + config: Some(PathBuf::from("/tmp/radrootsd/config.toml")), + identity: Some(PathBuf::from("/tmp/radrootsd/identity.secret.json")), + allow_generate_identity: false, + }, + }; + let mut settings = settings_with_relays(Vec::new()); + settings.config.service.logs_dir = "/tmp/radrootsd/logs".to_string(); + settings.config.bridge.state_path = PathBuf::from("/tmp/radrootsd/bridge-jobs.json"); + + let report = runtime_startup_report(&args, &settings, &sample_runtime_contract()); + + assert_eq!( + report, + RadrootsdRuntimeStartupReport { + active_profile: "interactive_user".to_string(), + config_path: PathBuf::from("/tmp/radrootsd/config.toml"), + canonical_config_path: PathBuf::from( + "/home/treesap/.radroots/config/services/radrootsd/config.toml" + ), + logs_dir: PathBuf::from("/tmp/radrootsd/logs"), + canonical_logs_dir: PathBuf::from( + "/home/treesap/.radroots/logs/services/radrootsd" + ), + identity_path: PathBuf::from("/tmp/radrootsd/identity.secret.json"), + canonical_identity_path: PathBuf::from( + "/home/treesap/.radroots/secrets/services/radrootsd/identity.secret.json" + ), + bridge_state_path: PathBuf::from("/tmp/radrootsd/bridge-jobs.json"), + canonical_bridge_state_path: PathBuf::from( + "/home/treesap/.radroots/data/services/radrootsd/bridge/bridge-jobs.json" + ), + default_shared_secret_backend: "encrypted_file".to_string(), + allowed_shared_secret_backends: vec!["encrypted_file".to_string()], + } + ); + } + + #[test] + fn runtime_startup_report_falls_back_to_canonical_contract_paths() { + let args = cli::Args { + service: radroots_runtime::RadrootsServiceCliArgs { + config: None, + identity: None, + allow_generate_identity: false, + }, + }; + let contract = sample_runtime_contract(); + let mut settings = settings_with_relays(Vec::new()); + settings.config.service.logs_dir = contract.canonical_logs_dir.display().to_string(); + settings.config.bridge.state_path = contract.canonical_bridge_state_path.clone(); + + let report = runtime_startup_report(&args, &settings, &contract); + + assert_eq!(report.config_path, contract.canonical_config_path); + assert_eq!(report.logs_dir, contract.canonical_logs_dir); + assert_eq!(report.identity_path, contract.canonical_identity_path); + assert_eq!( + report.bridge_state_path, + contract.canonical_bridge_state_path + ); + assert_eq!(report.default_shared_secret_backend, "encrypted_file"); + assert_eq!( + report.allowed_shared_secret_backends, + vec!["encrypted_file".to_string()] + ); + } }