Skip to main content

bezant_server/
routes.rs

1//! HTTP route handlers.
2//!
3//! The server mostly acts as an untyped pass-through: it takes the CPAPI
4//! path, hits the Gateway via the `bezant::Client`'s inner reqwest client,
5//! and forwards the JSON body back to the caller. This lets downstream
6//! apps in any language consume CPAPI over plain HTTP without touching
7//! the typed Rust layer.
8//!
9//! A handful of handlers (like `/health`) use the typed facade because
10//! they project the raw response into a narrower shape.
11
12use axum::body::Body;
13use axum::extract::{Path, Query, State};
14use axum::http::{header, HeaderMap, HeaderValue, Response, StatusCode};
15use axum::response::IntoResponse;
16use axum::routing::{delete, get, post};
17use axum::{Json, Router};
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20
21use crate::error::AppError;
22use crate::state::AppState;
23
24/// Build the full axum router wired to `state`. Exposed for integration
25/// tests that want to drive the router without opening a TCP listener.
26pub fn router(state: AppState) -> Router {
27    Router::new()
28        .route("/health", get(health))
29        .route("/health/sso", get(health_sso))
30        .route("/debug/jar", get(debug_jar))
31        .route("/debug/probe", get(debug_probe))
32        .route("/accounts", get(accounts))
33        .route("/accounts/{account_id}/summary", get(account_summary))
34        .route("/accounts/{account_id}/positions", get(account_positions))
35        .route("/accounts/{account_id}/ledger", get(account_ledger))
36        .route(
37            "/accounts/{account_id}/orders",
38            get(account_orders).post(submit_order),
39        )
40        .route(
41            "/accounts/{account_id}/orders/{order_id}",
42            delete(cancel_order),
43        )
44        .route("/contracts/search", get(contract_search))
45        .route("/market/snapshot", get(market_snapshot))
46        .route("/events/orders", get(events_orders))
47        .route("/events/pnl", get(events_pnl))
48        .route("/events/marketdata", get(events_marketdata))
49        .route("/events/gap", get(events_gap))
50        .route("/events/_status", get(events_status))
51        .route("/events/_reconnect", post(events_reconnect))
52        .route("/events/{topic}/history", get(events_history))
53        // Anything we haven't explicitly wrapped falls through to the
54        // Gateway verbatim. The big reason this matters: the CPGateway's
55        // interactive login flow (`/sso/Login`, static JS/CSS/img assets,
56        // `/v1/api/iserver/auth/ssodh/init`, …) has to run through
57        // *this* HTTP client so its cookie jar captures the session. Any
58        // future endpoints we add to bezant-server just take precedence
59        // over this catch-all via specific routes.
60        .fallback(passthrough_any)
61        .with_state(state)
62}
63
64/// Headers we MUST NOT forward through a proxy hop. Subset of RFC 7230 §6.1
65/// (`host`, `content-length`, `connection`, `keep-alive`, `proxy-*`, `te`,
66/// `trailer`, `transfer-encoding`, `upgrade`) extended with:
67///
68/// * **`cookie`** — reqwest's shared jar is the single source of truth.
69/// * **`authorization`** — CPGateway doesn't consume bearer/basic auth;
70///   forwarding lets a caller probe whatever auth scheme the upstream
71///   might (incorrectly) honour. Pure attack surface, drop it.
72/// * **`x-forwarded-*` / `forwarded` / `x-real-ip`** — caller-controlled
73///   client-IP claims. Forwarding lets a caller spoof their apparent
74///   source IP to anything that logs/rate-limits/audits on those headers
75///   downstream. Strip at the boundary; the proxy itself doesn't need
76///   them.
77const HOP_BY_HOP: &[&str] = &[
78    "host",
79    "content-length",
80    "connection",
81    "keep-alive",
82    "proxy-authenticate",
83    "proxy-authorization",
84    "te",
85    "trailer",
86    "transfer-encoding",
87    "upgrade",
88    "cookie",
89    "authorization",
90    "x-forwarded-for",
91    "x-forwarded-host",
92    "x-forwarded-proto",
93    "x-real-ip",
94    "forwarded",
95];
96
97fn is_hop_by_hop(name: &str) -> bool {
98    HOP_BY_HOP.iter().any(|h| name.eq_ignore_ascii_case(h))
99}
100
101// `skip_all` because `req` and `state` aren't `Display`; we emit
102// only the safe scalars (method + path) as span fields. Path is
103// already query-stripped — see the `path_only` derivation below.
104#[tracing::instrument(skip_all, fields(method, path))]
105async fn passthrough_any(
106    State(state): State<AppState>,
107    req: axum::extract::Request,
108) -> Result<Response<Body>, AppError> {
109    use axum::http::Method;
110    let method = req.method().clone();
111    let path_and_query = req
112        .uri()
113        .path_and_query()
114        .map(|pq| pq.as_str())
115        .unwrap_or("/")
116        .to_string();
117    // For logs we keep just the path — query strings frequently carry SSO
118    // tokens / session ids that we don't want fanned out into log shippers.
119    let path_only = req.uri().path().to_string();
120    // Record into the parent span so the rest of the handler's
121    // tracing events inherit them automatically.
122    tracing::Span::current().record("method", tracing::field::display(&method));
123    tracing::Span::current().record("path", tracing::field::display(&path_only));
124
125    // Compose the target URL: Gateway root + the incoming URI. We use
126    // the client's own derived root (scheme + host + trailing '/')
127    // instead of hand-trimming the base URL so a non-standard prefix
128    // doesn't silently break passthrough.
129    let gateway_root = state.client().gateway_root_url().as_str();
130    let target = format!("{}{}", gateway_root.trim_end_matches('/'), path_and_query);
131    let target_url: reqwest::Url = target
132        .parse()
133        .map_err(|e| bezant::Error::BadRequest(format!("target url: {e}")))?;
134
135    let headers = req.headers().clone();
136
137    // Replay any cookies the browser sent into the shared jar so typed
138    // API calls (`/health`, `/accounts`, …) see the same session that
139    // the interactive login established.
140    //
141    // The jar (`bezant::NameKeyedJar`) keys cookies purely by name, so
142    // inserting `JSESSIONID=NEW` always replaces `JSESSIONID=OLD` —
143    // duplicates can't accumulate even if the Gateway sets the same
144    // cookie at different paths in different responses. CPGateway
145    // rejects requests that arrive with two values for the same cookie
146    // name, so this single-source-of-truth model is required.
147    //
148    // **Trust model:** bezant-server is single-tenant. The shared jar
149    // is intentionally visible to *all* server-side typed callers.
150    // Don't deploy this proxy multi-tenant.
151    let jar = state.client().cookie_jar();
152    let mut pairs: Vec<&str> = Vec::new();
153    for cookie_header in headers.get_all(axum::http::header::COOKIE) {
154        if let Ok(raw) = cookie_header.to_str() {
155            for pair in raw.split(';') {
156                let trimmed = pair.trim();
157                if trimmed.is_empty() {
158                    continue;
159                }
160                // Drop edge-proxy auth cookies (Cloudflare Access /
161                // Cloudflare-style infrastructure cookies). They're set
162                // by the proxy layer for *its* session — not anything
163                // CPGateway / api.ibkr.com expects, and Akamai 401s the
164                // upstream call when an unrecognised `CF_Authorization=…`
165                // cookie shows up alongside the IBKR session cookies.
166                let name = trimmed.split('=').next().unwrap_or("").trim();
167                if is_edge_auth_cookie(name) {
168                    continue;
169                }
170                pairs.push(trimmed);
171            }
172        }
173    }
174    let injected = pairs.len();
175    if injected > 0 {
176        jar.set_pairs(&pairs);
177    }
178    // The Railway-deploy diagnostic phase is over (the residential-Pi
179    // pattern is settled). Cookie replay is a per-request event and
180    // belongs at debug level — no need to fan it out to log shippers.
181    tracing::debug!(
182        path = %path_only,
183        cookies = injected,
184        "passthrough cookie replay"
185    );
186
187    let body_bytes = axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024)
188        .await
189        .map_err(|e| bezant::Error::BadRequest(format!("read body: {e}")))?;
190
191    let method_reqwest = reqwest::Method::from_bytes(method.as_str().as_bytes())
192        .map_err(|e| bezant::Error::BadRequest(format!("method: {e}")))?;
193    // Origin/Referer policy is path-conditional:
194    //   * `/sso/*` (the interactive login flow) keeps the browser's
195    //     `Origin` verbatim — IBKR's 2FA polling validates it as part
196    //     of its session check, and rewriting it silently breaks the
197    //     `/sso/Authenticator` poll.
198    //   * `/v1/api/*` (CPAPI calls — the post-login surface) rewrites
199    //     `Origin` to the Gateway's own host so its CPAPI CSRF guard
200    //     accepts the call. Without this, post-login `/v1/api/*`
201    //     returns 401 when the proxy is on a different public host
202    //     than the Gateway thinks it's running on (Railway, fly.io,
203    //     ngrok, …).
204    let rewrite_origin = path_only.starts_with("/v1/api/") || path_only == "/v1/api";
205    let gateway_origin = if rewrite_origin {
206        let scheme = target_url.scheme();
207        target_url.host_str().map(|h| match target_url.port() {
208            Some(p) => format!("{scheme}://{h}:{p}"),
209            None => format!("{scheme}://{h}"),
210        })
211    } else {
212        None
213    };
214    let mut builder = state.client().http().request(method_reqwest, &target);
215    for (name, value) in headers.iter() {
216        // Drop hop-by-hop headers per RFC 7230 §6.1 plus `host`/`cookie`
217        // (reqwest rebuilds the former, the shared jar replaces the
218        // latter).
219        if is_hop_by_hop(name.as_str()) {
220            continue;
221        }
222        let lower = name.as_str().to_ascii_lowercase();
223        if let Some(ref origin) = gateway_origin {
224            if lower == "origin" {
225                if let Ok(v) = reqwest::header::HeaderValue::from_str(origin) {
226                    builder = builder.header(reqwest::header::ORIGIN, v);
227                }
228                continue;
229            }
230            if lower == "referer" {
231                // Replace the origin prefix of the Referer URL but keep
232                // the path/query — the upstream uses the path to drive
233                // post-login redirects, so we don't want to lose it.
234                if let Ok(orig) = value.to_str() {
235                    let rewritten = rewrite_referer_origin(orig, origin);
236                    if let Ok(v) = reqwest::header::HeaderValue::from_str(&rewritten) {
237                        builder = builder.header(reqwest::header::REFERER, v);
238                    }
239                }
240                continue;
241            }
242        }
243        if let Ok(v) = reqwest::header::HeaderValue::from_bytes(value.as_bytes()) {
244            if let Ok(name) = reqwest::header::HeaderName::from_bytes(name.as_str().as_bytes()) {
245                builder = builder.header(name, v);
246            }
247        }
248    }
249    if method != Method::GET && method != Method::HEAD {
250        // Pin Content-Length even for empty bodies — Akamai (fronting
251        // the CPAPI) returns 411 if the POST arrives with neither
252        // Content-Length nor Transfer-Encoding, which is the wire
253        // shape reqwest/hyper can produce for an empty Vec.
254        let len = body_bytes.len();
255        builder = builder
256            .header(reqwest::header::CONTENT_LENGTH, len.to_string())
257            .body(body_bytes.to_vec());
258    }
259
260    let resp = builder.send().await.map_err(bezant::Error::Http)?;
261
262    // Watch the SSO bridge go past. This single call is the one whose failure
263    // means no login can EVER complete, and nothing else in the system could
264    // see it: /health, auth/status and the process table all look healthy
265    // while it 500s. On 2026-09-03 that hid a wedged Gateway for eleven hours
266    // and looked, from the outside, exactly like 2FA not working.
267    //
268    // Free to record here — the relogin service and the watchdog already route
269    // these calls through this proxy — and it turns a silent outage into a
270    // field anything can read. See AppState::record_sso_bridge.
271    if path_only.ends_with("/iserver/auth/ssodh/init") {
272        state.record_sso_bridge(resp.status().as_u16());
273    }
274
275    forward(resp).await
276}
277
278#[derive(Serialize)]
279struct HealthBody {
280    authenticated: bool,
281    connected: bool,
282    competing: bool,
283    message: Option<String>,
284    /// Last observed state of the SSO bridge. Omitted until one has been seen.
285    ///
286    /// `authenticated:false` alone cannot distinguish "waiting for a human to
287    /// log in" from "cannot complete a login at all", and on 2026-09-03 that
288    /// ambiguity hid a wedged Gateway for eleven hours. This is the field that
289    /// tells them apart.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    sso_bridge: Option<SsoBridgeBody>,
292}
293
294/// Body of `/health/sso`. Always 200, in every state.
295///
296/// `/health` cannot carry this reliably: it answers 200 with
297/// `authenticated:false` in one flavour of logged-out and 401 with
298/// `code:not_authenticated` in the other, and a field on the success body
299/// disappears in the second — which is precisely where a wedged bridge is
300/// likely to be sitting. Rather than grow the shared `ErrorBody` a
301/// feature-specific field, the diagnostic gets its own endpoint that cannot
302/// fail.
303#[derive(Debug, Serialize)]
304struct SsoStatusBody {
305    /// False when no bridge call has passed through yet. Distinguished from a
306    /// healthy bridge on purpose: "no evidence" and "evidence of health" are
307    /// different answers, and only one of them should ever justify acting.
308    observed: bool,
309    #[serde(skip_serializing_if = "Option::is_none")]
310    status: Option<u16>,
311    #[serde(skip_serializing_if = "Option::is_none")]
312    at: Option<u64>,
313    consecutive_faults: u32,
314    wedged: bool,
315}
316
317#[derive(Debug, Serialize)]
318struct SsoBridgeBody {
319    /// HTTP status of the most recent `iserver/auth/ssodh/init`.
320    status: u16,
321    /// Unix seconds when that status was observed.
322    at: u64,
323    /// Consecutive 5xx responses. Non-zero means logins cannot complete.
324    consecutive_faults: u32,
325    /// True once the fault run is long enough to call the bridge wedged.
326    wedged: bool,
327}
328
329/// Diagnostic-only endpoint: lists shared cookie jar entries by name
330/// (and value length — never value itself). Useful when chasing
331/// "browser is logged in but typed /health says not_authenticated"
332/// issues without leaking the live IBKR session cookie to anyone who
333/// can hit the bind address.
334///
335/// Gated on `BEZANT_DEBUG_TOKEN`: returns 404 when no token is
336/// configured, 401 when the caller's token doesn't match. Callers
337/// authenticate via `?token=…` or the `X-Bezant-Debug-Token` header.
338async fn debug_jar(
339    State(state): State<AppState>,
340    headers: HeaderMap,
341    Query(q): Query<HashMap<String, String>>,
342) -> Response<Body> {
343    if let Err(resp) = debug_auth(&state, &headers, &q) {
344        return resp;
345    }
346    let jar = state.client().cookie_jar();
347    let entries: Vec<serde_json::Value> = jar
348        .snapshot()
349        .into_iter()
350        .map(|(name, value)| {
351            serde_json::json!({
352                "name": name,
353                "value_length": value.len(),
354            })
355        })
356        .collect();
357    let body = serde_json::json!({
358        "gateway_root": state.client().gateway_root_url().as_str(),
359        "size": entries.len(),
360        "entries": entries,
361    });
362    Json(body).into_response()
363}
364
365/// Token check shared by every `/debug/*` handler.
366///
367/// Returns a 404 response when debug is disabled (no token
368/// configured) so the existence of the endpoints isn't disclosed to
369/// a probing attacker — they look identical to any other unmapped
370/// route.
371///
372/// Returns a 401 response when a token IS configured but the caller
373/// didn't present a matching one (different status because the
374/// endpoint clearly exists; the caller is just unauthorised).
375///
376/// Token comparison is constant-time to avoid leaking length or
377/// prefix-match info via response timing.
378#[allow(clippy::result_large_err)]
379// `Response<Body>` is the return type axum hands back; boxing it would
380// just add an alloc on the (rare) auth-failure path. Suppress the lint.
381fn debug_auth(
382    state: &AppState,
383    headers: &HeaderMap,
384    query: &HashMap<String, String>,
385) -> Result<(), Response<Body>> {
386    let Some(expected) = state.debug_token() else {
387        return Err(Response::builder()
388            .status(StatusCode::NOT_FOUND)
389            .body(Body::empty())
390            .unwrap_or_default());
391    };
392    let presented = headers
393        .get("x-bezant-debug-token")
394        .and_then(|v| v.to_str().ok())
395        .or_else(|| query.get("token").map(String::as_str))
396        .unwrap_or("");
397    if constant_time_eq(presented.as_bytes(), expected.as_bytes()) {
398        return Ok(());
399    }
400    Err(Response::builder()
401        .status(StatusCode::UNAUTHORIZED)
402        .header(header::CONTENT_TYPE, "application/json")
403        .body(Body::from(
404            r#"{"code":"debug_unauthorized","message":"missing or invalid debug token"}"#,
405        ))
406        .unwrap_or_default())
407}
408
409/// Constant-time byte comparison so token mismatch can't be timed.
410/// Naive `==` short-circuits on first differing byte, leaking length
411/// + prefix-match info via response timing.
412fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
413    if a.len() != b.len() {
414        return false;
415    }
416    let mut diff = 0u8;
417    for (x, y) in a.iter().zip(b.iter()) {
418        diff |= x ^ y;
419    }
420    diff == 0
421}
422
423/// Diagnostic-only endpoint: walks the post-login CPAPI sequence
424/// (`auth/status` → `ssodh/init` → `tickle` → `portfolio/accounts`)
425/// against the Gateway and reports each step's status, latency, body
426/// preview, and Set-Cookie names — *plus* a top-level `verdict` that
427/// pins down which step diverges from the happy path.
428///
429/// Built to discriminate between proxy-layer regressions and upstream
430/// failures (e.g. CPGateway's internal SSODH bridge to `api.ibkr.com`
431/// being rejected from a datacenter egress IP). The probe never aborts
432/// on a step failure — it runs all four so the full picture is in
433/// the response body.
434///
435/// Response shape:
436/// ```json
437/// {
438///   "gateway_root": "https://localhost:5000/",
439///   "elapsed_ms": 412,
440///   "jar_size_before": 7,
441///   "jar_size_after": 7,
442///   "verdict": "ok",
443///   "steps": [{ "name": "auth_status", "status": 200, ... }, ...]
444/// }
445/// ```
446///
447/// Returns 200 with the diagnostic body when authenticated for debug
448/// (diagnostic-step failures surface in the body, not the HTTP status).
449/// Returns 404-style error when debug isn't enabled, 401 when the
450/// caller's debug token is missing/wrong.
451async fn debug_probe(
452    State(state): State<AppState>,
453    headers: HeaderMap,
454    Query(q): Query<HashMap<String, String>>,
455) -> Response<Body> {
456    if let Err(resp) = debug_auth(&state, &headers, &q) {
457        return resp;
458    }
459    let client = state.client();
460    let started = std::time::Instant::now();
461    let jar_before = client.cookie_jar().snapshot().len();
462
463    let auth_status = probe_step(
464        client,
465        "auth_status",
466        reqwest::Method::POST,
467        &["iserver", "auth", "status"],
468        None,
469    )
470    .await;
471    // `ssodh/init` is the bridge-establish call. Issuing it against a
472    // session that's *already* bridged tears the session down — every
473    // subsequent call then 401s. We discovered this the hard way: probe
474    // would show `auth_status:200, ssodh_init:401, accounts:401` and
475    // make a working session look broken. So skip the bridge step when
476    // auth_status already reports `authenticated:true`; the diagnostic
477    // is meant to *observe*, not perturb.
478    let already_bridged = is_authenticated(&auth_status);
479    let ssodh_init = if already_bridged {
480        skipped_step(
481            "ssodh_init",
482            "POST",
483            "/v1/api/iserver/auth/ssodh/init",
484            "session already bridged (auth_status authenticated)",
485        )
486    } else {
487        probe_step(
488            client,
489            "ssodh_init",
490            reqwest::Method::POST,
491            &["iserver", "auth", "ssodh", "init"],
492            Some(serde_json::json!({ "publish": true, "compete": true })),
493        )
494        .await
495    };
496    let tickle = probe_step(client, "tickle", reqwest::Method::POST, &["tickle"], None).await;
497    let accounts = probe_step(
498        client,
499        "accounts",
500        reqwest::Method::GET,
501        &["portfolio", "accounts"],
502        None,
503    )
504    .await;
505
506    let verdict = compute_verdict(&auth_status, &ssodh_init, &tickle, &accounts);
507    let jar_after = client.cookie_jar().snapshot().len();
508
509    let body = serde_json::json!({
510        "gateway_root": client.gateway_root_url().as_str(),
511        "elapsed_ms": started.elapsed().as_millis() as u64,
512        "jar_size_before": jar_before,
513        "jar_size_after": jar_after,
514        "verdict": verdict,
515        "steps": [auth_status, ssodh_init, tickle, accounts],
516    });
517    Json(body).into_response()
518}
519
520/// One step in the diagnostic probe.
521///
522/// Builds the request the same way the real proxy / typed client does:
523/// pins `Content-Length` (Akamai 411 workaround), rewrites `Origin` and
524/// `Referer` to the Gateway's own origin (CPAPI CSRF guard), and
525/// captures the response without touching the shared cookie jar's
526/// happy-path semantics — every Set-Cookie still flows back into the
527/// jar via reqwest's cookie provider.
528async fn probe_step(
529    client: &bezant::Client,
530    name: &'static str,
531    method: reqwest::Method,
532    path_segments: &[&str],
533    body: Option<serde_json::Value>,
534) -> serde_json::Value {
535    let mut url = client.base_url().clone();
536    if let Ok(mut segs) = url.path_segments_mut() {
537        for seg in path_segments {
538            segs.push(seg);
539        }
540    }
541    let path_for_log = url.path().to_owned();
542
543    let gateway_origin = client
544        .gateway_root_url()
545        .as_str()
546        .trim_end_matches('/')
547        .to_owned();
548
549    let mut builder = client
550        .http()
551        .request(method.clone(), url.clone())
552        .header(reqwest::header::ORIGIN, &gateway_origin)
553        .header(reqwest::header::REFERER, format!("{gateway_origin}/"));
554
555    let body_bytes: Vec<u8> = match (&method, body) {
556        (m, _) if m == reqwest::Method::GET || m == reqwest::Method::HEAD => Vec::new(),
557        (_, Some(json)) => serde_json::to_vec(&json).unwrap_or_default(),
558        (_, None) => Vec::new(),
559    };
560    if method != reqwest::Method::GET && method != reqwest::Method::HEAD {
561        builder = builder
562            .header(
563                reqwest::header::CONTENT_LENGTH,
564                body_bytes.len().to_string(),
565            )
566            .body(body_bytes.clone());
567        if !body_bytes.is_empty() {
568            builder = builder.header(reqwest::header::CONTENT_TYPE, "application/json");
569        }
570    }
571
572    let started = std::time::Instant::now();
573    // Per-step deadline: a hung Gateway shouldn't take the whole
574    // probe with it. 5s is generous for the small JSON payloads we
575    // hit; tickle/auth_status/accounts all return in <500 ms in
576    // healthy operation.
577    let result = match tokio::time::timeout(std::time::Duration::from_secs(5), builder.send()).await
578    {
579        Ok(send_result) => send_result.map_err(|e| e.to_string()),
580        Err(_) => Err("step timed out after 5s".to_string()),
581    };
582    let latency_ms = started.elapsed().as_millis() as u64;
583
584    match result {
585        Ok(resp) => {
586            let status = resp.status().as_u16();
587            let set_cookie_names: Vec<String> = resp
588                .headers()
589                .get_all(reqwest::header::SET_COOKIE)
590                .iter()
591                .filter_map(|v| v.to_str().ok())
592                .map(|raw| {
593                    raw.split(';')
594                        .next()
595                        .and_then(|s| s.split('=').next())
596                        .map(|s| s.trim().to_owned())
597                        .unwrap_or_default()
598                })
599                .filter(|s| !s.is_empty())
600                .collect();
601            // Cap the upstream read at 1 MiB so a misbehaving Gateway
602            // can't OOM the probe with an unbounded body. Probe targets
603            // are JSON status payloads — any response over 1 MiB is
604            // already broken, the cap just bounds the damage.
605            let bytes = read_capped(resp, 1024 * 1024).await.unwrap_or_default();
606            // Parse the FULL body (not the truncated preview) for
607            // discriminator fields the verdict logic needs. Doing it
608            // here means `compute_verdict` can't be misled by a
609            // response whose `authenticated` field happens to land
610            // past the 512-byte preview window.
611            let parsed_authenticated = serde_json::from_slice::<serde_json::Value>(&bytes)
612                .ok()
613                .and_then(|v| v["authenticated"].as_bool());
614            // Redact known token-bearing keys from the preview before
615            // exposing it to the operator — `session`, `ssoConclusion`,
616            // and any field with `token` in the name commonly carry
617            // resumable session material that shouldn't ride out via
618            // a debug endpoint or log shipper.
619            let preview_len = bytes.len().min(512);
620            let raw_preview = String::from_utf8_lossy(&bytes[..preview_len]).into_owned();
621            let body_preview = redact_tokens(&raw_preview);
622            serde_json::json!({
623                "name": name,
624                "method": method.as_str(),
625                "path": path_for_log,
626                "status": status,
627                "latency_ms": latency_ms,
628                "body_bytes": bytes.len(),
629                "body_preview": body_preview,
630                "set_cookie_names": set_cookie_names,
631                "error": serde_json::Value::Null,
632                "_authenticated": parsed_authenticated,
633            })
634        }
635        Err(e) => serde_json::json!({
636            "name": name,
637            "method": method.as_str(),
638            "path": path_for_log,
639            "status": serde_json::Value::Null,
640            "latency_ms": latency_ms,
641            "body_bytes": 0,
642            "body_preview": "",
643            "set_cookie_names": [],
644            "error": e,
645        }),
646    }
647}
648
649/// Best-effort redaction of token-bearing JSON keys in a body preview.
650///
651/// Walks the JSON once, replaces values for keys named `session`,
652/// `ssoConclusion`, or anything containing `token` (case-insensitive)
653/// with `"<redacted>"`. Falls back to returning the input verbatim if
654/// the preview isn't valid JSON — this is a best-effort guard, not a
655/// security boundary.
656///
657/// Used by `/debug/probe` because preview output ships across HTTP
658/// (and into the operator's terminal / log shipper) — leaking a live
659/// session token there would let anyone with read access to the
660/// debug response resume the IBKR session.
661fn redact_tokens(preview: &str) -> String {
662    let Ok(mut value) = serde_json::from_str::<serde_json::Value>(preview) else {
663        // Not JSON — return verbatim. Most non-JSON CPAPI responses
664        // are status pages or HTML error bodies that don't carry
665        // tokens.
666        return preview.to_owned();
667    };
668    redact_in_place(&mut value);
669    value.to_string()
670}
671
672fn redact_in_place(value: &mut serde_json::Value) {
673    match value {
674        serde_json::Value::Object(map) => {
675            for (k, v) in map.iter_mut() {
676                let lower = k.to_ascii_lowercase();
677                if lower == "session"
678                    || lower == "ssoconclusion"
679                    || lower.contains("token")
680                    || lower.contains("secret")
681                {
682                    *v = serde_json::Value::String("<redacted>".to_owned());
683                } else {
684                    redact_in_place(v);
685                }
686            }
687        }
688        serde_json::Value::Array(arr) => {
689            for v in arr.iter_mut() {
690                redact_in_place(v);
691            }
692        }
693        _ => {}
694    }
695}
696
697/// Pin the failure to the first diverging step.
698///
699/// `auth_status` carries an extra parse: a 200 with `authenticated:false`
700/// is a "needs_login" situation, not a transport success. Subsequent
701/// steps are pure HTTP-status checks — any non-2xx pins the verdict to
702/// that step's name. A `skipped` step is treated as success (the probe
703/// chose not to run it because it would have been destructive).
704fn compute_verdict(
705    auth_status: &serde_json::Value,
706    ssodh_init: &serde_json::Value,
707    tickle: &serde_json::Value,
708    accounts: &serde_json::Value,
709) -> &'static str {
710    if !is_2xx(auth_status) {
711        return "auth_status_failed";
712    }
713    // ssodh_init is the canonical Railway-vs-Pi discriminator: when
714    // auth_status reports unauthenticated AND a manual bridge attempt
715    // also fails, that pins the failure to the SSODH leg upstream of
716    // bezant-server. Check this BEFORE the needs_login short-circuit
717    // so a real ssodh failure surfaces with its own verdict instead of
718    // collapsing into the generic needs_login bucket.
719    let ssodh_ran_and_failed = !is_skipped(ssodh_init) && !is_2xx(ssodh_init);
720    if ssodh_ran_and_failed {
721        return "ssodh_failed";
722    }
723    if !is_authenticated(auth_status) {
724        return "needs_login";
725    }
726    if !is_2xx_or_skipped(tickle) {
727        return "tickle_failed";
728    }
729    if !is_2xx_or_skipped(accounts) {
730        return "accounts_failed";
731    }
732    "ok"
733}
734
735fn is_skipped(step: &serde_json::Value) -> bool {
736    step["skipped"].as_bool().unwrap_or(false)
737}
738
739fn is_2xx(step: &serde_json::Value) -> bool {
740    matches!(step["status"].as_u64(), Some(200..=299))
741}
742
743fn is_2xx_or_skipped(step: &serde_json::Value) -> bool {
744    is_2xx(step) || step["skipped"].as_bool().unwrap_or(false)
745}
746
747/// Decide whether the session is already bridged. Reads the
748/// `_authenticated` discriminator that `probe_step` extracted from the
749/// FULL response body (not the 512-byte preview), so an `authenticated`
750/// field that lands past the preview window doesn't silently flip the
751/// verdict to "needs_login" and trigger the destructive ssodh path.
752fn is_authenticated(step: &serde_json::Value) -> bool {
753    if !is_2xx(step) {
754        return false;
755    }
756    step["_authenticated"].as_bool().unwrap_or(false)
757}
758
759/// Build a placeholder step entry for a step the probe deliberately
760/// did not execute. Surfaces in the JSON body so a reader can tell
761/// "didn't fail, didn't run" apart from "ran and succeeded".
762fn skipped_step(
763    name: &'static str,
764    method: &'static str,
765    path: &'static str,
766    reason: &'static str,
767) -> serde_json::Value {
768    serde_json::json!({
769        "name": name,
770        "method": method,
771        "path": path,
772        "status": serde_json::Value::Null,
773        "latency_ms": 0,
774        "body_bytes": 0,
775        "body_preview": "",
776        "set_cookie_names": [],
777        "error": serde_json::Value::Null,
778        "skipped": true,
779        "skipped_reason": reason,
780    })
781}
782
783/// Consecutive `ssodh/init` 5xx responses before `/health` calls it wedged.
784/// Deliberately small: the endpoint either works or it does not, and a run of
785/// three leaves no reasonable doubt.
786const SSO_WEDGED_AFTER: u32 = 3;
787
788/// Read the SSO bridge state. Never fails, never probes — it reports what has
789/// already gone past the proxy. See [`SsoStatusBody`].
790#[tracing::instrument(skip_all)]
791async fn health_sso(State(state): State<AppState>) -> Json<SsoStatusBody> {
792    Json(state.sso_bridge().map_or(
793        SsoStatusBody {
794            observed: false,
795            status: None,
796            at: None,
797            consecutive_faults: 0,
798            wedged: false,
799        },
800        |b| SsoStatusBody {
801            observed: true,
802            status: Some(b.status),
803            at: Some(b.at),
804            consecutive_faults: b.consecutive_faults,
805            wedged: b.consecutive_faults >= SSO_WEDGED_AFTER,
806        },
807    ))
808}
809
810/// `/health` error code for "the Gateway is up, but IBKR behind it is not
811/// answering". The Client Portal Gateway is a proxy to `api.ibkr.com`: a
812/// 5xx from its `auth/status` means the Gateway process took our request
813/// and could not get an answer upstream (IBKR maintenance, Akamai, the
814/// host's route out). A restart cannot fix that and logs the session out,
815/// so it must not look like the Gateway itself being down —
816/// `upstream_unreachable` (connection refused) means exactly that.
817///
818/// The HTTP status is the Gateway's own 5xx, unchanged from before this
819/// code existed, so a client that only reads the status sees no change.
820pub(crate) const GATEWAY_UPSTREAM_FAILING: &str = "gateway_upstream_failing";
821
822/// `Some(response)` when `err` is the Gateway answering a 5xx — see
823/// [`GATEWAY_UPSTREAM_FAILING`].
824fn gateway_upstream_failing(err: &bezant::Error) -> Option<Response<Body>> {
825    let bezant::Error::UpstreamStatus { status, .. } = err else {
826        return None;
827    };
828    let code = StatusCode::from_u16(*status).ok()?;
829    if !code.is_server_error() {
830        return None;
831    }
832    tracing::warn!(
833        upstream_status = status,
834        "health: gateway is up but its upstream (api.ibkr.com) is failing"
835    );
836    Some(
837        (
838            code,
839            Json(serde_json::json!({
840                "code": GATEWAY_UPSTREAM_FAILING,
841                "message": format!(
842                    "the IBKR Gateway answered, but its auth/status failed upstream with HTTP {status}; \
843                     the Gateway process is up — restarting it will not help and logs the session out"
844                ),
845                "gateway_reachable": true,
846                "upstream_status": status,
847            })),
848        )
849            .into_response(),
850    )
851}
852
853#[tracing::instrument(skip_all)]
854async fn health(State(state): State<AppState>) -> Result<Response<Body>, AppError> {
855    let status = match state.client().auth_status().await {
856        Ok(s) => s,
857        Err(e) => return gateway_upstream_failing(&e).ok_or_else(|| e.into()),
858    };
859    Ok(Json(HealthBody {
860        authenticated: status.authenticated,
861        connected: status.connected,
862        competing: status.competing,
863        message: status.message,
864        sso_bridge: state.sso_bridge().map(|b| SsoBridgeBody {
865            status: b.status,
866            at: b.at,
867            consecutive_faults: b.consecutive_faults,
868            wedged: b.consecutive_faults >= SSO_WEDGED_AFTER,
869        }),
870    })
871    .into_response())
872}
873
874#[tracing::instrument(skip_all)]
875async fn accounts(State(state): State<AppState>) -> Result<Response<Body>, AppError> {
876    passthrough_get(&state, &["portfolio", "accounts"], &[]).await
877}
878
879#[tracing::instrument(skip(state), fields(account_id = %account_id))]
880async fn account_summary(
881    State(state): State<AppState>,
882    Path(account_id): Path<String>,
883) -> Result<Response<Body>, AppError> {
884    passthrough_get(&state, &["portfolio", account_id.as_str(), "summary"], &[]).await
885}
886
887#[derive(Deserialize, Debug)]
888struct PositionsQuery {
889    #[serde(default)]
890    page: u32,
891}
892
893#[tracing::instrument(skip(state), fields(account_id = %account_id, page = q.page))]
894async fn account_positions(
895    State(state): State<AppState>,
896    Path(account_id): Path<String>,
897    Query(q): Query<PositionsQuery>,
898) -> Result<Response<Body>, AppError> {
899    let page = q.page.to_string();
900    passthrough_get(
901        &state,
902        &["portfolio", account_id.as_str(), "positions", page.as_str()],
903        &[],
904    )
905    .await
906}
907
908#[tracing::instrument(skip(state), fields(account_id = %account_id))]
909async fn account_ledger(
910    State(state): State<AppState>,
911    Path(account_id): Path<String>,
912) -> Result<Response<Body>, AppError> {
913    passthrough_get(&state, &["portfolio", account_id.as_str(), "ledger"], &[]).await
914}
915
916/// List live + recently-filled orders for one account.
917#[tracing::instrument(skip(state), fields(account_id = %account_id))]
918async fn account_orders(
919    State(state): State<AppState>,
920    Path(account_id): Path<String>,
921) -> Result<Response<Body>, AppError> {
922    // CPAPI exposes this under /iserver/account/orders?accountId=…
923    passthrough_get(
924        &state,
925        &["iserver", "account", "orders"],
926        &[("accountId", account_id.as_str())],
927    )
928    .await
929}
930
931/// Submit one or more orders for an account.
932///
933/// The body is forwarded verbatim to `POST /iserver/account/{id}/orders`.
934/// CPAPI accepts either `{ "orders": [...] }` or a single order object; we
935/// stay out of the way and let IBKR's own validator surface errors.
936#[tracing::instrument(skip(state, body), fields(account_id = %account_id))]
937async fn submit_order(
938    State(state): State<AppState>,
939    Path(account_id): Path<String>,
940    axum::extract::Json(body): axum::extract::Json<serde_json::Value>,
941) -> Result<Response<Body>, AppError> {
942    let mut url = state.client().base_url().clone();
943    {
944        let mut segs = url
945            .path_segments_mut()
946            .map_err(|()| bezant::Error::UrlNotABase {
947                url: state.client().base_url().to_string(),
948            })?;
949        segs.push("iserver")
950            .push("account")
951            .push(account_id.as_str())
952            .push("orders");
953    }
954    let resp = state
955        .client()
956        .http()
957        .post(url)
958        .json(&body)
959        .send()
960        .await
961        .map_err(bezant::Error::Http)?;
962    forward(resp).await
963}
964
965/// Cancel a live order.
966#[tracing::instrument(skip(state), fields(account_id = %account_id, order_id = %order_id))]
967async fn cancel_order(
968    State(state): State<AppState>,
969    Path((account_id, order_id)): Path<(String, String)>,
970) -> Result<Response<Body>, AppError> {
971    let mut url = state.client().base_url().clone();
972    {
973        let mut segs = url
974            .path_segments_mut()
975            .map_err(|()| bezant::Error::UrlNotABase {
976                url: state.client().base_url().to_string(),
977            })?;
978        segs.push("iserver")
979            .push("account")
980            .push(account_id.as_str())
981            .push("order")
982            .push(order_id.as_str());
983    }
984    let resp = state
985        .client()
986        .http()
987        .delete(url)
988        .send()
989        .await
990        .map_err(bezant::Error::Http)?;
991    forward(resp).await
992}
993
994#[derive(Deserialize)]
995struct ContractSearchQuery {
996    symbol: String,
997    #[serde(default)]
998    name: bool,
999    #[serde(rename = "secType", default = "default_sec_type")]
1000    sec_type: String,
1001}
1002
1003fn default_sec_type() -> String {
1004    "STK".into()
1005}
1006
1007async fn contract_search(
1008    State(state): State<AppState>,
1009    Query(q): Query<ContractSearchQuery>,
1010) -> Result<Response<Body>, AppError> {
1011    // Symbol lookup is a POST with a JSON body on the CPAPI side.
1012    let mut url = state.client().base_url().clone();
1013    {
1014        let mut segs = url
1015            .path_segments_mut()
1016            .map_err(|()| bezant::Error::UrlNotABase {
1017                url: state.client().base_url().to_string(),
1018            })?;
1019        segs.push("iserver").push("secdef").push("search");
1020    }
1021    let body = serde_json::json!({
1022        "symbol": q.symbol,
1023        "name": q.name,
1024        "secType": q.sec_type,
1025    });
1026    let resp = state
1027        .client()
1028        .http()
1029        .post(url)
1030        .json(&body)
1031        .send()
1032        .await
1033        .map_err(bezant::Error::Http)?;
1034    forward(resp).await
1035}
1036
1037async fn market_snapshot(
1038    State(state): State<AppState>,
1039    Query(q): Query<HashMap<String, String>>,
1040) -> Result<Response<Body>, AppError> {
1041    let conids = q
1042        .get("conids")
1043        .ok_or(bezant::Error::MissingQuery { name: "conids" })?;
1044    let fields = q
1045        .get("fields")
1046        .cloned()
1047        .unwrap_or_else(|| "31,84,86,87".into());
1048    passthrough_get(
1049        &state,
1050        &["iserver", "marketdata", "snapshot"],
1051        &[("conids", conids), ("fields", &fields)],
1052    )
1053    .await
1054}
1055
1056/// Shared pass-through helper: builds `<base>/a/b/c`, appends query params,
1057/// forwards the Gateway response verbatim (status + content-type + body).
1058async fn passthrough_get(
1059    state: &AppState,
1060    path_segments: &[&str],
1061    query: &[(&str, &str)],
1062) -> Result<Response<Body>, AppError> {
1063    let mut url = state.client().base_url().clone();
1064    {
1065        let mut segs = url
1066            .path_segments_mut()
1067            .map_err(|()| bezant::Error::UrlNotABase {
1068                url: state.client().base_url().to_string(),
1069            })?;
1070        for seg in path_segments {
1071            segs.push(seg);
1072        }
1073    }
1074    if !query.is_empty() {
1075        let mut q = url.query_pairs_mut();
1076        for (k, v) in query {
1077            q.append_pair(k, v);
1078        }
1079    }
1080    let resp = state
1081        .client()
1082        .http()
1083        .get(url)
1084        .send()
1085        .await
1086        .map_err(bezant::Error::Http)?;
1087    forward(resp).await
1088}
1089
1090/// Forward a `reqwest::Response` as an axum response.
1091///
1092/// What we do, header by header:
1093/// * **Hop-by-hop** (`content-length`, `transfer-encoding`, `connection`,
1094///   `keep-alive`, `te`, `trailer`, `upgrade`, `proxy-authenticate`,
1095///   `proxy-authorization`) are dropped — RFC 7230 §6.1.
1096/// * **`Set-Cookie`** has any `Domain=` attribute stripped. The Gateway's
1097///   upstream (IBKR) sets `Domain=.ibkr.com`, which the browser silently
1098///   discards when the response arrives from a different host. Falling
1099///   back to a host-only cookie keeps the SSO flow working behind any
1100///   hostname; the `Secure`, `HttpOnly`, `SameSite`, and `Path` flags
1101///   are preserved.
1102/// * **`Content-Type`** is rewritten / defaulted in two cases (skipped
1103///   when the body is empty *or* the status is 1xx/204/304):
1104///     - upstream sent `application/octet-stream` for what's actually a
1105///       text/HTML response (CPGateway does this on `/sso/Dispatcher`),
1106///     - upstream sent no Content-Type at all and our `Vec<u8>` body
1107///       would default to `application/octet-stream` (which makes
1108///       browsers offer to download instead of rendering).
1109/// * **Body decode failures** are tolerated *only* on 1xx/204/304/3xx,
1110///   where any body is required-or-conventionally empty. On 2xx/4xx/5xx
1111///   the decode failure surfaces as a normal upstream-transport error so
1112///   real data loss can't slip through silently.
1113///
1114/// `Origin` and `Referer` on the *request* side are deliberately
1115/// forwarded verbatim — see `passthrough_any`.
1116/// Maximum upstream response body the proxy will buffer. CPAPI
1117/// payloads are JSON status / position / order objects — anything
1118/// over a few hundred KB is already malformed. Cap at 25 MiB so a
1119/// hostile or buggy upstream sending unbounded chunks can't OOM the
1120/// proxy. (Inbound side has the matching 10 MiB limit via
1121/// `RequestBodyLimitLayer`.)
1122const MAX_UPSTREAM_BODY_BYTES: usize = 25 * 1024 * 1024;
1123
1124#[tracing::instrument(skip_all, fields(upstream_status = %resp.status()))]
1125async fn forward(resp: reqwest::Response) -> Result<Response<Body>, AppError> {
1126    let status = resp.status();
1127    let headers_src = resp.headers().clone();
1128    let body_must_be_empty =
1129        matches!(status.as_u16(), 100..=199 | 204 | 304) || status.is_redirection();
1130
1131    let bytes: Vec<u8> = match read_capped(resp, MAX_UPSTREAM_BODY_BYTES).await {
1132        Ok(b) => b,
1133        Err(e) if body_must_be_empty => {
1134            // Reqwest sometimes errors finalising chunked-encoded
1135            // empty bodies on 3xx/204/304; the headers we care about
1136            // (Location, Set-Cookie) are already in `headers_src`, so
1137            // recover instead of 502-ing the redirect.
1138            tracing::debug!(
1139                %status,
1140                error = %e,
1141                "forward: empty-body fallback on no-body status"
1142            );
1143            Vec::new()
1144        }
1145        Err(e) => {
1146            return Err(bezant::Error::UpstreamStatus {
1147                endpoint: "passthrough",
1148                status: status.as_u16(),
1149                body_preview: Some(e),
1150            }
1151            .into())
1152        }
1153    };
1154
1155    let body_is_empty = bytes.is_empty();
1156
1157    let status = StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
1158
1159    let mut headers = HeaderMap::new();
1160    let mut had_content_type = false;
1161    for (name, value) in headers_src.iter() {
1162        let n = name.as_str().to_ascii_lowercase();
1163        if is_hop_by_hop_response(&n) {
1164            continue;
1165        }
1166        // Set-Cookie may legitimately appear multiple times.
1167        if n == "set-cookie" {
1168            let value_bytes: Vec<u8> = match value.to_str() {
1169                Ok(raw) => strip_cookie_domain(raw).into_bytes(),
1170                Err(_) => value.as_bytes().to_vec(),
1171            };
1172            if let (Ok(name), Ok(value)) = (
1173                header::HeaderName::from_bytes(name.as_str().as_bytes()),
1174                HeaderValue::from_bytes(&value_bytes),
1175            ) {
1176                headers.append(name, value);
1177            }
1178            continue;
1179        }
1180        // Content-Type: rewrite octet-stream → text/html only when there
1181        // *is* a body and the status normally has one. Use `insert` so
1182        // we never accidentally emit two Content-Type headers.
1183        if n == "content-type" {
1184            let raw = value.to_str().unwrap_or("");
1185            let rewrite = !body_is_empty
1186                && !body_must_be_empty
1187                && raw.eq_ignore_ascii_case("application/octet-stream");
1188            let bytes_to_emit: &[u8] = if rewrite {
1189                b"text/html; charset=UTF-8"
1190            } else {
1191                value.as_bytes()
1192            };
1193            if let (Ok(name), Ok(value)) = (
1194                header::HeaderName::from_bytes(name.as_str().as_bytes()),
1195                HeaderValue::from_bytes(bytes_to_emit),
1196            ) {
1197                headers.insert(name, value);
1198                had_content_type = true;
1199            }
1200            continue;
1201        }
1202        if let (Ok(name), Ok(value)) = (
1203            header::HeaderName::from_bytes(name.as_str().as_bytes()),
1204            HeaderValue::from_bytes(value.as_bytes()),
1205        ) {
1206            headers.append(name, value);
1207        }
1208    }
1209
1210    // Default Content-Type to text/html only when we actually have a
1211    // body to render. On 1xx/204/304/3xx we leave it absent to comply
1212    // with RFC 9110 §8.3.
1213    if !had_content_type && !body_is_empty && !body_must_be_empty {
1214        headers.insert(
1215            header::CONTENT_TYPE,
1216            HeaderValue::from_static("text/html; charset=UTF-8"),
1217        );
1218    }
1219
1220    // Build the response body directly instead of via
1221    // `(status, headers, bytes).into_response()`, because the latter
1222    // unconditionally inserts `Content-Type: application/octet-stream`
1223    // when none is set — which would defeat the explicit "no
1224    // Content-Type on 204" branch above.
1225    let mut response = Response::builder()
1226        .status(status)
1227        .body(Body::from(bytes))
1228        .map_err(|e| bezant::Error::ResponseBuild(e.to_string()))?;
1229    *response.headers_mut() = headers;
1230    Ok(response)
1231}
1232
1233/// Response-side hop-by-hop denylist.
1234fn is_hop_by_hop_response(name: &str) -> bool {
1235    matches!(
1236        name,
1237        "content-length"
1238            | "connection"
1239            | "keep-alive"
1240            | "proxy-authenticate"
1241            | "proxy-authorization"
1242            | "te"
1243            | "trailer"
1244            | "transfer-encoding"
1245            | "upgrade"
1246    )
1247}
1248
1249/// Replace the scheme + host\[:port\] prefix of `original` with
1250/// `new_origin`, preserving path + query. Used to rewrite the Referer
1251/// header on `/v1/api/*` requests when the proxy lives on a different
1252/// host than the Gateway.
1253fn rewrite_referer_origin(original: &str, new_origin: &str) -> String {
1254    match url::Url::parse(original) {
1255        Ok(u) => {
1256            let mut path_and_query = u.path().to_owned();
1257            if let Some(q) = u.query() {
1258                path_and_query.push('?');
1259                path_and_query.push_str(q);
1260            }
1261            format!("{}{}", new_origin.trim_end_matches('/'), path_and_query)
1262        }
1263        Err(_) => new_origin.to_owned(),
1264    }
1265}
1266
1267/// Recognise cookies set by an edge-proxy / Zero-Trust front so we
1268/// don't replay them into bezant-server's shared jar — they're for
1269/// the proxy's own session and confuse upstream CPAPI/Akamai.
1270///
1271/// Built-in matches cover the common managed Zero-Trust providers:
1272/// * **Cloudflare Access** — `CF_Authorization` (JWT), `CF_AppSession`
1273/// * **AWS ALB OIDC** — `AWSELBAuthSessionCookie-*` (split across
1274///   multiple cookies on long sessions)
1275/// * **OAuth2 Proxy** — `_oauth2_proxy*`
1276/// * **Vercel Authentication** — `_vercel_jwt`, `_vercel_sso_nonce`
1277/// * **Pomerium** — `_pomerium*`
1278///
1279/// For deployments behind a custom edge, set `BEZANT_EDGE_COOKIE_PREFIXES`
1280/// to a comma-separated list of additional prefixes — e.g.
1281/// `BEZANT_EDGE_COOKIE_PREFIXES=MyEdge_,_internal_` — and any cookie
1282/// whose name starts with one of those is dropped at the boundary.
1283fn is_edge_auth_cookie(name: &str) -> bool {
1284    const BUILTIN_PREFIXES: &[&str] = &[
1285        "CF_Authorization",
1286        "CF_AppSession",
1287        "AWSELBAuthSessionCookie",
1288        "_oauth2_proxy",
1289        "_vercel_jwt",
1290        "_vercel_sso_nonce",
1291        "_pomerium",
1292    ];
1293    if BUILTIN_PREFIXES
1294        .iter()
1295        .any(|p| name.eq_ignore_ascii_case(p) || name.starts_with(p))
1296    {
1297        return true;
1298    }
1299    // Lazy env lookup: keeps the helper cheap for the no-config case.
1300    if let Ok(extra) = std::env::var("BEZANT_EDGE_COOKIE_PREFIXES") {
1301        for prefix in extra.split(',').map(str::trim).filter(|p| !p.is_empty()) {
1302            if name.starts_with(prefix) {
1303                return true;
1304            }
1305        }
1306    }
1307    false
1308}
1309
1310/// Stream an upstream response body into a `Vec<u8>` while enforcing a
1311/// hard byte cap. Stops as soon as the cap is exceeded so a hostile
1312/// upstream sending unbounded chunks can't OOM the proxy. Returns the
1313/// fully buffered bytes on success, or an error if the cap is hit or
1314/// the upstream connection breaks. Used by both `forward()` (for
1315/// passthrough responses, large cap) and `probe_step` (smaller cap,
1316/// for diagnostic JSON).
1317async fn read_capped(resp: reqwest::Response, max: usize) -> std::result::Result<Vec<u8>, String> {
1318    use futures_util::StreamExt;
1319    let mut bytes = Vec::new();
1320    let mut stream = resp.bytes_stream();
1321    while let Some(chunk) = stream.next().await {
1322        let chunk = chunk.map_err(|e| format!("read chunk: {e}"))?;
1323        if bytes.len() + chunk.len() > max {
1324            return Err(format!(
1325                "upstream body exceeded {max} byte cap (>{}B)",
1326                bytes.len() + chunk.len()
1327            ));
1328        }
1329        bytes.extend_from_slice(&chunk);
1330    }
1331    Ok(bytes)
1332}
1333
1334/// Strip any `Domain=...` attribute from a Set-Cookie value. The browser
1335/// will fall back to a host-only cookie scoped to whatever host it saw
1336/// the response on, which is what we want when proxying a cookie that
1337/// the Gateway pinned to `Domain=.ibkr.com`.
1338fn strip_cookie_domain(value: &str) -> String {
1339    value
1340        .split(';')
1341        .filter(|part| !part.trim().to_ascii_lowercase().starts_with("domain="))
1342        .collect::<Vec<_>>()
1343        .join(";")
1344}
1345
1346// ===========================================================================
1347// /events/* handlers — cursor-based reads against the in-memory ring buffers
1348// maintained by the connector task. See `events::connector::EventsHandle`.
1349// ===========================================================================
1350
1351/// Query string for `/events/{topic}` reads.
1352#[derive(Debug, Deserialize)]
1353struct EventsQuery {
1354    /// Resume from this cursor (exclusive). `0` (or omitted) means "from
1355    /// the head of the buffer".
1356    #[serde(default)]
1357    since: u64,
1358    /// Maximum number of events to return. Defaults to `100`, capped at
1359    /// `1000` server-side to bound response size.
1360    #[serde(default = "EventsQuery::default_limit")]
1361    limit: usize,
1362}
1363
1364impl EventsQuery {
1365    fn default_limit() -> usize {
1366        100
1367    }
1368}
1369
1370/// 412 body when the caller's cursor falls past the ring buffer's head.
1371#[derive(Debug, Serialize)]
1372struct CursorExpiredBody {
1373    code: &'static str,
1374    head_cursor: u64,
1375    reset_epoch: u64,
1376    message: &'static str,
1377}
1378
1379async fn events_orders(
1380    State(state): State<AppState>,
1381    Query(q): Query<EventsQuery>,
1382) -> Response<Body> {
1383    read_events_topic(&state, "orders", q).await
1384}
1385
1386async fn events_pnl(State(state): State<AppState>, Query(q): Query<EventsQuery>) -> Response<Body> {
1387    read_events_topic(&state, "pnl", q).await
1388}
1389
1390async fn events_gap(State(state): State<AppState>, Query(q): Query<EventsQuery>) -> Response<Body> {
1391    read_events_topic(&state, "gap", q).await
1392}
1393
1394#[derive(Debug, Deserialize)]
1395struct MarketDataEventsQuery {
1396    conid: i64,
1397    #[serde(default)]
1398    since: u64,
1399    #[serde(default = "EventsQuery::default_limit")]
1400    limit: usize,
1401}
1402
1403async fn events_marketdata(
1404    State(state): State<AppState>,
1405    Query(q): Query<MarketDataEventsQuery>,
1406) -> Response<Body> {
1407    let Some(handle) = state.events() else {
1408        return events_disabled_response();
1409    };
1410    // Lazily ensure the upstream WS is subscribed for this conid.
1411    if let Err(e) = handle.ensure_market_data(q.conid).await {
1412        return (
1413            StatusCode::BAD_GATEWAY,
1414            Json(serde_json::json!({
1415                "code": "events_subscribe_failed",
1416                "message": e,
1417            })),
1418        )
1419            .into_response();
1420    }
1421
1422    let topic = format!("marketdata:{}", q.conid);
1423    let limit = q.limit.min(1_000);
1424    read_events_topic_resolved(handle, &topic, q.since, limit).await
1425}
1426
1427async fn events_status(State(state): State<AppState>) -> Response<Body> {
1428    let Some(handle) = state.events() else {
1429        return events_disabled_response();
1430    };
1431    Json(handle.status().await).into_response()
1432}
1433
1434/// `POST /events/_reconnect` — drop the connector's socket and reconnect
1435/// now, skipping any backoff. The soft first step when the stream has gone
1436/// quiet: cheaper than `/iserver/reauthenticate` and far cheaper than a
1437/// container restart, which logs the Gateway out.
1438///
1439/// Gated by the debug token like `/debug/*` (404 without one configured,
1440/// 401 on a wrong one) rather than by source address: behind Docker's
1441/// port mapping or a tunnel every caller looks local.
1442///
1443/// 202 `{code: "reconnect_requested", was_connected}` once the connector
1444/// has taken the request; 503 `events_connector_unavailable` if it could
1445/// not.
1446async fn events_reconnect(
1447    State(state): State<AppState>,
1448    headers: HeaderMap,
1449    Query(q): Query<HashMap<String, String>>,
1450) -> Response<Body> {
1451    if let Err(resp) = debug_auth(&state, &headers, &q) {
1452        return resp;
1453    }
1454    let Some(handle) = state.events() else {
1455        return events_disabled_response();
1456    };
1457    match handle.request_reconnect().await {
1458        Ok(was_connected) => {
1459            tracing::info!(was_connected, "events: reconnect requested over HTTP");
1460            (
1461                StatusCode::ACCEPTED,
1462                Json(serde_json::json!({
1463                    "code": "reconnect_requested",
1464                    "was_connected": was_connected,
1465                })),
1466            )
1467                .into_response()
1468        }
1469        Err(message) => (
1470            StatusCode::SERVICE_UNAVAILABLE,
1471            Json(serde_json::json!({
1472                "code": "events_connector_unavailable",
1473                "message": message,
1474            })),
1475        )
1476            .into_response(),
1477    }
1478}
1479
1480#[derive(Debug, Deserialize)]
1481struct HistoryQuery {
1482    /// RFC 3339 timestamp lower bound (exclusive). Required.
1483    since_ts: String,
1484    /// Cap on returned rows. Default 500, server cap 5000.
1485    #[serde(default = "HistoryQuery::default_limit")]
1486    limit: usize,
1487}
1488
1489impl HistoryQuery {
1490    fn default_limit() -> usize {
1491        500
1492    }
1493}
1494
1495async fn events_history(
1496    State(state): State<AppState>,
1497    Path(topic): Path<String>,
1498    Query(q): Query<HistoryQuery>,
1499) -> Response<Body> {
1500    let Some(handle) = state.events() else {
1501        return events_disabled_response();
1502    };
1503    let Some(log) = handle.event_log() else {
1504        return (
1505            StatusCode::SERVICE_UNAVAILABLE,
1506            Json(serde_json::json!({
1507                "code": "events_history_disabled",
1508                "message": "events sqlite persistence is not enabled \
1509                            (set BEZANT_EVENTS_DB_PATH to turn it on)"
1510            })),
1511        )
1512            .into_response();
1513    };
1514    let limit = q.limit.clamp(1, 5_000);
1515    let log_clone = log.clone();
1516    let topic_clone = topic.clone();
1517    let since_ts = q.since_ts.clone();
1518    let result =
1519        tokio::task::spawn_blocking(move || log_clone.query_since(&topic_clone, &since_ts, limit))
1520            .await;
1521    match result {
1522        Ok(Ok(events)) => {
1523            let body = serde_json::json!({
1524                "topic": topic,
1525                "events": events,
1526                "count": events.len(),
1527            });
1528            (StatusCode::OK, Json(body)).into_response()
1529        }
1530        Ok(Err(e)) => (
1531            StatusCode::INTERNAL_SERVER_ERROR,
1532            Json(serde_json::json!({
1533                "code": "events_history_query_failed",
1534                "message": e.to_string(),
1535            })),
1536        )
1537            .into_response(),
1538        Err(e) => (
1539            StatusCode::INTERNAL_SERVER_ERROR,
1540            Json(serde_json::json!({
1541                "code": "events_history_join_failed",
1542                "message": e.to_string(),
1543            })),
1544        )
1545            .into_response(),
1546    }
1547}
1548
1549async fn read_events_topic(state: &AppState, topic: &str, q: EventsQuery) -> Response<Body> {
1550    let Some(handle) = state.events() else {
1551        return events_disabled_response();
1552    };
1553    read_events_topic_resolved(handle, topic, q.since, q.limit.min(1_000)).await
1554}
1555
1556async fn read_events_topic_resolved(
1557    handle: &crate::events::EventsHandle,
1558    topic: &str,
1559    since: u64,
1560    limit: usize,
1561) -> Response<Body> {
1562    use crate::events::ReadResult;
1563
1564    let (result, ring_exists) = match handle.read_topic(topic, since, limit.max(1)).await {
1565        Some(r) => (r, true),
1566        // No event has arrived for this topic yet. Answer as an empty ring
1567        // in this process's cursor space would — never by echoing the
1568        // caller's cursor, which may belong to a previous process.
1569        None => (handle.read_empty_topic(topic, since).await, false),
1570    };
1571
1572    match result {
1573        ReadResult::Ok {
1574            events,
1575            next_cursor,
1576            reset_epoch,
1577        } => {
1578            // Caught up on a live ring — 204 to make "nothing happened"
1579            // cheap to detect on the client side without parsing a body.
1580            // The headers carry what a body would: the cursor to keep and
1581            // the live epoch, so a client that reads them can see a reset
1582            // without waiting for the next event.
1583            if events.is_empty() && next_cursor == since && ring_exists {
1584                let mut response = Response::builder()
1585                    .status(StatusCode::NO_CONTENT)
1586                    .body(Body::empty())
1587                    .unwrap_or_default();
1588                let headers = response.headers_mut();
1589                if let Ok(v) = HeaderValue::from_str(&next_cursor.to_string()) {
1590                    headers.insert("x-bezant-cursor", v);
1591                }
1592                if let Ok(v) = HeaderValue::from_str(&reset_epoch.to_string()) {
1593                    headers.insert("x-bezant-reset-epoch", v);
1594                }
1595                return response;
1596            }
1597            // `reset_epoch` is the ring's live epoch, not the first event's:
1598            // a batch that straddles a reconnect must report the new one.
1599            let body = serde_json::json!({
1600                "events": events,
1601                "next_cursor": next_cursor,
1602                "reset_epoch": reset_epoch,
1603            });
1604            (StatusCode::OK, Json(body)).into_response()
1605        }
1606        ReadResult::CursorExpired {
1607            head_cursor,
1608            reset_epoch,
1609        } => {
1610            let body = CursorExpiredBody {
1611                code: "cursor_expired",
1612                head_cursor,
1613                reset_epoch,
1614                message: "the requested cursor is not in this ring (older than the oldest \
1615                     buffered event, or issued by a previous process); reset to \
1616                     head_cursor - 1 and emit a synthetic gap on the consumer side",
1617            };
1618            (StatusCode::PRECONDITION_FAILED, Json(body)).into_response()
1619        }
1620    }
1621}
1622
1623fn events_disabled_response() -> Response<Body> {
1624    (
1625        StatusCode::SERVICE_UNAVAILABLE,
1626        Json(serde_json::json!({
1627            "code": "events_disabled",
1628            "message": "events capture is not enabled on this bezant-server instance \
1629                        (set BEZANT_EVENTS_ENABLED=1 to turn it on)"
1630        })),
1631    )
1632        .into_response()
1633}
1634
1635#[cfg(test)]
1636mod redact_tests {
1637    use super::redact_tokens;
1638
1639    #[test]
1640    fn redacts_session_field() {
1641        let input = r#"{"session":"AAAA-real-token","other":1}"#;
1642        let out = redact_tokens(input);
1643        assert!(out.contains("<redacted>"), "got: {out}");
1644        assert!(!out.contains("AAAA-real-token"), "raw token leaked: {out}");
1645        // Non-token fields preserved.
1646        assert!(out.contains("\"other\":1"), "got: {out}");
1647    }
1648
1649    #[test]
1650    fn redacts_token_substring_keys() {
1651        let input = r#"{"accessToken":"x","refresh_token":"y","tokenExpiry":99}"#;
1652        let out = redact_tokens(input);
1653        assert!(!out.contains(r#""x""#), "accessToken leaked: {out}");
1654        assert!(!out.contains(r#""y""#), "refresh_token leaked: {out}");
1655        // tokenExpiry has "token" in the name → also redacted (intentional;
1656        // expiry timestamps don't carry secrets but the conservative bias
1657        // is correct for a debug surface).
1658        assert!(!out.contains("99"), "tokenExpiry value leaked: {out}");
1659    }
1660
1661    #[test]
1662    fn passes_non_json_through_verbatim() {
1663        let input = "Not a JSON body, just text.";
1664        let out = redact_tokens(input);
1665        assert_eq!(out, input);
1666    }
1667
1668    #[test]
1669    fn redacts_inside_arrays_and_nested_objects() {
1670        let input = r#"{"sessions":[{"session":"a"},{"session":"b"}]}"#;
1671        let out = redact_tokens(input);
1672        assert!(!out.contains(r#""a""#), "got: {out}");
1673        assert!(!out.contains(r#""b""#), "got: {out}");
1674    }
1675}
1676
1677#[cfg(test)]
1678mod forward_tests {
1679    use super::strip_cookie_domain;
1680
1681    #[test]
1682    fn drops_ibkr_domain_attribute() {
1683        let input = "SID=abc; Domain=.ibkr.com; Path=/; Secure";
1684        // Whitespace retention is deliberate — we only drop the Domain
1685        // segment and keep the original spacing elsewhere.
1686        assert_eq!(strip_cookie_domain(input), "SID=abc; Path=/; Secure");
1687    }
1688
1689    #[test]
1690    fn leaves_cookie_without_domain_untouched() {
1691        let input = "SID=abc; Path=/; Secure";
1692        assert_eq!(strip_cookie_domain(input), input);
1693    }
1694
1695    #[test]
1696    fn case_insensitive() {
1697        let input = "SID=abc; DOMAIN=ibkr.com; Path=/";
1698        assert_eq!(strip_cookie_domain(input), "SID=abc; Path=/");
1699    }
1700}