commit c3b2de702fd1a4893ea5e85175e2e0b78c4d10fe
parent 13e1d274f04fc464124bf9c7b8d02c51f224c6a2
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Jul 2026 20:57:17 +0000
transport: clear proxy claims on local validation errors
- validate proxy target policy and delivery policy before local event ingest
- terminally clear claimed events for local proxy validation failures
- cover Reticulum proxy target rejection without stuck claims
- extend radrootsd adapter tests for protocol-field rejection
Diffstat:
3 files changed, 87 insertions(+), 3 deletions(-)
diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs
@@ -753,6 +753,18 @@ async fn push_proxy_claimed_outbox_event(
claimed.outbox_event_id,
),
)?;
+ let target_policy = match proxy_transport_publish_target_policy(claimed) {
+ Ok(target_policy) => target_policy,
+ Err(error) => {
+ return fail_proxy_local_validation(sync, claimed, error, now_ms).await;
+ }
+ };
+ let delivery_policy = match proxy_delivery_policy(sync, claimed, &target_policy).await {
+ Ok(delivery_policy) => delivery_policy,
+ Err(error) => {
+ return fail_proxy_local_validation(sync, claimed, error, now_ms).await;
+ }
+ };
sync.sdk
._outbox
.ingest_signed_event_local(
@@ -762,10 +774,9 @@ async fn push_proxy_claimed_outbox_event(
now_ms,
)
.await?;
- let target_policy = proxy_transport_publish_target_policy(claimed)?;
let request = RadrootsdProxyPublishRequest {
signed_event: signed_event.clone(),
- delivery_policy: proxy_delivery_policy(sync, claimed, &target_policy).await?,
+ delivery_policy,
target_policy,
idempotency_key: Some(proxy_outbox_idempotency_key(
claimed.outbox_event_id,
@@ -796,6 +807,26 @@ async fn push_proxy_claimed_outbox_event(
}
#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))]
+async fn fail_proxy_local_validation(
+ sync: &SyncClient<'_>,
+ claimed: &RadrootsOutboxClaimedEvent,
+ error: RadrootsSdkError,
+ now_ms: i64,
+) -> Result<TransportPublishJobView, RadrootsSdkError> {
+ let message = error.to_string();
+ sync.sdk
+ ._outbox
+ .mark_publish_failed_terminal(
+ claimed.outbox_event_id,
+ claimed.claim_token.as_str(),
+ message.as_str(),
+ now_ms,
+ )
+ .await?;
+ Err(error)
+}
+
+#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))]
async fn proxy_delivery_policy(
sync: &SyncClient<'_>,
claimed: &RadrootsOutboxClaimedEvent,
diff --git a/crates/sdk/tests/unit/adapters_radrootsd_tests.rs b/crates/sdk/tests/unit/adapters_radrootsd_tests.rs
@@ -692,6 +692,13 @@ async fn publish_signed_event_rejects_invalid_protocol_requests_before_http() {
NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault,
vec![" ".to_owned()],
);
+ let mut nostr_preview_behavior = base.clone();
+ nostr_preview_behavior.target_policy =
+ TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget {
+ transport_kind: "nostr".to_owned(),
+ endpoint_uri: "wss://relay.example.com".to_owned(),
+ preview_behavior: Some(TransportPublishPreviewBehavior::RejectDeliveryAttempts),
+ }]);
let mut empty_idempotency = base;
empty_idempotency.idempotency_key = Some(" ".to_owned());
@@ -701,6 +708,7 @@ async fn publish_signed_event_rejects_invalid_protocol_requests_before_http() {
invalid_quorum,
too_many_targets,
empty_endpoint_uri,
+ nostr_preview_behavior,
empty_idempotency,
] {
assert!(matches!(
diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs
@@ -44,7 +44,8 @@ use radroots_outbox::{
use radroots_outbox::{RadrootsOutboxEventState, RadrootsOutboxStatusSummary};
#[cfg(feature = "radrootsd-proxy")]
use radroots_transport::{
- RadrootsTransportKind, RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget,
+ RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RadrootsTransportKind,
+ RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget,
};
use radroots_transport_nostr::{
RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, RadrootsRelayPublishReceipt,
@@ -749,6 +750,50 @@ async fn proxy_claim_publish_marks_retryable_transport_errors() {
#[cfg(feature = "radrootsd-proxy")]
#[tokio::test]
+async fn proxy_local_validation_errors_release_claim_before_daemon_publish() {
+ let (sdk, mut claimed) = claimed_proxy_event("proxy-local-validation-error").await;
+ let reticulum_target = RadrootsTransportTarget::new(
+ RadrootsTransportKind::Reticulum,
+ RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI,
+ )
+ .expect("Reticulum target");
+ claimed.delivery_targets[0].transport_kind = reticulum_target.kind;
+ claimed.delivery_targets[0].endpoint_uri = reticulum_target.uri;
+ claimed.delivery_targets[0].endpoint_fingerprint = reticulum_target.fingerprint;
+ let sync = sdk.sync();
+ let adapter =
+ RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new("http://127.0.0.1:9/rpc"));
+ let error =
+ push_proxy_claimed_outbox_event(&sync, &adapter, &claimed, 60_000, 1_700_000_000_000)
+ .await
+ .expect_err("local proxy validation error");
+
+ assert!(matches!(
+ error,
+ RadrootsSdkError::InvalidRequest { message }
+ if message.contains("radrootsd proxy outbox publish")
+ && message.contains("Reticulum target")
+ ));
+ let stored = sdk
+ ._outbox
+ .get_event(claimed.outbox_event_id)
+ .await
+ .expect("stored")
+ .expect("stored");
+ assert_eq!(stored.state, RadrootsOutboxEventState::FailedTerminal);
+ assert!(stored.claim_token.is_none());
+ assert!(stored.event_store_ingested);
+ assert!(
+ stored
+ .last_error
+ .as_deref()
+ .expect("last error")
+ .contains("Reticulum target")
+ );
+}
+
+#[cfg(feature = "radrootsd-proxy")]
+#[tokio::test]
async fn proxy_completion_updates_outbox_for_success_retryable_and_terminal_receipts() {
let cases = [
(