commit 902a330a0d2e84226e5ee721013f6ca32e58c04f
parent 0c2674a0be83d35b7e171e79873db4e37359dc03
Author: triesap <tyson@radroots.org>
Date: Wed, 8 Apr 2026 17:08:46 +0000
config: align runtime contract surfaces
Diffstat:
| M | config.toml | | | 11 | +++++++++++ |
| M | src/app/config.rs | | | 122 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/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()]
+ );
+ }
}