commit 51a5fefe711824643ae4d817ab2fc67537651278
parent 204046dff715289199ddee562240fabb21272c32
Author: triesap <tyson@radroots.org>
Date: Tue, 23 Jun 2026 22:02:49 +0000
publish-proxy: preserve author relay outcomes
- keep cached author-write rejection evidence through fallback
- keep discovery relay rejection evidence after successful discovery
- record invalid author-write relay URLs as rejected outcomes
- add deterministic author relay preservation tests
Diffstat:
1 file changed, 329 insertions(+), 45 deletions(-)
diff --git a/src/core/publish_proxy/mod.rs b/src/core/publish_proxy/mod.rs
@@ -72,6 +72,7 @@ pub struct PublishProxy {
pub store: PublishProxyStore,
publisher: Option<Arc<dyn RadrootsRelayPublishAdapter>>,
resolver: Arc<dyn PublishRelayResolver>,
+ author_relay_discovery: Arc<dyn PublishAuthorRelayDiscovery>,
publish_jobs: Arc<Semaphore>,
}
@@ -84,6 +85,7 @@ impl PublishProxy {
store,
publisher: None,
resolver: Arc::new(SystemPublishRelayResolver),
+ author_relay_discovery: Arc::new(NostrPublishAuthorRelayDiscovery),
publish_jobs,
})
}
@@ -96,6 +98,7 @@ impl PublishProxy {
store,
publisher: None,
resolver: Arc::new(SystemPublishRelayResolver),
+ author_relay_discovery: Arc::new(NostrPublishAuthorRelayDiscovery),
publish_jobs,
})
}
@@ -111,6 +114,15 @@ impl PublishProxy {
self
}
+ #[cfg(test)]
+ fn with_author_relay_discovery(
+ mut self,
+ author_relay_discovery: Arc<dyn PublishAuthorRelayDiscovery>,
+ ) -> Self {
+ self.author_relay_discovery = author_relay_discovery;
+ self
+ }
+
fn acquire_publish_permit(&self) -> Result<OwnedSemaphorePermit, PublishProxyError> {
self.publish_jobs
.clone()
@@ -243,27 +255,42 @@ impl PublishProxy {
pubkey: &str,
) -> Result<PublishRelayResolution, PublishProxyError> {
let cached = self.store.cached_author_write_relays(pubkey)?;
- let cached_resolution = self.resolve_author_relay_inputs(&cached).await?;
+ let mut cached_resolution = self.resolve_author_relay_inputs(&cached).await?;
if !cached_resolution.targets.is_empty() {
return Ok(cached_resolution);
}
if self.config.author_relay_discovery_relays.is_empty() {
return Ok(cached_resolution);
}
- let discovery_targets = self
+ let mut discovery_targets = self
.resolve_config_relays(
&self.config.author_relay_discovery_relays,
PublishRelaySource::DaemonDefault,
)
.await?;
if discovery_targets.targets.is_empty() {
+ discovery_targets
+ .outcomes
+ .append(&mut cached_resolution.outcomes);
return Ok(discovery_targets);
}
let discovered = self
- .fetch_author_write_relays(pubkey, discovery_targets.targets)
+ .author_relay_discovery
+ .fetch_author_write_relays(
+ pubkey,
+ std::mem::take(&mut discovery_targets.targets),
+ self.config.connect_timeout_secs,
+ )
.await?;
self.store.cache_author_write_relays(pubkey, &discovered)?;
- self.resolve_author_relay_inputs(&discovered).await
+ let mut discovered_resolution = self.resolve_author_relay_inputs(&discovered).await?;
+ discovered_resolution
+ .outcomes
+ .append(&mut cached_resolution.outcomes);
+ discovered_resolution
+ .outcomes
+ .append(&mut discovery_targets.outcomes);
+ Ok(discovered_resolution)
}
async fn resolve_author_relay_inputs(
@@ -273,14 +300,24 @@ impl PublishProxy {
let mut targets = Vec::new();
let mut outcomes = Vec::new();
for relay in relays {
- if let Ok(url) = RadrootsRelayUrl::parse(relay, relay_url_policy(&self.config)) {
- self.push_checked_relay_target(
- &mut targets,
- &mut outcomes,
- url,
- PublishRelaySource::AuthorWrite,
- )
- .await;
+ match RadrootsRelayUrl::parse(relay, relay_url_policy(&self.config)) {
+ Ok(url) => {
+ self.push_checked_relay_target(
+ &mut targets,
+ &mut outcomes,
+ url,
+ PublishRelaySource::AuthorWrite,
+ )
+ .await;
+ }
+ Err(error) => outcomes.push(PublishRelayOutcome {
+ relay_url: relay.trim().to_owned(),
+ source: PublishRelaySource::AuthorWrite,
+ attempted: false,
+ outcome_kind: PublishRelayOutcomeKind::RelayUrlRejected,
+ message: Some(error.to_string()),
+ latency_ms: None,
+ }),
}
}
Ok(PublishRelayResolution { targets, outcomes })
@@ -360,39 +397,6 @@ impl PublishProxy {
}
}
- async fn fetch_author_write_relays(
- &self,
- pubkey: &str,
- discovery_targets: Vec<ResolvedPublishRelay>,
- ) -> Result<Vec<String>, PublishProxyError> {
- let Ok(public_key) = RadrootsNostrPublicKey::from_hex(pubkey) else {
- return Ok(Vec::new());
- };
- let client = RadrootsNostrClient::new_signerless();
- for target in discovery_targets {
- if client.add_read_relay(target.url.as_str()).await.is_err() {
- return Ok(Vec::new());
- }
- }
- let filter = RadrootsNostrFilter::new()
- .author(public_key)
- .kind(RadrootsNostrKind::Custom(10_002))
- .limit(10);
- let timeout = Duration::from_secs(self.config.connect_timeout_secs);
- let Ok(events) = client.fetch_events(filter, timeout).await else {
- return Ok(Vec::new());
- };
- let Some(event) = events.into_iter().max_by(|left, right| {
- left.created_at
- .as_secs()
- .cmp(&right.created_at.as_secs())
- .then_with(|| left.id.to_hex().cmp(&right.id.to_hex()))
- }) else {
- return Ok(Vec::new());
- };
- Ok(author_write_relays_from_nip65_event(&event))
- }
-
async fn complete_job_execution(
&self,
job_id: &str,
@@ -620,6 +624,18 @@ trait PublishRelayResolver: Send + Sync {
fn resolve<'a>(&'a self, url: &'a RadrootsRelayUrl) -> PublishRelayResolveFuture<'a>;
}
+type PublishAuthorRelayDiscoveryFuture<'a> =
+ Pin<Box<dyn Future<Output = Result<Vec<String>, PublishProxyError>> + Send + 'a>>;
+
+trait PublishAuthorRelayDiscovery: Send + Sync {
+ fn fetch_author_write_relays<'a>(
+ &'a self,
+ pubkey: &'a str,
+ discovery_targets: Vec<ResolvedPublishRelay>,
+ connect_timeout_secs: u64,
+ ) -> PublishAuthorRelayDiscoveryFuture<'a>;
+}
+
#[derive(Debug)]
struct SystemPublishRelayResolver;
@@ -633,6 +649,47 @@ impl PublishRelayResolver for SystemPublishRelayResolver {
}
}
+#[derive(Debug)]
+struct NostrPublishAuthorRelayDiscovery;
+
+impl PublishAuthorRelayDiscovery for NostrPublishAuthorRelayDiscovery {
+ fn fetch_author_write_relays<'a>(
+ &'a self,
+ pubkey: &'a str,
+ discovery_targets: Vec<ResolvedPublishRelay>,
+ connect_timeout_secs: u64,
+ ) -> PublishAuthorRelayDiscoveryFuture<'a> {
+ Box::pin(async move {
+ let Ok(public_key) = RadrootsNostrPublicKey::from_hex(pubkey) else {
+ return Ok(Vec::new());
+ };
+ let client = RadrootsNostrClient::new_signerless();
+ for target in discovery_targets {
+ if client.add_read_relay(target.url.as_str()).await.is_err() {
+ return Ok(Vec::new());
+ }
+ }
+ let filter = RadrootsNostrFilter::new()
+ .author(public_key)
+ .kind(RadrootsNostrKind::Custom(10_002))
+ .limit(10);
+ let timeout = Duration::from_secs(connect_timeout_secs);
+ let Ok(events) = client.fetch_events(filter, timeout).await else {
+ return Ok(Vec::new());
+ };
+ let Some(event) = events.into_iter().max_by(|left, right| {
+ left.created_at
+ .as_secs()
+ .cmp(&right.created_at.as_secs())
+ .then_with(|| left.id.to_hex().cmp(&right.id.to_hex()))
+ }) else {
+ return Ok(Vec::new());
+ };
+ Ok(author_write_relays_from_nip65_event(&event))
+ })
+ }
+}
+
impl PublishProxyStore {
pub fn open(path: PathBuf) -> Result<Self, PublishProxyError> {
if let Some(parent) = path
@@ -1756,6 +1813,7 @@ mod tests {
const RELAY_PRIMARY: &str = "wss://relay.example.com";
const RELAY_SECONDARY: &str = "wss://relay-2.example.com";
+ const RELAY_FORBIDDEN: &str = "wss://forbidden-relay.example.com";
fn event(pubkey: &str, kind: u32) -> SignedNostrEventWire {
SignedNostrEventWire {
@@ -1893,6 +1951,30 @@ mod tests {
}
}
+ struct StaticPublishAuthorRelayDiscovery {
+ relays: Vec<String>,
+ }
+
+ impl StaticPublishAuthorRelayDiscovery {
+ fn new(relays: Vec<&str>) -> Self {
+ Self {
+ relays: relays.into_iter().map(str::to_owned).collect(),
+ }
+ }
+ }
+
+ impl super::PublishAuthorRelayDiscovery for StaticPublishAuthorRelayDiscovery {
+ fn fetch_author_write_relays<'a>(
+ &'a self,
+ _pubkey: &'a str,
+ _discovery_targets: Vec<super::ResolvedPublishRelay>,
+ _connect_timeout_secs: u64,
+ ) -> super::PublishAuthorRelayDiscoveryFuture<'a> {
+ let relays = self.relays.clone();
+ Box::pin(async move { Ok(relays) })
+ }
+ }
+
#[test]
fn token_generation_and_hashing_do_not_store_plaintext() {
let token = generate_bearer_token();
@@ -2230,6 +2312,208 @@ mod tests {
}
#[tokio::test]
+ async fn publish_event_records_invalid_cached_author_write_relay() {
+ let identity = RadrootsIdentity::generate();
+ let (proxy, adapter) = publish_proxy(config_with_defaults(vec![RELAY_SECONDARY]));
+ proxy
+ .store
+ .cache_author_write_relays(
+ identity.public_key_hex().as_str(),
+ &[RELAY_PRIMARY.to_owned(), "not a cached relay".to_owned()],
+ )
+ .expect("cache author relays");
+ let principal = principal(
+ &proxy,
+ identity.public_key_hex(),
+ vec![PublishRelayPolicy::AuthorWriteThenDaemonDefault],
+ false,
+ PublishJobVisibility::Own,
+ );
+ let response = proxy
+ .publish_event(
+ &principal,
+ publish_request(
+ signed_event(&identity, "{}"),
+ Vec::new(),
+ PublishRelayPolicy::AuthorWriteThenDaemonDefault,
+ PublishDeliveryPolicy::Any,
+ None,
+ ),
+ )
+ .await
+ .expect("publish");
+
+ assert_eq!(response.job.status, PublishJobStatus::DeliverySatisfied);
+ let accepted = response
+ .job
+ .relays
+ .iter()
+ .find(|relay| relay.relay_url == RELAY_PRIMARY)
+ .expect("accepted author relay");
+ assert_eq!(accepted.source, PublishRelaySource::AuthorWrite);
+ assert!(accepted.attempted);
+ let rejected = response
+ .job
+ .relays
+ .iter()
+ .find(|relay| relay.relay_url == "not a cached relay")
+ .expect("rejected cached author relay");
+ assert_eq!(rejected.source, PublishRelaySource::AuthorWrite);
+ assert_eq!(
+ rejected.outcome_kind,
+ PublishRelayOutcomeKind::RelayUrlRejected
+ );
+ assert!(!rejected.attempted);
+ assert_eq!(adapter.captured_raw_events().len(), 1);
+ }
+
+ #[tokio::test]
+ async fn publish_event_preserves_author_and_discovery_rejections_through_fallback() {
+ let identity = RadrootsIdentity::generate();
+ let mut config = config_with_defaults(vec![RELAY_SECONDARY]);
+ config.author_relay_discovery_relays = vec!["not a discovery relay".to_owned()];
+ let (proxy, adapter) = publish_proxy(config);
+ proxy
+ .store
+ .cache_author_write_relays(
+ identity.public_key_hex().as_str(),
+ &["not a cached relay".to_owned()],
+ )
+ .expect("cache author relays");
+ let principal = principal(
+ &proxy,
+ identity.public_key_hex(),
+ vec![PublishRelayPolicy::AuthorWriteThenDaemonDefault],
+ false,
+ PublishJobVisibility::Own,
+ );
+ let response = proxy
+ .publish_event(
+ &principal,
+ publish_request(
+ signed_event(&identity, "{}"),
+ Vec::new(),
+ PublishRelayPolicy::AuthorWriteThenDaemonDefault,
+ PublishDeliveryPolicy::Any,
+ None,
+ ),
+ )
+ .await
+ .expect("publish");
+
+ assert_eq!(response.job.status, PublishJobStatus::DeliverySatisfied);
+ let daemon_default = response
+ .job
+ .relays
+ .iter()
+ .find(|relay| relay.relay_url == RELAY_SECONDARY)
+ .expect("daemon default relay");
+ assert_eq!(daemon_default.source, PublishRelaySource::DaemonDefault);
+ assert!(daemon_default.attempted);
+ let cached = response
+ .job
+ .relays
+ .iter()
+ .find(|relay| relay.relay_url == "not a cached relay")
+ .expect("cached author rejection");
+ assert_eq!(cached.source, PublishRelaySource::AuthorWrite);
+ assert_eq!(
+ cached.outcome_kind,
+ PublishRelayOutcomeKind::RelayUrlRejected
+ );
+ assert!(!cached.attempted);
+ let discovery = response
+ .job
+ .relays
+ .iter()
+ .find(|relay| relay.relay_url == "not a discovery relay")
+ .expect("discovery relay rejection");
+ assert_eq!(discovery.source, PublishRelaySource::DaemonDefault);
+ assert_eq!(
+ discovery.outcome_kind,
+ PublishRelayOutcomeKind::RelayUrlRejected
+ );
+ assert!(!discovery.attempted);
+ assert_eq!(adapter.captured_raw_events().len(), 1);
+ }
+
+ #[tokio::test]
+ async fn publish_event_preserves_discovery_and_discovered_author_rejections() {
+ let identity = RadrootsIdentity::generate();
+ let mut config = config_with_defaults(vec![RELAY_PRIMARY]);
+ config.author_relay_discovery_relays =
+ vec![RELAY_PRIMARY.to_owned(), RELAY_FORBIDDEN.to_owned()];
+ let resolver = StaticPublishRelayResolver::new().with_addresses(
+ RELAY_FORBIDDEN,
+ vec![IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))],
+ );
+ let adapter = RadrootsMockRelayPublishAdapter::new();
+ let proxy = PublishProxy::memory(config)
+ .expect("proxy")
+ .with_relay_resolver(Arc::new(resolver))
+ .with_author_relay_discovery(Arc::new(StaticPublishAuthorRelayDiscovery::new(vec![
+ "not a discovered author relay",
+ RELAY_SECONDARY,
+ ])))
+ .with_publisher(Arc::new(adapter.clone()));
+ let principal = principal(
+ &proxy,
+ identity.public_key_hex(),
+ vec![PublishRelayPolicy::AuthorWriteThenDaemonDefault],
+ false,
+ PublishJobVisibility::Own,
+ );
+ let response = proxy
+ .publish_event(
+ &principal,
+ publish_request(
+ signed_event(&identity, "{}"),
+ Vec::new(),
+ PublishRelayPolicy::AuthorWriteThenDaemonDefault,
+ PublishDeliveryPolicy::Any,
+ None,
+ ),
+ )
+ .await
+ .expect("publish");
+
+ assert_eq!(response.job.status, PublishJobStatus::DeliverySatisfied);
+ let accepted = response
+ .job
+ .relays
+ .iter()
+ .find(|relay| relay.relay_url == RELAY_SECONDARY)
+ .expect("discovered author relay");
+ assert_eq!(accepted.source, PublishRelaySource::AuthorWrite);
+ assert!(accepted.attempted);
+ let discovered = response
+ .job
+ .relays
+ .iter()
+ .find(|relay| relay.relay_url == "not a discovered author relay")
+ .expect("discovered author rejection");
+ assert_eq!(discovered.source, PublishRelaySource::AuthorWrite);
+ assert_eq!(
+ discovered.outcome_kind,
+ PublishRelayOutcomeKind::RelayUrlRejected
+ );
+ assert!(!discovered.attempted);
+ let discovery = response
+ .job
+ .relays
+ .iter()
+ .find(|relay| relay.relay_url == RELAY_FORBIDDEN)
+ .expect("discovery relay rejection");
+ assert_eq!(discovery.source, PublishRelaySource::DaemonDefault);
+ assert_eq!(
+ discovery.outcome_kind,
+ PublishRelayOutcomeKind::RelayUrlRejected
+ );
+ assert!(!discovery.attempted);
+ assert_eq!(adapter.captured_raw_events().len(), 1);
+ }
+
+ #[tokio::test]
async fn publish_event_records_no_publish_relays_failure() {
let identity = RadrootsIdentity::generate();
let (proxy, adapter) = publish_proxy(PublishProxyConfig::default());