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}