commit 12020a0d56e3570e47a51d85fdec716ce8f29ff8
parent cb9bbde65a6dec710e9628c084f3fd56659b1a21
Author: triesap <tyson@radroots.org>
Date: Thu, 11 Jun 2026 17:24:22 -0700
events_codec: add auth codecs
- add NIP-42 relay auth tag encoding and decoding
- add NIP-98 HTTP auth tag encoding and decoding
- validate empty content and required auth tags with focused tests
- remove an unused base64url tag helper after concrete codec use settled
Diffstat:
8 files changed, 458 insertions(+), 12 deletions(-)
diff --git a/crates/events_codec/src/field_helpers.rs b/crates/events_codec/src/field_helpers.rs
@@ -127,18 +127,6 @@ pub(crate) fn validate_non_empty_base64url(
}
}
-#[cfg(feature = "serde_json")]
-pub(crate) fn validate_non_empty_base64url_tag(
- value: &str,
- tag: &'static str,
-) -> Result<(), EventParseError> {
- if is_non_empty_base64url(value) {
- Ok(())
- } else {
- Err(EventParseError::InvalidTag(tag))
- }
-}
-
pub(crate) fn validate_non_empty_field(
value: &str,
field: &'static str,
diff --git a/crates/events_codec/src/http_auth/decode.rs b/crates/events_codec/src/http_auth/decode.rs
@@ -0,0 +1,91 @@
+#[cfg(not(feature = "std"))]
+use alloc::{
+ string::{String, ToString},
+ vec::Vec,
+};
+
+use radroots_events::{
+ RadrootsNostrEvent,
+ http_auth::{KIND_HTTP_AUTH, RadrootsHttpAuth},
+ tags::{TAG_METHOD, TAG_PAYLOAD, TAG_URL_AUTH},
+};
+
+use crate::error::EventParseError;
+use crate::field_helpers::{
+ optional_tag_value, require_empty_content, required_tag_value, validate_lowercase_hex_64_tag,
+};
+use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
+
+const EXPECTED_KIND: &str = "27235";
+
+pub fn http_auth_from_event(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsHttpAuth, EventParseError> {
+ if kind != KIND_HTTP_AUTH {
+ return Err(EventParseError::InvalidKind {
+ expected: EXPECTED_KIND,
+ got: kind,
+ });
+ }
+ require_empty_content(content, "content")?;
+ let payload_sha256 = optional_tag_value(tags, TAG_PAYLOAD)?;
+ if let Some(payload) = payload_sha256.as_deref() {
+ validate_lowercase_hex_64_tag(payload, TAG_PAYLOAD)?;
+ }
+ Ok(RadrootsHttpAuth {
+ url: required_tag_value(tags, TAG_URL_AUTH)?,
+ method: required_tag_value(tags, TAG_METHOD)?,
+ payload_sha256,
+ })
+}
+
+pub fn data_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> Result<RadrootsParsedData<RadrootsHttpAuth>, EventParseError> {
+ let auth = http_auth_from_event(kind, &tags, &content)?;
+ Ok(RadrootsParsedData::new(
+ id,
+ author,
+ published_at,
+ kind,
+ auth,
+ ))
+}
+
+pub fn parsed_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+ sig: String,
+) -> Result<RadrootsParsedEvent<RadrootsHttpAuth>, EventParseError> {
+ let data = data_from_event(
+ id.clone(),
+ author.clone(),
+ published_at,
+ kind,
+ content.clone(),
+ tags.clone(),
+ )?;
+ Ok(RadrootsParsedEvent {
+ event: RadrootsNostrEvent {
+ id,
+ author,
+ created_at: published_at,
+ kind,
+ content,
+ tags,
+ sig,
+ },
+ data,
+ })
+}
diff --git a/crates/events_codec/src/http_auth/encode.rs b/crates/events_codec/src/http_auth/encode.rs
@@ -0,0 +1,48 @@
+#[cfg(not(feature = "std"))]
+use alloc::{
+ string::{String, ToString},
+ vec::Vec,
+};
+
+use radroots_events::{
+ http_auth::{KIND_HTTP_AUTH, RadrootsHttpAuth},
+ tags::{TAG_METHOD, TAG_PAYLOAD, TAG_URL_AUTH},
+};
+
+use crate::error::EventEncodeError;
+use crate::field_helpers::{
+ push_optional_tag, push_tag, validate_lowercase_hex_64, validate_non_empty_field,
+};
+use crate::wire::WireEventParts;
+
+pub fn http_auth_build_tags(auth: &RadrootsHttpAuth) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ validate_non_empty_field(&auth.url, "url")?;
+ validate_non_empty_field(&auth.method, "method")?;
+ if let Some(payload) = auth.payload_sha256.as_deref() {
+ validate_lowercase_hex_64(payload, "payload_sha256")?;
+ }
+ let mut tags = Vec::new();
+ push_tag(&mut tags, TAG_URL_AUTH, auth.url.as_str());
+ push_tag(&mut tags, TAG_METHOD, auth.method.as_str());
+ push_optional_tag(&mut tags, TAG_PAYLOAD, auth.payload_sha256.as_deref());
+ Ok(tags)
+}
+
+pub fn to_wire_parts(auth: &RadrootsHttpAuth) -> Result<WireEventParts, EventEncodeError> {
+ to_wire_parts_with_kind(auth, KIND_HTTP_AUTH)
+}
+
+pub fn to_wire_parts_with_kind(
+ auth: &RadrootsHttpAuth,
+ kind: u32,
+) -> Result<WireEventParts, EventEncodeError> {
+ if kind != KIND_HTTP_AUTH {
+ return Err(EventEncodeError::InvalidKind(kind));
+ }
+ let tags = http_auth_build_tags(auth)?;
+ Ok(WireEventParts {
+ kind,
+ content: String::new(),
+ tags,
+ })
+}
diff --git a/crates/events_codec/src/http_auth/mod.rs b/crates/events_codec/src/http_auth/mod.rs
@@ -0,0 +1,109 @@
+pub mod decode;
+pub mod encode;
+
+#[cfg(test)]
+mod tests {
+ use radroots_events::{http_auth::RadrootsHttpAuth, kinds::KIND_POST};
+
+ use crate::error::{EventEncodeError, EventParseError};
+ use crate::http_auth::decode::http_auth_from_event;
+ use crate::http_auth::encode::{http_auth_build_tags, to_wire_parts, to_wire_parts_with_kind};
+
+ const PAYLOAD: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+ #[test]
+ fn http_auth_encodes_and_decodes_get_without_payload() {
+ let auth = RadrootsHttpAuth {
+ url: "https://media.example.invalid/download".to_string(),
+ method: "GET".to_string(),
+ payload_sha256: None,
+ };
+ let parts = to_wire_parts(&auth).expect("http auth wire parts");
+
+ assert_eq!(parts.kind, 27235);
+ assert_eq!(parts.content, "");
+ assert!(parts.tags.contains(&tag("u", auth.url.as_str())));
+ assert!(parts.tags.contains(&tag("method", "GET")));
+
+ let decoded =
+ http_auth_from_event(parts.kind, &parts.tags, &parts.content).expect("decode");
+ assert_eq!(decoded, auth);
+ }
+
+ #[test]
+ fn http_auth_encodes_and_decodes_post_with_payload() {
+ let auth = RadrootsHttpAuth {
+ url: "https://media.example.invalid/upload".to_string(),
+ method: "POST".to_string(),
+ payload_sha256: Some(PAYLOAD.to_string()),
+ };
+ let parts = to_wire_parts(&auth).expect("http auth wire parts");
+
+ assert!(parts.tags.contains(&tag("payload", PAYLOAD)));
+ let decoded =
+ http_auth_from_event(parts.kind, &parts.tags, &parts.content).expect("decode");
+ assert_eq!(decoded.payload_sha256.as_deref(), Some(PAYLOAD));
+ }
+
+ #[test]
+ fn http_auth_rejects_missing_url_missing_method_bad_payload_and_content() {
+ let auth = RadrootsHttpAuth {
+ url: "https://media.example.invalid/upload".to_string(),
+ method: "POST".to_string(),
+ payload_sha256: Some(PAYLOAD.to_string()),
+ };
+ let parts = to_wire_parts(&auth).expect("http auth wire parts");
+ let without_url = parts
+ .tags
+ .iter()
+ .filter(|tag| tag.first().map(|value| value.as_str()) != Some("u"))
+ .cloned()
+ .collect::<Vec<_>>();
+ let missing_url = http_auth_from_event(parts.kind, &without_url, "").unwrap_err();
+ assert!(matches!(missing_url, EventParseError::MissingTag("u")));
+
+ let without_method = parts
+ .tags
+ .iter()
+ .filter(|tag| tag.first().map(|value| value.as_str()) != Some("method"))
+ .cloned()
+ .collect::<Vec<_>>();
+ let missing_method = http_auth_from_event(parts.kind, &without_method, "").unwrap_err();
+ assert!(matches!(
+ missing_method,
+ EventParseError::MissingTag("method")
+ ));
+
+ let mut bad_payload = auth.clone();
+ bad_payload.payload_sha256 = Some("ABC".to_string());
+ let payload_err = http_auth_build_tags(&bad_payload).unwrap_err();
+ assert!(matches!(
+ payload_err,
+ EventEncodeError::InvalidField("payload_sha256")
+ ));
+
+ let content_err = http_auth_from_event(parts.kind, &parts.tags, "not empty").unwrap_err();
+ assert!(matches!(
+ content_err,
+ EventParseError::InvalidJson("content")
+ ));
+ }
+
+ #[test]
+ fn http_auth_rejects_wrong_kind() {
+ let auth = RadrootsHttpAuth {
+ url: "https://media.example.invalid/upload".to_string(),
+ method: "POST".to_string(),
+ payload_sha256: Some(PAYLOAD.to_string()),
+ };
+ let wrong_kind = to_wire_parts_with_kind(&auth, KIND_POST).unwrap_err();
+ assert!(matches!(
+ wrong_kind,
+ EventEncodeError::InvalidKind(KIND_POST)
+ ));
+ }
+
+ fn tag(key: &str, value: &str) -> Vec<String> {
+ vec![key.to_string(), value.to_string()]
+ }
+}
diff --git a/crates/events_codec/src/lib.rs b/crates/events_codec/src/lib.rs
@@ -24,11 +24,13 @@ pub mod farm_workspace;
pub mod follow;
pub mod geochat;
pub mod gift_wrap;
+pub mod http_auth;
pub mod message;
pub mod message_file;
pub mod plot;
pub mod post;
pub mod reaction;
+pub mod relay_auth;
pub mod resource_area;
pub mod resource_cap;
pub mod seal;
diff --git a/crates/events_codec/src/relay_auth/decode.rs b/crates/events_codec/src/relay_auth/decode.rs
@@ -0,0 +1,84 @@
+#[cfg(not(feature = "std"))]
+use alloc::{
+ string::{String, ToString},
+ vec::Vec,
+};
+
+use radroots_events::{
+ RadrootsNostrEvent,
+ relay_auth::{KIND_RELAY_AUTH, RadrootsRelayAuth},
+ tags::{TAG_CHALLENGE, TAG_RELAY},
+};
+
+use crate::error::EventParseError;
+use crate::field_helpers::{require_empty_content, required_tag_value};
+use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
+
+const EXPECTED_KIND: &str = "22242";
+
+pub fn relay_auth_from_event(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsRelayAuth, EventParseError> {
+ if kind != KIND_RELAY_AUTH {
+ return Err(EventParseError::InvalidKind {
+ expected: EXPECTED_KIND,
+ got: kind,
+ });
+ }
+ require_empty_content(content, "content")?;
+ Ok(RadrootsRelayAuth {
+ relay: required_tag_value(tags, TAG_RELAY)?,
+ challenge: required_tag_value(tags, TAG_CHALLENGE)?,
+ })
+}
+
+pub fn data_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> Result<RadrootsParsedData<RadrootsRelayAuth>, EventParseError> {
+ let auth = relay_auth_from_event(kind, &tags, &content)?;
+ Ok(RadrootsParsedData::new(
+ id,
+ author,
+ published_at,
+ kind,
+ auth,
+ ))
+}
+
+pub fn parsed_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+ sig: String,
+) -> Result<RadrootsParsedEvent<RadrootsRelayAuth>, EventParseError> {
+ let data = data_from_event(
+ id.clone(),
+ author.clone(),
+ published_at,
+ kind,
+ content.clone(),
+ tags.clone(),
+ )?;
+ Ok(RadrootsParsedEvent {
+ event: RadrootsNostrEvent {
+ id,
+ author,
+ created_at: published_at,
+ kind,
+ content,
+ tags,
+ sig,
+ },
+ data,
+ })
+}
diff --git a/crates/events_codec/src/relay_auth/encode.rs b/crates/events_codec/src/relay_auth/encode.rs
@@ -0,0 +1,44 @@
+#[cfg(not(feature = "std"))]
+use alloc::{
+ string::{String, ToString},
+ vec::Vec,
+};
+
+use radroots_events::{
+ relay_auth::{KIND_RELAY_AUTH, RadrootsRelayAuth},
+ tags::{TAG_CHALLENGE, TAG_RELAY},
+};
+
+use crate::error::EventEncodeError;
+use crate::field_helpers::{push_tag, validate_non_empty_field};
+use crate::wire::WireEventParts;
+
+pub fn relay_auth_build_tags(
+ auth: &RadrootsRelayAuth,
+) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ validate_non_empty_field(&auth.relay, "relay")?;
+ validate_non_empty_field(&auth.challenge, "challenge")?;
+ let mut tags = Vec::new();
+ push_tag(&mut tags, TAG_RELAY, auth.relay.as_str());
+ push_tag(&mut tags, TAG_CHALLENGE, auth.challenge.as_str());
+ Ok(tags)
+}
+
+pub fn to_wire_parts(auth: &RadrootsRelayAuth) -> Result<WireEventParts, EventEncodeError> {
+ to_wire_parts_with_kind(auth, KIND_RELAY_AUTH)
+}
+
+pub fn to_wire_parts_with_kind(
+ auth: &RadrootsRelayAuth,
+ kind: u32,
+) -> Result<WireEventParts, EventEncodeError> {
+ if kind != KIND_RELAY_AUTH {
+ return Err(EventEncodeError::InvalidKind(kind));
+ }
+ let tags = relay_auth_build_tags(auth)?;
+ Ok(WireEventParts {
+ kind,
+ content: String::new(),
+ tags,
+ })
+}
diff --git a/crates/events_codec/src/relay_auth/mod.rs b/crates/events_codec/src/relay_auth/mod.rs
@@ -0,0 +1,80 @@
+pub mod decode;
+pub mod encode;
+
+#[cfg(test)]
+mod tests {
+ use radroots_events::{kinds::KIND_POST, relay_auth::RadrootsRelayAuth};
+
+ use crate::error::{EventEncodeError, EventParseError};
+ use crate::relay_auth::decode::relay_auth_from_event;
+ use crate::relay_auth::encode::{
+ relay_auth_build_tags, to_wire_parts, to_wire_parts_with_kind,
+ };
+
+ #[test]
+ fn relay_auth_encodes_and_decodes_nip42_event() {
+ let auth = sample_auth();
+ let parts = to_wire_parts(&auth).expect("relay auth wire parts");
+
+ assert_eq!(parts.kind, 22242);
+ assert_eq!(parts.content, "");
+ assert!(parts.tags.contains(&tag("relay", auth.relay.as_str())));
+ assert!(
+ parts
+ .tags
+ .contains(&tag("challenge", auth.challenge.as_str()))
+ );
+
+ let decoded =
+ relay_auth_from_event(parts.kind, &parts.tags, &parts.content).expect("decode");
+ assert_eq!(decoded, auth);
+ }
+
+ #[test]
+ fn relay_auth_rejects_missing_challenge_non_empty_content_and_wrong_kind() {
+ let parts = to_wire_parts(&sample_auth()).expect("relay auth wire parts");
+ let without_challenge = parts
+ .tags
+ .iter()
+ .filter(|tag| tag.first().map(|value| value.as_str()) != Some("challenge"))
+ .cloned()
+ .collect::<Vec<_>>();
+ let missing =
+ relay_auth_from_event(parts.kind, &without_challenge, &parts.content).unwrap_err();
+ assert!(matches!(missing, EventParseError::MissingTag("challenge")));
+
+ let content_err = relay_auth_from_event(parts.kind, &parts.tags, "not empty").unwrap_err();
+ assert!(matches!(
+ content_err,
+ EventParseError::InvalidJson("content")
+ ));
+
+ let wrong_kind = to_wire_parts_with_kind(&sample_auth(), KIND_POST).unwrap_err();
+ assert!(matches!(
+ wrong_kind,
+ EventEncodeError::InvalidKind(KIND_POST)
+ ));
+ }
+
+ #[test]
+ fn relay_auth_rejects_empty_required_fields() {
+ let mut auth = sample_auth();
+ auth.relay.clear();
+ let relay_err = relay_auth_build_tags(&auth).unwrap_err();
+ assert!(matches!(
+ relay_err,
+ EventEncodeError::EmptyRequiredField("relay")
+ ));
+ }
+
+ fn sample_auth() -> RadrootsRelayAuth {
+ RadrootsRelayAuth {
+ relay: "wss://relay.example.invalid/farm/field-group".to_string(),
+ challenge: "relay-provided-challenge".to_string(),
+ }
+ }
+
+ fn tag(key: &str, value: &str) -> Vec<String> {
+ vec![key.to_string(), value.to_string()]
+ }
+}