Skip to main content

reddb_server/
server.rs

1//! Minimal HTTP server for RedDB management and remote access.
2
3pub(crate) use crate::application::json_input::{
4    json_bool_field, json_f32_field, json_string_field, json_usize_field,
5};
6pub(crate) use crate::application::{
7    AdminUseCases, CreateDocumentInput, CreateEdgeInput, CreateEntityOutput, CreateKvInput,
8    CreateNodeEmbeddingInput, CreateNodeGraphLinkInput, CreateNodeInput, CreateNodeTableLinkInput,
9    CreateRowInput, CreateVectorInput, DeleteEntityInput, EntityUseCases, ExecuteQueryInput,
10    ExplainQueryInput, GraphCentralityInput, GraphClusteringInput, GraphCommunitiesInput,
11    GraphComponentsInput, GraphCyclesInput, GraphHitsInput, GraphNeighborhoodInput,
12    GraphPersonalizedPageRankInput, GraphShortestPathInput, GraphTopologicalSortInput,
13    GraphTraversalInput, GraphUseCases, InspectNativeArtifactInput, NativeUseCases,
14    PatchEntityInput, PatchEntityOperation, PatchEntityOperationType, SearchHybridInput,
15    SearchIvfInput, SearchMultimodalInput, SearchSimilarInput, SearchTextInput, TreeUseCases,
16};
17use std::collections::{BTreeMap, HashMap};
18use std::io::{self, Read, Write};
19use std::net::{TcpListener, TcpStream};
20use std::sync::atomic::{AtomicU64, Ordering};
21use std::thread;
22use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
23
24use std::sync::Arc;
25
26use crate::api::{RedDBError, RedDBOptions, RedDBResult};
27use crate::auth::store::AuthStore;
28use crate::catalog::{CatalogModelSnapshot, CollectionDescriptor, CollectionModel, SchemaMode};
29use crate::health::{HealthProvider, HealthReport, HealthState};
30use crate::json::{parse_json, to_vec as json_to_vec, Map, Value as JsonValue};
31use crate::runtime::{
32    RedDBRuntime, RuntimeFilter, RuntimeFilterValue, RuntimeGraphCentralityAlgorithm,
33    RuntimeGraphCentralityResult, RuntimeGraphClusteringResult, RuntimeGraphCommunityAlgorithm,
34    RuntimeGraphCommunityResult, RuntimeGraphComponentsMode, RuntimeGraphComponentsResult,
35    RuntimeGraphCyclesResult, RuntimeGraphDirection, RuntimeGraphHitsResult,
36    RuntimeGraphNeighborhoodResult, RuntimeGraphPathAlgorithm, RuntimeGraphPathResult,
37    RuntimeGraphPattern, RuntimeGraphProjection, RuntimeGraphTopologicalSortResult,
38    RuntimeGraphTraversalResult, RuntimeGraphTraversalStrategy, RuntimeIvfSearchResult,
39    RuntimeQueryWeights, RuntimeStats, ScanCursor, ScanPage,
40};
41use crate::storage::unified::devx::refs::{NodeRef, TableRef, VectorRef};
42use crate::storage::unified::dsl::{MatchComponents, QueryResult as DslQueryResult};
43use crate::storage::unified::{MetadataValue, RefTarget, SparseVector};
44use crate::storage::{CrossRef, EntityData, EntityId, EntityKind, SimilarResult, UnifiedEntity};
45use reddb_types::Value;
46
47fn analytics_job_json(job: &crate::PhysicalAnalyticsJob) -> JsonValue {
48    crate::presentation::admin_json::analytics_job_json(job)
49}
50
51// Process-wide test guard for every RED_ADMIN_TOKEN mutation in server tests.
52#[cfg(test)]
53pub(crate) static RED_ADMIN_TOKEN_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58    use crate::api::RedDBOptions;
59    use crate::health::HealthReport;
60    use crate::transport::{TransportListenerFailure, TransportListenerState, TransportReadiness};
61
62    #[test]
63    fn server_options_default_http_body_limit_is_32_mib() {
64        assert_eq!(ServerOptions::default().max_body_bytes, 32 * 1024 * 1024);
65    }
66
67    #[test]
68    fn health_json_reports_transport_listeners() {
69        let runtime = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
70        let mut options = ServerOptions::default();
71        options.transport_readiness = TransportReadiness {
72            active: vec![TransportListenerState {
73                transport: "grpc".to_string(),
74                bind_addr: "127.0.0.1:5000".to_string(),
75                explicit: true,
76            }],
77            failed: vec![TransportListenerFailure {
78                transport: "http".to_string(),
79                bind_addr: "127.0.0.1:5000".to_string(),
80                explicit: false,
81                reason: "http listener bind 127.0.0.1:5000: address in use".to_string(),
82            }],
83        };
84        let server = RedDBServer::with_options(runtime, options);
85
86        let payload = server.health_json_with_transport(&HealthReport::healthy());
87        let JsonValue::Object(root) = payload else {
88            panic!("health payload should be an object");
89        };
90        let Some(JsonValue::Object(listeners)) = root.get("transport_listeners") else {
91            panic!("health payload should include transport_listeners");
92        };
93        let Some(JsonValue::Array(active)) = listeners.get("active") else {
94            panic!("transport_listeners.active should be an array");
95        };
96        let Some(JsonValue::Array(failed)) = listeners.get("failed") else {
97            panic!("transport_listeners.failed should be an array");
98        };
99
100        assert_eq!(active.len(), 1);
101        assert_eq!(failed.len(), 1);
102    }
103}
104
105fn graph_projection_json(projection: &crate::PhysicalGraphProjection) -> JsonValue {
106    crate::presentation::admin_json::graph_projection_json(projection)
107}
108
109mod axum_edge;
110pub mod handlers_admin;
111mod handlers_admin_metrics;
112mod handlers_admin_status;
113mod handlers_ai;
114mod handlers_ai_model_cache;
115pub(crate) mod handlers_auth;
116mod handlers_backup;
117mod handlers_browser_auth;
118mod handlers_cache;
119mod handlers_capabilities;
120mod handlers_collection_policy;
121mod handlers_ec;
122pub(crate) mod handlers_entity;
123mod handlers_failover;
124mod handlers_geo;
125mod handlers_graph;
126mod handlers_iam_policy;
127mod handlers_keyed;
128mod handlers_log;
129mod handlers_metrics;
130mod handlers_ops;
131mod handlers_ops_policy;
132/// Local `red ui` bridge: serves the UI bundle and fronts the embedded
133/// engine over RedWire-over-WebSocket for `file://` targets (issue #1042,
134/// ADR 0047/0049). Public so the `red ui` CLI command can drive it.
135pub mod ui_bridge;
136mod ws_edge;
137// `red ui` bundle resolver (issue #1043, ADR 0050): pin→URL resolution,
138// HTTPS download, SHA-256 verification, tgz extraction, and local cache
139// management for the pinned red-ui release asset.
140pub mod ui_bundle_resolver;
141// `red ui --token` credential handoff (issue #1048, ADR 0051): one-time
142// loopback nonce channel and served-UI auth-mode wiring.
143pub mod ui_auth;
144// `red server --ui` static bundle surface (issue #1047, ADR 0051): serve
145// the resolved red-ui bundle as inert static assets on the server's HTTP
146// edge so a remote browser can load it and connect back over RedWire-over-WS.
147mod ui_static;
148// `red ui <uri>` deep-link dispatch (issue #1046, ADR 0051): prefer the
149// installed desktop app via the `redui://` scheme, fall back to the served
150// browser bridge when no handler is registered. Decision + canonical
151// deep-link string live behind a testable seam.
152pub mod ui_deeplink;
153// `pub(crate)` so the RedWire input-stream path (issue #764 / S5)
154// can reuse the canonical S4 INSERT builders / identifier checks
155// (`build_insert_sql`, `is_safe_sql_identifier`) rather than fork
156// the SQL-escaping logic.
157pub(crate) mod handlers_query;
158mod handlers_replication;
159mod handlers_topology;
160mod handlers_vector;
161pub mod header_escape_guard;
162pub mod http_connection_limiter;
163pub mod http_handler_metrics;
164pub mod http_limits;
165pub mod http_principal_limiter;
166pub mod http_request_metrics;
167pub mod ingest_pipeline;
168pub mod output_stream;
169mod patch_support;
170mod request_body;
171mod request_context;
172mod request_identity;
173pub(crate) mod route_catalog;
174mod routes;
175pub(crate) use routes::discovered_route_catalog;
176mod routing;
177mod serverless_support;
178pub mod tls;
179mod transport;
180mod transport_surface;
181
182/// Generate the command-coverage artifact: every discovered command joined
183/// against each transport's own binding table. See
184/// [`route_catalog::render_command_coverage_matrix`].
185#[doc(hidden)]
186pub fn command_coverage_matrix() -> String {
187    route_catalog::render_command_coverage_matrix(routes::discovered_route_catalog())
188}
189
190use self::handlers_ai::*;
191use self::handlers_entity::*;
192use self::handlers_graph::*;
193use self::handlers_keyed::*;
194use self::handlers_metrics::*;
195use self::handlers_ops::*;
196use self::handlers_query::*;
197use self::http_connection_limiter::{
198    HandlerDeadline, HttpConnectionLimiter, MonotonicClock, SystemMonotonicClock,
199};
200use self::http_handler_metrics::{HttpHandlerMetrics, HttpRejectReason, HttpTransport};
201pub use self::http_limits::{
202    HttpLimitsCliInput, HttpLimitsResolved, DEFAULT_HANDLER_TIMEOUT_MS, DEFAULT_RETRY_AFTER_SECS,
203};
204use self::http_principal_limiter::PrincipalConnectionLimiter;
205use self::http_request_metrics::HttpRequestMetrics;
206use self::patch_support::*;
207use self::request_body::*;
208use self::routing::*;
209use self::serverless_support::*;
210use self::transport::*;
211
212pub(crate) fn command_catalog() -> &'static route_catalog::CommandCatalog {
213    routes::discovered_route_catalog()
214}
215
216/// PLAN.md Phase 6.2 — endpoint segregation. A given HTTP listener
217/// can serve either every public surface (`Public`, default) or a
218/// restricted slice (`AdminOnly`, `MetricsOnly`). The route filter at
219/// the top of `route()` consults this so a port bound only to
220/// loopback for admin work won't accidentally hand out DML.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum ServerSurface {
223    /// Everything routed normally (default — matches v0 behaviour).
224    Public,
225    /// Only `/admin/*`, `/metrics`, and `/health/*`. Other paths
226    /// return 404. Intended for `RED_ADMIN_BIND` operator listeners
227    /// which default to `127.0.0.1`.
228    AdminOnly,
229    /// Only `/metrics` and `/health/*`. Intended for
230    /// `RED_METRICS_BIND` Prometheus scrape ports that may be
231    /// exposed to non-admin networks.
232    MetricsOnly,
233}
234
235#[derive(Debug, Clone)]
236pub struct ServerOptions {
237    pub bind_addr: String,
238    pub max_body_bytes: usize,
239    pub read_timeout_ms: u64,
240    pub write_timeout_ms: u64,
241    pub max_scan_limit: usize,
242    /// Which subset of paths this listener serves. Defaults to
243    /// `Public`. Set to `AdminOnly` / `MetricsOnly` for dedicated
244    /// admin / scrape ports (PLAN.md Phase 6.2).
245    pub surface: ServerSurface,
246    pub transport_readiness: crate::transport::TransportReadiness,
247    /// Allowed `Origin` values for the RedWire-over-WSS browser endpoint
248    /// (issue #935, ADR 0036). WebSocket is not covered by CORS, so the
249    /// upgrade is gated on an explicit allowlist to block Cross-Site
250    /// WebSocket Hijacking. **Default-deny:** empty means the `/redwire`
251    /// route is not mounted at all — operators opt in by configuring at
252    /// least one origin. Matched exactly (scheme+host+port), e.g.
253    /// `https://app.example.com`.
254    pub websocket_allowed_origins: Vec<String>,
255    /// `red server --ui` (issue #1047, ADR 0051). When `Some`, this
256    /// listener also serves the resolved red-ui bundle from this directory
257    /// as inert static assets (`/` → `index.html`), alongside the existing
258    /// API. The assets carry no credential, so exposing them is safe by
259    /// construction — auth lives on the RedWire data endpoint, not the
260    /// asset path. Network reach is governed entirely by `bind_addr`
261    /// (default localhost), so this never widens reach on its own.
262    /// `None` (the default) leaves the listener API-only.
263    pub ui_dir: Option<std::path::PathBuf>,
264}
265
266pub const DEFAULT_HTTP_MAX_BODY_BYTES: usize = 32 * 1024 * 1024;
267
268impl Default for ServerOptions {
269    fn default() -> Self {
270        Self {
271            bind_addr: "127.0.0.1:5000".to_string(),
272            max_body_bytes: DEFAULT_HTTP_MAX_BODY_BYTES,
273            read_timeout_ms: 5_000,
274            write_timeout_ms: 5_000,
275            max_scan_limit: 1_000,
276            surface: ServerSurface::Public,
277            transport_readiness: crate::transport::TransportReadiness::default(),
278            websocket_allowed_origins: Vec::new(),
279            ui_dir: None,
280        }
281    }
282}
283
284/// Replication state exposed to the HTTP server.
285pub struct ServerReplicationState {
286    pub config: crate::replication::ReplicationConfig,
287    pub primary: Option<crate::replication::primary::PrimaryReplication>,
288}
289
290#[derive(Clone)]
291pub struct RedDBServer {
292    runtime: RedDBRuntime,
293    options: ServerOptions,
294    auth_store: Option<Arc<AuthStore>>,
295    replication: Option<Arc<ServerReplicationState>>,
296    /// Bounded handler-thread admission for the clear-text HTTP accept
297    /// loop (issue #570 slice 1). Cloned with the server; `Clone` of
298    /// `HttpConnectionLimiter` shares an `Arc` so every serve loop on
299    /// the same `RedDBServer` shares one cap.
300    http_limiter: HttpConnectionLimiter,
301    /// Small, independently bounded admission reserve for Kubernetes health
302    /// probes. A saturated query pool must not make a healthy process fail
303    /// readiness and disappear from its EndpointSlice. Only the four exact
304    /// GET health routes use this limiter; all other work remains subject to
305    /// `http_limiter` and the per-principal cap.
306    health_limiter: HttpConnectionLimiter,
307    /// Per-handler total-time deadline (issue #570 slice 2). Each
308    /// clear-text handler thread arms a deadline at spawn and bails
309    /// with a best-effort 503 at coarse boundaries between request
310    /// parse, route dispatch, and response write. Hard-coded to 30s
311    /// here; the config knob lands in slice 5.
312    handler_timeout: Duration,
313    /// Monotonic clock the per-handler deadline (issue #621) is armed
314    /// against — the same [`MonotonicClock`] abstraction the limiter
315    /// uses. Production wires [`SystemMonotonicClock`] (real wall time);
316    /// tests inject a fake to drive timeout expiry deterministically
317    /// without `sleep()`. Shared via `Arc` so cloned server handles
318    /// (e.g. `serve_in_background`) read the same clock.
319    handler_clock: Arc<dyn MonotonicClock>,
320    /// Test-only synchronous sleep injected between route dispatch and
321    /// response write so an integration test can simulate a slow
322    /// downstream tripping the deadline. Default 0 (no-op). Shared via
323    /// `Arc` so a cloned `RedDBServer` (e.g. `serve_in_background`)
324    /// observes flips from the originating handle. Production callers
325    /// have no way to set this — the setter is `#[doc(hidden)]`.
326    slow_inject_ms: Arc<AtomicU64>,
327    /// Prometheus metrics for the HTTP handler-thread pool (issue
328    /// #573 slice 4). Records rejections (cap_exhausted /
329    /// handler_timeout) and per-handler duration histograms. Cloned
330    /// with the server via `Arc` so every serve loop on the same
331    /// `RedDBServer` writes to one set of counters.
332    http_metrics: HttpHandlerMetrics,
333    /// Issue #1239 / PRD #1237 — `reddb_http_requests_total` counters by
334    /// `method`, matched route template, and status class. Recorded once
335    /// per handled request in `route()`; read by `/metrics` and the
336    /// operational telemetry read model. Cloned with the server via `Arc`
337    /// so every serve loop on the same `RedDBServer` shares one counter set.
338    http_request_metrics: HttpRequestMetrics,
339    /// `Retry-After` value (seconds) emitted in the async edge's
340    /// capacity-reject 503 path (issue #574 slice 5). Read on the reject
341    /// path in `axum_edge`.
342    retry_after_secs: u64,
343    /// Issue #934 / PRD #930 — per-principal concurrent in-flight cap. The
344    /// global `http_limiter` bounds *total* in-flight work (async
345    /// backpressure); this bounds any *single* principal's share so one
346    /// abusive caller can't drain the whole global cap and starve the rest.
347    /// Consulted at the async edge after global admission; a principal over
348    /// its cap gets a structured 429 refusal. Shared via `Arc` (inside the
349    /// limiter) so cloned server handles enforce one set of counters.
350    principal_limiter: PrincipalConnectionLimiter,
351    /// Issue #761 / S2 — process-wide output-stream capacity registry.
352    /// Shared via `Arc` so cloned server handles (e.g.
353    /// `serve_in_background`) all enforce against one set of counters.
354    /// The HTTP NDJSON path acquires through this in
355    /// `try_route_streaming` before invoking the handler; the guard is
356    /// dropped on return so any stream-end path (success / mid-stream
357    /// error / snapshot expiry / panic unwind) releases the slot.
358    pub(crate) stream_capacity: Arc<output_stream::StreamCapacityRegistry>,
359    /// Issue #766 / S7 — resume coordinator ledger. Tracks
360    /// `(snapshot_lsn → opened_at_ms, ttl_ms)` for resume-eligibility
361    /// checks. Shared via `Arc` so cloned server handles see one
362    /// ledger.
363    pub(crate) lease_registry: Arc<output_stream::LeaseRegistry>,
364    /// Issue #807 / PRD #750 — `/query/stream` cursor registry. Holds the
365    /// opaque token → (snapshot pin, TTL, tenant, principal, query) entries
366    /// that let a client resume or reference a streamed read. Shared via
367    /// `Arc` so cloned server handles see one registry.
368    pub(crate) cursor_registry: Arc<output_stream::CursorRegistry>,
369}
370
371/// Default per-handler total-time budget (issue #571 slice 2).
372const DEFAULT_HANDLER_TIMEOUT: Duration = Duration::from_millis(30_000);
373
374/// Health probes are cheap and infrequent, but the reserve is still bounded
375/// so probe traffic cannot create an unlimited bypass around admission.
376const HEALTH_HANDLER_CAP: usize = 4;
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379enum ServerlessWarmupScope {
380    Indexes,
381    GraphProjections,
382    AnalyticsJobs,
383    NativeArtifacts,
384}
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387enum DeploymentProfile {
388    Embedded,
389    Server,
390    Serverless,
391}
392
393fn percent_decode_path_segment(input: &str) -> Result<String, String> {
394    let bytes = input.as_bytes();
395    let mut out = Vec::with_capacity(bytes.len());
396    let mut index = 0;
397    while index < bytes.len() {
398        match bytes[index] {
399            b'%' => {
400                if index + 2 >= bytes.len() {
401                    return Err("truncated percent escape".to_string());
402                }
403                let high = hex_value(bytes[index + 1])
404                    .ok_or_else(|| "invalid percent escape".to_string())?;
405                let low = hex_value(bytes[index + 2])
406                    .ok_or_else(|| "invalid percent escape".to_string())?;
407                out.push((high << 4) | low);
408                index += 3;
409            }
410            byte => {
411                out.push(byte);
412                index += 1;
413            }
414        }
415    }
416    String::from_utf8(out).map_err(|_| "path segment is not valid UTF-8".to_string())
417}
418
419fn hex_value(byte: u8) -> Option<u8> {
420    match byte {
421        b'0'..=b'9' => Some(byte - b'0'),
422        b'a'..=b'f' => Some(byte - b'a' + 10),
423        b'A'..=b'F' => Some(byte - b'A' + 10),
424        _ => None,
425    }
426}
427
428#[derive(Debug, Clone)]
429struct ParsedQueryRequest {
430    query: String,
431    entity_types: Option<Vec<String>>,
432    capabilities: Option<Vec<String>>,
433    commit_policy: Option<crate::replication::CommitPolicy>,
434    /// Optional positional `$N` bind parameters (#358), decoded into the
435    /// Request module's transport-neutral vocabulary.
436    params: Option<Vec<crate::runtime::query_request::ParamValue>>,
437}
438
439#[derive(Debug, Clone, Copy)]
440enum PatchOperationType {
441    Set,
442    Replace,
443    Unset,
444}
445
446#[derive(Debug, Clone)]
447struct PatchOperation {
448    op: PatchOperationType,
449    path: Vec<String>,
450    value: Option<JsonValue>,
451}
452
453impl RedDBServer {
454    pub fn new(runtime: RedDBRuntime) -> Self {
455        Self::with_options(runtime, ServerOptions::default())
456    }
457
458    pub fn from_database_options(
459        db_options: RedDBOptions,
460        server_options: ServerOptions,
461    ) -> RedDBResult<Self> {
462        let runtime = RedDBRuntime::with_options(db_options)?;
463        Ok(Self::with_options(runtime, server_options))
464    }
465
466    pub fn with_options(runtime: RedDBRuntime, options: ServerOptions) -> Self {
467        Self {
468            runtime,
469            options,
470            auth_store: None,
471            replication: None,
472            http_limiter: HttpConnectionLimiter::with_default_cap(),
473            health_limiter: HttpConnectionLimiter::new(HEALTH_HANDLER_CAP),
474            handler_timeout: DEFAULT_HANDLER_TIMEOUT,
475            handler_clock: Arc::new(SystemMonotonicClock::new()),
476            slow_inject_ms: Arc::new(AtomicU64::new(0)),
477            http_metrics: HttpHandlerMetrics::new(),
478            http_request_metrics: HttpRequestMetrics::new(),
479            retry_after_secs: DEFAULT_RETRY_AFTER_SECS,
480            principal_limiter: PrincipalConnectionLimiter::new(
481                http_limits::DEFAULT_MAX_INFLIGHT_PER_PRINCIPAL,
482            ),
483            stream_capacity: output_stream::StreamCapacityRegistry::new(),
484            lease_registry: output_stream::LeaseRegistry::new(),
485            cursor_registry: output_stream::CursorRegistry::new(),
486        }
487    }
488
489    #[doc(hidden)]
490    pub fn stream_capacity(&self) -> &Arc<output_stream::StreamCapacityRegistry> {
491        &self.stream_capacity
492    }
493
494    #[doc(hidden)]
495    pub fn lease_registry(&self) -> &Arc<output_stream::LeaseRegistry> {
496        &self.lease_registry
497    }
498
499    #[doc(hidden)]
500    pub fn cursor_registry(&self) -> &Arc<output_stream::CursorRegistry> {
501        &self.cursor_registry
502    }
503
504    #[doc(hidden)]
505    pub fn http_metrics(&self) -> &HttpHandlerMetrics {
506        &self.http_metrics
507    }
508
509    /// HTTP request/error volume counters (`reddb_http_requests_total`).
510    /// Read by `/metrics` and the operational telemetry read model.
511    #[doc(hidden)]
512    pub fn http_request_metrics(&self) -> &HttpRequestMetrics {
513        &self.http_request_metrics
514    }
515
516    /// Visible for tests. Lets the integration test in
517    /// `tests/http_connection_limiter.rs` saturate the cap and observe
518    /// `503 Service Unavailable` responses without spinning up
519    /// thousands of sockets.
520    #[doc(hidden)]
521    pub fn with_http_limiter_cap(mut self, cap: usize) -> Self {
522        self.http_limiter = HttpConnectionLimiter::new(cap);
523        self
524    }
525
526    /// Stamp resolved HTTP limits onto the server (issue #574 slice 5).
527    /// Replaces the limiter cap, the per-handler deadline, and the
528    /// `Retry-After` value used by the limiter's reject path. All
529    /// values are assumed validated by [`http_limits::resolve_http_limits`].
530    pub fn with_http_limits(mut self, limits: HttpLimitsResolved) -> Self {
531        self.http_limiter = HttpConnectionLimiter::new(limits.max_handlers);
532        self.handler_timeout = Duration::from_millis(limits.handler_timeout_ms);
533        self.retry_after_secs = limits.retry_after_secs;
534        self.principal_limiter = PrincipalConnectionLimiter::new(limits.max_inflight_per_principal);
535        self
536    }
537
538    /// Visible for tests. Override the per-principal concurrent in-flight
539    /// cap (issue #934) so the integration test can saturate one principal
540    /// and observe the structured 429 refusal without standing up hundreds
541    /// of real sockets. `0` disables the per-principal cap.
542    #[doc(hidden)]
543    pub fn with_principal_inflight_cap(mut self, cap: usize) -> Self {
544        self.principal_limiter = PrincipalConnectionLimiter::new(cap);
545        self
546    }
547
548    #[doc(hidden)]
549    pub fn principal_limiter(&self) -> &PrincipalConnectionLimiter {
550        &self.principal_limiter
551    }
552
553    #[doc(hidden)]
554    pub fn retry_after_secs(&self) -> u64 {
555        self.retry_after_secs
556    }
557
558    #[doc(hidden)]
559    pub fn http_limiter(&self) -> &HttpConnectionLimiter {
560        &self.http_limiter
561    }
562
563    /// Visible for tests that prove health and query admission are isolated.
564    #[doc(hidden)]
565    pub fn health_limiter(&self) -> &HttpConnectionLimiter {
566        &self.health_limiter
567    }
568
569    /// Visible for tests. Production always uses [`HEALTH_HANDLER_CAP`].
570    #[doc(hidden)]
571    pub fn with_health_limiter_cap(mut self, cap: usize) -> Self {
572        self.health_limiter = HttpConnectionLimiter::new(cap);
573        self
574    }
575
576    /// Visible for tests. Override the per-handler total-time deadline
577    /// (issue #570 slice 2). Default 30s.
578    #[doc(hidden)]
579    pub fn with_handler_timeout(mut self, timeout: Duration) -> Self {
580        self.handler_timeout = timeout;
581        self
582    }
583
584    #[doc(hidden)]
585    pub fn handler_timeout(&self) -> Duration {
586        self.handler_timeout
587    }
588
589    /// Visible for tests. Override the clock the per-handler deadline
590    /// (issue #621) is armed against, so timeout expiry can be driven
591    /// deterministically without real sleeps. Default is the real
592    /// monotonic clock.
593    #[doc(hidden)]
594    pub fn with_handler_clock(mut self, clock: Arc<dyn MonotonicClock>) -> Self {
595        self.handler_clock = clock;
596        self
597    }
598
599    /// Test hook: set a synchronous sleep (in ms) inserted between
600    /// route dispatch and response write. The integration test for
601    /// slice 2 sets a value greater than `handler_timeout` to trip
602    /// the deadline, then resets to 0 to verify recovery. Shared via
603    /// `Arc<AtomicU64>` so cloned server handles see the same flip.
604    #[doc(hidden)]
605    pub fn set_test_slow_inject_ms(&self, ms: u64) {
606        self.slow_inject_ms.store(ms, Ordering::Relaxed);
607    }
608
609    /// Attach an `AuthStore` for HTTP-layer authentication.
610    /// Also injects the store into the runtime so that `Value::Secret`
611    /// auto-encrypt/decrypt can reach the vault AES key.
612    pub fn with_auth(mut self, auth_store: Arc<AuthStore>) -> Self {
613        self.runtime.set_auth_store(Arc::clone(&auth_store));
614        self.auth_store = Some(auth_store);
615        self
616    }
617
618    /// Attach replication state for status and snapshot endpoints.
619    pub fn with_replication(mut self, state: Arc<ServerReplicationState>) -> Self {
620        self.replication = Some(state);
621        self
622    }
623
624    /// Set the `Origin` allowlist that enables the RedWire-over-WSS
625    /// browser endpoint (issue #935, ADR 0036). A non-empty list mounts
626    /// the `/redwire` upgrade route on the TLS edge; an empty list leaves
627    /// it unmounted (default-deny).
628    pub fn with_websocket_allowed_origins(mut self, origins: Vec<String>) -> Self {
629        self.options.websocket_allowed_origins = origins;
630        self
631    }
632
633    /// The configured RedWire-over-WSS `Origin` allowlist (issue #935).
634    pub(crate) fn websocket_allowed_origins(&self) -> &[String] {
635        &self.options.websocket_allowed_origins
636    }
637
638    /// Serve the resolved red-ui bundle from `dir` as inert static assets
639    /// on this listener (`red server --ui`, issue #1047, ADR 0051). The
640    /// bundle is served as a fallback after the API routing table, so it
641    /// can never shadow a real endpoint; `GET /` resolves to the bundle's
642    /// `index.html`.
643    pub fn with_ui_dir(mut self, dir: std::path::PathBuf) -> Self {
644        self.options.ui_dir = Some(dir);
645        self
646    }
647
648    /// The directory this listener serves the red-ui bundle from, if
649    /// `--ui` is active (issue #1047).
650    pub(crate) fn ui_dir(&self) -> Option<&std::path::Path> {
651        self.options.ui_dir.as_deref()
652    }
653
654    /// Enable the browser credential layer (issue #936, PRD #930) by
655    /// wiring a hybrid-token authority into the runtime. Once set, the
656    /// `/auth/browser/*` HTTP endpoints issue/rotate the access+refresh
657    /// pair and the RedWire WS handshake accepts the access JWT. Left
658    /// unset, the browser flow is inert (default-deny), matching the WS
659    /// endpoint's own opt-in posture.
660    pub fn with_browser_token_authority(
661        self,
662        authority: Arc<crate::auth::browser_token::BrowserTokenAuthority>,
663    ) -> Self {
664        self.runtime.set_browser_token_authority(Some(authority));
665        self
666    }
667
668    pub fn runtime(&self) -> &RedDBRuntime {
669        &self.runtime
670    }
671
672    pub fn options(&self) -> &ServerOptions {
673        &self.options
674    }
675
676    fn admin_use_cases(&self) -> AdminUseCases<'_, RedDBRuntime> {
677        AdminUseCases::new(&self.runtime)
678    }
679
680    fn entity_use_cases(&self) -> EntityUseCases<'_, RedDBRuntime> {
681        EntityUseCases::new(&self.runtime)
682    }
683
684    fn graph_use_cases(&self) -> GraphUseCases<'_, RedDBRuntime> {
685        GraphUseCases::new(&self.runtime)
686    }
687
688    fn native_use_cases(&self) -> NativeUseCases<'_, RedDBRuntime> {
689        NativeUseCases::new(&self.runtime)
690    }
691
692    fn tree_use_cases(&self) -> TreeUseCases<'_, RedDBRuntime> {
693        TreeUseCases::new(&self.runtime)
694    }
695
696    fn transport_readiness_json(&self) -> JsonValue {
697        let active = self
698            .options
699            .transport_readiness
700            .active
701            .iter()
702            .map(|listener| {
703                let mut object = Map::new();
704                object.insert(
705                    "transport".to_string(),
706                    JsonValue::String(listener.transport.clone()),
707                );
708                object.insert(
709                    "bind_addr".to_string(),
710                    JsonValue::String(listener.bind_addr.clone()),
711                );
712                object.insert("explicit".to_string(), JsonValue::Bool(listener.explicit));
713                JsonValue::Object(object)
714            })
715            .collect();
716        let failed = self
717            .options
718            .transport_readiness
719            .failed
720            .iter()
721            .map(|listener| {
722                let mut object = Map::new();
723                object.insert(
724                    "transport".to_string(),
725                    JsonValue::String(listener.transport.clone()),
726                );
727                object.insert(
728                    "bind_addr".to_string(),
729                    JsonValue::String(listener.bind_addr.clone()),
730                );
731                object.insert("explicit".to_string(), JsonValue::Bool(listener.explicit));
732                object.insert(
733                    "reason".to_string(),
734                    JsonValue::String(listener.reason.clone()),
735                );
736                JsonValue::Object(object)
737            })
738            .collect();
739
740        let mut object = Map::new();
741        object.insert("active".to_string(), JsonValue::Array(active));
742        object.insert("failed".to_string(), JsonValue::Array(failed));
743        JsonValue::Object(object)
744    }
745
746    fn handle_grpc_discovery(&self) -> HttpResponse {
747        let mut methods = Map::new();
748        methods.insert(
749            "query".to_string(),
750            JsonValue::String("reddb.v1.RedDB/Query".to_string()),
751        );
752        methods.insert(
753            "batch_query".to_string(),
754            JsonValue::String("reddb.v1.RedDB/BatchQuery".to_string()),
755        );
756        methods.insert(
757            "health".to_string(),
758            JsonValue::String("reddb.v1.RedDB/Health".to_string()),
759        );
760        methods.insert(
761            "prepare".to_string(),
762            JsonValue::String("reddb.v1.RedDB/Prepare".to_string()),
763        );
764        methods.insert(
765            "execute_prepared".to_string(),
766            JsonValue::String("reddb.v1.RedDB/ExecutePrepared".to_string()),
767        );
768
769        let mut examples = Map::new();
770        examples.insert(
771            "query".to_string(),
772            JsonValue::String(
773                "grpcurl -plaintext -d '{\"query\":\"SELECT 1\"}' 127.0.0.1:5000 reddb.v1.RedDB/Query"
774                    .to_string(),
775            ),
776        );
777        examples.insert(
778            "query_with_params".to_string(),
779            JsonValue::String(
780                "grpcurl -plaintext -d '{\"query\":\"SELECT $1 AS value\",\"params\":[{\"intValue\":42}]}' 127.0.0.1:5000 reddb.v1.RedDB/Query"
781                    .to_string(),
782            ),
783        );
784        examples.insert(
785            "health".to_string(),
786            JsonValue::String(
787                "grpcurl -plaintext -d '{}' 127.0.0.1:5000 reddb.v1.RedDB/Health".to_string(),
788            ),
789        );
790
791        let mut object = Map::new();
792        object.insert("ok".to_string(), JsonValue::Bool(true));
793        object.insert(
794            "service".to_string(),
795            JsonValue::String("reddb.v1.RedDB".to_string()),
796        );
797        object.insert(
798            "package".to_string(),
799            JsonValue::String("reddb.v1".to_string()),
800        );
801        object.insert(
802            "proto".to_string(),
803            JsonValue::String("crates/reddb-grpc-proto/proto/reddb.proto".to_string()),
804        );
805        object.insert("methods".to_string(), JsonValue::Object(methods));
806        object.insert("examples".to_string(), JsonValue::Object(examples));
807        object.insert(
808            "transport_listeners".to_string(),
809            self.transport_readiness_json(),
810        );
811        object.insert(
812            "hint".to_string(),
813            JsonValue::String(
814                "If grpcurl cannot list services, pass the proto file with -import-path crates/reddb-grpc-proto/proto -proto reddb.proto."
815                    .to_string(),
816            ),
817        );
818        json_response(200, JsonValue::Object(object))
819    }
820
821    fn handle_query_contract(&self) -> HttpResponse {
822        let mut examples = Map::new();
823        examples.insert(
824            "raw_sql".to_string(),
825            JsonValue::String("curl -sS http://127.0.0.1:5000/query -d 'SELECT 1'".to_string()),
826        );
827        examples.insert(
828            "json_query".to_string(),
829            JsonValue::String(
830                "curl -sS http://127.0.0.1:5000/query -H 'content-type: application/json' -d '{\"query\":\"SELECT 1\"}'"
831                    .to_string(),
832            ),
833        );
834        examples.insert(
835            "json_query_with_params".to_string(),
836            JsonValue::String(
837                "curl -sS http://127.0.0.1:5000/query -H 'content-type: application/json' -d '{\"query\":\"SELECT $1 AS value\",\"params\":[42]}'"
838                    .to_string(),
839            ),
840        );
841
842        let mut request_body = Map::new();
843        request_body.insert(
844            "query".to_string(),
845            JsonValue::String("required string".to_string()),
846        );
847        request_body.insert(
848            "params".to_string(),
849            JsonValue::String("optional array".to_string()),
850        );
851
852        let mut response_shape = Map::new();
853        response_shape.insert(
854            "columns".to_string(),
855            JsonValue::String("projected column names".to_string()),
856        );
857        response_shape.insert(
858            "records[].values".to_string(),
859            JsonValue::String("only projected values".to_string()),
860        );
861        response_shape.insert(
862            "records[].meta".to_string(),
863            JsonValue::String("internal metadata when present".to_string()),
864        );
865
866        let mut object = Map::new();
867        object.insert("ok".to_string(), JsonValue::Bool(false));
868        object.insert(
869            "code".to_string(),
870            JsonValue::String("method_not_allowed".to_string()),
871        );
872        object.insert(
873            "message".to_string(),
874            JsonValue::String("/query accepts POST requests".to_string()),
875        );
876        object.insert(
877            "hint".to_string(),
878            JsonValue::String(
879                "Send raw SQL in the body, or JSON with a string 'query' field.".to_string(),
880            ),
881        );
882        object.insert("method".to_string(), JsonValue::String("POST".to_string()));
883        object.insert("path".to_string(), JsonValue::String("/query".to_string()));
884        object.insert("request_body".to_string(), JsonValue::Object(request_body));
885        object.insert(
886            "response_shape".to_string(),
887            JsonValue::Object(response_shape),
888        );
889        object.insert("examples".to_string(), JsonValue::Object(examples));
890        object.insert(
891            "docs".to_string(),
892            JsonValue::String("https://reddb.io/docs/query".to_string()),
893        );
894
895        json_response(405, JsonValue::Object(object))
896            .with_header("Allow", http::HeaderValue::from_static("POST"))
897    }
898
899    fn handle_root_discovery(&self) -> HttpResponse {
900        let mut endpoints = Map::new();
901        endpoints.insert(
902            "health".to_string(),
903            JsonValue::String("GET /health".to_string()),
904        );
905        endpoints.insert(
906            "ready".to_string(),
907            JsonValue::String("GET /ready".to_string()),
908        );
909        endpoints.insert(
910            "query".to_string(),
911            JsonValue::String("POST /query".to_string()),
912        );
913        endpoints.insert(
914            "query_readiness".to_string(),
915            JsonValue::String("GET /ready/query".to_string()),
916        );
917        endpoints.insert(
918            "catalog".to_string(),
919            JsonValue::String("GET /catalog".to_string()),
920        );
921        endpoints.insert(
922            "deployment_profiles".to_string(),
923            JsonValue::String("GET /deployment/profiles".to_string()),
924        );
925
926        let mut examples = Map::new();
927        examples.insert(
928            "http_raw_sql".to_string(),
929            JsonValue::String("curl -sS http://127.0.0.1:5000/query -d 'SELECT 1'".to_string()),
930        );
931        examples.insert(
932            "http_json_query".to_string(),
933            JsonValue::String(
934                "curl -sS http://127.0.0.1:5000/query -H 'content-type: application/json' -d '{\"query\":\"SELECT 1\"}'"
935                    .to_string(),
936            ),
937        );
938        examples.insert(
939            "http_json_query_with_params".to_string(),
940            JsonValue::String(
941                "curl -sS http://127.0.0.1:5000/query -H 'content-type: application/json' -d '{\"query\":\"SELECT $1 AS value\",\"params\":[42]}'"
942                    .to_string(),
943            ),
944        );
945
946        let mut object = Map::new();
947        object.insert("ok".to_string(), JsonValue::Bool(true));
948        object.insert(
949            "service".to_string(),
950            JsonValue::String("reddb".to_string()),
951        );
952        object.insert(
953            "version".to_string(),
954            JsonValue::String(env!("CARGO_PKG_VERSION").to_string()),
955        );
956        object.insert("endpoints".to_string(), JsonValue::Object(endpoints));
957        object.insert("examples".to_string(), JsonValue::Object(examples));
958        object.insert(
959            "docs".to_string(),
960            JsonValue::String("https://reddb.io/docs".to_string()),
961        );
962        object.insert(
963            "transport_listeners".to_string(),
964            self.transport_readiness_json(),
965        );
966        json_response(200, JsonValue::Object(object))
967    }
968
969    fn health_json_with_transport(&self, report: &HealthReport) -> JsonValue {
970        let mut value = crate::presentation::ops_json::health_json(report);
971        if let JsonValue::Object(ref mut object) = value {
972            object.insert(
973                "transport_listeners".to_string(),
974                self.transport_readiness_json(),
975            );
976        }
977        value
978    }
979
980    pub fn serve(&self) -> io::Result<()> {
981        let listener = TcpListener::bind(&self.options.bind_addr)?;
982        self.serve_on(listener)
983    }
984
985    /// Serve the async axum/hyper HTTP edge (issue #931) on the given
986    /// listener until it errors fatally. A dedicated multi-threaded tokio
987    /// runtime drives the I/O; the synchronous disk-backed engine is
988    /// reached via `spawn_blocking`. This replaces the retired
989    /// thread-per-connection accept loop and its `(2*num_cpus)` thread
990    /// cap — idle keep-alive connections are now cheap parked tasks.
991    pub fn serve_on(&self, listener: TcpListener) -> io::Result<()> {
992        let runtime = axum_edge::build_edge_runtime()?;
993        runtime.block_on(
994            self.clone()
995                .serve_edge_on_std(listener, HttpTransport::Http),
996        )
997    }
998
999    /// Accept and serve a single connection to completion, then return.
1000    /// Used by tests that want a one-shot HTTP server alongside another
1001    /// transport.
1002    pub fn serve_one_on(&self, listener: TcpListener) -> io::Result<()> {
1003        let runtime = axum_edge::build_background_edge_runtime()?;
1004        let server = self.clone();
1005        runtime.block_on(async move {
1006            listener.set_nonblocking(true)?;
1007            let listener = tokio::net::TcpListener::from_std(listener)?;
1008            let (stream, _peer) = listener.accept().await?;
1009            server.serve_edge_one(stream).await;
1010            Ok(())
1011        })
1012    }
1013
1014    pub fn serve_in_background(&self) -> thread::JoinHandle<io::Result<()>> {
1015        let server = self.clone();
1016        thread::spawn(move || server.serve())
1017    }
1018
1019    pub fn serve_in_background_on(
1020        &self,
1021        listener: TcpListener,
1022    ) -> thread::JoinHandle<io::Result<()>> {
1023        let server = self.clone();
1024        thread::spawn(move || {
1025            let runtime = axum_edge::build_background_edge_runtime()?;
1026            runtime.block_on(server.serve_edge_on_std(listener, HttpTransport::Http))
1027        })
1028    }
1029
1030    /// Serve TLS-wrapped HTTPS on the configured `bind_addr`. The
1031    /// `tls_config` is shared across all connections (rustls
1032    /// `ServerConfig` is `Send + Sync`).
1033    pub fn serve_tls(&self, tls_config: std::sync::Arc<rustls::ServerConfig>) -> io::Result<()> {
1034        let listener = TcpListener::bind(&self.options.bind_addr)?;
1035        self.serve_tls_on(listener, tls_config)
1036    }
1037
1038    pub fn serve_tls_on(
1039        &self,
1040        listener: TcpListener,
1041        tls_config: std::sync::Arc<rustls::ServerConfig>,
1042    ) -> io::Result<()> {
1043        let runtime = axum_edge::build_edge_runtime()?;
1044        let acceptor = axum_edge::tls_acceptor(tls_config);
1045        runtime.block_on(self.clone().serve_edge_tls_on_std(
1046            listener,
1047            acceptor,
1048            HttpTransport::Https,
1049        ))
1050    }
1051
1052    pub fn serve_tls_in_background(
1053        &self,
1054        tls_config: std::sync::Arc<rustls::ServerConfig>,
1055    ) -> thread::JoinHandle<io::Result<()>> {
1056        let server = self.clone();
1057        thread::spawn(move || server.serve_tls(tls_config))
1058    }
1059
1060    pub fn serve_tls_in_background_on(
1061        &self,
1062        listener: TcpListener,
1063        tls_config: std::sync::Arc<rustls::ServerConfig>,
1064    ) -> thread::JoinHandle<io::Result<()>> {
1065        let server = self.clone();
1066        thread::spawn(move || {
1067            let runtime = axum_edge::build_background_edge_runtime()?;
1068            let acceptor = axum_edge::tls_acceptor(tls_config);
1069            runtime.block_on(server.serve_edge_tls_on_std(listener, acceptor, HttpTransport::Https))
1070        })
1071    }
1072
1073    fn handle_connection(&self, stream: TcpStream) -> io::Result<()> {
1074        let started = Instant::now();
1075        let result = self.handle_connection_inner(stream);
1076        let elapsed = started.elapsed().as_secs_f64();
1077        self.http_metrics
1078            .record_duration(HttpTransport::Http, elapsed);
1079        result
1080    }
1081
1082    fn handle_connection_inner(&self, mut stream: TcpStream) -> io::Result<()> {
1083        stream.set_read_timeout(Some(Duration::from_millis(self.options.read_timeout_ms)))?;
1084        stream.set_write_timeout(Some(Duration::from_millis(self.options.write_timeout_ms)))?;
1085
1086        // Issue #570 slice 2 / #621: arm a deadline at handler spawn and
1087        // check at coarse boundaries. Armed against the injectable
1088        // monotonic clock (#621) so timeout behaviour is deterministically
1089        // testable without real sleeps; production wires the real clock,
1090        // so this tracks wall time. No hard pre-emption — a thread blocked
1091        // inside a true syscall is still bounded only by the per-socket
1092        // read/write timeouts.
1093        let deadline = HandlerDeadline::arm(Arc::clone(&self.handler_clock), self.handler_timeout);
1094
1095        let request = HttpRequest::read_from(&mut stream, self.options.max_body_bytes)?;
1096
1097        // Boundary (a): between request parse and route dispatch.
1098        if deadline.expired() {
1099            self.http_metrics
1100                .record_reject(HttpTransport::Http, HttpRejectReason::HandlerTimeout);
1101            Self::write_handler_timeout_503(&mut stream);
1102            return Ok(());
1103        }
1104
1105        if self.try_route_streaming(&request, &mut stream)? {
1106            return Ok(());
1107        }
1108        let response = self.route(request);
1109
1110        // Test-only injected slow downstream (issue #570 slice 2
1111        // integration test). Production builds set this to 0, so this
1112        // is a single relaxed atomic load on the hot path.
1113        let inject_ms = self.slow_inject_ms.load(Ordering::Relaxed);
1114        if inject_ms > 0 {
1115            thread::sleep(Duration::from_millis(inject_ms));
1116        }
1117
1118        // Boundary (b): between route dispatch and response write.
1119        if deadline.expired() {
1120            self.http_metrics
1121                .record_reject(HttpTransport::Http, HttpRejectReason::HandlerTimeout);
1122            Self::write_handler_timeout_503(&mut stream);
1123            return Ok(());
1124        }
1125
1126        response.write_to(&mut stream)?;
1127        stream.flush()?;
1128        Ok(())
1129    }
1130
1131    /// Best-effort 503 emitted when the per-handler deadline expires
1132    /// at a coarse boundary. Writes are swallowed — the caller has
1133    /// already exceeded its budget, so we do not propagate write
1134    /// errors. Permit drop happens on the handler thread's normal
1135    /// exit path.
1136    fn write_handler_timeout_503<S: Write>(stream: &mut S) {
1137        const RESPONSE: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
1138            Connection: close\r\n\
1139            Content-Length: 0\r\n\
1140            \r\n";
1141        let _ = stream.write_all(RESPONSE);
1142        let _ = stream.flush();
1143    }
1144}