commit d428fe1abf0339ecd6e40409e4e1abeb331eb406
parent 5f136f7a29d00ab53676d7f3cd7b9f144a97326d
Author: triesap <tyson@radroots.org>
Date: Mon, 25 May 2026 07:03:19 +0000
local-events: add relay evidence contracts
Diffstat:
8 files changed, 758 insertions(+), 6 deletions(-)
diff --git a/crates/local_events/src/lib.rs b/crates/local_events/src/lib.rs
@@ -4,7 +4,9 @@ mod error;
mod migrations;
mod models;
mod order_work;
+mod relay_delivery;
mod relay_set;
+mod relay_url;
mod store;
pub use error::LocalEventsError;
@@ -22,5 +24,7 @@ pub use order_work::{
validate_supported_buyer_order_request_local_work_payload,
validate_unsupported_buyer_order_request_local_work_payload,
};
+pub use relay_delivery::{RelayDeliveryEvidence, RelayDeliveryFailure, RelayDeliveryState};
pub use relay_set::{CANONICAL_RELAY_SET_FINGERPRINT_VERSION, canonical_relay_set_fingerprint};
+pub use relay_url::{RelayUrlValidationError, normalize_relay_url, normalize_relay_urls};
pub use store::LocalEventsStore;
diff --git a/crates/local_events/src/relay_delivery.rs b/crates/local_events/src/relay_delivery.rs
@@ -0,0 +1,262 @@
+#![forbid(unsafe_code)]
+
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::{
+ LocalEventsError, canonical_relay_set_fingerprint, relay_url::RelayUrlValidationError,
+ relay_url::normalize_relay_urls,
+};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RelayDeliveryState {
+ Pending,
+ Acknowledged,
+ Failed,
+}
+
+impl RelayDeliveryState {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Pending => "pending",
+ Self::Acknowledged => "acknowledged",
+ Self::Failed => "failed",
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RelayDeliveryFailure {
+ pub relay_url: String,
+ pub error: String,
+}
+
+impl RelayDeliveryFailure {
+ pub fn new(
+ relay_url: impl AsRef<str>,
+ error: impl AsRef<str>,
+ ) -> Result<Self, LocalEventsError> {
+ let relay_url = normalize_relay_url_for_evidence("failed_relays.relay_url", relay_url)?;
+ let error = normalize_non_empty_text("failed_relays.error", error.as_ref())?;
+ Ok(Self { relay_url, error })
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RelayDeliveryEvidence {
+ pub state: RelayDeliveryState,
+ pub target_relays: Vec<String>,
+ pub connected_relays: Vec<String>,
+ pub acknowledged_relays: Vec<String>,
+ pub failed_relays: Vec<RelayDeliveryFailure>,
+}
+
+impl RelayDeliveryEvidence {
+ pub fn pending<I, S>(target_relays: I) -> Result<Self, LocalEventsError>
+ where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+ {
+ Self::build(
+ RelayDeliveryState::Pending,
+ target_relays,
+ Vec::<String>::new(),
+ Vec::<String>::new(),
+ Vec::new(),
+ )
+ }
+
+ pub fn acknowledged<I, S, J, T, K, U>(
+ target_relays: I,
+ connected_relays: J,
+ acknowledged_relays: K,
+ failed_relays: Vec<RelayDeliveryFailure>,
+ ) -> Result<Self, LocalEventsError>
+ where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+ J: IntoIterator<Item = T>,
+ T: AsRef<str>,
+ K: IntoIterator<Item = U>,
+ U: AsRef<str>,
+ {
+ Self::build(
+ RelayDeliveryState::Acknowledged,
+ target_relays,
+ connected_relays,
+ acknowledged_relays,
+ failed_relays,
+ )
+ }
+
+ pub fn failed<I, S, J, T>(
+ target_relays: I,
+ connected_relays: J,
+ failed_relays: Vec<RelayDeliveryFailure>,
+ ) -> Result<Self, LocalEventsError>
+ where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+ J: IntoIterator<Item = T>,
+ T: AsRef<str>,
+ {
+ Self::build(
+ RelayDeliveryState::Failed,
+ target_relays,
+ connected_relays,
+ Vec::<String>::new(),
+ failed_relays,
+ )
+ }
+
+ pub fn validate(&self) -> Result<(), LocalEventsError> {
+ validate_relay_set("target_relays", &self.target_relays, true)?;
+ validate_relay_set("connected_relays", &self.connected_relays, false)?;
+ validate_relay_set("acknowledged_relays", &self.acknowledged_relays, false)?;
+ for failure in &self.failed_relays {
+ let normalized =
+ normalize_relay_url_for_evidence("failed_relays.relay_url", &failure.relay_url)?;
+ if normalized != failure.relay_url {
+ return Err(invalid_evidence(
+ "failed_relays.relay_url must be normalized and deduplicated",
+ ));
+ }
+ let normalized_error = normalize_non_empty_text("failed_relays.error", &failure.error)?;
+ if normalized_error != failure.error {
+ return Err(invalid_evidence("failed_relays.error must be trimmed"));
+ }
+ }
+ match self.state {
+ RelayDeliveryState::Pending => {
+ if !self.acknowledged_relays.is_empty() || !self.failed_relays.is_empty() {
+ return Err(invalid_evidence(
+ "pending delivery evidence must not include acknowledged or failed relays",
+ ));
+ }
+ }
+ RelayDeliveryState::Acknowledged => {
+ if self.acknowledged_relays.is_empty() {
+ return Err(invalid_evidence(
+ "acknowledged delivery evidence requires acknowledged_relays",
+ ));
+ }
+ }
+ RelayDeliveryState::Failed => {
+ if !self.acknowledged_relays.is_empty() || self.failed_relays.is_empty() {
+ return Err(invalid_evidence(
+ "failed delivery evidence requires failed_relays and no acknowledged_relays",
+ ));
+ }
+ }
+ }
+ Ok(())
+ }
+
+ pub fn relay_set_fingerprint(&self) -> Option<String> {
+ canonical_relay_set_fingerprint(&self.target_relays)
+ }
+
+ pub fn to_json_value(&self) -> Result<Value, LocalEventsError> {
+ self.validate()?;
+ serde_json::to_value(self).map_err(LocalEventsError::from)
+ }
+
+ pub fn from_json_value(value: &Value) -> Result<Self, LocalEventsError> {
+ let evidence: Self = serde_json::from_value(value.clone())?;
+ evidence.validate()?;
+ Ok(evidence)
+ }
+
+ fn build<I, S, J, T, K, U>(
+ state: RelayDeliveryState,
+ target_relays: I,
+ connected_relays: J,
+ acknowledged_relays: K,
+ failed_relays: Vec<RelayDeliveryFailure>,
+ ) -> Result<Self, LocalEventsError>
+ where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+ J: IntoIterator<Item = T>,
+ T: AsRef<str>,
+ K: IntoIterator<Item = U>,
+ U: AsRef<str>,
+ {
+ let evidence = Self {
+ state,
+ target_relays: normalize_required_relay_set("target_relays", target_relays)?,
+ connected_relays: normalize_relay_set("connected_relays", connected_relays)?,
+ acknowledged_relays: normalize_relay_set("acknowledged_relays", acknowledged_relays)?,
+ failed_relays,
+ };
+ evidence.validate()?;
+ Ok(evidence)
+ }
+}
+
+fn normalize_relay_url_for_evidence(
+ field: &str,
+ value: impl AsRef<str>,
+) -> Result<String, LocalEventsError> {
+ crate::relay_url::normalize_relay_url(value.as_ref()).map_err(|error| relay_error(field, error))
+}
+
+fn normalize_required_relay_set<I, S>(
+ field: &str,
+ values: I,
+) -> Result<Vec<String>, LocalEventsError>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+{
+ let relays = normalize_relay_set(field, values)?;
+ if relays.is_empty() {
+ return Err(invalid_evidence(format!("{field} must not be empty")));
+ }
+ Ok(relays)
+}
+
+fn normalize_relay_set<I, S>(field: &str, values: I) -> Result<Vec<String>, LocalEventsError>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+{
+ normalize_relay_urls(values).map_err(|error| relay_error(field, error))
+}
+
+fn validate_relay_set(
+ field: &str,
+ relays: &[String],
+ require_non_empty: bool,
+) -> Result<(), LocalEventsError> {
+ let normalized = normalize_relay_set(field, relays)?;
+ if require_non_empty && normalized.is_empty() {
+ return Err(invalid_evidence(format!("{field} must not be empty")));
+ }
+ if normalized != relays {
+ return Err(invalid_evidence(format!(
+ "{field} must be normalized and deduplicated"
+ )));
+ }
+ Ok(())
+}
+
+fn normalize_non_empty_text(field: &str, value: &str) -> Result<String, LocalEventsError> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return Err(invalid_evidence(format!("{field} must not be empty")));
+ }
+ Ok(trimmed.to_owned())
+}
+
+fn relay_error(field: &str, error: RelayUrlValidationError) -> LocalEventsError {
+ invalid_evidence(format!("{field}: {error}"))
+}
+
+fn invalid_evidence(message: impl Into<String>) -> LocalEventsError {
+ LocalEventsError::InvalidRecord(format!(
+ "invalid relay delivery evidence: {}",
+ message.into()
+ ))
+}
diff --git a/crates/local_events/src/relay_url.rs b/crates/local_events/src/relay_url.rs
@@ -0,0 +1,142 @@
+#![forbid(unsafe_code)]
+
+use std::fmt;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RelayUrlValidationError {
+ Empty,
+ UnsupportedScheme(String),
+ MissingHost(String),
+ InvalidAuthority(String),
+ InvalidPort(String),
+}
+
+impl fmt::Display for RelayUrlValidationError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Empty => f.write_str("relay url must not be empty"),
+ Self::UnsupportedScheme(value) => {
+ write!(f, "relay url must use ws or wss, got `{value}`")
+ }
+ Self::MissingHost(value) => write!(f, "relay url must include a host, got `{value}`"),
+ Self::InvalidAuthority(value) => {
+ write!(f, "relay url authority is invalid, got `{value}`")
+ }
+ Self::InvalidPort(value) => write!(f, "relay url port is invalid, got `{value}`"),
+ }
+ }
+}
+
+impl std::error::Error for RelayUrlValidationError {}
+
+pub fn normalize_relay_url(value: &str) -> Result<String, RelayUrlValidationError> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return Err(RelayUrlValidationError::Empty);
+ }
+
+ let rest = if let Some(rest) = trimmed.strip_prefix("ws://") {
+ rest
+ } else if let Some(rest) = trimmed.strip_prefix("wss://") {
+ rest
+ } else {
+ return Err(RelayUrlValidationError::UnsupportedScheme(
+ trimmed.to_owned(),
+ ));
+ };
+
+ validate_relay_authority(trimmed, rest)?;
+ Ok(trimmed.to_owned())
+}
+
+pub fn normalize_relay_urls<I, S>(values: I) -> Result<Vec<String>, RelayUrlValidationError>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+{
+ let mut normalized = Vec::new();
+ for value in values {
+ let relay = normalize_relay_url(value.as_ref())?;
+ if !normalized.iter().any(|existing| existing == &relay) {
+ normalized.push(relay);
+ }
+ }
+ Ok(normalized)
+}
+
+fn validate_relay_authority(original: &str, rest: &str) -> Result<(), RelayUrlValidationError> {
+ let authority_end = rest
+ .char_indices()
+ .find(|(_, ch)| matches!(ch, '/' | '?' | '#'))
+ .map(|(index, _)| index)
+ .unwrap_or(rest.len());
+ let authority = &rest[..authority_end];
+
+ if authority.is_empty() {
+ return Err(RelayUrlValidationError::MissingHost(original.to_owned()));
+ }
+ if authority.chars().any(char::is_whitespace) || authority.contains('@') {
+ return Err(RelayUrlValidationError::InvalidAuthority(
+ original.to_owned(),
+ ));
+ }
+
+ if let Some(after_open) = authority.strip_prefix('[') {
+ let Some(close_index) = after_open.find(']') else {
+ return Err(RelayUrlValidationError::InvalidAuthority(
+ original.to_owned(),
+ ));
+ };
+ let host = &after_open[..close_index];
+ let after_host = &after_open[close_index + 1..];
+ if host.is_empty() {
+ return Err(RelayUrlValidationError::MissingHost(original.to_owned()));
+ }
+ validate_optional_port(original, after_host)?;
+ return Ok(());
+ }
+
+ let colon_count = authority.bytes().filter(|byte| *byte == b':').count();
+ match colon_count {
+ 0 => {
+ if authority.is_empty() {
+ Err(RelayUrlValidationError::MissingHost(original.to_owned()))
+ } else {
+ Ok(())
+ }
+ }
+ 1 => {
+ let Some((host, port)) = authority.split_once(':') else {
+ return Err(RelayUrlValidationError::InvalidAuthority(
+ original.to_owned(),
+ ));
+ };
+ if host.is_empty() {
+ return Err(RelayUrlValidationError::MissingHost(original.to_owned()));
+ }
+ validate_port(original, port)
+ }
+ _ => Err(RelayUrlValidationError::InvalidAuthority(
+ original.to_owned(),
+ )),
+ }
+}
+
+fn validate_optional_port(original: &str, after_host: &str) -> Result<(), RelayUrlValidationError> {
+ if after_host.is_empty() {
+ return Ok(());
+ }
+ let Some(port) = after_host.strip_prefix(':') else {
+ return Err(RelayUrlValidationError::InvalidAuthority(
+ original.to_owned(),
+ ));
+ };
+ validate_port(original, port)
+}
+
+fn validate_port(original: &str, port: &str) -> Result<(), RelayUrlValidationError> {
+ if port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) {
+ return Err(RelayUrlValidationError::InvalidPort(original.to_owned()));
+ }
+ Ok(())
+}
diff --git a/crates/local_events/tests/relay_delivery.rs b/crates/local_events/tests/relay_delivery.rs
@@ -0,0 +1,102 @@
+use radroots_local_events::{
+ RelayDeliveryEvidence, RelayDeliveryFailure, RelayDeliveryState,
+ canonical_relay_set_fingerprint,
+};
+use serde_json::json;
+
+#[test]
+fn pending_delivery_evidence_uses_canonical_json_shape() {
+ let evidence = RelayDeliveryEvidence::pending([
+ " wss://relay-b.example ",
+ "wss://relay-a.example",
+ "wss://relay-b.example",
+ ])
+ .expect("pending evidence");
+
+ assert_eq!(evidence.state, RelayDeliveryState::Pending);
+ assert_eq!(
+ evidence.target_relays,
+ vec![
+ "wss://relay-b.example".to_owned(),
+ "wss://relay-a.example".to_owned()
+ ]
+ );
+ assert_eq!(
+ evidence.to_json_value().expect("json"),
+ json!({
+ "state": "pending",
+ "target_relays": ["wss://relay-b.example", "wss://relay-a.example"],
+ "connected_relays": [],
+ "acknowledged_relays": [],
+ "failed_relays": []
+ })
+ );
+}
+
+#[test]
+fn acknowledged_delivery_evidence_uses_canonical_failure_fields() {
+ let evidence = RelayDeliveryEvidence::acknowledged(
+ ["wss://relay-a.example", "wss://relay-b.example"],
+ [" wss://relay-a.example "],
+ ["wss://relay-a.example"],
+ vec![RelayDeliveryFailure::new(" wss://relay-b.example ", " timeout ").expect("failure")],
+ )
+ .expect("acknowledged evidence");
+
+ assert_eq!(
+ evidence.to_json_value().expect("json"),
+ json!({
+ "state": "acknowledged",
+ "target_relays": ["wss://relay-a.example", "wss://relay-b.example"],
+ "connected_relays": ["wss://relay-a.example"],
+ "acknowledged_relays": ["wss://relay-a.example"],
+ "failed_relays": [
+ {"relay_url": "wss://relay-b.example", "error": "timeout"}
+ ]
+ })
+ );
+}
+
+#[test]
+fn failed_delivery_evidence_requires_failures_without_acknowledgements() {
+ let evidence = RelayDeliveryEvidence::failed(
+ ["wss://relay-a.example"],
+ ["wss://relay-a.example"],
+ vec![RelayDeliveryFailure::new("wss://relay-a.example", "closed").expect("failure")],
+ )
+ .expect("failed evidence");
+
+ assert_eq!(evidence.state, RelayDeliveryState::Failed);
+ assert!(evidence.acknowledged_relays.is_empty());
+ assert_eq!(evidence.failed_relays.len(), 1);
+}
+
+#[test]
+fn delivery_evidence_fingerprint_uses_target_relays() {
+ let evidence = RelayDeliveryEvidence::acknowledged(
+ ["wss://relay-b.example", "wss://relay-a.example"],
+ ["wss://relay-a.example"],
+ ["wss://relay-a.example"],
+ Vec::new(),
+ )
+ .expect("evidence");
+
+ assert_eq!(
+ evidence.relay_set_fingerprint(),
+ canonical_relay_set_fingerprint(["wss://relay-a.example", "wss://relay-b.example"])
+ );
+}
+
+#[test]
+fn delivery_evidence_rejects_invalid_json_shape() {
+ let err = RelayDeliveryEvidence::from_json_value(&json!({
+ "state": "acknowledged",
+ "target_relays": ["wss://relay-a.example"],
+ "connected_relays": [],
+ "acknowledged_relays": [],
+ "failed_relays": []
+ }))
+ .expect_err("invalid evidence");
+
+ assert!(err.to_string().contains("acknowledged_relays"));
+}
diff --git a/crates/local_events/tests/relay_url.rs b/crates/local_events/tests/relay_url.rs
@@ -0,0 +1,103 @@
+use radroots_local_events::{RelayUrlValidationError, normalize_relay_url, normalize_relay_urls};
+
+#[test]
+fn relay_url_normalization_trims_and_dedupes() {
+ let relays = normalize_relay_urls([
+ " wss://relay-a.example ",
+ "wss://relay-a.example",
+ "ws://127.0.0.1:8080/nostr",
+ ])
+ .expect("normalize relays");
+
+ assert_eq!(
+ relays,
+ vec![
+ "wss://relay-a.example".to_owned(),
+ "ws://127.0.0.1:8080/nostr".to_owned()
+ ]
+ );
+}
+
+#[test]
+fn relay_url_validation_rejects_empty_values() {
+ assert_eq!(
+ normalize_relay_url(" "),
+ Err(RelayUrlValidationError::Empty)
+ );
+}
+
+#[test]
+fn relay_url_validation_rejects_non_websocket_schemes() {
+ assert_eq!(
+ normalize_relay_url("https://relay.example"),
+ Err(RelayUrlValidationError::UnsupportedScheme(
+ "https://relay.example".to_owned()
+ ))
+ );
+}
+
+#[test]
+fn relay_url_validation_rejects_hostless_values() {
+ assert_eq!(
+ normalize_relay_url("wss://"),
+ Err(RelayUrlValidationError::MissingHost("wss://".to_owned()))
+ );
+ assert_eq!(
+ normalize_relay_url("wss:///relay"),
+ Err(RelayUrlValidationError::MissingHost(
+ "wss:///relay".to_owned()
+ ))
+ );
+ assert_eq!(
+ normalize_relay_url("ws://:8080"),
+ Err(RelayUrlValidationError::MissingHost(
+ "ws://:8080".to_owned()
+ ))
+ );
+}
+
+#[test]
+fn relay_url_validation_rejects_malformed_authority() {
+ assert_eq!(
+ normalize_relay_url("wss://user@relay.example"),
+ Err(RelayUrlValidationError::InvalidAuthority(
+ "wss://user@relay.example".to_owned()
+ ))
+ );
+ assert_eq!(
+ normalize_relay_url("wss://relay example"),
+ Err(RelayUrlValidationError::InvalidAuthority(
+ "wss://relay example".to_owned()
+ ))
+ );
+ assert_eq!(
+ normalize_relay_url("wss://2001:db8::1"),
+ Err(RelayUrlValidationError::InvalidAuthority(
+ "wss://2001:db8::1".to_owned()
+ ))
+ );
+}
+
+#[test]
+fn relay_url_validation_rejects_invalid_ports() {
+ assert_eq!(
+ normalize_relay_url("wss://relay.example:abc"),
+ Err(RelayUrlValidationError::InvalidPort(
+ "wss://relay.example:abc".to_owned()
+ ))
+ );
+ assert_eq!(
+ normalize_relay_url("wss://[2001:db8::1]:abc"),
+ Err(RelayUrlValidationError::InvalidPort(
+ "wss://[2001:db8::1]:abc".to_owned()
+ ))
+ );
+}
+
+#[test]
+fn relay_url_validation_accepts_bracketed_ipv6() {
+ assert_eq!(
+ normalize_relay_url("wss://[2001:db8::1]:8080/nostr").expect("ipv6 relay"),
+ "wss://[2001:db8::1]:8080/nostr"
+ );
+}
diff --git a/crates/local_events/tests/store.rs b/crates/local_events/tests/store.rs
@@ -1,6 +1,6 @@
use radroots_local_events::{
LocalEventRecordInput, LocalEventRecordUpdate, LocalEventsStore, LocalRecordFamily,
- LocalRecordStatus, MIGRATIONS, PublishOutboxStatus, SourceRuntime,
+ LocalRecordStatus, MIGRATIONS, PublishOutboxStatus, RelayDeliveryEvidence, SourceRuntime,
};
use radroots_sql_core::migrations::migrations_run_all_up;
use radroots_sql_core::{SqlExecutor, SqliteExecutor};
@@ -63,7 +63,12 @@ fn signed_event(record_id: &str) -> LocalEventRecordInput {
raw_event_json: Some(json!({"id":"event-a","kind":3421})),
outbox_status: PublishOutboxStatus::Pending,
relay_set_fingerprint: Some("relay-set-a".to_owned()),
- relay_delivery_json: Some(json!({"pending":["ws://127.0.0.1:8080"]})),
+ relay_delivery_json: Some(
+ RelayDeliveryEvidence::pending(["ws://127.0.0.1:8080"])
+ .expect("pending delivery")
+ .to_json_value()
+ .expect("pending delivery json"),
+ ),
}
}
@@ -124,7 +129,17 @@ fn outbox_status_updates_signed_event_records() {
status: LocalRecordStatus::Published,
outbox_status: PublishOutboxStatus::Acknowledged,
relay_set_fingerprint: Some("relay-set-a".to_owned()),
- relay_delivery_json: Some(json!({"acked":["ws://127.0.0.1:8080"]})),
+ relay_delivery_json: Some(
+ RelayDeliveryEvidence::acknowledged(
+ ["ws://127.0.0.1:8080"],
+ ["ws://127.0.0.1:8080"],
+ ["ws://127.0.0.1:8080"],
+ Vec::new(),
+ )
+ .expect("acknowledged delivery")
+ .to_json_value()
+ .expect("acknowledged delivery json"),
+ ),
updated_at_ms: 3000,
})
.expect("update outbox");
@@ -133,7 +148,13 @@ fn outbox_status_updates_signed_event_records() {
assert_eq!(updated.outbox_status, PublishOutboxStatus::Acknowledged);
assert_eq!(
updated.relay_delivery_json,
- Some(json!({"acked":["ws://127.0.0.1:8080"]}))
+ Some(json!({
+ "state": "acknowledged",
+ "target_relays": ["ws://127.0.0.1:8080"],
+ "connected_relays": ["ws://127.0.0.1:8080"],
+ "acknowledged_relays": ["ws://127.0.0.1:8080"],
+ "failed_relays": []
+ }))
);
}
@@ -157,7 +178,17 @@ fn changed_after_uses_change_seq_for_appends_and_outbox_updates() {
status: LocalRecordStatus::Published,
outbox_status: PublishOutboxStatus::Acknowledged,
relay_set_fingerprint: Some("relay-set-a".to_owned()),
- relay_delivery_json: Some(json!({"acked":["ws://127.0.0.1:8080"]})),
+ relay_delivery_json: Some(
+ RelayDeliveryEvidence::acknowledged(
+ ["ws://127.0.0.1:8080"],
+ ["ws://127.0.0.1:8080"],
+ ["ws://127.0.0.1:8080"],
+ Vec::new(),
+ )
+ .expect("acknowledged delivery")
+ .to_json_value()
+ .expect("acknowledged delivery json"),
+ ),
updated_at_ms: 3000,
})
.expect("update outbox");
diff --git a/crates/sdk/src/config.rs b/crates/sdk/src/config.rs
@@ -277,12 +277,76 @@ fn normalize_relay_url(value: &str) -> Result<String, SdkConfigError> {
if trimmed.is_empty() {
return Err(SdkConfigError::EmptyRelayUrl);
}
- if !(trimmed.starts_with("ws://") || trimmed.starts_with("wss://")) {
+
+ let rest = if let Some(rest) = trimmed.strip_prefix("ws://") {
+ rest
+ } else if let Some(rest) = trimmed.strip_prefix("wss://") {
+ rest
+ } else {
+ return Err(SdkConfigError::InvalidRelayUrl(trimmed.to_owned()));
+ };
+
+ if relay_authority_is_invalid(rest) {
return Err(SdkConfigError::InvalidRelayUrl(trimmed.to_owned()));
}
+
Ok(trimmed.to_owned())
}
+fn relay_authority_is_invalid(rest: &str) -> bool {
+ let authority_end = rest
+ .char_indices()
+ .find(|(_, ch)| matches!(ch, '/' | '?' | '#'))
+ .map(|(index, _)| index)
+ .unwrap_or(rest.len());
+ let authority = &rest[..authority_end];
+
+ if authority.is_empty() || authority.chars().any(char::is_whitespace) {
+ return true;
+ }
+ if authority.contains('@') {
+ return true;
+ }
+
+ if let Some(after_open) = authority.strip_prefix('[') {
+ let Some(close_index) = after_open.find(']') else {
+ return true;
+ };
+ let host = &after_open[..close_index];
+ let after_host = &after_open[close_index + 1..];
+ if host.is_empty() {
+ return true;
+ }
+ return relay_port_suffix_is_invalid(after_host);
+ }
+
+ let colon_count = authority.bytes().filter(|byte| *byte == b':').count();
+ match colon_count {
+ 0 => false,
+ 1 => {
+ let Some((host, port)) = authority.split_once(':') else {
+ return true;
+ };
+ host.is_empty() || relay_port_is_invalid(port)
+ }
+ _ => true,
+ }
+}
+
+fn relay_port_suffix_is_invalid(after_host: &str) -> bool {
+ if after_host.is_empty() {
+ return false;
+ }
+ let Some(port) = after_host.strip_prefix(':') else {
+ return true;
+ };
+ relay_port_is_invalid(port)
+}
+
+fn relay_port_is_invalid(port: &str) -> bool {
+ port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit())
+}
+
fn normalize_radrootsd_endpoint(value: &str) -> Result<String, SdkConfigError> {
let trimmed = value.trim();
if trimmed.is_empty() {
diff --git a/crates/sdk/tests/config.rs b/crates/sdk/tests/config.rs
@@ -209,6 +209,50 @@ fn invalid_coordinate_schemes_fail_loudly() {
}
#[test]
+fn invalid_relay_authorities_fail_loudly() {
+ let invalid_relays = [
+ "wss://",
+ "wss:///relay",
+ "ws://:8080",
+ "wss://relay example",
+ "wss://user@relay.example",
+ "wss://relay.example:abc",
+ "wss://2001:db8::1",
+ ];
+
+ for relay_url in invalid_relays {
+ let mut config = RadrootsSdkConfig::production();
+ config.relay.urls = vec![relay_url.to_owned()];
+
+ assert_eq!(
+ config
+ .resolved_relay_urls()
+ .expect_err("relay authority error"),
+ SdkConfigError::InvalidRelayUrl(relay_url.to_owned())
+ );
+ }
+}
+
+#[test]
+fn valid_relay_authorities_still_resolve() {
+ let mut config = RadrootsSdkConfig::production();
+ config.relay.urls = vec![
+ " wss://relay.example/nostr ".to_owned(),
+ "ws://127.0.0.1:8080".to_owned(),
+ "wss://[2001:db8::1]:443/relay".to_owned(),
+ ];
+
+ assert_eq!(
+ config.resolved_relay_urls().expect("valid relays"),
+ vec![
+ "wss://relay.example/nostr".to_owned(),
+ "ws://127.0.0.1:8080".to_owned(),
+ "wss://[2001:db8::1]:443/relay".to_owned()
+ ]
+ );
+}
+
+#[test]
fn sdk_config_debug_redacts_bearer_tokens() {
let mut config = RadrootsSdkConfig::production();
config.radrootsd.auth = RadrootsdAuth::BearerToken("sdk-secret-token".to_owned());