Skip to main content

wscall_protocol/
lib.rs

1//! Shared protocol definitions for WSCALL.
2//!
3//! This crate contains the transport envelope, frame codec, encryption modes,
4//! and inline attachment model used by both the server and client crates.
5
6use std::sync::Arc;
7
8use aes_gcm::{Aes256Gcm, KeyInit as AesKeyInit, Nonce as AesNonce, aead::Aead as AesAead};
9use bytes::Bytes;
10use chacha20poly1305::{ChaCha20Poly1305, Nonce};
11use getrandom::getrandom;
12use serde::de::{Deserializer, MapAccess, SeqAccess, Visitor};
13use serde::ser::SerializeMap;
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value, json};
16use sha2::{Digest, Sha256};
17use thiserror::Error;
18use x25519_dalek::{PublicKey, StaticSecret};
19
20// ─── ECDH Dynamic Key Agreement ─────────────────────────────────────────────
21//
22// When the ECDH handshake mode is enabled, the client and server exchange
23// raw X25519 public keys (32 bytes each) over the WebSocket binary channel
24// immediately after the TCP/TLS upgrade. Both sides derive the same 32-byte
25// ChaCha20-Poly1305 session key via SHA-256(domain ‖ DH secret). This key is
26// unique per connection and never travels over the wire.
27
28/// Domain-separation tag mixed into the SHA-256 KDF so that wscall session
29/// keys cannot collide with keys derived for any other protocol.
30pub const ECDH_DOMAIN_TAG: &[u8] = b"wscall-ecdh-v1";
31
32/// Byte length of an X25519 public key (also the secret key length).
33pub const ECDH_KEY_LEN: usize = 32;
34
35/// A freshly generated X25519 keypair for the ECDH handshake.
36///
37/// The secret is zeroized on drop. Keep the secret on the side that generated
38/// it and send only the [`EcdhKeypair::public`] bytes to the peer.
39pub struct EcdhKeypair {
40    secret: StaticSecret,
41    public: PublicKey,
42}
43
44impl EcdhKeypair {
45    /// Generates a random X25519 keypair using the platform CSPRNG.
46    pub fn generate() -> Result<Self, ProtocolError> {
47        let mut secret_bytes = [0u8; ECDH_KEY_LEN];
48        getrandom(&mut secret_bytes).map_err(|source| ProtocolError::Random(source.to_string()))?;
49        let secret = StaticSecret::from(secret_bytes);
50        let public = PublicKey::from(&secret);
51        Ok(Self { secret, public })
52    }
53
54    /// Returns the 32-byte public key that should be sent to the peer.
55    pub fn public_bytes(&self) -> [u8; ECDH_KEY_LEN] {
56        self.public.to_bytes()
57    }
58
59    /// Derives the 32-byte ChaCha20-Poly1305 session key from the peer's
60    /// 32-byte public key.
61    ///
62    /// `peer_public` must be exactly 32 bytes received from the other side.
63    pub fn derive_session_key(&self, peer_public: &[u8; ECDH_KEY_LEN]) -> [u8; 32] {
64        let peer = PublicKey::from(*peer_public);
65        let shared = self.secret.diffie_hellman(&peer);
66        derive_session_key(&shared.to_bytes())
67    }
68}
69
70/// Derives a 32-byte symmetric key from a raw X25519 shared secret.
71///
72/// Uses SHA-256(domain ‖ shared_secret) for domain separation. The result is
73/// suitable as a ChaCha20-Poly1305 or AES-256-GCM key.
74pub fn derive_session_key(shared_secret: &[u8; 32]) -> [u8; 32] {
75    let mut hasher = Sha256::new();
76    hasher.update(ECDH_DOMAIN_TAG);
77    hasher.update(shared_secret);
78    let result = hasher.finalize();
79    let mut key = [0u8; 32];
80    key.copy_from_slice(&result);
81    key
82}
83
84/// Parses 32 raw bytes received from the peer into an X25519 public key.
85///
86/// Returns an error if the slice is not exactly 32 bytes.
87pub fn parse_peer_public(bytes: &[u8]) -> Result<[u8; ECDH_KEY_LEN], ProtocolError> {
88    if bytes.len() != ECDH_KEY_LEN {
89        return Err(ProtocolError::InvalidEcdhPublicKey {
90            expected: ECDH_KEY_LEN,
91            actual: bytes.len(),
92        });
93    }
94    let mut key = [0u8; ECDH_KEY_LEN];
95    key.copy_from_slice(bytes);
96    Ok(key)
97}
98
99const AES256_NONCE_LEN: usize = 12;
100const CHACHA20_NONCE_LEN: usize = 12;
101
102/// Default maximum frame size: 100 MiB.
103///
104/// This is no longer a hard protocol constant; the server configures it via
105/// `WscallServer::with_max_frame_bytes(n)` and the codec carries the value.
106pub const DEFAULT_MAX_FRAME_BYTES: usize = 100 * 1024 * 1024;
107
108/// Distinguishes API messages from event messages inside a WSCALL frame.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110#[repr(u8)]
111pub enum MessageType {
112    /// API request or API response.
113    Api = 0x00,
114    /// Event emit or event acknowledgement.
115    Event = 0x01,
116}
117
118impl TryFrom<u8> for MessageType {
119    type Error = ProtocolError;
120
121    fn try_from(value: u8) -> Result<Self, Self::Error> {
122        match value {
123            0x00 => Ok(Self::Api),
124            0x01 => Ok(Self::Event),
125            _ => Err(ProtocolError::UnknownMessageType(value)),
126        }
127    }
128}
129
130/// Selects how the payload section of a frame is encoded.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[repr(u8)]
133pub enum EncryptionKind {
134    /// Unencrypted JSON payload.
135    None = 0x00,
136    /// ChaCha20-Poly1305 encrypted payload.
137    ChaCha20 = 0x01,
138    /// AES256-GCM encrypted payload.
139    Aes256 = 0x02,
140}
141
142impl TryFrom<u8> for EncryptionKind {
143    type Error = ProtocolError;
144
145    fn try_from(value: u8) -> Result<Self, Self::Error> {
146        match value {
147            0x00 => Ok(Self::None),
148            0x01 => Ok(Self::ChaCha20),
149            0x02 => Ok(Self::Aes256),
150            _ => Err(ProtocolError::UnknownEncryption(value)),
151        }
152    }
153}
154
155/// Binary attachment carried alongside JSON params or event data.
156///
157/// In protocol v2 the attachment payload is stored as raw bytes in a binary
158/// section appended after the JSON metadata, eliminating the 33% overhead of
159/// Base64 encoding used in protocol v1.
160///
161/// The payload uses [`Bytes`] (reference-counted, immutable) so that cloning
162/// an attachment — e.g. during ECDH broadcasts — is a cheap refcount bump
163/// rather than a deep copy of the entire buffer.
164#[derive(Debug, Clone)]
165pub struct FileAttachment {
166    /// Attachment identifier referenced from JSON using `{ "$file": "..." }`.
167    pub id: String,
168    /// Original file name.
169    pub name: String,
170    /// MIME type supplied by the sender.
171    pub content_type: String,
172    /// Raw attachment payload bytes (zero-copy shared via `Bytes`).
173    pub data: Bytes,
174}
175
176impl FileAttachment {
177    /// Builds a text attachment from a string.
178    pub fn inline_text(
179        id: impl Into<String>,
180        name: impl Into<String>,
181        content_type: impl Into<String>,
182        text: impl AsRef<str>,
183    ) -> Self {
184        Self::inline_bytes(
185            id,
186            name,
187            content_type,
188            Bytes::from(text.as_ref().to_owned()),
189        )
190    }
191
192    /// Builds a binary attachment from raw bytes.
193    ///
194    /// Accepts anything convertible into [`Bytes`] (`Vec<u8>`, `Bytes`,
195    /// `&'static [u8]`, etc.).
196    pub fn inline_bytes(
197        id: impl Into<String>,
198        name: impl Into<String>,
199        content_type: impl Into<String>,
200        bytes: impl Into<Bytes>,
201    ) -> Self {
202        Self {
203            id: id.into(),
204            name: name.into(),
205            content_type: content_type.into(),
206            data: bytes.into(),
207        }
208    }
209
210    /// Returns the byte length of the attachment payload.
211    pub fn size(&self) -> usize {
212        self.data.len()
213    }
214
215    /// Returns a JSON reference object that points to an attachment by id.
216    pub fn param_ref(id: impl Into<String>) -> Value {
217        json!({ "$file": id.into() })
218    }
219
220    /// Computes the total wire size of this attachment's binary section.
221    ///
222    /// Layout: id_len:u8 + id + name_len:u8 + name + ct_len:u8 + ct + data_len:u32 + data
223    pub(crate) fn wire_size(&self) -> usize {
224        1 + self.id.len() + 1 + self.name.len() + 1 + self.content_type.len() + 4 + self.data.len()
225    }
226
227    /// Appends this attachment's binary section to `buf`.
228    pub(crate) fn write_wire(&self, buf: &mut Vec<u8>) {
229        buf.push(self.id.len() as u8);
230        buf.extend_from_slice(self.id.as_bytes());
231        buf.push(self.name.len() as u8);
232        buf.extend_from_slice(self.name.as_bytes());
233        buf.push(self.content_type.len() as u8);
234        buf.extend_from_slice(self.content_type.as_bytes());
235        buf.extend_from_slice(&(self.data.len() as u32).to_be_bytes());
236        buf.extend_from_slice(&self.data);
237    }
238
239    /// Parses one attachment binary section from `data`, returning the
240    /// attachment and the remaining unconsumed bytes.
241    ///
242    /// The payload is extracted via [`Bytes::slice`] which shares the
243    /// underlying buffer (zero-copy) rather than allocating a new `Vec`.
244    pub(crate) fn read_wire(data: &Bytes) -> Result<(Self, Bytes), ProtocolError> {
245        let mut pos = 0;
246
247        // id
248        if pos >= data.len() {
249            return Err(ProtocolError::InvalidAttachmentEncoding(
250                "truncated id_len".into(),
251            ));
252        }
253        let id_len = data[pos] as usize;
254        pos += 1;
255        if pos + id_len > data.len() {
256            return Err(ProtocolError::InvalidAttachmentEncoding(
257                "truncated id".into(),
258            ));
259        }
260        let id = String::from_utf8_lossy(&data[pos..pos + id_len]).into_owned();
261        pos += id_len;
262
263        // name
264        if pos >= data.len() {
265            return Err(ProtocolError::InvalidAttachmentEncoding(
266                "truncated name_len".into(),
267            ));
268        }
269        let name_len = data[pos] as usize;
270        pos += 1;
271        if pos + name_len > data.len() {
272            return Err(ProtocolError::InvalidAttachmentEncoding(
273                "truncated name".into(),
274            ));
275        }
276        let name = String::from_utf8_lossy(&data[pos..pos + name_len]).into_owned();
277        pos += name_len;
278
279        // content_type
280        if pos >= data.len() {
281            return Err(ProtocolError::InvalidAttachmentEncoding(
282                "truncated ct_len".into(),
283            ));
284        }
285        let ct_len = data[pos] as usize;
286        pos += 1;
287        if pos + ct_len > data.len() {
288            return Err(ProtocolError::InvalidAttachmentEncoding(
289                "truncated content_type".into(),
290            ));
291        }
292        let content_type = String::from_utf8_lossy(&data[pos..pos + ct_len]).into_owned();
293        pos += ct_len;
294
295        // data
296        if pos + 4 > data.len() {
297            return Err(ProtocolError::InvalidAttachmentEncoding(
298                "truncated data_len".into(),
299            ));
300        }
301        let data_len =
302            u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
303        pos += 4;
304        if pos + data_len > data.len() {
305            return Err(ProtocolError::InvalidAttachmentEncoding(
306                "truncated data".into(),
307            ));
308        }
309        // Zero-copy: slice shares the underlying Bytes buffer.
310        let payload = data.slice(pos..pos + data_len);
311        pos += data_len;
312
313        Ok((
314            Self {
315                id,
316                name,
317                content_type,
318                data: payload,
319            },
320            data.slice(pos..),
321        ))
322    }
323}
324
325// ─── Serde impls for FileAttachment ─────────────────────────────────────────
326//
327// `Bytes` does not implement Serialize/Deserialize out of the box. We provide
328// manual impls using `serialize_bytes` / `visit_bytes` so that the (rarely
329// used) JSON `a` field round-trips correctly.
330
331impl Serialize for FileAttachment {
332    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
333        let mut map = serializer.serialize_map(Some(4))?;
334        map.serialize_entry("id", &self.id)?;
335        map.serialize_entry("name", &self.name)?;
336        map.serialize_entry("content_type", &self.content_type)?;
337        map.serialize_entry("data", &self.data.to_vec())?;
338        map.end()
339    }
340}
341
342impl<'de> Deserialize<'de> for FileAttachment {
343    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
344        #[derive(Deserialize)]
345        #[serde(field_identifier, rename_all = "snake_case")]
346        enum Field {
347            Id,
348            Name,
349            ContentType,
350            Data,
351        }
352
353        struct FileAttachmentVisitor;
354
355        impl<'de> Visitor<'de> for FileAttachmentVisitor {
356            type Value = FileAttachment;
357
358            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
359                f.write_str("struct FileAttachment")
360            }
361
362            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
363                let mut id = None;
364                let mut name = None;
365                let mut content_type = None;
366                let mut data: Option<Bytes> = None;
367                while let Some(key) = map.next_key()? {
368                    match key {
369                        Field::Id => id = Some(map.next_value()?),
370                        Field::Name => name = Some(map.next_value()?),
371                        Field::ContentType => content_type = Some(map.next_value()?),
372                        Field::Data => {
373                            let bytes: Vec<u8> = map.next_value()?;
374                            data = Some(Bytes::from(bytes));
375                        }
376                    }
377                }
378                Ok(FileAttachment {
379                    id: id.ok_or_else(|| serde::de::Error::missing_field("id"))?,
380                    name: name.ok_or_else(|| serde::de::Error::missing_field("name"))?,
381                    content_type: content_type
382                        .ok_or_else(|| serde::de::Error::missing_field("content_type"))?,
383                    data: data.ok_or_else(|| serde::de::Error::missing_field("data"))?,
384                })
385            }
386
387            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
388                let id = seq
389                    .next_element()?
390                    .ok_or_else(|| serde::de::Error::invalid_length(0, &self))?;
391                let name = seq
392                    .next_element()?
393                    .ok_or_else(|| serde::de::Error::invalid_length(1, &self))?;
394                let content_type = seq
395                    .next_element()?
396                    .ok_or_else(|| serde::de::Error::invalid_length(2, &self))?;
397                let bytes: Vec<u8> = seq
398                    .next_element()?
399                    .ok_or_else(|| serde::de::Error::invalid_length(3, &self))?;
400                Ok(FileAttachment {
401                    id,
402                    name,
403                    content_type,
404                    data: Bytes::from(bytes),
405                })
406            }
407        }
408
409        const FIELDS: &[&str] = &["id", "name", "content_type", "data"];
410        deserializer.deserialize_struct("FileAttachment", FIELDS, FileAttachmentVisitor)
411    }
412}
413
414/// Standard error payload embedded in API responses and event acknowledgements.
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct ErrorPayload {
417    pub code: String,
418    pub message: String,
419    pub status: u16,
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub details: Option<Value>,
422}
423
424/// Compact numeric tag identifying each [`PacketBody`] variant in the JSON
425/// wire format. Using a single-byte numeric field (`"k": 0`) instead of a
426/// string tag (`"kind": "api_request"`) saves several bytes per frame.
427pub const K_API_REQUEST: u8 = 0;
428pub const K_EVENT_EMIT: u8 = 1;
429pub const K_API_RESPONSE: u8 = 2;
430pub const K_EVENT_ACK: u8 = 3;
431
432/// JSON-level message body transported inside a WSCALL frame.
433///
434/// The wire format uses short single-letter keys and a numeric `k` tag to
435/// minimize per-frame overhead:
436///
437/// | Variant | `k` | Fields |
438/// | --- | --- | --- |
439/// | `ApiRequest`  | 0 | `i` `r` `p` `a` `m` |
440/// | `EventEmit`   | 1 | `i` `n` `d` `a` `m` `e` |
441/// | `ApiResponse` | 2 | `i` `o` `s` `d` `m` [`er`] |
442/// | `EventAck`    | 3 | `i` `o` `rc` [`er`] |
443#[derive(Debug, Clone)]
444pub enum PacketBody {
445    /// Client-to-server API request.
446    ApiRequest {
447        request_id: u64,
448        route: String,
449        params: Value,
450        attachments: Vec<FileAttachment>,
451        metadata: Value,
452    },
453    /// Server-to-client API response.
454    ApiResponse {
455        request_id: u64,
456        ok: bool,
457        status: u16,
458        data: Value,
459        error: Option<ErrorPayload>,
460        metadata: Value,
461    },
462    /// Event emission in either direction.
463    ///
464    /// The `data` payload is always a JSON object (never a scalar or string).
465    EventEmit {
466        event_id: u64,
467        name: String,
468        data: Map<String, Value>,
469        attachments: Vec<FileAttachment>,
470        metadata: Value,
471        expect_ack: bool,
472    },
473    /// Acknowledgement for an emitted event.
474    EventAck {
475        event_id: u64,
476        ok: bool,
477        receipt: Value,
478        error: Option<ErrorPayload>,
479    },
480}
481
482// --- Manual Serialize: short keys + numeric `k` tag -------------------------
483
484/// Returns `true` when the metadata value carries no information (null or `{}`).
485fn metadata_is_empty(v: &Value) -> bool {
486    matches!(v, Value::Null) || v.as_object().is_some_and(|m| m.is_empty())
487}
488
489impl Serialize for PacketBody {
490    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
491    where
492        S: serde::Serializer,
493    {
494        match self {
495            Self::ApiRequest {
496                request_id,
497                route,
498                params,
499                attachments: _,
500                metadata,
501            } => {
502                let include_meta = !metadata_is_empty(metadata);
503                let field_count = 4 + usize::from(include_meta);
504                let mut s = serializer.serialize_map(Some(field_count))?;
505                s.serialize_entry("k", &K_API_REQUEST)?;
506                s.serialize_entry("i", request_id)?;
507                s.serialize_entry("r", route)?;
508                s.serialize_entry("p", params)?;
509                if include_meta {
510                    s.serialize_entry("m", metadata)?;
511                }
512                s.end()
513            }
514            Self::ApiResponse {
515                request_id,
516                ok,
517                status,
518                data,
519                error,
520                metadata,
521            } => {
522                let include_meta = !metadata_is_empty(metadata);
523                let field_count = 5 + usize::from(error.is_some()) + usize::from(include_meta);
524                let mut s = serializer.serialize_map(Some(field_count))?;
525                s.serialize_entry("k", &K_API_RESPONSE)?;
526                s.serialize_entry("i", request_id)?;
527                s.serialize_entry("o", ok)?;
528                s.serialize_entry("s", status)?;
529                s.serialize_entry("d", data)?;
530                if let Some(err) = error {
531                    s.serialize_entry("er", err)?;
532                }
533                if include_meta {
534                    s.serialize_entry("m", metadata)?;
535                }
536                s.end()
537            }
538            Self::EventEmit {
539                event_id,
540                name,
541                data,
542                attachments: _,
543                metadata,
544                expect_ack,
545            } => {
546                let include_meta = !metadata_is_empty(metadata);
547                let field_count = 5 + usize::from(include_meta);
548                let mut s = serializer.serialize_map(Some(field_count))?;
549                s.serialize_entry("k", &K_EVENT_EMIT)?;
550                s.serialize_entry("i", event_id)?;
551                s.serialize_entry("n", name)?;
552                s.serialize_entry("d", data)?;
553                if include_meta {
554                    s.serialize_entry("m", metadata)?;
555                }
556                s.serialize_entry("e", expect_ack)?;
557                s.end()
558            }
559            Self::EventAck {
560                event_id,
561                ok,
562                receipt,
563                error,
564            } => {
565                let field_count = 3 + usize::from(error.is_some()) + 1;
566                let mut s = serializer.serialize_map(Some(field_count))?;
567                s.serialize_entry("k", &K_EVENT_ACK)?;
568                s.serialize_entry("i", event_id)?;
569                s.serialize_entry("o", ok)?;
570                s.serialize_entry("rc", receipt)?;
571                if let Some(err) = error {
572                    s.serialize_entry("er", err)?;
573                }
574                s.end()
575            }
576        }
577    }
578}
579
580// --- Manual Deserialize: read numeric `k`, move fields out directly ----------
581//
582// The body is parsed into a JSON object map exactly once; each variant then
583// *moves* its fields out of that map. Nested payloads such as `params`, `data`,
584// `metadata` and `receipt` are transferred without a second traversal, unlike
585// the previous `Value::deserialize` + `serde_json::from_value` pair which walked
586// the whole tree twice.
587
588/// Moves a required unsigned integer field out of the map.
589fn take_u64<E: serde::de::Error>(map: &mut Map<String, Value>, key: &str) -> Result<u64, E> {
590    map.remove(key).and_then(|v| v.as_u64()).ok_or_else(|| {
591        E::custom(format!(
592            "missing or non-numeric '{key}' field in packet body"
593        ))
594    })
595}
596
597/// Moves a required string field out of the map.
598fn take_string<E: serde::de::Error>(map: &mut Map<String, Value>, key: &str) -> Result<String, E> {
599    match map.remove(key) {
600        Some(Value::String(s)) => Ok(s),
601        _ => Err(E::custom(format!(
602            "missing or non-string '{key}' field in packet body"
603        ))),
604    }
605}
606
607/// Moves an optional JSON object field, defaulting to an empty map.
608fn take_object<E: serde::de::Error>(
609    map: &mut Map<String, Value>,
610    key: &str,
611) -> Result<Map<String, Value>, E> {
612    match map.remove(key) {
613        None => Ok(Map::new()),
614        Some(Value::Object(o)) => Ok(o),
615        Some(_) => Err(E::custom(format!("'{key}' field must be a JSON object"))),
616    }
617}
618
619/// Moves an optional boolean field, defaulting to `false`.
620fn take_bool(map: &mut Map<String, Value>, key: &str) -> bool {
621    map.remove(key).and_then(|v| v.as_bool()).unwrap_or(false)
622}
623
624/// Moves an optional `u16` field, defaulting to `0`.
625fn take_u16(map: &mut Map<String, Value>, key: &str) -> u16 {
626    map.remove(key).and_then(|v| v.as_u64()).unwrap_or(0) as u16
627}
628
629/// Moves the optional binary-attachment reference list (`a`).
630///
631/// Attachments normally travel in the frame's binary section and are injected
632/// via [`PacketBody::set_attachments`]; the JSON `a` field is only honoured for
633/// completeness and is almost always absent.
634fn take_attachments<E: serde::de::Error>(
635    map: &mut Map<String, Value>,
636) -> Result<Vec<FileAttachment>, E> {
637    match map.remove("a") {
638        None => Ok(Vec::new()),
639        Some(value) => serde_json::from_value(value).map_err(E::custom),
640    }
641}
642
643/// Moves the optional error payload (`er`).
644fn take_error<E: serde::de::Error>(
645    map: &mut Map<String, Value>,
646) -> Result<Option<ErrorPayload>, E> {
647    match map.remove("er") {
648        None => Ok(None),
649        Some(value) => serde_json::from_value(value).map(Some).map_err(E::custom),
650    }
651}
652
653impl<'de> Deserialize<'de> for PacketBody {
654    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
655    where
656        D: Deserializer<'de>,
657    {
658        // Parse into a JSON object map once so we can inspect the numeric `k`
659        // discriminator, then move the remaining fields out by short key.
660        let mut map = match Value::deserialize(deserializer)? {
661            Value::Object(map) => map,
662            _ => {
663                return Err(serde::de::Error::custom(
664                    "packet body must be a JSON object",
665                ));
666            }
667        };
668
669        let k = map.get("k").and_then(|v| v.as_u64()).ok_or_else(|| {
670            serde::de::Error::custom("missing or non-numeric 'k' field in packet body")
671        })?;
672        let k = u8::try_from(k).map_err(|_| {
673            serde::de::Error::custom(format!("'k' value {k} is outside the valid range"))
674        })?;
675
676        match k {
677            K_API_REQUEST => Ok(Self::ApiRequest {
678                request_id: take_u64(&mut map, "i")?,
679                route: take_string(&mut map, "r")?,
680                params: map.remove("p").unwrap_or(Value::Null),
681                attachments: take_attachments(&mut map)?,
682                metadata: map.remove("m").unwrap_or(Value::Null),
683            }),
684            K_EVENT_EMIT => Ok(Self::EventEmit {
685                event_id: take_u64(&mut map, "i")?,
686                name: take_string(&mut map, "n")?,
687                data: take_object(&mut map, "d")?,
688                attachments: take_attachments(&mut map)?,
689                metadata: map.remove("m").unwrap_or(Value::Null),
690                expect_ack: take_bool(&mut map, "e"),
691            }),
692            K_API_RESPONSE => Ok(Self::ApiResponse {
693                request_id: take_u64(&mut map, "i")?,
694                ok: take_bool(&mut map, "o"),
695                status: take_u16(&mut map, "s"),
696                data: map.remove("d").unwrap_or(Value::Null),
697                error: take_error(&mut map)?,
698                metadata: map.remove("m").unwrap_or(Value::Null),
699            }),
700            K_EVENT_ACK => Ok(Self::EventAck {
701                event_id: take_u64(&mut map, "i")?,
702                ok: take_bool(&mut map, "o"),
703                receipt: map.remove("rc").unwrap_or(Value::Null),
704                error: take_error(&mut map)?,
705            }),
706            _ => Err(serde::de::Error::custom(format!("unknown 'k' value: {k}"))),
707        }
708    }
709}
710
711impl PacketBody {
712    pub fn message_type(&self) -> MessageType {
713        match self {
714            Self::ApiRequest { .. } | Self::ApiResponse { .. } => MessageType::Api,
715            Self::EventEmit { .. } | Self::EventAck { .. } => MessageType::Event,
716        }
717    }
718
719    /// Returns the attachments carried by this packet (empty for responses/acks).
720    pub fn attachments(&self) -> &[FileAttachment] {
721        match self {
722            Self::ApiRequest { attachments, .. } => attachments,
723            Self::EventEmit { attachments, .. } => attachments,
724            _ => &[],
725        }
726    }
727
728    /// Injects decoded binary attachments back into the packet body.
729    pub fn set_attachments(&mut self, attachments: Vec<FileAttachment>) {
730        match self {
731            Self::ApiRequest { attachments: a, .. } => *a = attachments,
732            Self::EventEmit { attachments: a, .. } => *a = attachments,
733            _ => {}
734        }
735    }
736}
737
738/// Full transport envelope before frame encoding.
739#[derive(Debug, Clone, Serialize, Deserialize)]
740pub struct PacketEnvelope {
741    /// Message category declared in the frame header.
742    pub message_type: MessageType,
743    /// Encryption mode declared in the frame header.
744    pub encryption: EncryptionKind,
745    /// JSON body payload.
746    pub body: PacketBody,
747}
748
749impl PacketEnvelope {
750    /// Builds a plaintext envelope from a body.
751    pub fn new(body: PacketBody) -> Self {
752        Self {
753            message_type: body.message_type(),
754            encryption: EncryptionKind::None,
755            body,
756        }
757    }
758
759    /// Builds an envelope with an explicit encryption mode.
760    pub fn with_encryption(body: PacketBody, encryption: EncryptionKind) -> Self {
761        Self {
762            message_type: body.message_type(),
763            encryption,
764            body,
765        }
766    }
767}
768
769/// Encodes and decodes WSCALL binary frames (protocol v2).
770///
771/// Protocol v2 uses a composite payload format: JSON metadata followed by raw
772/// binary attachment sections, eliminating the Base64 overhead of protocol v1.
773///
774/// The symmetric ciphers are constructed once when a key is configured and shared
775/// via [`Arc`] across every clone of the codec. This avoids redoing the key
776/// schedule (which for AES-256-GCM is especially expensive) on every frame.
777#[derive(Clone)]
778pub struct FrameCodec {
779    aes256_cipher: Option<Arc<Aes256Gcm>>,
780    chacha20_cipher: Option<Arc<ChaCha20Poly1305>>,
781    /// Maximum total frame size (including the 4-byte length prefix).
782    max_frame_bytes: usize,
783}
784
785impl Default for FrameCodec {
786    fn default() -> Self {
787        Self {
788            aes256_cipher: None,
789            chacha20_cipher: None,
790            max_frame_bytes: DEFAULT_MAX_FRAME_BYTES,
791        }
792    }
793}
794
795impl std::fmt::Debug for FrameCodec {
796    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
797        f.debug_struct("FrameCodec")
798            .field("aes256", &self.aes256_cipher.is_some())
799            .field("chacha20", &self.chacha20_cipher.is_some())
800            .field("max_frame_bytes", &self.max_frame_bytes)
801            .finish()
802    }
803}
804
805impl FrameCodec {
806    /// Builds a codec configured for plaintext transport.
807    pub fn plaintext() -> Self {
808        Self::default()
809    }
810
811    /// Configures a ChaCha20-Poly1305 key.
812    ///
813    /// The cipher is constructed eagerly so that subsequent frame encoding never
814    /// pays for the key schedule again.
815    pub fn with_chacha20_key(self, key: [u8; 32]) -> Self {
816        let cipher = ChaCha20Poly1305::new_from_slice(&key)
817            .expect("a 32-byte key is always valid for ChaCha20-Poly1305");
818        Self {
819            chacha20_cipher: Some(Arc::new(cipher)),
820            ..self
821        }
822    }
823
824    /// Configures an AES256-GCM key.
825    ///
826    /// The cipher is constructed eagerly so that subsequent frame encoding never
827    /// pays for the AES key expansion again.
828    pub fn with_aes256_key(self, key: [u8; 32]) -> Self {
829        let cipher =
830            Aes256Gcm::new_from_slice(&key).expect("a 32-byte key is always valid for AES-256-GCM");
831        Self {
832            aes256_cipher: Some(Arc::new(cipher)),
833            ..self
834        }
835    }
836
837    /// Sets the maximum total frame size (including the 4-byte length prefix).
838    ///
839    /// Frames exceeding this limit are rejected during encode/decode.
840    pub fn with_max_frame_bytes(mut self, max: usize) -> Self {
841        self.max_frame_bytes = max;
842        self
843    }
844
845    /// Returns the configured maximum frame size.
846    pub fn max_frame_bytes(&self) -> usize {
847        self.max_frame_bytes
848    }
849
850    /// Encodes an envelope into a binary WSCALL frame (protocol v2 composite format).
851    ///
852    /// The composite payload layout (before encryption):
853    /// ```text
854    /// | meta_len:u32(be) | JSON_bytes | att_count:u8 | [att sections...] |
855    /// ```
856    pub fn encode(&self, packet: &PacketEnvelope) -> Result<Vec<u8>, ProtocolError> {
857        let max_payload = self.max_frame_bytes.saturating_sub(6);
858        let attachments = packet.body.attachments();
859        let att_size: usize = attachments.iter().map(|a| a.wire_size()).sum();
860        let message_type = packet.message_type as u8;
861        let encryption = packet.encryption as u8;
862
863        // Plaintext fast path: build the final frame in a single buffer with no
864        // intermediate copies. The JSON metadata is written directly into the
865        // frame via `to_writer` (no temporary `json_bytes` Vec), and the
866        // `frame_len` / `meta_len` prefixes are patched in place once known.
867        //
868        // Layout: frame_len:u32 | msg_type:u8 | enc:u8 | meta_len:u32 | json | att_count:u8 | [atts]
869        if packet.encryption == EncryptionKind::None {
870            let mut frame = Vec::with_capacity(4 + 2 + 4 + 1 + att_size + 64);
871            frame.extend_from_slice(&[0u8; 4]); // frame_len placeholder
872            frame.push(message_type);
873            frame.push(encryption);
874            frame.extend_from_slice(&[0u8; 4]); // meta_len placeholder
875            serde_json::to_writer(&mut frame, &packet.body)?;
876            let json_len = frame.len() - 10;
877            frame.push(attachments.len() as u8);
878            for att in attachments {
879                att.write_wire(&mut frame);
880            }
881            let payload_len = frame.len() - 6;
882            if payload_len > max_payload {
883                return Err(ProtocolError::PayloadTooLarge {
884                    actual: payload_len,
885                    max: max_payload,
886                });
887            }
888            let frame_len = (frame.len() - 4) as u32;
889            frame[0..4].copy_from_slice(&frame_len.to_be_bytes());
890            frame[6..10].copy_from_slice(&(json_len as u32).to_be_bytes());
891            return Ok(frame);
892        }
893
894        // Encrypted path: build the composite payload (meta_len | json |
895        // att_count | atts) writing JSON directly, encrypt it, then wrap with
896        // the frame header.
897        let mut composite = Vec::with_capacity(4 + 1 + att_size + 64);
898        composite.extend_from_slice(&[0u8; 4]); // meta_len placeholder
899        serde_json::to_writer(&mut composite, &packet.body)?;
900        let json_len = composite.len() - 4;
901        composite[0..4].copy_from_slice(&(json_len as u32).to_be_bytes());
902        composite.push(attachments.len() as u8);
903        for att in attachments {
904            att.write_wire(&mut composite);
905        }
906
907        // Pre-check size before encryption.
908        if composite.len() > max_payload {
909            return Err(ProtocolError::PayloadTooLarge {
910                actual: composite.len(),
911                max: max_payload,
912            });
913        }
914
915        let payload = match packet.encryption {
916            EncryptionKind::ChaCha20 => self.encrypt_chacha20(&composite)?,
917            EncryptionKind::Aes256 => self.encrypt_aes256(&composite)?,
918            EncryptionKind::None => unreachable!("plaintext is handled by the fast path above"),
919        };
920
921        // Post-encryption size check (nonce + tag add overhead).
922        if payload.len() > max_payload {
923            return Err(ProtocolError::PayloadTooLarge {
924                actual: payload.len(),
925                max: max_payload,
926            });
927        }
928
929        let frame_len = 2 + payload.len();
930        let mut frame = Vec::with_capacity(4 + frame_len);
931        frame.extend_from_slice(&(frame_len as u32).to_be_bytes());
932        frame.push(message_type);
933        frame.push(encryption);
934        frame.extend_from_slice(&payload);
935        Ok(frame)
936    }
937
938    /// Decodes a binary WSCALL frame back into an envelope (protocol v2 composite format).
939    ///
940    /// Accepts [`Bytes`] so that plaintext attachment payloads can be extracted
941    /// via zero-copy slicing of the original buffer (no intermediate `Vec`).
942    pub fn decode(&self, frame: Bytes) -> Result<PacketEnvelope, ProtocolError> {
943        if frame.len() < 6 {
944            return Err(ProtocolError::FrameTooShort);
945        }
946
947        let declared = u32::from_be_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize;
948        let actual = frame.len() - 4;
949        if declared != actual {
950            return Err(ProtocolError::FrameLengthMismatch { declared, actual });
951        }
952
953        if frame.len() > self.max_frame_bytes {
954            return Err(ProtocolError::FrameTooLarge {
955                actual: frame.len(),
956                max: self.max_frame_bytes,
957            });
958        }
959
960        let message_type = MessageType::try_from(frame[4])?;
961        let encryption = EncryptionKind::try_from(frame[5])?;
962
963        // Obtain the composite payload as `Bytes`. In the plaintext path this
964        // is a zero-copy slice of the original frame buffer; in the encrypted
965        // path the decrypted `Vec` is wrapped into a fresh `Bytes`.
966        let composite: Bytes = match encryption {
967            EncryptionKind::None => frame.slice(6..),
968            EncryptionKind::ChaCha20 => Bytes::from(self.decrypt_chacha20(&frame[6..])?),
969            EncryptionKind::Aes256 => Bytes::from(self.decrypt_aes256(&frame[6..])?),
970        };
971
972        // Parse composite: meta_len:u32 | JSON | att_count:u8 | [att sections]
973        if composite.len() < 5 {
974            return Err(ProtocolError::FrameTooShort);
975        }
976        let meta_len =
977            u32::from_be_bytes([composite[0], composite[1], composite[2], composite[3]]) as usize;
978        if composite.len() < 4 + meta_len + 1 {
979            return Err(ProtocolError::FrameTooShort);
980        }
981        let json_slice = &composite[4..4 + meta_len];
982        let att_count = composite[4 + meta_len] as usize;
983        let mut att_data = composite.slice(4 + meta_len + 1..);
984
985        // Parse binary attachment sections (zero-copy via Bytes::slice).
986        let mut attachments = Vec::with_capacity(att_count);
987        for _ in 0..att_count {
988            let (att, rest) = FileAttachment::read_wire(&att_data)?;
989            attachments.push(att);
990            att_data = rest;
991        }
992
993        // Parse JSON body and inject attachments.
994        let mut body: PacketBody = serde_json::from_slice(json_slice)?;
995        body.set_attachments(attachments);
996
997        if body.message_type() != message_type {
998            return Err(ProtocolError::MessageTypeMismatch);
999        }
1000
1001        Ok(PacketEnvelope {
1002            message_type,
1003            encryption,
1004            body,
1005        })
1006    }
1007
1008    fn encrypt_chacha20(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
1009        let cipher = self
1010            .chacha20_cipher
1011            .as_ref()
1012            .ok_or(ProtocolError::MissingEncryptionKey("chacha20"))?;
1013        let mut nonce_bytes = [0_u8; CHACHA20_NONCE_LEN];
1014        getrandom(&mut nonce_bytes).map_err(|source| ProtocolError::Random(source.to_string()))?;
1015        let ciphertext = cipher
1016            .encrypt(Nonce::from_slice(&nonce_bytes), payload)
1017            .map_err(|_| ProtocolError::EncryptionFailed("chacha20"))?;
1018
1019        let mut encoded = Vec::with_capacity(CHACHA20_NONCE_LEN + ciphertext.len());
1020        encoded.extend_from_slice(&nonce_bytes);
1021        encoded.extend_from_slice(&ciphertext);
1022        Ok(encoded)
1023    }
1024
1025    fn decrypt_chacha20(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
1026        if payload.len() < CHACHA20_NONCE_LEN {
1027            return Err(ProtocolError::EncryptedPayloadTooShort {
1028                algorithm: "chacha20",
1029                expected_min: CHACHA20_NONCE_LEN,
1030                actual: payload.len(),
1031            });
1032        }
1033
1034        let cipher = self
1035            .chacha20_cipher
1036            .as_ref()
1037            .ok_or(ProtocolError::MissingEncryptionKey("chacha20"))?;
1038        let (nonce_bytes, ciphertext) = payload.split_at(CHACHA20_NONCE_LEN);
1039        cipher
1040            .decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
1041            .map_err(|_| ProtocolError::DecryptionFailed("chacha20"))
1042    }
1043
1044    fn encrypt_aes256(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
1045        let cipher = self
1046            .aes256_cipher
1047            .as_ref()
1048            .ok_or(ProtocolError::MissingEncryptionKey("aes256"))?;
1049        let mut nonce_bytes = [0_u8; AES256_NONCE_LEN];
1050        getrandom(&mut nonce_bytes).map_err(|source| ProtocolError::Random(source.to_string()))?;
1051        let ciphertext = cipher
1052            .encrypt(AesNonce::from_slice(&nonce_bytes), payload)
1053            .map_err(|_| ProtocolError::EncryptionFailed("aes256"))?;
1054
1055        let mut encoded = Vec::with_capacity(AES256_NONCE_LEN + ciphertext.len());
1056        encoded.extend_from_slice(&nonce_bytes);
1057        encoded.extend_from_slice(&ciphertext);
1058        Ok(encoded)
1059    }
1060
1061    fn decrypt_aes256(&self, payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
1062        if payload.len() < AES256_NONCE_LEN {
1063            return Err(ProtocolError::EncryptedPayloadTooShort {
1064                algorithm: "aes256",
1065                expected_min: AES256_NONCE_LEN,
1066                actual: payload.len(),
1067            });
1068        }
1069
1070        let cipher = self
1071            .aes256_cipher
1072            .as_ref()
1073            .ok_or(ProtocolError::MissingEncryptionKey("aes256"))?;
1074        let (nonce_bytes, ciphertext) = payload.split_at(AES256_NONCE_LEN);
1075        cipher
1076            .decrypt(AesNonce::from_slice(nonce_bytes), ciphertext)
1077            .map_err(|_| ProtocolError::DecryptionFailed("aes256"))
1078    }
1079}
1080
1081/// Helper for encoding a plaintext frame without constructing a custom codec.
1082pub fn encode_frame(packet: &PacketEnvelope) -> Result<Vec<u8>, ProtocolError> {
1083    FrameCodec::plaintext().encode(packet)
1084}
1085
1086/// Helper for decoding a plaintext frame without constructing a custom codec.
1087pub fn decode_frame(frame: Bytes) -> Result<PacketEnvelope, ProtocolError> {
1088    FrameCodec::plaintext().decode(frame)
1089}
1090
1091/// Errors returned while encoding or decoding WSCALL frames.
1092#[derive(Debug, Error)]
1093pub enum ProtocolError {
1094    #[error("frame too short")]
1095    FrameTooShort,
1096    #[error("frame length mismatch: declared={declared}, actual={actual}")]
1097    FrameLengthMismatch { declared: usize, actual: usize },
1098    #[error("payload too large: actual={actual}, max={max}")]
1099    PayloadTooLarge { actual: usize, max: usize },
1100    #[error("frame too large: actual={actual}, max={max}")]
1101    FrameTooLarge { actual: usize, max: usize },
1102    #[error("unknown message type: {0:#x}")]
1103    UnknownMessageType(u8),
1104    #[error("unknown encryption kind: {0:#x}")]
1105    UnknownEncryption(u8),
1106    #[error("unsupported encryption kind: {0:#x}")]
1107    UnsupportedEncryption(u8),
1108    #[error("missing encryption key for {0}")]
1109    MissingEncryptionKey(&'static str),
1110    #[error("invalid encryption key for {0}")]
1111    InvalidEncryptionKey(&'static str),
1112    #[error("secure random generation failed: {0}")]
1113    Random(String),
1114    #[error(
1115        "encrypted payload too short for {algorithm}: expected at least {expected_min}, actual={actual}"
1116    )]
1117    EncryptedPayloadTooShort {
1118        algorithm: &'static str,
1119        expected_min: usize,
1120        actual: usize,
1121    },
1122    #[error("encryption failed for {0}")]
1123    EncryptionFailed(&'static str),
1124    #[error("decryption failed for {0}")]
1125    DecryptionFailed(&'static str),
1126    #[error("invalid ECDH public key: expected {expected} bytes, got {actual}")]
1127    InvalidEcdhPublicKey { expected: usize, actual: usize },
1128    #[error("ECDH handshake failed: {0}")]
1129    EcdhHandshake(String),
1130    #[error("message type does not match packet body")]
1131    MessageTypeMismatch,
1132    #[error("invalid attachment encoding: {0}")]
1133    InvalidAttachmentEncoding(String),
1134    #[error("json error: {0}")]
1135    Json(#[from] serde_json::Error),
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::{
1141        Bytes, EncryptionKind, FileAttachment, FrameCodec, PacketBody, PacketEnvelope,
1142        ProtocolError, decode_frame, encode_frame,
1143    };
1144    use serde_json::json;
1145
1146    const TEST_KEY: [u8; 32] = [0x11; 32];
1147
1148    #[test]
1149    fn plaintext_helpers_still_work() {
1150        let packet = PacketEnvelope::new(PacketBody::EventAck {
1151            event_id: 1,
1152            ok: true,
1153            receipt: json!({ "ok": true }),
1154            error: None,
1155        });
1156
1157        let encoded = encode_frame(&packet).expect("encode plaintext");
1158        let decoded = decode_frame(Bytes::from(encoded)).expect("decode plaintext");
1159        assert!(matches!(decoded.encryption, EncryptionKind::None));
1160    }
1161
1162    #[test]
1163    fn aes256_roundtrip_works() {
1164        let codec = FrameCodec::plaintext().with_aes256_key(TEST_KEY);
1165        let packet = PacketEnvelope::with_encryption(
1166            PacketBody::ApiResponse {
1167                request_id: 1,
1168                ok: true,
1169                status: 200,
1170                data: json!({ "message": "encrypted" }),
1171                error: None,
1172                metadata: json!({}),
1173            },
1174            EncryptionKind::Aes256,
1175        );
1176
1177        let encoded = codec.encode(&packet).expect("encode aes256");
1178        let decoded = codec.decode(Bytes::from(encoded)).expect("decode aes256");
1179        assert!(matches!(decoded.encryption, EncryptionKind::Aes256));
1180    }
1181
1182    #[test]
1183    fn attachment_roundtrip_plaintext() {
1184        let att = FileAttachment::inline_bytes(
1185            "f1",
1186            "test.bin",
1187            "application/octet-stream",
1188            vec![1, 2, 3, 4, 5],
1189        );
1190        let packet = PacketEnvelope::new(PacketBody::ApiRequest {
1191            request_id: 42,
1192            route: "files.upload".to_string(),
1193            params: json!({ "file": { "$file": "f1" } }),
1194            attachments: vec![att],
1195            metadata: json!({}),
1196        });
1197
1198        let encoded = encode_frame(&packet).expect("encode with attachment");
1199        let decoded = decode_frame(Bytes::from(encoded)).expect("decode with attachment");
1200
1201        let atts = decoded.body.attachments();
1202        assert_eq!(atts.len(), 1);
1203        assert_eq!(atts[0].id, "f1");
1204        assert_eq!(atts[0].name, "test.bin");
1205        assert_eq!(atts[0].content_type, "application/octet-stream");
1206        assert_eq!(&atts[0].data[..], &[1, 2, 3, 4, 5]);
1207    }
1208
1209    #[test]
1210    fn attachment_roundtrip_encrypted() {
1211        let codec = FrameCodec::plaintext().with_chacha20_key(TEST_KEY);
1212        let att = FileAttachment::inline_text("f2", "hello.txt", "text/plain", "hello world");
1213        let packet = PacketEnvelope::with_encryption(
1214            PacketBody::EventEmit {
1215                event_id: 7,
1216                name: "chat.message".to_string(),
1217                data: json!({ "text": "see attached" })
1218                    .as_object()
1219                    .unwrap()
1220                    .clone(),
1221                attachments: vec![att],
1222                metadata: json!({}),
1223                expect_ack: true,
1224            },
1225            EncryptionKind::ChaCha20,
1226        );
1227
1228        let encoded = codec
1229            .encode(&packet)
1230            .expect("encode encrypted with attachment");
1231        let decoded = codec
1232            .decode(Bytes::from(encoded))
1233            .expect("decode encrypted with attachment");
1234
1235        let atts = decoded.body.attachments();
1236        assert_eq!(atts.len(), 1);
1237        assert_eq!(atts[0].id, "f2");
1238        assert_eq!(&atts[0].data[..], b"hello world");
1239    }
1240
1241    #[test]
1242    fn encode_rejects_payloads_over_limit() {
1243        // Use a small max to trigger the limit without allocating 100 MiB.
1244        let codec = FrameCodec::plaintext().with_max_frame_bytes(1024);
1245        let packet = PacketEnvelope::new(PacketBody::ApiResponse {
1246            request_id: 999,
1247            ok: true,
1248            status: 200,
1249            data: json!({ "blob": "a".repeat(2048) }),
1250            error: None,
1251            metadata: json!({}),
1252        });
1253
1254        let error = codec
1255            .encode(&packet)
1256            .expect_err("oversized payload should fail");
1257        assert!(matches!(error, ProtocolError::PayloadTooLarge { .. }));
1258    }
1259
1260    #[test]
1261    fn decode_rejects_frames_over_limit() {
1262        let codec = FrameCodec::plaintext().with_max_frame_bytes(64);
1263        // Build a valid frame that exceeds 64 bytes total.
1264        let packet = PacketEnvelope::new(PacketBody::ApiResponse {
1265            request_id: 1,
1266            ok: true,
1267            status: 200,
1268            data: json!({ "msg": "a]".repeat(50) }),
1269            error: None,
1270            metadata: json!({}),
1271        });
1272        let encoded = FrameCodec::plaintext()
1273            .encode(&packet)
1274            .expect("encode with default limit");
1275        assert!(encoded.len() > 64);
1276
1277        let error = codec
1278            .decode(Bytes::from(encoded))
1279            .expect_err("oversized frame should fail");
1280        assert!(matches!(error, ProtocolError::FrameTooLarge { .. }));
1281    }
1282}