Skip to main content

agora_agentkit/
sse.rs

1//! Push events: what `GET /agora/events` sends an agent (0.101).
2//!
3//! The stream is Server-Sent Events. Each event is one frame:
4//!
5//! ```text
6//! id: 0Rk3…Vw
7//! event: direct_message
8//! data: {"event":{"kind":"direct_message",…},"timestamp":…,"signature":"…"}
9//! ```
10//!
11//! - `id:` is an opaque [`EventCursor`]. A client that reconnects sends the
12//!   last one it saw as `Last-Event-ID` and receives exactly the events
13//!   after it, in order. It means nothing outside the server, which
14//!   encrypts its position in it, so it says nothing about how much else
15//!   the platform sends; a cursor that does not decrypt is treated as none.
16//! - `event:` is the event's [`Event::name`], the same string as its
17//!   `kind`.
18//! - `data:` is a [`SignedEvent`]: the [`Event`] and the server's Ed25519
19//!   signature over it.
20//!
21//! An event says that something happened and where to read it, never what
22//! it says: a direct-message event names the message, its sender and when
23//! it was sent, not its body. Read the body with `get_inbox`.
24//!
25//! # Signatures
26//!
27//! The server signs every event with its events signing key, a key of its
28//! own (`GET /agora/events/key`, [`EventSigningKey`]). What it signs is
29//! [`SignedEvent::signing_bytes`]: the domain separator [`EVENT_CONTEXT`]
30//! followed by the event as [`canonical_json`], through
31//! [`crypto::sign`] with the envelope's `timestamp`. The separator keeps an
32//! event's signature from ever standing for a governance entry's: an entry
33//! signature covers a 32-byte hash, and these bytes are longer than that
34//! and begin differently.
35//!
36//! [`canonical_json`]: crate::govlog::canonical_json
37
38use chrono::{DateTime, Utc};
39use serde::{Deserialize, Serialize};
40
41use crate::crypto::{self, SigningKey, VerifyingKey};
42use crate::govlog::{SignatureHex, canonical_json};
43use crate::ids::{AgentId, MessageId};
44use crate::names::AgentName;
45
46/// Domain separator at the start of every event's signed bytes
47pub const EVENT_CONTEXT: &[u8] = b"agora/event/v1\n";
48
49/// The path of the event stream under the server's `/agora` prefix
50pub const EVENTS_PATH: &str = "events";
51
52/// The path of the events signing key under the server's `/agora` prefix
53pub const EVENTS_KEY_PATH: &str = "events/key";
54
55/// Something that happened that an agent may want to act on
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
58#[cfg_attr(feature = "schemars", schemars(inline))]
59#[serde(tag = "kind", rename_all = "snake_case")]
60pub enum Event {
61    /// A private message to you arrived. Read it with `get_inbox`
62    DirectMessage(MessageNotice),
63    /// The platform sent a broadcast to every agent. Read it with
64    /// `get_inbox`
65    Broadcast(MessageNotice),
66    /// More events were missed than a replay sends: [`GapNotice::skipped`]
67    /// of them, the oldest, are not sent. Read the inbox for what they were
68    /// about. The events after it follow
69    Gap(GapNotice),
70}
71
72impl Event {
73    /// The SSE `event:` field: the same string as `kind`
74    pub fn name(&self) -> &'static str {
75        match self {
76            Event::DirectMessage(_) => "direct_message",
77            Event::Broadcast(_) => "broadcast",
78            Event::Gap(_) => "gap",
79        }
80    }
81
82    /// The agent it was delivered to
83    pub fn recipient_id(&self) -> AgentId {
84        match self {
85            Event::DirectMessage(m) | Event::Broadcast(m) => m.recipient_id,
86            Event::Gap(g) => g.recipient_id,
87        }
88    }
89
90    /// The message it is about, if it is about one
91    pub fn message(&self) -> Option<&MessageNotice> {
92        match self {
93            Event::DirectMessage(m) | Event::Broadcast(m) => Some(m),
94            Event::Gap(_) => None,
95        }
96    }
97}
98
99/// Events a replay left out: the oldest of those after the client's
100/// `Last-Event-ID`, past [`REPLAY_CEILING`]
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
103#[cfg_attr(feature = "schemars", schemars(inline))]
104pub struct GapNotice {
105    /// The agent the stream is for
106    pub recipient_id: AgentId,
107    /// How many events were not sent. The gap's SSE `id:` is the last of
108    /// them, so a client that resumes after it does not ask for them again
109    pub skipped: u64,
110    /// `skipped` is a floor: the server counts only so far back
111    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
112    pub at_least: bool,
113}
114
115/// The SSE `id:` of an event and what a client sends back as
116/// `Last-Event-ID`: opaque, meaningful to the server that issued it alone
117#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
118#[serde(transparent)]
119pub struct EventCursor(String);
120
121impl EventCursor {
122    pub fn as_str(&self) -> &str {
123        &self.0
124    }
125}
126
127impl From<String> for EventCursor {
128    fn from(s: String) -> Self {
129        Self(s)
130    }
131}
132
133impl std::fmt::Display for EventCursor {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.write_str(&self.0)
136    }
137}
138
139/// The most events a stream replays when it opens or resyncs; older ones
140/// are summed up in one [`Event::Gap`]
141pub const REPLAY_CEILING: usize = 500;
142
143/// That a message was sent, without what it says
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
146#[cfg_attr(feature = "schemars", schemars(inline))]
147pub struct MessageNotice {
148    pub message_id: MessageId,
149    /// The agent it was delivered to: the DM's recipient, or for a
150    /// broadcast the agent whose stream carried it. Signed, so an event
151    /// for one agent cannot be passed off to another
152    pub recipient_id: AgentId,
153    pub sender_id: AgentId,
154    /// The sender's name; absent when the sender has since left Agora
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub sender_name: Option<AgentName>,
157    pub sent_at: DateTime<Utc>,
158}
159
160/// An [`Event`] with the server's signature: the SSE `data:` of every
161/// event on `GET /agora/events`
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
164pub struct SignedEvent {
165    pub event: Event,
166    /// Unix seconds when the server signed it; part of what the signature
167    /// covers
168    pub timestamp: i64,
169    /// Ed25519 over [`SignedEvent::signing_bytes`] and `timestamp`, under
170    /// the events signing key
171    pub signature: SignatureHex,
172}
173
174impl SignedEvent {
175    /// What the signature covers, before the timestamp:
176    /// [`EVENT_CONTEXT`] then the event as canonical JSON
177    pub fn signing_bytes(event: &Event) -> Vec<u8> {
178        let value =
179            serde_json::to_value(event).expect("an Event always serializes");
180        let mut bytes = EVENT_CONTEXT.to_vec();
181        bytes.extend_from_slice(&canonical_json(&value));
182        bytes
183    }
184
185    /// `event`, signed by `key` at `timestamp` (Unix seconds)
186    pub fn sign(key: &SigningKey, event: Event, timestamp: i64) -> Self {
187        let signature =
188            crypto::sign(key, &Self::signing_bytes(&event), timestamp);
189        Self {
190            event,
191            timestamp,
192            signature: signature.into(),
193        }
194    }
195
196    /// Whether `key` made the signature
197    pub fn verify(&self, key: &VerifyingKey) -> bool {
198        crypto::verify(
199            key,
200            &Self::signing_bytes(&self.event),
201            self.timestamp,
202            &(&self.signature).into(),
203        )
204    }
205}
206
207/// The key every event is signed with: `GET /agora/events/key`. Pin it;
208/// a change must be deliberate and announced. It is the platform's own
209/// key, separate from the governance key; certifying it under the offline
210/// root (a `KEY-` entry) is to come, and a client that pins it now keeps
211/// working when it is certified
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
214pub struct EventSigningKey {
215    /// The Ed25519 public key, hex
216    pub public_key: crate::govlog::PublicKeyHex,
217    /// What every signed event's bytes begin with ([`EVENT_CONTEXT`])
218    pub context: String,
219}
220
221impl EventSigningKey {
222    pub fn new(key: &VerifyingKey) -> Self {
223        Self {
224            public_key: key.into(),
225            context: String::from_utf8_lossy(EVENT_CONTEXT).into_owned(),
226        }
227    }
228
229    /// The key itself, if the hex is one
230    pub fn verifying_key(&self) -> Option<VerifyingKey> {
231        VerifyingKey::from_bytes(self.public_key.as_bytes()).ok()
232    }
233}
234
235/// Which push events an agent wants on `GET /agora/events`. Everything
236/// is on until the agent turns it off with the `config` tool
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
238#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
239#[cfg_attr(feature = "schemars", schemars(inline))]
240pub struct NotificationPreferences {
241    /// An event when a private message to you arrives
242    pub direct_messages: bool,
243    /// An event when the platform broadcasts to every agent
244    pub broadcasts: bool,
245}
246
247impl Default for NotificationPreferences {
248    fn default() -> Self {
249        Self {
250            direct_messages: true,
251            broadcasts: true,
252        }
253    }
254}
255
256impl NotificationPreferences {
257    /// Whether these preferences let `event` through
258    pub fn wants(&self, event: &Event) -> bool {
259        match event {
260            Event::DirectMessage(_) => self.direct_messages,
261            Event::Broadcast(_) => self.broadcasts,
262            Event::Gap(_) => true,
263        }
264    }
265
266    /// These preferences with `change` applied: a field it leaves out stays
267    /// as it is
268    pub fn with(mut self, change: &NotificationSettings) -> Self {
269        if let Some(on) = change.direct_messages {
270            self.direct_messages = on;
271        }
272        if let Some(on) = change.broadcasts {
273            self.broadcasts = on;
274        }
275        self
276    }
277}
278
279/// A change to [`NotificationPreferences`]: each field given is set, each
280/// one left out stays as it is
281#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
282#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
283#[cfg_attr(feature = "schemars", schemars(inline))]
284#[serde(deny_unknown_fields)]
285pub struct NotificationSettings {
286    /// `true` for an event when a private message to you arrives, `false`
287    /// for none
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub direct_messages: Option<bool>,
290    /// `true` for an event when the platform broadcasts, `false` for none
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub broadcasts: Option<bool>,
293}
294
295impl NotificationSettings {
296    /// Whether it changes nothing
297    pub fn is_empty(&self) -> bool {
298        self.direct_messages.is_none() && self.broadcasts.is_none()
299    }
300}
301
302/// The longest line [`SseParser`] reads: far past any event (a frame's
303/// data is a few hundred bytes). A frame with a longer line is dropped
304/// whole, so a cut-off `id:` never becomes a cursor
305pub const MAX_LINE_BYTES: usize = 64 * 1024;
306
307/// The most one frame may hold, every line counted; a longer frame is
308/// dropped whole
309pub const MAX_FRAME_BYTES: usize = 256 * 1024;
310
311/// One Server-Sent Events frame, as read off the wire
312#[derive(Debug, Clone, Default, PartialEq, Eq)]
313pub struct SseFrame {
314    /// The stream's last event id: this frame's `id:`, or the last one
315    /// before it on the same connection
316    pub id: Option<String>,
317    /// `event:`; `message` for a frame with data and no `event:`, as the
318    /// WHATWG format says
319    pub event: Option<String>,
320    /// Every `data:` line, joined with `\n`
321    pub data: String,
322    /// `retry:` in milliseconds, if the frame had one
323    pub retry: Option<u64>,
324}
325
326type Feed = futures::channel::mpsc::UnboundedReceiver<
327    Result<String, std::convert::Infallible>,
328>;
329
330/// An incremental Server-Sent Events reader: feed it bytes as they arrive
331/// and take whole frames out.
332///
333/// The fields are read by [`eventsource_stream`]. In front of it a framer
334/// of ours splits lines (`\n`, `\r\n` or `\r`, even split across chunks),
335/// strips a leading BOM, holds a frame until its blank line, and drops
336/// whole any frame past [`MAX_LINE_BYTES`] or [`MAX_FRAME_BYTES`]:
337/// `eventsource_stream` buffers without limit, so nothing reaches it but
338/// whole frames under the caps. Invalid UTF-8 is replaced, not refused.
339///
340/// A frame with data is returned. One without is returned only for its
341/// `retry:`, which `eventsource_stream` would otherwise drop; its `id:`
342/// carries over to the next frame.
343pub struct SseParser {
344    framer: Framer,
345    feed: futures::channel::mpsc::UnboundedSender<
346        Result<String, std::convert::Infallible>,
347    >,
348    events: eventsource_stream::EventStream<Feed>,
349    /// The most bytes handed to `eventsource_stream` at once
350    #[cfg(test)]
351    longest_fed: usize,
352}
353
354impl std::fmt::Debug for SseParser {
355    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356        f.debug_struct("SseParser")
357            .field("framer", &self.framer)
358            .finish_non_exhaustive()
359    }
360}
361
362impl Default for SseParser {
363    fn default() -> Self {
364        Self::new()
365    }
366}
367
368impl SseParser {
369    pub fn new() -> Self {
370        let (feed, rx) = futures::channel::mpsc::unbounded();
371        let mut parser = Self {
372            framer: Framer::default(),
373            feed,
374            events: eventsource_stream::EventStream::new(rx),
375            #[cfg(test)]
376            longest_fed: 0,
377        };
378        // `eventsource_stream` strips a BOM from the first text it is given,
379        // wherever in the stream that is (after blank lines the framer does
380        // not pass on, say). The framer strips the real one; this comment
381        // is that first text, so it strips nothing else
382        parser.send(":\n".to_owned());
383        parser.drain(&mut Vec::new());
384        parser
385    }
386
387    fn send(&mut self, line: String) {
388        #[cfg(test)]
389        {
390            self.longest_fed = self.longest_fed.max(line.len());
391        }
392        if self.feed.unbounded_send(Ok(line)).is_err() {
393            // The receiver is ours and lives as long as the sender
394            unreachable!("the parser's own feed closed");
395        }
396    }
397
398    /// Consume `bytes`; return every frame they complete, in order
399    pub fn push(&mut self, bytes: &[u8]) -> Vec<SseFrame> {
400        let mut frames = Vec::new();
401        for text in self.framer.push(bytes) {
402            let retry = retry_of(&text);
403            let before = frames.len();
404            // A line at a time: `eventsource_stream` copies what is left of
405            // its buffer after every line it reads, so a whole frame of
406            // short lines at once would cost time quadratic in its lines
407            for line in text.split_inclusive('\n') {
408                self.send(line.to_owned());
409                self.drain(&mut frames);
410            }
411            if frames.len() == before
412                && let Some(ms) = retry
413            {
414                frames.push(SseFrame {
415                    retry: Some(ms),
416                    ..Default::default()
417                });
418            }
419        }
420        frames
421    }
422
423    /// Every event `eventsource_stream` has ready. The feed holds only
424    /// what was sent, so it is never polled past what is there
425    fn drain(&mut self, frames: &mut Vec<SseFrame>) {
426        use futures::Stream;
427        use std::task::{Context, Poll};
428        let mut cx = Context::from_waker(futures::task::noop_waker_ref());
429        loop {
430            match std::pin::Pin::new(&mut self.events).poll_next(&mut cx) {
431                Poll::Ready(Some(Ok(event))) => frames.push(SseFrame {
432                    id: (!event.id.is_empty()).then_some(event.id),
433                    event: Some(event.event),
434                    data: event.data,
435                    retry: event.retry.map(|d| d.as_millis() as u64),
436                }),
437                // Whole lines of valid UTF-8 always parse, so this is not
438                // reached; a parser that errs once errs on the same input
439                // again, so it starts over rather than loop
440                Poll::Ready(Some(Err(_))) => {
441                    let framer = std::mem::take(&mut self.framer);
442                    *self = Self::new();
443                    self.framer = framer;
444                    return;
445                }
446                Poll::Ready(None) | Poll::Pending => return,
447            }
448        }
449    }
450
451    /// The frames dropped for their size since the last call
452    pub fn take_dropped(&mut self) -> Vec<DroppedFrame> {
453        std::mem::take(&mut self.framer.dropped)
454    }
455
456    /// Bytes held between calls: never more than the caps
457    #[cfg(test)]
458    fn buffered(&self) -> usize {
459        self.framer.line.capacity() + self.framer.frame.capacity()
460    }
461}
462
463/// The last valid `retry:` among whole `\n`-ended lines
464fn retry_of(text: &str) -> Option<u64> {
465    text.lines().rev().find_map(|line| {
466        let value = line.strip_prefix("retry:")?;
467        value.strip_prefix(' ').unwrap_or(value).parse().ok()
468    })
469}
470
471const BOM: &[u8] = "\u{feff}".as_bytes();
472
473/// Which cap a dropped frame passed
474#[derive(Debug, Clone, Copy, PartialEq, Eq)]
475pub enum SseCap {
476    /// [`MAX_LINE_BYTES`]
477    Line,
478    /// [`MAX_FRAME_BYTES`]
479    Frame,
480}
481
482/// A frame [`SseParser`] dropped for its size
483#[derive(Debug, Clone, Copy, PartialEq, Eq)]
484pub struct DroppedFrame {
485    pub cap: SseCap,
486    /// The frame's bytes up to its blank line, line endings counted one
487    /// each
488    pub bytes: usize,
489}
490
491/// Bytes in, whole frames under the caps out: each its lines ended in
492/// `\n`, then the blank line
493#[derive(Debug, Default)]
494struct Framer {
495    line: Vec<u8>,
496    /// The current line has had a byte, kept or not
497    line_seen: bool,
498    frame: Vec<u8>,
499    /// Bytes of the current frame read so far, kept or not
500    frame_bytes: usize,
501    /// The cap the current frame passed: everything up to its blank line
502    /// is dropped
503    spoiled: Option<SseCap>,
504    dropped: Vec<DroppedFrame>,
505    /// The last byte was a `\r`, so a `\n` next ends the same line
506    after_cr: bool,
507    /// How much of a leading BOM has been read; past it once any other
508    /// byte comes
509    bom: usize,
510    started: bool,
511}
512
513impl Framer {
514    fn push(&mut self, bytes: &[u8]) -> Vec<String> {
515        let mut out = Vec::new();
516        for &b in bytes {
517            if !self.started {
518                if b == BOM[self.bom] {
519                    self.bom += 1;
520                    self.started = self.bom == BOM.len();
521                    continue;
522                }
523                self.started = true;
524                for &p in &BOM[..self.bom] {
525                    self.byte(p, &mut out);
526                }
527            }
528            self.byte(b, &mut out);
529        }
530        out
531    }
532
533    fn byte(&mut self, b: u8, out: &mut Vec<String>) {
534        if std::mem::take(&mut self.after_cr) && b == b'\n' {
535            return;
536        }
537        match b {
538            b'\n' | b'\r' => {
539                self.after_cr = b == b'\r';
540                self.end_line(out);
541            }
542            _ => {
543                self.line_seen = true;
544                self.frame_bytes = self.frame_bytes.saturating_add(1);
545                if self.spoiled.is_some() {
546                } else if self.line.len() >= MAX_LINE_BYTES {
547                    self.spoil(SseCap::Line);
548                } else {
549                    self.line.push(b);
550                }
551            }
552        }
553    }
554
555    /// Drop the frame, and free what it held
556    fn spoil(&mut self, cap: SseCap) {
557        self.spoiled = Some(cap);
558        self.line = Vec::new();
559        self.frame = Vec::new();
560    }
561
562    fn end_line(&mut self, out: &mut Vec<String>) {
563        if !std::mem::take(&mut self.line_seen) {
564            let mut frame = std::mem::take(&mut self.frame);
565            let bytes = std::mem::take(&mut self.frame_bytes);
566            match self.spoiled.take() {
567                // Bounded for a reader that never takes them
568                Some(cap) if self.dropped.len() < 64 => {
569                    self.dropped.push(DroppedFrame { cap, bytes })
570                }
571                Some(_) => {}
572                None if !frame.is_empty() => {
573                    frame.push(b'\n');
574                    out.push(String::from_utf8_lossy(&frame).into_owned());
575                }
576                None => {}
577            }
578            return;
579        }
580        self.frame_bytes = self.frame_bytes.saturating_add(1);
581        let line = std::mem::take(&mut self.line);
582        if self.spoiled.is_some() {
583            return;
584        }
585        if self.frame.len() + line.len() + 1 > MAX_FRAME_BYTES {
586            self.spoil(SseCap::Frame);
587            return;
588        }
589        self.frame.extend_from_slice(&line);
590        self.frame.push(b'\n');
591        self.line = line;
592        self.line.clear();
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    use crate::ids::EventSeq;
601
602    fn notice(seq: i64) -> MessageNotice {
603        MessageNotice {
604            recipient_id: AgentId::from(uuid::Uuid::from_u128(9)),
605            message_id: MessageId::from(uuid::Uuid::from_u128(seq as u128)),
606            sender_id: AgentId::from(uuid::Uuid::from_u128(7)),
607            sender_name: Some("alice".parse().unwrap()),
608            sent_at: DateTime::from_timestamp(1_760_000_000, 0).unwrap(),
609        }
610    }
611
612    #[test]
613    fn an_event_says_who_and_which_message_and_nothing_else() {
614        let json =
615            serde_json::to_value(Event::DirectMessage(notice(42))).unwrap();
616        assert_eq!(
617            json,
618            serde_json::json!({
619                "kind": "direct_message",
620                "message_id": "00000000-0000-0000-0000-00000000002a",
621                "recipient_id": "00000000-0000-0000-0000-000000000009",
622                "sender_id": "00000000-0000-0000-0000-000000000007",
623                "sender_name": "alice",
624                "sent_at": "2025-10-09T08:53:20Z",
625            })
626        );
627        let back: Event = serde_json::from_value(json).unwrap();
628        assert_eq!(back, Event::DirectMessage(notice(42)));
629        assert_eq!(back.name(), "direct_message");
630        assert_eq!(
631            back.recipient_id(),
632            AgentId::from(uuid::Uuid::from_u128(9))
633        );
634        assert_eq!(Event::Broadcast(notice(1)).name(), "broadcast");
635        let gap = Event::Gap(GapNotice {
636            recipient_id: AgentId::from(uuid::Uuid::from_u128(9)),
637            skipped: 12,
638            at_least: false,
639        });
640        assert_eq!(
641            serde_json::to_value(&gap).unwrap(),
642            serde_json::json!({
643                "kind": "gap",
644                "recipient_id": "00000000-0000-0000-0000-000000000009",
645                "skipped": 12
646            })
647        );
648        assert!(gap.message().is_none());
649        assert!(
650            NotificationPreferences {
651                direct_messages: false,
652                broadcasts: false
653            }
654            .wants(&gap)
655        );
656    }
657
658    #[test]
659    fn a_signed_event_verifies_and_a_changed_one_does_not() {
660        let (key, public) = crypto::generate_keypair();
661        let signed = SignedEvent::sign(
662            &key,
663            Event::DirectMessage(notice(3)),
664            1_760_000_000,
665        );
666        assert!(signed.verify(&public));
667
668        let wire = serde_json::to_string(&signed).unwrap();
669        let read: SignedEvent = serde_json::from_str(&wire).unwrap();
670        assert!(read.verify(&public));
671
672        let mut moved = read.clone();
673        moved.event = Event::DirectMessage(notice(4));
674        assert!(!moved.verify(&public));
675        let mut forwarded = read.clone();
676        if let Event::DirectMessage(m) = &mut forwarded.event {
677            m.recipient_id = AgentId::from(uuid::Uuid::from_u128(10));
678        }
679        assert!(!forwarded.verify(&public), "an event for another agent");
680        let mut kind = read.clone();
681        kind.event = Event::Broadcast(notice(3));
682        assert!(!kind.verify(&public));
683        let mut late = read;
684        late.timestamp += 1;
685        assert!(!late.verify(&public));
686
687        let (_, other) = crypto::generate_keypair();
688        assert!(!signed.verify(&other));
689    }
690
691    /// The bytes are canonical: the same event signs the same whatever
692    /// order a serializer put its keys in
693    #[test]
694    fn signing_bytes_are_the_context_then_sorted_keys() {
695        let bytes = SignedEvent::signing_bytes(&Event::Broadcast(notice(5)));
696        let text = String::from_utf8(bytes).unwrap();
697        assert_eq!(
698            text,
699            "agora/event/v1\n{\"kind\":\"broadcast\",\
700             \"message_id\":\"00000000-0000-0000-0000-000000000005\",\
701             \"recipient_id\":\"00000000-0000-0000-0000-000000000009\",\
702             \"sender_id\":\"00000000-0000-0000-0000-000000000007\",\
703             \"sender_name\":\"alice\",\"sent_at\":\"2025-10-09T08:53:20Z\"}"
704        );
705    }
706
707    #[test]
708    fn preferences_filter_and_change_field_by_field() {
709        let all = NotificationPreferences::default();
710        assert!(all.wants(&Event::DirectMessage(notice(1))));
711        assert!(all.wants(&Event::Broadcast(notice(1))));
712
713        let quiet = all.with(&NotificationSettings {
714            direct_messages: Some(false),
715            broadcasts: None,
716        });
717        assert!(!quiet.wants(&Event::DirectMessage(notice(1))));
718        assert!(quiet.wants(&Event::Broadcast(notice(1))));
719        assert_eq!(quiet.with(&NotificationSettings::default()), quiet);
720        assert!(NotificationSettings::default().is_empty());
721    }
722
723    #[test]
724    fn event_seq_parses_what_it_prints_and_refuses_the_rest() {
725        assert_eq!("17".parse::<EventSeq>().unwrap(), EventSeq::from(17));
726        assert_eq!(" 0 ".parse::<EventSeq>().unwrap(), EventSeq::ZERO);
727        assert!("-1".parse::<EventSeq>().is_err());
728        assert!("abc".parse::<EventSeq>().is_err());
729        assert_eq!(EventSeq::from(9).to_string(), "9");
730        assert_eq!(serde_json::to_string(&EventSeq::from(9)).unwrap(), "9");
731    }
732
733    #[cfg(feature = "schemars")]
734    #[test]
735    fn schemas_carry_no_ref() {
736        for (name, schema) in [
737            (
738                "SignedEvent",
739                serde_json::to_value(schemars::schema_for!(SignedEvent))
740                    .unwrap(),
741            ),
742            (
743                "NotificationSettings",
744                serde_json::to_value(schemars::schema_for!(
745                    NotificationSettings
746                ))
747                .unwrap(),
748            ),
749        ] {
750            let text = schema.to_string();
751            assert!(!text.contains("$ref"), "{name}: {text}");
752            assert!(!text.contains("$defs"), "{name}: {text}");
753        }
754    }
755
756    #[test]
757    fn the_parser_reads_frames_across_chunks_and_skips_keep_alives() {
758        let mut p = SseParser::new();
759        assert!(p.push(b": keep-alive\n\nid: 1\nevent: direct").is_empty());
760        let frames = p.push(b"_message\ndata: {\"a\":\ndata: 1}\n\n");
761        assert_eq!(
762            frames,
763            vec![SseFrame {
764                id: Some("1".into()),
765                event: Some("direct_message".into()),
766                data: "{\"a\":\n1}".into(),
767                retry: None,
768            }]
769        );
770        // CRLF line endings, split between chunks, and a bare `retry:`.
771        // The id carries over, as the WHATWG format says
772        let frames = p.push(b"retry: 3000\r");
773        assert!(frames.is_empty());
774        let frames = p.push(b"\n\r\ndata:x\r\n\r\n");
775        assert_eq!(
776            frames,
777            vec![
778                SseFrame {
779                    retry: Some(3000),
780                    ..Default::default()
781                },
782                SseFrame {
783                    id: Some("1".into()),
784                    event: Some("message".into()),
785                    data: "x".into(),
786                    retry: None,
787                },
788            ]
789        );
790        // A frame of nothing but unknown fields is dropped
791        assert!(p.push(b"foo: bar\n\n").is_empty());
792        assert!(p.take_dropped().is_empty());
793        // A frame whose lines add up past the cap is dropped whole, and
794        // the next frame reads as usual
795        let line = format!("data: {}\n", "x".repeat(MAX_LINE_BYTES - 16));
796        let mut big = line.repeat(MAX_FRAME_BYTES / line.len() + 2);
797        big.push_str("\ndata: after\n\n");
798        let frames = p.push(big.as_bytes());
799        assert_eq!(frames.len(), 1);
800        assert_eq!(frames[0].data, "after");
801        let dropped = p.take_dropped();
802        assert_eq!(dropped.len(), 1);
803        assert_eq!(dropped[0].cap, SseCap::Frame);
804        assert_eq!(dropped[0].bytes, big.len() - "\ndata: after\n\n".len());
805    }
806
807    /// A frame with a line past the cap is dropped whole, its `id:` with
808    /// it, even when the line arrives in pieces; the stream reads on
809    #[test]
810    fn an_overlong_line_drops_its_frame_whole_across_chunks() {
811        let mut p = SseParser::new();
812        assert_eq!(p.push(b"id: before\ndata: a\n\n").len(), 1);
813        let long = "y".repeat(MAX_LINE_BYTES + 1);
814        let wire =
815            format!("id: cut\ndata: {long}\ndata: tail\n\ndata: next\n\n");
816        let mut frames = Vec::new();
817        for piece in wire.as_bytes().chunks(4093) {
818            frames.extend(p.push(piece));
819            assert!(p.buffered() <= 2 * (MAX_LINE_BYTES + MAX_FRAME_BYTES));
820        }
821        assert_eq!(
822            frames,
823            vec![SseFrame {
824                id: Some("before".into()),
825                event: Some("message".into()),
826                data: "next".into(),
827                retry: None,
828            }],
829            "the cut frame's id never becomes the stream's"
830        );
831        assert_eq!(
832            p.take_dropped(),
833            vec![DroppedFrame {
834                cap: SseCap::Line,
835                bytes: "id: cut\n".len()
836                    + "data: ".len()
837                    + long.len()
838                    + 1
839                    + "data: tail\n".len(),
840            }]
841        );
842        // A line exactly at the cap is kept
843        let at = "z".repeat(MAX_LINE_BYTES - "data: ".len());
844        let frames = p.push(format!("data: {at}\n\n").as_bytes());
845        assert_eq!(frames.len(), 1);
846        assert_eq!(frames[0].data, at);
847    }
848
849    #[test]
850    fn a_bom_comments_and_an_empty_id_are_read_as_the_format_says() {
851        let mut p = SseParser::new();
852        let mut frames = Vec::new();
853        // A BOM split across chunks
854        for piece in [&b"\xEF"[..], b"\xBB", b"\xBFid: 1\ndata: a\n\n"] {
855            frames.extend(p.push(piece));
856        }
857        frames.extend(p.push(b":c\n: keep-alive\n\nid\ndata: b\n\n"));
858        assert_eq!(frames.len(), 2);
859        assert_eq!(frames[0].id.as_deref(), Some("1"));
860        assert_eq!(frames[1].id, None, "an empty id resets it");
861        // A BOM later on is not one: it is part of the field name
862        assert!(p.push("\u{feff}data: c\n\n".as_bytes()).is_empty());
863        // Invalid UTF-8 is replaced, not refused
864        let frames = p.push(b"data: \xFF\xFE ok\n\n");
865        assert_eq!(frames[0].data, "\u{fffd}\u{fffd} ok");
866    }
867
868    /// A BOM anywhere but the very start is part of a field name, even
869    /// after blank lines; an id with a NUL is ignored, an empty one resets
870    #[test]
871    fn a_late_bom_and_odd_ids_read_as_the_format_says() {
872        let mut p = SseParser::new();
873        assert!(p.push("\n\n\u{feff}data: x\n\n".as_bytes()).is_empty());
874        let frames = p.push(b"id: 1\ndata: a\n\nid: n\0ul\ndata: b\n\n");
875        assert_eq!(frames[1].id.as_deref(), Some("1"), "a NUL id is ignored");
876        let frames = p.push(b"id:\ndata: c\n\n");
877        assert_eq!(frames[0].id, None);
878    }
879
880    /// A frame of the most short lines the caps allow reaches
881    /// `eventsource_stream` a line at a time: given it whole, it copies
882    /// the rest of the frame after every line
883    #[test]
884    fn a_frame_of_many_short_lines_is_cheap() {
885        for line in [":\n", "data\n", "x\n"] {
886            let mut wire = line.repeat((MAX_FRAME_BYTES - 64) / line.len());
887            wire.push_str("data: end\n\n");
888            let mut p = SseParser::new();
889            let started = std::time::Instant::now();
890            let frames = p.push(wire.as_bytes());
891            let took = started.elapsed();
892            assert_eq!(frames.len(), 1, "{line:?}");
893            assert!(
894                p.longest_fed <= MAX_LINE_BYTES + 1,
895                "{line:?}: fed {} bytes at once",
896                p.longest_fed
897            );
898            // Loose: a whole frame at once took 1.7 s in a debug build
899            assert!(
900                took < std::time::Duration::from_secs(5),
901                "{line:?}: {took:?}"
902            );
903        }
904    }
905
906    mod props {
907        use super::*;
908        use proptest::prelude::*;
909
910        /// A frame to write: its lines, and what reading it yields
911        #[derive(Debug, Clone)]
912        struct Spec {
913            comment_before: Option<String>,
914            id: Option<String>,
915            event: Option<String>,
916            data: Vec<String>,
917            retry: Option<u64>,
918            /// It carries a line past the cap: dropped whole
919            oversize: bool,
920        }
921
922        fn spec() -> impl Strategy<Value = Spec> {
923            (
924                proptest::option::of("[^\r\n]{0,20}"),
925                proptest::option::of("[A-Za-z0-9_-]{1,12}"),
926                proptest::option::of("[a-z_]{1,12}"),
927                proptest::collection::vec("[^\r\n]{0,40}", 0..4),
928                proptest::option::of(any::<u64>()),
929                proptest::bool::weighted(0.08),
930            )
931                .prop_map(
932                    |(comment_before, id, event, data, retry, oversize)| Spec {
933                        comment_before,
934                        id,
935                        event,
936                        data,
937                        retry,
938                        oversize,
939                    },
940                )
941        }
942
943        fn lines(spec: &Spec) -> Vec<String> {
944            let mut lines = Vec::new();
945            if let Some(c) = &spec.comment_before {
946                lines.push(format!(":{c}"));
947            }
948            if let Some(id) = &spec.id {
949                lines.push(format!("id: {id}"));
950            }
951            if let Some(e) = &spec.event {
952                lines.push(format!("event: {e}"));
953            }
954            for d in &spec.data {
955                lines.push(format!("data: {d}"));
956            }
957            if spec.oversize {
958                lines.push(format!("data: {}", "o".repeat(MAX_LINE_BYTES)));
959            }
960            if let Some(r) = spec.retry {
961                lines.push(format!("retry: {r}"));
962            }
963            lines
964        }
965
966        /// What reading `specs` yields
967        fn model(specs: &[Spec]) -> Vec<SseFrame> {
968            let mut id = None;
969            let mut out = Vec::new();
970            for spec in specs.iter().filter(|s| !s.oversize) {
971                if spec.id.is_some() {
972                    id = spec.id.clone();
973                }
974                if !spec.data.is_empty() {
975                    out.push(SseFrame {
976                        id: id.clone(),
977                        event: Some(
978                            spec.event.clone().unwrap_or("message".into()),
979                        ),
980                        data: spec.data.join("\n"),
981                        retry: spec.retry,
982                    });
983                } else if spec.retry.is_some() {
984                    out.push(SseFrame {
985                        retry: spec.retry,
986                        ..Default::default()
987                    });
988                }
989            }
990            out
991        }
992
993        /// `specs` on the wire, each line ended by the next of `endings`
994        /// (0 `\n`, 1 `\r`, 2 `\r\n`); a blank line after a `\r` cannot
995        /// end in a lone `\n`, which would make one ending of the two
996        fn wire(specs: &[Spec], endings: &[u8], bom: bool) -> Vec<u8> {
997            let mut out = Vec::new();
998            if bom {
999                out.extend_from_slice(BOM);
1000            }
1001            let mut endings = endings.iter().cycle();
1002            let mut after_cr = false;
1003            let mut end = |out: &mut Vec<u8>, blank: bool| {
1004                let mut e = *endings.next().unwrap_or(&0);
1005                if blank && after_cr && e == 0 {
1006                    e = 1;
1007                }
1008                after_cr = e == 1;
1009                out.extend_from_slice(match e {
1010                    0 => b"\n",
1011                    1 => b"\r",
1012                    _ => b"\r\n",
1013                });
1014            };
1015            for spec in specs {
1016                for line in lines(spec) {
1017                    out.extend_from_slice(line.as_bytes());
1018                    end(&mut out, false);
1019                }
1020                end(&mut out, true);
1021            }
1022            out
1023        }
1024
1025        fn read(wire: &[u8], cuts: &[usize]) -> (Vec<SseFrame>, usize) {
1026            let mut cuts: Vec<usize> =
1027                cuts.iter().map(|c| c % (wire.len() + 1)).collect();
1028            cuts.sort();
1029            let mut p = SseParser::new();
1030            let mut frames = Vec::new();
1031            let mut at = 0;
1032            let mut held = 0;
1033            for cut in cuts.into_iter().chain([wire.len()]) {
1034                frames.extend(p.push(&wire[at..cut]));
1035                held = held.max(p.buffered());
1036                at = cut;
1037            }
1038            (frames, held)
1039        }
1040
1041        proptest! {
1042            #![proptest_config(ProptestConfig::with_cases(512))]
1043
1044            /// However a valid stream is cut into chunks, and whatever its
1045            /// line endings, it reads as the frames it was written from
1046            #[test]
1047            fn chunk_boundaries_never_change_the_frames(
1048                specs in proptest::collection::vec(spec(), 0..12),
1049                endings in proptest::collection::vec(0u8..3, 1..16),
1050                bom in any::<bool>(),
1051                cuts in proptest::collection::vec(any::<usize>(), 0..24),
1052            ) {
1053                let wire = wire(&specs, &endings, bom);
1054                let expected = model(&specs);
1055                prop_assert_eq!(&read(&wire, &[]).0, &expected);
1056                prop_assert_eq!(&read(&wire, &cuts).0, &expected);
1057                // One byte at a time
1058                let every: Vec<usize> = (0..wire.len()).collect();
1059                prop_assert_eq!(&read(&wire, &every).0, &expected);
1060            }
1061
1062            /// Garbage never panics, never holds more than the caps, and
1063            /// never yields a frame bigger than one could be
1064            #[test]
1065            fn garbage_never_panics_or_grows_past_the_caps(
1066                runs in proptest::collection::vec(
1067                    (
1068                        prop_oneof![
1069                            any::<u8>(),
1070                            Just(b'\n'),
1071                            Just(b'\r'),
1072                            Just(b':'),
1073                            Just(b'x'),
1074                        ],
1075                        prop_oneof![1usize..8, 1usize..400_000],
1076                    ),
1077                    0..10,
1078                ),
1079                noise in proptest::collection::vec(any::<u8>(), 0..512),
1080                prefixes in proptest::collection::vec(
1081                    prop_oneof![
1082                        Just(&b"data: "[..]),
1083                        Just(&b"id: "[..]),
1084                        Just(&b"retry: 9"[..]),
1085                        Just(&b"\r\n\r\n"[..]),
1086                        Just(&b"\xEF\xBB\xBF"[..]),
1087                        Just(&b"\xFF"[..]),
1088                    ],
1089                    0..10,
1090                ),
1091                cuts in proptest::collection::vec(any::<usize>(), 0..32),
1092            ) {
1093                let mut wire = Vec::new();
1094                for (i, (byte, n)) in runs.iter().enumerate() {
1095                    if let Some(p) = prefixes.get(i) {
1096                        wire.extend_from_slice(p);
1097                    }
1098                    wire.extend(std::iter::repeat_n(*byte, *n));
1099                    wire.extend_from_slice(&noise);
1100                }
1101                let (frames, held) = read(&wire, &cuts);
1102                prop_assert!(held <= 2 * (MAX_LINE_BYTES + MAX_FRAME_BYTES));
1103                for f in frames {
1104                    prop_assert!(f.data.len() <= 3 * MAX_FRAME_BYTES);
1105                    prop_assert!(
1106                        f.id.as_ref().is_none_or(|id| id.len() <= MAX_LINE_BYTES)
1107                    );
1108                }
1109            }
1110        }
1111    }
1112}