Skip to main content

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}