app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 832fc0bf3e87d6b7162b6c587570de3bfe0a878d
parent 4182c7e6574dd44e3efe34306049419fb9f1b0a8
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 03:13:32 +0000

network: inject relay configuration from the desktop host

- parse HarvestCircle relay input only at the desktop composition boundary
- inject an explicit local relay for development and no packaged fallback
- pass typed relay bootstrap data through the UniFFI constructor
- preserve degraded local startup for missing or invalid configuration

Diffstat:
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt | 2+-
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt | 25+++++++++++++++++++++++--
Mapp/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt | 19+++++++++++++++++++
Mapp/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductNamespaceGuardTest.kt | 12++++++++++++
Mcore/compatibility/harvestcircle-ffi-v4.properties | 2+-
Mcore/crates/harvestcircle_application/src/config.rs | 81++++++++++++++++++++++++++++++++++---------------------------------------------
Mcore/crates/harvestcircle_application/src/lib.rs | 4+---
Mcore/crates/harvestcircle_ffi/src/commands.rs | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcore/crates/harvestcircle_ffi/src/lib.rs | 2+-
9 files changed, 156 insertions(+), 65 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt @@ -10,7 +10,7 @@ internal const val EXPECTED_DISTRIBUTION_PACKAGE_VERSION = "1.0.0" internal const val EXPECTED_PRODUCT_COORDINATE_DIGEST = "f81db525a0228782530799911879fb55cb25e8e631d09605fd5084e9bd88fbbe" internal const val EXPECTED_SOURCE_PROVENANCE_DIGEST = "d4d54ab897e98a93dfbe27a9d9589dbc38c3b7e2163097617d59beaf64e0358c" internal const val EXPECTED_SOURCE_FOUNDATION_BASELINE = "a2038b3e25b9e34f0b8fd001f26a8ed10b5772cb" -internal const val EXPECTED_FFI_CONTRACT_HASH = "cfbf28d566b8379904f276500be724a61e549cd04fa2491e9305abc29163c0ef" +internal const val EXPECTED_FFI_CONTRACT_HASH = "ca5c229f2fc6d93e355dc1278f204f7ac06ac3af0c5c8afef30ba0aa7d42c564" internal val EXPECTED_FFI_CONTRACT_MAJOR: UShort = 4.toUShort() internal val MINIMUM_FFI_CONTRACT_MINOR: UShort = 0.toUShort() internal const val EXPECTED_SNAPSHOT_SCHEMA: UInt = 1U diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt @@ -15,6 +15,7 @@ import org.harvestcircle.ffi.HarvestCircleChangeObserver import org.harvestcircle.ffi.IdentityCommandReceiptDto import org.harvestcircle.ffi.IdentityDto import org.harvestcircle.ffi.ObserverSubscription +import org.harvestcircle.ffi.RelayBootstrapInputDto import org.harvestcircle.ffi.RemovalRequest import org.harvestcircle.ffi.RequestContextDto import org.harvestcircle.ffi.ShutdownReceiptDto @@ -247,15 +248,35 @@ class NativeHarvestCircleRuntime internal constructor( } companion object { - fun open(developmentMode: Boolean): NativeHarvestCircleRuntime { + fun open( + developmentMode: Boolean, + relayInput: RelayBootstrapInputDto = desktopRelayBootstrapInput(developmentMode), + ): NativeHarvestCircleRuntime { val expectation = verifyNativeCompatibility(compatibilityDescriptor()) return NativeHarvestCircleRuntime( - UniFfiNativeCorePort(HarvestCircleAppCore.openCompatible(expectation, developmentMode)), + UniFfiNativeCorePort(HarvestCircleAppCore.openCompatible(expectation, developmentMode, relayInput)), ) } } } +internal const val HARVESTCIRCLE_NOSTR_RELAYS_ENVIRONMENT = "HARVESTCIRCLE_NOSTR_RELAYS" +internal const val HARVESTCIRCLE_LOCAL_DEVELOPMENT_RELAY = "ws://localhost:8080" + +internal fun desktopRelayBootstrapInput( + developmentMode: Boolean, + configuredValue: String? = System.getenv(HARVESTCIRCLE_NOSTR_RELAYS_ENVIRONMENT), +): RelayBootstrapInputDto { + val configured = configuredValue?.trim().orEmpty() + val relayUrls = + when { + configured.isNotEmpty() -> configured.split(',').map(String::trim) + developmentMode -> listOf(HARVESTCIRCLE_LOCAL_DEVELOPMENT_RELAY) + else -> emptyList() + } + return RelayBootstrapInputDto(relayUrls) +} + internal fun interface NativeHandleIdSource { fun next(kind: String): String } diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt @@ -183,6 +183,25 @@ class NativeRuntimeMappingsTest { assertEquals(SignerBindingKind.LocalKeyring, SignerBindingKindDto.LOCAL_KEYRING.toSignerBindingKind()) assertEquals(1, SignerBindingKindDto.entries.size) } + + @Test + fun desktopHostBuildsExplicitRelayBootstrapInput() { + assertEquals( + listOf(HARVESTCIRCLE_LOCAL_DEVELOPMENT_RELAY), + desktopRelayBootstrapInput(developmentMode = true, configuredValue = null).relayUrls, + ) + assertEquals( + emptyList(), + desktopRelayBootstrapInput(developmentMode = false, configuredValue = null).relayUrls, + ) + assertEquals( + listOf("wss://relay.one", "wss://relay.two"), + desktopRelayBootstrapInput( + developmentMode = false, + configuredValue = " wss://relay.one, wss://relay.two ", + ).relayUrls, + ) + } } class NativeHarvestCircleRuntimeTest { diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductNamespaceGuardTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductNamespaceGuardTest.kt @@ -12,6 +12,18 @@ import kotlin.test.assertTrue class ProductNamespaceGuardTest { @Test + fun reusableApplicationCoreDoesNotReadTheProcessEnvironment() { + val root = findRepositoryRoot() + val environmentRead = listOf("std", "env").joinToString("::") + val findings = + trackedFiles(root) + .filter { it.startsWith("core/crates/harvestcircle_application/src/") && it.endsWith(".rs") } + .filter { root.resolve(it).readText().contains(environmentRead) } + + assertEquals(emptyList(), findings.sorted()) + } + + @Test fun productionKotlinDoesNotMintProcessLocalOperationCounters() { val root = findRepositoryRoot() val counterType = "Atomic" + "Long" diff --git a/core/compatibility/harvestcircle-ffi-v4.properties b/core/compatibility/harvestcircle-ffi-v4.properties @@ -2,7 +2,7 @@ schema=harvestcircle.ffi.v4 contract.id=harvestcircle-desktop-ffi-v4 contract.major=4 contract.minor=0 -contract.hash=cfbf28d566b8379904f276500be724a61e549cd04fa2491e9305abc29163c0ef +contract.hash=ca5c229f2fc6d93e355dc1278f204f7ac06ac3af0c5c8afef30ba0aa7d42c564 product.coordinate_digest=f81db525a0228782530799911879fb55cb25e8e631d09605fd5084e9bd88fbbe snapshot.schema=1 storage.schema.minimum=5 diff --git a/core/crates/harvestcircle_application/src/config.rs b/core/crates/harvestcircle_application/src/config.rs @@ -4,51 +4,30 @@ use harvestcircle_domain::{ 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. +/// Validates relay URLs supplied by a platform host without reading 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>, +/// Returns a safe configuration error when an entry is invalid or no relay was +/// explicitly supplied. +pub fn relay_configuration_from_urls( + values: &[String], mode: RelayRuntimeMode, ) -> Result<RelayConfiguration, SafeError> { - let configured = value.unwrap_or_default().trim(); - let (source, policy) = if configured.is_empty() { - match mode { - RelayRuntimeMode::Development => (DEVELOPMENT_RELAY, RelayDestinationPolicy::Local), - RelayRuntimeMode::Packaged => return Err(invalid_configuration()), - } - } else { - (configured, RelayDestinationPolicy::Public) + if values.is_empty() { + return Err(invalid_configuration()); + } + let policy = match mode { + RelayRuntimeMode::Development => RelayDestinationPolicy::Local, + RelayRuntimeMode::Packaged => RelayDestinationPolicy::Public, }; - let normalized = normalize_relay_urls(source.split(',').map(str::trim), policy)?; + let normalized = normalize_relay_urls(values.iter().map(String::as_str), policy)?; if normalized.is_empty() { return Err(invalid_configuration()); } @@ -66,24 +45,31 @@ const fn invalid_configuration() -> SafeError { mod tests { use harvestcircle_domain::SafeErrorCode; - use super::{RelayRuntimeMode, relay_configuration_from_value}; + use super::{RelayRuntimeMode, relay_configuration_from_urls}; #[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); + fn relay_config_requires_explicit_input_in_every_mode() { + for mode in [RelayRuntimeMode::Development, RelayRuntimeMode::Packaged] { + let error = relay_configuration_from_urls(&[], mode).expect_err("input required"); + assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); } + + let development = relay_configuration_from_urls( + &["ws://localhost:8080".to_owned()], + RelayRuntimeMode::Development, + ) + .expect("explicit development relay"); + assert_eq!(development.relays()[0].as_str(), "ws://localhost:8080/"); } #[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/ "), + let configuration = relay_configuration_from_urls( + &[ + " wss://relay.one ".to_owned(), + "wss://relay.two".to_owned(), + "wss://relay.one/ ".to_owned(), + ], RelayRuntimeMode::Packaged, ) .expect("configuration"); @@ -97,8 +83,11 @@ mod tests { #[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"), + let error = relay_configuration_from_urls( + &[ + "wss://relay.one".to_owned(), + "https://not-a-relay.test".to_owned(), + ], RelayRuntimeMode::Packaged, ) .expect_err("invalid entry"); diff --git a/core/crates/harvestcircle_application/src/lib.rs b/core/crates/harvestcircle_application/src/lib.rs @@ -26,9 +26,7 @@ pub use app_core::{AppCore, RemovalConfirmationToken, RemovalImpact}; pub use change_stream::{ ChangeSubscriptionId, OrderedSnapshotChanges, SnapshotChange, SnapshotChangeReceiver, }; -pub use config::{ - RelayRuntimeMode, relay_configuration_from_environment, relay_configuration_from_value, -}; +pub use config::{RelayRuntimeMode, relay_configuration_from_urls}; pub use custody::{ GENERATED_KEY_STAGE_TTL, GeneratedKeyRecoveryHandle, GeneratedKeyStage, GeneratedKeyStageView, RecoveryStageId, StagedGeneratedKey, diff --git a/core/crates/harvestcircle_ffi/src/commands.rs b/core/crates/harvestcircle_ffi/src/commands.rs @@ -9,7 +9,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use directories::ProjectDirs; use harvestcircle_application::{ Clock, DurableRequestId, GeneratedKeyRecoveryHandle, RelayConfiguration, RelayRuntimeMode, - RemovalConfirmationToken, relay_configuration_from_environment, + RemovalConfirmationToken, relay_configuration_from_urls, }; use harvestcircle_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; use harvestcircle_nostr::SdkNostrClient; @@ -45,6 +45,12 @@ pub struct RequestContextDto { #[derive(Clone, Debug, Eq, PartialEq)] #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] +pub struct RelayBootstrapInputDto { + pub relay_urls: Vec<String>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] pub struct IdentityCommandReceiptDto { pub request_id: String, pub committed_revision: u64, @@ -261,9 +267,10 @@ impl HarvestCircleAppCore { pub fn open_compatible( expectation: CompatibilityExpectation, development_mode: bool, + relay_input: RelayBootstrapInputDto, ) -> Result<Arc<Self>, HarvestCircleError> { let path = application_database_path(development_mode)?; - Self::open_path_compatible(&path, &expectation, development_mode) + Self::open_path_compatible(&path, &expectation, development_mode, relay_input) } /// Restores durable public application state. @@ -535,25 +542,31 @@ impl HarvestCircleAppCore { path: &Path, expectation: &CompatibilityExpectation, development_mode: bool, + relay_input: RelayBootstrapInputDto, ) -> Result<Arc<Self>, HarvestCircleError> { verify_compatibility(expectation)?; std::fs::create_dir_all(path.parent().ok_or_else(path_unavailable)?) .map_err(|_| path_unavailable())?; - Self::open_path(path, development_mode) + Self::open_path(path, development_mode, relay_input) } // The concrete product opener binds operating-system paths, keyrings, and // SQLite ownership. Platform installation lanes exercise this adapter; // deterministic coverage owns the compatibility and runtime policies. #[cfg_attr(coverage_nightly, coverage(off))] - fn open_path(path: &Path, development_mode: bool) -> Result<Arc<Self>, HarvestCircleError> { + fn open_path( + path: &Path, + development_mode: bool, + relay_input: RelayBootstrapInputDto, + ) -> Result<Arc<Self>, HarvestCircleError> { let mode = if development_mode { RelayRuntimeMode::Development } else { RelayRuntimeMode::Packaged }; - let (relays, startup_relay_problem) = - local_first_relay_configuration(relay_configuration_from_environment(mode)); + let (relays, startup_relay_problem) = local_first_relay_configuration( + relay_configuration_from_urls(&relay_input.relay_urls, mode), + ); let runtime = runtime()?; let actor = runtime.block_on(RuntimeActorHandle::open( path, @@ -728,7 +741,7 @@ mod tests { use std::num::NonZeroUsize; use std::sync::Arc; - use harvestcircle_application::{InMemorySecretStore, RelayConfiguration}; + use harvestcircle_application::{InMemorySecretStore, RelayConfiguration, RelayRuntimeMode}; use harvestcircle_domain::SafeError; use harvestcircle_nostr::SdkNostrClient; use harvestcircle_runtime::{ @@ -741,9 +754,9 @@ mod tests { ACTOR_MAILBOX_CAPACITY, CompatibilityExpectation, DATABASE_APPLICATION, DATABASE_FILENAME, DATABASE_ORGANIZATION, DATABASE_QUALIFIER, FFI_CONTRACT_HASH, FFI_CONTRACT_ID, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, HarvestCircleAppCore, HarvestCircleError, - PRODUCT_COORDINATE_DIGEST, ProjectDirs, RequestContextDto, RuntimeCore, - SNAPSHOT_SCHEMA_VERSION, SystemClock, WireErrorCategory, WireErrorCode, WireRecoveryAction, - actor_mailbox_capacity, compatibility_descriptor, confirmation_expired, + PRODUCT_COORDINATE_DIGEST, ProjectDirs, RelayBootstrapInputDto, RequestContextDto, + RuntimeCore, SNAPSHOT_SCHEMA_VERSION, SystemClock, WireErrorCategory, WireErrorCode, + WireRecoveryAction, actor_mailbox_capacity, compatibility_descriptor, confirmation_expired, generated_commit_failed, local_first_relay_configuration, path_unavailable, runtime, runtime_unavailable, verify_compatibility, }; @@ -1088,7 +1101,15 @@ mod tests { ..compatible }; assert!( - HarvestCircleAppCore::open_path_compatible(&rejected, &incompatible, true).is_err() + HarvestCircleAppCore::open_path_compatible( + &rejected, + &incompatible, + true, + RelayBootstrapInputDto { + relay_urls: Vec::new(), + }, + ) + .is_err() ); assert!(!rejected.parent().expect("parent").exists()); } @@ -1140,4 +1161,35 @@ mod tests { assert!(relays.relays().is_empty()); assert_eq!(degraded, Some(problem)); } + + #[test] + fn injected_relay_input_distinguishes_development_packaged_and_invalid_values() { + let development = harvestcircle_application::relay_configuration_from_urls( + &["ws://localhost:8080".to_owned()], + RelayRuntimeMode::Development, + ) + .expect("explicit local development relay"); + assert_eq!(development.relays()[0].as_str(), "ws://localhost:8080/"); + + let packaged = harvestcircle_application::relay_configuration_from_urls( + &["wss://relay.example".to_owned()], + RelayRuntimeMode::Packaged, + ) + .expect("explicit packaged relay"); + assert_eq!(packaged.relays()[0].as_str(), "wss://relay.example/"); + + for input in [Vec::new(), vec!["https://not-a-relay.example".to_owned()]] { + let (relays, degraded) = local_first_relay_configuration( + harvestcircle_application::relay_configuration_from_urls( + &input, + RelayRuntimeMode::Packaged, + ), + ); + assert!(relays.relays().is_empty()); + assert_eq!( + degraded.map(|problem| problem.code()), + Some(harvestcircle_domain::SafeErrorCode::InvalidRelayConfiguration) + ); + } + } } diff --git a/core/crates/harvestcircle_ffi/src/lib.rs b/core/crates/harvestcircle_ffi/src/lib.rs @@ -8,7 +8,7 @@ mod observer; pub use commands::{ GeneratedRecoveryRequest, HarvestCircleAppCore, HarvestCircleError, IdentityCommandReceiptDto, - RemovalRequest, RequestContextDto, + RelayBootstrapInputDto, RemovalRequest, RequestContextDto, }; pub use contract::{ DISTRIBUTION_PACKAGE_VERSION, FFI_CONTRACT_HASH, FFI_CONTRACT_ID, FFI_CONTRACT_MAJOR,