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