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 dto;
22pub mod error;
23pub mod pool;
24pub mod port;
25pub mod state;
26pub mod wire_proxy;
27
28pub use config::EdgeConfig;
29pub use error::ApiError;
30pub use pool::{BrainPool, BrainPoolConfig};
31pub use port::{
32 BearerResolver, CredentialResolver, MeterEvent, MeteringSink, NoopMeter, Outcome,
33 ResolvedCredential, bearer_token,
34};
35pub use state::EdgeState;
36pub use wire_proxy::{RateLimitConfig, WireProxyConfig};
37
38/// The per-request effective-identity selector the gateway sets when running a
39/// [`BrainPool::shared`] service pool: build one with `ActAs { namespace,
40/// space_id }` (or the SDK request builders' `.act_as(..)`) to run each op as a
41/// resolved tenant on behalf of the trusted service principal. Re-exported so a
42/// gateway consuming `brain_edge` need not also name `brain_db_sdk` directly.
43pub use brain_db_sdk::wire::types::ActAs;
44
45use std::time::Duration;
46
47use axum::Router;
48use axum::extract::State;
49use axum::http::{HeaderName, StatusCode, header};
50use axum::response::IntoResponse;
51use axum::routing::get;
52use tower_http::catch_panic::CatchPanicLayer;
53use tower_http::limit::RequestBodyLimitLayer;
54use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
55use tower_http::sensitive_headers::SetSensitiveRequestHeadersLayer;
56use tower_http::timeout::TimeoutLayer;
57use tower_http::trace::TraceLayer;
58use tracing::{error, info};
59
60/// The header carrying the correlation id, in and out.
61const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
62
63/// Turn a panicking handler into a `500` instead of a dropped connection.
64///
65/// Without this a panic aborts the task and hyper closes the socket with no
66/// response: the caller sees a connection reset with no status, no body, and
67/// nothing to quote in a bug report. The panic is logged, and the caller gets
68/// the same JSON envelope as every other failure — carrying the request id, so
69/// the two ends can be joined up afterwards.
70fn panic_to_response(err: &Box<dyn std::any::Any + Send + 'static>) -> axum::response::Response {
71 // `panic!` and `unwrap` produce a `&str` or a `String`; anything else is
72 // opaque and there is nothing useful to extract.
73 let detail: &str = err.downcast_ref::<&str>().copied().unwrap_or_else(|| {
74 err.downcast_ref::<String>()
75 .map_or("non-string panic payload", String::as_str)
76 });
77 ApiError::internal(format!("handler panicked: {detail}")).into_response()
78}
79
80/// Apply the transport-level protections every deployment needs.
81///
82/// Panic recovery, correlation ids, header redaction, request tracing, a
83/// request timeout, and a request-body cap. [`run`] applies these;
84/// [`app::router`] deliberately does not, because a host application merges
85/// that router under its own middleware stack.
86///
87/// This exists so an embedder does not have to rediscover the list. The body
88/// cap is the one most easily missed: [`EdgeConfig::max_body_bytes`] is a
89/// visible, validated knob, so a gateway author can reasonably assume the
90/// router already honours it. It does not — only this function does.
91///
92/// # Arguments
93///
94/// * `request_timeout` — a stalled Brain must not hang a request forever; past
95/// this the request is cut with an explicit `408`.
96/// * `max_body_bytes` — an oversized body must not be buffered into memory.
97pub fn harden(router: Router, request_timeout: Duration, max_body_bytes: usize) -> Router {
98 router
99 // A panic must not take the connection down silently. Innermost, so it
100 // wraps the handler rather than the middleware around it.
101 .layer(CatchPanicLayer::custom(|e| panic_to_response(&e)))
102 // Redact credential headers so they never reach the log sink, then trace
103 // every request. Order matters: mark-sensitive is outermost so it wraps
104 // the trace layer's view of the headers.
105 .layer(TraceLayer::new_for_http())
106 .layer(SetSensitiveRequestHeadersLayer::new([
107 header::AUTHORIZATION,
108 HeaderName::from_static("x-api-key"),
109 ]))
110 .layer(TimeoutLayer::with_status_code(
111 StatusCode::REQUEST_TIMEOUT,
112 request_timeout,
113 ))
114 .layer(RequestBodyLimitLayer::new(max_body_bytes))
115 // Correlation, outermost so every response carries it — including the
116 // ones the layers above generate without reaching a handler (413 from
117 // the body cap, 408 from the timeout). A caller-supplied
118 // `x-request-id` is kept, so an id assigned upstream survives the hop.
119 .layer(PropagateRequestIdLayer::new(REQUEST_ID_HEADER))
120 .layer(SetRequestIdLayer::new(REQUEST_ID_HEADER, MakeRequestUuid))
121}
122
123/// Run the self-host edge: build default state (bearer passthrough, no
124/// metering), bind the listener, and serve until shutdown.
125///
126/// The hosted gateway does not call this — it builds its own [`EdgeState`] with
127/// [`EdgeState::with_ports`], merges [`app::router`] under its middleware, and
128/// serves itself.
129///
130/// # Errors
131/// Fails if the listen address can't be bound or the server exits with an error.
132pub async fn run(config: EdgeConfig) -> Result<(), Box<dyn std::error::Error>> {
133 let listen_addr = config.listen_addr;
134 let request_timeout = Duration::from_secs(config.request_timeout_secs);
135 let max_body_bytes = config.max_body_bytes;
136 // Wire-proxy settings, captured before `config` is moved into the state.
137 let wire_listen_addr = config.wire_listen_addr;
138 let brain_addr = config.brain_addr;
139 let wire_rate = RateLimitConfig {
140 capacity: config.wire_rate_capacity,
141 refill_per_sec: config.wire_rate_refill_per_sec,
142 };
143 let state = EdgeState::new(config);
144
145 // The transparent wire-protocol proxy is a second listener alongside HTTP.
146 // Opt-in (only when an address is configured); it reuses the state's metering
147 // sink so wire-path ops meter through the same sink as the HTTP data plane.
148 if let Some(wire_addr) = wire_listen_addr {
149 let meter = state.meter();
150 let wcfg = WireProxyConfig {
151 wire_listen_addr: wire_addr,
152 brain_addr,
153 rate: wire_rate,
154 };
155 tokio::spawn(async move {
156 if let Err(e) = wire_proxy::serve(wcfg, meter).await {
157 error!(error = %e, "wire proxy listener exited");
158 }
159 });
160 }
161
162 // Drop credential pools that have gone idle past their TTL. Runs off the
163 // request path so a long-lived self-host process serving many credentials
164 // doesn't hold live sockets forever between bursts.
165 {
166 let sweeper_state = state.clone();
167 tokio::spawn(async move {
168 let mut ticker = tokio::time::interval(Duration::from_secs(60));
169 ticker.tick().await; // consume the immediate first tick
170 loop {
171 ticker.tick().await;
172 sweeper_state.pool().sweep_idle();
173 }
174 });
175 }
176
177 // The library router is the data plane (`/v1/*`) only, so a host app can
178 // merge it without colliding on health routes; the binary owns its health.
179 // `/health/live` is a static liveness signal (the process is up);
180 // `/health/ready` actually probes Brain so an orchestrator doesn't route
181 // traffic here before the database it proxies to is reachable.
182 let health = axum::Router::new()
183 .route("/health/live", get(|| async { StatusCode::OK }))
184 .route("/health/ready", get(readiness))
185 .with_state(state.clone());
186
187 let app = harden(
188 app::router(state).merge(health),
189 request_timeout,
190 max_body_bytes,
191 );
192
193 let listener = tokio::net::TcpListener::bind(listen_addr).await?;
194 info!(addr = %listen_addr, "listening");
195 axum::serve(listener, app)
196 .with_graceful_shutdown(shutdown_signal())
197 .await?;
198 info!("shutdown complete");
199 Ok(())
200}
201
202/// Readiness: 200 only when a TCP socket to Brain opens within the probe budget,
203/// else 503. Lets an orchestrator gate traffic on the downstream being live.
204async fn readiness(State(state): State<EdgeState>) -> StatusCode {
205 if state.pool().probe(Duration::from_secs(2)).await {
206 StatusCode::OK
207 } else {
208 StatusCode::SERVICE_UNAVAILABLE
209 }
210}
211
212/// Resolve when the process receives SIGINT (Ctrl-C) or SIGTERM (the signal an
213/// orchestrator sends on rollout/scale-down), so in-flight requests drain
214/// instead of being severed mid-response.
215async fn shutdown_signal() {
216 let ctrl_c = async {
217 let _ = tokio::signal::ctrl_c().await;
218 };
219
220 #[cfg(unix)]
221 let terminate = async {
222 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
223 Ok(mut sig) => {
224 sig.recv().await;
225 }
226 Err(_) => std::future::pending::<()>().await,
227 }
228 };
229
230 #[cfg(not(unix))]
231 let terminate = std::future::pending::<()>();
232
233 tokio::select! {
234 () = ctrl_c => info!("SIGINT received, shutting down"),
235 () = terminate => info!("SIGTERM received, shutting down"),
236 }
237}