Skip to main content

fraiseql_cli/config/
toml_schema.rs

1//! Complete TOML schema configuration supporting types, queries, mutations, federation, observers,
2//! caching
3//!
4//! This module extends FraiseQLConfig to support the full TOML-based schema definition.
5
6use std::{collections::BTreeMap, fmt, path::PathBuf};
7
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10
11use super::runtime::{DatabaseRuntimeConfig, ServerRuntimeConfig};
12use super::expand_env_vars;
13
14/// Domain-based schema organization
15///
16/// Automatically discovers schema files in domain directories:
17/// ```toml
18/// [schema.domain_discovery]
19/// enabled = true
20/// root_dir = "schema"
21/// ```
22///
23/// Expects structure:
24/// ```text
25/// schema/
26/// ├── auth/
27/// │   ├── types.json
28/// │   ├── queries.json
29/// │   └── mutations.json
30/// ├── products/
31/// │   ├── types.json
32/// │   ├── queries.json
33/// │   └── mutations.json
34/// ```
35#[derive(Debug, Clone, Default, Deserialize, Serialize)]
36#[serde(default, deny_unknown_fields)]
37pub struct DomainDiscovery {
38    /// Enable automatic domain discovery
39    pub enabled:  bool,
40    /// Root directory containing domains
41    pub root_dir: String,
42}
43
44/// Represents a discovered domain
45#[derive(Debug, Clone)]
46pub struct Domain {
47    /// Domain name (directory name)
48    pub name: String,
49    /// Path to domain root
50    pub path: PathBuf,
51}
52
53impl DomainDiscovery {
54    /// Discover all domains in root_dir
55    pub fn resolve_domains(&self) -> Result<Vec<Domain>> {
56        if !self.enabled {
57            return Ok(Vec::new());
58        }
59
60        let root = PathBuf::from(&self.root_dir);
61        if !root.is_dir() {
62            anyhow::bail!("Domain discovery root not found: {}", self.root_dir);
63        }
64
65        let mut domains = Vec::new();
66
67        for entry in std::fs::read_dir(&root)
68            .context(format!("Failed to read domain root: {}", self.root_dir))?
69        {
70            let entry = entry.context("Failed to read directory entry")?;
71            let path = entry.path();
72
73            if path.is_dir() {
74                let name = path
75                    .file_name()
76                    .and_then(|n| n.to_str())
77                    .map(std::string::ToString::to_string)
78                    .ok_or_else(|| anyhow::anyhow!("Invalid domain name: {}", path.display()))?;
79
80                domains.push(Domain { name, path });
81            }
82        }
83
84        // Sort for deterministic ordering
85        domains.sort_by(|a, b| a.name.cmp(&b.name));
86
87        Ok(domains)
88    }
89}
90
91/// Schema includes for multi-file composition (glob patterns)
92///
93/// Supports glob patterns for flexible file inclusion:
94/// ```toml
95/// [schema.includes]
96/// types = ["schema/types/**/*.json"]
97/// queries = ["schema/queries/**/*.json"]
98/// mutations = ["schema/mutations/**/*.json"]
99/// ```
100#[derive(Debug, Clone, Default, Deserialize, Serialize)]
101#[serde(default, deny_unknown_fields)]
102pub struct SchemaIncludes {
103    /// Glob patterns for type files
104    pub types:     Vec<String>,
105    /// Glob patterns for query files
106    pub queries:   Vec<String>,
107    /// Glob patterns for mutation files
108    pub mutations: Vec<String>,
109}
110
111impl SchemaIncludes {
112    /// Check if any includes are specified
113    pub fn is_empty(&self) -> bool {
114        self.types.is_empty() && self.queries.is_empty() && self.mutations.is_empty()
115    }
116
117    /// Resolve glob patterns to actual file paths
118    ///
119    /// # Returns
120    /// ResolvedIncludes with expanded file paths, or error if resolution fails
121    pub fn resolve_globs(&self) -> Result<ResolvedIncludes> {
122        use glob::glob as glob_pattern;
123
124        let mut type_paths = Vec::new();
125        let mut query_paths = Vec::new();
126        let mut mutation_paths = Vec::new();
127
128        // Resolve type globs
129        for pattern in &self.types {
130            for entry in glob_pattern(pattern)
131                .context(format!("Invalid glob pattern for types: {pattern}"))?
132            {
133                match entry {
134                    Ok(path) => type_paths.push(path),
135                    Err(e) => {
136                        anyhow::bail!("Error resolving type glob pattern '{pattern}': {e}");
137                    },
138                }
139            }
140        }
141
142        // Resolve query globs
143        for pattern in &self.queries {
144            for entry in glob_pattern(pattern)
145                .context(format!("Invalid glob pattern for queries: {pattern}"))?
146            {
147                match entry {
148                    Ok(path) => query_paths.push(path),
149                    Err(e) => {
150                        anyhow::bail!("Error resolving query glob pattern '{pattern}': {e}");
151                    },
152                }
153            }
154        }
155
156        // Resolve mutation globs
157        for pattern in &self.mutations {
158            for entry in glob_pattern(pattern)
159                .context(format!("Invalid glob pattern for mutations: {pattern}"))?
160            {
161                match entry {
162                    Ok(path) => mutation_paths.push(path),
163                    Err(e) => {
164                        anyhow::bail!("Error resolving mutation glob pattern '{pattern}': {e}");
165                    },
166                }
167            }
168        }
169
170        // Sort for deterministic ordering
171        type_paths.sort();
172        query_paths.sort();
173        mutation_paths.sort();
174
175        // Remove duplicates
176        type_paths.dedup();
177        query_paths.dedup();
178        mutation_paths.dedup();
179
180        Ok(ResolvedIncludes {
181            types:     type_paths,
182            queries:   query_paths,
183            mutations: mutation_paths,
184        })
185    }
186}
187
188/// Resolved glob patterns to actual file paths
189#[derive(Debug, Clone)]
190pub struct ResolvedIncludes {
191    /// Resolved type file paths
192    pub types:     Vec<PathBuf>,
193    /// Resolved query file paths
194    pub queries:   Vec<PathBuf>,
195    /// Resolved mutation file paths
196    pub mutations: Vec<PathBuf>,
197}
198
199/// Global defaults for list-query auto-params.
200///
201/// Applied when a per-query `auto_params` does not specify a given flag.
202/// Relay queries and single-item queries are never affected.
203///
204/// ```toml
205/// [query_defaults]
206/// where    = true
207/// order_by = true
208/// limit    = false  # e.g. Relay-first project
209/// offset   = false
210/// ```
211#[derive(Debug, Clone, Deserialize, Serialize)]
212#[serde(default, deny_unknown_fields)]
213pub struct QueryDefaults {
214    /// Enable automatic `where` filter parameter (default: true)
215    #[serde(rename = "where", default = "default_true")]
216    pub where_clause: bool,
217    /// Enable automatic `order_by` parameter (default: true)
218    #[serde(default = "default_true")]
219    pub order_by: bool,
220    /// Enable automatic `limit` parameter (default: true)
221    #[serde(default = "default_true")]
222    pub limit: bool,
223    /// Enable automatic `offset` parameter (default: true)
224    #[serde(default = "default_true")]
225    pub offset: bool,
226}
227
228impl Default for QueryDefaults {
229    fn default() -> Self {
230        Self { where_clause: true, order_by: true, limit: true, offset: true }
231    }
232}
233
234fn default_true() -> bool {
235    true
236}
237
238/// Complete TOML schema configuration
239#[derive(Debug, Clone, Default, Deserialize, Serialize)]
240#[serde(default, deny_unknown_fields)]
241pub struct TomlSchema {
242    /// Schema metadata
243    #[serde(rename = "schema")]
244    pub schema: SchemaMetadata,
245
246    /// Database connection pool configuration (optional — all fields have defaults).
247    ///
248    /// Supports `${VAR}` environment variable interpolation in the `url` field.
249    #[serde(rename = "database")]
250    pub database: DatabaseRuntimeConfig,
251
252    /// HTTP server runtime configuration (optional — all fields have defaults).
253    ///
254    /// CLI flags (`--port`, `--bind`) take precedence over these settings.
255    #[serde(rename = "server")]
256    pub server: ServerRuntimeConfig,
257
258    /// Type definitions
259    #[serde(rename = "types")]
260    pub types: BTreeMap<String, TypeDefinition>,
261
262    /// Query definitions
263    #[serde(rename = "queries")]
264    pub queries: BTreeMap<String, QueryDefinition>,
265
266    /// Mutation definitions
267    #[serde(rename = "mutations")]
268    pub mutations: BTreeMap<String, MutationDefinition>,
269
270    /// Federation configuration
271    #[serde(rename = "federation")]
272    pub federation: FederationConfig,
273
274    /// Security configuration
275    #[serde(rename = "security")]
276    pub security: SecuritySettings,
277
278    /// Observers/event system configuration
279    #[serde(rename = "observers")]
280    pub observers: ObserversConfig,
281
282    /// Result caching configuration
283    #[serde(rename = "caching")]
284    pub caching: CachingConfig,
285
286    /// Analytics configuration
287    #[serde(rename = "analytics")]
288    pub analytics: AnalyticsConfig,
289
290    /// Observability configuration
291    #[serde(rename = "observability")]
292    pub observability: ObservabilityConfig,
293
294    /// Schema includes configuration for multi-file composition
295    #[serde(default)]
296    pub includes: SchemaIncludes,
297
298    /// Domain discovery configuration for domain-based organization
299    #[serde(default)]
300    pub domain_discovery: DomainDiscovery,
301
302    /// Global defaults for list-query auto-params.
303    ///
304    /// Provides project-wide defaults for `where`, `order_by`, `limit`, and `offset`
305    /// parameters on list queries. Per-query `auto_params` overrides are partial —
306    /// only the specified flags override the defaults. Relay queries and single-item
307    /// queries are never affected.
308    #[serde(default)]
309    pub query_defaults: QueryDefaults,
310
311    /// OAuth2 client identity for server-side PKCE flows.
312    ///
313    /// Required when `[security.pkce] enabled = true`.
314    /// Holds the OIDC provider discovery URL, client_id, and a reference to
315    /// the env var containing the client secret. Never stores the secret itself.
316    #[serde(default)]
317    pub auth: Option<OidcClientConfig>,
318
319    /// WebSocket subscription configuration (hooks, limits).
320    #[serde(default)]
321    pub subscriptions: SubscriptionsConfig,
322
323    /// Query validation limits (depth, complexity).
324    #[serde(default)]
325    pub validation: ValidationConfig,
326
327    /// Debug/development settings (database EXPLAIN, SQL exposure).
328    #[serde(default)]
329    pub debug: DebugConfig,
330
331    /// MCP (Model Context Protocol) server configuration.
332    #[serde(default)]
333    pub mcp: McpConfig,
334}
335
336/// MCP (Model Context Protocol) server configuration.
337///
338/// Enables AI/LLM tools to interact with FraiseQL queries and mutations
339/// through the standardized Model Context Protocol.
340#[derive(Debug, Clone, Deserialize, Serialize)]
341#[serde(default, deny_unknown_fields)]
342pub struct McpConfig {
343    /// Enable MCP server endpoint.
344    pub enabled:      bool,
345    /// Transport mode: "http", "stdio", or "both".
346    pub transport:    String,
347    /// HTTP path for MCP endpoint (e.g., "/mcp").
348    pub path:         String,
349    /// Require authentication for MCP requests.
350    pub require_auth: bool,
351    /// Whitelist of query/mutation names to expose (empty = all).
352    #[serde(default)]
353    pub include:      Vec<String>,
354    /// Blacklist of query/mutation names to hide.
355    #[serde(default)]
356    pub exclude:      Vec<String>,
357}
358
359impl Default for McpConfig {
360    fn default() -> Self {
361        Self {
362            enabled:      false,
363            transport:    "http".to_string(),
364            path:         "/mcp".to_string(),
365            require_auth: true,
366            include:      Vec::new(),
367            exclude:      Vec::new(),
368        }
369    }
370}
371
372/// Schema metadata
373#[derive(Debug, Clone, Deserialize, Serialize)]
374#[serde(default, deny_unknown_fields)]
375pub struct SchemaMetadata {
376    /// Schema name
377    pub name:            String,
378    /// Schema version
379    pub version:         String,
380    /// Optional schema description
381    pub description:     Option<String>,
382    /// Target database (postgresql, mysql, sqlite, sqlserver)
383    pub database_target: String,
384}
385
386impl Default for SchemaMetadata {
387    fn default() -> Self {
388        Self {
389            name:            "myapp".to_string(),
390            version:         "1.0.0".to_string(),
391            description:     None,
392            database_target: "postgresql".to_string(),
393        }
394    }
395}
396
397/// Type definition in TOML
398#[derive(Debug, Clone, Deserialize, Serialize)]
399#[serde(default, deny_unknown_fields)]
400pub struct TypeDefinition {
401    /// SQL source table or view
402    pub sql_source:  String,
403    /// Human-readable type description
404    pub description: Option<String>,
405    /// Field definitions
406    pub fields:      BTreeMap<String, FieldDefinition>,
407}
408
409impl Default for TypeDefinition {
410    fn default() -> Self {
411        Self {
412            sql_source:  "v_entity".to_string(),
413            description: None,
414            fields:      BTreeMap::new(),
415        }
416    }
417}
418
419/// Field definition
420#[derive(Debug, Clone, Deserialize, Serialize)]
421#[serde(deny_unknown_fields)]
422pub struct FieldDefinition {
423    /// GraphQL field type (ID, String, Int, Boolean, DateTime, etc.)
424    #[serde(rename = "type")]
425    pub field_type:  String,
426    /// Whether field can be null
427    #[serde(default)]
428    pub nullable:    bool,
429    /// Field description
430    pub description: Option<String>,
431}
432
433/// Query definition in TOML
434#[derive(Debug, Clone, Deserialize, Serialize)]
435#[serde(default, deny_unknown_fields)]
436pub struct QueryDefinition {
437    /// Return type name
438    pub return_type:  String,
439    /// Whether query returns an array
440    #[serde(default)]
441    pub return_array: bool,
442    /// SQL source for the query
443    pub sql_source:   String,
444    /// Query description
445    pub description:  Option<String>,
446    /// Query arguments
447    pub args:         Vec<ArgumentDefinition>,
448}
449
450impl Default for QueryDefinition {
451    fn default() -> Self {
452        Self {
453            return_type:  "String".to_string(),
454            return_array: false,
455            sql_source:   "v_entity".to_string(),
456            description:  None,
457            args:         vec![],
458        }
459    }
460}
461
462/// Mutation definition in TOML
463#[derive(Debug, Clone, Deserialize, Serialize)]
464#[serde(default, deny_unknown_fields)]
465pub struct MutationDefinition {
466    /// Return type name
467    pub return_type: String,
468    /// SQL function or procedure source
469    pub sql_source:  String,
470    /// Operation type (CREATE, UPDATE, DELETE)
471    pub operation:   String,
472    /// Mutation description
473    pub description: Option<String>,
474    /// Mutation arguments
475    pub args:        Vec<ArgumentDefinition>,
476}
477
478impl Default for MutationDefinition {
479    fn default() -> Self {
480        Self {
481            return_type: "String".to_string(),
482            sql_source:  "fn_operation".to_string(),
483            operation:   "CREATE".to_string(),
484            description: None,
485            args:        vec![],
486        }
487    }
488}
489
490/// Argument definition
491#[derive(Debug, Clone, Deserialize, Serialize)]
492#[serde(deny_unknown_fields)]
493pub struct ArgumentDefinition {
494    /// Argument name
495    pub name:        String,
496    /// Argument type
497    #[serde(rename = "type")]
498    pub arg_type:    String,
499    /// Whether argument is required
500    #[serde(default)]
501    pub required:    bool,
502    /// Default value if not provided
503    pub default:     Option<serde_json::Value>,
504    /// Argument description
505    pub description: Option<String>,
506}
507
508/// Circuit breaker configuration for a specific federated database/service
509#[derive(Debug, Clone, Deserialize, Serialize)]
510#[serde(deny_unknown_fields)]
511pub struct PerDatabaseCircuitBreakerOverride {
512    /// Database or service name matching a federation entity
513    pub database:             String,
514    /// Override: number of consecutive failures before opening (must be > 0)
515    pub failure_threshold:    Option<u32>,
516    /// Override: seconds to wait before attempting recovery (must be > 0)
517    pub recovery_timeout_secs: Option<u64>,
518    /// Override: successes required in half-open state to close the breaker (must be > 0)
519    pub success_threshold:    Option<u32>,
520}
521
522/// Circuit breaker configuration for Apollo Federation fan-out requests
523#[derive(Debug, Clone, Deserialize, Serialize)]
524#[serde(default, deny_unknown_fields)]
525pub struct FederationCircuitBreakerConfig {
526    /// Enable circuit breaker protection on federation fan-out
527    pub enabled:              bool,
528    /// Consecutive failures before the breaker opens (default: 5, must be > 0)
529    pub failure_threshold:    u32,
530    /// Seconds to wait before attempting a probe request (default: 30, must be > 0)
531    pub recovery_timeout_secs: u64,
532    /// Probe successes needed to transition from half-open to closed (default: 2, must be > 0)
533    pub success_threshold:    u32,
534    /// Per-database overrides (database name must match a defined federation entity)
535    pub per_database:         Vec<PerDatabaseCircuitBreakerOverride>,
536}
537
538impl Default for FederationCircuitBreakerConfig {
539    fn default() -> Self {
540        Self {
541            enabled:              true,
542            failure_threshold:    5,
543            recovery_timeout_secs: 30,
544            success_threshold:    2,
545            per_database:         vec![],
546        }
547    }
548}
549
550/// Federation configuration
551#[derive(Debug, Clone, Deserialize, Serialize)]
552#[serde(default, deny_unknown_fields)]
553pub struct FederationConfig {
554    /// Enable Apollo federation
555    #[serde(default)]
556    pub enabled:         bool,
557    /// Apollo federation version
558    pub apollo_version:  Option<u32>,
559    /// Federated entities
560    pub entities:        Vec<FederationEntity>,
561    /// Circuit breaker configuration for federation fan-out requests
562    pub circuit_breaker: Option<FederationCircuitBreakerConfig>,
563}
564
565impl Default for FederationConfig {
566    fn default() -> Self {
567        Self {
568            enabled:         false,
569            apollo_version:  Some(2),
570            entities:        vec![],
571            circuit_breaker: None,
572        }
573    }
574}
575
576/// Federation entity
577#[derive(Debug, Clone, Deserialize, Serialize)]
578#[serde(deny_unknown_fields)]
579pub struct FederationEntity {
580    /// Entity name
581    pub name:       String,
582    /// Key fields for entity resolution
583    pub key_fields: Vec<String>,
584}
585
586/// Security configuration
587#[derive(Debug, Clone, Deserialize, Serialize)]
588#[serde(default, deny_unknown_fields)]
589pub struct SecuritySettings {
590    /// Default policy to apply if none specified
591    pub default_policy:      Option<String>,
592    /// Custom authorization rules
593    pub rules:               Vec<AuthorizationRule>,
594    /// Authorization policies
595    pub policies:            Vec<AuthorizationPolicy>,
596    /// Field-level authorization rules
597    pub field_auth:          Vec<FieldAuthRule>,
598    /// Enterprise security configuration (legacy flags)
599    pub enterprise:          EnterpriseSecurityConfig,
600    /// Error sanitization — controls what detail clients see in error responses
601    pub error_sanitization:  Option<ErrorSanitizationTomlConfig>,
602    /// Rate limiting — per-endpoint request caps
603    pub rate_limiting:       Option<RateLimitingSecurityConfig>,
604    /// State encryption — AEAD encryption for OAuth state and PKCE blobs
605    pub state_encryption:    Option<StateEncryptionConfig>,
606    /// PKCE — Proof Key for Code Exchange for OAuth Authorization Code flows
607    pub pkce:                Option<PkceConfig>,
608    /// API key authentication — static or database-backed key-based auth
609    pub api_keys:            Option<ApiKeySecurityConfig>,
610    /// Token revocation — reject JWTs by `jti` after revocation
611    pub token_revocation:    Option<TokenRevocationSecurityConfig>,
612    /// Trusted documents — query allowlist (strict or permissive mode)
613    pub trusted_documents:   Option<TrustedDocumentsConfig>,
614}
615
616impl Default for SecuritySettings {
617    fn default() -> Self {
618        Self {
619            default_policy:     Some("authenticated".to_string()),
620            rules:              vec![],
621            policies:           vec![],
622            field_auth:         vec![],
623            enterprise:         EnterpriseSecurityConfig::default(),
624            error_sanitization: None,
625            rate_limiting:      None,
626            state_encryption:   None,
627            pkce:               None,
628            api_keys:           None,
629            token_revocation:   None,
630            trusted_documents:  None,
631        }
632    }
633}
634
635/// Authorization rule (custom expressions)
636#[derive(Debug, Clone, Deserialize, Serialize)]
637#[serde(deny_unknown_fields)]
638pub struct AuthorizationRule {
639    /// Rule name
640    pub name:              String,
641    /// Rule expression or condition
642    pub rule:              String,
643    /// Rule description
644    pub description:       Option<String>,
645    /// Whether rule result can be cached
646    #[serde(default)]
647    pub cacheable:         bool,
648    /// Cache time-to-live in seconds
649    pub cache_ttl_seconds: Option<u32>,
650}
651
652/// Authorization policy (RBAC/ABAC)
653#[derive(Debug, Clone, Deserialize, Serialize)]
654#[serde(deny_unknown_fields)]
655pub struct AuthorizationPolicy {
656    /// Policy name
657    pub name:              String,
658    /// Policy type (RBAC, ABAC, CUSTOM, HYBRID)
659    #[serde(rename = "type")]
660    pub policy_type:       String,
661    /// Optional rule expression
662    pub rule:              Option<String>,
663    /// Roles this policy applies to
664    pub roles:             Vec<String>,
665    /// Combination strategy (ANY, ALL, EXACTLY)
666    pub strategy:          Option<String>,
667    /// Attributes for attribute-based access control
668    #[serde(default)]
669    pub attributes:        Vec<String>,
670    /// Policy description
671    pub description:       Option<String>,
672    /// Cache time-to-live in seconds
673    pub cache_ttl_seconds: Option<u32>,
674}
675
676/// Field-level authorization rule
677#[derive(Debug, Clone, Deserialize, Serialize)]
678#[serde(deny_unknown_fields)]
679pub struct FieldAuthRule {
680    /// Type name this rule applies to
681    pub type_name:  String,
682    /// Field name this rule applies to
683    pub field_name: String,
684    /// Policy to enforce
685    pub policy:     String,
686}
687
688/// Enterprise security configuration
689#[derive(Debug, Clone, Deserialize, Serialize)]
690#[serde(default, deny_unknown_fields)]
691pub struct EnterpriseSecurityConfig {
692    /// Enable rate limiting
693    pub rate_limiting_enabled:        bool,
694    /// Max requests per auth endpoint
695    pub auth_endpoint_max_requests:   u32,
696    /// Rate limit window in seconds
697    pub auth_endpoint_window_seconds: u64,
698    /// Enable audit logging
699    pub audit_logging_enabled:        bool,
700    /// Audit log backend service
701    pub audit_log_backend:            String,
702    /// Audit log retention in days
703    pub audit_retention_days:         u32,
704    /// Enable error sanitization
705    pub error_sanitization:           bool,
706    /// Hide implementation details in errors
707    pub hide_implementation_details:  bool,
708    /// Enable constant-time token comparison
709    pub constant_time_comparison:     bool,
710    /// Enable PKCE for OAuth flows
711    pub pkce_enabled:                 bool,
712}
713
714impl Default for EnterpriseSecurityConfig {
715    fn default() -> Self {
716        Self {
717            rate_limiting_enabled:        true,
718            auth_endpoint_max_requests:   100,
719            auth_endpoint_window_seconds: 60,
720            audit_logging_enabled:        true,
721            audit_log_backend:            "postgresql".to_string(),
722            audit_retention_days:         365,
723            error_sanitization:           true,
724            hide_implementation_details:  true,
725            constant_time_comparison:     true,
726            pkce_enabled:                 true,
727        }
728    }
729}
730
731/// Controls how much error detail is exposed to API clients.
732/// When enabled, internal error messages, SQL, and stack traces are stripped.
733///
734/// Note: named `ErrorSanitizationTomlConfig` to avoid collision with the identically-named
735/// struct in `config::security` which serves `FraiseQLConfig`.
736#[derive(Debug, Clone, Deserialize, Serialize)]
737#[serde(default, deny_unknown_fields)]
738pub struct ErrorSanitizationTomlConfig {
739    /// Enable error sanitization (default: false — opt-in)
740    pub enabled: bool,
741    /// Strip stack traces, SQL fragments, file paths (default: true)
742    #[serde(default = "default_true")]
743    pub hide_implementation_details: bool,
744    /// Replace raw database error messages with a generic message (default: true)
745    #[serde(default = "default_true")]
746    pub sanitize_database_errors: bool,
747    /// Replacement message shown to clients when an internal error is sanitized
748    pub custom_error_message: Option<String>,
749}
750
751impl Default for ErrorSanitizationTomlConfig {
752    fn default() -> Self {
753        Self {
754            enabled:                     false,
755            hide_implementation_details: true,
756            sanitize_database_errors:    true,
757            custom_error_message:        None,
758        }
759    }
760}
761
762/// Per-endpoint and global rate limiting configuration for `[security.rate_limiting]`.
763#[derive(Debug, Clone, Deserialize, Serialize)]
764#[serde(default, deny_unknown_fields)]
765pub struct RateLimitingSecurityConfig {
766    /// Enable rate limiting
767    pub enabled: bool,
768    /// Global request rate cap (requests per second, per IP)
769    pub requests_per_second: u32,
770    /// Burst allowance above the steady-state rate
771    pub burst_size: u32,
772    /// Auth initiation endpoint — max requests per window
773    pub auth_start_max_requests: u32,
774    /// Auth initiation window in seconds
775    pub auth_start_window_secs: u64,
776    /// OAuth callback endpoint — max requests per window
777    pub auth_callback_max_requests: u32,
778    /// OAuth callback window in seconds
779    pub auth_callback_window_secs: u64,
780    /// Token refresh endpoint — max requests per window
781    pub auth_refresh_max_requests: u32,
782    /// Token refresh window in seconds
783    pub auth_refresh_window_secs: u64,
784    /// Logout endpoint — max requests per window
785    pub auth_logout_max_requests: u32,
786    /// Logout window in seconds
787    pub auth_logout_window_secs: u64,
788    /// Failed login attempts before lockout
789    pub failed_login_max_attempts: u32,
790    /// Duration of failed-login lockout in seconds
791    pub failed_login_lockout_secs: u64,
792    /// Per-authenticated-user request rate in requests/second.
793    /// Defaults to 10× `requests_per_second` if not set.
794    #[serde(default, skip_serializing_if = "Option::is_none")]
795    pub requests_per_second_per_user: Option<u32>,
796    /// Redis URL for distributed rate limiting (optional — falls back to in-memory)
797    pub redis_url: Option<String>,
798    /// Trust `X-Real-IP` / `X-Forwarded-For` headers for client IP extraction.
799    ///
800    /// Set to `true` only when FraiseQL is deployed behind a trusted reverse proxy
801    /// (e.g. nginx, Cloudflare, AWS ALB) that sets these headers.
802    /// Enabling without a trusted proxy allows clients to spoof their IP address.
803    #[serde(default)]
804    pub trust_proxy_headers: bool,
805}
806
807impl Default for RateLimitingSecurityConfig {
808    fn default() -> Self {
809        Self {
810            enabled:                      false,
811            requests_per_second:          100,
812            requests_per_second_per_user: None,
813            burst_size:                   200,
814            auth_start_max_requests:      5,
815            auth_start_window_secs:       60,
816            auth_callback_max_requests:   10,
817            auth_callback_window_secs:    60,
818            auth_refresh_max_requests:    20,
819            auth_refresh_window_secs:     300,
820            auth_logout_max_requests:     30,
821            auth_logout_window_secs:      60,
822            failed_login_max_attempts:    10,
823            failed_login_lockout_secs:    900,
824            redis_url:                    None,
825            trust_proxy_headers:          false,
826        }
827    }
828}
829
830/// AEAD algorithm for OAuth state and PKCE state blobs.
831#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
832pub enum EncryptionAlgorithm {
833    /// ChaCha20-Poly1305 (recommended — constant-time, software-friendly)
834    #[default]
835    #[serde(rename = "chacha20-poly1305")]
836    Chacha20Poly1305,
837    /// AES-256-GCM (hardware-accelerated on modern CPUs)
838    #[serde(rename = "aes-256-gcm")]
839    Aes256Gcm,
840}
841
842impl fmt::Display for EncryptionAlgorithm {
843    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
844        match self {
845            Self::Chacha20Poly1305 => f.write_str("chacha20-poly1305"),
846            Self::Aes256Gcm        => f.write_str("aes-256-gcm"),
847        }
848    }
849}
850
851/// Where the encryption key is sourced from.
852#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
853#[serde(rename_all = "lowercase")]
854pub enum KeySource {
855    /// Read key from an environment variable
856    #[default]
857    Env,
858}
859
860/// AEAD encryption for OAuth state parameter and PKCE code challenges.
861#[derive(Debug, Clone, Deserialize, Serialize)]
862#[serde(default, deny_unknown_fields)]
863pub struct StateEncryptionConfig {
864    /// Enable state encryption
865    pub enabled: bool,
866    /// AEAD algorithm to use
867    pub algorithm: EncryptionAlgorithm,
868    /// Where to source the encryption key
869    pub key_source: KeySource,
870    /// Environment variable holding the 32-byte hex-encoded key
871    pub key_env: Option<String>,
872}
873
874impl Default for StateEncryptionConfig {
875    fn default() -> Self {
876        Self {
877            enabled:    false,
878            algorithm:  EncryptionAlgorithm::default(),
879            key_source: KeySource::Env,
880            key_env:    Some("STATE_ENCRYPTION_KEY".to_string()),
881        }
882    }
883}
884
885/// PKCE code challenge method.
886#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
887pub enum CodeChallengeMethod {
888    /// SHA-256 (required in production)
889    #[default]
890    #[serde(rename = "S256")]
891    S256,
892    /// Plain (spec-allowed but insecure — warns at runtime)
893    #[serde(rename = "plain")]
894    Plain,
895}
896
897/// PKCE (Proof Key for Code Exchange) configuration.
898/// Requires `state_encryption` to be enabled for secure state storage.
899#[derive(Debug, Clone, Deserialize, Serialize)]
900#[serde(default, deny_unknown_fields)]
901pub struct PkceConfig {
902    /// Enable PKCE for OAuth Authorization Code flows
903    pub enabled: bool,
904    /// Code challenge method (`S256` recommended)
905    pub code_challenge_method: CodeChallengeMethod,
906    /// How long the PKCE state is valid before the auth flow expires (seconds)
907    pub state_ttl_secs: u64,
908    /// Redis URL for distributed PKCE state storage across multiple replicas.
909    ///
910    /// Required for multi-replica deployments (Kubernetes, ECS, fly.io with
911    /// multiple instances). Without Redis, `/auth/start` and `/auth/callback`
912    /// must hit the same replica.
913    ///
914    /// Requires the `redis-pkce` Cargo feature to be compiled in.
915    /// Example: `"redis://localhost:6379"` or `"${REDIS_URL}"`.
916    #[serde(skip_serializing_if = "Option::is_none")]
917    pub redis_url: Option<String>,
918}
919
920impl Default for PkceConfig {
921    fn default() -> Self {
922        Self {
923            enabled:               false,
924            code_challenge_method: CodeChallengeMethod::S256,
925            state_ttl_secs:        600,
926            redis_url:             None,
927        }
928    }
929}
930
931/// API key authentication configuration.
932///
933/// ```toml
934/// [security.api_keys]
935/// enabled = true
936/// header = "X-API-Key"
937/// hash_algorithm = "sha256"
938/// storage = "env"
939///
940/// [[security.api_keys.static]]
941/// key_hash = "sha256:abc123..."
942/// scopes = ["read:*"]
943/// name = "ci-readonly"
944/// ```
945#[derive(Debug, Clone, Deserialize, Serialize)]
946#[serde(default, deny_unknown_fields)]
947pub struct ApiKeySecurityConfig {
948    /// Enable API key authentication
949    pub enabled: bool,
950    /// HTTP header name to read the API key from
951    pub header: String,
952    /// Hash algorithm for key verification (`sha256`)
953    pub hash_algorithm: String,
954    /// Storage backend: `"env"` for static keys, `"postgres"` for DB-backed
955    pub storage: String,
956    /// Static API key entries (only for `storage = "env"`)
957    #[serde(default, rename = "static")]
958    pub static_keys: Vec<StaticApiKeyEntry>,
959}
960
961impl Default for ApiKeySecurityConfig {
962    fn default() -> Self {
963        Self {
964            enabled:        false,
965            header:         "X-API-Key".to_string(),
966            hash_algorithm: "sha256".to_string(),
967            storage:        "env".to_string(),
968            static_keys:    vec![],
969        }
970    }
971}
972
973/// A single static API key entry.
974#[derive(Debug, Clone, Deserialize, Serialize)]
975#[serde(deny_unknown_fields)]
976pub struct StaticApiKeyEntry {
977    /// Hex-encoded hash, optionally prefixed with algorithm (e.g. `sha256:abc...`)
978    pub key_hash: String,
979    /// Scopes granted by this key
980    #[serde(default)]
981    pub scopes: Vec<String>,
982    /// Human-readable name for audit logging
983    pub name: String,
984}
985
986/// Trusted document mode.
987#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
988#[serde(rename_all = "lowercase")]
989pub enum TrustedDocumentMode {
990    /// Only documentId requests allowed; raw query strings rejected
991    Strict,
992    /// documentId requests use the manifest; raw queries fall through
993    #[default]
994    Permissive,
995}
996
997/// Trusted documents / query allowlist configuration.
998///
999/// ```toml
1000/// [security.trusted_documents]
1001/// enabled = true
1002/// mode = "strict"
1003/// manifest_path = "./trusted-documents.json"
1004/// reload_interval_secs = 0
1005/// ```
1006#[derive(Debug, Clone, Deserialize, Serialize)]
1007#[serde(default, deny_unknown_fields)]
1008pub struct TrustedDocumentsConfig {
1009    /// Enable trusted documents
1010    pub enabled: bool,
1011    /// Enforcement mode: "strict" or "permissive"
1012    pub mode: TrustedDocumentMode,
1013    /// Path to the trusted documents manifest JSON file
1014    #[serde(skip_serializing_if = "Option::is_none")]
1015    pub manifest_path: Option<String>,
1016    /// URL to fetch the trusted documents manifest from at startup
1017    #[serde(skip_serializing_if = "Option::is_none")]
1018    pub manifest_url: Option<String>,
1019    /// Poll interval in seconds for hot-reloading the manifest (0 = no reload)
1020    #[serde(default)]
1021    pub reload_interval_secs: u64,
1022}
1023
1024impl Default for TrustedDocumentsConfig {
1025    fn default() -> Self {
1026        Self {
1027            enabled:              false,
1028            mode:                 TrustedDocumentMode::Permissive,
1029            manifest_path:        None,
1030            manifest_url:         None,
1031            reload_interval_secs: 0,
1032        }
1033    }
1034}
1035
1036/// Token revocation configuration.
1037///
1038/// ```toml
1039/// [security.token_revocation]
1040/// enabled = true
1041/// backend = "redis"
1042/// require_jti = true
1043/// fail_open = false
1044/// ```
1045#[derive(Debug, Clone, Deserialize, Serialize)]
1046#[serde(default, deny_unknown_fields)]
1047pub struct TokenRevocationSecurityConfig {
1048    /// Enable token revocation
1049    pub enabled: bool,
1050    /// Backend: `"redis"`, `"postgres"`, or `"memory"`
1051    pub backend: String,
1052    /// Reject JWTs without a `jti` claim when revocation is enabled
1053    #[serde(default = "default_true")]
1054    pub require_jti: bool,
1055    /// If revocation store is unreachable: `false` = reject (fail-closed), `true` = allow (fail-open)
1056    #[serde(default)]
1057    pub fail_open: bool,
1058    /// Redis URL for distributed revocation (optional — inherited from `[fraiseql.redis]` if absent)
1059    #[serde(skip_serializing_if = "Option::is_none")]
1060    pub redis_url: Option<String>,
1061}
1062
1063impl Default for TokenRevocationSecurityConfig {
1064    fn default() -> Self {
1065        Self {
1066            enabled:     false,
1067            backend:     "memory".to_string(),
1068            require_jti: true,
1069            fail_open:   false,
1070            redis_url:   None,
1071        }
1072    }
1073}
1074
1075/// OAuth2 client configuration for server-side PKCE flows.
1076///
1077/// The client secret is intentionally absent — use `client_secret_env` to
1078/// name the environment variable that holds the secret at runtime.
1079///
1080/// ```toml
1081/// [auth]
1082/// discovery_url       = "https://accounts.google.com"
1083/// client_id           = "my-fraiseql-client"
1084/// client_secret_env   = "OIDC_CLIENT_SECRET"
1085/// server_redirect_uri = "https://api.example.com/auth/callback"
1086/// ```
1087#[derive(Debug, Clone, Deserialize, Serialize)]
1088#[serde(deny_unknown_fields)]
1089pub struct OidcClientConfig {
1090    /// OIDC provider discovery URL (e.g. `"https://accounts.google.com"`).
1091    /// Used to fetch `authorization_endpoint` and `token_endpoint` at compile time.
1092    pub discovery_url:       String,
1093    /// OAuth2 `client_id` registered with the provider.
1094    pub client_id:           String,
1095    /// Name of the environment variable that holds the client secret.
1096    /// The secret itself must never appear in TOML or the compiled schema.
1097    pub client_secret_env:   String,
1098    /// The full URL of this server's `/auth/callback` endpoint,
1099    /// e.g. `"https://api.example.com/auth/callback"`.
1100    pub server_redirect_uri: String,
1101}
1102
1103/// Observers/event system configuration
1104#[derive(Debug, Clone, Deserialize, Serialize)]
1105#[serde(default, deny_unknown_fields)]
1106pub struct ObserversConfig {
1107    /// Enable observers system
1108    #[serde(default)]
1109    pub enabled:   bool,
1110    /// Backend service (redis, nats, postgresql, mysql, in-memory)
1111    pub backend:   String,
1112    /// Redis connection URL (required when backend = "redis")
1113    pub redis_url: Option<String>,
1114    /// NATS connection URL (required when backend = "nats")
1115    ///
1116    /// Example: `nats://localhost:4222`
1117    /// Can be overridden at runtime via the `FRAISEQL_NATS_URL` environment variable.
1118    pub nats_url:  Option<String>,
1119    /// Event handlers
1120    pub handlers:  Vec<EventHandler>,
1121}
1122
1123impl Default for ObserversConfig {
1124    fn default() -> Self {
1125        Self {
1126            enabled:   false,
1127            backend:   "redis".to_string(),
1128            redis_url: None,
1129            nats_url:  None,
1130            handlers:  vec![],
1131        }
1132    }
1133}
1134
1135/// Event handler configuration
1136#[derive(Debug, Clone, Deserialize, Serialize)]
1137#[serde(deny_unknown_fields)]
1138pub struct EventHandler {
1139    /// Handler name
1140    pub name:           String,
1141    /// Event type to handle
1142    pub event:          String,
1143    /// Action to perform (slack, email, sms, webhook, push, etc.)
1144    pub action:         String,
1145    /// Webhook URL for webhook actions
1146    pub webhook_url:    Option<String>,
1147    /// Retry strategy
1148    pub retry_strategy: Option<String>,
1149    /// Maximum retry attempts
1150    pub max_retries:    Option<u32>,
1151    /// Handler description
1152    pub description:    Option<String>,
1153}
1154
1155/// Caching configuration
1156#[derive(Debug, Clone, Deserialize, Serialize)]
1157#[serde(default, deny_unknown_fields)]
1158pub struct CachingConfig {
1159    /// Enable caching
1160    #[serde(default)]
1161    pub enabled:   bool,
1162    /// Cache backend (redis, memory, postgresql)
1163    pub backend:   String,
1164    /// Redis connection URL
1165    pub redis_url: Option<String>,
1166    /// Cache invalidation rules
1167    pub rules:     Vec<CacheRule>,
1168}
1169
1170impl Default for CachingConfig {
1171    fn default() -> Self {
1172        Self {
1173            enabled:   false,
1174            backend:   "redis".to_string(),
1175            redis_url: None,
1176            rules:     vec![],
1177        }
1178    }
1179}
1180
1181/// Cache invalidation rule
1182#[derive(Debug, Clone, Deserialize, Serialize)]
1183#[serde(deny_unknown_fields)]
1184pub struct CacheRule {
1185    /// Query pattern to cache
1186    pub query:                 String,
1187    /// Time-to-live in seconds
1188    pub ttl_seconds:           u32,
1189    /// Events that trigger cache invalidation
1190    pub invalidation_triggers: Vec<String>,
1191}
1192
1193/// Analytics configuration
1194#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1195#[serde(default, deny_unknown_fields)]
1196pub struct AnalyticsConfig {
1197    /// Enable analytics
1198    #[serde(default)]
1199    pub enabled: bool,
1200    /// Analytics queries
1201    pub queries: Vec<AnalyticsQuery>,
1202}
1203
1204/// Analytics query definition
1205#[derive(Debug, Clone, Deserialize, Serialize)]
1206#[serde(deny_unknown_fields)]
1207pub struct AnalyticsQuery {
1208    /// Query name
1209    pub name:        String,
1210    /// SQL source for the query
1211    pub sql_source:  String,
1212    /// Query description
1213    pub description: Option<String>,
1214}
1215
1216/// Observability configuration
1217#[derive(Debug, Clone, Deserialize, Serialize)]
1218#[serde(default, deny_unknown_fields)]
1219pub struct ObservabilityConfig {
1220    /// Enable Prometheus metrics
1221    pub prometheus_enabled:            bool,
1222    /// Port for Prometheus metrics endpoint
1223    pub prometheus_port:               u16,
1224    /// Enable OpenTelemetry tracing
1225    pub otel_enabled:                  bool,
1226    /// OpenTelemetry exporter type
1227    pub otel_exporter:                 String,
1228    /// Jaeger endpoint for trace collection
1229    pub otel_jaeger_endpoint:          Option<String>,
1230    /// Enable health check endpoint
1231    pub health_check_enabled:          bool,
1232    /// Health check interval in seconds
1233    pub health_check_interval_seconds: u32,
1234    /// Log level threshold
1235    pub log_level:                     String,
1236    /// Log output format (json, text)
1237    pub log_format:                    String,
1238}
1239
1240impl Default for ObservabilityConfig {
1241    fn default() -> Self {
1242        Self {
1243            prometheus_enabled:            false,
1244            prometheus_port:               9090,
1245            otel_enabled:                  false,
1246            otel_exporter:                 "jaeger".to_string(),
1247            otel_jaeger_endpoint:          None,
1248            health_check_enabled:          true,
1249            health_check_interval_seconds: 30,
1250            log_level:                     "info".to_string(),
1251            log_format:                    "json".to_string(),
1252        }
1253    }
1254}
1255
1256impl TomlSchema {
1257    /// Load schema from TOML file
1258    pub fn from_file(path: &str) -> Result<Self> {
1259        let content =
1260            std::fs::read_to_string(path).context(format!("Failed to read TOML file: {path}"))?;
1261        Self::parse_toml(&content)
1262    }
1263
1264    /// Parse schema from TOML string.
1265    ///
1266    /// Expands `${VAR}` environment variable placeholders before parsing.
1267    pub fn parse_toml(content: &str) -> Result<Self> {
1268        let expanded = expand_env_vars(content);
1269        toml::from_str(&expanded).context("Failed to parse TOML schema")
1270    }
1271
1272    /// Validate schema
1273    pub fn validate(&self) -> Result<()> {
1274        // Validate that all query return types exist
1275        for (query_name, query_def) in &self.queries {
1276            if !self.types.contains_key(&query_def.return_type) {
1277                anyhow::bail!(
1278                    "Query '{query_name}' references undefined type '{}'",
1279                    query_def.return_type
1280                );
1281            }
1282        }
1283
1284        // Validate that all mutation return types exist
1285        for (mut_name, mut_def) in &self.mutations {
1286            if !self.types.contains_key(&mut_def.return_type) {
1287                anyhow::bail!(
1288                    "Mutation '{mut_name}' references undefined type '{}'",
1289                    mut_def.return_type
1290                );
1291            }
1292        }
1293
1294        // Validate field auth rules reference existing policies
1295        for field_auth in &self.security.field_auth {
1296            let policy_exists = self.security.policies.iter().any(|p| p.name == field_auth.policy);
1297            if !policy_exists {
1298                anyhow::bail!("Field auth references undefined policy '{}'", field_auth.policy);
1299            }
1300        }
1301
1302        // Validate federation entities reference existing types
1303        for entity in &self.federation.entities {
1304            if !self.types.contains_key(&entity.name) {
1305                anyhow::bail!("Federation entity '{}' references undefined type", entity.name);
1306            }
1307        }
1308
1309        self.server.validate()?;
1310        self.database.validate()?;
1311
1312        // Validate federation circuit breaker configuration
1313        if let Some(cb) = &self.federation.circuit_breaker {
1314            if cb.failure_threshold == 0 {
1315                anyhow::bail!(
1316                    "federation.circuit_breaker.failure_threshold must be greater than 0"
1317                );
1318            }
1319            if cb.recovery_timeout_secs == 0 {
1320                anyhow::bail!(
1321                    "federation.circuit_breaker.recovery_timeout_secs must be greater than 0"
1322                );
1323            }
1324            if cb.success_threshold == 0 {
1325                anyhow::bail!(
1326                    "federation.circuit_breaker.success_threshold must be greater than 0"
1327                );
1328            }
1329
1330            // Validate per-database overrides reference defined entity names
1331            let entity_names: std::collections::HashSet<&str> =
1332                self.federation.entities.iter().map(|e| e.name.as_str()).collect();
1333            for override_cfg in &cb.per_database {
1334                if !entity_names.contains(override_cfg.database.as_str()) {
1335                    anyhow::bail!(
1336                        "federation.circuit_breaker.per_database entry '{}' does not match \
1337                         any defined federation entity",
1338                        override_cfg.database
1339                    );
1340                }
1341                if override_cfg.failure_threshold == Some(0) {
1342                    anyhow::bail!(
1343                        "federation.circuit_breaker.per_database['{}'].failure_threshold \
1344                         must be greater than 0",
1345                        override_cfg.database
1346                    );
1347                }
1348                if override_cfg.recovery_timeout_secs == Some(0) {
1349                    anyhow::bail!(
1350                        "federation.circuit_breaker.per_database['{}'].recovery_timeout_secs \
1351                         must be greater than 0",
1352                        override_cfg.database
1353                    );
1354                }
1355                if override_cfg.success_threshold == Some(0) {
1356                    anyhow::bail!(
1357                        "federation.circuit_breaker.per_database['{}'].success_threshold \
1358                         must be greater than 0",
1359                        override_cfg.database
1360                    );
1361                }
1362            }
1363        }
1364
1365        Ok(())
1366    }
1367
1368    /// Convert to intermediate schema format (compatible with language-generated types.json)
1369    pub fn to_intermediate_schema(&self) -> serde_json::Value {
1370        let mut types_json = serde_json::Map::new();
1371
1372        for (type_name, type_def) in &self.types {
1373            let mut fields_json = serde_json::Map::new();
1374
1375            for (field_name, field_def) in &type_def.fields {
1376                fields_json.insert(
1377                    field_name.clone(),
1378                    serde_json::json!({
1379                        "type": field_def.field_type,
1380                        "nullable": field_def.nullable,
1381                        "description": field_def.description,
1382                    }),
1383                );
1384            }
1385
1386            types_json.insert(
1387                type_name.clone(),
1388                serde_json::json!({
1389                    "name": type_name,
1390                    "sql_source": type_def.sql_source,
1391                    "description": type_def.description,
1392                    "fields": fields_json,
1393                }),
1394            );
1395        }
1396
1397        let mut queries_json = serde_json::Map::new();
1398
1399        for (query_name, query_def) in &self.queries {
1400            let args: Vec<serde_json::Value> = query_def
1401                .args
1402                .iter()
1403                .map(|arg| {
1404                    serde_json::json!({
1405                        "name": arg.name,
1406                        "type": arg.arg_type,
1407                        "required": arg.required,
1408                        "default": arg.default,
1409                        "description": arg.description,
1410                    })
1411                })
1412                .collect();
1413
1414            queries_json.insert(
1415                query_name.clone(),
1416                serde_json::json!({
1417                    "name": query_name,
1418                    "return_type": query_def.return_type,
1419                    "return_array": query_def.return_array,
1420                    "sql_source": query_def.sql_source,
1421                    "description": query_def.description,
1422                    "args": args,
1423                }),
1424            );
1425        }
1426
1427        serde_json::json!({
1428            "types": types_json,
1429            "queries": queries_json,
1430        })
1431    }
1432}
1433
1434/// WebSocket subscription configuration.
1435///
1436/// ```toml
1437/// [subscriptions]
1438/// max_subscriptions_per_connection = 50
1439///
1440/// [subscriptions.hooks]
1441/// on_connect = "http://localhost:8001/hooks/ws-connect"
1442/// on_disconnect = "http://localhost:8001/hooks/ws-disconnect"
1443/// on_subscribe = "http://localhost:8001/hooks/ws-subscribe"
1444/// timeout_ms = 500
1445/// ```
1446#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1447#[serde(default, deny_unknown_fields)]
1448pub struct SubscriptionsConfig {
1449    /// Maximum subscriptions per WebSocket connection.
1450    /// `None` (or omitted) means unlimited.
1451    #[serde(default, skip_serializing_if = "Option::is_none")]
1452    pub max_subscriptions_per_connection: Option<u32>,
1453
1454    /// Webhook lifecycle hooks.
1455    #[serde(default, skip_serializing_if = "Option::is_none")]
1456    pub hooks: Option<SubscriptionHooksConfig>,
1457}
1458
1459/// Webhook URLs invoked during subscription lifecycle events.
1460#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1461#[serde(default, deny_unknown_fields)]
1462pub struct SubscriptionHooksConfig {
1463    /// URL to POST on WebSocket `connection_init` (fail-closed).
1464    #[serde(default, skip_serializing_if = "Option::is_none")]
1465    pub on_connect: Option<String>,
1466
1467    /// URL to POST on WebSocket disconnect (fire-and-forget).
1468    #[serde(default, skip_serializing_if = "Option::is_none")]
1469    pub on_disconnect: Option<String>,
1470
1471    /// URL to POST before a subscription is registered (fail-closed).
1472    #[serde(default, skip_serializing_if = "Option::is_none")]
1473    pub on_subscribe: Option<String>,
1474
1475    /// Timeout in milliseconds for fail-closed hooks (default: 500).
1476    #[serde(default = "default_hook_timeout_ms")]
1477    pub timeout_ms: u64,
1478}
1479
1480fn default_hook_timeout_ms() -> u64 {
1481    500
1482}
1483
1484/// Query validation limits (depth and complexity).
1485///
1486/// ```toml
1487/// [validation]
1488/// max_query_depth = 10
1489/// max_query_complexity = 100
1490/// ```
1491#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1492#[serde(default, deny_unknown_fields)]
1493pub struct ValidationConfig {
1494    /// Maximum allowed query nesting depth. `None` uses the server default (10).
1495    #[serde(default, skip_serializing_if = "Option::is_none")]
1496    pub max_query_depth: Option<u32>,
1497
1498    /// Maximum allowed query complexity score. `None` uses the server default (100).
1499    #[serde(default, skip_serializing_if = "Option::is_none")]
1500    pub max_query_complexity: Option<u32>,
1501}
1502
1503/// Debug/development configuration.
1504///
1505/// Controls features that should only be enabled during development or
1506/// in trusted environments. All flags default to off.
1507///
1508/// ```toml
1509/// [debug]
1510/// enabled = true
1511/// database_explain = true
1512/// expose_sql = true
1513/// ```
1514#[derive(Debug, Clone, Deserialize, Serialize)]
1515#[serde(default, deny_unknown_fields)]
1516pub struct DebugConfig {
1517    /// Master switch — all debug features require this to be `true`.
1518    pub enabled: bool,
1519
1520    /// When `true`, the explain endpoint will also run `EXPLAIN` against the
1521    /// database and include the query plan in the response.
1522    pub database_explain: bool,
1523
1524    /// When `true`, the explain endpoint includes the generated SQL in the
1525    /// response. Defaults to `true` (SQL is shown even without
1526    /// `database_explain`).
1527    pub expose_sql: bool,
1528}
1529
1530impl Default for DebugConfig {
1531    fn default() -> Self {
1532        Self {
1533            enabled:          false,
1534            database_explain: false,
1535            expose_sql:       true,
1536        }
1537    }
1538}
1539
1540#[cfg(test)]
1541mod tests {
1542    use super::*;
1543
1544    #[test]
1545    fn test_parse_toml_schema() {
1546        let toml = r#"
1547[schema]
1548name = "myapp"
1549version = "1.0.0"
1550database_target = "postgresql"
1551
1552[types.User]
1553sql_source = "v_user"
1554
1555[types.User.fields.id]
1556type = "ID"
1557nullable = false
1558
1559[types.User.fields.name]
1560type = "String"
1561nullable = false
1562
1563[queries.users]
1564return_type = "User"
1565return_array = true
1566sql_source = "v_user"
1567"#;
1568        let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1569        assert_eq!(schema.schema.name, "myapp");
1570        assert!(schema.types.contains_key("User"));
1571    }
1572
1573    #[test]
1574    fn test_validate_schema() {
1575        let schema = TomlSchema::default();
1576        assert!(schema.validate().is_ok());
1577    }
1578
1579    // --- Issue #38: nats_url ---
1580
1581    #[test]
1582    fn test_observers_config_nats_url_round_trip() {
1583        let toml = r#"
1584[schema]
1585name = "myapp"
1586version = "1.0.0"
1587database_target = "postgresql"
1588
1589[observers]
1590enabled = true
1591backend = "nats"
1592nats_url = "nats://localhost:4222"
1593"#;
1594        let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1595        assert_eq!(schema.observers.backend, "nats");
1596        assert_eq!(
1597            schema.observers.nats_url.as_deref(),
1598            Some("nats://localhost:4222")
1599        );
1600        assert!(schema.observers.redis_url.is_none());
1601    }
1602
1603    #[test]
1604    fn test_observers_config_redis_url_unchanged() {
1605        let toml = r#"
1606[schema]
1607name = "myapp"
1608version = "1.0.0"
1609database_target = "postgresql"
1610
1611[observers]
1612enabled = true
1613backend = "redis"
1614redis_url = "redis://localhost:6379"
1615"#;
1616        let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1617        assert_eq!(schema.observers.backend, "redis");
1618        assert_eq!(
1619            schema.observers.redis_url.as_deref(),
1620            Some("redis://localhost:6379")
1621        );
1622        assert!(schema.observers.nats_url.is_none());
1623    }
1624
1625    #[test]
1626    fn test_observers_config_nats_url_default_is_none() {
1627        let config = ObserversConfig::default();
1628        assert!(config.nats_url.is_none());
1629    }
1630
1631    // --- Issue #39: federation circuit breaker ---
1632
1633    #[test]
1634    fn test_federation_circuit_breaker_round_trip() {
1635        let toml = r#"
1636[schema]
1637name = "myapp"
1638version = "1.0.0"
1639database_target = "postgresql"
1640
1641[types.Product]
1642sql_source = "v_product"
1643
1644[federation]
1645enabled = true
1646apollo_version = 2
1647
1648[[federation.entities]]
1649name = "Product"
1650key_fields = ["id"]
1651
1652[federation.circuit_breaker]
1653enabled = true
1654failure_threshold = 3
1655recovery_timeout_secs = 60
1656success_threshold = 1
1657"#;
1658        let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1659        let cb = schema.federation.circuit_breaker.as_ref().expect("Expected circuit_breaker");
1660        assert!(cb.enabled);
1661        assert_eq!(cb.failure_threshold, 3);
1662        assert_eq!(cb.recovery_timeout_secs, 60);
1663        assert_eq!(cb.success_threshold, 1);
1664        assert!(cb.per_database.is_empty());
1665    }
1666
1667    #[test]
1668    fn test_federation_circuit_breaker_zero_failure_threshold_rejected() {
1669        let toml = r#"
1670[schema]
1671name = "myapp"
1672version = "1.0.0"
1673database_target = "postgresql"
1674
1675[federation]
1676enabled = true
1677
1678[federation.circuit_breaker]
1679enabled = true
1680failure_threshold = 0
1681recovery_timeout_secs = 30
1682success_threshold = 2
1683"#;
1684        let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1685        let err = schema.validate().unwrap_err();
1686        assert!(err.to_string().contains("failure_threshold"), "{err}");
1687    }
1688
1689    #[test]
1690    fn test_federation_circuit_breaker_zero_recovery_timeout_rejected() {
1691        let toml = r#"
1692[schema]
1693name = "myapp"
1694version = "1.0.0"
1695database_target = "postgresql"
1696
1697[federation]
1698enabled = true
1699
1700[federation.circuit_breaker]
1701enabled = true
1702failure_threshold = 5
1703recovery_timeout_secs = 0
1704success_threshold = 2
1705"#;
1706        let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1707        let err = schema.validate().unwrap_err();
1708        assert!(err.to_string().contains("recovery_timeout_secs"), "{err}");
1709    }
1710
1711    #[test]
1712    fn test_federation_circuit_breaker_per_database_unknown_entity_rejected() {
1713        let toml = r#"
1714[schema]
1715name = "myapp"
1716version = "1.0.0"
1717database_target = "postgresql"
1718
1719[types.Product]
1720sql_source = "v_product"
1721
1722[federation]
1723enabled = true
1724
1725[[federation.entities]]
1726name = "Product"
1727key_fields = ["id"]
1728
1729[federation.circuit_breaker]
1730enabled = true
1731failure_threshold = 5
1732recovery_timeout_secs = 30
1733success_threshold = 2
1734
1735[[federation.circuit_breaker.per_database]]
1736database = "NonExistentEntity"
1737failure_threshold = 3
1738"#;
1739        let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1740        let err = schema.validate().unwrap_err();
1741        assert!(err.to_string().contains("NonExistentEntity"), "{err}");
1742    }
1743
1744    #[test]
1745    fn test_federation_circuit_breaker_per_database_valid() {
1746        let toml = r#"
1747[schema]
1748name = "myapp"
1749version = "1.0.0"
1750database_target = "postgresql"
1751
1752[types.Product]
1753sql_source = "v_product"
1754
1755[federation]
1756enabled = true
1757
1758[[federation.entities]]
1759name = "Product"
1760key_fields = ["id"]
1761
1762[federation.circuit_breaker]
1763enabled = true
1764failure_threshold = 5
1765recovery_timeout_secs = 30
1766success_threshold = 2
1767
1768[[federation.circuit_breaker.per_database]]
1769database = "Product"
1770failure_threshold = 3
1771recovery_timeout_secs = 15
1772"#;
1773        let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1774        assert!(schema.validate().is_ok());
1775        let cb = schema.federation.circuit_breaker.as_ref().unwrap();
1776        assert_eq!(cb.per_database.len(), 1);
1777        assert_eq!(cb.per_database[0].database, "Product");
1778        assert_eq!(cb.per_database[0].failure_threshold, Some(3));
1779        assert_eq!(cb.per_database[0].recovery_timeout_secs, Some(15));
1780    }
1781
1782    #[test]
1783    fn test_toml_schema_parses_server_section() {
1784        let toml = r#"
1785[schema]
1786name = "myapp"
1787version = "1.0.0"
1788database_target = "postgresql"
1789
1790[server]
1791host = "127.0.0.1"
1792port = 9999
1793
1794[server.cors]
1795origins = ["https://example.com"]
1796"#;
1797        let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1798        assert_eq!(schema.server.host, "127.0.0.1");
1799        assert_eq!(schema.server.port, 9999);
1800        assert_eq!(schema.server.cors.origins, ["https://example.com"]);
1801    }
1802
1803    #[test]
1804    fn test_toml_schema_database_uses_runtime_config() {
1805        let toml = r#"
1806[schema]
1807name = "myapp"
1808version = "1.0.0"
1809database_target = "postgresql"
1810
1811[database]
1812url      = "postgresql://localhost/mydb"
1813pool_min = 5
1814pool_max = 30
1815ssl_mode = "require"
1816"#;
1817        let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1818        assert_eq!(schema.database.url, Some("postgresql://localhost/mydb".to_string()));
1819        assert_eq!(schema.database.pool_min, 5);
1820        assert_eq!(schema.database.pool_max, 30);
1821        assert_eq!(schema.database.ssl_mode, "require");
1822    }
1823
1824    #[test]
1825    fn test_env_var_expansion_in_toml_schema() {
1826        temp_env::with_var("SCHEMA_TEST_DB_URL", Some("postgres://test/fraiseql"), || {
1827            let toml = r#"
1828[schema]
1829name = "myapp"
1830version = "1.0.0"
1831database_target = "postgresql"
1832
1833[database]
1834url = "${SCHEMA_TEST_DB_URL}"
1835"#;
1836            let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1837            assert_eq!(
1838                schema.database.url,
1839                Some("postgres://test/fraiseql".to_string())
1840            );
1841        });
1842    }
1843
1844    #[test]
1845    fn test_toml_schema_defaults_without_server_section() {
1846        let toml = r#"
1847[schema]
1848name = "myapp"
1849version = "1.0.0"
1850database_target = "postgresql"
1851"#;
1852        let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1853        // Defaults should apply
1854        assert_eq!(schema.server.host, "0.0.0.0");
1855        assert_eq!(schema.server.port, 8080);
1856        assert_eq!(schema.database.pool_min, 2);
1857        assert_eq!(schema.database.pool_max, 20);
1858        assert!(schema.database.url.is_none());
1859    }
1860
1861    #[test]
1862    fn test_rate_limiting_config_parses_per_user_rps() {
1863        let toml = r"
1864[security.rate_limiting]
1865enabled = true
1866requests_per_second = 100
1867requests_per_second_per_user = 250
1868";
1869        let schema: TomlSchema = toml::from_str(toml).unwrap();
1870        let rl = schema.security.rate_limiting.unwrap();
1871        assert_eq!(rl.requests_per_second_per_user, Some(250));
1872    }
1873
1874    #[test]
1875    fn test_rate_limiting_config_per_user_rps_defaults_to_none() {
1876        let toml = r"
1877[security.rate_limiting]
1878enabled = true
1879requests_per_second = 50
1880";
1881        let schema: TomlSchema = toml::from_str(toml).unwrap();
1882        let rl = schema.security.rate_limiting.unwrap();
1883        assert_eq!(rl.requests_per_second_per_user, None);
1884    }
1885
1886    #[test]
1887    fn test_validation_config_parses_limits() {
1888        let toml = r"
1889[validation]
1890max_query_depth = 5
1891max_query_complexity = 50
1892";
1893        let schema: TomlSchema = toml::from_str(toml).unwrap();
1894        assert_eq!(schema.validation.max_query_depth, Some(5));
1895        assert_eq!(schema.validation.max_query_complexity, Some(50));
1896    }
1897
1898    #[test]
1899    fn test_validation_config_defaults_to_none() {
1900        let toml = "";
1901        let schema: TomlSchema = toml::from_str(toml).unwrap();
1902        assert_eq!(schema.validation.max_query_depth, None);
1903        assert_eq!(schema.validation.max_query_complexity, None);
1904    }
1905
1906    #[test]
1907    fn test_validation_config_partial() {
1908        let toml = r"
1909[validation]
1910max_query_depth = 3
1911";
1912        let schema: TomlSchema = toml::from_str(toml).unwrap();
1913        assert_eq!(schema.validation.max_query_depth, Some(3));
1914        assert_eq!(schema.validation.max_query_complexity, None);
1915    }
1916}