use std::sync::Arc;
use fraiseql_core::{db::traits::DatabaseAdapter, schema::CompiledSchema};
use tracing::{info, warn};
use super::{RateLimiter, Server, ServerError};
pub(super) fn effective_validation_limits(
runtime: Option<&fraiseql_core::schema::ValidationConfig>,
compiled: Option<&fraiseql_core::schema::ValidationConfig>,
) -> (Option<u32>, Option<u32>) {
let depth = runtime
.and_then(|v| v.max_query_depth)
.or_else(|| compiled.and_then(|v| v.max_query_depth))
.or(Some(fraiseql_core::schema::DEFAULT_MAX_QUERY_DEPTH));
let complexity = runtime
.and_then(|v| v.max_query_complexity)
.or_else(|| compiled.and_then(|v| v.max_query_complexity));
(depth, complexity)
}
pub(super) fn executor_runtime_config(
schema: &CompiledSchema,
config: &crate::server_config::ServerConfig,
) -> Result<fraiseql_core::runtime::RuntimeConfig, String> {
let mut rt = fraiseql_core::runtime::RuntimeConfig::from_compiled_schema(schema)?;
rt.mutation_error_shape_check = config.mutation_error_shape_check;
if config.validation.is_some() {
let (depth, complexity) = effective_validation_limits(
config.validation.as_ref(),
schema.validation_config.as_ref(),
);
if depth.is_some() || complexity.is_some() {
rt.query_validation = Some(fraiseql_core::security::QueryValidatorConfig {
max_depth: depth.map_or(usize::MAX, |d| d as usize),
max_complexity: complexity.map_or(usize::MAX, |c| c as usize),
max_size_bytes: usize::MAX,
max_aliases: usize::MAX,
});
}
}
if let Some(bytes) = config.validation.as_ref().and_then(|v| v.max_response_bytes) {
rt.max_response_bytes = Some(bytes);
}
let sanitizer = schema_error_sanitizer(schema);
rt.root_error_renderer = Some(std::sync::Arc::new(move |error| {
let rendered = sanitizer.sanitize(crate::error::GraphQLError::from_fraiseql_error(error));
serde_json::to_value(rendered)
.unwrap_or_else(|_| serde_json::json!({ "message": "An internal error occurred" }))
}));
Ok(rt)
}
fn schema_error_sanitizer(
schema: &CompiledSchema,
) -> crate::config::error_sanitization::ErrorSanitizer {
let compiled = schema.security.as_ref().and_then(|s| s.error_sanitization.clone());
build_error_sanitizer(compiled, crate::ServerConfig::is_production_mode())
}
pub(super) struct SharedStateBackends {
pub pkce_in_memory: bool,
pub rate_limiter_in_memory: bool,
pub revocation_in_memory: bool,
pub saml_replay_in_memory: bool,
}
impl SharedStateBackends {
pub(super) fn per_process_subsystems(&self) -> Vec<&'static str> {
let mut v = Vec::new();
if self.pkce_in_memory {
v.push("PKCE auth state ([security.pkce])");
}
if self.rate_limiter_in_memory {
v.push("rate limiting ([security.rate_limiting])");
}
if self.revocation_in_memory {
v.push("token revocation ([security.token_revocation])");
}
if self.saml_replay_in_memory {
v.push("SAML assertion replay protection ([saml])");
}
v
}
}
impl Server {
#[cfg(feature = "auth")]
pub(super) fn state_encryption_from_schema(
schema: &CompiledSchema,
) -> crate::Result<Option<Arc<crate::auth::state_encryption::StateEncryptionService>>> {
match schema.security.as_ref() {
None => Ok(None),
Some(s) => {
let s_val = serde_json::to_value(s).map_err(|e| {
ServerError::ConfigError(format!("Failed to serialize security config: {e}"))
})?;
crate::auth::state_encryption::StateEncryptionService::from_compiled_schema(&s_val)
.map_err(|e| ServerError::ConfigError(e.to_string()))
},
}
}
pub(super) fn check_redis_requirement(backends: &SharedStateBackends) -> crate::Result<()> {
if std::env::var("FRAISEQL_REQUIRE_REDIS").is_err() {
return Ok(());
}
let violations = backends.per_process_subsystems();
if violations.is_empty() {
return Ok(());
}
Err(ServerError::ConfigError(format!(
"FraiseQL failed to start\n\n FRAISEQL_REQUIRE_REDIS is set but the following \
subsystems hold per-process state: {}.\n In a multi-replica deployment that \
means auth callbacks can fail across replicas, revoked tokens stay accepted by \
other replicas, and rate limits multiply by the replica count.\n\n To fix, give \
each of them a shared backend:\n [security.pkce] redis_url = \
\"redis://…\"\n [security.rate_limiting] redis_url = \"redis://…\"\n \
[security.token_revocation] backend = \"postgres\" (or redis_url = \"redis://…\")\
\n [saml] uses the configured pool automatically; a \
per-process replay store means Server was built programmatically without \
one\n\n To allow per-process state (single-replica only): unset \
FRAISEQL_REQUIRE_REDIS",
violations.join(", ")
)))
}
#[cfg(feature = "auth")]
pub(super) async fn oidc_server_client_from_schema(
schema: &CompiledSchema,
) -> Option<Arc<crate::auth::OidcServerClient>> {
let schema_json = serde_json::to_value(schema)
.inspect_err(|e| warn!(error = %e, "Failed to serialize compiled schema for OIDC client construction"))
.ok()?;
crate::auth::OidcServerClient::from_compiled_schema(&schema_json).await
}
pub(super) fn error_sanitizer_from_schema(
schema: &CompiledSchema,
) -> Arc<crate::config::error_sanitization::ErrorSanitizer> {
Arc::new(schema_error_sanitizer(schema))
}
#[allow(clippy::cognitive_complexity)] pub(super) fn trusted_docs_from_schema(
schema: &CompiledSchema,
tasks: &mut tokio::task::JoinSet<()>,
) -> Option<Arc<crate::trusted_documents::TrustedDocumentStore>> {
let security = schema.security.as_ref()?;
let persisted_queries_only = security.persisted_queries_only;
let Some(cfg) = security.trusted_documents.as_ref() else {
if persisted_queries_only {
warn!(
"security.persisted_queries_only = true but no [security.trusted_documents] \
is configured — the flag has no effect. Configure a trusted-documents \
manifest (manifest_path or manifest_url) so persisted queries can be \
allow-listed."
);
}
return None;
};
if !cfg.enabled {
if persisted_queries_only {
warn!(
"security.persisted_queries_only = true but [security.trusted_documents].enabled \
= false — the flag has no effect; enable trusted documents with a manifest."
);
}
return None;
}
let mode = effective_trusted_doc_mode(cfg.mode, persisted_queries_only);
if let Some(ref path) = cfg.manifest_path {
match crate::trusted_documents::TrustedDocumentStore::from_manifest_file(
std::path::Path::new(path),
mode,
) {
Ok(store) => {
let store = Arc::new(store);
if cfg.reload_interval_secs > 0 {
if let Some(ref url) = cfg.manifest_url {
Self::spawn_trusted_docs_reload(
Arc::clone(&store),
url.clone(),
cfg.reload_interval_secs,
tasks,
);
} else {
warn!(
"trusted_documents.reload_interval_secs > 0 but no manifest_url set \
— hot-reload disabled (file-based manifests must be reloaded manually)"
);
}
}
info!(
manifest = %path,
mode = ?mode,
"Trusted documents loaded"
);
Some(store)
},
Err(e) => {
tracing::error!(error = %e, "Failed to load trusted documents manifest");
None
},
}
} else {
warn!("trusted_documents.enabled = true but no manifest_path or manifest_url set");
None
}
}
pub(super) fn spawn_trusted_docs_reload(
store: Arc<crate::trusted_documents::TrustedDocumentStore>,
url: String,
interval_secs: u64,
tasks: &mut tokio::task::JoinSet<()>,
) {
if is_manifest_url_ssrf_blocked(&url) {
tracing::error!(
url = %url,
"Trusted documents manifest URL targets a private/loopback address \
(SSRF protection) — hot-reload disabled"
);
return;
}
tasks.spawn(async move {
const MANIFEST_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const MAX_TRUSTED_DOCS_RESPONSE_BYTES: usize = 10 * 1024 * 1024;
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let client = reqwest::Client::builder()
.timeout(MANIFEST_FETCH_TIMEOUT)
.build()
.expect("reqwest client with timeout should always build");
loop {
ticker.tick().await;
match client.get(&url).send().await {
Ok(resp) => {
let status = resp.status();
if status.is_success() {
match read_capped_body(resp, MAX_TRUSTED_DOCS_RESPONSE_BYTES).await {
Ok(body_bytes) => {
#[derive(serde::Deserialize)]
struct Manifest {
documents: std::collections::HashMap<String, String>,
}
match serde_json::from_slice::<Manifest>(&body_bytes) {
Ok(manifest) => {
let count = manifest.documents.len();
store.replace_documents(manifest.documents);
info!(count, "Trusted documents manifest reloaded");
},
Err(e) => {
warn!(error = %e, "Failed to parse trusted documents manifest");
},
}
},
Err(e) => {
warn!(error = %e, "Failed to read trusted documents manifest response");
},
}
} else {
warn!(
%status,
%url,
"Trusted documents manifest fetch returned non-success — skipping reload"
);
}
},
Err(e) => {
warn!(error = %e, "Failed to fetch trusted documents manifest");
},
}
}
});
}
}
pub(super) const fn effective_trusted_doc_mode(
declared_mode: fraiseql_core::schema::TrustedDocumentMode,
persisted_queries_only: bool,
) -> crate::trusted_documents::TrustedDocumentMode {
if persisted_queries_only
|| matches!(declared_mode, fraiseql_core::schema::TrustedDocumentMode::Strict)
{
crate::trusted_documents::TrustedDocumentMode::Strict
} else {
crate::trusted_documents::TrustedDocumentMode::Permissive
}
}
pub(super) fn build_error_sanitizer(
compiled: Option<crate::config::error_sanitization::ErrorSanitizationConfig>,
is_production: bool,
) -> crate::config::error_sanitization::ErrorSanitizer {
use crate::config::error_sanitization::{ErrorSanitizationConfig, ErrorSanitizer};
match compiled {
Some(cfg) => ErrorSanitizer::new(cfg),
None if is_production => ErrorSanitizer::new(ErrorSanitizationConfig {
enabled: true,
..Default::default()
}),
None => ErrorSanitizer::disabled(),
}
}
#[cfg(feature = "auth")]
pub(super) fn pkce_state_encryption_check(
has_state_encryption: bool,
is_production: bool,
) -> crate::Result<()> {
if has_state_encryption {
return Ok(());
}
if is_production {
return Err(ServerError::ConfigError(
concat!(
"FraiseQL failed to start\n\n",
" [security.pkce] enabled = true but [security.state_encryption] is\n",
" missing or disabled. PKCE state tokens would be sent to the OIDC\n",
" provider unencrypted, so the documented \"state encryption is\n",
" enforced\" posture would be false.\n\n",
" To fix, enable state encryption:\n",
" [security.state_encryption]\n",
" enabled = true\n",
" # 32-byte hex key supplied via STATE_ENCRYPTION_KEY\n",
" # (or set key_env to name a different variable)\n\n",
" For local development only:\n",
" Set FRAISEQL_ENV=development to downgrade this to a warning.",
)
.into(),
));
}
warn!(
"pkce.enabled = true but state_encryption is disabled — PKCE state tokens are \
sent to the OIDC provider unencrypted. Allowed only because \
FRAISEQL_ENV=development; enable [security.state_encryption] before production."
);
Ok(())
}
pub(super) fn failed_login_lockout_check(
max_attempts: u32,
lockout_secs: u64,
is_production: bool,
) -> crate::Result<()> {
let tuned = max_attempts != crate::middleware::rate_limit::DEFAULT_FAILED_LOGIN_MAX_ATTEMPTS
|| lockout_secs != crate::middleware::rate_limit::DEFAULT_FAILED_LOGIN_LOCKOUT_SECS;
if !tuned {
return Ok(());
}
if is_production {
return Err(crate::ServerError::ConfigError(
concat!(
"FraiseQL failed to start\n\n",
" [security.rate_limiting] failed_login_max_attempts / failed_login_lockout_secs\n",
" are set, but the fraiseql-server binary performs no first-factor login and\n",
" cannot enforce a failed-login lockout. OIDC/JWT is validated cryptographically\n",
" (first-factor auth is delegated to your identity provider), and TOTP MFA is a\n",
" library-only feature this binary does not mount.\n\n",
" Enforce brute-force protection where the first factor is actually checked:\n",
" - at your identity provider (login attempt limits / lockout), or\n",
" - at the edge (nginx / Cloudflare / a WAF) in front of FraiseQL.\n\n",
" Then remove failed_login_max_attempts / failed_login_lockout_secs from\n",
" [security.rate_limiting] (per-IP / per-endpoint rate limits still apply).\n\n",
" For local development only:\n",
" Set FRAISEQL_ENV=development to downgrade this to a warning.",
)
.into(),
));
}
warn!(
"[security.rate_limiting] failed_login_* is set but this binary performs no \
first-factor login and cannot enforce a failed-login lockout. Allowed only because \
FRAISEQL_ENV=development; enforce brute-force protection at your identity provider or \
edge proxy. Per-IP / per-endpoint rate limits still apply."
);
Ok(())
}
#[cfg(feature = "auth")]
#[allow(clippy::cognitive_complexity)] pub(super) async fn pkce_store_from_schema_in(
schema: &CompiledSchema,
state_encryption: Option<&Arc<crate::auth::state_encryption::StateEncryptionService>>,
is_production: bool,
) -> crate::Result<Option<Arc<crate::auth::PkceStateStore>>> {
let Some(security) = schema.security.as_ref() else {
return Ok(None);
};
let Some(cfg) = security.pkce.as_ref() else {
return Ok(None);
};
if !cfg.enabled {
return Ok(None);
}
pkce_state_encryption_check(state_encryption.is_some(), is_production)?;
if matches!(cfg.code_challenge_method, fraiseql_core::schema::CodeChallengeMethod::Plain) {
warn!(
"pkce.code_challenge_method = \"plain\" is insecure. \
Use \"S256\" in all production environments."
);
}
let enc = state_encryption.cloned();
#[cfg(feature = "redis-pkce")]
if let Some(ref url) = cfg.redis_url {
match crate::auth::PkceStateStore::new_redis(url, cfg.state_ttl_secs, enc.clone()).await {
Ok(store) => {
info!(redis_url = %url, "PKCE state store: Redis backend");
return Ok(Some(Arc::new(store)));
},
Err(e) => {
redis_backend_unavailable_check(
"[security.pkce] redis_url",
&e.to_string(),
PKCE_REDIS_CONSEQUENCE,
is_production,
)?;
},
}
}
#[cfg(not(feature = "redis-pkce"))]
if cfg.redis_url.is_some() {
redis_backend_unavailable_check(
"[security.pkce] redis_url",
"the `redis-pkce` Cargo feature is not compiled into this binary",
PKCE_REDIS_CONSEQUENCE,
is_production,
)?;
}
if cfg.redis_url.is_none() {
warn!(
"PKCE state store: in-memory. In a multi-replica deployment, auth flows will fail \
if /auth/start and /auth/callback hit different replicas. \
Set [security.pkce] redis_url to enable the Redis backend, \
or FRAISEQL_REQUIRE_REDIS=1 to enforce it at startup."
);
}
Ok(Some(Arc::new(crate::auth::PkceStateStore::new(cfg.state_ttl_secs, enc))))
}
#[cfg(feature = "auth")]
const PKCE_REDIS_CONSEQUENCE: &str = "PKCE login state would live only in this process's memory: \
behind a load balancer, /auth/callback fails with \"state not found\" whenever it lands on a \
different replica than /auth/start, and a restart drops every in-flight login";
pub(super) async fn resolve_rate_limiter(
schema: &CompiledSchema,
config: &crate::ServerConfig,
) -> crate::Result<Option<Arc<RateLimiter>>> {
resolve_rate_limiter_in(schema, config, crate::ServerConfig::is_production_mode()).await
}
pub(super) async fn resolve_rate_limiter_in(
schema: &CompiledSchema,
config: &crate::ServerConfig,
is_production: bool,
) -> crate::Result<Option<Arc<RateLimiter>>> {
let schema_sec = rate_limiting_from_schema(schema);
let overrides = &config.rate_limit_overrides;
let (mut effective, path_rules_source) = match (&schema_sec, &config.rate_limiting) {
(Some(sec), _) => (rate_limit_config_checked(sec, is_production)?, Some(sec)),
(None, Some(server_cfg)) => {
proxy_trust_check_parsed(
server_cfg.trust_proxy_headers,
&server_cfg.trusted_proxy_cidrs,
is_production,
)?;
(server_cfg.clone(), None)
},
(None, None) if overrides.enables() => {
(crate::middleware::RateLimitConfig::default(), None)
},
(None, None) => return Ok(None),
};
overrides.apply_to(&mut effective);
if !effective.enabled {
info!("Rate limiting disabled by configuration");
return Ok(None);
}
if effective.rps_per_ip == 0 || effective.burst_size == 0 {
return Err(crate::ServerError::ConfigError(format!(
"rate limiting is enabled but its budget is zero (rps_per_ip = {}, burst_size = {}), \
so every request would be rejected. Set a positive requests_per_second and \
burst_size, or disable rate limiting.",
effective.rps_per_ip, effective.burst_size
)));
}
proxy_trust_check_parsed(
effective.trust_proxy_headers,
&effective.trusted_proxy_cidrs,
is_production,
)?;
let limiter = build_rate_limiter(effective, path_rules_source, is_production).await?;
Ok(Some(Arc::new(limiter)))
}
async fn build_rate_limiter(
config: crate::middleware::RateLimitConfig,
sec: Option<&crate::middleware::RateLimitingSecurityConfig>,
is_production: bool,
) -> crate::Result<RateLimiter> {
let with_rules = |limiter: RateLimiter| match sec {
Some(sec) => limiter.with_path_rules_from_security(sec),
None => limiter,
};
let redis_url = sec.and_then(|s| s.redis_url.as_deref());
#[cfg(feature = "redis-rate-limiting")]
if let Some(url) = redis_url {
match RateLimiter::new_redis(url, config.clone()).await {
Ok(rl) => {
info!(
url,
rps_per_ip = config.rps_per_ip,
burst_size = config.burst_size,
"Rate limiting: using Redis distributed backend"
);
return Ok(with_rules(rl));
},
Err(e) => {
redis_backend_unavailable_check(
"[security.rate_limiting] redis_url",
&e.to_string(),
RATE_LIMIT_REDIS_CONSEQUENCE,
is_production,
)?;
},
}
}
#[cfg(not(feature = "redis-rate-limiting"))]
if redis_url.is_some() {
redis_backend_unavailable_check(
"[security.rate_limiting] redis_url",
"the `redis-rate-limiting` Cargo feature is not compiled into this binary",
RATE_LIMIT_REDIS_CONSEQUENCE,
is_production,
)?;
}
info!(
rps_per_ip = config.rps_per_ip,
burst_size = config.burst_size,
"Rate limiting: using in-memory backend"
);
Ok(with_rules(RateLimiter::new(config)))
}
const RATE_LIMIT_REDIS_CONSEQUENCE: &str = "rate-limit budgets would be tracked per process, so a \
deployment of N replicas enforces N times the configured rate while every replica's \
startup log reads healthy";
fn rate_limiting_from_schema(
schema: &CompiledSchema,
) -> Option<crate::middleware::RateLimitingSecurityConfig> {
schema.security.as_ref().and_then(|s| s.rate_limiting.clone())
}
fn rate_limit_config_checked(
sec: &crate::middleware::RateLimitingSecurityConfig,
is_production: bool,
) -> crate::Result<crate::middleware::RateLimitConfig> {
failed_login_lockout_check(
sec.failed_login_max_attempts,
sec.failed_login_lockout_secs,
is_production,
)?;
let config = crate::middleware::RateLimitConfig::try_from_security_config(sec)
.map_err(crate::ServerError::ConfigError)?;
proxy_trust_check_parsed(
config.trust_proxy_headers,
&config.trusted_proxy_cidrs,
is_production,
)?;
Ok(config)
}
pub(super) fn proxy_trust_check_parsed(
trust_proxy_headers: bool,
trusted_proxy_cidrs: &[ipnet::IpNet],
is_production: bool,
) -> crate::Result<()> {
let as_strings: Vec<String> = trusted_proxy_cidrs.iter().map(ToString::to_string).collect();
proxy_trust_check(trust_proxy_headers, Some(&as_strings), is_production)
}
pub(super) fn proxy_trust_check(
trust_proxy_headers: bool,
trusted_proxy_cidrs: Option<&[String]>,
is_production: bool,
) -> crate::Result<()> {
let trust_all_by_omission =
trust_proxy_headers && trusted_proxy_cidrs.is_none_or(<[String]>::is_empty);
if !trust_all_by_omission {
return Ok(());
}
if is_production {
return Err(crate::ServerError::ConfigError(
concat!(
"FraiseQL failed to start\n\n",
" [security.rate_limiting] trust_proxy_headers = true, but trusted_proxy_cidrs\n",
" is empty or unset. Every direct peer would be trusted to set X-Forwarded-For,\n",
" so any client could spoof its IP and bypass per-IP rate limiting (and poison\n",
" IP-derived logging).\n\n",
" Restrict which peers are trusted proxies:\n",
" trusted_proxy_cidrs = [\"10.0.0.0/8\"] # your load balancer / proxy ranges\n\n",
" Or, to keep trusting every proxy on purpose, opt in explicitly:\n",
" trusted_proxy_cidrs = [\"0.0.0.0/0\"]\n\n",
" For local development only:\n",
" Set FRAISEQL_ENV=development to downgrade this to a warning.",
)
.into(),
));
}
warn!(
"[security.rate_limiting] trust_proxy_headers = true but trusted_proxy_cidrs is empty. \
Any client can spoof X-Forwarded-For and bypass per-IP rate limits. Allowed only \
because FRAISEQL_ENV=development; set trusted_proxy_cidrs to your proxy ranges (e.g. \
[\"10.0.0.0/8\"]), or [\"0.0.0.0/0\"] to keep trusting every proxy explicitly."
);
Ok(())
}
pub fn redis_backend_unavailable_check(
config_key: &str,
cause: &str,
consequence: &str,
is_production: bool,
) -> crate::Result<()> {
if is_production {
return Err(crate::ServerError::ConfigError(format!(
"FraiseQL failed to start\n\n \
{config_key} is configured but the Redis backend is unavailable: {cause}.\n\n \
Falling back to in-memory would silently disable what the configuration \
promises:\n {consequence}.\n\n \
To fix, choose one:\n \
- make Redis reachable at the configured URL (and build with the matching \
Cargo feature)\n \
- remove the Redis URL from {config_key} to accept per-process, \
single-replica state\n\n \
For local development only:\n \
Set FRAISEQL_ENV=development to downgrade this to a warning."
)));
}
warn!(
config_key,
cause,
"configured Redis backend is unavailable — falling back to in-memory. {consequence}. \
Allowed only because FRAISEQL_ENV=development."
);
Ok(())
}
#[cfg(feature = "observers")]
pub(super) fn observer_transport_check(
kind: fraiseql_observers::config::TransportKind,
compiled_in: bool,
nats_url_present: bool,
is_production: bool,
) -> crate::Result<()> {
use fraiseql_observers::config::TransportKind;
match kind {
TransportKind::Postgres | TransportKind::InMemory => return Ok(()),
TransportKind::Nats => {},
_ => {
return refuse_or_warn_transport(
is_production,
UNKNOWN_TRANSPORT_MSG,
UNKNOWN_TRANSPORT_WARN,
);
},
}
if !compiled_in {
return refuse_or_warn_transport(
is_production,
NATS_NOT_COMPILED_MSG,
NATS_NOT_COMPILED_WARN,
);
}
if !nats_url_present {
return refuse_or_warn_transport(is_production, NATS_NO_URL_MSG, NATS_NO_URL_WARN);
}
Ok(())
}
#[cfg(feature = "observers")]
const NATS_NOT_COMPILED_MSG: &str = concat!(
"FraiseQL failed to start\n\n",
" [observers.runtime.transport] transport = \"nats\" (or\n",
" FRAISEQL_OBSERVER_TRANSPORT=nats) was selected, but this binary was not\n",
" built with NATS support, so the observer runtime cannot run on NATS and\n",
" would silently fall back to PostgreSQL LISTEN/NOTIFY.\n\n",
" To fix, build/run a binary with the NATS transport compiled in:\n",
" cargo build -p fraiseql-server --features observers-nats\n\n",
" Or select the PostgreSQL transport explicitly:\n",
" [observers.runtime.transport]\n",
" transport = \"postgres\"\n\n",
" For local development only:\n",
" Set FRAISEQL_ENV=development to downgrade this to a warning (runs on PostgreSQL).",
);
#[cfg(feature = "observers")]
const NATS_NOT_COMPILED_WARN: &str = "observer transport = \"nats\" selected but this binary lacks the observers-nats feature; \
the observer runtime will run on PostgreSQL. Allowed only because FRAISEQL_ENV=development; \
build with --features observers-nats before production.";
#[cfg(feature = "observers")]
const NATS_NO_URL_MSG: &str = concat!(
"FraiseQL failed to start\n\n",
" [observers.runtime.transport] transport = \"nats\" was selected, but no NATS\n",
" broker URL is configured, so the observer runtime cannot connect.\n\n",
" To fix, set the broker URL:\n",
" [observers.runtime.transport.nats]\n",
" url = \"nats://your-broker:4222\"\n",
" (or export FRAISEQL_NATS_URL).\n\n",
" For local development only:\n",
" Set FRAISEQL_ENV=development to downgrade this to a warning (runs on PostgreSQL).",
);
#[cfg(feature = "observers")]
const NATS_NO_URL_WARN: &str = "observer transport = \"nats\" selected but no NATS broker URL is configured; the observer \
runtime will run on PostgreSQL. Allowed only because FRAISEQL_ENV=development; set \
[observers.runtime.transport.nats] url before production.";
#[cfg(feature = "observers")]
const UNKNOWN_TRANSPORT_MSG: &str = concat!(
"FraiseQL failed to start\n\n",
" [observers.runtime.transport] selected an observer transport this binary\n",
" does not know how to run. Upgrade fraiseql-server, or select a supported\n",
" transport (\"postgres\" or \"nats\").\n\n",
" For local development only:\n",
" Set FRAISEQL_ENV=development to downgrade this to a warning (runs on PostgreSQL).",
);
#[cfg(feature = "observers")]
const UNKNOWN_TRANSPORT_WARN: &str = "observer transport selection is not supported by this binary; the observer runtime will run \
on PostgreSQL. Allowed only because FRAISEQL_ENV=development; upgrade fraiseql-server or \
select a supported transport before production.";
#[cfg(feature = "observers")]
fn refuse_or_warn_transport(
is_production: bool,
prod_msg: &'static str,
dev_warn: &'static str,
) -> crate::Result<()> {
if is_production {
return Err(crate::ServerError::ConfigError(prod_msg.into()));
}
warn!("{dev_warn}");
Ok(())
}
async fn read_capped_body(
response: reqwest::Response,
max_bytes: usize,
) -> Result<Vec<u8>, String> {
if let Some(declared) = response.content_length() {
if declared > max_bytes as u64 {
return Err(format!("response declares {declared} bytes, max {max_bytes}"));
}
}
let mut body = Vec::new();
let mut response = response;
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
if body.len() + chunk.len() > max_bytes {
return Err(format!("response exceeds {max_bytes} bytes"));
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
pub(super) fn is_manifest_url_ssrf_blocked(url: &str) -> bool {
let Ok(parsed) = reqwest::Url::parse(url) else {
return true;
};
let Some(host) = parsed.host_str() else {
return true;
};
fraiseql_guard::net::blocked_host_reason(host).is_some()
}
pub fn tenant_isolation_declaration_check(
schema: &CompiledSchema,
cache_enabled: bool,
) -> crate::Result<()> {
if !cache_enabled || schema.has_rls_configured() {
return Ok(());
}
if schema.is_multi_tenant() {
return Err(crate::ServerError::ConfigError(format!(
"Cache is enabled for a multi-tenant schema (tenancy.mode = {}) but no \
Row-Level Security is declared. Cache keys do not carry a tenant, so two \
tenants issuing the same query would share one cached response. In \
fraiseql.toml either declare `[security.rls] enabled = true` (and define the \
policies in the database), disable caching with `cache_enabled = false` in the \
server configuration, or \
set `[security] multi_tenant = false` with `[tenancy] mode = \"none\"` to \
acknowledge single-tenant mode.",
schema.tenancy_mode()
)));
}
warn!(
"Query-result caching is enabled but no Row-Level Security is declared in the \
compiled schema. This is safe for single-tenant deployments. For multi-tenant \
deployments, declare `[security.rls] enabled = true` and set `[security] \
multi_tenant = true`."
);
Ok(())
}
pub(super) async fn build_cached_adapter<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
schema: &CompiledSchema,
cache_enabled: bool,
adapter: Arc<A>,
) -> crate::Result<(
fraiseql_core::cache::CachedDatabaseAdapter<A>,
fraiseql_core::cache::CacheConfig,
)> {
use fraiseql_core::cache::{CacheConfig, CachedDatabaseAdapter, QueryResultCache};
tenant_isolation_declaration_check(schema, cache_enabled)?;
unattributable_mutation_check(schema, cache_enabled)?;
warn_on_inert_cache_ttls(schema, cache_enabled);
let cache_config = CacheConfig::from(cache_enabled);
if cache_config.enabled {
info!(
max_entries = cache_config.max_entries,
ttl_seconds = cache_config.ttl_seconds,
rls_enforcement = ?cache_config.rls_enforcement,
"Query result cache: active"
);
} else {
info!("Query result cache: disabled");
}
let inner = Arc::try_unwrap(adapter).unwrap_or_else(|shared| (*shared).clone());
let cached = CachedDatabaseAdapter::new(
inner,
QueryResultCache::new(cache_config),
schema.content_hash(),
)
.with_cache_metadata_from_schema(schema)
.with_rls(schema.has_rls_configured());
verify_declared_rls(schema, &cached, cache_config.rls_enforcement).await?;
fraiseql_core::schema::refuse_standby_unreadable_sources(&cached, schema)
.await
.map_err(|e| crate::ServerError::ConfigError(e.to_string()))?;
Ok((cached, cache_config))
}
pub(super) fn unattributable_mutation_check(
schema: &CompiledSchema,
cache_enabled: bool,
) -> crate::Result<()> {
if !cache_enabled || !fraiseql_core::cache::declares_cacheable_views(schema) {
return Ok(());
}
let unattributable = fraiseql_core::cache::unattributable_mutations(schema);
if unattributable.is_empty() {
return Ok(());
}
Err(crate::ServerError::ConfigError(format!(
"The compiled schema declares cacheable views and mutation(s) whose invalidation \
cannot be resolved from it: {}. A successful mutation that resolves to no view \
invalidates nothing — permanently, for a view annotated `cache_ttl_seconds = 0`. \
Declare `invalidates_views` on each mutation and recompile, or disable the result \
cache with `cache_enabled = false`.",
unattributable.join(", ")
)))
}
pub(super) async fn verify_declared_rls<A: DatabaseAdapter>(
schema: &CompiledSchema,
cached: &fraiseql_core::cache::CachedDatabaseAdapter<A>,
enforcement: fraiseql_core::cache::RlsEnforcement,
) -> crate::Result<()> {
if !schema.is_multi_tenant() || !schema.has_rls_configured() {
return Ok(());
}
cached
.enforce_rls(schema, enforcement)
.await
.map_err(|e| crate::ServerError::ConfigError(e.to_string()))
}
pub(super) fn warn_on_inert_cache_ttls(schema: &CompiledSchema, cache_enabled: bool) {
if cache_enabled {
return;
}
let declared: Vec<&str> = schema
.queries
.iter()
.filter(|q| q.cache_ttl_seconds.is_some())
.map(|q| q.name.as_str())
.collect();
if !declared.is_empty() {
warn!(
queries = ?declared,
"The compiled schema declares cache TTLs for {} query/queries, but the server's \
result cache is disabled (`cache_enabled = false`) — the TTLs (and any \
[[caching.rules]] they came from) have no effect. Enable `cache_enabled` or \
remove the declarations.",
declared.len()
);
}
}
pub fn field_encryption_unsupported_check(schema: &CompiledSchema) -> crate::Result<()> {
let encrypted: Vec<String> = schema
.types
.iter()
.flat_map(|t| {
t.fields
.iter()
.filter(|f| f.encryption.is_some())
.map(move |f| format!("{}.{}", t.name, f.name))
})
.collect();
if encrypted.is_empty() {
return Ok(());
}
Err(crate::ServerError::ConfigError(format!(
"Field-level at-rest encryption is configured for {} but is not supported in this \
release: the mutation path does not encrypt on write, so these field(s) would be \
stored in plaintext and then fail to decrypt on read (HTTP 500). Remove the \
`encryption` marker from these field(s) — and any `[security.field_encryption]` \
config — to start the server.",
encrypted.join(", ")
)))
}
#[cfg(feature = "auth")]
pub fn enrichment_consumer_without_resolver_check(
schema: &CompiledSchema,
config: &crate::ServerConfig,
) -> crate::Result<()> {
if !schema.declares_enrichment_consumer() {
return Ok(());
}
let enabled = config
.identity
.as_ref()
.and_then(|identity| identity.enrichment.as_ref())
.is_some_and(|enrichment| enrichment.enabled);
if enabled {
return Ok(());
}
Err(crate::ServerError::ConfigError(
"The compiled schema reads enriched identity (a session variable or inject param \
with an `enrichment` source), but `[identity.enrichment]` is not enabled. Nothing \
would resolve an identity, so every request reading an enriched field would fail \
and every other request would be served without the fail-closed check the schema \
implies. Enable `[identity.enrichment]` in fraiseql.toml, or remove the \
`enrichment` sources from the schema."
.to_string(),
))
}