Skip to main content

brain_edge/app/
mod.rs

1//! The HTTP surface: the data-plane router.
2
3pub mod handlers;
4
5use axum::Router;
6use axum::routing::get;
7use axum::routing::post;
8
9use crate::state::EdgeState;
10
11/// Build the data-plane router (`/v1/*`), with [`EdgeState`] baked in.
12///
13/// This is the reusable half: the self-host binary adds its own health routes
14/// around it (see [`crate::run`]); the hosted gateway merges it under its auth +
15/// metering middleware alongside its control-plane routes. It deliberately does
16/// NOT register `/health/*`, so a host app can own health without colliding.
17///
18/// # What this router does NOT include
19///
20/// No transport-level protection is attached here — not the request-body cap,
21/// the request timeout, the credential-header redaction, or tracing. An
22/// embedder must apply them, and [`crate::harden`] does exactly that.
23///
24/// The body cap is the one most easily missed: [`crate::EdgeConfig`] exposes
25/// `max_body_bytes` as a validated knob, which makes it reasonable to assume
26/// the router already honours it. It does not.
27/// Which planes a given process serves.
28///
29/// One binary, deployed twice. The data plane carries every tenant's memory
30/// traffic; the credential plane is the only place a plaintext provider key
31/// exists in memory, and it sits on the LLM path with a completely different
32/// latency profile.
33///
34/// Running both in one process is supported, and is the default — standing up
35/// two deployments just to try BYOK would be absurd. Production should split
36/// them: it keeps every tenant's keys out of the busiest process and lets the
37/// two scale on their own shapes.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum Plane {
40    /// Memory verbs plus credentials and the broker. The default.
41    #[default]
42    Both,
43    /// Memory verbs only. No key material is reachable in this process.
44    Data,
45    /// BYOK credential management and the broker only.
46    Credential,
47}
48
49/// Env var selecting the plane this process serves.
50pub const PLANE_ENV: &str = "BRAIN_EDGE_PLANE";
51
52impl Plane {
53    /// Parse a [`PLANE_ENV`] value.
54    ///
55    /// # Errors
56    /// Names the accepted set. An unrecognised plane is a hard error rather
57    /// than a silent fallback to `Both`: quietly serving credentials from a
58    /// process an operator meant to be data-only is the exact mistake this
59    /// flag exists to prevent.
60    pub fn from_name(name: &str) -> Result<Self, String> {
61        match name.trim().to_ascii_lowercase().as_str() {
62            "both" | "" => Ok(Self::Both),
63            "data" => Ok(Self::Data),
64            "credential" => Ok(Self::Credential),
65            other => Err(format!(
66                "unknown plane `{other}`: expected one of both, data, credential"
67            )),
68        }
69    }
70
71    /// Read the plane from the environment.
72    ///
73    /// # Errors
74    /// As [`Self::from_name`].
75    pub fn from_env() -> Result<Self, String> {
76        std::env::var(PLANE_ENV).map_or(Ok(Self::Both), |v| Self::from_name(&v))
77    }
78
79    /// Whether this plane serves the memory verbs.
80    #[must_use]
81    pub const fn serves_data(self) -> bool {
82        matches!(self, Self::Both | Self::Data)
83    }
84
85    /// Whether this plane serves credentials and the broker.
86    #[must_use]
87    pub const fn serves_credentials(self) -> bool {
88        matches!(self, Self::Both | Self::Credential)
89    }
90}
91
92/// Build the router for the default (`Both`) plane.
93pub fn router(state: EdgeState) -> Router {
94    router_for(state, Plane::Both)
95}
96
97/// Build the router for one plane.
98pub fn router_for(state: EdgeState, plane: Plane) -> Router {
99    let mut r = Router::new();
100    if plane.serves_credentials() {
101        r = r.merge(credential_router());
102    }
103    if plane.serves_data() {
104        r = r.merge(data_router());
105    }
106    r.with_state(state)
107}
108
109/// BYOK credential management plus the broker.
110///
111/// Scoped to the caller's own namespace, which is read from Brain rather than
112/// from the request — see the handler module. Registered even without a KEK,
113/// where they return `501`: that tells a tenant "this deployment does not do
114/// BYOK" instead of a 404 that looks like a client-side URL mistake.
115fn credential_router() -> Router<EdgeState> {
116    Router::new()
117        .route(
118            "/v1/llm-credential",
119            axum::routing::put(handlers::llm_credential::put)
120                .get(handlers::llm_credential::get)
121                .delete(handlers::llm_credential::delete),
122        )
123        // The broker. Paths mirror each provider's native shape so Brain's
124        // existing provider clients work by pointing `base_url` here.
125        .route(
126            "/llm/ns/{namespace}/openai/v1/chat/completions",
127            post(handlers::llm_proxy::openai_chat_completions),
128        )
129        .route(
130            "/llm/ns/{namespace}/anthropic/v1/messages",
131            post(handlers::llm_proxy::anthropic_messages),
132        )
133        .route(
134            "/llm/ns/{namespace}/resolve",
135            get(handlers::llm_proxy::resolve),
136        )
137}
138
139/// The memory verbs.
140fn data_router() -> Router<EdgeState> {
141    Router::new()
142        // identity
143        .route("/v1/whoami", get(handlers::identity::whoami))
144        .route("/v1/capabilities", get(handlers::identity::capabilities))
145        // memory
146        .route(
147            "/v1/memories",
148            post(handlers::memory::encode)
149                .get(handlers::memory::list)
150                .delete(handlers::memory::forget),
151        )
152        .route("/v1/memories/{id}/inspect", get(handlers::memory::inspect))
153        .route("/v1/recall", post(handlers::memory::recall))
154        // reasoning
155        .route("/v1/plan", post(handlers::reasoning::plan))
156        .route("/v1/reason", post(handlers::reasoning::reason))
157        // memory graph
158        .route(
159            "/v1/links",
160            post(handlers::graph::link).delete(handlers::graph::unlink),
161        )
162        // typed-graph export
163        .route("/v1/graph", get(handlers::graph::fetch))
164        // typed-graph entities
165        .route(
166            "/v1/entities",
167            post(handlers::entity::create).get(handlers::entity::list),
168        )
169        .route("/v1/entities/resolve", post(handlers::entity::resolve))
170        .route("/v1/entities/{id}", get(handlers::entity::get))
171        .route(
172            "/v1/entities/{id}/traverse",
173            post(handlers::entity::traverse),
174        )
175        .route("/v1/entities/{id}/relations", get(handlers::relation::list))
176        // typed-graph statements
177        .route("/v1/statements", get(handlers::statement::list))
178        .route("/v1/statements/{id}", get(handlers::statement::get))
179        // typed-graph relations
180        .route("/v1/relations/{id}", get(handlers::relation::get))
181        // schema. GET reads, POST merges, PUT replaces destructively — the
182        // method carries the difference, which is the clearest signal
183        // available that one of these three is not like the others.
184        .route(
185            "/v1/schema",
186            get(handlers::schema::get)
187                .post(handlers::schema::upload)
188                .put(handlers::schema::replace),
189        )
190        .route("/v1/schema/validate", post(handlers::schema::validate))
191}
192
193#[cfg(test)]
194mod plane_tests {
195    use super::*;
196
197    #[test]
198    fn the_default_serves_everything() {
199        // Self-host must work without setting anything.
200        assert_eq!(Plane::default(), Plane::Both);
201        assert!(Plane::Both.serves_data());
202        assert!(Plane::Both.serves_credentials());
203        assert_eq!(Plane::from_name("").unwrap(), Plane::Both);
204    }
205
206    #[test]
207    fn the_data_plane_cannot_reach_key_material() {
208        // The whole point of the split: a data-plane process serves no route
209        // that can read or use a provider key.
210        assert!(Plane::Data.serves_data());
211        assert!(!Plane::Data.serves_credentials());
212    }
213
214    #[test]
215    fn the_credential_plane_serves_no_memory_verbs() {
216        assert!(Plane::Credential.serves_credentials());
217        assert!(!Plane::Credential.serves_data());
218    }
219
220    #[test]
221    fn parsing_is_case_and_whitespace_tolerant() {
222        assert_eq!(Plane::from_name(" DATA ").unwrap(), Plane::Data);
223        assert_eq!(Plane::from_name("Credential").unwrap(), Plane::Credential);
224    }
225
226    #[test]
227    fn an_unknown_plane_is_an_error_not_a_silent_both() {
228        // Falling back to `Both` would quietly serve credentials from a
229        // process the operator meant to be data-only — the exact mistake this
230        // flag exists to prevent.
231        let err = Plane::from_name("dataplane").unwrap_err();
232        assert!(err.contains("dataplane"), "{err}");
233        assert!(err.contains("data"), "names the accepted set: {err}");
234    }
235}