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:
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,