Skip to main content

omni_dev/browser/
bridge.rs

1//! The long-lived bridge server.
2//!
3//! A WebSocket plane the browser connects to and an HTTP control plane the
4//! operator drives, joined by an `id`-keyed correlator.
5//!
6//! A request flows: control plane (authenticated) → assign `id` + register a
7//! `oneshot` waiter → serialise a [`Command`] frame → WebSocket → browser
8//! `fetch()` → [`BrowserReply`] frame → correlator resolves the waiter by `id`
9//! → control plane returns the HTTP response.
10
11use std::collections::{BTreeMap, HashMap};
12use std::net::Ipv4Addr;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Arc, Mutex as StdMutex};
15use std::time::Duration;
16
17use anyhow::{Context, Result};
18use axum::{
19    body::{Body, Bytes},
20    extract::{DefaultBodyLimit, Request, State},
21    http::{header, StatusCode},
22    middleware::{self, Next},
23    response::{IntoResponse, Response},
24    routing::{get, post},
25    Json, Router,
26};
27use base64::engine::general_purpose::STANDARD as BASE64;
28use base64::Engine as _;
29use futures::{SinkExt, StreamExt};
30use tokio::net::{TcpListener, TcpStream};
31use tokio::sync::{mpsc, oneshot, Mutex, OwnedSemaphorePermit, Semaphore};
32use tokio_tungstenite::tungstenite::Message;
33
34use crate::browser::auth;
35use crate::browser::protocol::{
36    BrowserFrame, BrowserReply, CancelCommand, Command, ControlRequest, ReplyOutcome,
37    ResponseEnvelope, StatusResponse, StreamItem, StreamLine, TabInfo,
38};
39use crate::browser::snippet;
40
41/// Resolved runtime configuration for a bridge instance.
42#[derive(Debug, Clone)]
43pub struct BridgeConfig {
44    /// WebSocket-plane port (`0` binds an OS-assigned free port).
45    pub ws_port: u16,
46    /// HTTP control-plane port (`0` binds an OS-assigned free port).
47    pub control_port: u16,
48    /// Per-request timeout before the control plane returns `504`.
49    pub request_timeout: Duration,
50    /// Optional cross-origin allowlist for both the WS upgrade and outbound URLs.
51    pub allow_origin: Option<String>,
52    /// Maximum browser response body size accepted, in bytes.
53    pub max_body_bytes: usize,
54    /// Maximum number of concurrent in-flight requests.
55    pub max_concurrent: usize,
56}
57
58/// A registered waiter for a given id: either a buffered one-shot (resolved by a
59/// single reply) or a stream (fed many [`StreamItem`]s until `End`/`Error`).
60enum Waiter {
61    /// Buffered request: one reply resolves it.
62    Buffered(oneshot::Sender<BrowserReply>),
63    /// Streamed request: head + chunk + terminator items are forwarded here.
64    Stream(mpsc::UnboundedSender<StreamItem>),
65}
66
67/// `id → waiter` registry plus the monotonic id counter.
68#[derive(Clone)]
69struct Correlator {
70    pending: Arc<StdMutex<HashMap<u64, Waiter>>>,
71    next_id: Arc<AtomicU64>,
72}
73
74impl Correlator {
75    fn new() -> Self {
76        Self {
77            pending: Arc::new(StdMutex::new(HashMap::new())),
78            next_id: Arc::new(AtomicU64::new(1)),
79        }
80    }
81
82    /// Allocates an id and registers a buffered waiter for its single reply.
83    fn register(&self) -> (u64, oneshot::Receiver<BrowserReply>) {
84        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
85        let (tx, rx) = oneshot::channel();
86        self.lock().insert(id, Waiter::Buffered(tx));
87        (id, rx)
88    }
89
90    /// Allocates an id and registers a stream waiter that receives every
91    /// [`StreamItem`] of the response until `End`/`Error`.
92    fn register_stream(&self) -> (u64, mpsc::UnboundedReceiver<StreamItem>) {
93        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
94        let (tx, rx) = mpsc::unbounded_channel();
95        self.lock().insert(id, Waiter::Stream(tx));
96        (id, rx)
97    }
98
99    /// Drops a waiter without resolving it (timeout / send failure cleanup).
100    fn remove(&self, id: u64) {
101        self.lock().remove(&id);
102    }
103
104    /// Routes an inbound browser frame to its waiter.
105    ///
106    /// Buffered waiters resolve and are removed; stream waiters receive one
107    /// [`StreamItem`] and are removed only on a terminal item (or when their
108    /// consumer has gone). Returns `Some(id)` when the browser should be told to
109    /// cancel that stream (its control-plane consumer disconnected).
110    fn deliver(&self, frame: BrowserFrame) -> Option<u64> {
111        let id = frame.id;
112        let mut guard = self.lock();
113        match guard.get(&id) {
114            Some(Waiter::Buffered(_)) => {
115                if let Some(Waiter::Buffered(tx)) = guard.remove(&id) {
116                    let _ = tx.send(frame.into_reply());
117                }
118                None
119            }
120            Some(Waiter::Stream(_)) => {
121                let item = frame.stream_item();
122                let terminal = matches!(item, StreamItem::End | StreamItem::Error(_));
123                let send_failed = match guard.get(&id) {
124                    Some(Waiter::Stream(tx)) => tx.send(item).is_err(),
125                    _ => false,
126                };
127                if terminal || send_failed {
128                    guard.remove(&id);
129                }
130                send_failed.then_some(id)
131            }
132            None => None,
133        }
134    }
135
136    fn pending_count(&self) -> usize {
137        self.lock().len()
138    }
139
140    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<u64, Waiter>> {
141        self.pending
142            .lock()
143            .unwrap_or_else(std::sync::PoisonError::into_inner)
144    }
145}
146
147/// One authenticated browser connection in the registry.
148struct WsConn {
149    /// Outbound message channel to this connection's writer task.
150    sender: mpsc::UnboundedSender<Message>,
151    /// The connecting tab's `Origin`, if it sent one.
152    origin: Option<String>,
153}
154
155/// Shared server state, cloned into every handler and task.
156#[derive(Clone)]
157struct AppState {
158    token: Arc<String>,
159    config: Arc<BridgeConfig>,
160    correlator: Correlator,
161    /// Connected tabs keyed by connection id (the public routing selector). A
162    /// new authenticated connection never displaces an existing one — each lives
163    /// under its own key — so the non-eviction guarantee holds per-connection.
164    tabs: Arc<Mutex<HashMap<u64, WsConn>>>,
165    in_flight: Arc<Semaphore>,
166    conn_counter: Arc<AtomicU64>,
167}
168
169/// Binds both planes (fail-closed) and serves until the process is stopped.
170///
171/// `token` is the already-resolved session token (never sourced from argv).
172pub async fn run(mut config: BridgeConfig, token: String) -> Result<()> {
173    let control_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, config.control_port))
174        .await
175        .with_context(|| {
176            format!(
177                "Failed to bind control plane to 127.0.0.1:{} (already in use?)",
178                config.control_port
179            )
180        })?;
181    let ws_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, config.ws_port))
182        .await
183        .with_context(|| {
184            format!(
185                "Failed to bind WebSocket plane to 127.0.0.1:{} (already in use?)",
186                config.ws_port
187            )
188        })?;
189
190    // Read back the OS-assigned ports so port-0 (random) is reflected
191    // everywhere: the snippet, the printed instructions, and the Host check.
192    config.control_port = control_listener.local_addr()?.port();
193    config.ws_port = ws_listener.local_addr()?.port();
194
195    let token = Arc::new(token);
196    let state = AppState {
197        token: token.clone(),
198        config: Arc::new(config.clone()),
199        correlator: Correlator::new(),
200        tabs: Arc::new(Mutex::new(HashMap::new())),
201        in_flight: Arc::new(Semaphore::new(config.max_concurrent)),
202        conn_counter: Arc::new(AtomicU64::new(1)),
203    };
204
205    print_startup(&config, &token);
206
207    // WebSocket accept loop.
208    let ws_state = state.clone();
209    tokio::spawn(async move {
210        loop {
211            match ws_listener.accept().await {
212                Ok((stream, _peer)) => {
213                    tokio::spawn(handle_ws_conn(stream, ws_state.clone()));
214                }
215                Err(e) => tracing::warn!("WebSocket accept error: {e}"),
216            }
217        }
218    });
219
220    let app = control_router(state, config.max_body_bytes);
221    axum::serve(control_listener, app)
222        .await
223        .context("Control-plane server error")
224}
225
226/// Prints the bound ports, session token, and paste-ready snippet to stdout.
227fn print_startup(config: &BridgeConfig, token: &str) {
228    let snippet = snippet::render(config.ws_port, token);
229    println!("omni-dev browser bridge serve");
230    println!("  control plane : http://127.0.0.1:{}", config.control_port);
231    println!("  websocket     : ws://127.0.0.1:{}", config.ws_port);
232    println!("  session token : {token}");
233    if let Some(origin) = &config.allow_origin {
234        println!("  allow-origin  : {origin}");
235    }
236    println!();
237    println!("Paste this into the DevTools console of the authenticated tab:");
238    println!();
239    println!("{snippet}");
240    println!();
241    println!("Then drive requests, e.g.:");
242    println!(
243        "  omni-dev browser bridge request --control-port {} --url /path",
244        config.control_port
245    );
246}
247
248// ── WebSocket plane ──────────────────────────────────────────────────
249
250/// Handles one inbound TCP connection on the WebSocket plane: authenticates the
251/// upgrade, registers the connection, and pumps replies into the correlator.
252//
253// `clippy::result_large_err`: the handshake callback's `Result<Response,
254// ErrorResponse>` return type is dictated by `tungstenite::accept_hdr_async`;
255// `ErrorResponse` is a large `http::Response`, but the signature is not ours to
256// change.
257#[allow(clippy::result_large_err)]
258async fn handle_ws_conn(stream: TcpStream, state: AppState) {
259    use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
260
261    let token = state.token.clone();
262    let allow_origin = state.config.allow_origin.clone();
263    let captured_origin: Arc<StdMutex<Option<String>>> = Arc::new(StdMutex::new(None));
264    let co = captured_origin.clone();
265
266    let callback =
267        move |req: &Request, mut response: Response| -> Result<Response, ErrorResponse> {
268            let origin = req
269                .headers()
270                .get("origin")
271                .and_then(|v| v.to_str().ok())
272                .map(str::to_string);
273
274            if !auth::ws_origin_allowed(origin.as_deref(), allow_origin.as_deref()) {
275                tracing::warn!("Rejected WebSocket upgrade: origin not allowed");
276                return Err(ws_error(StatusCode::FORBIDDEN, "origin not allowed"));
277            }
278
279            let protocols: Vec<String> = req
280                .headers()
281                .get_all("sec-websocket-protocol")
282                .iter()
283                .filter_map(|v| v.to_str().ok())
284                .flat_map(|s| s.split(',').map(|p| p.trim().to_string()))
285                .collect();
286
287            let Some(matched) =
288                auth::ws_subprotocol_token(protocols.iter().map(String::as_str), &token)
289            else {
290                tracing::warn!("Rejected WebSocket upgrade: missing or invalid token");
291                return Err(ws_error(
292                    StatusCode::UNAUTHORIZED,
293                    "missing or invalid token",
294                ));
295            };
296            if let Ok(value) = matched.parse() {
297                response
298                    .headers_mut()
299                    .insert("sec-websocket-protocol", value);
300            }
301            *co.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = origin;
302            Ok(response)
303        };
304
305    let ws_stream = match tokio_tungstenite::accept_hdr_async(stream, callback).await {
306        Ok(s) => s,
307        Err(e) => {
308            tracing::debug!("WebSocket handshake failed: {e}");
309            return;
310        }
311    };
312
313    let origin = captured_origin
314        .lock()
315        .unwrap_or_else(std::sync::PoisonError::into_inner)
316        .take();
317    let conn_id = state.conn_counter.fetch_add(1, Ordering::Relaxed);
318    tracing::info!(
319        "Browser connected (conn {conn_id}{})",
320        origin
321            .as_deref()
322            .map(|o| format!(", origin {o}"))
323            .unwrap_or_default()
324    );
325
326    let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
327    {
328        let mut guard = state.tabs.lock().await;
329        guard.insert(conn_id, WsConn { sender: tx, origin });
330    }
331
332    let (mut sink, mut read) = ws_stream.split();
333    let writer = tokio::spawn(async move {
334        while let Some(msg) = rx.recv().await {
335            if sink.send(msg).await.is_err() {
336                break;
337            }
338        }
339    });
340
341    while let Some(Ok(msg)) = read.next().await {
342        match msg {
343            Message::Text(txt) => match serde_json::from_str::<BrowserFrame>(&txt) {
344                Ok(frame) => {
345                    // If a streamed response's control-plane consumer has gone,
346                    // tell *this* browser (the one fetching it) to cancel its
347                    // reader so it stops fetching.
348                    if let Some(cancel_id) = state.correlator.deliver(frame) {
349                        send_cancel(&state, conn_id, cancel_id).await;
350                    }
351                }
352                Err(e) => tracing::debug!("Unparseable browser frame: {e}"),
353            },
354            Message::Close(_) => break,
355            _ => {}
356        }
357    }
358
359    writer.abort();
360    // Drop only this connection's registry entry (keyed by its unique id, so a
361    // reconnect under a new id is never clobbered).
362    if state.tabs.lock().await.remove(&conn_id).is_some() {
363        tracing::info!("Browser disconnected (conn {conn_id})");
364    }
365}
366
367fn ws_error(
368    code: StatusCode,
369    msg: &str,
370) -> tokio_tungstenite::tungstenite::handshake::server::ErrorResponse {
371    let mut resp = tokio_tungstenite::tungstenite::http::Response::new(Some(msg.to_string()));
372    *resp.status_mut() = code;
373    resp
374}
375
376// ── HTTP control plane ───────────────────────────────────────────────
377
378fn control_router(state: AppState, max_body_bytes: usize) -> Router {
379    Router::new()
380        .route("/__bridge/status", get(status_handler))
381        .route("/__bridge/request", post(request_handler))
382        .fallback(proxy_handler)
383        .layer(DefaultBodyLimit::max(max_body_bytes))
384        .layer(middleware::from_fn_with_state(state.clone(), guard))
385        .with_state(state)
386}
387
388/// Enforces the control-plane trust boundary on every request: bearer token,
389/// `X-Omni-Bridge: 1`, `Host` allowlist, and rejection of browser-originated
390/// requests. Emits no CORS headers and refuses `OPTIONS`.
391async fn guard(State(state): State<AppState>, request: Request, next: Next) -> Response {
392    // Never answer OPTIONS (would be a CORS preflight); legitimate CLI clients
393    // do not send it.
394    if request.method() == axum::http::Method::OPTIONS {
395        return (StatusCode::METHOD_NOT_ALLOWED, "OPTIONS not allowed").into_response();
396    }
397
398    let headers = request.headers();
399    let get = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
400
401    let host = get(header::HOST.as_str()).unwrap_or("");
402    if !auth::host_allowed(host, state.config.control_port) {
403        tracing::warn!("Rejected control-plane request: disallowed Host");
404        return (StatusCode::BAD_REQUEST, "host not allowed").into_response();
405    }
406
407    if auth::is_browser_originated(get("origin"), get("sec-fetch-site")) {
408        tracing::warn!("Rejected control-plane request: browser-originated");
409        return (
410            StatusCode::FORBIDDEN,
411            "browser-originated requests are denied",
412        )
413            .into_response();
414    }
415
416    if !auth::has_bridge_header(get(auth::BRIDGE_HEADER)) {
417        return (StatusCode::FORBIDDEN, "missing X-Omni-Bridge: 1").into_response();
418    }
419
420    if !auth::bearer_matches(get(header::AUTHORIZATION.as_str()), &state.token) {
421        return (StatusCode::UNAUTHORIZED, "invalid or missing bearer token").into_response();
422    }
423
424    next.run(request).await
425}
426
427async fn status_handler(State(state): State<AppState>) -> Json<StatusResponse> {
428    let mut tabs: Vec<TabInfo> = {
429        let guard = state.tabs.lock().await;
430        guard
431            .iter()
432            .map(|(id, conn)| TabInfo {
433                id: *id,
434                origin: conn.origin.clone(),
435            })
436            .collect()
437    };
438    tabs.sort_by_key(|t| t.id);
439    // `browser_origin` is v1 back-compat: meaningful only when exactly one tab
440    // is connected; ambiguous (so `None`) for zero or several.
441    let browser_origin = match tabs.as_slice() {
442        [only] => only.origin.clone(),
443        _ => None,
444    };
445    Json(StatusResponse {
446        connected: !tabs.is_empty(),
447        browser_origin,
448        tabs,
449        pending: state.correlator.pending_count(),
450    })
451}
452
453/// `POST /__bridge/request` — full-fidelity control endpoint. A `stream: true`
454/// body returns an NDJSON stream (head line, `{seq,chunk}` lines, `{done}`);
455/// otherwise a single JSON response envelope.
456async fn request_handler(
457    State(state): State<AppState>,
458    headers: axum::http::HeaderMap,
459    body: Bytes,
460) -> Response {
461    let mut req: ControlRequest = match serde_json::from_slice(&body) {
462        Ok(r) => r,
463        Err(e) => {
464            return (StatusCode::BAD_REQUEST, format!("invalid JSON body: {e}")).into_response()
465        }
466    };
467    // The header, when present, overrides a `target` body field.
468    if let Some(target) = target_header(&headers) {
469        req.target = Some(target);
470    }
471    if req.stream {
472        return match start_stream(&state, req).await {
473            Ok((status, headers, driver)) => ndjson_stream_response(status, headers, driver),
474            Err((code, msg)) => (code, msg).into_response(),
475        };
476    }
477    match dispatch(&state, req).await {
478        Ok(env) => Json(env).into_response(),
479        Err((code, msg)) => (code, msg).into_response(),
480    }
481}
482
483/// Transparent proxy for any path not under `/__bridge/`.
484async fn proxy_handler(State(state): State<AppState>, request: Request) -> Response {
485    let (parts, body) = request.into_parts();
486
487    let path = parts.uri.path();
488    if auth::normalize_request_path(path).is_none() {
489        return (StatusCode::BAD_REQUEST, "unsafe request path").into_response();
490    }
491    // `?__stream=1` opts the proxied request into a streamed (chunked) response;
492    // the marker is stripped so it never reaches the upstream URL.
493    let (stream, forwarded_query) = extract_stream_flag(parts.uri.query());
494    let url = match forwarded_query.as_deref() {
495        Some(q) => format!("{path}?{q}"),
496        None => path.to_string(),
497    };
498
499    let headers = forwardable_headers(&parts.headers);
500
501    let Ok(body_bytes) = axum::body::to_bytes(body, state.config.max_body_bytes).await else {
502        return (StatusCode::PAYLOAD_TOO_LARGE, "request body too large").into_response();
503    };
504    let body = if body_bytes.is_empty() {
505        None
506    } else {
507        Some(String::from_utf8_lossy(&body_bytes).into_owned())
508    };
509
510    let req = ControlRequest {
511        url,
512        method: parts.method.to_string(),
513        headers,
514        body,
515        stream,
516        target: target_header(&parts.headers),
517        // The transparent proxy has no per-request override channel; cross-origin
518        // proxying is governed solely by the `serve --allow-origin` global.
519        allow_origin: None,
520        // The transparent proxy has no per-request credentials control; it keeps
521        // the snippet default (`include`), matching pre-credentials behavior.
522        credentials: None,
523    };
524
525    if stream {
526        return match start_stream(&state, req).await {
527            Ok((status, headers, driver)) => raw_stream_response(status, headers, driver),
528            Err((code, msg)) => (code, msg).into_response(),
529        };
530    }
531
532    match dispatch(&state, req).await {
533        Ok(env) => envelope_to_response(env),
534        Err((code, msg)) => (code, msg).into_response(),
535    }
536}
537
538/// Splits a `__stream` marker out of a query string.
539///
540/// Returns whether streaming was requested and the query with the marker
541/// removed (`None` when nothing remains). `__stream=0` / `__stream=false`
542/// explicitly disable it; any other presence enables it.
543fn extract_stream_flag(query: Option<&str>) -> (bool, Option<String>) {
544    let Some(query) = query else {
545        return (false, None);
546    };
547    let mut stream = false;
548    let kept: Vec<&str> = query
549        .split('&')
550        .filter(|kv| {
551            let (key, value) = match kv.split_once('=') {
552                Some((k, v)) => (k, Some(v)),
553                None => (*kv, None),
554            };
555            if key == "__stream" {
556                stream = !matches!(value, Some("0" | "false"));
557                false
558            } else {
559                true
560            }
561        })
562        .collect();
563    let rebuilt = (!kept.is_empty()).then(|| kept.join("&"));
564    (stream, rebuilt)
565}
566
567/// Copies request headers safe to forward to the browser, dropping the
568/// bridge-control and hop-by-hop headers a CLI client adds.
569fn forwardable_headers(headers: &axum::http::HeaderMap) -> BTreeMap<String, String> {
570    const DROP: &[&str] = &[
571        "host",
572        "authorization",
573        auth::BRIDGE_HEADER,
574        auth::BRIDGE_TARGET_HEADER,
575        "content-length",
576        "connection",
577        "accept-encoding",
578        "origin",
579        "sec-fetch-site",
580        "sec-fetch-mode",
581        "sec-fetch-dest",
582    ];
583    headers
584        .iter()
585        .filter_map(|(k, v)| {
586            let name = k.as_str();
587            if DROP.contains(&name) {
588                return None;
589            }
590            v.to_str()
591                .ok()
592                .map(|val| (name.to_string(), val.to_string()))
593        })
594        .collect()
595}
596
597/// Extracts the `X-Omni-Bridge-Target` selector from request headers, if present
598/// and non-empty.
599fn target_header(headers: &axum::http::HeaderMap) -> Option<String> {
600    headers
601        .get(auth::BRIDGE_TARGET_HEADER)
602        .and_then(|v| v.to_str().ok())
603        .map(str::trim)
604        .filter(|s| !s.is_empty())
605        .map(str::to_string)
606}
607
608/// Resolves which connected tab a request targets, returning its connection id
609/// and a clone of its outbound sender.
610///
611/// An explicit `target` is a connection id (canonical) or an `Origin` that
612/// uniquely matches one tab. With no target, routing succeeds only when exactly
613/// one tab is connected — otherwise the request is ambiguous and rejected.
614fn resolve_target(
615    tabs: &HashMap<u64, WsConn>,
616    target: Option<&str>,
617) -> Result<(u64, mpsc::UnboundedSender<Message>), (StatusCode, String)> {
618    if tabs.is_empty() {
619        return Err((
620            StatusCode::SERVICE_UNAVAILABLE,
621            "no browser connected".to_string(),
622        ));
623    }
624    let Some(sel) = target else {
625        // No target: route only when exactly one tab is connected.
626        let mut it = tabs.iter();
627        return match (it.next(), it.next()) {
628            (Some((id, conn)), None) => Ok((*id, conn.sender.clone())),
629            _ => Err((
630                StatusCode::CONFLICT,
631                format!(
632                    "multiple tabs connected; select one with the X-Omni-Bridge-Target \
633                     header or a `target` field ({})",
634                    tab_list(tabs)
635                ),
636            )),
637        };
638    };
639
640    // A bare integer selects by connection id (canonical, unambiguous).
641    if let Ok(id) = sel.parse::<u64>() {
642        return match tabs.get(&id) {
643            Some(conn) => Ok((id, conn.sender.clone())),
644            None => Err((
645                StatusCode::NOT_FOUND,
646                format!(
647                    "no connected tab with id {id}; connected: {}",
648                    tab_list(tabs)
649                ),
650            )),
651        };
652    }
653
654    // Otherwise match the selector against tab origins.
655    let mut hits = tabs
656        .iter()
657        .filter(|(_, c)| c.origin.as_deref() == Some(sel));
658    match (hits.next(), hits.next()) {
659        (Some((id, conn)), None) => Ok((*id, conn.sender.clone())),
660        (None, _) => Err((
661            StatusCode::NOT_FOUND,
662            format!(
663                "no connected tab with origin {sel}; connected: {}",
664                tab_list(tabs)
665            ),
666        )),
667        (Some(_), Some(_)) => Err((
668            StatusCode::CONFLICT,
669            format!(
670                "origin {sel} matches multiple tabs; target by connection id ({})",
671                tab_list(tabs)
672            ),
673        )),
674    }
675}
676
677/// Renders the connected tabs as `id N: origin, …` (id-sorted) for error
678/// messages. Carries no authenticated data beyond the origin already in status.
679fn tab_list(tabs: &HashMap<u64, WsConn>) -> String {
680    let mut items: Vec<(u64, Option<&str>)> = tabs
681        .iter()
682        .map(|(id, c)| (*id, c.origin.as_deref()))
683        .collect();
684    items.sort_by_key(|(id, _)| *id);
685    items
686        .iter()
687        .map(|(id, origin)| match origin {
688            Some(o) => format!("id {id}: {o}"),
689            None => format!("id {id}"),
690        })
691        .collect::<Vec<_>>()
692        .join(", ")
693}
694
695/// Resolves the outbound-origin allowlist for a single request.
696///
697/// A per-request `request --allow-origin` override (`req.allow_origin`) takes
698/// precedence over the `serve --allow-origin` global; otherwise the global is
699/// used. This value reaches **only** [`auth::validate_outbound_url`] — never the
700/// connection-time `ws_origin_allowed` gate — so a request may target a
701/// cross-origin URL without the page's own tab being rejected at upgrade.
702///
703/// A WARN is logged whenever the per-request override is exercised, since it
704/// widens this request's outbound scope beyond what `serve` was started with.
705fn resolve_allow_origin<'a>(req: &'a ControlRequest, state: &'a AppState) -> Option<&'a str> {
706    match req.allow_origin.as_deref() {
707        Some(origin) => {
708            tracing::warn!(
709                "Per-request --allow-origin override in effect; outbound scope for this request \
710                 widened to {origin}"
711            );
712            Some(origin)
713        }
714        None => state.config.allow_origin.as_deref(),
715    }
716}
717
718/// The shared request path: scope-check, register a waiter, send the command,
719/// and await the browser's reply (or time out).
720async fn dispatch(
721    state: &AppState,
722    req: ControlRequest,
723) -> Result<ResponseEnvelope, (StatusCode, String)> {
724    auth::validate_outbound_url(&req.url, resolve_allow_origin(&req, state)).map_err(|_| {
725        (
726            StatusCode::FORBIDDEN,
727            "outbound URL is cross-origin; pass --allow-origin to permit it".to_string(),
728        )
729    })?;
730
731    for (name, value) in &req.headers {
732        if !auth::header_is_safe(name, value) {
733            return Err((
734                StatusCode::BAD_REQUEST,
735                "invalid header name or value".to_string(),
736            ));
737        }
738    }
739
740    let _permit = state.in_flight.clone().try_acquire_owned().map_err(|_| {
741        (
742            StatusCode::TOO_MANY_REQUESTS,
743            "too many in-flight requests".to_string(),
744        )
745    })?;
746
747    let (id, rx) = state.correlator.register();
748    let command = Command {
749        id,
750        url: req.url,
751        method: req.method,
752        headers: req.headers,
753        body: req.body,
754        stream: false,
755        credentials: req.credentials,
756    };
757    let frame = match serde_json::to_string(&command) {
758        Ok(f) => f,
759        Err(e) => {
760            state.correlator.remove(id);
761            return Err((
762                StatusCode::INTERNAL_SERVER_ERROR,
763                format!("serialise error: {e}"),
764            ));
765        }
766    };
767
768    {
769        let tabs = state.tabs.lock().await;
770        let (_conn_id, sender) = match resolve_target(&tabs, req.target.as_deref()) {
771            Ok(t) => t,
772            Err(e) => {
773                state.correlator.remove(id);
774                return Err(e);
775            }
776        };
777        if sender.send(Message::Text(frame)).is_err() {
778            state.correlator.remove(id);
779            return Err((
780                StatusCode::SERVICE_UNAVAILABLE,
781                "no browser connected".to_string(),
782            ));
783        }
784    }
785
786    match tokio::time::timeout(state.config.request_timeout, rx).await {
787        Ok(Ok(reply)) => match reply.outcome() {
788            ReplyOutcome::Success {
789                status,
790                headers,
791                body,
792                encoding,
793            } => {
794                // Size is accounted against the *decoded* body. For base64 that
795                // means decoding here to learn the true byte length; the envelope
796                // still carries the base64 string (the caller / proxy decodes).
797                let decoded_len = match encoding.as_deref() {
798                    None => body.len(),
799                    Some("base64") => match BASE64.decode(body.as_bytes()) {
800                        Ok(bytes) => bytes.len(),
801                        Err(_) => {
802                            return Err((
803                                StatusCode::BAD_GATEWAY,
804                                "browser sent an invalid base64 body".to_string(),
805                            ))
806                        }
807                    },
808                    Some(other) => {
809                        return Err((
810                            StatusCode::BAD_GATEWAY,
811                            format!("browser sent an unsupported body encoding: {other}"),
812                        ))
813                    }
814                };
815                if decoded_len > state.config.max_body_bytes {
816                    return Err((
817                        StatusCode::BAD_GATEWAY,
818                        format!(
819                            "browser response body is {decoded_len} bytes, exceeding the \
820                             --max-body-bytes limit of {} bytes; page the request to fetch \
821                             less per call (e.g. narrow the time range or lower a `limit`/page \
822                             size) or raise --max-body-bytes",
823                            state.config.max_body_bytes
824                        ),
825                    ));
826                }
827                Ok(ResponseEnvelope {
828                    id,
829                    status,
830                    headers,
831                    body,
832                    encoding,
833                })
834            }
835            ReplyOutcome::Error(msg) => Err((
836                StatusCode::BAD_GATEWAY,
837                format!("browser fetch failed: {msg}"),
838            )),
839        },
840        Ok(Err(_)) => Err((
841            StatusCode::BAD_GATEWAY,
842            "browser connection closed before replying".to_string(),
843        )),
844        Err(_) => {
845            state.correlator.remove(id);
846            Err((
847                StatusCode::GATEWAY_TIMEOUT,
848                "browser did not reply in time".to_string(),
849            ))
850        }
851    }
852}
853
854/// Sends a best-effort cancellation frame to the tab handling stream `id` and
855/// drops the pending stream, so a stream whose consumer is gone (or which
856/// tripped a limit) stops the in-page reader rather than fetching to completion.
857/// A no-op if that tab has since disconnected.
858async fn send_cancel(state: &AppState, conn_id: u64, id: u64) {
859    state.correlator.remove(id);
860    let Ok(frame) = serde_json::to_string(&CancelCommand::new(id)) else {
861        return;
862    };
863    if let Some(conn) = state.tabs.lock().await.get(&conn_id) {
864        let _ = conn.sender.send(Message::Text(frame));
865    }
866}
867
868/// The shared streaming request path: scope-check, register a stream waiter, send
869/// the `stream: true` command, and await the head frame (status + headers) under
870/// the inter-chunk idle timeout. Returns the head plus a [`StreamDriver`] that
871/// pulls the remaining body chunks; the concurrency permit is held by the driver
872/// for the stream's lifetime.
873async fn start_stream(
874    state: &AppState,
875    req: ControlRequest,
876) -> Result<(u16, BTreeMap<String, String>, StreamDriver), (StatusCode, String)> {
877    auth::validate_outbound_url(&req.url, resolve_allow_origin(&req, state)).map_err(|_| {
878        (
879            StatusCode::FORBIDDEN,
880            "outbound URL is cross-origin; pass --allow-origin to permit it".to_string(),
881        )
882    })?;
883
884    for (name, value) in &req.headers {
885        if !auth::header_is_safe(name, value) {
886            return Err((
887                StatusCode::BAD_REQUEST,
888                "invalid header name or value".to_string(),
889            ));
890        }
891    }
892
893    let permit = state.in_flight.clone().try_acquire_owned().map_err(|_| {
894        (
895            StatusCode::TOO_MANY_REQUESTS,
896            "too many in-flight requests".to_string(),
897        )
898    })?;
899
900    let (id, mut rx) = state.correlator.register_stream();
901    let command = Command {
902        id,
903        url: req.url,
904        method: req.method,
905        headers: req.headers,
906        body: req.body,
907        stream: true,
908        credentials: req.credentials,
909    };
910    let frame = match serde_json::to_string(&command) {
911        Ok(f) => f,
912        Err(e) => {
913            state.correlator.remove(id);
914            return Err((
915                StatusCode::INTERNAL_SERVER_ERROR,
916                format!("serialise error: {e}"),
917            ));
918        }
919    };
920
921    let conn_id = {
922        let tabs = state.tabs.lock().await;
923        let (conn_id, sender) = match resolve_target(&tabs, req.target.as_deref()) {
924            Ok(t) => t,
925            Err(e) => {
926                state.correlator.remove(id);
927                return Err(e);
928            }
929        };
930        if sender.send(Message::Text(frame)).is_err() {
931            state.correlator.remove(id);
932            return Err((
933                StatusCode::SERVICE_UNAVAILABLE,
934                "no browser connected".to_string(),
935            ));
936        }
937        conn_id
938    };
939
940    let idle = state.config.request_timeout;
941    let (status, headers) = match tokio::time::timeout(idle, rx.recv()).await {
942        Ok(Some(StreamItem::Head { status, headers })) => (status, headers),
943        Ok(Some(StreamItem::Error(msg))) => {
944            state.correlator.remove(id);
945            return Err((
946                StatusCode::BAD_GATEWAY,
947                format!("browser fetch failed: {msg}"),
948            ));
949        }
950        Ok(Some(_)) => {
951            state.correlator.remove(id);
952            return Err((
953                StatusCode::BAD_GATEWAY,
954                "browser streamed a body chunk before the response head".to_string(),
955            ));
956        }
957        Ok(None) => {
958            return Err((
959                StatusCode::BAD_GATEWAY,
960                "browser connection closed before replying".to_string(),
961            ));
962        }
963        Err(_) => {
964            send_cancel(state, conn_id, id).await;
965            return Err((
966                StatusCode::GATEWAY_TIMEOUT,
967                "browser did not start streaming in time".to_string(),
968            ));
969        }
970    };
971
972    let driver = StreamDriver {
973        state: state.clone(),
974        id,
975        conn_id,
976        rx,
977        idle,
978        max_body: state.config.max_body_bytes,
979        sent: 0,
980        _permit: permit,
981        done: false,
982    };
983    Ok((status, headers, driver))
984}
985
986/// Drives a registered stream's body chunks: applies the inter-chunk idle
987/// timeout, decodes each base64 chunk, enforces the cumulative `--max-body-bytes`
988/// ceiling, and cancels the browser stream on early/abnormal termination. Holds
989/// the concurrency permit until dropped.
990struct StreamDriver {
991    state: AppState,
992    id: u64,
993    /// Connection id of the tab serving this stream; cancels route back to it.
994    conn_id: u64,
995    rx: mpsc::UnboundedReceiver<StreamItem>,
996    idle: Duration,
997    max_body: usize,
998    sent: usize,
999    _permit: OwnedSemaphorePermit,
1000    done: bool,
1001}
1002
1003/// One step of a [`StreamDriver`]: decoded chunk bytes, or end-of-stream.
1004enum NextChunk {
1005    /// A decoded body chunk and its sequence number.
1006    Data {
1007        /// Chunk sequence number reported by the browser.
1008        seq: u64,
1009        /// Decoded chunk bytes.
1010        bytes: Vec<u8>,
1011    },
1012    /// The stream is finished (normal end, error, idle timeout, or cap hit).
1013    End,
1014}
1015
1016impl StreamDriver {
1017    /// Pulls the next decoded chunk, ending the stream on a terminal item, an
1018    /// invalid chunk, an idle timeout, or the cumulative byte cap.
1019    async fn next_chunk(&mut self) -> NextChunk {
1020        if self.done {
1021            return NextChunk::End;
1022        }
1023        loop {
1024            match tokio::time::timeout(self.idle, self.rx.recv()).await {
1025                Ok(Some(StreamItem::Chunk { seq, data })) => {
1026                    let Ok(bytes) = BASE64.decode(data.as_bytes()) else {
1027                        return self.abort().await;
1028                    };
1029                    self.sent = self.sent.saturating_add(bytes.len());
1030                    if self.sent > self.max_body {
1031                        return self.abort().await;
1032                    }
1033                    return NextChunk::Data { seq, bytes };
1034                }
1035                // A stray head after the first is a protocol slip; ignore it.
1036                Ok(Some(StreamItem::Head { .. })) => {}
1037                Ok(Some(StreamItem::End | StreamItem::Error(_)) | None) => {
1038                    return self.finish();
1039                }
1040                // Inter-chunk idle timeout: stop the browser and end the stream.
1041                Err(_) => return self.abort().await,
1042            }
1043        }
1044    }
1045
1046    /// Ends the stream and removes the pending entry (terminal item / consumer
1047    /// gone — the browser is already done, so no cancel is sent).
1048    fn finish(&mut self) -> NextChunk {
1049        self.done = true;
1050        self.state.correlator.remove(self.id);
1051        NextChunk::End
1052    }
1053
1054    /// Ends the stream early and tells the browser to cancel its reader (idle
1055    /// timeout, cap exceeded, or an undecodable chunk).
1056    async fn abort(&mut self) -> NextChunk {
1057        self.done = true;
1058        send_cancel(&self.state, self.conn_id, self.id).await;
1059        NextChunk::End
1060    }
1061}
1062
1063/// Serialises a [`StreamLine`] as one NDJSON line (trailing newline).
1064fn to_ndjson_line(line: &StreamLine) -> String {
1065    let mut s = serde_json::to_string(line).unwrap_or_else(|_| "{}".to_string());
1066    s.push('\n');
1067    s
1068}
1069
1070/// Builds the transparent-proxy response for a streamed body: status and
1071/// `content-type` from the head frame, decoded chunk bytes streamed as a chunked
1072/// HTTP body.
1073fn raw_stream_response(
1074    status: u16,
1075    headers: BTreeMap<String, String>,
1076    driver: StreamDriver,
1077) -> Response {
1078    let code = StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY);
1079    let mut builder = Response::builder().status(code);
1080    if let Some(ct) = headers.get("content-type") {
1081        builder = builder.header(header::CONTENT_TYPE, ct);
1082    }
1083    let stream = futures::stream::unfold(driver, |mut driver| async move {
1084        match driver.next_chunk().await {
1085            NextChunk::Data { bytes, .. } => Some((
1086                Ok::<_, std::convert::Infallible>(Bytes::from(bytes)),
1087                driver,
1088            )),
1089            NextChunk::End => None,
1090        }
1091    });
1092    builder
1093        .body(Body::from_stream(stream))
1094        .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1095}
1096
1097/// Builds the `POST /__bridge/request` response for a streamed body: an NDJSON
1098/// body of a head line, `{seq,chunk}` lines, and a terminating `{done}` line.
1099fn ndjson_stream_response(
1100    status: u16,
1101    headers: BTreeMap<String, String>,
1102    driver: StreamDriver,
1103) -> Response {
1104    let head_line = to_ndjson_line(&StreamLine::Head { status, headers });
1105    // State: (pending head line, driver, done-line-emitted).
1106    let init = (Some(head_line), driver, false);
1107    let stream = futures::stream::unfold(init, |(head, mut driver, done_emitted)| async move {
1108        if let Some(line) = head {
1109            return Some((
1110                Ok::<_, std::convert::Infallible>(Bytes::from(line)),
1111                (None, driver, done_emitted),
1112            ));
1113        }
1114        if done_emitted {
1115            return None;
1116        }
1117        match driver.next_chunk().await {
1118            NextChunk::Data { seq, bytes } => {
1119                let line = to_ndjson_line(&StreamLine::Chunk {
1120                    seq,
1121                    chunk: BASE64.encode(&bytes),
1122                });
1123                Some((Ok(Bytes::from(line)), (None, driver, false)))
1124            }
1125            NextChunk::End => {
1126                let line = to_ndjson_line(&StreamLine::Done { done: true });
1127                Some((Ok(Bytes::from(line)), (None, driver, true)))
1128            }
1129        }
1130    });
1131    Response::builder()
1132        .status(StatusCode::OK)
1133        .header(header::CONTENT_TYPE, "application/x-ndjson")
1134        .body(Body::from_stream(stream))
1135        .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1136}
1137
1138/// Renders a browser response envelope as the transparent-proxy HTTP response.
1139///
1140/// A base64-tagged body is decoded back to raw bytes so a `curl` client gets the
1141/// original bytes (image, gzip blob, …); the base64 is validated in `dispatch`,
1142/// but a decode failure here still fails closed with `502`.
1143fn envelope_to_response(env: ResponseEnvelope) -> Response {
1144    let status = StatusCode::from_u16(env.status).unwrap_or(StatusCode::BAD_GATEWAY);
1145    let mut builder = Response::builder().status(status);
1146    if let Some(ct) = env.headers.get("content-type") {
1147        builder = builder.header(header::CONTENT_TYPE, ct);
1148    }
1149    let body = match env.encoding.as_deref() {
1150        Some("base64") => match BASE64.decode(env.body.as_bytes()) {
1151            Ok(bytes) => Body::from(bytes),
1152            Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
1153        },
1154        _ => Body::from(env.body),
1155    };
1156    builder
1157        .body(body)
1158        .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
1159}
1160
1161#[cfg(test)]
1162#[allow(clippy::unwrap_used, clippy::expect_used)]
1163mod tests {
1164    use super::*;
1165
1166    fn buffered_frame(id: u64) -> BrowserFrame {
1167        BrowserFrame {
1168            id,
1169            status: Some(200),
1170            headers: None,
1171            body: Some("ok".into()),
1172            encoding: None,
1173            error: None,
1174            stream: None,
1175            chunk: None,
1176            seq: None,
1177            done: None,
1178        }
1179    }
1180
1181    /// Builds a `tabs` map entry with a detached sender (the receiver is dropped;
1182    /// routing tests only assert *which* connection is chosen, not delivery).
1183    fn tab(origin: Option<&str>) -> WsConn {
1184        let (sender, _rx) = mpsc::unbounded_channel();
1185        WsConn {
1186            sender,
1187            origin: origin.map(str::to_string),
1188        }
1189    }
1190
1191    #[test]
1192    fn resolve_target_no_tabs_is_503() {
1193        let tabs = HashMap::new();
1194        let err = resolve_target(&tabs, None).unwrap_err();
1195        assert_eq!(err.0, StatusCode::SERVICE_UNAVAILABLE);
1196    }
1197
1198    #[test]
1199    fn resolve_target_single_tab_routes_without_target() {
1200        let mut tabs = HashMap::new();
1201        tabs.insert(1, tab(Some("https://a.test")));
1202        let (id, _s) = resolve_target(&tabs, None).unwrap();
1203        assert_eq!(id, 1);
1204    }
1205
1206    #[test]
1207    fn resolve_target_multiple_tabs_no_target_is_409() {
1208        let mut tabs = HashMap::new();
1209        tabs.insert(1, tab(Some("https://a.test")));
1210        tabs.insert(2, tab(Some("https://b.test")));
1211        let err = resolve_target(&tabs, None).unwrap_err();
1212        assert_eq!(err.0, StatusCode::CONFLICT);
1213        // The message lists both connected tabs to disambiguate.
1214        assert!(err.1.contains("id 1") && err.1.contains("id 2"));
1215    }
1216
1217    #[test]
1218    fn resolve_target_by_connection_id() {
1219        let mut tabs = HashMap::new();
1220        tabs.insert(1, tab(Some("https://a.test")));
1221        tabs.insert(2, tab(Some("https://b.test")));
1222        let (id, _s) = resolve_target(&tabs, Some("2")).unwrap();
1223        assert_eq!(id, 2);
1224        // Unknown id is a 404.
1225        assert_eq!(
1226            resolve_target(&tabs, Some("9")).unwrap_err().0,
1227            StatusCode::NOT_FOUND
1228        );
1229    }
1230
1231    #[test]
1232    fn resolve_target_by_unique_origin() {
1233        let mut tabs = HashMap::new();
1234        tabs.insert(1, tab(Some("https://a.test")));
1235        tabs.insert(2, tab(Some("https://b.test")));
1236        let (id, _s) = resolve_target(&tabs, Some("https://b.test")).unwrap();
1237        assert_eq!(id, 2);
1238        // Unknown origin is a 404.
1239        assert_eq!(
1240            resolve_target(&tabs, Some("https://nope.test"))
1241                .unwrap_err()
1242                .0,
1243            StatusCode::NOT_FOUND
1244        );
1245    }
1246
1247    #[test]
1248    fn resolve_target_ambiguous_origin_is_409() {
1249        let mut tabs = HashMap::new();
1250        tabs.insert(1, tab(Some("https://a.test")));
1251        tabs.insert(2, tab(Some("https://a.test")));
1252        let err = resolve_target(&tabs, Some("https://a.test")).unwrap_err();
1253        assert_eq!(err.0, StatusCode::CONFLICT);
1254        // Two tabs share the origin → caller is told to target by id.
1255        assert!(err.1.contains("connection id"));
1256    }
1257
1258    #[test]
1259    fn target_header_trims_and_drops_empty() {
1260        let mut h = axum::http::HeaderMap::new();
1261        assert_eq!(target_header(&h), None);
1262        h.insert(auth::BRIDGE_TARGET_HEADER, "  2  ".parse().unwrap());
1263        assert_eq!(target_header(&h).as_deref(), Some("2"));
1264        h.insert(auth::BRIDGE_TARGET_HEADER, "   ".parse().unwrap());
1265        assert_eq!(target_header(&h), None);
1266    }
1267
1268    #[test]
1269    fn tab_list_renders_id_with_and_without_origin() {
1270        let mut tabs = HashMap::new();
1271        tabs.insert(1, tab(Some("https://a.test")));
1272        tabs.insert(2, tab(None));
1273        // Id-sorted; a tab that sent no `Origin` renders as the bare id.
1274        assert_eq!(tab_list(&tabs), "id 1: https://a.test, id 2");
1275    }
1276
1277    /// A minimal [`AppState`] for exercising `dispatch` / `start_stream` without
1278    /// a real WebSocket peer.
1279    fn test_state() -> AppState {
1280        AppState {
1281            token: Arc::new("t".to_string()),
1282            config: Arc::new(BridgeConfig {
1283                ws_port: 0,
1284                control_port: 0,
1285                request_timeout: Duration::from_secs(5),
1286                allow_origin: None,
1287                max_body_bytes: 1024,
1288                max_concurrent: 8,
1289            }),
1290            correlator: Correlator::new(),
1291            tabs: Arc::new(Mutex::new(HashMap::new())),
1292            in_flight: Arc::new(Semaphore::new(8)),
1293            conn_counter: Arc::new(AtomicU64::new(1)),
1294        }
1295    }
1296
1297    /// Inserts a tab whose writer receiver is already dropped, so any send to it
1298    /// fails — modelling a tab that vanished between routing and dispatch.
1299    async fn insert_dead_tab(state: &AppState, id: u64) {
1300        let (sender, rx) = mpsc::unbounded_channel();
1301        drop(rx);
1302        state.tabs.lock().await.insert(
1303            id,
1304            WsConn {
1305                sender,
1306                origin: None,
1307            },
1308        );
1309    }
1310
1311    fn plain_request() -> ControlRequest {
1312        ControlRequest {
1313            url: "/x".to_string(),
1314            method: "GET".to_string(),
1315            headers: BTreeMap::new(),
1316            body: None,
1317            stream: false,
1318            target: None,
1319            allow_origin: None,
1320            credentials: None,
1321        }
1322    }
1323
1324    /// Builds a state whose `serve` global allow-origin is set, to prove a
1325    /// per-request override wins over (and a missing one falls back to) it.
1326    fn state_with_global_origin(global: Option<&str>) -> AppState {
1327        let mut state = test_state();
1328        let mut config = (*state.config).clone();
1329        config.allow_origin = global.map(str::to_string);
1330        state.config = Arc::new(config);
1331        state
1332    }
1333
1334    #[test]
1335    fn resolve_allow_origin_prefers_per_request_override() {
1336        let state = state_with_global_origin(Some("https://global.test"));
1337        let req = ControlRequest {
1338            allow_origin: Some("https://per-request.test".to_string()),
1339            ..plain_request()
1340        };
1341        // The per-request value wins over the serve global.
1342        assert_eq!(
1343            resolve_allow_origin(&req, &state),
1344            Some("https://per-request.test")
1345        );
1346        // A request carrying the override permits its matched cross-origin
1347        // target, and still rejects an unmatched one.
1348        assert_eq!(
1349            auth::validate_outbound_url(
1350                "https://per-request.test/x",
1351                resolve_allow_origin(&req, &state)
1352            ),
1353            Ok(())
1354        );
1355        assert!(auth::validate_outbound_url(
1356            "https://other.test/x",
1357            resolve_allow_origin(&req, &state)
1358        )
1359        .is_err());
1360    }
1361
1362    #[test]
1363    fn resolve_allow_origin_falls_back_to_global() {
1364        let state = state_with_global_origin(Some("https://global.test"));
1365        let req = plain_request();
1366        assert!(req.allow_origin.is_none());
1367        // With no per-request override, the serve global governs the scope.
1368        assert_eq!(
1369            resolve_allow_origin(&req, &state),
1370            Some("https://global.test")
1371        );
1372    }
1373
1374    #[test]
1375    fn per_request_override_does_not_affect_ws_origin_gate() {
1376        // The per-request override feeds only the outbound-URL check. The
1377        // connection-time WS gate reads the `serve` global directly, so a tab on
1378        // origin A stays connectable even when a request override permits B.
1379        let state = state_with_global_origin(None);
1380        let req = ControlRequest {
1381            allow_origin: Some("https://b.test".to_string()),
1382            ..plain_request()
1383        };
1384        // Outbound to B is permitted by the override...
1385        assert_eq!(
1386            auth::validate_outbound_url("https://b.test/x", resolve_allow_origin(&req, &state)),
1387            Ok(())
1388        );
1389        // ...while the WS upgrade gate (serve global = None) still admits any
1390        // origin, unchanged by the per-request value.
1391        assert!(auth::ws_origin_allowed(
1392            Some("https://a.test"),
1393            state.config.allow_origin.as_deref()
1394        ));
1395    }
1396
1397    #[tokio::test]
1398    async fn dispatch_returns_503_when_send_fails() {
1399        let state = test_state();
1400        insert_dead_tab(&state, 1).await;
1401        let err = dispatch(&state, plain_request()).await.unwrap_err();
1402        assert_eq!(err.0, StatusCode::SERVICE_UNAVAILABLE);
1403        // The failed dispatch leaves no dangling waiter behind.
1404        assert_eq!(state.correlator.pending_count(), 0);
1405    }
1406
1407    #[tokio::test]
1408    async fn start_stream_returns_503_when_send_fails() {
1409        let state = test_state();
1410        insert_dead_tab(&state, 1).await;
1411        let req = ControlRequest {
1412            stream: true,
1413            ..plain_request()
1414        };
1415        let err = start_stream(&state, req).await.err().map(|e| e.0);
1416        assert_eq!(err, Some(StatusCode::SERVICE_UNAVAILABLE));
1417        assert_eq!(state.correlator.pending_count(), 0);
1418    }
1419
1420    #[test]
1421    fn correlator_register_resolve_round_trip() {
1422        let c = Correlator::new();
1423        let (id, rx) = c.register();
1424        assert_eq!(c.pending_count(), 1);
1425        assert_eq!(c.deliver(buffered_frame(id)), None);
1426        assert_eq!(c.pending_count(), 0);
1427        let reply = rx.now_or_never().unwrap().unwrap();
1428        assert_eq!(reply.id, id);
1429    }
1430
1431    #[test]
1432    fn correlator_stream_forwards_items_until_terminal() {
1433        let c = Correlator::new();
1434        let (id, mut rx) = c.register_stream();
1435        assert_eq!(c.pending_count(), 1);
1436
1437        let mut head = buffered_frame(id);
1438        head.stream = Some(true);
1439        head.body = None;
1440        assert_eq!(c.deliver(head), None);
1441        assert!(matches!(
1442            rx.try_recv(),
1443            Ok(StreamItem::Head { status: 200, .. })
1444        ));
1445        // Head is non-terminal: the waiter stays registered.
1446        assert_eq!(c.pending_count(), 1);
1447
1448        let mut done = BrowserFrame {
1449            done: Some(true),
1450            ..buffered_frame(id)
1451        };
1452        done.body = None;
1453        assert_eq!(c.deliver(done), None);
1454        assert!(matches!(rx.try_recv(), Ok(StreamItem::End)));
1455        assert_eq!(c.pending_count(), 0);
1456    }
1457
1458    #[test]
1459    fn correlator_deliver_unknown_id_is_noop() {
1460        let c = Correlator::new();
1461        // A frame whose id was never registered (or already terminal) is dropped
1462        // without panicking and never asks the caller to cancel.
1463        assert_eq!(c.deliver(buffered_frame(999)), None);
1464        assert_eq!(c.pending_count(), 0);
1465    }
1466
1467    #[test]
1468    fn correlator_stream_signals_cancel_when_consumer_gone() {
1469        let c = Correlator::new();
1470        let (id, rx) = c.register_stream();
1471        drop(rx); // consumer disconnected
1472        let mut chunk = buffered_frame(id);
1473        chunk.chunk = Some("aGk=".into());
1474        chunk.body = None;
1475        // Delivery fails (receiver dropped) → caller is told to cancel `id`.
1476        assert_eq!(c.deliver(chunk), Some(id));
1477        assert_eq!(c.pending_count(), 0);
1478    }
1479
1480    #[test]
1481    fn correlator_ids_are_monotonic() {
1482        let c = Correlator::new();
1483        let (a, _ra) = c.register();
1484        let (b, _rb) = c.register();
1485        assert!(b > a);
1486    }
1487
1488    #[test]
1489    fn correlator_remove_drops_waiter() {
1490        let c = Correlator::new();
1491        let (id, _rx) = c.register();
1492        c.remove(id);
1493        assert_eq!(c.pending_count(), 0);
1494    }
1495
1496    #[test]
1497    fn extract_stream_flag_detects_and_strips_marker() {
1498        assert_eq!(extract_stream_flag(None), (false, None));
1499        assert_eq!(
1500            extract_stream_flag(Some("a=1&b=2")),
1501            (false, Some("a=1&b=2".to_string()))
1502        );
1503        assert_eq!(
1504            extract_stream_flag(Some("a=1&__stream=1&b=2")),
1505            (true, Some("a=1&b=2".to_string()))
1506        );
1507        // Bare marker, nothing else left.
1508        assert_eq!(extract_stream_flag(Some("__stream")), (true, None));
1509        // Explicit disable.
1510        assert_eq!(extract_stream_flag(Some("__stream=0")), (false, None));
1511    }
1512
1513    #[test]
1514    fn forwardable_headers_drops_control_headers() {
1515        let mut h = axum::http::HeaderMap::new();
1516        h.insert("host", "localhost:9998".parse().unwrap());
1517        h.insert("authorization", "Bearer x".parse().unwrap());
1518        h.insert("x-omni-bridge", "1".parse().unwrap());
1519        h.insert("accept", "application/json".parse().unwrap());
1520        let out = forwardable_headers(&h);
1521        assert!(!out.contains_key("host"));
1522        assert!(!out.contains_key("authorization"));
1523        assert!(!out.contains_key("x-omni-bridge"));
1524        assert_eq!(
1525            out.get("accept").map(String::as_str),
1526            Some("application/json")
1527        );
1528    }
1529
1530    #[test]
1531    fn envelope_to_response_passes_text_body_through() {
1532        let env = ResponseEnvelope {
1533            id: 1,
1534            status: 200,
1535            headers: BTreeMap::new(),
1536            body: "hello".into(),
1537            encoding: None,
1538        };
1539        assert_eq!(envelope_to_response(env).status(), StatusCode::OK);
1540    }
1541
1542    #[test]
1543    fn envelope_to_response_rejects_invalid_base64() {
1544        // `dispatch` validates base64 before this runs, so this path is only
1545        // reachable defensively — assert it still fails closed with 502.
1546        let env = ResponseEnvelope {
1547            id: 1,
1548            status: 200,
1549            headers: BTreeMap::new(),
1550            body: "not valid base64 @@@".into(),
1551            encoding: Some("base64".into()),
1552        };
1553        assert_eq!(envelope_to_response(env).status(), StatusCode::BAD_GATEWAY);
1554    }
1555
1556    use futures::FutureExt;
1557}