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