Skip to main content

softchat/
relay.rs

1//! Bounded Nostr relay codecs, filters, authentication plans, and batch results.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5
6use serde::de::{MapAccess, Visitor};
7use serde::{Deserialize, Deserializer, Serialize};
8use serde_json::Value;
9use url::Url;
10
11use crate::{
12    LocalIdentity, NostrEventDraft, NostrEventId, NostrEventKind, NostrPublicKey, NostrTag,
13    SignedEvent, SignedNostrEvent, SoftchatError,
14};
15
16/// Maximum accepted logical relay frame size.
17pub const MAX_RELAY_FRAME_BYTES: usize = 512 * 1024;
18/// Maximum number of events in the deployed `EVENTS` extension.
19pub const MAX_RELAY_BATCH_EVENTS: usize = 256;
20/// NIP-01 maximum subscription identifier length in Unicode scalar values.
21pub const MAX_SUBSCRIPTION_ID_CHARS: usize = 64;
22/// Maximum number of filters in one query.
23pub const MAX_RELAY_FILTERS: usize = 64;
24/// Maximum values in one filter field or generic-tag constraint.
25pub const MAX_RELAY_FILTER_VALUES: usize = 1_024;
26/// Maximum bounded diagnostic/challenge string accepted from a relay.
27pub const MAX_RELAY_MESSAGE_CHARS: usize = 4_096;
28/// Accepted NIP-42 authentication timestamp skew in seconds.
29pub const NIP42_AUTH_WINDOW_SECONDS: u64 = 600;
30
31/// A strict NIP-01 filter that retains generic `#<tag>` constraints.
32#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
33pub struct RelayFilter {
34    /// Event ID prefixes.
35    #[serde(skip_serializing_if = "Vec::is_empty")]
36    pub ids: Vec<String>,
37    /// Author public-key prefixes.
38    #[serde(skip_serializing_if = "Vec::is_empty")]
39    pub authors: Vec<String>,
40    /// Exact event kinds.
41    #[serde(skip_serializing_if = "Vec::is_empty")]
42    pub kinds: Vec<u16>,
43    /// Inclusive lower timestamp bound.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub since: Option<u64>,
46    /// Inclusive upper timestamp bound.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub until: Option<u64>,
49    /// Maximum result count.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub limit: Option<u32>,
52    /// Generic tag constraints keyed by the one-character tag name.
53    #[serde(flatten)]
54    pub generic_tags: BTreeMap<String, Vec<String>>,
55}
56
57impl RelayFilter {
58    /// Parse one filter while rejecting duplicate JSON fields and unbounded values.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`SoftchatError::InvalidRelayFilter`] for malformed or oversized input.
63    pub fn from_json(json: &str) -> Result<Self, SoftchatError> {
64        if json.len() > MAX_RELAY_FRAME_BYTES {
65            return Err(SoftchatError::InvalidRelayFilter);
66        }
67        let mut deserializer = serde_json::Deserializer::from_str(json);
68        let filter =
69            Self::deserialize(&mut deserializer).map_err(|_| SoftchatError::InvalidRelayFilter)?;
70        deserializer
71            .end()
72            .map_err(|_| SoftchatError::InvalidRelayFilter)?;
73        filter.validate()
74    }
75
76    /// Serialize a canonical compact filter object.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`SoftchatError::InvalidRelayFilter`] if the value violates bounds.
81    pub fn to_json(&self) -> Result<String, SoftchatError> {
82        self.clone().validate()?;
83        serde_json::to_string(self).map_err(|_| SoftchatError::InvalidRelayFilter)
84    }
85
86    fn validate(self) -> Result<Self, SoftchatError> {
87        validate_prefixes(&self.ids)?;
88        validate_prefixes(&self.authors)?;
89        validate_value_count(self.kinds.len())?;
90        if self
91            .since
92            .zip(self.until)
93            .is_some_and(|(since, until)| since > until)
94        {
95            return Err(SoftchatError::InvalidRelayFilter);
96        }
97        if self.limit == Some(0) {
98            return Err(SoftchatError::InvalidRelayFilter);
99        }
100        for (key, values) in &self.generic_tags {
101            if !is_generic_tag_key(key) || values.is_empty() {
102                return Err(SoftchatError::InvalidRelayFilter);
103            }
104            validate_value_count(values.len())?;
105            if values
106                .iter()
107                .any(|value| value.len() > MAX_RELAY_FRAME_BYTES)
108            {
109                return Err(SoftchatError::InvalidRelayFilter);
110            }
111        }
112        Ok(self)
113    }
114}
115
116impl<'de> Deserialize<'de> for RelayFilter {
117    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
118    where
119        D: Deserializer<'de>,
120    {
121        struct FilterVisitor;
122
123        impl<'de> Visitor<'de> for FilterVisitor {
124            type Value = RelayFilter;
125
126            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
127                formatter.write_str("a bounded NIP-01 filter object")
128            }
129
130            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
131            where
132                A: MapAccess<'de>,
133            {
134                let mut filter = RelayFilter::default();
135                let mut seen = BTreeSet::new();
136                while let Some(key) = map.next_key::<String>()? {
137                    if !seen.insert(key.clone()) {
138                        return Err(serde::de::Error::custom("duplicate filter field"));
139                    }
140                    match key.as_str() {
141                        "ids" => filter.ids = map.next_value()?,
142                        "authors" => filter.authors = map.next_value()?,
143                        "kinds" => filter.kinds = map.next_value()?,
144                        "since" => filter.since = Some(map.next_value()?),
145                        "until" => filter.until = Some(map.next_value()?),
146                        "limit" => filter.limit = Some(map.next_value()?),
147                        _ if is_generic_tag_key(&key) => {
148                            filter.generic_tags.insert(key, map.next_value()?);
149                        }
150                        _ => return Err(serde::de::Error::custom("unknown filter field")),
151                    }
152                }
153                filter.validate().map_err(serde::de::Error::custom)
154            }
155        }
156
157        deserializer.deserialize_map(FilterVisitor)
158    }
159}
160
161/// A validated client-to-relay frame.
162#[derive(Clone, Debug, Eq, PartialEq)]
163pub enum ClientRelayFrame {
164    /// Publish one event.
165    Event(SignedNostrEvent),
166    /// Publish a non-empty collection through Softchat's extension.
167    Events(Vec<SignedNostrEvent>),
168    /// Open or replace one subscription.
169    Req {
170        /// Bounded subscription identifier.
171        subscription_id: String,
172        /// One or more strict filters.
173        filters: Vec<RelayFilter>,
174    },
175    /// Close one subscription.
176    Close {
177        /// Bounded subscription identifier.
178        subscription_id: String,
179    },
180    /// Request a count for one or more filters.
181    Count {
182        /// Bounded subscription identifier.
183        subscription_id: String,
184        /// One or more strict filters.
185        filters: Vec<RelayFilter>,
186    },
187    /// Submit one signed NIP-42 authentication event.
188    Auth(SignedNostrEvent),
189}
190
191impl ClientRelayFrame {
192    /// Serialize this frame as compact JSON.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`SoftchatError::InvalidRelayFrame`] if serialization exceeds bounds.
197    pub fn to_json(&self) -> Result<String, SoftchatError> {
198        let mut values = Vec::new();
199        match self {
200            Self::Event(event) => {
201                values.push(Value::String("EVENT".to_owned()));
202                values.push(event_value(event)?);
203            }
204            Self::Events(events) => {
205                validate_batch_events(events)?;
206                values.push(Value::String("EVENTS".to_owned()));
207                for event in events {
208                    values.push(event_value(event)?);
209                }
210            }
211            Self::Req {
212                subscription_id,
213                filters,
214            } => {
215                validate_subscription_id(subscription_id)?;
216                validate_filters(filters)?;
217                values.push(Value::String("REQ".to_owned()));
218                values.push(Value::String(subscription_id.clone()));
219                append_filter_values(&mut values, filters)?;
220            }
221            Self::Close { subscription_id } => {
222                validate_subscription_id(subscription_id)?;
223                values.push(Value::String("CLOSE".to_owned()));
224                values.push(Value::String(subscription_id.clone()));
225            }
226            Self::Count {
227                subscription_id,
228                filters,
229            } => {
230                validate_subscription_id(subscription_id)?;
231                validate_filters(filters)?;
232                values.push(Value::String("COUNT".to_owned()));
233                values.push(Value::String(subscription_id.clone()));
234                append_filter_values(&mut values, filters)?;
235            }
236            Self::Auth(event) => {
237                if event.kind() != NostrEventKind::CLIENT_AUTHENTICATION {
238                    return Err(SoftchatError::InvalidRelayAuthentication);
239                }
240                values.push(Value::String("AUTH".to_owned()));
241                values.push(event_value(event)?);
242            }
243        }
244        encode_frame(values)
245    }
246}
247
248/// A validated relay-to-client frame.
249#[derive(Clone, Debug, Eq, PartialEq)]
250pub enum RelayResponseFrame {
251    /// One event for a subscription.
252    Event {
253        /// Bounded subscription identifier.
254        subscription_id: String,
255        /// Verified signed event.
256        event: SignedNostrEvent,
257    },
258    /// Terminal result for one event publication.
259    Ok(BatchAcknowledgement),
260    /// Stored history for a subscription is complete.
261    Eose {
262        /// Bounded subscription identifier.
263        subscription_id: String,
264    },
265    /// Relay closed a subscription.
266    Closed {
267        /// Bounded subscription identifier.
268        subscription_id: String,
269        /// Bounded diagnostic text.
270        message: String,
271    },
272    /// Bounded relay notice.
273    Notice(String),
274    /// NIP-42 challenge.
275    Auth(String),
276    /// Count result.
277    Count {
278        /// Bounded subscription identifier.
279        subscription_id: String,
280        /// Exact non-negative count.
281        count: u64,
282    },
283}
284
285impl RelayResponseFrame {
286    /// Serialize this frame as compact JSON.
287    ///
288    /// # Errors
289    ///
290    /// Returns [`SoftchatError::InvalidRelayFrame`] for invalid bounds.
291    pub fn to_json(&self) -> Result<String, SoftchatError> {
292        let values = match self {
293            Self::Event {
294                subscription_id,
295                event,
296            } => {
297                validate_subscription_id(subscription_id)?;
298                vec![
299                    Value::String("EVENT".to_owned()),
300                    Value::String(subscription_id.clone()),
301                    event_value(event)?,
302                ]
303            }
304            Self::Ok(acknowledgement) => {
305                acknowledgement.validate()?;
306                vec![
307                    Value::String("OK".to_owned()),
308                    Value::String(acknowledgement.event_id.clone()),
309                    Value::Bool(acknowledgement.accepted),
310                    Value::String(acknowledgement.message.clone()),
311                ]
312            }
313            Self::Eose { subscription_id } => {
314                validate_subscription_id(subscription_id)?;
315                vec![
316                    Value::String("EOSE".to_owned()),
317                    Value::String(subscription_id.clone()),
318                ]
319            }
320            Self::Closed {
321                subscription_id,
322                message,
323            } => {
324                validate_subscription_id(subscription_id)?;
325                validate_relay_text(message)?;
326                vec![
327                    Value::String("CLOSED".to_owned()),
328                    Value::String(subscription_id.clone()),
329                    Value::String(message.clone()),
330                ]
331            }
332            Self::Notice(message) => {
333                validate_relay_text(message)?;
334                vec![
335                    Value::String("NOTICE".to_owned()),
336                    Value::String(message.clone()),
337                ]
338            }
339            Self::Auth(challenge) => {
340                validate_relay_text(challenge)?;
341                if challenge.is_empty() {
342                    return Err(SoftchatError::InvalidRelayAuthentication);
343                }
344                vec![
345                    Value::String("AUTH".to_owned()),
346                    Value::String(challenge.clone()),
347                ]
348            }
349            Self::Count {
350                subscription_id,
351                count,
352            } => {
353                validate_subscription_id(subscription_id)?;
354                vec![
355                    Value::String("COUNT".to_owned()),
356                    Value::String(subscription_id.clone()),
357                    serde_json::json!({ "count": count }),
358                ]
359            }
360        };
361        encode_frame(values)
362    }
363}
364
365/// One terminal relay result for a submitted event.
366#[derive(Clone, Debug, Eq, PartialEq)]
367#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
368pub struct BatchAcknowledgement {
369    /// Canonical event ID.
370    pub event_id: String,
371    /// Relay acceptance bit.
372    pub accepted: bool,
373    /// Bounded diagnostic text, never protocol identity.
374    pub message: String,
375}
376
377impl BatchAcknowledgement {
378    fn validate(&self) -> Result<(), SoftchatError> {
379        NostrEventId::from_hex(&self.event_id).map_err(|_| SoftchatError::InvalidRelayBatch)?;
380        validate_relay_text(&self.message).map_err(|_| SoftchatError::InvalidRelayBatch)
381    }
382}
383
384/// Terminal state for one event in a batch projection.
385#[derive(Clone, Copy, Debug, Eq, PartialEq)]
386#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
387pub enum BatchEventState {
388    /// No terminal `OK` was observed.
389    Pending,
390    /// A terminal successful `OK` was observed.
391    Accepted,
392    /// A terminal rejected `OK` was observed.
393    Rejected,
394}
395
396/// Deterministic per-event batch progress in original submission order.
397#[derive(Clone, Debug, Eq, PartialEq)]
398#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
399pub struct BatchProgress {
400    /// Submitted canonical event IDs.
401    pub event_ids: Vec<String>,
402    /// State at the same index as `event_ids`.
403    pub states: Vec<BatchEventState>,
404    /// Bounded terminal messages at the same index; pending entries are empty.
405    pub messages: Vec<String>,
406    /// Whether every submitted event is terminal.
407    pub complete: bool,
408}
409
410/// Parse one bounded client-to-relay JSON frame.
411///
412/// # Errors
413///
414/// Returns [`SoftchatError::InvalidRelayFrame`] for malformed commands, arity,
415/// nested event/filter validation, duplicate batch IDs, or size violations.
416pub fn parse_client_relay_frame(json: &str) -> Result<ClientRelayFrame, SoftchatError> {
417    let values = parse_frame_array(json)?;
418    let command = command(&values)?;
419    match command {
420        "EVENT" if values.len() == 2 => Ok(ClientRelayFrame::Event(parse_event(&values[1])?)),
421        "EVENTS" if values.len() >= 2 => {
422            let events = values[1..]
423                .iter()
424                .map(parse_event)
425                .collect::<Result<Vec<_>, _>>()?;
426            validate_batch_events(&events)?;
427            Ok(ClientRelayFrame::Events(events))
428        }
429        "REQ" | "COUNT" if values.len() >= 3 => {
430            let subscription_id = parse_subscription_id(&values[1])?;
431            let filters = values[2..]
432                .iter()
433                .map(parse_filter_value)
434                .collect::<Result<Vec<_>, _>>()?;
435            validate_filters(&filters)?;
436            if command == "REQ" {
437                Ok(ClientRelayFrame::Req {
438                    subscription_id,
439                    filters,
440                })
441            } else {
442                Ok(ClientRelayFrame::Count {
443                    subscription_id,
444                    filters,
445                })
446            }
447        }
448        "CLOSE" if values.len() == 2 => Ok(ClientRelayFrame::Close {
449            subscription_id: parse_subscription_id(&values[1])?,
450        }),
451        "AUTH" if values.len() == 2 => {
452            let event = parse_event(&values[1])?;
453            if event.kind() != NostrEventKind::CLIENT_AUTHENTICATION {
454                return Err(SoftchatError::InvalidRelayAuthentication);
455            }
456            Ok(ClientRelayFrame::Auth(event))
457        }
458        _ => Err(SoftchatError::InvalidRelayFrame),
459    }
460}
461
462/// Parse one bounded relay-to-client JSON frame.
463///
464/// # Errors
465///
466/// Returns [`SoftchatError::InvalidRelayFrame`] for malformed commands,
467/// invalid nested events, incorrect arity, or size violations.
468pub fn parse_relay_response_frame(json: &str) -> Result<RelayResponseFrame, SoftchatError> {
469    let values = parse_frame_array(json)?;
470    match command(&values)? {
471        "EVENT" if values.len() == 3 => Ok(RelayResponseFrame::Event {
472            subscription_id: parse_subscription_id(&values[1])?,
473            event: parse_event(&values[2])?,
474        }),
475        "OK" if values.len() == 4 => {
476            let acknowledgement = BatchAcknowledgement {
477                event_id: value_string(&values[1])?.to_owned(),
478                accepted: values[2]
479                    .as_bool()
480                    .ok_or(SoftchatError::InvalidRelayFrame)?,
481                message: value_string(&values[3])?.to_owned(),
482            };
483            acknowledgement
484                .validate()
485                .map_err(|_| SoftchatError::InvalidRelayFrame)?;
486            Ok(RelayResponseFrame::Ok(acknowledgement))
487        }
488        "EOSE" if values.len() == 2 => Ok(RelayResponseFrame::Eose {
489            subscription_id: parse_subscription_id(&values[1])?,
490        }),
491        "CLOSED" if values.len() == 3 => {
492            let message = value_string(&values[2])?.to_owned();
493            validate_relay_text(&message)?;
494            Ok(RelayResponseFrame::Closed {
495                subscription_id: parse_subscription_id(&values[1])?,
496                message,
497            })
498        }
499        "NOTICE" if values.len() == 2 => {
500            let message = value_string(&values[1])?.to_owned();
501            validate_relay_text(&message)?;
502            Ok(RelayResponseFrame::Notice(message))
503        }
504        "AUTH" if values.len() == 2 => {
505            let challenge = value_string(&values[1])?.to_owned();
506            validate_relay_text(&challenge)?;
507            if challenge.is_empty() {
508                return Err(SoftchatError::InvalidRelayAuthentication);
509            }
510            Ok(RelayResponseFrame::Auth(challenge))
511        }
512        "COUNT" if values.len() == 3 => {
513            let count = values[2]
514                .as_object()
515                .filter(|object| object.len() == 1)
516                .and_then(|object| object.get("count"))
517                .and_then(Value::as_u64)
518                .ok_or(SoftchatError::InvalidRelayFrame)?;
519            Ok(RelayResponseFrame::Count {
520                subscription_id: parse_subscription_id(&values[1])?,
521                count,
522            })
523        }
524        _ => Err(SoftchatError::InvalidRelayFrame),
525    }
526}
527
528/// Encode the deployed flat, non-empty `EVENTS` extension.
529///
530/// # Errors
531///
532/// Returns [`SoftchatError::InvalidRelayBatch`] for empty, oversized, or
533/// duplicate-ID batches.
534#[cfg_attr(feature = "native-bindings", uniffi::export)]
535pub fn encode_events_frame(events: Vec<SignedEvent>) -> Result<String, SoftchatError> {
536    let events = events
537        .into_iter()
538        .map(SignedNostrEvent::try_from)
539        .collect::<Result<Vec<_>, _>>()
540        .map_err(|_| SoftchatError::InvalidRelayBatch)?;
541    ClientRelayFrame::Events(events)
542        .to_json()
543        .map_err(|_| SoftchatError::InvalidRelayBatch)
544}
545
546/// Project unordered and repeated `OK` results onto one submitted batch.
547///
548/// Identical duplicate terminal results are idempotent. A conflicting
549/// duplicate, unknown event ID, duplicate submitted ID, or malformed value is
550/// a protocol error.
551///
552/// # Errors
553///
554/// Returns [`SoftchatError::InvalidRelayBatch`] for any ambiguous result set.
555#[cfg_attr(feature = "native-bindings", uniffi::export)]
556pub fn track_batch_acknowledgements(
557    event_ids: Vec<String>,
558    acknowledgements: Vec<BatchAcknowledgement>,
559) -> Result<BatchProgress, SoftchatError> {
560    if event_ids.is_empty() || event_ids.len() > MAX_RELAY_BATCH_EVENTS {
561        return Err(SoftchatError::InvalidRelayBatch);
562    }
563
564    let mut indexes = BTreeMap::new();
565    for (index, event_id) in event_ids.iter().enumerate() {
566        NostrEventId::from_hex(event_id).map_err(|_| SoftchatError::InvalidRelayBatch)?;
567        if indexes.insert(event_id.clone(), index).is_some() {
568            return Err(SoftchatError::InvalidRelayBatch);
569        }
570    }
571
572    let mut states = vec![BatchEventState::Pending; event_ids.len()];
573    let mut messages = vec![String::new(); event_ids.len()];
574    for acknowledgement in acknowledgements {
575        acknowledgement.validate()?;
576        let index = *indexes
577            .get(&acknowledgement.event_id)
578            .ok_or(SoftchatError::InvalidRelayBatch)?;
579        let state = if acknowledgement.accepted {
580            BatchEventState::Accepted
581        } else {
582            BatchEventState::Rejected
583        };
584        match states[index] {
585            BatchEventState::Pending => {
586                states[index] = state;
587                messages[index] = acknowledgement.message;
588            }
589            current if current == state && messages[index] == acknowledgement.message => {}
590            _ => return Err(SoftchatError::InvalidRelayBatch),
591        }
592    }
593
594    let complete = states
595        .iter()
596        .all(|state| *state != BatchEventState::Pending);
597    Ok(BatchProgress {
598        event_ids,
599        states,
600        messages,
601        complete,
602    })
603}
604
605/// Create a signed NIP-42 authentication event for one challenge and relay.
606///
607/// # Errors
608///
609/// Returns [`SoftchatError::InvalidRelayAuthentication`] for invalid context
610/// or a signing failure.
611pub fn plan_nip42_authentication(
612    identity: &LocalIdentity,
613    challenge: &str,
614    relay_url: &str,
615    created_at: u64,
616) -> Result<SignedNostrEvent, SoftchatError> {
617    validate_challenge(challenge)?;
618    validate_relay_url(relay_url)?;
619    let tags = vec![
620        NostrTag::new(vec!["relay", relay_url])
621            .map_err(|_| SoftchatError::InvalidRelayAuthentication)?,
622        NostrTag::new(vec!["challenge", challenge])
623            .map_err(|_| SoftchatError::InvalidRelayAuthentication)?,
624    ];
625    let draft = NostrEventDraft::new(created_at, NostrEventKind::CLIENT_AUTHENTICATION, tags, "")
626        .map_err(|_| SoftchatError::InvalidRelayAuthentication)?;
627    identity
628        .sign_event(draft)
629        .map_err(|_| SoftchatError::InvalidRelayAuthentication)
630}
631
632/// Validate a NIP-42 authentication event against its exact connection context.
633///
634/// # Errors
635///
636/// Returns [`SoftchatError::InvalidRelayAuthentication`] for wrong kind,
637/// content, challenge, relay, duplicated tags, or timestamp window.
638pub fn validate_nip42_authentication(
639    event: &SignedNostrEvent,
640    expected_author: &NostrPublicKey,
641    challenge: &str,
642    relay_url: &str,
643    now: u64,
644) -> Result<(), SoftchatError> {
645    validate_challenge(challenge)?;
646    validate_relay_url(relay_url)?;
647    if event.kind() != NostrEventKind::CLIENT_AUTHENTICATION
648        || event.public_key() != *expected_author
649        || !event.content().is_empty()
650        || event.created_at().abs_diff(now) > NIP42_AUTH_WINDOW_SECONDS
651    {
652        return Err(SoftchatError::InvalidRelayAuthentication);
653    }
654
655    let mut found_relay = false;
656    let mut found_challenge = false;
657    for tag in event.tags() {
658        match tag.first().map(String::as_str) {
659            Some("relay") if !found_relay && tag.len() == 2 && tag[1] == relay_url => {
660                found_relay = true;
661            }
662            Some("challenge") if !found_challenge && tag.len() == 2 && tag[1] == challenge => {
663                found_challenge = true;
664            }
665            Some("relay" | "challenge") => {
666                return Err(SoftchatError::InvalidRelayAuthentication);
667            }
668            _ => {}
669        }
670    }
671    if found_relay && found_challenge {
672        Ok(())
673    } else {
674        Err(SoftchatError::InvalidRelayAuthentication)
675    }
676}
677
678fn parse_frame_array(json: &str) -> Result<Vec<Value>, SoftchatError> {
679    if json.is_empty() || json.len() > MAX_RELAY_FRAME_BYTES {
680        return Err(SoftchatError::InvalidRelayFrame);
681    }
682    let value: Value = serde_json::from_str(json).map_err(|_| SoftchatError::InvalidRelayFrame)?;
683    value
684        .as_array()
685        .filter(|values| !values.is_empty())
686        .cloned()
687        .ok_or(SoftchatError::InvalidRelayFrame)
688}
689
690fn command(values: &[Value]) -> Result<&str, SoftchatError> {
691    values
692        .first()
693        .and_then(Value::as_str)
694        .ok_or(SoftchatError::InvalidRelayFrame)
695}
696
697fn value_string(value: &Value) -> Result<&str, SoftchatError> {
698    value.as_str().ok_or(SoftchatError::InvalidRelayFrame)
699}
700
701fn parse_event(value: &Value) -> Result<SignedNostrEvent, SoftchatError> {
702    let json = serde_json::to_string(value).map_err(|_| SoftchatError::InvalidRelayFrame)?;
703    SignedNostrEvent::from_json(&json).map_err(|_| SoftchatError::InvalidRelayFrame)
704}
705
706fn event_value(event: &SignedNostrEvent) -> Result<Value, SoftchatError> {
707    serde_json::from_str(&event.to_json()?).map_err(|_| SoftchatError::InvalidRelayFrame)
708}
709
710fn parse_filter_value(value: &Value) -> Result<RelayFilter, SoftchatError> {
711    RelayFilter::from_json(
712        &serde_json::to_string(value).map_err(|_| SoftchatError::InvalidRelayFilter)?,
713    )
714}
715
716fn append_filter_values(
717    values: &mut Vec<Value>,
718    filters: &[RelayFilter],
719) -> Result<(), SoftchatError> {
720    for filter in filters {
721        values.push(
722            serde_json::to_value(filter.clone().validate()?)
723                .map_err(|_| SoftchatError::InvalidRelayFilter)?,
724        );
725    }
726    Ok(())
727}
728
729fn encode_frame(values: Vec<Value>) -> Result<String, SoftchatError> {
730    let json = serde_json::to_string(&values).map_err(|_| SoftchatError::InvalidRelayFrame)?;
731    if json.len() > MAX_RELAY_FRAME_BYTES {
732        return Err(SoftchatError::InvalidRelayFrame);
733    }
734    Ok(json)
735}
736
737fn parse_subscription_id(value: &Value) -> Result<String, SoftchatError> {
738    let subscription_id = value_string(value)?.to_owned();
739    validate_subscription_id(&subscription_id)?;
740    Ok(subscription_id)
741}
742
743fn validate_subscription_id(subscription_id: &str) -> Result<(), SoftchatError> {
744    let length = subscription_id.chars().count();
745    if length == 0 || length > MAX_SUBSCRIPTION_ID_CHARS {
746        return Err(SoftchatError::InvalidRelayFrame);
747    }
748    Ok(())
749}
750
751fn validate_relay_text(value: &str) -> Result<(), SoftchatError> {
752    if value.chars().count() > MAX_RELAY_MESSAGE_CHARS {
753        return Err(SoftchatError::InvalidRelayFrame);
754    }
755    Ok(())
756}
757
758fn validate_challenge(challenge: &str) -> Result<(), SoftchatError> {
759    validate_relay_text(challenge).map_err(|_| SoftchatError::InvalidRelayAuthentication)?;
760    if challenge.is_empty() {
761        return Err(SoftchatError::InvalidRelayAuthentication);
762    }
763    Ok(())
764}
765
766fn validate_relay_url(relay_url: &str) -> Result<(), SoftchatError> {
767    let url = Url::parse(relay_url).map_err(|_| SoftchatError::InvalidRelayAuthentication)?;
768    if !matches!(url.scheme(), "ws" | "wss") || url.host_str().is_none() || url.fragment().is_some()
769    {
770        return Err(SoftchatError::InvalidRelayAuthentication);
771    }
772    Ok(())
773}
774
775fn validate_prefixes(values: &[String]) -> Result<(), SoftchatError> {
776    validate_value_count(values.len())?;
777    for value in values {
778        if value.is_empty()
779            || value.len() > 64
780            || !value
781                .bytes()
782                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
783        {
784            return Err(SoftchatError::InvalidRelayFilter);
785        }
786    }
787    Ok(())
788}
789
790fn validate_value_count(count: usize) -> Result<(), SoftchatError> {
791    if count > MAX_RELAY_FILTER_VALUES {
792        return Err(SoftchatError::InvalidRelayFilter);
793    }
794    Ok(())
795}
796
797fn is_generic_tag_key(key: &str) -> bool {
798    let bytes = key.as_bytes();
799    bytes.len() == 2 && bytes[0] == b'#' && bytes[1].is_ascii_alphabetic()
800}
801
802fn validate_filters(filters: &[RelayFilter]) -> Result<(), SoftchatError> {
803    if filters.is_empty() || filters.len() > MAX_RELAY_FILTERS {
804        return Err(SoftchatError::InvalidRelayFrame);
805    }
806    for filter in filters {
807        filter
808            .clone()
809            .validate()
810            .map_err(|_| SoftchatError::InvalidRelayFrame)?;
811    }
812    Ok(())
813}
814
815fn validate_batch_events(events: &[SignedNostrEvent]) -> Result<(), SoftchatError> {
816    if events.is_empty() || events.len() > MAX_RELAY_BATCH_EVENTS {
817        return Err(SoftchatError::InvalidRelayBatch);
818    }
819    let mut ids = BTreeSet::new();
820    if events.iter().any(|event| !ids.insert(event.id())) {
821        return Err(SoftchatError::InvalidRelayBatch);
822    }
823    Ok(())
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829
830    const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
831
832    fn event(kind: NostrEventKind, created_at: u64) -> Result<SignedNostrEvent, SoftchatError> {
833        let identity = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
834        identity.sign_event(NostrEventDraft::new(created_at, kind, Vec::new(), "test")?)
835    }
836
837    #[test]
838    fn filters_round_trip_generic_tags_and_reject_duplicate_fields() -> Result<(), SoftchatError> {
839        let filter = RelayFilter::from_json(
840            r##"{"ids":["ab"],"authors":["cd"],"kinds":[14],"since":1,"until":2,"limit":3,"#p":["ef"]}"##,
841        )?;
842        assert_eq!(RelayFilter::from_json(&filter.to_json()?)?, filter);
843        assert!(RelayFilter::from_json(r#"{"limit":1,"limit":2}"#).is_err());
844        assert!(RelayFilter::from_json(r#"{"unknown":[]}"#).is_err());
845        assert!(RelayFilter::from_json(r#"{"since":2,"until":1}"#).is_err());
846        Ok(())
847    }
848
849    #[test]
850    fn client_and_relay_frames_round_trip_and_reject_trailing_items() -> Result<(), SoftchatError> {
851        let authored = event(NostrEventKind::SHORT_TEXT_NOTE, 1)?;
852        for frame in [
853            ClientRelayFrame::Event(authored.clone()),
854            ClientRelayFrame::Req {
855                subscription_id: "sub".to_owned(),
856                filters: vec![RelayFilter::default()],
857            },
858            ClientRelayFrame::Close {
859                subscription_id: "sub".to_owned(),
860            },
861            ClientRelayFrame::Count {
862                subscription_id: "sub".to_owned(),
863                filters: vec![RelayFilter::default()],
864            },
865        ] {
866            let encoded = frame.to_json()?;
867            assert_eq!(parse_client_relay_frame(&encoded)?, frame);
868        }
869
870        let response = RelayResponseFrame::Event {
871            subscription_id: "sub".to_owned(),
872            event: authored,
873        };
874        assert_eq!(parse_relay_response_frame(&response.to_json()?)?, response);
875        assert!(parse_relay_response_frame(r#"["EOSE","sub","trailing"]"#).is_err());
876        assert!(parse_client_relay_frame(r#"["UNKNOWN"]"#).is_err());
877        Ok(())
878    }
879
880    #[test]
881    fn events_batch_is_flat_non_empty_unique_and_bounded() -> Result<(), SoftchatError> {
882        let first = event(NostrEventKind::SHORT_TEXT_NOTE, 1)?;
883        let second = event(NostrEventKind::SHORT_TEXT_NOTE, 2)?;
884        let frame = ClientRelayFrame::Events(vec![first.clone(), second.clone()]);
885        let json = frame.to_json()?;
886        assert_eq!(parse_client_relay_frame(&json)?, frame);
887        assert!(json.starts_with("[\"EVENTS\",{"));
888        assert!(ClientRelayFrame::Events(Vec::new()).to_json().is_err());
889        assert!(
890            ClientRelayFrame::Events(vec![first.clone(), first])
891                .to_json()
892                .is_err()
893        );
894        Ok(())
895    }
896
897    #[test]
898    fn batch_tracker_is_per_event_idempotent_and_conflict_strict() -> Result<(), SoftchatError> {
899        let first = event(NostrEventKind::SHORT_TEXT_NOTE, 1)?.id().to_hex();
900        let second = event(NostrEventKind::SHORT_TEXT_NOTE, 2)?.id().to_hex();
901        let accepted = BatchAcknowledgement {
902            event_id: first.clone(),
903            accepted: true,
904            message: String::new(),
905        };
906        let rejected = BatchAcknowledgement {
907            event_id: second.clone(),
908            accepted: false,
909            message: "blocked".to_owned(),
910        };
911        let progress = track_batch_acknowledgements(
912            vec![first.clone(), second.clone()],
913            vec![rejected.clone(), accepted.clone(), accepted.clone()],
914        )?;
915        assert_eq!(
916            progress.states,
917            vec![BatchEventState::Accepted, BatchEventState::Rejected]
918        );
919        assert!(progress.complete);
920
921        let conflict = BatchAcknowledgement {
922            accepted: false,
923            ..accepted
924        };
925        assert!(
926            track_batch_acknowledgements(vec![first, second], vec![conflict, rejected]).is_ok()
927        );
928        let event_id = progress.event_ids[0].clone();
929        assert!(
930            track_batch_acknowledgements(
931                progress.event_ids,
932                vec![
933                    BatchAcknowledgement {
934                        event_id: event_id.clone(),
935                        accepted: true,
936                        message: String::new(),
937                    },
938                    BatchAcknowledgement {
939                        event_id,
940                        accepted: false,
941                        message: String::new(),
942                    },
943                ],
944            )
945            .is_err()
946        );
947        Ok(())
948    }
949
950    #[test]
951    fn nip42_plan_binds_challenge_relay_author_and_time() -> Result<(), SoftchatError> {
952        let identity = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
953        let event =
954            plan_nip42_authentication(&identity, "challenge", "wss://relay.example", 1_000)?;
955        validate_nip42_authentication(
956            &event,
957            &identity.public_key(),
958            "challenge",
959            "wss://relay.example",
960            1_500,
961        )?;
962        assert!(
963            validate_nip42_authentication(
964                &event,
965                &identity.public_key(),
966                "wrong",
967                "wss://relay.example",
968                1_500,
969            )
970            .is_err()
971        );
972        assert!(
973            validate_nip42_authentication(
974                &event,
975                &identity.public_key(),
976                "challenge",
977                "wss://relay.example",
978                1_601,
979            )
980            .is_err()
981        );
982        Ok(())
983    }
984}