Skip to main content

omni_dev/browser/
protocol.rs

1//! Wire-protocol types for the browser bridge.
2//!
3//! Three layers share these structs. The **control plane** accepts a
4//! [`ControlRequest`] body on `POST /__bridge/request` (and synthesises one for
5//! the transparent proxy). The **WebSocket plane** serialises a [`Command`] to
6//! the browser and deserialises a [`BrowserReply`] back. The control plane then
7//! returns a [`ResponseEnvelope`] to the caller. Every frame is newline-free
8//! JSON correlated by a monotonic integer `id` assigned by the server; the
9//! browser echoes it back unchanged.
10
11use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15/// A request as supplied by a control-plane caller (the `POST /__bridge/request`
16/// body, or synthesised from the path/method/headers of a transparent-proxy
17/// request).
18///
19/// `url` is resolved against the page origin by the browser snippet; absolute
20/// URLs are rejected by the server unless an `--allow-origin` permits them.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct ControlRequest {
23    /// Request URL. Relative (page-origin) by default.
24    pub url: String,
25    /// HTTP method. Defaults to `GET` when omitted.
26    #[serde(default = "default_method")]
27    pub method: String,
28    /// Request headers. Forwarded verbatim to the browser `fetch()`.
29    #[serde(default)]
30    pub headers: BTreeMap<String, String>,
31    /// Request body, or `null` for no body.
32    #[serde(default)]
33    pub body: Option<String>,
34    /// When `true`, the response is streamed back as incremental chunk frames
35    /// (`response.body.getReader()`) rather than buffered into one reply.
36    #[serde(default)]
37    pub stream: bool,
38    /// Which connected tab to route this request to: a connection id (the
39    /// canonical, always-unambiguous selector) or an `Origin` string that
40    /// uniquely matches one tab. Omitted is allowed only when exactly one tab is
41    /// connected. The `X-Omni-Bridge-Target` header takes precedence over this
42    /// field. Server-side routing only — never sent to the browser.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub target: Option<String>,
45    /// Request-scoped outbound-origin override. When present it takes
46    /// precedence over `serve --allow-origin` for *this request's*
47    /// [`crate::browser::auth::validate_outbound_url`] check only, letting one
48    /// request target a cross-origin URL without affecting the connection-time
49    /// `ws_origin_allowed` gate. Server-side scope check only — never sent to
50    /// the browser.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub allow_origin: Option<String>,
53    /// Fetch credentials mode (`include` | `omit` | `same-origin`). Absent means
54    /// the browser snippet defaults to `include`, preserving v1 behavior.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub credentials: Option<String>,
57}
58
59fn default_method() -> String {
60    "GET".to_string()
61}
62
63/// Serde predicate: skip a `bool` field when it is `false` (the default), so
64/// buffered command frames stay byte-identical to the pre-streaming wire format.
65// `skip_serializing_if` requires `fn(&T) -> bool`, so the `&bool` is mandatory.
66#[allow(clippy::trivially_copy_pass_by_ref)]
67fn is_false(b: &bool) -> bool {
68    !*b
69}
70
71/// Server → browser command frame.
72///
73/// Identical shape to [`ControlRequest`] plus the server-assigned `id`.
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75pub struct Command {
76    /// Server-assigned correlation id; echoed back by the browser.
77    pub id: u64,
78    /// Request URL (already scope-validated by the server).
79    pub url: String,
80    /// HTTP method.
81    pub method: String,
82    /// Request headers.
83    pub headers: BTreeMap<String, String>,
84    /// Request body, or `null`.
85    pub body: Option<String>,
86    /// When `true`, the browser streams the response as chunk frames. Omitted
87    /// from the wire when `false` for back-compat with buffered clients.
88    #[serde(default, skip_serializing_if = "is_false")]
89    pub stream: bool,
90    /// Fetch credentials mode (`include` | `omit` | `same-origin`), or `null`
91    /// to let the browser snippet default to `include`.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub credentials: Option<String>,
94}
95
96/// Server → browser cancellation frame.
97///
98/// Sent to stop an in-flight streamed response (the control-plane consumer
99/// disconnected, or a limit tripped) so the browser cancels its reader.
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101pub struct CancelCommand {
102    /// Correlation id of the stream to cancel.
103    pub id: u64,
104    /// Always `true`; distinguishes this frame from a [`Command`] in the browser.
105    pub cancel: bool,
106}
107
108impl CancelCommand {
109    /// Builds a cancellation frame for the given stream id.
110    #[must_use]
111    pub fn new(id: u64) -> Self {
112        Self { id, cancel: true }
113    }
114}
115
116/// Browser → server reply frame.
117///
118/// Either a success (`status`/`headers`/`body` present) or an error
119/// (`error` present). Modelled as a flat struct of `Option`s so a single
120/// `serde` deserialise accepts both shapes; [`BrowserReply::outcome`]
121/// classifies it.
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123pub struct BrowserReply {
124    /// Correlation id echoed from the [`Command`].
125    pub id: u64,
126    /// HTTP status code on success.
127    #[serde(skip_serializing_if = "Option::is_none", default)]
128    pub status: Option<u16>,
129    /// Response headers on success.
130    #[serde(skip_serializing_if = "Option::is_none", default)]
131    pub headers: Option<BTreeMap<String, String>>,
132    /// Response body on success. Plain text unless [`Self::encoding`] tags it.
133    #[serde(skip_serializing_if = "Option::is_none", default)]
134    pub body: Option<String>,
135    /// Body transfer encoding. `Some("base64")` when the browser read a
136    /// non-text body via `arrayBuffer()` and base64-encoded it; absent (the
137    /// default) means `body` is plain text, for back-compat with v1.
138    #[serde(skip_serializing_if = "Option::is_none", default)]
139    pub encoding: Option<String>,
140    /// Error message when the browser `fetch()` failed.
141    #[serde(skip_serializing_if = "Option::is_none", default)]
142    pub error: Option<String>,
143}
144
145/// Classified outcome of a [`BrowserReply`].
146pub enum ReplyOutcome {
147    /// A successful response with status, headers, and body.
148    Success {
149        /// HTTP status code.
150        status: u16,
151        /// Response headers.
152        headers: BTreeMap<String, String>,
153        /// Response body (base64-encoded when `encoding` is `Some("base64")`).
154        body: String,
155        /// Body transfer encoding (`Some("base64")` for binary bodies).
156        encoding: Option<String>,
157    },
158    /// The browser reported a `fetch()` failure.
159    Error(String),
160}
161
162impl BrowserReply {
163    /// Classifies this reply as success or error.
164    ///
165    /// A reply carrying an `error` is an error; otherwise the success fields
166    /// are taken with sensible defaults for any that the browser omitted.
167    pub fn outcome(self) -> ReplyOutcome {
168        match self.error {
169            Some(error) => ReplyOutcome::Error(error),
170            None => ReplyOutcome::Success {
171                status: self.status.unwrap_or(0),
172                headers: self.headers.unwrap_or_default(),
173                body: self.body.unwrap_or_default(),
174                encoding: self.encoding,
175            },
176        }
177    }
178}
179
180/// Browser → server frame: a superset of [`BrowserReply`] plus the streaming
181/// fields (`stream`/`chunk`/`seq`/`done`).
182///
183/// The WebSocket reader deserialises every inbound frame into this struct, then
184/// interprets it as a buffered reply or a [`StreamItem`] depending on which
185/// waiter is registered for its `id`. Modelled as a flat struct of `Option`s so
186/// one `serde` deserialise accepts every shape.
187#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
188pub struct BrowserFrame {
189    /// Correlation id echoed from the [`Command`].
190    pub id: u64,
191    /// HTTP status code (buffered success, or a stream's head frame).
192    #[serde(skip_serializing_if = "Option::is_none", default)]
193    pub status: Option<u16>,
194    /// Response headers (buffered success, or a stream's head frame).
195    #[serde(skip_serializing_if = "Option::is_none", default)]
196    pub headers: Option<BTreeMap<String, String>>,
197    /// Buffered response body.
198    #[serde(skip_serializing_if = "Option::is_none", default)]
199    pub body: Option<String>,
200    /// Buffered body transfer encoding (`Some("base64")` for binary bodies).
201    #[serde(skip_serializing_if = "Option::is_none", default)]
202    pub encoding: Option<String>,
203    /// Error message when the browser `fetch()` failed.
204    #[serde(skip_serializing_if = "Option::is_none", default)]
205    pub error: Option<String>,
206    /// `Some(true)` on the first frame of a streamed response (head: status +
207    /// headers, no body yet).
208    #[serde(skip_serializing_if = "Option::is_none", default)]
209    pub stream: Option<bool>,
210    /// Base64-encoded body chunk of a streamed response.
211    #[serde(skip_serializing_if = "Option::is_none", default)]
212    pub chunk: Option<String>,
213    /// Monotonic chunk sequence number within a stream.
214    #[serde(skip_serializing_if = "Option::is_none", default)]
215    pub seq: Option<u64>,
216    /// `Some(true)` on the terminating frame of a streamed response.
217    #[serde(skip_serializing_if = "Option::is_none", default)]
218    pub done: Option<bool>,
219}
220
221/// One item of a streamed browser response, derived from a [`BrowserFrame`] when
222/// the registered waiter is a stream.
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub enum StreamItem {
225    /// Stream head: status and headers, before any body bytes.
226    Head {
227        /// HTTP status code.
228        status: u16,
229        /// Response headers.
230        headers: BTreeMap<String, String>,
231    },
232    /// A base64-encoded body chunk.
233    Chunk {
234        /// Monotonic chunk sequence number.
235        seq: u64,
236        /// Base64-encoded chunk bytes.
237        data: String,
238    },
239    /// The stream terminated normally.
240    End,
241    /// The browser reported an error (before or mid-stream).
242    Error(String),
243}
244
245impl BrowserFrame {
246    /// Interprets this frame as a buffered reply (the back-compat path taken
247    /// when the registered waiter is a one-shot buffered request).
248    #[must_use]
249    pub fn into_reply(self) -> BrowserReply {
250        BrowserReply {
251            id: self.id,
252            status: self.status,
253            headers: self.headers,
254            body: self.body,
255            encoding: self.encoding,
256            error: self.error,
257        }
258    }
259
260    /// Interprets this frame as a [`StreamItem`] (taken when the registered
261    /// waiter is a stream). An `error` wins; then `done`; then a `chunk`;
262    /// otherwise the frame is the stream's head.
263    #[must_use]
264    pub fn stream_item(self) -> StreamItem {
265        if let Some(error) = self.error {
266            StreamItem::Error(error)
267        } else if self.done == Some(true) {
268            StreamItem::End
269        } else if let Some(data) = self.chunk {
270            StreamItem::Chunk {
271                seq: self.seq.unwrap_or(0),
272                data,
273            }
274        } else {
275            StreamItem::Head {
276                status: self.status.unwrap_or(0),
277                headers: self.headers.unwrap_or_default(),
278            }
279        }
280    }
281}
282
283/// One NDJSON line of a streamed `POST /__bridge/request` response.
284///
285/// The server serialises these (one per line); the thin client deserialises
286/// them. Untagged so each line is the bare object the operator sees
287/// (`{status,headers}` / `{seq,chunk}` / `{done}` / `{error}`).
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
289#[serde(untagged)]
290pub enum StreamLine {
291    /// Head line: status and headers.
292    Head {
293        /// HTTP status code.
294        status: u16,
295        /// Response headers.
296        headers: BTreeMap<String, String>,
297    },
298    /// Chunk line: a base64-encoded body chunk.
299    Chunk {
300        /// Monotonic chunk sequence number.
301        seq: u64,
302        /// Base64-encoded chunk bytes.
303        chunk: String,
304    },
305    /// Terminating line.
306    Done {
307        /// Always `true`.
308        done: bool,
309    },
310    /// Error line.
311    Error {
312        /// Error message.
313        error: String,
314    },
315}
316
317/// Control-plane response envelope returned to the caller of
318/// `POST /__bridge/request`.
319#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
320pub struct ResponseEnvelope {
321    /// Correlation id of the request this envelope answers.
322    pub id: u64,
323    /// HTTP status returned by the browser.
324    pub status: u16,
325    /// Response headers returned by the browser.
326    pub headers: BTreeMap<String, String>,
327    /// Response body returned by the browser. Base64-encoded when
328    /// [`Self::encoding`] is `Some("base64")`; the caller decodes it.
329    pub body: String,
330    /// Body transfer encoding. `Some("base64")` for binary bodies; absent (the
331    /// default) means `body` is plain text.
332    #[serde(skip_serializing_if = "Option::is_none", default)]
333    pub encoding: Option<String>,
334}
335
336/// One connected tab, as reported by `GET /__bridge/status`.
337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
338pub struct TabInfo {
339    /// Server-assigned connection id; the canonical routing selector.
340    pub id: u64,
341    /// The connecting tab's `Origin`, if it sent one.
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub origin: Option<String>,
344}
345
346/// `GET /__bridge/status` response body.
347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
348pub struct StatusResponse {
349    /// Whether at least one authenticated browser tab is currently connected.
350    pub connected: bool,
351    /// The connected tab's `Origin` when exactly one tab is connected; `None`
352    /// when zero or several are (ambiguous). Retained for v1 back-compat — use
353    /// [`Self::tabs`] for the full picture.
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub browser_origin: Option<String>,
356    /// Every connected tab (id + origin), for routing with a `target`.
357    pub tabs: Vec<TabInfo>,
358    /// Number of in-flight requests awaiting a browser reply.
359    pub pending: usize,
360}
361
362#[cfg(test)]
363#[allow(clippy::unwrap_used, clippy::expect_used)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn control_request_defaults_method_and_body() {
369        let req: ControlRequest = serde_json::from_str(r#"{"url":"/x"}"#).unwrap();
370        assert_eq!(req.method, "GET");
371        assert!(req.body.is_none());
372        assert!(req.headers.is_empty());
373        // The per-request outbound-origin override defaults to absent.
374        assert!(req.allow_origin.is_none());
375    }
376
377    #[test]
378    fn control_request_omits_allow_origin_when_absent() {
379        let req = ControlRequest {
380            url: "/x".to_string(),
381            method: "GET".to_string(),
382            headers: BTreeMap::new(),
383            body: None,
384            stream: false,
385            target: None,
386            allow_origin: None,
387            credentials: None,
388        };
389        // Back-compat: an absent override is not serialised, so the wire body
390        // stays byte-identical to a pre-feature client's.
391        let json = serde_json::to_string(&req).unwrap();
392        assert!(!json.contains("allow_origin"));
393
394        // A present override round-trips.
395        let with = ControlRequest {
396            allow_origin: Some("https://ok.test".to_string()),
397            ..req
398        };
399        let json = serde_json::to_string(&with).unwrap();
400        let back: ControlRequest = serde_json::from_str(&json).unwrap();
401        assert_eq!(back.allow_origin.as_deref(), Some("https://ok.test"));
402    }
403
404    #[test]
405    fn command_round_trips_and_is_newline_free() {
406        let cmd = Command {
407            id: 7,
408            url: "/loki/api/v1/labels".to_string(),
409            method: "GET".to_string(),
410            headers: BTreeMap::new(),
411            body: None,
412            stream: false,
413            credentials: None,
414        };
415        let json = serde_json::to_string(&cmd).unwrap();
416        assert!(!json.contains('\n'));
417        // A buffered command omits `stream` entirely (wire back-compat).
418        assert!(!json.contains("stream"));
419        let back: Command = serde_json::from_str(&json).unwrap();
420        assert_eq!(cmd, back);
421    }
422
423    #[test]
424    fn streaming_command_serialises_stream_flag() {
425        let cmd = Command {
426            id: 1,
427            url: "/sse".to_string(),
428            method: "GET".to_string(),
429            headers: BTreeMap::new(),
430            body: None,
431            stream: true,
432            credentials: None,
433        };
434        let json = serde_json::to_string(&cmd).unwrap();
435        assert!(json.contains("\"stream\":true"));
436    }
437
438    #[test]
439    fn cancel_command_serialises_with_cancel_true() {
440        let json = serde_json::to_string(&CancelCommand::new(9)).unwrap();
441        assert_eq!(json, r#"{"id":9,"cancel":true}"#);
442    }
443
444    #[test]
445    fn frame_classifies_stream_head_chunk_and_end() {
446        let head: BrowserFrame =
447            serde_json::from_str(r#"{"id":1,"status":200,"headers":{"a":"b"},"stream":true}"#)
448                .unwrap();
449        assert!(matches!(
450            head.stream_item(),
451            StreamItem::Head { status: 200, headers } if headers.get("a").map(String::as_str) == Some("b")
452        ));
453
454        let chunk: BrowserFrame =
455            serde_json::from_str(r#"{"id":1,"seq":3,"chunk":"aGk="}"#).unwrap();
456        assert!(matches!(
457            chunk.stream_item(),
458            StreamItem::Chunk { seq: 3, data } if data == "aGk="
459        ));
460
461        let end: BrowserFrame = serde_json::from_str(r#"{"id":1,"done":true}"#).unwrap();
462        assert_eq!(end.stream_item(), StreamItem::End);
463
464        let err: BrowserFrame = serde_json::from_str(r#"{"id":1,"error":"boom"}"#).unwrap();
465        assert_eq!(err.stream_item(), StreamItem::Error("boom".into()));
466    }
467
468    #[test]
469    fn frame_into_reply_preserves_buffered_fields() {
470        let frame: BrowserFrame = serde_json::from_str(
471            r#"{"id":2,"status":200,"headers":{},"body":"hi","encoding":"base64"}"#,
472        )
473        .unwrap();
474        let reply = frame.into_reply();
475        assert_eq!(reply.id, 2);
476        assert!(matches!(
477            reply.outcome(),
478            ReplyOutcome::Success { body, encoding, .. }
479                if body == "hi" && encoding.as_deref() == Some("base64")
480        ));
481    }
482
483    #[test]
484    fn stream_lines_round_trip_untagged() {
485        for (line, json) in [
486            (
487                StreamLine::Head {
488                    status: 200,
489                    headers: BTreeMap::new(),
490                },
491                r#"{"status":200,"headers":{}}"#,
492            ),
493            (
494                StreamLine::Chunk {
495                    seq: 0,
496                    chunk: "aGk=".into(),
497                },
498                r#"{"seq":0,"chunk":"aGk="}"#,
499            ),
500            (StreamLine::Done { done: true }, r#"{"done":true}"#),
501            (
502                StreamLine::Error {
503                    error: "boom".into(),
504                },
505                r#"{"error":"boom"}"#,
506            ),
507        ] {
508            let serialised = serde_json::to_string(&line).unwrap();
509            assert_eq!(serialised, json);
510            assert!(!serialised.contains('\n'));
511            let back: StreamLine = serde_json::from_str(json).unwrap();
512            assert_eq!(back, line);
513        }
514    }
515
516    #[test]
517    fn control_request_defaults_credentials_to_none() {
518        let req: ControlRequest = serde_json::from_str(r#"{"url":"/x"}"#).unwrap();
519        assert!(req.credentials.is_none());
520    }
521
522    #[test]
523    fn control_request_omits_credentials_when_absent() {
524        let req = ControlRequest {
525            url: "/x".to_string(),
526            method: "GET".to_string(),
527            headers: BTreeMap::new(),
528            body: None,
529            stream: false,
530            target: None,
531            allow_origin: None,
532            credentials: None,
533        };
534        let json = serde_json::to_string(&req).unwrap();
535        assert!(!json.contains("credentials"));
536    }
537
538    #[test]
539    fn command_serializes_credentials_when_present() {
540        let cmd = Command {
541            id: 1,
542            url: "/x".to_string(),
543            method: "GET".to_string(),
544            headers: BTreeMap::new(),
545            body: None,
546            stream: false,
547            credentials: Some("omit".to_string()),
548        };
549        let json = serde_json::to_string(&cmd).unwrap();
550        assert!(json.contains(r#""credentials":"omit""#));
551        let back: Command = serde_json::from_str(&json).unwrap();
552        assert_eq!(cmd, back);
553    }
554
555    #[test]
556    fn success_reply_classifies_as_success() {
557        let reply: BrowserReply =
558            serde_json::from_str(r#"{"id":7,"status":200,"headers":{"a":"b"},"body":"hi"}"#)
559                .unwrap();
560        assert_eq!(reply.id, 7);
561        // `matches!` keeps the assertion to one expression so there is no
562        // never-taken `panic!` arm to register as an uncovered line.
563        assert!(
564            matches!(reply.outcome(),
565                ReplyOutcome::Success { status, headers, body, encoding }
566                    if status == 200
567                        && headers.get("a").map(String::as_str) == Some("b")
568                        && body == "hi"
569                        && encoding.is_none()),
570            "success reply must classify as Success with the expected fields"
571        );
572    }
573
574    #[test]
575    fn base64_reply_carries_encoding_through_outcome() {
576        let reply: BrowserReply = serde_json::from_str(
577            r#"{"id":7,"status":200,"headers":{},"body":"iVBOR=","encoding":"base64"}"#,
578        )
579        .unwrap();
580        // `matches!` keeps the whole assertion on one expression so there is no
581        // never-taken `panic!` arm to register as an uncovered line.
582        assert!(
583            matches!(reply.outcome(),
584                ReplyOutcome::Success { body, encoding, .. }
585                    if body == "iVBOR=" && encoding.as_deref() == Some("base64")),
586            "base64 reply must classify as Success with its encoding preserved"
587        );
588    }
589
590    #[test]
591    fn text_reply_omits_encoding_on_serialise() {
592        let reply = BrowserReply {
593            id: 1,
594            status: Some(200),
595            headers: None,
596            body: Some("hi".into()),
597            encoding: None,
598            error: None,
599        };
600        let json = serde_json::to_string(&reply).unwrap();
601        assert!(!json.contains("encoding"));
602    }
603
604    #[test]
605    fn envelope_omits_encoding_when_text() {
606        let env = ResponseEnvelope {
607            id: 1,
608            status: 200,
609            headers: BTreeMap::new(),
610            body: "hi".into(),
611            encoding: None,
612        };
613        let json = serde_json::to_string(&env).unwrap();
614        assert!(!json.contains("encoding"));
615    }
616
617    #[test]
618    fn error_reply_classifies_as_error() {
619        let reply: BrowserReply =
620            serde_json::from_str(r#"{"id":7,"error":"Failed to fetch"}"#).unwrap();
621        match reply.outcome() {
622            ReplyOutcome::Error(msg) => assert_eq!(msg, "Failed to fetch"),
623            ReplyOutcome::Success { .. } => panic!("expected error"),
624        }
625    }
626
627    #[test]
628    fn status_response_omits_origin_when_absent() {
629        let s = StatusResponse {
630            connected: false,
631            browser_origin: None,
632            tabs: Vec::new(),
633            pending: 0,
634        };
635        let json = serde_json::to_string(&s).unwrap();
636        assert!(!json.contains("browser_origin"));
637    }
638}