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}