app

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

commit 56ff9bcaabc2c2865d7e91d23c4c83b5c84d5eaf
parent 77c2e8c3129498f0a8688ffdc515c5fabd29ac15
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 18:17:34 +0000

network: type relay destination policies

- carry destination and read/write capabilities on every relay endpoint
- validate schemes, address classes, duplicates, and empty capabilities in Rust
- parse explicit classified desktop entries and preserve degraded packaged startup
- advance the v4 FFI minor contract and regenerate compatible Kotlin mappings

Diffstat:
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt | 28+++++++++++++++++++++++++---
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/NativeRuntimeMappings.kt | 19++++++++++++++++---
Mapp/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt | 39+++++++++++++++++++++++++++------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ApplicationModels.kt | 34+++++++++++++++++++++++++++-------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiModel.kt | 2+-
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/RuntimeContractsTest.kt | 9++++++---
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/identities/ui/IdentityUiModelTest.kt | 14++++++++++++--
MbuildSrc/src/main/kotlin/org/harvestcircle/gradle/FfiCompatibilityBaseline.kt | 2+-
Mcore/compatibility/harvestcircle-ffi-v4.properties | 4++--
Mcore/crates/harvestcircle_application/src/config.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mcore/crates/harvestcircle_application/src/lib.rs | 2+-
Mcore/crates/harvestcircle_application/src/ports.rs | 8++++----
Mcore/crates/harvestcircle_application/src/profile_refresh.rs | 20+++++++++++++-------
Mcore/crates/harvestcircle_application/src/snapshot.rs | 14++++++++------
Mcore/crates/harvestcircle_domain/src/lib.rs | 2+-
Mcore/crates/harvestcircle_domain/src/relay.rs | 150++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mcore/crates/harvestcircle_ffi/build.rs | 2+-
Mcore/crates/harvestcircle_ffi/src/commands.rs | 109++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mcore/crates/harvestcircle_ffi/src/contract.rs | 2+-
Mcore/crates/harvestcircle_ffi/src/dto.rs | 45++++++++++++++++++++++++++++++++++++++++++---
Mcore/crates/harvestcircle_ffi/src/lib.rs | 8++++----
Mcore/crates/harvestcircle_ffi/src/observer.rs | 11++++++++---
Mcore/crates/harvestcircle_nostr/src/client.rs | 60++++++++++++++++++++++++++++++++++++++++++------------------
Mcore/crates/harvestcircle_runtime/src/runtime_actor.rs | 35+++++++++++++++++++++++++----------
Mcore/crates/harvestcircle_runtime/tests/local_relay_e2e.rs | 11++++++++---
Mspec/harvestcircle_mvp_v1/ARCHITECTURE.md | 17+++++++++++++++++
26 files changed, 572 insertions(+), 230 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt @@ -18,6 +18,8 @@ import org.harvestcircle.ffi.IdentityCommandReceiptDto import org.harvestcircle.ffi.IdentityDto import org.harvestcircle.ffi.ObserverSubscription import org.harvestcircle.ffi.RelayBootstrapInputDto +import org.harvestcircle.ffi.RelayDestinationDto +import org.harvestcircle.ffi.RelayEndpointDto import org.harvestcircle.ffi.RemovalRequest import org.harvestcircle.ffi.RequestContextDto import org.harvestcircle.ffi.ShutdownReceiptDto @@ -292,20 +294,40 @@ class NativeHarvestCircleRuntime internal constructor( } internal const val HARVESTCIRCLE_NOSTR_RELAYS_ENVIRONMENT = "HARVESTCIRCLE_NOSTR_RELAYS" -internal const val HARVESTCIRCLE_LOCAL_DEVELOPMENT_RELAY = "ws://localhost:8080" +internal const val HARVESTCIRCLE_LOCAL_DEVELOPMENT_RELAY = "local|ws://localhost:8080" internal fun desktopRelayBootstrapInput( developmentMode: Boolean, configuredValue: String? = System.getenv(HARVESTCIRCLE_NOSTR_RELAYS_ENVIRONMENT), ): RelayBootstrapInputDto { val configured = configuredValue?.trim().orEmpty() - val relayUrls = + val entries = when { configured.isNotEmpty() -> configured.split(',').map(String::trim) developmentMode -> listOf(HARVESTCIRCLE_LOCAL_DEVELOPMENT_RELAY) else -> emptyList() } - return RelayBootstrapInputDto(relayUrls) + return RelayBootstrapInputDto(entries.map(::parseRelayEndpoint)) +} + +private fun parseRelayEndpoint(value: String): RelayEndpointDto { + val parts = value.split('|', limit = 2) + require(parts.size == 2 && parts.all(String::isNotBlank)) { + "Relay entries must include an explicit destination classification" + } + val destination = + when (parts[0].trim()) { + "local" -> RelayDestinationDto.LOCAL + "private" -> RelayDestinationDto.PRIVATE_NETWORK + "public" -> RelayDestinationDto.PUBLIC + else -> error("Relay destination classification is invalid") + } + return RelayEndpointDto( + url = parts[1].trim(), + destination = destination, + read = true, + write = true, + ) } internal fun interface NativeHandleIdSource { diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeRuntimeMappings.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeRuntimeMappings.kt @@ -89,7 +89,7 @@ internal fun IdentityDto.toIdentitySummary(): IdentitySummary = lastUsedAt = lastUsedAtSeconds?.let(::UnixSeconds), ) -internal fun ActiveIdentityDto.toActiveIdentity(configuredRelays: List<String>): ActiveIdentity = +internal fun ActiveIdentityDto.toActiveIdentity(configuredRelays: List<RelayEndpoint>): ActiveIdentity = ActiveIdentity( identity = identity.toIdentitySummary(), relays = RelaySummary(configuredRelays, relayState.toRelayConnectionState()), @@ -102,16 +102,29 @@ internal fun AppSnapshotDto.toApplicationSnapshot(): ApplicationSnapshot = revision = SnapshotRevision(revision), lifecycle = lifecycle.toApplicationLifecycle(), lifecycleProblem = lifecycleError?.toApplicationProblem(), - configuredRelays = configuredRelays, + configuredRelays = configuredRelays.map { it.toRelayEndpoint() }, identities = identities.map(IdentityDto::toIdentitySummary), selectedIdentityId = selectedPublicKeyHex?.let(IdentityId::fromPublicKeyHex), session = session.toSessionLifecycle(), sessionSubjectIdentityId = sessionSubjectPublicKeyHex?.let(IdentityId::fromPublicKeyHex), sessionProblem = sessionError?.toApplicationProblem(), - activeIdentity = activeIdentity?.toActiveIdentity(configuredRelays), + activeIdentity = activeIdentity?.toActiveIdentity(configuredRelays.map { it.toRelayEndpoint() }), recoverableProblem = recoverableProblem?.toApplicationProblem(), ) +internal fun org.harvestcircle.ffi.RelayEndpointDto.toRelayEndpoint(): RelayEndpoint = + RelayEndpoint( + url = url, + destination = + when (destination) { + org.harvestcircle.ffi.RelayDestinationDto.LOCAL -> RelayDestination.Local + org.harvestcircle.ffi.RelayDestinationDto.PRIVATE_NETWORK -> RelayDestination.PrivateNetwork + org.harvestcircle.ffi.RelayDestinationDto.PUBLIC -> RelayDestination.Public + }, + read = read, + write = write, + ) + internal fun ProfileDto.toProfileSummary(): ProfileSummary = ProfileSummary( name = name, diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt @@ -22,6 +22,8 @@ import org.harvestcircle.ffi.IdentityDto import org.harvestcircle.ffi.ProfileDto import org.harvestcircle.ffi.ProfileLoadStateDto import org.harvestcircle.ffi.RelayConnectionStateDto +import org.harvestcircle.ffi.RelayDestinationDto +import org.harvestcircle.ffi.RelayEndpointDto import org.harvestcircle.ffi.RequestContextDto import org.harvestcircle.ffi.SafeErrorDto import org.harvestcircle.ffi.SessionStateDto @@ -163,7 +165,7 @@ class NativeRuntimeMappingsTest { assertEquals(SnapshotRevision(2UL), mapped.revision) assertEquals(ApplicationLifecycle.Degraded, mapped.lifecycle) assertEquals(ApplicationErrorCode.RelayConnectionFailed, mapped.lifecycleProblem?.code) - assertEquals(native.configuredRelays, mapped.configuredRelays) + assertEquals(native.configuredRelays.map { it.url }, mapped.configuredRelays.map { it.url }) assertEquals( native.identities.single().publicKeyHex, mapped.identities @@ -242,21 +244,26 @@ class NativeRuntimeMappingsTest { @Test fun desktopHostBuildsExplicitRelayBootstrapInput() { - assertEquals( - listOf(HARVESTCIRCLE_LOCAL_DEVELOPMENT_RELAY), - desktopRelayBootstrapInput(developmentMode = true, configuredValue = null).relayUrls, - ) + val development = desktopRelayBootstrapInput(developmentMode = true, configuredValue = null) + assertEquals("ws://localhost:8080", development.endpoints.single().url) + assertEquals(RelayDestinationDto.LOCAL, development.endpoints.single().destination) assertEquals( emptyList(), - desktopRelayBootstrapInput(developmentMode = false, configuredValue = null).relayUrls, + desktopRelayBootstrapInput(developmentMode = false, configuredValue = null).endpoints, ) - assertEquals( - listOf("wss://relay.one", "wss://relay.two"), + val mixed = desktopRelayBootstrapInput( - developmentMode = false, - configuredValue = " wss://relay.one, wss://relay.two ", - ).relayUrls, + developmentMode = true, + configuredValue = " local|ws://127.0.0.1:8080, public|wss://relay.example ", + ).endpoints + assertEquals( + listOf(RelayDestinationDto.LOCAL, RelayDestinationDto.PUBLIC), + mixed.map { it.destination }, ) + assertTrue(mixed.all { it.read && it.write }) + assertFailsWith<IllegalArgumentException> { + desktopRelayBootstrapInput(developmentMode = true, configuredValue = "wss://unclassified.example") + } } } @@ -479,7 +486,15 @@ private fun populatedSnapshot(revision: ULong): AppSnapshotDto { revision = revision, lifecycle = AppLifecycleDto.DEGRADED, lifecycleError = safeError(WireErrorCode.RELAY_CONNECTION_FAILED), - configuredRelays = listOf("wss://relay.example"), + configuredRelays = + listOf( + RelayEndpointDto( + url = "wss://relay.example", + destination = RelayDestinationDto.PUBLIC, + read = true, + write = true, + ), + ), identities = listOf(identity), selectedPublicKeyHex = identity.publicKeyHex, session = SessionStateDto.ACTIVE, diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ApplicationModels.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ApplicationModels.kt @@ -62,13 +62,32 @@ enum class RelayConnectionState { Error, } +enum class RelayDestination { + Local, + PrivateNetwork, + Public, +} + +data class RelayEndpoint( + val url: String, + val destination: RelayDestination, + val read: Boolean, + val write: Boolean, +) { + init { + requireSafeText(url, "Relay URL", 2048) + require(read || write) { "Relay endpoint must allow reading or writing" } + } +} + data class RelaySummary( - val destinations: List<String>, + val endpoints: List<RelayEndpoint>, val state: RelayConnectionState, ) { init { - require(destinations.distinct() == destinations) { "Relay destinations must be unique" } - destinations.forEach { requireSafeText(it, "Relay destination", 2048) } + require(endpoints.map(RelayEndpoint::url).distinct().size == endpoints.size) { + "Relay endpoints must be unique" + } } } @@ -169,7 +188,7 @@ data class ApplicationSnapshot( val revision: SnapshotRevision, val lifecycle: ApplicationLifecycle, val lifecycleProblem: ApplicationProblem?, - val configuredRelays: List<String>, + val configuredRelays: List<RelayEndpoint>, val identities: List<IdentitySummary>, val selectedIdentityId: IdentityId?, val session: SessionLifecycle, @@ -179,8 +198,9 @@ data class ApplicationSnapshot( val recoverableProblem: ApplicationProblem?, ) { init { - require(configuredRelays.distinct() == configuredRelays) { "Configured relays must be unique" } - configuredRelays.forEach { requireSafeText(it, "Configured relay", 2048) } + require(configuredRelays.map(RelayEndpoint::url).distinct().size == configuredRelays.size) { + "Configured relays must be unique" + } require(identities.map(IdentitySummary::id).distinct().size == identities.size) { "Snapshot identities must be unique" } @@ -197,7 +217,7 @@ data class ApplicationSnapshot( require((session == SessionLifecycle.Active) == (activeIdentity != null)) { "Active session and active identity must agree" } - require(activeIdentity == null || activeIdentity.relays.destinations == configuredRelays) { + require(activeIdentity == null || activeIdentity.relays.endpoints == configuredRelays) { "Active identity relays do not match configured relays" } } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiModel.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiModel.kt @@ -84,7 +84,7 @@ fun HarvestCirclePresenterState.toUiModel(): HarvestCircleUiModel { route = route, identities = identities, activeIdentity = snapshot.activeIdentity?.toUiModel(selectedPublicKeyHex), - configuredRelays = snapshot.configuredRelays, + configuredRelays = snapshot.configuredRelays.map { it.url }, importDraft = importDraft, generatedKeyBackup = generatedKeyBackup?.let { diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/RuntimeContractsTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/RuntimeContractsTest.kt @@ -39,7 +39,7 @@ class RuntimeContractsTest { ReleaseReadinessProblem.SourceDateEpoch to clean.copy(sourceDateEpoch = 0UL), ReleaseReadinessProblem.FfiContractId to clean.copy(ffiContractId = "wrong"), ReleaseReadinessProblem.FfiContractMajor to clean.copy(ffiContractMajor = 5.toUShort()), - ReleaseReadinessProblem.FfiContractMinor to clean.copy(ffiContractMinor = 1.toUShort()), + ReleaseReadinessProblem.FfiContractMinor to clean.copy(ffiContractMinor = 2.toUShort()), ReleaseReadinessProblem.FfiContractHash to clean.copy(ffiContractHash = "bad"), ReleaseReadinessProblem.SnapshotSchema to clean.copy(snapshotSchemaVersion = 0U), ReleaseReadinessProblem.StorageSchema to @@ -181,7 +181,7 @@ private fun releaseReadyBuildInfo(): BuildInfo { distributionPackageVersion = "1.0.0", ffiContractId = "harvestcircle-desktop-ffi-v4", ffiContractMajor = 4.toUShort(), - ffiContractMinor = 0.toUShort(), + ffiContractMinor = 1.toUShort(), ffiContractHash = "c".repeat(64), snapshotSchemaVersion = 1U, minimumStorageSchemaVersion = 5U, @@ -226,7 +226,10 @@ private fun snapshot(revision: ULong): ApplicationSnapshot { revision = SnapshotRevision(revision), lifecycle = ApplicationLifecycle.Ready, lifecycleProblem = null, - configuredRelays = listOf("wss://relay.example"), + configuredRelays = + listOf( + RelayEndpoint("wss://relay.example", RelayDestination.Public, read = true, write = true), + ), identities = listOf(identity), selectedIdentityId = identity.id, session = SessionLifecycle.SignedOut, diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/identities/ui/IdentityUiModelTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/identities/ui/IdentityUiModelTest.kt @@ -14,6 +14,8 @@ import org.harvestcircle.application.ProfileLoadState import org.harvestcircle.application.ProfileSummary import org.harvestcircle.application.RecoveryAction import org.harvestcircle.application.RelayConnectionState +import org.harvestcircle.application.RelayDestination +import org.harvestcircle.application.RelayEndpoint import org.harvestcircle.application.RelaySummary import org.harvestcircle.application.SessionLifecycle import org.harvestcircle.application.SignerAvailability @@ -37,7 +39,7 @@ class IdentityUiModelTest { active = ActiveIdentity( identity = identity, - relays = RelaySummary(listOf("ws://localhost:8080"), RelayConnectionState.Connected), + relays = RelaySummary(listOf(localRelay()), RelayConnectionState.Connected), profileState = ProfileLoadState.Fresh, profile = ProfileSummary("alice", "Alice", "alice@example.com", "Farmer", "https://example.com/a.png"), ), @@ -111,7 +113,7 @@ private fun snapshot( revision = SnapshotRevision(1UL), lifecycle = ApplicationLifecycle.Ready, lifecycleProblem = null, - configuredRelays = listOf("ws://localhost:8080"), + configuredRelays = listOf(localRelay()), identities = listOfNotNull(identity), selectedIdentityId = identity?.id, session = if (active == null) SessionLifecycle.SignedOut else SessionLifecycle.Active, @@ -121,6 +123,14 @@ private fun snapshot( recoverableProblem = null, ) +private fun localRelay() = + RelayEndpoint( + url = "ws://localhost:8080", + destination = RelayDestination.Local, + read = true, + write = true, + ) + private fun identity() = IdentitySummary( id = IdentityId.fromPublicKeyHex("12".repeat(32)), diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FfiCompatibilityBaseline.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FfiCompatibilityBaseline.kt @@ -52,7 +52,7 @@ class FfiCompatibilityBaseline private constructor( require(values.getValue("schema") == "harvestcircle.ffi.v4") require(values.getValue("contract.id") == "harvestcircle-desktop-ffi-v4") require(values.getValue("contract.major") == "4") - require(values.getValue("contract.minor") == "0") + require(values.getValue("contract.minor") == "1") require(values.getValue("snapshot.schema") == "1") require(values.getValue("storage.schema.minimum") == "5") require(values.getValue("storage.schema.current") == "10") diff --git a/core/compatibility/harvestcircle-ffi-v4.properties b/core/compatibility/harvestcircle-ffi-v4.properties @@ -1,8 +1,8 @@ schema=harvestcircle.ffi.v4 contract.id=harvestcircle-desktop-ffi-v4 contract.major=4 -contract.minor=0 -contract.hash=565f25d8a3ddf418b06a320c92284455ec4d8b82886cde8609a93bbb2486c3a4 +contract.minor=1 +contract.hash=c7a84960e53cd9df35d676bab28294eb048a8b86c766d81cded2635b64a7f3d6 product.coordinate_digest=93bf10e334e989b20ba5fb8ed05e5d55b83f4502efba5f893aef4dc1a66c8223 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 @@ -1,37 +1,59 @@ +use std::collections::HashSet; + use harvestcircle_domain::{ - RelayDestinationPolicy, SafeError, SafeErrorCode, SafeMessage, normalize_relay_urls, + RelayDestinationPolicy, RelayEndpoint, SafeError, SafeErrorCode, SafeMessage, }; use crate::RelayConfiguration; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum RelayRuntimeMode { - Development, - Packaged, +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RelayEndpointInput { + url: String, + destination: RelayDestinationPolicy, + read: bool, + write: bool, +} + +impl RelayEndpointInput { + #[must_use] + pub fn new( + url: impl Into<String>, + destination: RelayDestinationPolicy, + read: bool, + write: bool, + ) -> Self { + Self { + url: url.into(), + destination, + read, + write, + } + } } -/// Validates relay URLs supplied by a platform host without reading process state. +/// Validates explicitly classified relay endpoints supplied by a platform host. /// /// # Errors /// /// 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, +pub fn relay_configuration_from_endpoints( + values: &[RelayEndpointInput], ) -> Result<RelayConfiguration, SafeError> { 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(values.iter().map(String::as_str), policy)?; - if normalized.is_empty() { - return Err(invalid_configuration()); + let mut seen = HashSet::new(); + let mut endpoints = Vec::with_capacity(values.len()); + for value in values { + let endpoint = + RelayEndpoint::parse(&value.url, value.destination, value.read, value.write)?; + if !seen.insert(endpoint.url().as_str().to_owned()) { + return Err(invalid_configuration()); + } + endpoints.push(endpoint); } - RelayConfiguration::new(normalized) + RelayConfiguration::new(endpoints) } const fn invalid_configuration() -> SafeError { @@ -43,53 +65,84 @@ const fn invalid_configuration() -> SafeError { #[cfg(test)] mod tests { - use harvestcircle_domain::SafeErrorCode; + use harvestcircle_domain::{RelayDestinationPolicy, SafeErrorCode}; - use super::{RelayRuntimeMode, relay_configuration_from_urls}; + use super::{RelayEndpointInput, relay_configuration_from_endpoints}; #[test] - 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); - } + fn relay_config_requires_explicit_input_and_supports_mixed_destinations() { + let error = relay_configuration_from_endpoints(&[]).expect_err("input required"); + assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); - let development = relay_configuration_from_urls( - &["ws://localhost:8080".to_owned()], - RelayRuntimeMode::Development, - ) + let development = relay_configuration_from_endpoints(&[ + RelayEndpointInput::new( + "ws://localhost:8080", + RelayDestinationPolicy::Local, + true, + true, + ), + RelayEndpointInput::new( + "wss://relay.example", + RelayDestinationPolicy::Public, + true, + true, + ), + ]) .expect("explicit development relay"); - assert_eq!(development.relays()[0].as_str(), "ws://localhost:8080/"); + assert_eq!( + development.relays()[0].url().as_str(), + "ws://localhost:8080/" + ); + assert_eq!( + development.relays()[1].destination(), + RelayDestinationPolicy::Public + ); } #[test] - fn relay_config_trims_deduplicates_and_preserves_order() { - let configuration = relay_configuration_from_urls( - &[ - " wss://relay.one ".to_owned(), - "wss://relay.two".to_owned(), - "wss://relay.one/ ".to_owned(), + fn relay_config_rejects_normalized_duplicates_and_capability_free_entries() { + for values in [ + vec![ + RelayEndpointInput::new( + "wss://relay.one", + RelayDestinationPolicy::Public, + true, + false, + ), + RelayEndpointInput::new( + "wss://RELAY.one/", + RelayDestinationPolicy::Public, + false, + true, + ), ], - RelayRuntimeMode::Packaged, - ) - .expect("configuration"); - let relays = configuration - .relays() - .iter() - .map(harvestcircle_domain::RelayUrl::as_str) - .collect::<Vec<_>>(); - assert_eq!(relays, ["wss://relay.one/", "wss://relay.two/"]); + vec![RelayEndpointInput::new( + "wss://relay.one", + RelayDestinationPolicy::Public, + false, + false, + )], + ] { + assert!(relay_configuration_from_endpoints(&values).is_err()); + } } #[test] fn relay_config_rejects_any_invalid_comma_separated_entry() { - let error = relay_configuration_from_urls( - &[ - "wss://relay.one".to_owned(), - "https://not-a-relay.test".to_owned(), - ], - RelayRuntimeMode::Packaged, - ) + let error = relay_configuration_from_endpoints(&[ + RelayEndpointInput::new( + "wss://relay.one", + RelayDestinationPolicy::Public, + true, + true, + ), + RelayEndpointInput::new( + "https://not-a-relay.test", + RelayDestinationPolicy::Public, + true, + true, + ), + ]) .expect_err("invalid entry"); assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); } diff --git a/core/crates/harvestcircle_application/src/lib.rs b/core/crates/harvestcircle_application/src/lib.rs @@ -26,7 +26,7 @@ pub use app_core::{AppCore, RemovalConfirmationToken, RemovalImpact}; pub use change_stream::{ ChangeSubscriptionId, OrderedSnapshotChanges, SnapshotChange, SnapshotChangeReceiver, }; -pub use config::{RelayRuntimeMode, relay_configuration_from_urls}; +pub use config::{RelayEndpointInput, relay_configuration_from_endpoints}; pub use custody::{ GENERATED_KEY_STAGE_TTL, GeneratedKeyRecoveryHandle, GeneratedKeyStage, GeneratedKeyStageView, RecoveryStageId, StagedGeneratedKey, diff --git a/core/crates/harvestcircle_application/src/ports.rs b/core/crates/harvestcircle_application/src/ports.rs @@ -3,7 +3,7 @@ use std::pin::Pin; use std::time::Instant; use harvestcircle_domain::{ - Kind0ProfileCandidate, NostrIdentity, Npub, Nsec, PublicKey, RelayUrl, SafeError, + Kind0ProfileCandidate, NostrIdentity, Npub, Nsec, PublicKey, RelayEndpoint, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, SignerAvailability, UnixTimestamp, }; @@ -626,7 +626,7 @@ pub trait NostrClient: Send + Sync { fn fetch_profile<'a>( &'a self, public_key: PublicKey, - relays: &'a [RelayUrl], + relays: &'a [RelayEndpoint], deadline: Instant, ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>>; } @@ -708,7 +708,7 @@ mod tests { use std::sync::Mutex; - use harvestcircle_domain::{NostrIdentity, PublicKey, RelayUrl, SafeError, UnixTimestamp}; + use harvestcircle_domain::{NostrIdentity, PublicKey, RelayEndpoint, SafeError, UnixTimestamp}; use super::{ AppStateRepository, BoxFuture, CachedProfile, Clock, DurableOperationReceipt, @@ -879,7 +879,7 @@ mod tests { fn fetch_profile<'a>( &'a self, _public_key: PublicKey, - _relays: &'a [RelayUrl], + _relays: &'a [RelayEndpoint], _deadline: Instant, ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { Box::pin(async { Ok(ProfileFetchResult::complete(None)) }) diff --git a/core/crates/harvestcircle_application/src/profile_refresh.rs b/core/crates/harvestcircle_application/src/profile_refresh.rs @@ -1,4 +1,4 @@ -use harvestcircle_domain::{PublicKey, RelayUrl, SafeError, SafeErrorCode}; +use harvestcircle_domain::{PublicKey, RelayEndpoint, SafeError, SafeErrorCode}; use std::time::Instant; use crate::{ @@ -11,7 +11,7 @@ use crate::{ pub struct ProfileRefreshPlan { public_key: PublicKey, active_identity: ActiveIdentitySnapshot, - relays: Vec<RelayUrl>, + relays: Vec<RelayEndpoint>, expected_revision: SnapshotRevision, } @@ -27,7 +27,7 @@ impl ProfileRefreshPlan { } #[must_use] - pub fn relays(&self) -> &[RelayUrl] { + pub fn relays(&self) -> &[RelayEndpoint] { &self.relays } @@ -256,7 +256,7 @@ mod tests { use harvestcircle_domain::{ EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, RelayDestinationPolicy, - RelayUrl, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, + RelayEndpoint, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, select_latest_kind0, }; @@ -323,7 +323,7 @@ mod tests { fn fetch_profile<'a>( &'a self, _public_key: PublicKey, - _relays: &'a [RelayUrl], + _relays: &'a [RelayEndpoint], _deadline: std::time::Instant, ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { let result = self.0.clone(); @@ -351,7 +351,7 @@ mod tests { fn fetch_profile<'a>( &'a self, _public_key: PublicKey, - _relays: &'a [RelayUrl], + _relays: &'a [RelayEndpoint], _deadline: std::time::Instant, ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { Box::pin(async move { @@ -374,7 +374,13 @@ mod tests { fn active_core(profiles: &MemoryProfiles, cached_name: Option<&str>) -> (AppCore, PublicKey) { let relays = RelayConfiguration::new(vec![ - RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Local).expect("relay"), + RelayEndpoint::parse( + "ws://localhost:8080", + RelayDestinationPolicy::Local, + true, + true, + ) + .expect("relay"), ]) .expect("relay configuration"); let core = AppCore::in_memory(relays); diff --git a/core/crates/harvestcircle_application/src/snapshot.rs b/core/crates/harvestcircle_application/src/snapshot.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; use harvestcircle_domain::{ - NostrIdentity, ProfileMetadata, PublicKey, RelayUrl, SafeError, SafeErrorCode, SafeMessage, + NostrIdentity, ProfileMetadata, PublicKey, RelayEndpoint, SafeError, SafeErrorCode, SafeMessage, }; pub const MAX_CONFIGURED_RELAYS: usize = 16; @@ -69,7 +69,7 @@ pub enum ProfileLoadState { } #[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct RelayConfiguration(Vec<RelayUrl>); +pub struct RelayConfiguration(Vec<RelayEndpoint>); impl RelayConfiguration { /// Creates a bounded, explicitly classified relay configuration. @@ -78,7 +78,7 @@ impl RelayConfiguration { /// /// Returns a safe configuration error before runtime or network work when /// the relay count exceeds the HarvestCircle policy. - pub fn new(relays: Vec<RelayUrl>) -> Result<Self, SafeError> { + pub fn new(relays: Vec<RelayEndpoint>) -> Result<Self, SafeError> { if relays.len() > MAX_CONFIGURED_RELAYS { return Err(relay_limit_exceeded()); } @@ -86,7 +86,7 @@ impl RelayConfiguration { } #[must_use] - pub fn relays(&self) -> &[RelayUrl] { + pub fn relays(&self) -> &[RelayEndpoint] { &self.0 } } @@ -296,7 +296,7 @@ const fn invalid_snapshot() -> SafeError { mod tests { use harvestcircle_domain::{ IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, - RelayDestinationPolicy, RelayUrl, SafeErrorCode, SignerAvailability, UnixTimestamp, + RelayDestinationPolicy, RelayEndpoint, SafeErrorCode, SignerAvailability, UnixTimestamp, }; use super::{ @@ -338,9 +338,11 @@ mod tests { fn relay_configuration_rejects_excess_targets_before_runtime_work() { let relays = (0..=super::MAX_CONFIGURED_RELAYS) .map(|index| { - RelayUrl::parse( + RelayEndpoint::parse( format!("wss://relay-{index}.example").as_str(), RelayDestinationPolicy::Public, + true, + true, ) .expect("relay") }) diff --git a/core/crates/harvestcircle_domain/src/lib.rs b/core/crates/harvestcircle_domain/src/lib.rs @@ -17,5 +17,5 @@ pub use key::{ SecretKeyInput, SecretKeyInputKind, classify_persisted_public_key, }; pub use profile::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0}; -pub use relay::{RelayDestinationPolicy, RelayUrl, normalize_relay_urls}; +pub use relay::{RelayDestinationPolicy, RelayEndpoint, RelayUrl}; pub use time::UnixTimestamp; diff --git a/core/crates/harvestcircle_domain/src/relay.rs b/core/crates/harvestcircle_domain/src/relay.rs @@ -1,6 +1,5 @@ //! Validated Nostr relay values. -use std::collections::HashSet; use std::fmt::{self, Display, Formatter}; use url::{Host, Url}; @@ -20,6 +19,57 @@ pub struct RelayUrl { policy: RelayDestinationPolicy, } +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct RelayEndpoint { + url: RelayUrl, + read: bool, + write: bool, +} + +impl RelayEndpoint { + /// Validates one explicitly classified relay endpoint and its capabilities. + /// + /// # Errors + /// + /// Returns a safe configuration error when the URL and destination conflict + /// or when the endpoint has neither read nor write capability. + pub fn parse( + value: &str, + destination: RelayDestinationPolicy, + read: bool, + write: bool, + ) -> Result<Self, SafeError> { + if !read && !write { + return Err(invalid_relay()); + } + Ok(Self { + url: RelayUrl::parse(value, destination)?, + read, + write, + }) + } + + #[must_use] + pub const fn url(&self) -> &RelayUrl { + &self.url + } + + #[must_use] + pub const fn destination(&self) -> RelayDestinationPolicy { + self.url.policy() + } + + #[must_use] + pub const fn can_read(&self) -> bool { + self.read + } + + #[must_use] + pub const fn can_write(&self) -> bool { + self.write + } +} + impl RelayUrl { /// Parses and normalizes an allowed WebSocket relay URL. /// @@ -42,7 +92,8 @@ impl RelayUrl { } match (policy, parsed.scheme()) { - (RelayDestinationPolicy::Public | RelayDestinationPolicy::PrivateNetwork, "wss") => {} + (RelayDestinationPolicy::Public, "wss") if is_public_destination(&parsed) => {} + (RelayDestinationPolicy::PrivateNetwork, "wss") if is_private_network(&parsed) => {} (RelayDestinationPolicy::Local, "ws" | "wss") if is_loopback(&parsed) => {} _ => return Err(invalid_relay()), } @@ -74,30 +125,6 @@ impl Display for RelayUrl { } } -/// Parses relay values and removes duplicates without changing first-seen order. -/// -/// # Errors -/// -/// Returns the first safe relay validation error. -pub fn normalize_relay_urls<I, S>( - values: I, - policy: RelayDestinationPolicy, -) -> Result<Vec<RelayUrl>, SafeError> -where - I: IntoIterator<Item = S>, - S: AsRef<str>, -{ - let mut seen = HashSet::new(); - let mut relays = Vec::new(); - for value in values { - let relay = RelayUrl::parse(value.as_ref(), policy)?; - if seen.insert(relay.clone()) { - relays.push(relay); - } - } - Ok(relays) -} - fn is_loopback(url: &Url) -> bool { match url.host() { Some(Host::Domain(domain)) => domain == "localhost", @@ -107,6 +134,37 @@ fn is_loopback(url: &Url) -> bool { } } +fn is_private_network(url: &Url) -> bool { + match url.host() { + Some(Host::Ipv4(address)) => address.is_private() || address.is_link_local(), + Some(Host::Ipv6(address)) => { + let first = address.segments()[0]; + first & 0xfe00 == 0xfc00 || first & 0xffc0 == 0xfe80 + } + Some(Host::Domain(_)) | None => false, + } +} + +fn is_public_destination(url: &Url) -> bool { + match url.host() { + Some(Host::Domain(domain)) => domain != "localhost" && !domain.ends_with(".local"), + Some(Host::Ipv4(address)) => { + !address.is_loopback() + && !address.is_private() + && !address.is_link_local() + && !address.is_unspecified() + } + Some(Host::Ipv6(address)) => { + let first = address.segments()[0]; + !address.is_loopback() + && !address.is_unspecified() + && first & 0xfe00 != 0xfc00 + && first & 0xffc0 != 0xfe80 + } + None => false, + } +} + const fn invalid_relay() -> SafeError { SafeError::new( SafeErrorCode::InvalidRelayConfiguration, @@ -116,7 +174,7 @@ const fn invalid_relay() -> SafeError { #[cfg(test)] mod tests { - use super::{RelayDestinationPolicy, RelayUrl, normalize_relay_urls}; + use super::{RelayDestinationPolicy, RelayEndpoint, RelayUrl}; use crate::SafeErrorCode; #[test] @@ -169,21 +227,25 @@ mod tests { } #[test] - fn relay_deduplication_preserves_normalized_first_seen_order() { - let relays = normalize_relay_urls( - [ - "wss://relay.example", - " wss://second.example/path ", - "wss://RELAY.example/", - "wss://second.example/path", - ], + fn relay_endpoint_requires_a_direction_capability() { + let endpoint = RelayEndpoint::parse( + "wss://relay.example", RelayDestinationPolicy::Public, + true, + false, ) - .expect("valid relays"); - - assert_eq!( - relays.iter().map(RelayUrl::as_str).collect::<Vec<_>>(), - vec!["wss://relay.example/", "wss://second.example/path"] + .expect("read endpoint"); + assert!(endpoint.can_read()); + assert!(!endpoint.can_write()); + assert_eq!(endpoint.destination(), RelayDestinationPolicy::Public); + assert!( + RelayEndpoint::parse( + "wss://relay.example", + RelayDestinationPolicy::Public, + false, + false, + ) + .is_err() ); } @@ -191,6 +253,14 @@ mod tests { fn relay_destination_policy_is_explicit_and_fail_closed() { assert!(RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Public).is_err()); assert!(RelayUrl::parse("wss://relay.example", RelayDestinationPolicy::Local).is_err()); + assert!(RelayUrl::parse("wss://10.0.0.4", RelayDestinationPolicy::Public).is_err()); + assert!( + RelayUrl::parse( + "wss://relay.example", + RelayDestinationPolicy::PrivateNetwork + ) + .is_err() + ); let private = RelayUrl::parse("wss://10.0.0.4", RelayDestinationPolicy::PrivateNetwork) .expect("explicit private network"); assert_eq!(private.policy(), RelayDestinationPolicy::PrivateNetwork); diff --git a/core/crates/harvestcircle_ffi/build.rs b/core/crates/harvestcircle_ffi/build.rs @@ -223,7 +223,7 @@ fn validate_baseline_inputs(baseline: &BTreeMap<String, String>) { "harvestcircle-desktop-ffi-v4" ); assert_eq!(required(baseline, "contract.major"), "4"); - assert_eq!(required(baseline, "contract.minor"), "0"); + assert_eq!(required(baseline, "contract.minor"), "1"); assert_eq!(required(baseline, "snapshot.schema"), "1"); assert_eq!(required(baseline, "storage.schema.minimum"), "5"); assert_eq!(required(baseline, "storage.schema.current"), "10"); diff --git a/core/crates/harvestcircle_ffi/src/commands.rs b/core/crates/harvestcircle_ffi/src/commands.rs @@ -8,10 +8,12 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use directories::ProjectDirs; use harvestcircle_application::{ - Clock, DurableRequestId, GeneratedKeyRecoveryHandle, RelayConfiguration, RelayRuntimeMode, - RemovalConfirmationToken, relay_configuration_from_urls, + Clock, DurableRequestId, GeneratedKeyRecoveryHandle, RelayConfiguration, RelayEndpointInput, + RemovalConfirmationToken, relay_configuration_from_endpoints, +}; +use harvestcircle_domain::{ + PublicKey, RelayDestinationPolicy, SafeError, SecretKeyInput, UnixTimestamp, }; -use harvestcircle_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; use harvestcircle_nostr::SdkNostrClient; use harvestcircle_product::{ DATABASE_APPLICATION, DATABASE_FILENAME, DATABASE_ORGANIZATION, DATABASE_QUALIFIER, @@ -23,7 +25,8 @@ use harvestcircle_runtime::{ use harvestcircle_storage::OsKeyringSecretStore; use crate::{ - AppSnapshotDto, IdentityDto, WireErrorCategory, WireErrorCode, WireRecoveryAction, + AppSnapshotDto, IdentityDto, RelayDestinationDto, RelayEndpointDto, WireErrorCategory, + WireErrorCode, WireRecoveryAction, contract::{ BUILD_JAVA_TOOLCHAIN, BUILD_KOTLIN_TOOLCHAIN, BUILD_PROVENANCE_DIGEST, BUILD_RADROOTS_REVISION, BUILD_RUST_TOOLCHAIN, BUILD_SOURCE_COMMIT, @@ -49,7 +52,7 @@ pub struct RequestContextDto { #[derive(Clone, Debug, Eq, PartialEq)] #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] pub struct RelayBootstrapInputDto { - pub relay_urls: Vec<String>, + pub endpoints: Vec<RelayEndpointDto>, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -313,7 +316,7 @@ impl HarvestCircleAppCore { relay_input: RelayBootstrapInputDto, ) -> Result<Arc<Self>, HarvestCircleError> { let path = application_database_path(development_mode)?; - Self::open_path_compatible(&path, &expectation, development_mode, relay_input) + Self::open_path_compatible(&path, &expectation, relay_input) } /// Restores durable public application state. @@ -584,13 +587,12 @@ impl HarvestCircleAppCore { fn open_path_compatible( 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, relay_input) + Self::open_path(path, relay_input) } // The concrete product opener binds operating-system paths, keyrings, and @@ -599,17 +601,28 @@ impl HarvestCircleAppCore { #[cfg_attr(coverage_nightly, coverage(off))] 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_urls(&relay_input.relay_urls, mode), - ); + let relay_endpoints = relay_input + .endpoints + .into_iter() + .map(|endpoint| { + RelayEndpointInput::new( + endpoint.url, + match endpoint.destination { + RelayDestinationDto::Local => RelayDestinationPolicy::Local, + RelayDestinationDto::PrivateNetwork => { + RelayDestinationPolicy::PrivateNetwork + } + RelayDestinationDto::Public => RelayDestinationPolicy::Public, + }, + endpoint.read, + endpoint.write, + ) + }) + .collect::<Vec<_>>(); + let (relays, startup_relay_problem) = + local_first_relay_configuration(relay_configuration_from_endpoints(&relay_endpoints)); let runtime = runtime()?; let actor = runtime.block_on(RuntimeActorHandle::open( path, @@ -784,8 +797,11 @@ mod tests { use std::num::NonZeroUsize; use std::sync::Arc; - use harvestcircle_application::{InMemorySecretStore, RelayConfiguration, RelayRuntimeMode}; - use harvestcircle_domain::SafeError; + use harvestcircle_application::{ + InMemorySecretStore, RelayConfiguration, RelayEndpointInput, + relay_configuration_from_endpoints, + }; + use harvestcircle_domain::{RelayDestinationPolicy, SafeError}; use harvestcircle_nostr::SdkNostrClient; use harvestcircle_runtime::{ RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, @@ -1147,9 +1163,8 @@ mod tests { HarvestCircleAppCore::open_path_compatible( &rejected, &incompatible, - true, RelayBootstrapInputDto { - relay_urls: Vec::new(), + endpoints: Vec::new(), }, ) .is_err() @@ -1206,28 +1221,36 @@ mod tests { } #[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, - ), - ); + fn injected_relay_input_is_explicit_mixed_and_fail_closed() { + let mixed = relay_configuration_from_endpoints(&[ + RelayEndpointInput::new( + "ws://localhost:8080", + RelayDestinationPolicy::Local, + true, + true, + ), + RelayEndpointInput::new( + "wss://relay.example", + RelayDestinationPolicy::Public, + true, + true, + ), + ]) + .expect("explicit mixed relays"); + assert_eq!(mixed.relays()[0].url().as_str(), "ws://localhost:8080/"); + assert_eq!(mixed.relays()[1].url().as_str(), "wss://relay.example/"); + + for input in [ + Vec::new(), + vec![RelayEndpointInput::new( + "https://not-a-relay.example", + RelayDestinationPolicy::Public, + true, + true, + )], + ] { + let (relays, degraded) = + local_first_relay_configuration(relay_configuration_from_endpoints(&input)); assert!(relays.relays().is_empty()); assert_eq!( degraded.map(|problem| problem.code()), diff --git a/core/crates/harvestcircle_ffi/src/contract.rs b/core/crates/harvestcircle_ffi/src/contract.rs @@ -2,7 +2,7 @@ pub const FFI_CONTRACT_ID: &str = env!("HARVESTCIRCLE_FFI_CONTRACT_ID"); pub const PRODUCT_VERSION: &str = env!("HARVESTCIRCLE_PRODUCT_VERSION"); pub const DISTRIBUTION_PACKAGE_VERSION: &str = env!("HARVESTCIRCLE_PACKAGE_VERSION"); pub const FFI_CONTRACT_MAJOR: u16 = 4; -pub const FFI_CONTRACT_MINOR: u16 = 0; +pub const FFI_CONTRACT_MINOR: u16 = 1; pub const PRODUCT_COORDINATE_DIGEST: &str = env!("HARVESTCIRCLE_PRODUCT_COORDINATE_DIGEST"); pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1; pub const MINIMUM_SCHEMA_VERSION: u32 = 5; diff --git a/core/crates/harvestcircle_ffi/src/dto.rs b/core/crates/harvestcircle_ffi/src/dto.rs @@ -3,7 +3,8 @@ use harvestcircle_application::{ RuntimeLifecycle, SessionState, }; use harvestcircle_domain::{ - NostrIdentity, ProfileMetadata, SafeError, SafeErrorCode, SignerAvailability, + NostrIdentity, ProfileMetadata, RelayDestinationPolicy, RelayEndpoint, SafeError, + SafeErrorCode, SignerAvailability, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -119,6 +120,23 @@ pub enum ProfileLoadStateDto { #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] +pub enum RelayDestinationDto { + Local, + PrivateNetwork, + Public, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] +pub struct RelayEndpointDto { + pub url: String, + pub destination: RelayDestinationDto, + pub read: bool, + pub write: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] pub enum SignerBindingKindDto { LocalKeyring, } @@ -168,7 +186,7 @@ pub struct AppSnapshotDto { pub revision: u64, pub lifecycle: AppLifecycleDto, pub lifecycle_error: Option<SafeErrorDto>, - pub configured_relays: Vec<String>, + pub configured_relays: Vec<RelayEndpointDto>, pub identities: Vec<IdentityDto>, pub selected_public_key_hex: Option<String>, pub session: SessionStateDto, @@ -202,7 +220,7 @@ impl From<&AppSnapshot> for AppSnapshotDto { .relay_configuration() .relays() .iter() - .map(|relay| relay.as_str().to_owned()) + .map(RelayEndpointDto::from) .collect(), identities: snapshot .identities() @@ -267,6 +285,27 @@ impl From<&NostrIdentity> for IdentityDto { } } +impl From<&RelayEndpoint> for RelayEndpointDto { + fn from(endpoint: &RelayEndpoint) -> Self { + Self { + url: endpoint.url().as_str().to_owned(), + destination: endpoint.destination().into(), + read: endpoint.can_read(), + write: endpoint.can_write(), + } + } +} + +impl From<RelayDestinationPolicy> for RelayDestinationDto { + fn from(destination: RelayDestinationPolicy) -> Self { + match destination { + RelayDestinationPolicy::Local => Self::Local, + RelayDestinationPolicy::PrivateNetwork => Self::PrivateNetwork, + RelayDestinationPolicy::Public => Self::Public, + } + } +} + impl From<&ActiveIdentitySnapshot> for ActiveIdentityDto { fn from(active: &ActiveIdentitySnapshot) -> Self { Self { diff --git a/core/crates/harvestcircle_ffi/src/lib.rs b/core/crates/harvestcircle_ffi/src/lib.rs @@ -18,9 +18,9 @@ pub use contract::{ }; pub use dto::{ ActiveIdentityDto, AppLifecycleDto, AppSnapshotDto, IdentityDto, ProfileDto, - ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, - SignerAvailabilityDto, SignerBindingKindDto, WireErrorCategory, WireErrorCode, - WireRecoveryAction, + ProfileLoadStateDto, RelayConnectionStateDto, RelayDestinationDto, RelayEndpointDto, + SafeErrorDto, SessionStateDto, SignerAvailabilityDto, SignerBindingKindDto, WireErrorCategory, + WireErrorCode, WireRecoveryAction, }; pub use observer::{ HarvestCircleChangeObserver, ObserverSubscription, ShutdownReceiptDto, SnapshotChangeDto, @@ -52,7 +52,7 @@ mod tests { assert_eq!(env!("CARGO_PKG_VERSION"), "0.1.0-alpha"); assert_eq!(super::FFI_CONTRACT_ID, "harvestcircle-desktop-ffi-v4"); assert_eq!(super::FFI_CONTRACT_MAJOR, 4); - assert_eq!(super::FFI_CONTRACT_MINOR, 0); + assert_eq!(super::FFI_CONTRACT_MINOR, 1); assert_eq!(super::SNAPSHOT_SCHEMA_VERSION, 1); assert_eq!( super::PRODUCT_COORDINATE_DIGEST, diff --git a/core/crates/harvestcircle_ffi/src/observer.rs b/core/crates/harvestcircle_ffi/src/observer.rs @@ -223,7 +223,7 @@ mod tests { use std::time::Duration; use harvestcircle_application::{InMemorySecretStore, RelayConfiguration}; - use harvestcircle_domain::{RelayDestinationPolicy, RelayUrl}; + use harvestcircle_domain::{RelayDestinationPolicy, RelayEndpoint}; use harvestcircle_nostr::SdkNostrClient; use harvestcircle_runtime::{ RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, @@ -427,8 +427,13 @@ mod tests { let core = core_with_relays( RelayConfiguration::new(vec![ - RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local) - .expect("relay URL"), + RelayEndpoint::parse( + relay_url.as_str(), + RelayDestinationPolicy::Local, + true, + true, + ) + .expect("relay endpoint"), ]) .expect("relay configuration"), ) diff --git a/core/crates/harvestcircle_nostr/src/client.rs b/core/crates/harvestcircle_nostr/src/client.rs @@ -1,7 +1,7 @@ use std::time::{Duration, Instant}; use harvestcircle_domain::{ - PublicKey, RelayUrl, SafeError, SafeErrorCode, SafeMessage, select_latest_kind0, + PublicKey, RelayEndpoint, SafeError, SafeErrorCode, SafeMessage, select_latest_kind0, }; use nostr::{Filter, JsonUtil, Kind, PublicKey as NostrPublicKey}; use nostr_sdk::Client; @@ -27,11 +27,15 @@ impl NostrClient for SdkNostrClient { fn fetch_profile<'a>( &'a self, public_key: PublicKey, - relays: &'a [RelayUrl], + relays: &'a [RelayEndpoint], deadline: Instant, ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { Box::pin(async move { - if relays.is_empty() { + let readable_relays = relays + .iter() + .filter(|endpoint| endpoint.can_read()) + .collect::<Vec<_>>(); + if readable_relays.is_empty() { return Err(invalid_relay_configuration()); } if relays.len() > MAX_CONFIGURED_RELAYS { @@ -47,7 +51,8 @@ impl NostrClient for SdkNostrClient { .author(author) .kind(Kind::Metadata) .limit(MAX_PROFILE_EVENTS_PER_RELAY); - for relay in relays { + for relay in &readable_relays { + let relay_url = relay.url().as_str(); let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { break; @@ -55,15 +60,15 @@ impl NostrClient for SdkNostrClient { let client = Client::default(); let result = match tokio::time::timeout_at(deadline.into(), async { client - .add_relay(relay.as_str()) + .add_relay(relay_url) .await .map_err(|_| relay_connection_failed())?; client - .try_connect_relay(relay.as_str(), remaining) + .try_connect_relay(relay_url, remaining) .await .map_err(|_| relay_connection_failed())?; client - .fetch_events_from([relay.as_str()], filter.clone(), remaining) + .fetch_events_from([relay_url], filter.clone(), remaining) .await .map_err(|_| relay_connection_failed()) }) @@ -84,7 +89,7 @@ impl NostrClient for SdkNostrClient { return Err(relay_connection_failed()); } let candidate = select_latest_kind0(candidates); - if successful_relays == relays.len() { + if successful_relays == readable_relays.len() { Ok(ProfileFetchResult::complete(candidate)) } else { Ok(ProfileFetchResult::partial(candidate)) @@ -111,7 +116,9 @@ const fn relay_connection_failed() -> SafeError { mod tests { use std::time::Duration; - use harvestcircle_domain::{PublicKey, RelayDestinationPolicy, RelayUrl, SafeErrorCode}; + use harvestcircle_domain::{ + PublicKey, RelayDestinationPolicy, RelayEndpoint, RelayUrl, SafeErrorCode, + }; use nostr::{EventBuilder, Keys, Metadata}; use nostr_relay_builder::MockRelay; use nostr_sdk::Client; @@ -140,8 +147,7 @@ mod tests { .expect("publish metadata"); let adapter = SdkNostrClient::new(Duration::from_secs(2)); - let domain_relay = RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local) - .expect("domain relay URL"); + let domain_relay = endpoint(relay_url.as_str(), RelayDestinationPolicy::Local); let public_key = PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"); let fetched = adapter @@ -178,8 +184,24 @@ mod tests { assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); - let relay = RelayUrl::parse("wss://relay.example.test", RelayDestinationPolicy::Public) - .expect("relay URL"); + let write_only = RelayEndpoint::parse( + "wss://relay.example.test", + RelayDestinationPolicy::Public, + false, + true, + ) + .expect("write-only relay"); + let error = SdkNostrClient::new(Duration::from_millis(10)) + .fetch_profile( + PublicKey::from_bytes([7; 32]).expect("valid public key"), + &[write_only], + std::time::Instant::now() + Duration::from_millis(10), + ) + .await + .expect_err("read capability required"); + assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); + + let relay = endpoint("wss://relay.example.test", RelayDestinationPolicy::Public); let too_many = vec![relay; harvestcircle_application::MAX_CONFIGURED_RELAYS + 1]; let error = SdkNostrClient::new(Duration::from_millis(10)) .fetch_profile( @@ -194,8 +216,7 @@ mod tests { #[tokio::test] async fn sdk_client_fails_when_no_configured_relay_completes() { - let relay = RelayUrl::parse("ws://127.0.0.1:1", RelayDestinationPolicy::Local) - .expect("unavailable relay"); + let relay = endpoint("ws://127.0.0.1:1", RelayDestinationPolicy::Local); let error = SdkNostrClient::new(Duration::from_millis(25)) .fetch_profile( PublicKey::from_bytes([7; 32]).expect("valid public key"), @@ -224,9 +245,8 @@ mod tests { .expect("publish metadata"); let configured = [ - RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local).expect("live relay"), - RelayUrl::parse("ws://127.0.0.1:1", RelayDestinationPolicy::Local) - .expect("unavailable relay"), + endpoint(relay_url.as_str(), RelayDestinationPolicy::Local), + endpoint("ws://127.0.0.1:1", RelayDestinationPolicy::Local), ]; let fetched = SdkNostrClient::new(Duration::from_millis(250)) .fetch_profile( @@ -267,4 +287,8 @@ mod tests { SafeErrorCode::RelayConnectionFailed ); } + + fn endpoint(value: &str, destination: RelayDestinationPolicy) -> RelayEndpoint { + RelayEndpoint::parse(value, destination, true, true).expect("relay endpoint") + } } diff --git a/core/crates/harvestcircle_runtime/src/runtime_actor.rs b/core/crates/harvestcircle_runtime/src/runtime_actor.rs @@ -1401,8 +1401,8 @@ mod tests { SecretStore, SecretStoreOperation, SessionGeneration, SessionState, SnapshotRevision, }; use harvestcircle_domain::{ - LocalKeyringBinding, NostrIdentityReference, PublicKey, RelayDestinationPolicy, RelayUrl, - SafeError, SafeErrorCode, SecretKeyInput, SignerAvailability, UnixTimestamp, + LocalKeyringBinding, NostrIdentityReference, PublicKey, RelayDestinationPolicy, + RelayEndpoint, SafeError, SafeErrorCode, SecretKeyInput, SignerAvailability, UnixTimestamp, }; use super::{ @@ -1444,7 +1444,7 @@ mod tests { fn fetch_profile<'a>( &'a self, _public_key: PublicKey, - _relays: &'a [RelayUrl], + _relays: &'a [RelayEndpoint], _deadline: Instant, ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { Box::pin(async { Ok(ProfileFetchResult::complete(None)) }) @@ -1469,7 +1469,7 @@ mod tests { fn fetch_profile<'a>( &'a self, _public_key: PublicKey, - _relays: &'a [RelayUrl], + _relays: &'a [RelayEndpoint], _deadline: Instant, ) -> BoxFuture<'a, Result<ProfileFetchResult, SafeError>> { Box::pin(async move { @@ -1867,8 +1867,13 @@ mod tests { let client = Arc::new(BlockingNostr::new()); let actor = RuntimeActorHandle::in_memory( RelayConfiguration::new(vec![ - RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Local) - .expect("relay"), + RelayEndpoint::parse( + "ws://localhost:8080", + RelayDestinationPolicy::Local, + true, + true, + ) + .expect("relay"), ]) .expect("relay configuration"), dependencies(Arc::new(InMemorySecretStore::default()), client.clone()), @@ -1913,8 +1918,13 @@ mod tests { let client = Arc::new(BlockingNostr::new()); let actor = RuntimeActorHandle::in_memory( RelayConfiguration::new(vec![ - RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Local) - .expect("relay"), + RelayEndpoint::parse( + "ws://localhost:8080", + RelayDestinationPolicy::Local, + true, + true, + ) + .expect("relay"), ]) .expect("relay configuration"), dependencies(Arc::new(InMemorySecretStore::default()), client.clone()), @@ -2252,8 +2262,13 @@ mod tests { let client = Arc::new(BlockingNostr::new()); let actor = RuntimeActorHandle::in_memory( RelayConfiguration::new(vec![ - RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Local) - .expect("relay"), + RelayEndpoint::parse( + "ws://localhost:8080", + RelayDestinationPolicy::Local, + true, + true, + ) + .expect("relay"), ]) .expect("relay configuration"), dependencies(Arc::new(InMemorySecretStore::default()), client.clone()), diff --git a/core/crates/harvestcircle_runtime/tests/local_relay_e2e.rs b/core/crates/harvestcircle_runtime/tests/local_relay_e2e.rs @@ -4,7 +4,7 @@ use harvestcircle_application::{ Clock, InMemorySecretStore, ProfileLoadState, ProfileRepository, RelayConfiguration, RelayConnectionState, SecretStore, SessionState, }; -use harvestcircle_domain::{RelayDestinationPolicy, RelayUrl, SecretKeyInput, UnixTimestamp}; +use harvestcircle_domain::{RelayDestinationPolicy, RelayEndpoint, SecretKeyInput, UnixTimestamp}; use harvestcircle_nostr::SdkNostrClient; use harvestcircle_runtime::PersistentAppCore; use nostr::{EventBuilder, Keys, Metadata}; @@ -43,8 +43,13 @@ async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() { .await .expect("publish profile"); - let relay = - RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local).expect("relay URL"); + let relay = RelayEndpoint::parse( + relay_url.as_str(), + RelayDestinationPolicy::Local, + true, + true, + ) + .expect("relay endpoint"); let adapter = PersistentAppCore::in_memory( RelayConfiguration::new(vec![relay]).expect("relay configuration"), ) diff --git a/spec/harvestcircle_mvp_v1/ARCHITECTURE.md b/spec/harvestcircle_mvp_v1/ARCHITECTURE.md @@ -20,3 +20,20 @@ Kotlin shared code owns presentation state and platform-neutral use cases. Generated FFI types remain in the desktop adapter. No silent fallback, duplicated commercial model, or UI-thread blocking. + +## Relay bootstrap + +Every relay endpoint carries an explicit destination (`Local`, +`PrivateNetwork`, or `Public`) and independent read/write capabilities. Rust +owns URL, destination, uniqueness, and capability validation; the desktop host +only adapts environment input into the typed UniFFI record. + +Development input uses deterministic comma-separated entries: + +```text +HARVESTCIRCLE_NOSTR_RELAYS=local|ws://127.0.0.1:8080,public|wss://relay.example +``` + +The `private|` prefix selects `PrivateNetwork`. Current desktop entries enable +both reading and writing. Missing packaged configuration remains a visible +degraded-network state and never invents or reclassifies a relay.