omnigraph-server: optional CORS layer for browser-based UIs

Off by default — production deployments behind a same-origin reverse
proxy need no configuration. When OMNIGRAPH_SERVER_CORS_ORIGIN is set
(comma-separated origins), attach a tower_http::cors::CorsLayer
permitting GET/POST/DELETE plus Authorization and Content-Type request
headers. Empty/unset variable preserves prior no-CORS behavior.

Tests: cors_default_off_does_not_emit_allow_origin_header,
cors_env_origin_emits_allow_origin_header.

Companion to omnigraph-ui's web demo (Vite dev origin
http://127.0.0.1:5173 hitting the API at 127.0.0.1:8080).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
andrew 2026-05-10 16:22:39 +03:00 committed by aaltshuler
parent 60eee78465
commit 0685d5530f
4 changed files with 125 additions and 4 deletions

View file

@ -52,7 +52,7 @@ serde_yaml = "0.9"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
color-eyre = "0.6"
tempfile = "3"
ahash = "0.8"

View file

@ -49,6 +49,7 @@ use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::trace::TraceLayer;
use tracing::{error, info};
use tracing_subscriber::EnvFilter;
@ -550,15 +551,48 @@ pub fn build_app(state: AppState) -> Router {
require_bearer_auth,
));
Router::new()
let mut router = Router::new()
.route("/healthz", get(server_health))
.route("/openapi.json", get(server_openapi))
.merge(protected)
.layer(DefaultBodyLimit::max(DEFAULT_REQUEST_BODY_LIMIT_BYTES))
.layer(TraceLayer::new_for_http())
.with_state(state)
.with_state(state);
if let Some(cors) = build_cors_layer() {
router = router.layer(cors);
}
router
}
/// Build a CORS layer if `OMNIGRAPH_SERVER_CORS_ORIGIN` is set. The value is a
/// comma-separated list of origins. Default off so production deployments are
/// unchanged.
fn build_cors_layer() -> Option<CorsLayer> {
let raw = std::env::var(CORS_ORIGIN_ENV).ok()?;
let origins: Vec<_> = raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<axum::http::HeaderValue>().ok())
.collect();
if origins.is_empty() {
return None;
}
let layer = CorsLayer::new()
.allow_origin(AllowOrigin::list(origins))
.allow_methods([
axum::http::Method::GET,
axum::http::Method::POST,
axum::http::Method::DELETE,
])
.allow_headers([AUTHORIZATION, CONTENT_TYPE]);
Some(layer)
}
pub const CORS_ORIGIN_ENV: &str = "OMNIGRAPH_SERVER_CORS_ORIGIN";
pub async fn serve(config: ServerConfig) -> Result<()> {
let token_source = resolve_token_source().await?;
info!(source = token_source.name(), "loaded bearer token source");

View file

@ -3478,3 +3478,77 @@ async fn oversized_request_body_returns_payload_too_large() {
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn cors_default_off_does_not_emit_allow_origin_header() {
// Make sure the env is unset when build_app runs.
// SAFETY: tests are gated by #[serial], so no concurrent env reads.
unsafe {
env::remove_var("OMNIGRAPH_SERVER_CORS_ORIGIN");
}
let (_temp, app) = app_for_loaded_repo().await;
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/healthz")
.method(Method::GET)
.header("Origin", "http://localhost:5173")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
response.headers().get("access-control-allow-origin").is_none(),
"default-off CORS should not advertise allow-origin",
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn cors_env_origin_emits_allow_origin_header() {
// SAFETY: tests are gated by #[serial], so no concurrent env reads.
unsafe {
env::set_var(
"OMNIGRAPH_SERVER_CORS_ORIGIN",
"http://localhost:5173,https://app.example.com",
);
}
// Build the app *after* the env var is set so build_cors_layer reads it.
let (_temp, app) = app_for_loaded_repo().await;
// Reset the env immediately to keep other tests clean.
unsafe {
env::remove_var("OMNIGRAPH_SERVER_CORS_ORIGIN");
}
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/healthz")
.method(Method::GET)
.header("Origin", "http://localhost:5173")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let allow_origin = response
.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
assert_eq!(
allow_origin, "http://localhost:5173",
"CORS layer should echo the matching origin",
);
}

View file

@ -90,9 +90,22 @@ See [deployment.md](deployment.md) for token-source operational details.
- Startup logs: token source name, repo URI, bind address
- Graceful SIGINT shutdown
## CORS
Off by default — production deployments behind a same-origin reverse proxy need no
configuration. To enable cross-origin requests (e.g. from a browser-based UI on a
different host/port during development), set:
- `OMNIGRAPH_SERVER_CORS_ORIGIN` — comma-separated list of allowed origins.
Example: `OMNIGRAPH_SERVER_CORS_ORIGIN=http://localhost:5173,https://app.example.com`.
When set, the server attaches a `tower_http::cors::CorsLayer` permitting `GET`, `POST`,
`DELETE`, plus the `Authorization` and `Content-Type` request headers. Requests from
origins not in the list receive no CORS allow-origin response and the browser blocks
them. Empty/unset variable → no layer → no CORS headers (default behaviour preserved).
## Not implemented (by design or "TBD")
- CORS — not configured; add `tower_http::cors` if needed.
- Rate limiting — per-actor admission control gates `/change`, `/ingest`,
`/branches/{create,delete,merge}`, `/schema/apply` (see "Per-actor
admission control" above). No global rate limiter is configured;