commit af00441a500a28ef1cdd0e974e68f258a3f37303
parent a7a45d4df766f9c20dab6be86a5f4cae886a382f
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 19:16:04 +0000
core(config): add relay environment configuration
- parse the ordered comma-separated Rust relay setting
- trim and deduplicate valid WebSocket relay addresses
- restrict localhost fallback to development and tests
- fail packaged runtime safely when configuration is absent
Diffstat:
4 files changed, 117 insertions(+), 1 deletion(-)
diff --git a/core/crates/application/src/config.rs b/core/crates/application/src/config.rs
@@ -0,0 +1,105 @@
+use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage, normalize_relay_urls};
+
+use crate::RelayConfiguration;
+
+pub const RELAY_ENVIRONMENT_VARIABLE: &str = "RADROOTS_NOSTR_RELAYS";
+const DEVELOPMENT_RELAY: &str = "ws://localhost:8080";
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum RelayRuntimeMode {
+ Development,
+ Packaged,
+}
+
+/// Reads the process relay configuration once through the Rust-owned boundary.
+///
+/// # Errors
+///
+/// Returns a safe configuration error for missing Unicode or invalid relay data.
+pub fn relay_configuration_from_environment(
+ mode: RelayRuntimeMode,
+) -> Result<RelayConfiguration, SafeError> {
+ let value = match std::env::var(RELAY_ENVIRONMENT_VARIABLE) {
+ Ok(value) => Some(value),
+ Err(std::env::VarError::NotPresent) => None,
+ Err(std::env::VarError::NotUnicode(_)) => return Err(invalid_configuration()),
+ };
+ relay_configuration_from_value(value.as_deref(), mode)
+}
+
+/// Parses an injected comma-separated relay list without mutating process state.
+///
+/// # Errors
+///
+/// Returns a safe configuration error when an entry is invalid or packaged mode
+/// has no configured relay.
+pub fn relay_configuration_from_value(
+ value: Option<&str>,
+ mode: RelayRuntimeMode,
+) -> Result<RelayConfiguration, SafeError> {
+ let configured = value.unwrap_or_default().trim();
+ let source = if configured.is_empty() {
+ match mode {
+ RelayRuntimeMode::Development => DEVELOPMENT_RELAY,
+ RelayRuntimeMode::Packaged => return Err(invalid_configuration()),
+ }
+ } else {
+ configured
+ };
+ let normalized = normalize_relay_urls(source.split(',').map(str::trim))?;
+ if normalized.is_empty() {
+ return Err(invalid_configuration());
+ }
+ Ok(RelayConfiguration::new(normalized))
+}
+
+const fn invalid_configuration() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidRelayConfiguration,
+ SafeMessage::new("The Nostr relay configuration is invalid."),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_studio_domain::SafeErrorCode;
+
+ use super::{RelayRuntimeMode, relay_configuration_from_value};
+
+ #[test]
+ fn relay_config_uses_localhost_fallback_only_for_development() {
+ for value in [None, Some(""), Some(" ")] {
+ let development = relay_configuration_from_value(value, RelayRuntimeMode::Development)
+ .expect("development fallback");
+ assert_eq!(development.relays()[0].as_str(), "ws://localhost:8080/");
+ let packaged = relay_configuration_from_value(value, RelayRuntimeMode::Packaged)
+ .expect_err("packaged configuration required");
+ assert_eq!(packaged.code(), SafeErrorCode::InvalidRelayConfiguration);
+ }
+ }
+
+ #[test]
+ fn relay_config_trims_deduplicates_and_preserves_order() {
+ let configuration = relay_configuration_from_value(
+ Some(" wss://relay.one ,wss://relay.two,wss://relay.one/ "),
+ RelayRuntimeMode::Packaged,
+ )
+ .expect("configuration");
+ let relays = configuration
+ .relays()
+ .iter()
+ .map(radroots_studio_domain::RelayUrl::as_str)
+ .collect::<Vec<_>>();
+ assert_eq!(relays, ["wss://relay.one/", "wss://relay.two/"]);
+ }
+
+ #[test]
+ fn relay_config_rejects_any_invalid_comma_separated_entry() {
+ let error = relay_configuration_from_value(
+ Some("wss://relay.one,https://not-a-relay.test"),
+ RelayRuntimeMode::Packaged,
+ )
+ .expect_err("invalid entry");
+ assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration);
+ }
+}
diff --git a/core/crates/application/src/lib.rs b/core/crates/application/src/lib.rs
@@ -2,6 +2,7 @@
pub mod accounts;
pub mod app_core;
+pub mod config;
pub mod ports;
pub mod recovery;
pub mod secrets;
@@ -14,6 +15,9 @@ pub use accounts::{
InMemoryOperationJournal,
};
pub use app_core::{AppCore, AppObserver, ObserverHandle, RemovalConfirmationToken};
+pub use config::{
+ RelayRuntimeMode, relay_configuration_from_environment, relay_configuration_from_value,
+};
pub use ports::{
AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey,
AccountRepository, AppStateRepository, BoxFuture, CachedProfile, Clock, NostrClient,
diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md
@@ -513,7 +513,7 @@ handoff commit sequence.
### RCLD-08
-- [ ] 36. Add relay configuration source and environment parser.
+- [x] 36. Add relay configuration source and environment parser.
- [ ] 37. Implement Nostr event verification and kind-0 parsing adapter.
- [ ] 38. Add Nostr client port implementation and local relay fixture.
- [ ] 39. Implement cache-first active profile refresh orchestration.
diff --git a/docs/runbooks/local-relay-development.md b/docs/runbooks/local-relay-development.md
@@ -1,5 +1,12 @@
# Local relay development
+Rust reads the ordered comma-separated relay list from
+`RADROOTS_NOSTR_RELAYS`, trims entries, validates the WebSocket policy, and
+deduplicates while preserving first-seen order. Development and tests may use
+`ws://localhost:8080` when the variable is absent or empty. Packaged builds
+have no fallback and report `InvalidRelayConfiguration` until at least one
+valid relay is configured.
+
## Status
Initial runbook contract. Exact commands will be added with the relay fixture.