Skip to main content

bezant_server/
state.rs

1//! Shared application state.
2
3use std::sync::Arc;
4
5use crate::events::EventsHandle;
6
7/// State shared across all axum handlers.
8#[derive(Clone)]
9pub struct AppState {
10    inner: Arc<Inner>,
11}
12
13/// Last thing the SSO bridge (`iserver/auth/ssodh/init`) said, observed as
14/// those calls pass through the proxy.
15///
16/// Why this is worth keeping: the Gateway can be ALIVE AND WRONG. On
17/// 2026-09-03 that endpoint returned HTTP 500 for eleven hours while
18/// `/health`, `auth/status` and the process table all looked perfect —
19/// meaning no login could complete, and the only symptom anyone could see was
20/// 2FA "not working". A restart cleared it and the same call answered 401.
21///
22/// Recorded PASSIVELY rather than probed on a timer: the relogin service and
23/// the watchdog already POST this endpoint through us, so watching what goes
24/// by costs nothing and cannot add load to a Gateway that is already unwell.
25#[derive(Debug, Clone, Copy)]
26pub struct SsoBridge {
27    /// HTTP status of the most recent `ssodh/init` seen.
28    pub status: u16,
29    /// Unix seconds when it was seen.
30    pub at: u64,
31    /// Consecutive 5xx. 401 is healthy-but-logged-out and resets this.
32    pub consecutive_faults: u32,
33}
34
35struct Inner {
36    client: bezant::Client,
37    /// See [`SsoBridge`]. `None` until a bridge call has been observed.
38    sso_bridge: std::sync::Mutex<Option<SsoBridge>>,
39    /// Optional token guarding the `/debug/*` endpoints. When `None`,
40    /// debug endpoints return 404. When `Some`, callers must present a
41    /// matching token via `?token=…` query string or
42    /// `X-Bezant-Debug-Token` header.
43    debug_token: Option<String>,
44    /// Handle to the optional events-capture connector. `None` disables
45    /// the `/events/*` routes (they return 503).
46    events: Option<EventsHandle>,
47}
48
49impl AppState {
50    /// Build app state from a configured [`bezant::Client`].
51    ///
52    /// Debug endpoints are disabled by default. Use
53    /// [`AppState::with_debug_token`] to enable them with token gating.
54    /// Events are disabled by default. Use [`AppState::with_events`] to
55    /// attach a connector handle.
56    #[must_use]
57    pub fn new(client: bezant::Client) -> Self {
58        Self {
59            inner: Arc::new(Inner {
60                client,
61                sso_bridge: std::sync::Mutex::new(None),
62                debug_token: None,
63                events: None,
64            }),
65        }
66    }
67
68    /// Enable the `/debug/*` endpoints, requiring the given token on
69    /// every request (via `?token=…` or `X-Bezant-Debug-Token` header).
70    /// Without this, all `/debug/*` routes 404.
71    ///
72    /// **Security:** the cookie jar holds live IBKR session cookies
73    /// — anyone who can read it can resume the IBKR session and
74    /// trade the account. Pick a long, random token (>=32 bytes
75    /// from `/dev/urandom`) and treat it like a credential.
76    #[must_use]
77    pub fn with_debug_token(client: bezant::Client, token: impl Into<String>) -> Self {
78        Self {
79            inner: Arc::new(Inner {
80                client,
81                sso_bridge: std::sync::Mutex::new(None),
82                debug_token: Some(token.into()),
83                events: None,
84            }),
85        }
86    }
87
88    /// Return a new state with the given events handle attached. The
89    /// handle is what powers `/events/*` reads. Without it, those routes
90    /// return 503.
91    #[must_use]
92    pub fn with_events(self, events: EventsHandle) -> Self {
93        let inner = Inner {
94            client: self.inner.client.clone(),
95            // Carry the observation across, rather than resetting it: this is
96            // called during startup wiring and a fault seen before the events
97            // handle attaches is still a fault.
98            sso_bridge: std::sync::Mutex::new(
99                *self
100                    .inner
101                    .sso_bridge
102                    .lock()
103                    .unwrap_or_else(|e| e.into_inner()),
104            ),
105            debug_token: self.inner.debug_token.clone(),
106            events: Some(events),
107        };
108        Self {
109            inner: Arc::new(inner),
110        }
111    }
112
113    /// Borrow the underlying Bezant client.
114    #[must_use]
115    pub fn client(&self) -> &bezant::Client {
116        &self.inner.client
117    }
118
119    /// Record what the SSO bridge just answered. Called from the proxy as
120    /// `ssodh/init` responses pass through — see [`SsoBridge`].
121    ///
122    /// A poisoned lock is recovered from rather than propagated: this is
123    /// observability, and it must never be the reason a proxied trading call
124    /// fails.
125    pub fn record_sso_bridge(&self, status: u16) {
126        let at = std::time::SystemTime::now()
127            .duration_since(std::time::UNIX_EPOCH)
128            .map_or(0, |d| d.as_secs());
129        let mut slot = self
130            .inner
131            .sso_bridge
132            .lock()
133            .unwrap_or_else(|e| e.into_inner());
134        let previous_faults = slot.map_or(0, |b: SsoBridge| b.consecutive_faults);
135        *slot = Some(SsoBridge {
136            status,
137            at,
138            consecutive_faults: if status >= 500 {
139                previous_faults.saturating_add(1)
140            } else {
141                0
142            },
143        });
144    }
145
146    /// The last observed SSO bridge state, if any call has been seen.
147    #[must_use]
148    pub fn sso_bridge(&self) -> Option<SsoBridge> {
149        *self
150            .inner
151            .sso_bridge
152            .lock()
153            .unwrap_or_else(|e| e.into_inner())
154    }
155
156    /// Borrow the configured debug token, if any.
157    #[must_use]
158    pub fn debug_token(&self) -> Option<&str> {
159        self.inner.debug_token.as_deref()
160    }
161
162    /// Borrow the events handle, if attached.
163    #[must_use]
164    pub fn events(&self) -> Option<&EventsHandle> {
165        self.inner.events.as_ref()
166    }
167}