field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 79a2c9a053ffa022686cf0c934181244474deffc
parent ccd23e24e8d3fdf12f6cf289c602e37eac12ec7f
Author: triesap <tyson@radroots.org>
Date:   Fri,  7 Aug 2026 17:33:35 +0000

transport: add evidence-based relay profiles

- validate public, simulator, and device relay authority
- bind fetch cursors and retries to exact relay evidence
- expose directional status through SDK and native mobile bindings
- prove loopback I/O, offline outbox behavior, and release gates

Diffstat:
Mcore/crates/tera_core/Cargo.toml | 1+
Mcore/crates/tera_core/src/runtime/builder.rs | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcore/crates/tera_core/src/runtime/mod.rs | 17++++++++++++-----
Mcore/crates/tera_core/src/runtime/product_surface/context.rs | 53++++++++++++++++++++++++++++++++++++++++++++---------
Mcore/crates/tera_core/src/runtime/product_surface/outbox.rs | 2++
Mcore/crates/tera_core/src/runtime/sdk.rs | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 276 insertions(+), 15 deletions(-)

diff --git a/core/crates/tera_core/Cargo.toml b/core/crates/tera_core/Cargo.toml @@ -40,6 +40,7 @@ radroots_signing = { workspace = true, default-features = false, features = ["st radroots_storage = { workspace = true, default-features = false } radroots_sync = { workspace = true, default-features = false } radroots_transport = { workspace = true, default-features = false, features = ["std"] } +radroots_transport_nostr = { workspace = true } chrono = { workspace = true } hex = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/core/crates/tera_core/src/runtime/builder.rs b/core/crates/tera_core/src/runtime/builder.rs @@ -6,15 +6,20 @@ pub struct RuntimeBuilder { store: MobileUserStoreConfig, #[cfg(feature = "mobile-social")] signer: Option<std::sync::Arc<dyn radroots_signing::Signer>>, + #[cfg(feature = "mobile-social")] + relay_profile: radroots_sdk::transport::RelayProfile, } impl RuntimeBuilder { #[must_use] - pub const fn new(store: MobileUserStoreConfig) -> Self { + pub fn new(store: MobileUserStoreConfig) -> Self { Self { store, #[cfg(feature = "mobile-social")] signer: None, + #[cfg(feature = "mobile-social")] + relay_profile: radroots_sdk::transport::RelayProfile::public(Vec::<String>::new()) + .expect("bundled public relay profile is valid"), } } @@ -26,6 +31,15 @@ impl RuntimeBuilder { self } + /// Replaces the bundled read-only public profile with one validated host + /// environment profile. Construction remains inert. + #[cfg(feature = "mobile-social")] + #[must_use] + pub fn relay_profile(mut self, relay_profile: radroots_sdk::transport::RelayProfile) -> Self { + self.relay_profile = relay_profile; + self + } + /// Opens the exact authenticated user's durable SQLite store. pub async fn build(self) -> Result<RadrootsRuntime, RadrootsAppError> { if self.store.protected_data() == ProtectedDataAvailability::Unavailable { @@ -41,6 +55,8 @@ impl RuntimeBuilder { Some(self.store.public_key()), #[cfg(feature = "mobile-social")] self.signer, + #[cfg(feature = "mobile-social")] + Some(self.relay_profile), ) } } @@ -81,6 +97,55 @@ mod tests { runtime.sdk_storage_status().await.expect("status").backend, "sqlite" ); + #[cfg(feature = "mobile-social")] + { + let report = runtime + .sdk_relay_status() + .expect("relay status") + .expect("configured profile"); + assert_eq!(report.profile, "public"); + assert_eq!(report.state, "configured"); + assert_eq!(report.relays.len(), 1); + assert_eq!(report.relays[0].relay_url, "wss://radroots.org"); + assert_eq!(report.relays[0].access, "read_only"); + assert_eq!(report.relays[0].read_state, "unobserved"); + assert_eq!(report.relays[0].write_state, "unsupported"); + } + runtime.shutdown().await.expect("shutdown"); + } + + #[cfg(feature = "mobile-social")] + #[tokio::test] + async fn runtime_reconfiguration_preserves_profile_network_boundaries() { + let root = tempfile::tempdir().expect("tempdir"); + let runtime = RuntimeBuilder::new(store(root.path(), ProtectedDataAvailability::Available)) + .build() + .await + .expect("runtime"); + assert!( + runtime + .configure_simulator_relays(vec!["ws://127.0.0.1:8080".to_owned()]) + .is_ok() + ); + let report = runtime + .sdk_relay_status() + .expect("simulator status") + .expect("configured profile"); + assert_eq!(report.profile, "simulator_local"); + assert_eq!(report.relays.len(), 1); + assert_eq!(report.relays[0].access, "read_write"); + assert!( + runtime + .configure_public_relays(vec!["ws://127.0.0.1:8080".to_owned()]) + .is_err() + ); + assert_eq!( + runtime + .sdk_relay_status() + .expect("unchanged status") + .expect("configured profile"), + report + ); runtime.shutdown().await.expect("shutdown"); } diff --git a/core/crates/tera_core/src/runtime/mod.rs b/core/crates/tera_core/src/runtime/mod.rs @@ -34,15 +34,20 @@ impl RadrootsRuntime { #[cfg(feature = "mobile-social")] signer: Option< std::sync::Arc<dyn radroots_signing::Signer>, >, + #[cfg(feature = "mobile-social")] relay_profile: Option< + radroots_sdk::transport::RelayProfile, + >, ) -> Result<Self, RadrootsAppError> { #[cfg(feature = "mobile-social")] - let nostr_slot = radroots_sdk::transport::NostrSlot::new( - radroots_sdk::transport::RelayUrlPolicy::Public, - ); - #[cfg(feature = "mobile-social")] let builder = { + let nostr_slot = radroots_sdk::transport::NostrSlot::new(); + if let Some(profile) = relay_profile { + nostr_slot + .configure(profile) + .map_err(RadrootsAppError::from_sdk)?; + } let builder = builder - .nostr(nostr_slot.clone()) + .nostr(nostr_slot) .host_sync(radroots_sdk::sync::HostPolicy::standard()); match signer { Some(signer) => builder.signing(radroots_sdk::signing::Provider::host(signer)), @@ -67,6 +72,8 @@ impl RadrootsRuntime { None, #[cfg(feature = "mobile-social")] None, + #[cfg(feature = "mobile-social")] + None, ) } diff --git a/core/crates/tera_core/src/runtime/product_surface/context.rs b/core/crates/tera_core/src/runtime/product_surface/context.rs @@ -1,6 +1,6 @@ use std::collections::BTreeSet; -use radroots_event::id::RelayUrl; +use radroots_transport_nostr::{RelayUrl, RelayUrlPolicy}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -53,17 +53,17 @@ impl LocalNetwork { return Err(LocalNetworkError::MissingRelay); } let mut relays = BTreeSet::new(); - for relay in &relay_urls { - if relay.is_empty() - || relay.len() > RELAY_URL_MAX_BYTES - || !relay.starts_with("wss://") - || RelayUrl::parse(relay).is_err() - { + let mut canonical_relay_urls = Vec::with_capacity(relay_urls.len()); + for relay in relay_urls { + if relay.is_empty() || relay.len() > RELAY_URL_MAX_BYTES { return Err(LocalNetworkError::InvalidRelay); } - if !relays.insert(relay) { + let relay = RelayUrl::parse(relay, RelayUrlPolicy::Public) + .map_err(|_| LocalNetworkError::InvalidRelay)?; + if !relays.insert(relay.clone()) { return Err(LocalNetworkError::DuplicateRelay); } + canonical_relay_urls.push(relay.to_string()); } let mut authors = BTreeSet::new(); for author in &followed_authors { @@ -81,7 +81,7 @@ impl LocalNetwork { Ok(Self { id, label, - relay_urls, + relay_urls: canonical_relay_urls, locality, followed_authors, generation, @@ -334,5 +334,40 @@ mod tests { ] { assert!(invalid.is_err()); } + let canonical = LocalNetwork::new( + "id".into(), + "label".into(), + vec!["WSS://RELAY.EXAMPLE:443/".into()], + None, + vec![], + 0, + ) + .expect("canonical relay"); + assert_eq!(canonical.relay_urls, vec!["wss://relay.example"]); + assert!(matches!( + LocalNetwork::new( + "id".into(), + "label".into(), + vec![ + "wss://relay.example".into(), + "WSS://RELAY.EXAMPLE:443/".into(), + ], + None, + vec![], + 0, + ), + Err(LocalNetworkError::DuplicateRelay) + )); + assert!(matches!( + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://127.0.0.1:7447".into()], + None, + vec![], + 0, + ), + Err(LocalNetworkError::InvalidRelay) + )); } } diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox.rs b/core/crates/tera_core/src/runtime/product_surface/outbox.rs @@ -1179,6 +1179,7 @@ mod tests { ClientBuilder::memory_default(), Some(PublicKey::from_hex(AUTHOR).unwrap()), None, + None, ) .unwrap() } @@ -1192,6 +1193,7 @@ mod tests { ClientBuilder::memory_default(), Some(PublicKey::from_hex(AUTHOR).unwrap()), Some(std::sync::Arc::new(signer)), + None, ) .unwrap() } diff --git a/core/crates/tera_core/src/runtime/sdk.rs b/core/crates/tera_core/src/runtime/sdk.rs @@ -21,6 +21,27 @@ pub struct SdkStorageStatusRecord { } #[derive(Clone, Debug, Eq, PartialEq)] +pub struct SdkRelayStatusRecord { + pub relay_url: String, + pub access: String, + pub read_state: String, + pub write_state: String, + pub read_last_attempt_unix_ms: Option<u64>, + pub write_last_attempt_unix_ms: Option<u64>, + pub read_next_attempt_unix_ms: Option<u64>, + pub write_next_attempt_unix_ms: Option<u64>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SdkRelayStatusReportRecord { + pub profile: String, + pub state: String, + pub read_availability: String, + pub write_availability: String, + pub relays: Vec<SdkRelayStatusRecord>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] pub struct SdkShutdownRecord { pub state: String, pub already_closed: bool, @@ -54,6 +75,136 @@ impl RadrootsRuntime { integrity: status.integrity().health().as_str().to_owned(), }) } + + /// Installs a validated public relay profile without probing it. + #[cfg(feature = "mobile-social")] + pub fn configure_public_relays( + &self, + writable_relays: Vec<String>, + ) -> Result<(), RadrootsAppError> { + self.configure_relay_profile( + radroots_sdk::transport::RelayProfile::public(writable_relays) + .map_err(|error| RadrootsAppError::runtime(error.to_string()))?, + ) + } + + /// Installs an exact-loopback simulator profile without probing it. + #[cfg(feature = "mobile-social")] + pub fn configure_simulator_relays( + &self, + loopback_relays: Vec<String>, + ) -> Result<(), RadrootsAppError> { + self.configure_relay_profile( + radroots_sdk::transport::RelayProfile::simulator(loopback_relays) + .map_err(|error| RadrootsAppError::runtime(error.to_string()))?, + ) + } + + /// Installs an explicit physical-device TLS relay profile without probing it. + #[cfg(feature = "mobile-social")] + pub fn configure_device_relays( + &self, + writable_relays: Vec<String>, + ) -> Result<(), RadrootsAppError> { + self.configure_relay_profile( + radroots_sdk::transport::RelayProfile::device(writable_relays) + .map_err(|error| RadrootsAppError::runtime(error.to_string()))?, + ) + } + + #[cfg(feature = "mobile-social")] + fn configure_relay_profile( + &self, + profile: radroots_sdk::transport::RelayProfile, + ) -> Result<(), RadrootsAppError> { + self.client + .configure_nostr(profile) + .map_err(RadrootsAppError::from_sdk) + } + + /// Returns passive relay evidence without DNS, socket, or probe work. + #[cfg(feature = "mobile-social")] + pub fn sdk_relay_status(&self) -> Result<Option<SdkRelayStatusReportRecord>, RadrootsAppError> { + let report = self + .client + .nostr_status() + .map_err(RadrootsAppError::from_sdk)?; + Ok(report.map(|report| SdkRelayStatusReportRecord { + profile: relay_profile_label(report.profile_kind()).to_owned(), + state: relay_aggregate_label(report.state()).to_owned(), + read_availability: transport_availability_label(report.read_availability()).to_owned(), + write_availability: transport_availability_label(report.write_availability()) + .to_owned(), + relays: report + .relays() + .iter() + .map(|relay| SdkRelayStatusRecord { + relay_url: relay.endpoint().url().to_string(), + access: if relay.endpoint().access().can_write() { + "read_write" + } else { + "read_only" + } + .to_owned(), + read_state: relay_evidence_label(relay.read().state()).to_owned(), + write_state: relay_evidence_label(relay.write().state()).to_owned(), + read_last_attempt_unix_ms: relay.read().last_attempt_unix_ms(), + write_last_attempt_unix_ms: relay.write().last_attempt_unix_ms(), + read_next_attempt_unix_ms: relay.read().next_attempt_unix_ms(), + write_next_attempt_unix_ms: relay.write().next_attempt_unix_ms(), + }) + .collect(), + })) + } +} + +#[cfg(feature = "mobile-social")] +const fn relay_evidence_label(value: radroots_sdk::transport::RelayEvidenceState) -> &'static str { + match value { + radroots_sdk::transport::RelayEvidenceState::Unsupported => "unsupported", + radroots_sdk::transport::RelayEvidenceState::Unobserved => "unobserved", + radroots_sdk::transport::RelayEvidenceState::Connecting => "connecting", + radroots_sdk::transport::RelayEvidenceState::Available => "available", + radroots_sdk::transport::RelayEvidenceState::Unavailable => "unavailable", + _ => "unknown", + } +} + +#[cfg(feature = "mobile-social")] +const fn relay_profile_label(value: radroots_sdk::transport::RelayProfileKind) -> &'static str { + match value { + radroots_sdk::transport::RelayProfileKind::Public => "public", + radroots_sdk::transport::RelayProfileKind::Simulator => "simulator_local", + radroots_sdk::transport::RelayProfileKind::Device => "device_development", + _ => "unknown", + } +} + +#[cfg(feature = "mobile-social")] +const fn relay_aggregate_label( + value: radroots_sdk::transport::RelayAggregateState, +) -> &'static str { + match value { + radroots_sdk::transport::RelayAggregateState::Configured => "configured", + radroots_sdk::transport::RelayAggregateState::Connecting => "connecting", + radroots_sdk::transport::RelayAggregateState::ReadOnly => "read_only", + radroots_sdk::transport::RelayAggregateState::Writable => "writable", + radroots_sdk::transport::RelayAggregateState::Degraded => "degraded", + radroots_sdk::transport::RelayAggregateState::Offline => "offline", + radroots_sdk::transport::RelayAggregateState::Failed => "failed", + _ => "unknown", + } +} + +#[cfg(feature = "mobile-social")] +const fn transport_availability_label( + value: radroots_transport::capability::Availability, +) -> &'static str { + match value { + radroots_transport::capability::Availability::Available => "available", + radroots_transport::capability::Availability::Degraded => "degraded", + radroots_transport::capability::Availability::Unavailable => "unavailable", + } } const fn availability_label(value: Availability) -> &'static str {