brain_edge/lib.rs
1//! brain-edge — the HTTP/JSON edge for the Brain memory database, as a library.
2//!
3//! Brain speaks a binary wire protocol (CBOR over TCP). This crate translates
4//! the hosted `/v1/*` HTTP/JSON API to that wire protocol via the Brain SDK. It
5//! is both:
6//!
7//! - a **binary** ([`run`]) — the self-hostable front door; point any HTTP
8//! client at it and the self-host experience matches the cloud, only the base
9//! URL differs; and
10//! - a **library** — the Arc cloud gateway embeds [`app::router`] and injects
11//! its own [`CredentialResolver`] + [`MeteringSink`] (via
12//! [`EdgeState::with_ports`]) to add API-key auth and usage metering without
13//! forking the wire↔JSON translation layer.
14//!
15//! The two deployments share one engine: the same DTOs, the same mapping, the
16//! same per-credential connection pool. They differ only in the two injected
17//! ports.
18
19pub mod app;
20pub mod config;
21pub mod crypto;
22pub mod dto;
23pub mod error;
24pub mod pool;
25pub mod port;
26pub mod state;
27pub mod wire_proxy;
28
29pub use config::EdgeConfig;
30pub use error::ApiError;
31pub use pool::{BrainPool, BrainPoolConfig};
32pub use port::{
33 BearerResolver, CredentialResolver, MeterEvent, MeteringSink, NoopMeter, Outcome,
34 ResolvedCredential, bearer_token,
35};
36pub use state::EdgeState;
37pub use wire_proxy::{RateLimitConfig, WireProxyConfig};
38
39/// The per-request effective-identity selector the gateway sets when running a
40/// [`BrainPool::shared`] service pool: build one with `ActAs { namespace,
41/// space_id }` (or the SDK request builders' `.act_as(..)`) to run each op as a
42/// resolved tenant on behalf of the trusted service principal. Re-exported so a
43/// gateway consuming `brain_edge` need not also name `brain_db_sdk` directly.
44pub use brain_db_sdk::wire::types::ActAs;
45
46use std::time::Duration;
47
48use axum::Router;
49use axum::extract::State;
50use axum::http::{HeaderName, StatusCode, header};
51use axum::response::IntoResponse;
52use axum::routing::get;
53use tower_http::catch_panic::CatchPanicLayer;
54use tower_http::limit::RequestBodyLimitLayer;
55use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
56use tower_http::sensitive_headers::SetSensitiveRequestHeadersLayer;
57use tower_http::timeout::TimeoutLayer;
58use tower_http::trace::TraceLayer;
59use tracing::{error, info};
60
61/// The header carrying the correlation id, in and out.
62const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
63
64/// Turn a panicking handler into a `500` instead of a dropped connection.
65///
66/// Without this a panic aborts the task and hyper closes the socket with no
67/// response: the caller sees a connection reset with no status, no body, and
68/// nothing to quote in a bug report. The panic is logged, and the caller gets
69/// the same JSON envelope as every other failure — carrying the request id, so
70/// the two ends can be joined up afterwards.
71fn panic_to_response(err: &Box<dyn std::any::Any + Send + 'static>) -> axum::response::Response {
72 // `panic!` and `unwrap` produce a `&str` or a `String`; anything else is
73 // opaque and there is nothing useful to extract.
74 let detail: &str = err.downcast_ref::<&str>().copied().unwrap_or_else(|| {
75 err.downcast_ref::<String>()
76 .map_or("non-string panic payload", String::as_str)
77 });
78 ApiError::internal(format!("handler panicked: {detail}")).into_response()
79}
80
81/// Apply the transport-level protections every deployment needs.
82///
83/// Panic recovery, correlation ids, header redaction, request tracing, a
84/// request timeout, and a request-body cap. [`run`] applies these;
85/// [`app::router`] deliberately does not, because a host application merges
86/// that router under its own middleware stack.
87///
88/// This exists so an embedder does not have to rediscover the list. The body
89/// cap is the one most easily missed: [`EdgeConfig::max_body_bytes`] is a
90/// visible, validated knob, so a gateway author can reasonably assume the
91/// router already honours it. It does not — only this function does.
92///
93/// # Arguments
94///
95/// * `request_timeout` — a stalled Brain must not hang a request forever; past
96/// this the request is cut with an explicit `408`.
97/// * `max_body_bytes` — an oversized body must not be buffered into memory.
98pub fn harden(router: Router, request_timeout: Duration, max_body_bytes: usize) -> Router {
99 router
100 // A panic must not take the connection down silently. Innermost, so it
101 // wraps the handler rather than the middleware around it.
102 .layer(CatchPanicLayer::custom(|e| panic_to_response(&e)))
103 // Redact credential headers so they never reach the log sink, then trace
104 // every request. Order matters: mark-sensitive is outermost so it wraps
105 // the trace layer's view of the headers.
106 .layer(TraceLayer::new_for_http())
107 .layer(SetSensitiveRequestHeadersLayer::new([
108 header::AUTHORIZATION,
109 HeaderName::from_static("x-api-key"),
110 ]))
111 .layer(TimeoutLayer::with_status_code(
112 StatusCode::REQUEST_TIMEOUT,
113 request_timeout,
114 ))
115 .layer(RequestBodyLimitLayer::new(max_body_bytes))
116 // Correlation, outermost so every response carries it — including the
117 // ones the layers above generate without reaching a handler (413 from
118 // the body cap, 408 from the timeout). A caller-supplied
119 // `x-request-id` is kept, so an id assigned upstream survives the hop.
120 .layer(PropagateRequestIdLayer::new(REQUEST_ID_HEADER))
121 .layer(SetRequestIdLayer::new(REQUEST_ID_HEADER, MakeRequestUuid))
122}
123
124/// Run the self-host edge: build default state (bearer passthrough, no
125/// metering), bind the listener, and serve until shutdown.
126///
127/// The hosted gateway does not call this — it builds its own [`EdgeState`] with
128/// [`EdgeState::with_ports`], merges [`app::router`] under its middleware, and
129/// serves itself.
130///
131/// # Errors
132/// Fails if the listen address can't be bound or the server exits with an error.
133pub async fn run(config: EdgeConfig) -> Result<(), Box<dyn std::error::Error>> {
134 let listen_addr = config.listen_addr;
135 let request_timeout = Duration::from_secs(config.request_timeout_secs);
136 let max_body_bytes = config.max_body_bytes;
137 // Wire-proxy settings, captured before `config` is moved into the state.
138 let wire_listen_addr = config.wire_listen_addr;
139 let brain_addr = config.brain_addr;
140 let wire_rate = RateLimitConfig {
141 capacity: config.wire_rate_capacity,
142 refill_per_sec: config.wire_rate_refill_per_sec,
143 };
144 // BYOK: install the key ring when one is configured. Absent, the edge runs
145 // exactly as before and refuses to store provider keys at all — it never
146 // falls back to holding them in the clear.
147 //
148 // The self-host default store is process-local, so a restart drops every
149 // registered key. That is said out loud at startup rather than discovered
150 // when writes begin failing; a durable store is injected by the gateway.
151 let keyring = crypto::keyring_from_env().map_err(|e| format!("llm kek: {e}"))?;
152 let state = EdgeState::new(config);
153 let state = if let Some(ring) = keyring {
154 {
155 tracing::info!(
156 kek_id = ring.current_kek_id(),
157 "BYOK enabled: provider keys sealed at the edge",
158 );
159 state.with_llm_credentials(byok_store()?, std::sync::Arc::new(ring))
160 }
161 } else {
162 tracing::info!(
163 "BYOK disabled: set {} to enable tenant provider keys",
164 crypto::KEK_SPEC_ENV,
165 );
166 state
167 };
168
169 // The transparent wire-protocol proxy is a second listener alongside HTTP.
170 // Opt-in (only when an address is configured); it reuses the state's metering
171 // sink so wire-path ops meter through the same sink as the HTTP data plane.
172 if let Some(wire_addr) = wire_listen_addr {
173 let meter = state.meter();
174 let wcfg = WireProxyConfig {
175 wire_listen_addr: wire_addr,
176 brain_addr,
177 rate: wire_rate,
178 };
179 tokio::spawn(async move {
180 if let Err(e) = wire_proxy::serve(wcfg, meter).await {
181 error!(error = %e, "wire proxy listener exited");
182 }
183 });
184 }
185
186 // Drop credential pools that have gone idle past their TTL. Runs off the
187 // request path so a long-lived self-host process serving many credentials
188 // doesn't hold live sockets forever between bursts.
189 {
190 let sweeper_state = state.clone();
191 tokio::spawn(async move {
192 let mut ticker = tokio::time::interval(Duration::from_secs(60));
193 ticker.tick().await; // consume the immediate first tick
194 loop {
195 ticker.tick().await;
196 sweeper_state.pool().sweep_idle();
197 }
198 });
199 }
200
201 // The library router is the data plane (`/v1/*`) only, so a host app can
202 // merge it without colliding on health routes; the binary owns its health.
203 // `/health/live` is a static liveness signal (the process is up);
204 // `/health/ready` actually probes Brain so an orchestrator doesn't route
205 // traffic here before the database it proxies to is reachable.
206 let health = axum::Router::new()
207 .route("/health/live", get(|| async { StatusCode::OK }))
208 .route("/health/ready", get(readiness))
209 .with_state(state.clone());
210
211 // One binary, two deployable planes. Default `Both` so a self-host
212 // operator never has to think about it; production splits them to keep
213 // tenants' provider keys out of the process carrying memory traffic.
214 let plane = app::Plane::from_env().map_err(|e| format!("{}: {e}", app::PLANE_ENV))?;
215 tracing::info!(?plane, "serving plane");
216
217 let app = harden(
218 app::router_for(state, plane).merge(health),
219 request_timeout,
220 max_body_bytes,
221 );
222
223 let listener = tokio::net::TcpListener::bind(listen_addr).await?;
224 info!(addr = %listen_addr, "listening");
225 axum::serve(listener, app)
226 .with_graceful_shutdown(shutdown_signal())
227 .await?;
228 info!("shutdown complete");
229 Ok(())
230}
231
232/// Readiness: 200 only when a TCP socket to Brain opens within the probe budget,
233/// else 503. Lets an orchestrator gate traffic on the downstream being live.
234async fn readiness(State(state): State<EdgeState>) -> StatusCode {
235 if state.pool().probe(Duration::from_secs(2)).await {
236 StatusCode::OK
237 } else {
238 StatusCode::SERVICE_UNAVAILABLE
239 }
240}
241
242/// Resolve when the process receives SIGINT (Ctrl-C) or SIGTERM (the signal an
243/// orchestrator sends on rollout/scale-down), so in-flight requests drain
244/// instead of being severed mid-response.
245async fn shutdown_signal() {
246 let ctrl_c = async {
247 let _ = tokio::signal::ctrl_c().await;
248 };
249
250 #[cfg(unix)]
251 let terminate = async {
252 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
253 Ok(mut sig) => {
254 sig.recv().await;
255 }
256 Err(_) => std::future::pending::<()>().await,
257 }
258 };
259
260 #[cfg(not(unix))]
261 let terminate = std::future::pending::<()>();
262
263 tokio::select! {
264 () = ctrl_c => info!("SIGINT received, shutting down"),
265 () = terminate => info!("SIGTERM received, shutting down"),
266 }
267}
268
269/// Env var pointing at a durable BYOK credential file.
270pub const CREDENTIAL_STORE_ENV: &str = "BRAIN_EDGE_LLM_CREDENTIAL_FILE";
271
272/// Pick the credential store for this process.
273///
274/// A path gives a durable, file-backed store; without one the store is
275/// in-memory and every registered key is lost on restart — which then fails
276/// the whole write path until each tenant re-registers.
277///
278/// The in-memory case stays available because requiring a path to try BYOK
279/// would be obnoxious, but it warns loudly rather than letting an operator
280/// discover the property during an incident.
281fn byok_store() -> Result<std::sync::Arc<dyn port::LlmCredentialStore>, Box<dyn std::error::Error>>
282{
283 match std::env::var(CREDENTIAL_STORE_ENV) {
284 Ok(path) if !path.trim().is_empty() => {
285 let store = port::FileLlmCredentialStore::open(path.trim())
286 .map_err(|e| format!("{CREDENTIAL_STORE_ENV}: {e}"))?;
287 tracing::info!(path = %path.trim(), "BYOK credential store: file-backed");
288 Ok(std::sync::Arc::new(store))
289 }
290 _ => {
291 tracing::warn!(
292 "BYOK credential store is IN-MEMORY: registered provider keys are lost on \
293 restart, and writes then fail until every tenant re-registers. Set {} to a \
294 file path, or inject a durable LlmCredentialStore.",
295 CREDENTIAL_STORE_ENV,
296 );
297 Ok(std::sync::Arc::new(
298 port::InMemoryLlmCredentialStore::default(),
299 ))
300 }
301 }
302}