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