Skip to main content

heliosdb_proxy/
admin.rs

1//! Admin API
2//!
3//! REST API for proxy management, monitoring, and configuration.
4//! Includes HTTP SQL API for transparent write routing (TWR) and load balancing.
5
6#[cfg(feature = "anomaly-detection")]
7use crate::anomaly::AnomalyDetector;
8use crate::config::{NodeConfig, NodeRole, ProxyConfig};
9#[cfg(feature = "edge-proxy")]
10use crate::edge::{EdgeCache, EdgeRegistry, InvalidationEvent};
11#[cfg(feature = "wasm-plugins")]
12use crate::plugins::PluginManager;
13#[cfg(feature = "ha-tr")]
14use crate::replay::{ReplayEngine, TimeTravelRequest};
15use crate::server::{NodeHealth, ServerMetricsSnapshot};
16use crate::{ProxyError, Result};
17#[cfg(feature = "ha-tr")]
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21use std::net::SocketAddr;
22use std::sync::atomic::{AtomicUsize, Ordering};
23use std::sync::Arc;
24use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
25use tokio::net::TcpStream;
26use tokio::sync::{broadcast, RwLock};
27
28/// Static admin UI (vanilla HTML + JS). Compiled into the binary via
29/// `include_str!` so deployments are a single binary — no extra file
30/// serving or asset bundling. Served at `GET /` and `GET /ui`.
31const ADMIN_UI_HTML: &str = include_str!("admin_ui.html");
32
33/// Admin API server
34/// Constant-time string comparison (admin token check).
35fn constant_time_eq_str(a: &str, b: &str) -> bool {
36    let (a, b) = (a.as_bytes(), b.as_bytes());
37    if a.len() != b.len() {
38        return false;
39    }
40    let mut diff = 0u8;
41    for i in 0..a.len() {
42        diff |= a[i] ^ b[i];
43    }
44    diff == 0
45}
46
47pub struct AdminServer {
48    /// Listen address
49    listen_address: String,
50    /// Shared state with proxy
51    state: Arc<AdminState>,
52    /// Shutdown channel
53    shutdown_tx: broadcast::Sender<()>,
54}
55
56/// Shared admin state
57pub struct AdminState {
58    /// Node health status
59    pub node_health: RwLock<HashMap<String, NodeHealth>>,
60    /// Server metrics
61    pub metrics: RwLock<ServerMetricsSnapshot>,
62    /// Active sessions count
63    pub active_sessions: RwLock<u64>,
64    /// Configuration (read-only)
65    pub config_snapshot: RwLock<ConfigSnapshot>,
66    /// Full proxy config (for SQL routing)
67    pub proxy_config: RwLock<Option<ProxyConfig>>,
68    /// Round-robin counter for read load balancing
69    read_lb_counter: AtomicUsize,
70    /// Registered command handlers
71    commands: RwLock<HashMap<String, CommandHandler>>,
72    /// Connection pool manager (Session/Transaction/Statement modes).
73    /// Attached at startup; `/api/pools` returns real per-node pool
74    /// stats when present, an empty list otherwise.
75    #[cfg(feature = "pool-modes")]
76    pub pool_manager: RwLock<Option<Arc<crate::pool::ConnectionPoolManager>>>,
77    /// Circuit-breaker manager. Attached at startup; `/api/circuit` reports
78    /// each node's live circuit state (closed / open / half-open).
79    #[cfg(feature = "circuit-breaker")]
80    pub circuit_breaker: RwLock<Option<Arc<crate::circuit_breaker::CircuitBreakerManager>>>,
81    /// Time-travel replay engine. Optional so test fixtures don't have
82    /// to wire a backend template; production startup attaches it via
83    /// `with_replay_engine`. Endpoint returns 503 when missing.
84    #[cfg(feature = "ha-tr")]
85    pub replay_engine: RwLock<Option<Arc<ReplayEngine>>>,
86    /// WASM plugin manager. None when the proxy started without
87    /// plugins (or with a different feature set). `/plugins`
88    /// endpoint returns 503 when missing; UI panel says "no plugin
89    /// manager attached".
90    #[cfg(feature = "wasm-plugins")]
91    pub plugin_manager: RwLock<Option<Arc<PluginManager>>>,
92    /// Chaos-mode overrides: per-node-address marker that the chaos
93    /// system (POST /api/chaos) has forced this node to a particular
94    /// state. Lets the UI distinguish "operationally disabled" from
95    /// "chaos-injected fault" and lets `Reset` restore everything.
96    pub chaos_overrides: RwLock<HashMap<String, ChaosOverride>>,
97    /// Anomaly detector — same Arc the server populates from the
98    /// query path. /api/anomalies polls this for the recent-events
99    /// ring buffer.
100    #[cfg(feature = "anomaly-detection")]
101    pub anomaly_detector: RwLock<Option<Arc<AnomalyDetector>>>,
102    /// Query-analytics engine — same Arc the server records on from the query
103    /// path. `/api/analytics` reads top queries + slow-query log from it.
104    #[cfg(feature = "query-analytics")]
105    pub analytics: RwLock<Option<Arc<crate::analytics::QueryAnalytics>>>,
106    /// Edge proxy cache + registry. Cache surfaces stats; registry
107    /// is the home-side fanout for invalidations.
108    #[cfg(feature = "edge-proxy")]
109    pub edge_cache: RwLock<Option<Arc<EdgeCache>>>,
110    #[cfg(feature = "edge-proxy")]
111    pub edge_registry: RwLock<Option<Arc<EdgeRegistry>>>,
112    /// Bearer token required on admin requests (except liveness probes).
113    /// `None` = open. Set once at startup from `config.admin_token`.
114    pub auth_token: RwLock<Option<String>>,
115    /// Traffic-mirror / migration info for `/api/migration/status`. `Some`
116    /// when `[mirror] enabled`.
117    pub migration: RwLock<Option<MigrationInfo>>,
118    /// Branch-database config for `/api/branch`. `Some` when `[branch]
119    /// enabled`.
120    pub branch: RwLock<Option<crate::config::BranchConfig>>,
121}
122
123/// What the admin API needs to report migration status, without owning the
124/// mirror worker.
125#[derive(Clone)]
126pub struct MigrationInfo {
127    pub target: String,
128    pub writes_only: bool,
129    pub metrics: Arc<crate::mirror::MirrorMetrics>,
130    /// Mirror config (source + target) for snapshot bootstrap.
131    pub config: crate::config::MirrorConfig,
132    /// The proxy's cutover switch and the target to promote to.
133    pub cutover: Arc<arc_swap::ArcSwap<Option<Arc<crate::mirror::CutoverTarget>>>>,
134    pub cutover_target: crate::mirror::CutoverTarget,
135}
136
137/// Chaos override applied to a single node. Today only the
138/// `ForceUnhealthy` flavour is implemented — `inject_query_delay`
139/// is the natural follow-up but wants per-query interception that
140/// lives in the server message loop, not here.
141#[derive(Debug, Clone, Serialize)]
142pub struct ChaosOverride {
143    /// Wall-clock when the override was applied (RFC 3339).
144    pub since: String,
145    /// "force_unhealthy" | "delay_ms"
146    pub kind: String,
147    /// Free-form description shown in admin UI.
148    pub note: String,
149}
150
151/// Command handler type
152type CommandHandler = Arc<dyn Fn(&[&str]) -> Result<String> + Send + Sync>;
153
154/// Configuration snapshot for admin API
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ConfigSnapshot {
157    pub listen_address: String,
158    pub admin_address: String,
159    pub tr_enabled: bool,
160    pub tr_mode: String,
161    pub pool_min_connections: usize,
162    pub pool_max_connections: usize,
163    pub nodes: Vec<NodeSnapshot>,
164}
165
166/// Node configuration snapshot
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct NodeSnapshot {
169    pub address: String,
170    pub role: String,
171    pub weight: u32,
172    pub enabled: bool,
173}
174
175impl AdminServer {
176    /// Create a new admin server
177    pub fn new(listen_address: String, state: Arc<AdminState>) -> Self {
178        let (shutdown_tx, _) = broadcast::channel(1);
179
180        Self {
181            listen_address,
182            state,
183            shutdown_tx,
184        }
185    }
186
187    /// Run the admin server
188    pub async fn run(&self) -> Result<()> {
189        // SO_REUSEPORT like the client listener, so a binary handoff can re-bind
190        // the admin address concurrently while the old process drains (Batch H).
191        let listener = crate::server::bind_reuseport(&self.listen_address)?;
192
193        tracing::info!(
194            "Admin API listening on {} (SO_REUSEPORT)",
195            self.listen_address
196        );
197
198        let mut shutdown_rx = self.shutdown_tx.subscribe();
199        // Bound concurrent admin connections so a flood can't spawn unbounded
200        // tasks (each may buffer up to the body cap). Excess connections are
201        // dropped rather than queued.
202        let conn_limit = std::sync::Arc::new(tokio::sync::Semaphore::new(Self::MAX_ADMIN_CONNS));
203
204        loop {
205            tokio::select! {
206                accept_result = listener.accept() => {
207                    match accept_result {
208                        Ok((stream, addr)) => {
209                            let permit = match conn_limit.clone().try_acquire_owned() {
210                                Ok(p) => p,
211                                Err(_) => {
212                                    tracing::warn!(%addr, "admin connection limit reached; dropping");
213                                    drop(stream);
214                                    continue;
215                                }
216                            };
217                            let state = self.state.clone();
218                            tokio::spawn(async move {
219                                let _permit = permit; // released when the connection ends
220                                if let Err(e) = Self::handle_connection(stream, addr, state).await {
221                                    tracing::error!("Admin connection error: {}", e);
222                                }
223                            });
224                        }
225                        Err(e) => {
226                            tracing::error!("Admin accept error: {}", e);
227                        }
228                    }
229                }
230                _ = shutdown_rx.recv() => {
231                    tracing::info!("Admin server shutting down");
232                    break;
233                }
234            }
235        }
236
237        Ok(())
238    }
239
240    /// Overall deadline for reading one admin request (headers + body). Bounds
241    /// slow-loris clients on the default-open admin listener.
242    /// Max concurrent admin connections; excess are dropped, not queued.
243    const MAX_ADMIN_CONNS: usize = 256;
244    const ADMIN_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
245    /// Bound on every SSE write+flush (preamble, event frame,
246    /// heartbeat), mirroring the data path's CLIENT_WRITE_TIMEOUT
247    /// convention: a subscriber that stops reading must be reaped, not
248    /// pin its admin task + connection permit forever. Comfortably
249    /// above one 15s heartbeat interval.
250    #[cfg(feature = "edge-proxy")]
251    const ADMIN_SSE_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
252    /// Max number of header lines accepted per admin request.
253    const MAX_ADMIN_HEADERS: usize = 100;
254    /// Max total bytes of the header section.
255    const MAX_ADMIN_HEADER_BYTES: usize = 64 * 1024;
256    /// Max admin request body. Admin payloads (config fragments, replay windows)
257    /// are small; this bounds the `vec![0u8; content_length]` allocation.
258    const MAX_ADMIN_BODY_BYTES: usize = 8 * 1024 * 1024;
259
260    /// Handle an admin connection
261    async fn handle_connection(
262        mut stream: TcpStream,
263        addr: SocketAddr,
264        state: Arc<AdminState>,
265    ) -> Result<()> {
266        tracing::debug!("Admin connection from {}", addr);
267
268        let (reader, mut writer) = stream.split();
269        let mut reader = BufReader::new(reader);
270        let mut line = String::new();
271
272        // Read HTTP request headers
273        let mut headers = Vec::new();
274        let mut content_length: usize = 0;
275        let mut header_bytes: usize = 0;
276
277        loop {
278            line.clear();
279            // Bound the whole request-read phase in time so a slow-loris client
280            // dribbling bytes cannot pin this admin task indefinitely. The admin
281            // listener is open by default, so this is unauthenticated input.
282            let bytes_read =
283                match tokio::time::timeout(Self::ADMIN_READ_TIMEOUT, reader.read_line(&mut line))
284                    .await
285                {
286                    Ok(r) => r.map_err(|e| ProxyError::Network(format!("Read error: {}", e)))?,
287                    Err(_) => return Ok(()), // read timeout — drop the connection
288                };
289
290            if bytes_read == 0 || line == "\r\n" {
291                break;
292            }
293
294            // Bound header size + count so a client streaming header lines
295            // forever cannot grow memory without limit (unauthenticated DoS).
296            header_bytes += bytes_read;
297            if headers.len() >= Self::MAX_ADMIN_HEADERS
298                || header_bytes > Self::MAX_ADMIN_HEADER_BYTES
299            {
300                Self::send_response(
301                    &mut writer,
302                    431,
303                    "Request Header Fields Too Large",
304                    "header section too large",
305                )
306                .await?;
307                return Ok(());
308            }
309
310            // Parse Content-Length header
311            let trimmed = line.trim();
312            if trimmed.to_lowercase().starts_with("content-length:") {
313                if let Some(len_str) = trimmed.split(':').nth(1) {
314                    content_length = len_str.trim().parse().unwrap_or(0);
315                }
316            }
317            headers.push(trimmed.to_string());
318        }
319
320        if headers.is_empty() {
321            return Ok(());
322        }
323
324        // Reject an oversized declared body BEFORE allocating for it: the old
325        // `vec![0u8; content_length]` would zero-fill an attacker-chosen size
326        // (e.g. `Content-Length: 99999999999`) and OOM the whole process on the
327        // default-open admin port.
328        if content_length > Self::MAX_ADMIN_BODY_BYTES {
329            Self::send_response(
330                &mut writer,
331                413,
332                "Payload Too Large",
333                "request body exceeds admin size limit",
334            )
335            .await?;
336            return Ok(());
337        }
338
339        // Parse request line
340        let request_line = &headers[0];
341        let parts: Vec<&str> = request_line.split_whitespace().collect();
342
343        if parts.len() < 2 {
344            Self::send_response(&mut writer, 400, "Bad Request", "Invalid request line").await?;
345            return Ok(());
346        }
347
348        let method = parts[0];
349        let path = parts[1];
350
351        // Bearer-token gate. Liveness probes stay open so orchestrators can
352        // health-check without the token; everything else is rejected with
353        // 401 unless `Authorization: Bearer <token>` matches.
354        {
355            let required = state.auth_token.read().await.clone();
356            if let Some(token) = required {
357                let path_only = path.split('?').next().unwrap_or(path);
358                let is_liveness = method == "GET"
359                    && matches!(path_only, "/health" | "/healthz" | "/livez" | "/readyz");
360                if !is_liveness && !Self::admin_authorized(&headers, &token) {
361                    Self::send_response(
362                        &mut writer,
363                        401,
364                        "Unauthorized",
365                        "{\"error\":\"missing or invalid admin bearer token\"}",
366                    )
367                    .await?;
368                    return Ok(());
369                }
370            }
371        }
372
373        // Long-lived SSE subscription for edge invalidations (T3.2, H5).
374        // Intercepted here — before the one-shot `route_request` dispatch —
375        // because `send_json_response` frames with `Content-Length` +
376        // `Connection: close`, which can't hold a stream open. The admin
377        // bearer gate above has already run, so an unauthenticated
378        // subscribe gets the exact same 401 as any other protected route.
379        // ADMIN_READ_TIMEOUT bounded only the request-read phase; the held
380        // SSE response is deliberately unbounded in time, but every WRITE
381        // on it is bounded by ADMIN_SSE_WRITE_TIMEOUT — with the 15s
382        // heartbeat that caps a wedged subscriber's lifetime, so held
383        // MAX_ADMIN_CONNS permits can never exceed live subscribers
384        // (`max_edges`, far below the 256-connection cap) for long.
385        #[cfg(feature = "edge-proxy")]
386        if method == "GET" && path.split('?').next().unwrap_or(path) == "/api/edge/subscribe" {
387            let params = parse_query_params(path);
388            let edge_id = params.get("edge_id").map(String::as_str).unwrap_or("");
389            if edge_id.is_empty() {
390                Self::send_json_response(
391                    &mut writer,
392                    400,
393                    &serde_json::json!({ "error": "edge_id query parameter is required" }),
394                )
395                .await?;
396                return Ok(());
397            }
398            let region = params.get("region").map(String::as_str).unwrap_or("");
399            let base_url = params.get("base_url").map(String::as_str).unwrap_or("");
400            return Self::handle_edge_subscribe(&mut writer, &state, edge_id, region, base_url)
401                .await;
402        }
403
404        // Read request body for POST/PUT requests (size already bounded above).
405        let body = if content_length > 0 && (method == "POST" || method == "PUT") {
406            let mut body_buf = vec![0u8; content_length];
407            match tokio::time::timeout(Self::ADMIN_READ_TIMEOUT, reader.read_exact(&mut body_buf))
408                .await
409            {
410                Ok(r) => {
411                    r.map_err(|e| ProxyError::Network(format!("Body read error: {}", e)))?;
412                }
413                Err(_) => return Ok(()), // body read timeout — drop the connection
414            }
415            Some(String::from_utf8_lossy(&body_buf).to_string())
416        } else {
417            None
418        };
419
420        // Static admin UI — single HTML file compiled into the binary.
421        // Served at `/` and `/ui`; all other routes remain JSON.
422        if method == "GET" && (path == "/" || path == "/ui" || path == "/ui/") {
423            Self::send_html_response(&mut writer, 200, ADMIN_UI_HTML).await?;
424            return Ok(());
425        }
426
427        // Route request
428        let response = Self::route_request(method, path, body.as_deref(), &state).await;
429
430        match response {
431            Ok((status, body)) => {
432                Self::send_json_response(&mut writer, status, &body).await?;
433            }
434            Err(e) => {
435                let error = ErrorResponse {
436                    error: e.to_string(),
437                };
438                Self::send_json_response(&mut writer, 500, &error).await?;
439            }
440        }
441
442        Ok(())
443    }
444
445    /// True if the request carries `Authorization: Bearer <token>` matching
446    /// the configured admin token (constant-time compare).
447    fn admin_authorized(headers: &[String], token: &str) -> bool {
448        let expected = format!("Bearer {}", token);
449        for h in headers {
450            let mut sp = h.splitn(2, ':');
451            let name = sp.next().unwrap_or("").trim();
452            if name.eq_ignore_ascii_case("authorization") {
453                let value = sp.next().unwrap_or("").trim();
454                return constant_time_eq_str(value, &expected);
455            }
456        }
457        false
458    }
459
460    /// Serve a text/html HTTP response. Used by the admin UI route.
461    async fn send_html_response(
462        writer: &mut tokio::net::tcp::WriteHalf<'_>,
463        status: u16,
464        html: &str,
465    ) -> Result<()> {
466        let status_text = match status {
467            200 => "OK",
468            404 => "Not Found",
469            _ => "Unknown",
470        };
471        let response = format!(
472            "HTTP/1.1 {} {}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
473            status,
474            status_text,
475            html.len(),
476            html
477        );
478        writer
479            .write_all(response.as_bytes())
480            .await
481            .map_err(|e| ProxyError::Network(format!("Write error: {}", e)))?;
482        Ok(())
483    }
484
485    /// Route a request to the appropriate handler
486    async fn route_request(
487        method: &str,
488        path: &str,
489        body: Option<&str>,
490        state: &Arc<AdminState>,
491    ) -> Result<(u16, serde_json::Value)> {
492        match (method, path) {
493            // SQL API - Execute SQL with TWR (Transparent Write Routing)
494            ("POST", "/api/sql") => Self::handle_sql_request(body, state).await,
495
496            // Health endpoints
497            ("GET", "/health") => {
498                let health = HealthResponse { status: "ok" };
499                Ok((200, serde_json::to_value(health)?))
500            }
501            ("GET", "/health/ready") => {
502                let ready = Self::check_readiness(state).await;
503                let response = ReadinessResponse {
504                    ready,
505                    message: if ready {
506                        "Proxy is ready"
507                    } else {
508                        "Proxy is not ready"
509                    },
510                };
511                let status = if ready { 200 } else { 503 };
512                Ok((status, serde_json::to_value(response)?))
513            }
514            ("GET", "/health/live") => {
515                let response = LivenessResponse { alive: true };
516                Ok((200, serde_json::to_value(response)?))
517            }
518
519            // Metrics
520            ("GET", "/metrics") => {
521                let metrics = state.metrics.read().await.clone();
522                Ok((200, serde_json::to_value(MetricsResponse::from(metrics))?))
523            }
524            ("GET", "/metrics/prometheus") => {
525                let metrics = state.metrics.read().await.clone();
526                let prometheus = Self::format_prometheus_metrics(&metrics);
527                Ok((200, serde_json::json!({ "text": prometheus })))
528            }
529
530            // Node management
531            ("GET", "/nodes") => {
532                let health = state.node_health.read().await;
533                let nodes: Vec<NodeHealthResponse> = health
534                    .values()
535                    .map(|h| NodeHealthResponse::from(h.clone()))
536                    .collect();
537                Ok((200, serde_json::to_value(nodes)?))
538            }
539            ("GET", path) if path.starts_with("/nodes/") => {
540                let node_addr = path.trim_start_matches("/nodes/");
541                let health = state.node_health.read().await;
542                match health.get(node_addr) {
543                    Some(h) => Ok((
544                        200,
545                        serde_json::to_value(NodeHealthResponse::from(h.clone()))?,
546                    )),
547                    None => Ok((404, serde_json::json!({ "error": "Node not found" }))),
548                }
549            }
550            ("POST", path) if path.starts_with("/nodes/") && path.ends_with("/enable") => {
551                let node_addr = path
552                    .trim_start_matches("/nodes/")
553                    .trim_end_matches("/enable");
554                Self::set_node_enabled(state, node_addr, true).await?;
555                Ok((200, serde_json::json!({ "status": "enabled" })))
556            }
557            ("POST", path) if path.starts_with("/nodes/") && path.ends_with("/disable") => {
558                let node_addr = path
559                    .trim_start_matches("/nodes/")
560                    .trim_end_matches("/disable");
561                Self::set_node_enabled(state, node_addr, false).await?;
562                Ok((200, serde_json::json!({ "status": "disabled" })))
563            }
564
565            // Topology — joins config (role) with node_health (healthy)
566            // so external controllers (operator, ops dashboards) can
567            // populate `currentPrimary` / `healthyNodes` /
568            // `unhealthyNodes` in one round-trip. Designed for
569            // poll-friendly use; never blocks.
570            ("GET", "/topology") => {
571                let topo = Self::compute_topology(state).await;
572                Ok((200, serde_json::to_value(topo)?))
573            }
574
575            // Time-travel replay — replays a journal window against a
576            // target backend (typically a staging DB). Body shape is
577            // `ReplayRequestBody` below.
578            #[cfg(feature = "ha-tr")]
579            ("POST", "/api/replay") => Self::handle_replay_request(body, state).await,
580            #[cfg(not(feature = "ha-tr"))]
581            ("POST", "/api/replay") => Ok((
582                503,
583                serde_json::json!({ "error": "ha-tr feature not compiled in" }),
584            )),
585
586            // Shadow execution (T3.4) — runs a query against a source
587            // backend AND a shadow backend, diffs the result. Used for
588            // major-version upgrade validation, schema-migration
589            // canaries, replica-drift detection. Body is
590            // `ShadowRequestBody`.
591            #[cfg(feature = "ha-tr")]
592            ("POST", "/api/shadow") => Self::handle_shadow_request(body).await,
593            #[cfg(not(feature = "ha-tr"))]
594            ("POST", "/api/shadow") => Ok((
595                503,
596                serde_json::json!({ "error": "ha-tr feature not compiled in" }),
597            )),
598
599            // Loaded WASM plugins — name, version, hooks, state,
600            // invocation count. Returns 503 when no plugin manager
601            // is attached (proxy started without --features
602            // wasm-plugins or with plugins disabled in config).
603            ("GET", "/plugins") => Self::handle_plugins_list(state).await,
604
605            // Anomaly detector recent-events feed (T3.1). Optional
606            // ?limit query string clamps the response size; default
607            // is 100 events newest-first.
608            #[cfg(feature = "anomaly-detection")]
609            ("GET", p) if p == "/anomalies" || p.starts_with("/anomalies?") => {
610                Self::handle_anomalies_list(p, state).await
611            }
612            #[cfg(not(feature = "anomaly-detection"))]
613            ("GET", p) if p == "/anomalies" || p.starts_with("/anomalies?") => Ok((
614                503,
615                serde_json::json!({ "error": "anomaly-detection feature not compiled in" }),
616            )),
617
618            // Query analytics: top queries by call count + slow-query log.
619            #[cfg(feature = "query-analytics")]
620            ("GET", p)
621                if p == "/api/analytics"
622                    || p == "/analytics"
623                    || p.starts_with("/api/analytics?")
624                    || p.starts_with("/analytics?") =>
625            {
626                Self::handle_analytics(p, state).await
627            }
628            #[cfg(not(feature = "query-analytics"))]
629            ("GET", p)
630                if p == "/api/analytics"
631                    || p == "/analytics"
632                    || p.starts_with("/api/analytics?")
633                    || p.starts_with("/analytics?") =>
634            {
635                Ok((
636                    503,
637                    serde_json::json!({ "error": "query-analytics feature not compiled in" }),
638                ))
639            }
640
641            // Edge mode (T3.2). Stats panel for the home; the home's
642            // registered edges + cache stats; and a manual
643            // invalidation endpoint for ops drills. (The live SSE
644            // stream, `GET /api/edge/subscribe`, never reaches this
645            // dispatch — `handle_connection` intercepts it first,
646            // since the one-shot JSON writers here can't hold a
647            // stream open.)
648            #[cfg(feature = "edge-proxy")]
649            ("GET", "/api/edge") => Self::handle_edge_status(state).await,
650            #[cfg(feature = "edge-proxy")]
651            ("POST", "/api/edge/register") => Self::handle_edge_register(body, state).await,
652            #[cfg(feature = "edge-proxy")]
653            ("POST", "/api/edge/invalidate") => Self::handle_edge_invalidate(body, state).await,
654            #[cfg(not(feature = "edge-proxy"))]
655            ("GET", "/api/edge")
656            | ("POST", "/api/edge/register")
657            | ("POST", "/api/edge/invalidate") => Ok((
658                503,
659                serde_json::json!({ "error": "edge-proxy feature not compiled in" }),
660            )),
661            // Without the feature the subscribe intercept above is compiled
662            // out, so the SSE path falls through to here: same 503 as its
663            // sibling edge routes (query string included in the match).
664            #[cfg(not(feature = "edge-proxy"))]
665            ("GET", p) if p == "/api/edge/subscribe" || p.starts_with("/api/edge/subscribe?") => {
666                Ok((
667                    503,
668                    serde_json::json!({ "error": "edge-proxy feature not compiled in" }),
669                ))
670            }
671
672            // Chaos engineering — controlled fault injection for HA
673            // testing. Body is `ChaosRequestBody`; supported actions
674            // are `force_unhealthy` / `restore` / `reset`.
675            ("POST", "/api/chaos") => Self::handle_chaos_request(body, state).await,
676            // Read current overrides so the UI can show "what's
677            // currently broken on purpose".
678            ("GET", "/api/chaos") => {
679                let overrides = state.chaos_overrides.read().await.clone();
680                Ok((200, serde_json::to_value(overrides)?))
681            }
682
683            // Live per-node circuit-breaker state (closed / open / half-open)
684            // so an operator can see which backends the breaker has tripped.
685            #[cfg(feature = "circuit-breaker")]
686            ("GET", "/api/circuit") => Self::handle_circuit_status(state).await,
687            #[cfg(not(feature = "circuit-breaker"))]
688            ("GET", "/api/circuit") => Ok((
689                503,
690                serde_json::json!({ "error": "circuit-breaker feature not enabled" }),
691            )),
692
693            // Migration / traffic-mirror status
694            ("GET", "/api/migration/status") | ("GET", "/migration/status") => {
695                match state.migration.read().await.as_ref() {
696                    Some(info) => {
697                        let st =
698                            crate::mirror::status(&info.target, info.writes_only, &info.metrics);
699                        let mut v = serde_json::to_value(st)?;
700                        let cut = info.cutover.load_full().is_some();
701                        v["cutover_active"] = serde_json::json!(cut);
702                        Ok((200, v))
703                    }
704                    None => Ok((
705                        503,
706                        serde_json::json!({ "error": "traffic mirroring not enabled" }),
707                    )),
708                }
709            }
710
711            // Promote the mirror target to primary: new connections route there.
712            ("POST", "/api/migration/cutover") | ("POST", "/migration/cutover") => {
713                let info = state.migration.read().await.clone();
714                let Some(info) = info else {
715                    return Ok((
716                        503,
717                        serde_json::json!({ "error": "traffic mirroring not enabled" }),
718                    ));
719                };
720                let force = path.contains("force=true")
721                    || body.map(|b| b.contains("\"force\":true")).unwrap_or(false);
722                let st = crate::mirror::status(&info.target, info.writes_only, &info.metrics);
723                if !st.migration_ready && !force {
724                    return Ok((
725                        409,
726                        serde_json::json!({
727                            "ok": false,
728                            "error": "not migration_ready (backlog/drops present); pass force=true to override",
729                            "status": st,
730                        }),
731                    ));
732                }
733                info.cutover
734                    .store(Arc::new(Some(Arc::new(info.cutover_target.clone()))));
735                tracing::warn!(target = %info.cutover_target.addr, "migration cutover: new connections now route to the promoted target");
736                Ok((
737                    200,
738                    serde_json::json!({ "ok": true, "promoted_to": info.cutover_target.addr }),
739                ))
740            }
741
742            // Roll a cutover back to the original primary.
743            ("POST", "/api/migration/cutover/rollback")
744            | ("POST", "/migration/cutover/rollback") => {
745                let info = state.migration.read().await.clone();
746                let Some(info) = info else {
747                    return Ok((
748                        503,
749                        serde_json::json!({ "error": "traffic mirroring not enabled" }),
750                    ));
751                };
752                info.cutover.store(Arc::new(None));
753                Ok((200, serde_json::json!({ "ok": true, "rolled_back": true })))
754            }
755
756            // Snapshot-bootstrap named tables from the source into the mirror.
757            ("POST", "/api/migration/snapshot") | ("POST", "/migration/snapshot") => {
758                let info = state.migration.read().await.clone();
759                let Some(info) = info else {
760                    return Ok((
761                        503,
762                        serde_json::json!({ "error": "traffic mirroring not enabled" }),
763                    ));
764                };
765                let body = body.unwrap_or("{}");
766                let req: serde_json::Value = serde_json::from_str(body)
767                    .map_err(|e| ProxyError::Internal(format!("invalid JSON: {}", e)))?;
768                let tables: Vec<String> = req
769                    .get("tables")
770                    .and_then(|t| t.as_array())
771                    .map(|a| {
772                        a.iter()
773                            .filter_map(|v| v.as_str().map(String::from))
774                            .collect()
775                    })
776                    .unwrap_or_default();
777                if tables.is_empty() {
778                    return Ok((
779                        400,
780                        serde_json::json!({ "error": "provide a non-empty 'tables' array" }),
781                    ));
782                }
783                match crate::mirror::snapshot_tables(&info.config, &tables).await {
784                    Ok(rep) => {
785                        let total: u64 = rep.iter().map(|t| t.copied).sum();
786                        Ok((
787                            200,
788                            serde_json::json!({ "ok": true, "tables": rep, "rows_copied": total }),
789                        ))
790                    }
791                    Err(e) => Ok((500, serde_json::json!({ "ok": false, "error": e }))),
792                }
793            }
794
795            // Branch databases: list / create / drop.
796            ("GET", p) if p == "/api/branch" || p == "/branch" || p.starts_with("/api/branch?") => {
797                let cfg = state.branch.read().await.clone();
798                let Some(cfg) = cfg else {
799                    return Ok((
800                        503,
801                        serde_json::json!({ "error": "branch databases not enabled" }),
802                    ));
803                };
804                match crate::branch::list(&cfg).await {
805                    Ok(branches) => Ok((200, serde_json::json!({ "branches": branches }))),
806                    Err(e) => Ok((500, serde_json::json!({ "error": e }))),
807                }
808            }
809            ("POST", p) if p == "/api/branch" || p == "/branch" => {
810                let cfg = state.branch.read().await.clone();
811                let Some(cfg) = cfg else {
812                    return Ok((
813                        503,
814                        serde_json::json!({ "error": "branch databases not enabled" }),
815                    ));
816                };
817                let req: serde_json::Value = serde_json::from_str(body.unwrap_or("{}"))
818                    .map_err(|e| ProxyError::Internal(format!("invalid JSON: {}", e)))?;
819                let name = req.get("name").and_then(|v| v.as_str()).unwrap_or("");
820                if name.is_empty() {
821                    return Ok((400, serde_json::json!({ "error": "provide 'name'" })));
822                }
823                let base = req.get("base").and_then(|v| v.as_str());
824                match crate::branch::create(&cfg, name, base).await {
825                    Ok(()) => Ok((
826                        200,
827                        serde_json::json!({ "ok": true, "branch": name,
828                        "base": base.unwrap_or(&cfg.base_database) }),
829                    )),
830                    Err(e) => Ok((500, serde_json::json!({ "ok": false, "error": e }))),
831                }
832            }
833            ("DELETE", p) if p.starts_with("/api/branch") || p.starts_with("/branch") => {
834                let cfg = state.branch.read().await.clone();
835                let Some(cfg) = cfg else {
836                    return Ok((
837                        503,
838                        serde_json::json!({ "error": "branch databases not enabled" }),
839                    ));
840                };
841                let name = p.find("name=").map(|i| &p[i + 5..]).unwrap_or("");
842                if name.is_empty() {
843                    return Ok((
844                        400,
845                        serde_json::json!({ "error": "provide ?name=<branch>" }),
846                    ));
847                }
848                match crate::branch::drop(&cfg, name).await {
849                    Ok(()) => Ok((200, serde_json::json!({ "ok": true, "dropped": name }))),
850                    Err(e) => Ok((500, serde_json::json!({ "ok": false, "error": e }))),
851                }
852            }
853
854            // Configuration
855            ("GET", "/config") => {
856                let config = state.config_snapshot.read().await.clone();
857                Ok((200, serde_json::to_value(config)?))
858            }
859
860            // Sessions
861            ("GET", "/sessions") => {
862                let count = *state.active_sessions.read().await;
863                let response = SessionsResponse {
864                    active_sessions: count,
865                };
866                Ok((200, serde_json::to_value(response)?))
867            }
868
869            // Pools
870            ("GET", "/pools") => {
871                let pools = Self::get_pool_stats(state).await;
872                Ok((200, serde_json::to_value(pools)?))
873            }
874
875            // Version
876            ("GET", "/version") => {
877                let response = VersionResponse {
878                    version: crate::VERSION.to_string(),
879                    build_time: env!("CARGO_PKG_VERSION").to_string(),
880                };
881                Ok((200, serde_json::to_value(response)?))
882            }
883
884            // Not found
885            _ => Ok((404, serde_json::json!({ "error": "Not found" }))),
886        }
887    }
888
889    /// Handle SQL execution request with TWR (Transparent Write Routing)
890    async fn handle_sql_request(
891        body: Option<&str>,
892        state: &Arc<AdminState>,
893    ) -> Result<(u16, serde_json::Value)> {
894        // Parse request body
895        let body = body.ok_or_else(|| ProxyError::Internal("Missing request body".to_string()))?;
896        let request: SqlRequest = serde_json::from_str(body)
897            .map_err(|e| ProxyError::Internal(format!("Invalid JSON: {}", e)))?;
898
899        let sql = request.query.trim();
900        if sql.is_empty() {
901            return Ok((400, serde_json::json!({ "error": "Empty query" })));
902        }
903
904        // Classify query as read or write
905        let is_write = Self::is_write_query(sql);
906        let query_type = if is_write { "write" } else { "read" };
907
908        // Get proxy config
909        let proxy_config = state.proxy_config.read().await;
910        let config = proxy_config
911            .as_ref()
912            .ok_or_else(|| ProxyError::Internal("Proxy config not initialized".to_string()))?;
913
914        // Get node health
915        let health = state.node_health.read().await;
916
917        // Select target node based on query type
918        let target_node = if is_write {
919            // Write queries always go to primary
920            Self::select_primary_node(config, &health)?
921        } else {
922            // Read queries can go to any healthy node with load balancing
923            Self::select_read_node(config, &health, state)?
924        };
925
926        let target_address = format!("{}:{}", target_node.host, target_node.port);
927        // Use HTTP port from node config (defaults to 8080)
928        let http_port = target_node.http_port;
929        let http_url = format!("http://{}:{}/api/sql", target_node.host, http_port);
930
931        tracing::debug!(
932            "Routing {} query to {} ({})",
933            query_type,
934            target_address,
935            match target_node.role {
936                NodeRole::Primary => "primary",
937                NodeRole::Standby => "standby",
938                NodeRole::ReadReplica => "replica",
939            }
940        );
941
942        // Forward request to backend node
943        let result = Self::forward_sql_request(&http_url, sql).await?;
944
945        // Return result with routing metadata
946        let response = SqlResponse {
947            query_type: query_type.to_string(),
948            routed_to: target_address,
949            node_role: format!("{:?}", target_node.role).to_lowercase(),
950            result,
951        };
952
953        Ok((200, serde_json::to_value(response)?))
954    }
955
956    /// Determine if a query is a write operation
957    fn is_write_query(sql: &str) -> bool {
958        let upper = sql.trim().to_uppercase();
959
960        // Write operations
961        if upper.starts_with("INSERT")
962            || upper.starts_with("UPDATE")
963            || upper.starts_with("DELETE")
964            || upper.starts_with("CREATE")
965            || upper.starts_with("ALTER")
966            || upper.starts_with("DROP")
967            || upper.starts_with("TRUNCATE")
968            || upper.starts_with("GRANT")
969            || upper.starts_with("REVOKE")
970            || upper.starts_with("VACUUM")
971            || upper.starts_with("REINDEX")
972            || upper.starts_with("MERGE")
973            || upper.starts_with("UPSERT")
974        {
975            return true;
976        }
977
978        // Transaction control that might contain writes
979        if upper.starts_with("BEGIN")
980            || upper.starts_with("COMMIT")
981            || upper.starts_with("ROLLBACK")
982            || upper.starts_with("SAVEPOINT")
983        {
984            // Transaction control goes to primary for safety
985            return true;
986        }
987
988        // Read operations
989        false
990    }
991
992    /// Select primary node for write queries
993    fn select_primary_node<'a>(
994        config: &'a ProxyConfig,
995        health: &HashMap<String, NodeHealth>,
996    ) -> Result<&'a NodeConfig> {
997        config
998            .nodes
999            .iter()
1000            .find(|n| {
1001                n.role == NodeRole::Primary
1002                    && n.enabled
1003                    && health.get(&n.address()).map(|h| h.healthy).unwrap_or(false)
1004            })
1005            .ok_or_else(|| ProxyError::Internal("No healthy primary node available".to_string()))
1006    }
1007
1008    /// Select node for read queries with load balancing
1009    fn select_read_node<'a>(
1010        config: &'a ProxyConfig,
1011        health: &HashMap<String, NodeHealth>,
1012        state: &AdminState,
1013    ) -> Result<&'a NodeConfig> {
1014        // Get all healthy nodes (primary, standby, or replica)
1015        let healthy_nodes: Vec<&NodeConfig> = config
1016            .nodes
1017            .iter()
1018            .filter(|n| n.enabled && health.get(&n.address()).map(|h| h.healthy).unwrap_or(false))
1019            .collect();
1020
1021        if healthy_nodes.is_empty() {
1022            return Err(ProxyError::Internal(
1023                "No healthy nodes available".to_string(),
1024            ));
1025        }
1026
1027        // If read/write splitting is enabled and there are standbys, prefer them
1028        if config.load_balancer.read_write_split {
1029            let read_nodes: Vec<&NodeConfig> = healthy_nodes
1030                .iter()
1031                .filter(|n| n.role == NodeRole::Standby || n.role == NodeRole::ReadReplica)
1032                .copied()
1033                .collect();
1034
1035            if !read_nodes.is_empty() {
1036                // Round-robin across read nodes
1037                let counter = state.read_lb_counter.fetch_add(1, Ordering::Relaxed);
1038                let index = counter % read_nodes.len();
1039                return Ok(read_nodes[index]);
1040            }
1041        }
1042
1043        // Fall back to round-robin across all healthy nodes
1044        let counter = state.read_lb_counter.fetch_add(1, Ordering::Relaxed);
1045        let index = counter % healthy_nodes.len();
1046        Ok(healthy_nodes[index])
1047    }
1048
1049    /// Forward SQL request to backend node's HTTP API
1050    async fn forward_sql_request(url: &str, sql: &str) -> Result<serde_json::Value> {
1051        // Build HTTP request
1052        let request_body = serde_json::json!({ "query": sql });
1053        let body_bytes = serde_json::to_vec(&request_body)
1054            .map_err(|e| ProxyError::Internal(format!("JSON serialization error: {}", e)))?;
1055
1056        // Parse URL
1057        let url_parts: Vec<&str> = url.trim_start_matches("http://").splitn(2, '/').collect();
1058        if url_parts.is_empty() {
1059            return Err(ProxyError::Internal("Invalid URL".to_string()));
1060        }
1061
1062        let host_port = url_parts[0];
1063        let path = if url_parts.len() > 1 {
1064            format!("/{}", url_parts[1])
1065        } else {
1066            "/".to_string()
1067        };
1068
1069        // Connect to backend
1070        let stream = TcpStream::connect(host_port).await.map_err(|e| {
1071            ProxyError::Network(format!("Failed to connect to {}: {}", host_port, e))
1072        })?;
1073
1074        let (reader, mut writer) = stream.into_split();
1075        let mut reader = BufReader::new(reader);
1076
1077        // Send HTTP request
1078        let request = format!(
1079            "POST {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1080            path,
1081            host_port,
1082            body_bytes.len()
1083        );
1084
1085        writer
1086            .write_all(request.as_bytes())
1087            .await
1088            .map_err(|e| ProxyError::Network(format!("Write error: {}", e)))?;
1089        writer
1090            .write_all(&body_bytes)
1091            .await
1092            .map_err(|e| ProxyError::Network(format!("Write body error: {}", e)))?;
1093
1094        // Read response headers
1095        let mut response_headers = Vec::new();
1096        let mut line = String::new();
1097        let mut content_length: usize = 0;
1098
1099        loop {
1100            line.clear();
1101            let bytes_read = reader
1102                .read_line(&mut line)
1103                .await
1104                .map_err(|e| ProxyError::Network(format!("Response read error: {}", e)))?;
1105
1106            if bytes_read == 0 || line == "\r\n" {
1107                break;
1108            }
1109
1110            let trimmed = line.trim();
1111            if trimmed.to_lowercase().starts_with("content-length:") {
1112                if let Some(len_str) = trimmed.split(':').nth(1) {
1113                    content_length = len_str.trim().parse().unwrap_or(0);
1114                }
1115            }
1116            response_headers.push(trimmed.to_string());
1117        }
1118
1119        // Read response body
1120        let mut body_buf = vec![0u8; content_length];
1121        if content_length > 0 {
1122            reader
1123                .read_exact(&mut body_buf)
1124                .await
1125                .map_err(|e| ProxyError::Network(format!("Response body read error: {}", e)))?;
1126        }
1127
1128        let response_body = String::from_utf8_lossy(&body_buf);
1129
1130        // Parse JSON response
1131        serde_json::from_str(&response_body).map_err(|e| {
1132            ProxyError::Internal(format!(
1133                "Invalid JSON response: {} - body: {}",
1134                e, response_body
1135            ))
1136        })
1137    }
1138
1139    /// Check if proxy is ready to accept connections
1140    async fn check_readiness(state: &Arc<AdminState>) -> bool {
1141        let health = state.node_health.read().await;
1142
1143        // Need at least one healthy primary
1144        health.values().any(|h| h.healthy)
1145    }
1146
1147    /// Set node enabled status
1148    async fn set_node_enabled(
1149        state: &Arc<AdminState>,
1150        node_addr: &str,
1151        enabled: bool,
1152    ) -> Result<()> {
1153        let mut health = state.node_health.write().await;
1154
1155        if let Some(node_health) = health.get_mut(node_addr) {
1156            node_health.healthy = enabled;
1157            Ok(())
1158        } else {
1159            Err(ProxyError::Config(format!("Node not found: {}", node_addr)))
1160        }
1161    }
1162
1163    /// Get pool statistics
1164    async fn get_pool_stats(_state: &Arc<AdminState>) -> Vec<PoolStatsResponse> {
1165        // Real per-node pool stats from the attached pool manager. Returns
1166        // an empty list when pool-modes is off or no manager is attached.
1167        #[cfg(feature = "pool-modes")]
1168        if let Some(mgr) = _state.pool_manager.read().await.clone() {
1169            let stats = mgr.get_stats().await;
1170            return stats
1171                .node_stats
1172                .into_iter()
1173                .map(|ns| PoolStatsResponse {
1174                    node: ns.node_id.0.to_string(),
1175                    active_connections: ns.active as u64,
1176                    idle_connections: ns.idle as u64,
1177                    // Per-node pending/created/closed counters are not tracked
1178                    // separately by the manager; total is the live pool size.
1179                    pending_requests: 0,
1180                    total_connections_created: ns.total as u64,
1181                    total_connections_closed: 0,
1182                })
1183                .collect();
1184        }
1185        Vec::new()
1186    }
1187
1188    /// Handle `POST /api/replay`. Body is a JSON `ReplayRequestBody`.
1189    /// Returns 503 when no replay engine is attached, 400 on a malformed
1190    /// body or inverted window, 200 with `ReplaySummary` on success.
1191    #[cfg(feature = "ha-tr")]
1192    async fn handle_replay_request(
1193        body: Option<&str>,
1194        state: &Arc<AdminState>,
1195    ) -> Result<(u16, serde_json::Value)> {
1196        let raw =
1197            body.ok_or_else(|| ProxyError::Internal("replay: empty request body".to_string()))?;
1198        let req: ReplayRequestBody = match serde_json::from_str(raw) {
1199            Ok(r) => r,
1200            Err(e) => {
1201                return Ok((
1202                    400,
1203                    serde_json::json!({ "error": format!("invalid body: {}", e) }),
1204                ));
1205            }
1206        };
1207        let engine = match state.replay_engine.read().await.clone() {
1208            Some(e) => e,
1209            None => {
1210                return Ok((
1211                    503,
1212                    serde_json::json!({ "error": "replay engine not attached" }),
1213                ));
1214            }
1215        };
1216        let tt = TimeTravelRequest {
1217            from: req.from,
1218            to: req.to,
1219            target_host: req.target_host,
1220            target_port: req.target_port,
1221            target_user: req.target_user,
1222            target_password: req.target_password,
1223            target_database: req.target_database,
1224        };
1225        match engine.replay_window(&tt).await {
1226            Ok(summary) => Ok((200, serde_json::to_value(summary)?)),
1227            Err(e) => Ok((
1228                500,
1229                serde_json::json!({ "error": format!("replay failed: {}", e) }),
1230            )),
1231        }
1232    }
1233
1234    /// `GET /api/edge` — surfaces edge-mode state: cache stats +
1235    /// the list of registered edges (when running in home mode).
1236    #[cfg(feature = "edge-proxy")]
1237    async fn handle_edge_status(state: &Arc<AdminState>) -> Result<(u16, serde_json::Value)> {
1238        let cache_stats = state.edge_cache.read().await.clone().map(|c| c.stats());
1239        let edges = match state.edge_registry.read().await.clone() {
1240            Some(r) => r.list(),
1241            None => Vec::new(),
1242        };
1243        Ok((
1244            200,
1245            serde_json::json!({
1246                "cache":          cache_stats,
1247                "registered":     edges,
1248                "edge_count":     edges.len(),
1249            }),
1250        ))
1251    }
1252
1253    /// `POST /api/edge/register` — ack-only compatibility path. Body
1254    /// shape: `{"edge_id":"e1","region":"us-east","base_url":"https://e1"}`.
1255    /// Returns 201 with the assigned slot, 503 when registry full.
1256    /// The live invalidation stream is `GET /api/edge/subscribe`, which
1257    /// registers AND holds the receiver open for the connection's
1258    /// lifetime — edges should use that instead of this endpoint.
1259    #[cfg(feature = "edge-proxy")]
1260    async fn handle_edge_register(
1261        body: Option<&str>,
1262        state: &Arc<AdminState>,
1263    ) -> Result<(u16, serde_json::Value)> {
1264        let raw =
1265            body.ok_or_else(|| ProxyError::Internal("edge register: empty body".to_string()))?;
1266        let req: EdgeRegisterBody = match serde_json::from_str(raw) {
1267            Ok(r) => r,
1268            Err(e) => {
1269                return Ok((
1270                    400,
1271                    serde_json::json!({ "error": format!("invalid body: {}", e) }),
1272                ));
1273            }
1274        };
1275        let registry = match state.edge_registry.read().await.clone() {
1276            Some(r) => r,
1277            None => {
1278                return Ok((
1279                    503,
1280                    serde_json::json!({ "error": "edge registry not attached" }),
1281                ));
1282            }
1283        };
1284        let now = chrono::Utc::now().to_rfc3339();
1285        match registry.register(&req.edge_id, &req.region, &req.base_url, &now) {
1286            Ok(_rx) => {
1287                // Receiver intentionally dropped (H5 resolved): this
1288                // JSON endpoint only acknowledges. The live stream is
1289                // `GET /api/edge/subscribe`, whose handler holds the
1290                // receiver for the connection's lifetime. An edge that
1291                // registers here without subscribing is pruned on the
1292                // next broadcast.
1293                Ok((
1294                    201,
1295                    serde_json::json!({
1296                        "edge_id":  req.edge_id,
1297                        "region":   req.region,
1298                        "base_url": req.base_url,
1299                        "registered_at": now,
1300                    }),
1301                ))
1302            }
1303            Err(e) => Ok((503, serde_json::json!({ "error": e.to_string() }))),
1304        }
1305    }
1306
1307    /// `GET /api/edge/subscribe?edge_id=..&region=..&base_url=..` — the
1308    /// live invalidation stream (SSE). Registers the edge, then holds
1309    /// the registry receiver open for the connection's lifetime,
1310    /// forwarding every `InvalidationEvent` as an SSE `invalidate`
1311    /// frame with a `: keepalive` heartbeat every 15s in between.
1312    /// Resolves H5: the receiver lives exactly as long as the
1313    /// subscriber's connection; returning drops it, and the next
1314    /// broadcast prunes the dead sender.
1315    ///
1316    /// Writes to the raw write half — the SSE response is unframed
1317    /// (no Content-Length) and stays open until the edge disconnects
1318    /// or the registry evicts the subscription. Auth was already
1319    /// enforced by `handle_connection`'s bearer gate.
1320    #[cfg(feature = "edge-proxy")]
1321    async fn handle_edge_subscribe(
1322        writer: &mut tokio::net::tcp::WriteHalf<'_>,
1323        state: &Arc<AdminState>,
1324        edge_id: &str,
1325        region: &str,
1326        base_url: &str,
1327    ) -> Result<()> {
1328        let registry = match state.edge_registry.read().await.clone() {
1329            Some(r) => r,
1330            None => {
1331                Self::send_json_response(
1332                    writer,
1333                    503,
1334                    &serde_json::json!({ "error": "edge registry not attached" }),
1335                )
1336                .await?;
1337                return Ok(());
1338            }
1339        };
1340        let now = chrono::Utc::now().to_rfc3339();
1341        let mut rx = match registry.register(edge_id, region, base_url, &now) {
1342            Ok(rx) => rx,
1343            // CapacityExceeded — same 503 shape as the JSON register path.
1344            Err(e) => {
1345                Self::send_json_response(
1346                    writer,
1347                    503,
1348                    &serde_json::json!({ "error": e.to_string() }),
1349                )
1350                .await?;
1351                return Ok(());
1352            }
1353        };
1354
1355        // SSE preamble, straight onto the socket. Every SSE write is
1356        // bounded by ADMIN_SSE_WRITE_TIMEOUT: a subscriber that stops
1357        // reading (zero-window peer) must not pin this task — and its
1358        // MAX_ADMIN_CONNS permit — forever. The 15s heartbeat
1359        // guarantees a write attempt on every connection, so a wedged
1360        // subscriber is reaped within one beat + the timeout,
1361        // releasing both the permit and the receiver.
1362        if !Self::write_sse(
1363            writer,
1364            b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\n\r\n",
1365        )
1366        .await
1367        {
1368            return Ok(());
1369        }
1370
1371        // Hello frame: an invalidate event carrying the home's
1372        // per-boot epoch and current version. The edge (a) detects a
1373        // home restart immediately instead of at the first post-restart
1374        // write, (b) re-syncs its observed-home clock, and (c) flushes
1375        // entries cached while it was disconnected (empty table set =
1376        // wildcard), closing the missed-event window on reconnect.
1377        if let Some(cache) = state.edge_cache.read().await.clone() {
1378            let hello = InvalidationEvent {
1379                up_to_version: cache.current_version(),
1380                tables: Vec::new(),
1381                committed_at: now.clone(),
1382                epoch: cache.epoch(),
1383            };
1384            let json = serde_json::to_string(&hello)
1385                .map_err(|e| ProxyError::Internal(format!("JSON error: {}", e)))?;
1386            let frame = format!("event: invalidate\ndata: {}\n\n", json);
1387            if !Self::write_sse(writer, frame.as_bytes()).await {
1388                return Ok(());
1389            }
1390        }
1391
1392        tracing::info!(edge_id, region, "edge subscribed to invalidation stream");
1393
1394        let mut heartbeat = tokio::time::interval(std::time::Duration::from_secs(15));
1395        // The first interval tick completes immediately — consume it so
1396        // heartbeats start 15s after the preamble, not on top of it.
1397        heartbeat.tick().await;
1398
1399        loop {
1400            tokio::select! {
1401                ev = rx.recv() => {
1402                    match ev {
1403                        Some(ev) => {
1404                            // `to_string` is single-line, per the SSE
1405                            // framing contract (exactly one `data:` line).
1406                            let json = serde_json::to_string(&ev)
1407                                .map_err(|e| ProxyError::Internal(format!("JSON error: {}", e)))?;
1408                            let frame = format!("event: invalidate\ndata: {}\n\n", json);
1409                            if !Self::write_sse(writer, frame.as_bytes()).await {
1410                                // Edge gone (or wedged past the write
1411                                // timeout). Returning drops `rx`; the
1412                                // next broadcast prunes the sender.
1413                                tracing::debug!(edge_id, "edge SSE write failed; closing stream");
1414                                return Ok(());
1415                            }
1416                        }
1417                        // Sender side gone: the registry evicted this
1418                        // subscription (prune_stale, unregister, or a
1419                        // re-register under the same edge_id replaced it).
1420                        None => {
1421                            tracing::debug!(edge_id, "edge subscription evicted by registry");
1422                            return Ok(());
1423                        }
1424                    }
1425                }
1426                _ = heartbeat.tick() => {
1427                    // Comment-only keepalive — detects a dead client via
1428                    // the write error/timeout long before TCP keepalive
1429                    // would.
1430                    if !Self::write_sse(writer, b": keepalive\n\n").await {
1431                        tracing::debug!(edge_id, "edge SSE heartbeat failed; closing stream");
1432                        return Ok(());
1433                    }
1434                    // A delivered heartbeat proves the edge is reading:
1435                    // refresh registry liveness so a write-idle home
1436                    // never GC-prunes healthy subscribers (prune_stale
1437                    // stays as the backstop for wedged/dead peers).
1438                    registry.touch(edge_id, &chrono::Utc::now().to_rfc3339());
1439                }
1440            }
1441        }
1442    }
1443
1444    /// One timeout-bounded SSE write+flush. `false` = the connection
1445    /// is dead or wedged (treat exactly like a write error and close).
1446    #[cfg(feature = "edge-proxy")]
1447    async fn write_sse(writer: &mut tokio::net::tcp::WriteHalf<'_>, bytes: &[u8]) -> bool {
1448        let write = async {
1449            writer.write_all(bytes).await?;
1450            writer.flush().await
1451        };
1452        matches!(
1453            tokio::time::timeout(Self::ADMIN_SSE_WRITE_TIMEOUT, write).await,
1454            Ok(Ok(()))
1455        )
1456    }
1457
1458    /// `POST /api/edge/invalidate` — manual invalidation for ops
1459    /// drills. The proxy normally fans out invalidations
1460    /// automatically on writes; this endpoint is for "I just ran
1461    /// a migration outside the proxy, please drop caches".
1462    /// Body: `{"tables":["users"],"up_to_version":null}` — null
1463    /// version means "use the cache's current version" (drop all).
1464    #[cfg(feature = "edge-proxy")]
1465    async fn handle_edge_invalidate(
1466        body: Option<&str>,
1467        state: &Arc<AdminState>,
1468    ) -> Result<(u16, serde_json::Value)> {
1469        let raw =
1470            body.ok_or_else(|| ProxyError::Internal("edge invalidate: empty body".to_string()))?;
1471        let req: EdgeInvalidateBody = match serde_json::from_str(raw) {
1472            Ok(r) => r,
1473            Err(e) => {
1474                return Ok((
1475                    400,
1476                    serde_json::json!({ "error": format!("invalid body: {}", e) }),
1477                ));
1478            }
1479        };
1480        let cache = match state.edge_cache.read().await.clone() {
1481            Some(c) => c,
1482            None => {
1483                return Ok((
1484                    503,
1485                    serde_json::json!({ "error": "edge cache not attached" }),
1486                ));
1487            }
1488        };
1489        let registry = match state.edge_registry.read().await.clone() {
1490            Some(r) => r,
1491            None => {
1492                return Ok((
1493                    503,
1494                    serde_json::json!({ "error": "edge registry not attached" }),
1495                ));
1496            }
1497        };
1498        // Null version = "use the cache's current version": strictly
1499        // greater than every stamp this process has minted, so the
1500        // sweep covers all live entries (a fetch_add mint would also
1501        // burn a version for nothing). An explicit version is CLAMPED to the
1502        // current clock: a value above it would, once applied on the edges via
1503        // observe_home_version, push their observed-home counter past anything
1504        // the home will mint for a long time, so every subsequent real
1505        // invalidation (a smaller version) would sweep nothing — a permanent
1506        // fleet-wide poison from one oversized admin request.
1507        let version = req
1508            .up_to_version
1509            .map(|v| v.min(cache.current_version()))
1510            .unwrap_or_else(|| cache.current_version());
1511        // Local cache invalidation (home-side cache, if any).
1512        let dropped_local = cache.invalidate(version, &req.tables);
1513        // Fan out to every registered edge.
1514        let ev = InvalidationEvent {
1515            up_to_version: version,
1516            tables: req.tables.clone(),
1517            committed_at: chrono::Utc::now().to_rfc3339(),
1518            epoch: cache.epoch(),
1519        };
1520        let (sent, pruned) = registry.broadcast(ev).await;
1521        Ok((
1522            200,
1523            serde_json::json!({
1524                "version":         version,
1525                "tables":          req.tables,
1526                "dropped_local":   dropped_local,
1527                "edges_notified":  sent,
1528                "edges_pruned":    pruned,
1529            }),
1530        ))
1531    }
1532
1533    /// Handle `GET /anomalies`. Returns the anomaly detector's
1534    /// recent-events ring buffer as JSON. Optional `?limit=N`
1535    /// query string clamps the response size (default 100, max 1024).
1536    /// Returns 503 when the detector hasn't been attached.
1537    #[cfg(feature = "anomaly-detection")]
1538    async fn handle_anomalies_list(
1539        path: &str,
1540        state: &Arc<AdminState>,
1541    ) -> Result<(u16, serde_json::Value)> {
1542        let limit = parse_limit_query(path, 100, 1024);
1543        let det = match state.anomaly_detector.read().await.clone() {
1544            Some(d) => d,
1545            None => {
1546                return Ok((
1547                    503,
1548                    serde_json::json!({ "error": "anomaly detector not attached" }),
1549                ));
1550            }
1551        };
1552        let events = det.recent_events(limit);
1553        Ok((
1554            200,
1555            serde_json::json!({
1556                "count":     events.len(),
1557                "limit":     limit,
1558                "events":    events,
1559                "buffer_total": det.event_count(),
1560            }),
1561        ))
1562    }
1563
1564    /// `GET /api/analytics` — top queries by call count plus the slow-query
1565    /// count. Returns 503 when analytics is not attached/enabled.
1566    #[cfg(feature = "query-analytics")]
1567    async fn handle_analytics(
1568        path: &str,
1569        state: &Arc<AdminState>,
1570    ) -> Result<(u16, serde_json::Value)> {
1571        use crate::analytics::OrderBy;
1572        let limit = parse_limit_query(path, 50, 1024);
1573        let a = match state.analytics.read().await.clone() {
1574            Some(a) => a,
1575            None => {
1576                return Ok((503, serde_json::json!({ "error": "analytics not enabled" })));
1577            }
1578        };
1579        let top: Vec<serde_json::Value> = a
1580            .top_queries(OrderBy::Calls, limit)
1581            .into_iter()
1582            .map(|s| {
1583                serde_json::json!({
1584                    "fingerprint": s.fingerprint_hash,
1585                    "normalized":  s.normalized,
1586                    "calls":       s.calls,
1587                    "avg_ms":      s.avg_time.as_secs_f64() * 1000.0,
1588                    "p99_ms":      s.p99.as_secs_f64() * 1000.0,
1589                    "rows":        s.rows,
1590                    "errors":      s.errors,
1591                })
1592            })
1593            .collect();
1594        let slow_count = a.slow_queries(limit).len();
1595        Ok((
1596            200,
1597            serde_json::json!({
1598                "limit":            limit,
1599                "top_queries":      top,
1600                "slow_query_count": slow_count,
1601            }),
1602        ))
1603    }
1604
1605    /// Handle `POST /api/shadow`. Body is a JSON `ShadowRequestBody`.
1606    /// Connects to both source and shadow backends, runs the SQL on
1607    /// each, returns a `ShadowExecuteReport` with the diff.
1608    ///
1609    /// Status codes:
1610    ///   200 — both sides ran (report carries pass/fail details)
1611    ///   400 — malformed body
1612    ///   500 — source connect failure (shadow connect failures end up
1613    ///         in the report rather than the HTTP status)
1614    #[cfg(feature = "ha-tr")]
1615    async fn handle_shadow_request(body: Option<&str>) -> Result<(u16, serde_json::Value)> {
1616        use crate::backend::{
1617            tls::default_client_config, BackendClient, BackendConfig, ParamValue, TlsMode,
1618        };
1619        use crate::shadow_execute::shadow_execute;
1620
1621        let raw =
1622            body.ok_or_else(|| ProxyError::Internal("shadow: empty request body".to_string()))?;
1623        let req: ShadowRequestBody = match serde_json::from_str(raw) {
1624            Ok(r) => r,
1625            Err(e) => {
1626                return Ok((
1627                    400,
1628                    serde_json::json!({ "error": format!("invalid body: {}", e) }),
1629                ));
1630            }
1631        };
1632
1633        // Build the two configs from the request. TLS off + 5s
1634        // connect / 30s query timeouts mirror the replay defaults.
1635        let mk_cfg = |host: String,
1636                      port: u16,
1637                      user: Option<String>,
1638                      password: Option<String>,
1639                      database: Option<String>| BackendConfig {
1640            host,
1641            port,
1642            user: user.unwrap_or_else(|| "postgres".into()),
1643            password,
1644            database,
1645            application_name: Some("heliosdb-proxy-shadow".into()),
1646            tls_mode: TlsMode::Disable,
1647            connect_timeout: std::time::Duration::from_secs(5),
1648            query_timeout: std::time::Duration::from_secs(30),
1649            tls_config: default_client_config(),
1650        };
1651        let source_cfg = mk_cfg(
1652            req.source_host,
1653            req.source_port,
1654            req.source_user,
1655            req.source_password,
1656            req.source_database,
1657        );
1658        let shadow_cfg = mk_cfg(
1659            req.shadow_host,
1660            req.shadow_port,
1661            req.shadow_user,
1662            req.shadow_password,
1663            req.shadow_database,
1664        );
1665
1666        // Connect to source. Connect failure here is a real HTTP
1667        // error since we can't even attempt the diff; shadow connect
1668        // failures land inside the report as `shadow_error`.
1669        let mut source = match BackendClient::connect(&source_cfg).await {
1670            Ok(c) => c,
1671            Err(e) => {
1672                return Ok((
1673                    500,
1674                    serde_json::json!({ "error": format!("source connect: {}", e) }),
1675                ));
1676            }
1677        };
1678
1679        let params: Vec<ParamValue> = req
1680            .params
1681            .unwrap_or_default()
1682            .into_iter()
1683            .map(ParamValue::Text)
1684            .collect();
1685
1686        let outcome = shadow_execute(&mut source, &shadow_cfg, &req.sql, &params).await;
1687        source.close().await;
1688
1689        match outcome {
1690            Ok((_qr, report)) => Ok((
1691                200,
1692                serde_json::json!({
1693                    "sql":                report.sql,
1694                    "both_succeeded":     report.both_succeeded,
1695                    "row_count_match":    report.row_count_match,
1696                    "row_hash_match":     report.row_hash_match,
1697                    "primary_elapsed_us": report.primary_elapsed_us,
1698                    "shadow_elapsed_us":  report.shadow_elapsed_us,
1699                    "primary_error":      report.primary_error,
1700                    "shadow_error":       report.shadow_error,
1701                    "is_clean":           report.is_clean(),
1702                }),
1703            )),
1704            Err(e) => Ok((
1705                500,
1706                serde_json::json!({ "error": format!("shadow execute: {}", e) }),
1707            )),
1708        }
1709    }
1710
1711    /// Handle `POST /api/chaos`. Body is a JSON `ChaosRequestBody`.
1712    ///
1713    /// Supported actions (intentionally narrow — the goal is "test
1714    /// the failover machinery without external chaos tooling", not
1715    /// "ship a kitchen-sink fault injector"):
1716    ///
1717    ///   force_unhealthy { target_node }  — flip the node's health flag
1718    ///                                      to false; the failover
1719    ///                                      controller observes this and
1720    ///                                      reroutes traffic.
1721    ///   restore         { target_node }  — flip the node's health flag
1722    ///                                      back to true and clear the
1723    ///                                      override entry.
1724    ///   reset                            — restore every overridden
1725    ///                                      node in one call.
1726    /// `GET /api/circuit` — live per-node circuit-breaker state. Reports each
1727    /// configured node's breaker as `closed` / `open` / `half_open` so an
1728    /// operator can see which backends the breaker has tripped out of rotation.
1729    #[cfg(feature = "circuit-breaker")]
1730    async fn handle_circuit_status(state: &Arc<AdminState>) -> Result<(u16, serde_json::Value)> {
1731        let mgr = match state.circuit_breaker.read().await.clone() {
1732            Some(m) => m,
1733            None => {
1734                return Ok((
1735                    503,
1736                    serde_json::json!({ "error": "circuit breaker not attached" }),
1737                ))
1738            }
1739        };
1740        let nodes = state.config_snapshot.read().await.nodes.clone();
1741        let circuits: Vec<serde_json::Value> = nodes
1742            .iter()
1743            .map(|n| {
1744                let st = mgr.get_breaker(&n.address).get_state();
1745                serde_json::json!({
1746                    "node": n.address,
1747                    "state": format!("{:?}", st).to_lowercase(),
1748                })
1749            })
1750            .collect();
1751        Ok((200, serde_json::json!({ "circuits": circuits })))
1752    }
1753
1754    async fn handle_chaos_request(
1755        body: Option<&str>,
1756        state: &Arc<AdminState>,
1757    ) -> Result<(u16, serde_json::Value)> {
1758        let raw =
1759            body.ok_or_else(|| ProxyError::Internal("chaos: empty request body".to_string()))?;
1760        let action: ChaosAction = match serde_json::from_str(raw) {
1761            Ok(a) => a,
1762            Err(e) => {
1763                return Ok((
1764                    400,
1765                    serde_json::json!({ "error": format!("invalid body: {}", e) }),
1766                ));
1767            }
1768        };
1769        match action {
1770            ChaosAction::ForceUnhealthy { target_node } => {
1771                if let Err(e) = Self::set_node_enabled(state, &target_node, false).await {
1772                    return Ok((404, serde_json::json!({ "error": e.to_string() })));
1773                }
1774                state.chaos_overrides.write().await.insert(
1775                    target_node.clone(),
1776                    ChaosOverride {
1777                        since: chrono::Utc::now().to_rfc3339(),
1778                        kind: "force_unhealthy".to_string(),
1779                        note: "forced unhealthy via chaos endpoint".to_string(),
1780                    },
1781                );
1782                Ok((
1783                    200,
1784                    serde_json::json!({
1785                        "applied":     "force_unhealthy",
1786                        "target_node": target_node,
1787                    }),
1788                ))
1789            }
1790            ChaosAction::Restore { target_node } => {
1791                if let Err(e) = Self::set_node_enabled(state, &target_node, true).await {
1792                    return Ok((404, serde_json::json!({ "error": e.to_string() })));
1793                }
1794                state.chaos_overrides.write().await.remove(&target_node);
1795                Ok((
1796                    200,
1797                    serde_json::json!({
1798                        "restored":    target_node,
1799                    }),
1800                ))
1801            }
1802            ChaosAction::Reset => {
1803                let overrides: Vec<String> =
1804                    state.chaos_overrides.read().await.keys().cloned().collect();
1805                let mut restored = Vec::with_capacity(overrides.len());
1806                for addr in overrides {
1807                    let _ = Self::set_node_enabled(state, &addr, true).await;
1808                    restored.push(addr);
1809                }
1810                state.chaos_overrides.write().await.clear();
1811                Ok((
1812                    200,
1813                    serde_json::json!({
1814                        "reset":      true,
1815                        "restored":   restored,
1816                    }),
1817                ))
1818            }
1819        }
1820    }
1821
1822    /// Handle `GET /plugins`. Returns 503 when no plugin manager is
1823    /// attached, 200 with `Vec<PluginListEntry>` otherwise. Building
1824    /// the response in admin.rs (rather than serialising
1825    /// `plugins::PluginInfo` directly) keeps the plugins module
1826    /// independent of serde — only the wire shape lives here.
1827    #[cfg(feature = "wasm-plugins")]
1828    async fn handle_plugins_list(state: &Arc<AdminState>) -> Result<(u16, serde_json::Value)> {
1829        let pm = match state.plugin_manager.read().await.clone() {
1830            Some(p) => p,
1831            None => {
1832                return Ok((
1833                    503,
1834                    serde_json::json!({ "error": "plugin manager not attached" }),
1835                ));
1836            }
1837        };
1838        let plugins: Vec<PluginListEntry> = pm
1839            .list_plugins()
1840            .into_iter()
1841            .map(|info| PluginListEntry {
1842                name: info.name,
1843                version: info.version,
1844                description: info.description,
1845                hooks: info
1846                    .hooks
1847                    .iter()
1848                    .map(|h| h.export_name().to_string())
1849                    .collect(),
1850                state: format!("{:?}", info.state),
1851                invocations: info.stats.total_calls,
1852                errors: info.stats.error_count,
1853            })
1854            .collect();
1855        Ok((200, serde_json::to_value(plugins)?))
1856    }
1857
1858    #[cfg(not(feature = "wasm-plugins"))]
1859    async fn handle_plugins_list(_state: &Arc<AdminState>) -> Result<(u16, serde_json::Value)> {
1860        Ok((
1861            503,
1862            serde_json::json!({ "error": "wasm-plugins feature not compiled in" }),
1863        ))
1864    }
1865
1866    /// Compute the joined topology view used by `/topology`.
1867    ///
1868    /// `currentPrimary` is the address of the first node whose role
1869    /// is "primary" and whose health entry is `healthy = true`. None
1870    /// is the legitimate answer when failover is in progress.
1871    async fn compute_topology(state: &Arc<AdminState>) -> TopologyResponse {
1872        let health = state.node_health.read().await;
1873        let cfg = state.config_snapshot.read().await;
1874
1875        let mut current_primary: Option<String> = None;
1876        for n in &cfg.nodes {
1877            if n.role.eq_ignore_ascii_case("primary") {
1878                let healthy = health.get(&n.address).map(|h| h.healthy).unwrap_or(false);
1879                if healthy {
1880                    current_primary = Some(n.address.clone());
1881                    break;
1882                }
1883            }
1884        }
1885
1886        let healthy_nodes = health.values().filter(|h| h.healthy).count() as u32;
1887        let unhealthy_nodes = health.values().filter(|h| !h.healthy).count() as u32;
1888        let total_nodes = cfg.nodes.len() as u32;
1889
1890        TopologyResponse {
1891            current_primary,
1892            healthy_nodes,
1893            unhealthy_nodes,
1894            total_nodes,
1895            last_failover_at: None,
1896        }
1897    }
1898
1899    /// Format metrics as Prometheus text format
1900    fn format_prometheus_metrics(metrics: &ServerMetricsSnapshot) -> String {
1901        let mut output = String::new();
1902
1903        output.push_str("# HELP heliosdb_proxy_connections_total Total connections accepted\n");
1904        output.push_str("# TYPE heliosdb_proxy_connections_total counter\n");
1905        output.push_str(&format!(
1906            "heliosdb_proxy_connections_total {}\n",
1907            metrics.connections_accepted
1908        ));
1909
1910        output.push_str("# HELP heliosdb_proxy_connections_closed Total connections closed\n");
1911        output.push_str("# TYPE heliosdb_proxy_connections_closed counter\n");
1912        output.push_str(&format!(
1913            "heliosdb_proxy_connections_closed {}\n",
1914            metrics.connections_closed
1915        ));
1916
1917        output.push_str("# HELP heliosdb_proxy_queries_total Total queries processed\n");
1918        output.push_str("# TYPE heliosdb_proxy_queries_total counter\n");
1919        output.push_str(&format!(
1920            "heliosdb_proxy_queries_total {}\n",
1921            metrics.queries_processed
1922        ));
1923
1924        output.push_str("# HELP heliosdb_proxy_bytes_received_total Total bytes received\n");
1925        output.push_str("# TYPE heliosdb_proxy_bytes_received_total counter\n");
1926        output.push_str(&format!(
1927            "heliosdb_proxy_bytes_received_total {}\n",
1928            metrics.bytes_received
1929        ));
1930
1931        output.push_str("# HELP heliosdb_proxy_bytes_sent_total Total bytes sent\n");
1932        output.push_str("# TYPE heliosdb_proxy_bytes_sent_total counter\n");
1933        output.push_str(&format!(
1934            "heliosdb_proxy_bytes_sent_total {}\n",
1935            metrics.bytes_sent
1936        ));
1937
1938        output.push_str("# HELP heliosdb_proxy_failovers_total Total failovers\n");
1939        output.push_str("# TYPE heliosdb_proxy_failovers_total counter\n");
1940        output.push_str(&format!(
1941            "heliosdb_proxy_failovers_total {}\n",
1942            metrics.failovers
1943        ));
1944
1945        output
1946    }
1947
1948    /// Send HTTP response
1949    async fn send_response(
1950        writer: &mut tokio::net::tcp::WriteHalf<'_>,
1951        status: u16,
1952        status_text: &str,
1953        body: &str,
1954    ) -> Result<()> {
1955        let response = format!(
1956            "HTTP/1.1 {} {}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1957            status,
1958            status_text,
1959            body.len(),
1960            body
1961        );
1962
1963        writer
1964            .write_all(response.as_bytes())
1965            .await
1966            .map_err(|e| ProxyError::Network(format!("Write error: {}", e)))?;
1967
1968        Ok(())
1969    }
1970
1971    /// Send JSON HTTP response
1972    async fn send_json_response<T: Serialize>(
1973        writer: &mut tokio::net::tcp::WriteHalf<'_>,
1974        status: u16,
1975        body: &T,
1976    ) -> Result<()> {
1977        let json = serde_json::to_string(body)
1978            .map_err(|e| ProxyError::Internal(format!("JSON error: {}", e)))?;
1979
1980        let status_text = match status {
1981            200 => "OK",
1982            400 => "Bad Request",
1983            404 => "Not Found",
1984            500 => "Internal Server Error",
1985            503 => "Service Unavailable",
1986            _ => "Unknown",
1987        };
1988
1989        let response = format!(
1990            "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1991            status,
1992            status_text,
1993            json.len(),
1994            json
1995        );
1996
1997        writer
1998            .write_all(response.as_bytes())
1999            .await
2000            .map_err(|e| ProxyError::Network(format!("Write error: {}", e)))?;
2001
2002        Ok(())
2003    }
2004
2005    /// Shutdown the admin server
2006    pub fn shutdown(&self) {
2007        let _ = self.shutdown_tx.send(());
2008    }
2009}
2010
2011impl AdminState {
2012    /// Create new admin state
2013    pub fn new() -> Self {
2014        Self {
2015            node_health: RwLock::new(HashMap::new()),
2016            metrics: RwLock::new(ServerMetricsSnapshot {
2017                connections_accepted: 0,
2018                connections_closed: 0,
2019                queries_processed: 0,
2020                bytes_received: 0,
2021                bytes_sent: 0,
2022                failovers: 0,
2023            }),
2024            active_sessions: RwLock::new(0),
2025            config_snapshot: RwLock::new(ConfigSnapshot {
2026                listen_address: String::new(),
2027                admin_address: String::new(),
2028                tr_enabled: false,
2029                tr_mode: String::new(),
2030                pool_min_connections: 0,
2031                pool_max_connections: 0,
2032                nodes: Vec::new(),
2033            }),
2034            proxy_config: RwLock::new(None),
2035            read_lb_counter: AtomicUsize::new(0),
2036            commands: RwLock::new(HashMap::new()),
2037            #[cfg(feature = "pool-modes")]
2038            pool_manager: RwLock::new(None),
2039            #[cfg(feature = "circuit-breaker")]
2040            circuit_breaker: RwLock::new(None),
2041            #[cfg(feature = "ha-tr")]
2042            replay_engine: RwLock::new(None),
2043            #[cfg(feature = "wasm-plugins")]
2044            plugin_manager: RwLock::new(None),
2045            chaos_overrides: RwLock::new(HashMap::new()),
2046            #[cfg(feature = "anomaly-detection")]
2047            anomaly_detector: RwLock::new(None),
2048            #[cfg(feature = "query-analytics")]
2049            analytics: RwLock::new(None),
2050            #[cfg(feature = "edge-proxy")]
2051            edge_cache: RwLock::new(None),
2052            #[cfg(feature = "edge-proxy")]
2053            edge_registry: RwLock::new(None),
2054            auth_token: RwLock::new(None),
2055            migration: RwLock::new(None),
2056            branch: RwLock::new(None),
2057        }
2058    }
2059
2060    /// Set the admin Bearer token (wired by the server at startup).
2061    pub async fn with_auth_token(&self, token: Option<String>) {
2062        *self.auth_token.write().await = token;
2063    }
2064
2065    /// Attach traffic-mirror info so `/api/migration/status` can report it.
2066    pub async fn with_migration(&self, info: MigrationInfo) {
2067        *self.migration.write().await = Some(info);
2068    }
2069
2070    /// Attach branch-database config so `/api/branch` can provision.
2071    pub async fn with_branch(&self, cfg: crate::config::BranchConfig) {
2072        *self.branch.write().await = Some(cfg);
2073    }
2074
2075    /// Attach the connection pool manager so `/api/pools` reports real
2076    /// per-node pool statistics. Wired by the server at startup.
2077    #[cfg(feature = "pool-modes")]
2078    pub async fn with_pool_manager(&self, manager: Arc<crate::pool::ConnectionPoolManager>) {
2079        *self.pool_manager.write().await = Some(manager);
2080    }
2081
2082    /// Attach the circuit-breaker manager so `/api/circuit` reports live
2083    /// per-node circuit state. Wired by the server at startup.
2084    #[cfg(feature = "circuit-breaker")]
2085    pub async fn with_circuit_breaker(
2086        &self,
2087        manager: Arc<crate::circuit_breaker::CircuitBreakerManager>,
2088    ) {
2089        *self.circuit_breaker.write().await = Some(manager);
2090    }
2091
2092    /// Attach an anomaly detector. Mirror of with_replay_engine /
2093    /// with_plugin_manager — wired by the server at startup.
2094    #[cfg(feature = "anomaly-detection")]
2095    pub async fn with_anomaly_detector(&self, detector: Arc<AnomalyDetector>) {
2096        *self.anomaly_detector.write().await = Some(detector);
2097    }
2098
2099    /// Attach the query-analytics engine so `/api/analytics` can read it.
2100    #[cfg(feature = "query-analytics")]
2101    pub async fn with_analytics(&self, analytics: Arc<crate::analytics::QueryAnalytics>) {
2102        *self.analytics.write().await = Some(analytics);
2103    }
2104
2105    /// Attach edge cache + registry. Server calls this once at
2106    /// startup; both Arcs are the same instances ServerState holds.
2107    #[cfg(feature = "edge-proxy")]
2108    pub async fn with_edge(&self, cache: Arc<EdgeCache>, registry: Arc<EdgeRegistry>) {
2109        *self.edge_cache.write().await = Some(cache);
2110        *self.edge_registry.write().await = Some(registry);
2111    }
2112
2113    /// Attach a time-travel replay engine. Production startup calls
2114    /// this once with a `ReplayEngine` constructed from the proxy's
2115    /// shared `TransactionJournal` + a `BackendConfig` template; the
2116    /// `/api/replay` endpoint returns 503 until this is set.
2117    #[cfg(feature = "ha-tr")]
2118    pub async fn with_replay_engine(&self, engine: Arc<ReplayEngine>) {
2119        *self.replay_engine.write().await = Some(engine);
2120    }
2121
2122    /// Attach a WASM plugin manager. Production startup calls this
2123    /// once with the same Arc held by ProxyServer; the `/plugins`
2124    /// endpoint returns 503 until set.
2125    #[cfg(feature = "wasm-plugins")]
2126    pub async fn with_plugin_manager(&self, manager: Arc<PluginManager>) {
2127        *self.plugin_manager.write().await = Some(manager);
2128    }
2129
2130    /// Set the proxy configuration for SQL routing
2131    pub async fn set_proxy_config(&self, config: ProxyConfig) {
2132        let mut proxy_config = self.proxy_config.write().await;
2133        *proxy_config = Some(config);
2134    }
2135
2136    /// Register a command handler
2137    pub async fn register_command<F>(&self, name: &str, handler: F)
2138    where
2139        F: Fn(&[&str]) -> Result<String> + Send + Sync + 'static,
2140    {
2141        let mut commands = self.commands.write().await;
2142        commands.insert(name.to_string(), Arc::new(handler));
2143    }
2144
2145    /// Execute a command
2146    pub async fn execute_command(&self, name: &str, args: &[&str]) -> Result<String> {
2147        let commands = self.commands.read().await;
2148        match commands.get(name) {
2149            Some(handler) => handler(args),
2150            None => Err(ProxyError::Internal(format!("Unknown command: {}", name))),
2151        }
2152    }
2153}
2154
2155impl Default for AdminState {
2156    fn default() -> Self {
2157        Self::new()
2158    }
2159}
2160
2161// Request and Response types
2162
2163/// SQL execution request
2164#[derive(Debug, Deserialize)]
2165struct SqlRequest {
2166    /// SQL query to execute
2167    query: String,
2168}
2169
2170/// SQL execution response
2171#[derive(Debug, Serialize)]
2172struct SqlResponse {
2173    /// Query type (read/write)
2174    query_type: String,
2175    /// Node the query was routed to
2176    routed_to: String,
2177    /// Role of the target node
2178    node_role: String,
2179    /// Query result from backend
2180    result: serde_json::Value,
2181}
2182
2183#[derive(Serialize)]
2184struct HealthResponse {
2185    status: &'static str,
2186}
2187
2188#[derive(Serialize)]
2189struct ReadinessResponse {
2190    ready: bool,
2191    message: &'static str,
2192}
2193
2194#[derive(Serialize)]
2195struct LivenessResponse {
2196    alive: bool,
2197}
2198
2199#[derive(Serialize)]
2200struct ErrorResponse {
2201    error: String,
2202}
2203
2204#[derive(Serialize)]
2205struct MetricsResponse {
2206    connections_accepted: u64,
2207    connections_closed: u64,
2208    connections_active: u64,
2209    queries_processed: u64,
2210    bytes_received: u64,
2211    bytes_sent: u64,
2212    failovers: u64,
2213}
2214
2215impl From<ServerMetricsSnapshot> for MetricsResponse {
2216    fn from(m: ServerMetricsSnapshot) -> Self {
2217        Self {
2218            connections_accepted: m.connections_accepted,
2219            connections_closed: m.connections_closed,
2220            connections_active: m.connections_accepted.saturating_sub(m.connections_closed),
2221            queries_processed: m.queries_processed,
2222            bytes_received: m.bytes_received,
2223            bytes_sent: m.bytes_sent,
2224            failovers: m.failovers,
2225        }
2226    }
2227}
2228
2229#[derive(Serialize)]
2230struct NodeHealthResponse {
2231    address: String,
2232    healthy: bool,
2233    last_check: String,
2234    failure_count: u32,
2235    last_error: Option<String>,
2236    latency_ms: f64,
2237    replication_lag_bytes: Option<u64>,
2238}
2239
2240impl From<NodeHealth> for NodeHealthResponse {
2241    fn from(h: NodeHealth) -> Self {
2242        Self {
2243            address: h.address,
2244            healthy: h.healthy,
2245            last_check: h.last_check.to_rfc3339(),
2246            failure_count: h.failure_count,
2247            last_error: h.last_error,
2248            latency_ms: h.latency_ms,
2249            replication_lag_bytes: h.replication_lag_bytes,
2250        }
2251    }
2252}
2253
2254#[derive(Serialize)]
2255struct SessionsResponse {
2256    active_sessions: u64,
2257}
2258
2259/// JSON body for `POST /api/edge/register`.
2260#[cfg(feature = "edge-proxy")]
2261#[derive(Debug, Deserialize)]
2262struct EdgeRegisterBody {
2263    edge_id: String,
2264    region: String,
2265    base_url: String,
2266}
2267
2268/// JSON body for `POST /api/edge/invalidate`. `up_to_version` is
2269/// optional — when None, the cache mints the next version stamp
2270/// (effectively "drop everything matching `tables`").
2271#[cfg(feature = "edge-proxy")]
2272#[derive(Debug, Deserialize)]
2273struct EdgeInvalidateBody {
2274    #[serde(default)]
2275    tables: Vec<String>,
2276    #[serde(default)]
2277    up_to_version: Option<u64>,
2278}
2279
2280/// Parse `?limit=N` from a path. Returns clamped value, or `default`
2281/// when the param is missing / unparseable.
2282///
2283/// Shared by the `/anomalies` (`anomaly-detection`) and `/api/analytics`
2284/// (`query-analytics`) handlers, so it must compile whenever *either* feature
2285/// is enabled — not just `anomaly-detection`.
2286#[cfg(any(feature = "anomaly-detection", feature = "query-analytics"))]
2287fn parse_limit_query(path: &str, default: usize, max: usize) -> usize {
2288    let q = match path.find('?') {
2289        Some(i) => &path[i + 1..],
2290        None => return default,
2291    };
2292    for kv in q.split('&') {
2293        let mut it = kv.splitn(2, '=');
2294        if let (Some(k), Some(v)) = (it.next(), it.next()) {
2295            if k == "limit" {
2296                if let Ok(n) = v.parse::<usize>() {
2297                    return n.min(max);
2298                }
2299            }
2300        }
2301    }
2302    default
2303}
2304
2305/// Decode `%XX` percent-escapes in a query-string value. Invalid or
2306/// truncated escapes pass through literally (lenient — the decoded
2307/// value feeds an identifier/URL echo, not a security decision). `+`
2308/// is NOT decoded to space: subscribers send RFC 3986 percent-encoded
2309/// values, not `application/x-www-form-urlencoded`.
2310#[cfg(feature = "edge-proxy")]
2311fn percent_decode(s: &str) -> String {
2312    let bytes = s.as_bytes();
2313    let mut out = Vec::with_capacity(bytes.len());
2314    let mut i = 0;
2315    while i < bytes.len() {
2316        if bytes[i] == b'%' && i + 2 < bytes.len() {
2317            let hi = (bytes[i + 1] as char).to_digit(16);
2318            let lo = (bytes[i + 2] as char).to_digit(16);
2319            if let (Some(hi), Some(lo)) = (hi, lo) {
2320                out.push(((hi as u8) << 4) | lo as u8);
2321                i += 3;
2322                continue;
2323            }
2324        }
2325        out.push(bytes[i]);
2326        i += 1;
2327    }
2328    String::from_utf8_lossy(&out).into_owned()
2329}
2330
2331/// Parse a URL query string (`?k=v&k2=v2`) into a map, percent-decoding
2332/// the values. Keys without a `=` are ignored. Used by the edge SSE
2333/// subscribe route — the only admin route taking query params beyond
2334/// the single `?limit=` that `parse_limit_query` covers.
2335#[cfg(feature = "edge-proxy")]
2336fn parse_query_params(path: &str) -> HashMap<String, String> {
2337    let mut out = HashMap::new();
2338    let q = match path.find('?') {
2339        Some(i) => &path[i + 1..],
2340        None => return out,
2341    };
2342    for kv in q.split('&') {
2343        let mut it = kv.splitn(2, '=');
2344        if let (Some(k), Some(v)) = (it.next(), it.next()) {
2345            if !k.is_empty() {
2346                out.insert(k.to_string(), percent_decode(v));
2347            }
2348        }
2349    }
2350    out
2351}
2352
2353/// JSON body for `POST /api/shadow`.
2354#[cfg(feature = "ha-tr")]
2355#[derive(Debug, Deserialize)]
2356struct ShadowRequestBody {
2357    /// SQL to execute on both sides.
2358    sql: String,
2359    /// Optional text-format parameters interpolated into `sql`. None
2360    /// or empty list runs as a simple_query.
2361    #[serde(default)]
2362    params: Option<Vec<String>>,
2363
2364    /// Source backend (the side whose result the application would
2365    /// see in production).
2366    source_host: String,
2367    source_port: u16,
2368    #[serde(default)]
2369    source_user: Option<String>,
2370    #[serde(default)]
2371    source_password: Option<String>,
2372    #[serde(default)]
2373    source_database: Option<String>,
2374
2375    /// Shadow backend (the side being validated — typically a
2376    /// new-version replica or post-migration schema).
2377    shadow_host: String,
2378    shadow_port: u16,
2379    #[serde(default)]
2380    shadow_user: Option<String>,
2381    #[serde(default)]
2382    shadow_password: Option<String>,
2383    #[serde(default)]
2384    shadow_database: Option<String>,
2385}
2386
2387/// Chaos actions the proxy supports today. Forward-compatible —
2388/// unknown actions deserialise as an error.
2389///
2390/// Wire shape: `{"action":"force_unhealthy","target_node":"..."}`.
2391#[derive(Debug, Deserialize)]
2392#[serde(tag = "action", rename_all = "snake_case")]
2393enum ChaosAction {
2394    /// Mark a node unhealthy until restored (or until reset is
2395    /// called). Triggers the failover path the same way a real
2396    /// health-check failure would.
2397    ForceUnhealthy { target_node: String },
2398    /// Mark a previously-overridden node healthy again.
2399    Restore { target_node: String },
2400    /// Reset every chaos override in one call. Idempotent.
2401    Reset,
2402}
2403
2404/// JSON entry returned by `GET /plugins`. Built in admin.rs so the
2405/// plugins module doesn't need a serde dep.
2406#[cfg(feature = "wasm-plugins")]
2407#[derive(Serialize)]
2408struct PluginListEntry {
2409    name: String,
2410    version: String,
2411    description: String,
2412    /// Hook export names (`pre_query`, `post_query`, `route`, ...).
2413    hooks: Vec<String>,
2414    /// `Loading` | `Running` | `Paused` | `Error(...)` | `Unloading`.
2415    state: String,
2416    invocations: u64,
2417    errors: u64,
2418}
2419
2420/// JSON body for `POST /api/replay`.
2421#[cfg(feature = "ha-tr")]
2422#[derive(Debug, Deserialize)]
2423struct ReplayRequestBody {
2424    /// RFC 3339 inclusive window start.
2425    from: DateTime<Utc>,
2426    /// RFC 3339 inclusive window end.
2427    to: DateTime<Utc>,
2428    /// Target backend host (typically a staging DB).
2429    target_host: String,
2430    /// Target backend port.
2431    target_port: u16,
2432    /// Optional credential overrides — when omitted, the engine uses
2433    /// the template values set at server startup. Production callers
2434    /// targeting a separate staging DB pass these explicitly so the
2435    /// proxy doesn't need to hold staging credentials in its own
2436    /// config.
2437    #[serde(default)]
2438    target_user: Option<String>,
2439    #[serde(default)]
2440    target_password: Option<String>,
2441    #[serde(default)]
2442    target_database: Option<String>,
2443}
2444
2445/// Joined view exposed at `/topology`. Field names use camelCase so
2446/// they map cleanly into the Kubernetes operator's CRD status
2447/// (`HeliosProxyStatus.currentPrimary`, etc).
2448#[derive(Serialize)]
2449struct TopologyResponse {
2450    #[serde(rename = "currentPrimary")]
2451    current_primary: Option<String>,
2452    #[serde(rename = "healthyNodes")]
2453    healthy_nodes: u32,
2454    #[serde(rename = "unhealthyNodes")]
2455    unhealthy_nodes: u32,
2456    #[serde(rename = "totalNodes")]
2457    total_nodes: u32,
2458    /// RFC 3339 timestamp of the last detected primary change.
2459    /// `None` when the proxy hasn't observed a failover since boot.
2460    #[serde(rename = "lastFailoverAt")]
2461    last_failover_at: Option<String>,
2462}
2463
2464#[derive(Serialize)]
2465struct PoolStatsResponse {
2466    node: String,
2467    active_connections: u64,
2468    idle_connections: u64,
2469    pending_requests: u64,
2470    total_connections_created: u64,
2471    total_connections_closed: u64,
2472}
2473
2474#[derive(Serialize)]
2475struct VersionResponse {
2476    version: String,
2477    build_time: String,
2478}
2479
2480#[cfg(test)]
2481mod tests {
2482    use super::*;
2483
2484    #[tokio::test]
2485    async fn test_admin_state_creation() {
2486        let state = AdminState::new();
2487        let sessions = state.active_sessions.read().await;
2488        assert_eq!(*sessions, 0);
2489    }
2490
2491    #[test]
2492    fn test_admin_authorized() {
2493        let h = |s: &str| vec!["GET /topology HTTP/1.1".to_string(), s.to_string()];
2494        assert!(AdminServer::admin_authorized(
2495            &h("Authorization: Bearer s3cret"),
2496            "s3cret"
2497        ));
2498        // case-insensitive header name, exact token
2499        assert!(AdminServer::admin_authorized(
2500            &h("authorization: Bearer s3cret"),
2501            "s3cret"
2502        ));
2503        // wrong token / wrong scheme / missing header all rejected
2504        assert!(!AdminServer::admin_authorized(
2505            &h("Authorization: Bearer nope"),
2506            "s3cret"
2507        ));
2508        assert!(!AdminServer::admin_authorized(
2509            &h("Authorization: Basic s3cret"),
2510            "s3cret"
2511        ));
2512        assert!(!AdminServer::admin_authorized(
2513            &["GET / HTTP/1.1".to_string()],
2514            "s3cret"
2515        ));
2516    }
2517
2518    #[test]
2519    fn test_constant_time_eq_str() {
2520        assert!(constant_time_eq_str("abc", "abc"));
2521        assert!(!constant_time_eq_str("abc", "abd"));
2522        assert!(!constant_time_eq_str("abc", "abcd"));
2523    }
2524
2525    #[tokio::test]
2526    async fn test_readiness_check_no_nodes() {
2527        let state = Arc::new(AdminState::new());
2528        let ready = AdminServer::check_readiness(&state).await;
2529        assert!(!ready);
2530    }
2531
2532    #[tokio::test]
2533    async fn test_readiness_check_with_healthy_node() {
2534        let state = Arc::new(AdminState::new());
2535
2536        {
2537            let mut health = state.node_health.write().await;
2538            health.insert(
2539                "localhost:5432".to_string(),
2540                NodeHealth {
2541                    address: "localhost:5432".to_string(),
2542                    healthy: true,
2543                    last_check: chrono::Utc::now(),
2544                    failure_count: 0,
2545                    last_error: None,
2546                    latency_ms: 1.0,
2547                    replication_lag_bytes: None,
2548                },
2549            );
2550        }
2551
2552        let ready = AdminServer::check_readiness(&state).await;
2553        assert!(ready);
2554    }
2555
2556    #[tokio::test]
2557    async fn test_command_registration() {
2558        let state = AdminState::new();
2559
2560        state
2561            .register_command("test", |args| {
2562                Ok(format!("Test command with {} args", args.len()))
2563            })
2564            .await;
2565
2566        let result = state.execute_command("test", &["arg1", "arg2"]).await;
2567        assert!(result.is_ok());
2568        assert_eq!(result.unwrap(), "Test command with 2 args");
2569    }
2570
2571    #[tokio::test]
2572    async fn test_unknown_command() {
2573        let state = AdminState::new();
2574        let result = state.execute_command("unknown", &[]).await;
2575        assert!(result.is_err());
2576    }
2577
2578    #[test]
2579    fn test_prometheus_metrics_format() {
2580        let metrics = ServerMetricsSnapshot {
2581            connections_accepted: 100,
2582            connections_closed: 50,
2583            queries_processed: 1000,
2584            bytes_received: 50000,
2585            bytes_sent: 100000,
2586            failovers: 2,
2587        };
2588
2589        let output = AdminServer::format_prometheus_metrics(&metrics);
2590        assert!(output.contains("heliosdb_proxy_connections_total 100"));
2591        assert!(output.contains("heliosdb_proxy_queries_total 1000"));
2592        assert!(output.contains("heliosdb_proxy_failovers_total 2"));
2593    }
2594
2595    #[test]
2596    fn test_metrics_response_active_connections() {
2597        let snapshot = ServerMetricsSnapshot {
2598            connections_accepted: 100,
2599            connections_closed: 30,
2600            queries_processed: 500,
2601            bytes_received: 10000,
2602            bytes_sent: 20000,
2603            failovers: 1,
2604        };
2605
2606        let response = MetricsResponse::from(snapshot);
2607        assert_eq!(response.connections_active, 70);
2608    }
2609
2610    /// Helper: build an AdminState with the given (address, role,
2611    /// healthy) tuples seeded into config + node_health.
2612    async fn topology_state(nodes: &[(&str, &str, bool)]) -> Arc<AdminState> {
2613        let state = Arc::new(AdminState::new());
2614        {
2615            let mut cfg = state.config_snapshot.write().await;
2616            cfg.nodes = nodes
2617                .iter()
2618                .map(|(addr, role, _)| NodeSnapshot {
2619                    address: (*addr).to_string(),
2620                    role: (*role).to_string(),
2621                    weight: 100,
2622                    enabled: true,
2623                })
2624                .collect();
2625        }
2626        {
2627            let mut health = state.node_health.write().await;
2628            for (addr, _, healthy) in nodes {
2629                health.insert(
2630                    (*addr).to_string(),
2631                    NodeHealth {
2632                        address: (*addr).to_string(),
2633                        healthy: *healthy,
2634                        last_check: chrono::Utc::now(),
2635                        failure_count: 0,
2636                        last_error: None,
2637                        latency_ms: 1.0,
2638                        replication_lag_bytes: None,
2639                    },
2640                );
2641            }
2642        }
2643        state
2644    }
2645
2646    #[tokio::test]
2647    async fn test_topology_returns_healthy_primary() {
2648        let state = topology_state(&[
2649            ("primary.svc:5432", "primary", true),
2650            ("standby-a.svc:5432", "standby", true),
2651            ("standby-b.svc:5432", "standby", false),
2652        ])
2653        .await;
2654
2655        let topo = AdminServer::compute_topology(&state).await;
2656        assert_eq!(topo.current_primary.as_deref(), Some("primary.svc:5432"));
2657        assert_eq!(topo.healthy_nodes, 2);
2658        assert_eq!(topo.unhealthy_nodes, 1);
2659        assert_eq!(topo.total_nodes, 3);
2660    }
2661
2662    #[tokio::test]
2663    async fn test_topology_no_primary_when_primary_unhealthy() {
2664        // Failover in progress: the configured primary is sick and
2665        // no other node has been promoted yet.
2666        let state = topology_state(&[
2667            ("primary.svc:5432", "primary", false),
2668            ("standby.svc:5432", "standby", true),
2669        ])
2670        .await;
2671
2672        let topo = AdminServer::compute_topology(&state).await;
2673        assert_eq!(topo.current_primary, None);
2674        assert_eq!(topo.healthy_nodes, 1);
2675        assert_eq!(topo.unhealthy_nodes, 1);
2676    }
2677
2678    #[tokio::test]
2679    async fn test_topology_handles_empty_cluster() {
2680        let state = Arc::new(AdminState::new());
2681        let topo = AdminServer::compute_topology(&state).await;
2682        assert_eq!(topo.current_primary, None);
2683        assert_eq!(topo.healthy_nodes, 0);
2684        assert_eq!(topo.unhealthy_nodes, 0);
2685        assert_eq!(topo.total_nodes, 0);
2686    }
2687
2688    #[tokio::test]
2689    async fn test_topology_role_match_is_case_insensitive() {
2690        let state = topology_state(&[("primary.svc:5432", "PRIMARY", true)]).await;
2691        let topo = AdminServer::compute_topology(&state).await;
2692        assert_eq!(topo.current_primary.as_deref(), Some("primary.svc:5432"));
2693    }
2694
2695    #[cfg(feature = "ha-tr")]
2696    #[tokio::test]
2697    async fn test_replay_returns_503_when_engine_unattached() {
2698        let state = Arc::new(AdminState::new());
2699        let body = r#"{
2700            "from": "2026-04-25T10:00:00Z",
2701            "to":   "2026-04-25T11:00:00Z",
2702            "target_host": "127.0.0.1",
2703            "target_port": 5432
2704        }"#;
2705        let (status, value) = AdminServer::handle_replay_request(Some(body), &state)
2706            .await
2707            .expect("handler returns Ok with status code");
2708        assert_eq!(status, 503);
2709        assert_eq!(value["error"], "replay engine not attached");
2710    }
2711
2712    #[cfg(feature = "ha-tr")]
2713    #[tokio::test]
2714    async fn test_replay_400_on_malformed_body() {
2715        let state = Arc::new(AdminState::new());
2716        let (status, _) = AdminServer::handle_replay_request(Some("not json"), &state)
2717            .await
2718            .expect("handler returns Ok with status code");
2719        assert_eq!(status, 400);
2720    }
2721
2722    #[cfg(feature = "ha-tr")]
2723    #[tokio::test]
2724    async fn test_replay_errors_on_empty_body() {
2725        let state = Arc::new(AdminState::new());
2726        let err = AdminServer::handle_replay_request(None, &state).await;
2727        assert!(err.is_err(), "empty body must surface as Err");
2728    }
2729
2730    #[cfg(feature = "wasm-plugins")]
2731    #[tokio::test]
2732    async fn test_plugins_list_returns_503_when_manager_unattached() {
2733        let state = Arc::new(AdminState::new());
2734        let (status, value) = AdminServer::handle_plugins_list(&state)
2735            .await
2736            .expect("handler returns Ok with status code");
2737        assert_eq!(status, 503);
2738        assert_eq!(value["error"], "plugin manager not attached");
2739    }
2740
2741    #[cfg(not(feature = "wasm-plugins"))]
2742    #[tokio::test]
2743    async fn test_plugins_list_503_without_feature() {
2744        let state = Arc::new(AdminState::new());
2745        let (status, _) = AdminServer::handle_plugins_list(&state)
2746            .await
2747            .expect("handler returns Ok");
2748        assert_eq!(status, 503);
2749    }
2750
2751    /// Helper: state with a single healthy node seeded into health.
2752    async fn chaos_state_with_node(addr: &str) -> Arc<AdminState> {
2753        let state = Arc::new(AdminState::new());
2754        state.node_health.write().await.insert(
2755            addr.to_string(),
2756            NodeHealth {
2757                address: addr.to_string(),
2758                healthy: true,
2759                last_check: chrono::Utc::now(),
2760                failure_count: 0,
2761                last_error: None,
2762                latency_ms: 1.0,
2763                replication_lag_bytes: None,
2764            },
2765        );
2766        state
2767    }
2768
2769    #[tokio::test]
2770    async fn test_chaos_force_unhealthy_flips_node_and_records_override() {
2771        let state = chaos_state_with_node("primary.svc:5432").await;
2772        let body = r#"{"action":"force_unhealthy","target_node":"primary.svc:5432"}"#;
2773        let (status, value) = AdminServer::handle_chaos_request(Some(body), &state)
2774            .await
2775            .expect("handler returns Ok");
2776        assert_eq!(status, 200);
2777        assert_eq!(value["applied"], "force_unhealthy");
2778        // Health flag flipped.
2779        assert!(!state.node_health.read().await["primary.svc:5432"].healthy);
2780        // Override recorded.
2781        assert!(state
2782            .chaos_overrides
2783            .read()
2784            .await
2785            .contains_key("primary.svc:5432"));
2786    }
2787
2788    #[tokio::test]
2789    async fn test_chaos_restore_clears_override_and_flips_back() {
2790        let state = chaos_state_with_node("primary.svc:5432").await;
2791        let _ = AdminServer::handle_chaos_request(
2792            Some(r#"{"action":"force_unhealthy","target_node":"primary.svc:5432"}"#),
2793            &state,
2794        )
2795        .await
2796        .unwrap();
2797        let (status, _) = AdminServer::handle_chaos_request(
2798            Some(r#"{"action":"restore","target_node":"primary.svc:5432"}"#),
2799            &state,
2800        )
2801        .await
2802        .unwrap();
2803        assert_eq!(status, 200);
2804        assert!(state.node_health.read().await["primary.svc:5432"].healthy);
2805        assert!(state.chaos_overrides.read().await.is_empty());
2806    }
2807
2808    #[tokio::test]
2809    async fn test_chaos_reset_restores_all_overrides() {
2810        let state = chaos_state_with_node("a:5432").await;
2811        state.node_health.write().await.insert(
2812            "b:5432".to_string(),
2813            NodeHealth {
2814                address: "b:5432".to_string(),
2815                healthy: true,
2816                last_check: chrono::Utc::now(),
2817                failure_count: 0,
2818                last_error: None,
2819                latency_ms: 1.0,
2820                replication_lag_bytes: None,
2821            },
2822        );
2823        for addr in &["a:5432", "b:5432"] {
2824            let body = format!(r#"{{"action":"force_unhealthy","target_node":"{}"}}"#, addr);
2825            let _ = AdminServer::handle_chaos_request(Some(&body), &state)
2826                .await
2827                .unwrap();
2828        }
2829        let (status, value) =
2830            AdminServer::handle_chaos_request(Some(r#"{"action":"reset"}"#), &state)
2831                .await
2832                .unwrap();
2833        assert_eq!(status, 200);
2834        assert_eq!(value["reset"], true);
2835        let restored = value["restored"].as_array().unwrap();
2836        assert_eq!(restored.len(), 2);
2837        // Both nodes back to healthy + overrides cleared.
2838        for addr in &["a:5432", "b:5432"] {
2839            assert!(state.node_health.read().await[*addr].healthy);
2840        }
2841        assert!(state.chaos_overrides.read().await.is_empty());
2842    }
2843
2844    #[tokio::test]
2845    async fn test_chaos_force_unhealthy_404s_when_node_unknown() {
2846        let state = Arc::new(AdminState::new());
2847        let body = r#"{"action":"force_unhealthy","target_node":"missing.svc:5432"}"#;
2848        let (status, _) = AdminServer::handle_chaos_request(Some(body), &state)
2849            .await
2850            .expect("handler returns Ok");
2851        assert_eq!(status, 404);
2852    }
2853
2854    #[tokio::test]
2855    async fn test_chaos_400_on_malformed_body() {
2856        let state = Arc::new(AdminState::new());
2857        let (status, _) = AdminServer::handle_chaos_request(Some("not json"), &state)
2858            .await
2859            .expect("handler returns Ok");
2860        assert_eq!(status, 400);
2861    }
2862
2863    #[tokio::test]
2864    async fn test_chaos_400_on_unknown_action() {
2865        let state = Arc::new(AdminState::new());
2866        let body = r#"{"action":"format_disk","target_node":"x"}"#;
2867        let (status, _) = AdminServer::handle_chaos_request(Some(body), &state)
2868            .await
2869            .expect("handler returns Ok");
2870        assert_eq!(status, 400);
2871    }
2872
2873    #[cfg(feature = "ha-tr")]
2874    #[tokio::test]
2875    async fn test_shadow_400_on_malformed_body() {
2876        let (status, _) = AdminServer::handle_shadow_request(Some("not json"))
2877            .await
2878            .expect("handler returns Ok");
2879        assert_eq!(status, 400);
2880    }
2881
2882    #[cfg(feature = "ha-tr")]
2883    #[tokio::test]
2884    async fn test_shadow_500_on_source_unreachable() {
2885        // Address that nothing is listening on (port 1 = tcpmux,
2886        // refused by everything reasonable).
2887        let body = r#"{
2888            "sql": "SELECT 1",
2889            "source_host": "127.0.0.1",
2890            "source_port": 1,
2891            "shadow_host": "127.0.0.1",
2892            "shadow_port": 1
2893        }"#;
2894        let (status, value) = AdminServer::handle_shadow_request(Some(body))
2895            .await
2896            .expect("handler returns Ok");
2897        assert_eq!(status, 500);
2898        let err = value["error"].as_str().expect("error field");
2899        assert!(
2900            err.contains("source connect"),
2901            "expected source connect error, got {}",
2902            err
2903        );
2904    }
2905
2906    #[cfg(feature = "ha-tr")]
2907    #[tokio::test]
2908    async fn test_shadow_errors_on_empty_body() {
2909        let err = AdminServer::handle_shadow_request(None).await;
2910        assert!(err.is_err(), "empty body must surface as Err");
2911    }
2912
2913    #[cfg(feature = "anomaly-detection")]
2914    #[tokio::test]
2915    async fn test_anomalies_returns_503_when_detector_unattached() {
2916        let state = Arc::new(AdminState::new());
2917        let (status, value) = AdminServer::handle_anomalies_list("/anomalies", &state)
2918            .await
2919            .expect("handler returns Ok");
2920        assert_eq!(status, 503);
2921        assert_eq!(value["error"], "anomaly detector not attached");
2922    }
2923
2924    #[cfg(feature = "anomaly-detection")]
2925    #[tokio::test]
2926    async fn test_anomalies_returns_attached_detector_events() {
2927        use crate::anomaly::{AnomalyConfig, AnomalyDetector, QueryObservation};
2928        let state = Arc::new(AdminState::new());
2929        let det = Arc::new(AnomalyDetector::new(AnomalyConfig::default()));
2930        // Seed a SQL injection event into the detector.
2931        let _ = det.record_query(&QueryObservation {
2932            tenant: "test".into(),
2933            fingerprint: "fp".into(),
2934            sql: "SELECT * FROM users WHERE id = 1 OR 1=1 --".into(),
2935            timestamp: std::time::Instant::now(),
2936        });
2937        state.with_anomaly_detector(det.clone()).await;
2938
2939        let (status, value) = AdminServer::handle_anomalies_list("/anomalies", &state)
2940            .await
2941            .expect("handler returns Ok");
2942        assert_eq!(status, 200);
2943        let count = value["count"].as_u64().expect("count field");
2944        assert!(count > 0, "expected at least one event, got {}", count);
2945    }
2946
2947    #[cfg(feature = "anomaly-detection")]
2948    #[tokio::test]
2949    async fn test_anomalies_limit_query_string_respected() {
2950        use crate::anomaly::{AnomalyConfig, AnomalyDetector, QueryObservation};
2951        let state = Arc::new(AdminState::new());
2952        let det = Arc::new(AnomalyDetector::new(AnomalyConfig::default()));
2953        for i in 0..50 {
2954            let fp = format!("fp{}", i);
2955            let _ = det.record_query(&QueryObservation {
2956                tenant: "test".into(),
2957                fingerprint: fp,
2958                sql: "SELECT 1".into(),
2959                timestamp: std::time::Instant::now(),
2960            });
2961        }
2962        state.with_anomaly_detector(det).await;
2963
2964        let (status, value) = AdminServer::handle_anomalies_list("/anomalies?limit=5", &state)
2965            .await
2966            .expect("handler returns Ok");
2967        assert_eq!(status, 200);
2968        assert_eq!(value["limit"].as_u64().unwrap(), 5);
2969        assert_eq!(value["events"].as_array().unwrap().len(), 5);
2970    }
2971
2972    #[cfg(any(feature = "anomaly-detection", feature = "query-analytics"))]
2973    #[test]
2974    fn test_parse_limit_query_helper() {
2975        assert_eq!(parse_limit_query("/anomalies", 100, 1024), 100);
2976        assert_eq!(parse_limit_query("/anomalies?limit=42", 100, 1024), 42);
2977        assert_eq!(parse_limit_query("/anomalies?limit=99999", 100, 1024), 1024);
2978        assert_eq!(parse_limit_query("/anomalies?limit=abc", 100, 1024), 100);
2979        assert_eq!(
2980            parse_limit_query("/anomalies?other=x&limit=7", 100, 1024),
2981            7
2982        );
2983    }
2984
2985    #[cfg(feature = "edge-proxy")]
2986    async fn edge_state() -> Arc<AdminState> {
2987        use crate::edge::{EdgeCache, EdgeRegistry};
2988        use std::time::Duration;
2989        let s = Arc::new(AdminState::new());
2990        let cache = Arc::new(EdgeCache::new(100));
2991        let registry = Arc::new(EdgeRegistry::new(8, Duration::from_secs(60)));
2992        s.with_edge(cache, registry).await;
2993        s
2994    }
2995
2996    #[cfg(feature = "edge-proxy")]
2997    #[tokio::test]
2998    async fn test_edge_status_returns_empty_lists_initially() {
2999        let s = edge_state().await;
3000        let (status, value) = AdminServer::handle_edge_status(&s)
3001            .await
3002            .expect("handler returns Ok");
3003        assert_eq!(status, 200);
3004        assert_eq!(value["edge_count"].as_u64().unwrap(), 0);
3005        assert_eq!(value["registered"].as_array().unwrap().len(), 0);
3006        assert!(value["cache"].is_object(), "cache stats present");
3007    }
3008
3009    #[cfg(feature = "edge-proxy")]
3010    #[tokio::test]
3011    async fn test_edge_register_then_status_lists_edge() {
3012        let s = edge_state().await;
3013        let body = r#"{"edge_id":"e1","region":"us-east","base_url":"https://e1.svc"}"#;
3014        let (status, _) = AdminServer::handle_edge_register(Some(body), &s)
3015            .await
3016            .expect("handler ok");
3017        assert_eq!(status, 201);
3018        let (status2, value2) = AdminServer::handle_edge_status(&s).await.unwrap();
3019        assert_eq!(status2, 200);
3020        assert_eq!(value2["edge_count"].as_u64().unwrap(), 1);
3021        assert_eq!(value2["registered"][0]["edge_id"].as_str().unwrap(), "e1");
3022    }
3023
3024    #[cfg(feature = "edge-proxy")]
3025    #[tokio::test]
3026    async fn test_edge_register_400_on_malformed_body() {
3027        let s = edge_state().await;
3028        let (status, _) = AdminServer::handle_edge_register(Some("not json"), &s)
3029            .await
3030            .expect("handler ok");
3031        assert_eq!(status, 400);
3032    }
3033
3034    #[cfg(feature = "edge-proxy")]
3035    #[tokio::test]
3036    async fn test_edge_invalidate_drops_local_cache_entries() {
3037        use crate::edge::{CacheEntry, CacheKey};
3038        use std::time::{Duration, Instant};
3039        let s = edge_state().await;
3040        // Seed an entry into the local cache.
3041        let cache = s.edge_cache.read().await.clone().unwrap();
3042        cache.insert(
3043            CacheKey::new("fp1", "p1"),
3044            CacheEntry {
3045                version: 1,
3046                response_bytes: bytes::Bytes::from_static(b"row"),
3047                tables: vec!["users".into()],
3048                expires_at: Instant::now() + Duration::from_secs(60),
3049            },
3050        );
3051        assert!(cache.get(&CacheKey::new("fp1", "p1")).is_some());
3052
3053        let body = r#"{"tables":["users"]}"#;
3054        let (status, value) = AdminServer::handle_edge_invalidate(Some(body), &s)
3055            .await
3056            .expect("handler ok");
3057        assert_eq!(status, 200);
3058        assert_eq!(value["dropped_local"].as_u64().unwrap(), 1);
3059        assert!(cache.get(&CacheKey::new("fp1", "p1")).is_none());
3060    }
3061
3062    #[cfg(feature = "edge-proxy")]
3063    #[tokio::test]
3064    async fn test_edge_invalidate_503_when_cache_unattached() {
3065        let s = Arc::new(AdminState::new());
3066        let body = r#"{"tables":["users"]}"#;
3067        let (status, _) = AdminServer::handle_edge_invalidate(Some(body), &s)
3068            .await
3069            .expect("handler ok");
3070        assert_eq!(status, 503);
3071    }
3072
3073    // ---- edge SSE subscribe: query parsing + gate behaviour ----
3074
3075    #[cfg(feature = "edge-proxy")]
3076    #[test]
3077    fn test_parse_query_params_decodes_and_defaults() {
3078        let p = parse_query_params(
3079            "/api/edge/subscribe?edge_id=e1&region=us-east&base_url=http%3A%2F%2Fe1.svc%3A9090",
3080        );
3081        assert_eq!(p.get("edge_id").unwrap(), "e1");
3082        assert_eq!(p.get("region").unwrap(), "us-east");
3083        assert_eq!(p.get("base_url").unwrap(), "http://e1.svc:9090");
3084        // No query string at all -> empty map.
3085        assert!(parse_query_params("/api/edge/subscribe").is_empty());
3086        // Key without '=' is ignored; truncated / invalid escapes pass
3087        // through literally.
3088        let p2 = parse_query_params("/x?flag&edge_id=a%2&b=%zz");
3089        assert!(!p2.contains_key("flag"));
3090        assert_eq!(p2.get("edge_id").unwrap(), "a%2");
3091        assert_eq!(p2.get("b").unwrap(), "%zz");
3092    }
3093
3094    /// Drive one raw HTTP request through `handle_connection` over a
3095    /// real localhost socket (the SSE route is intercepted there,
3096    /// before `route_request`, so handler-level tests can't reach it).
3097    #[cfg(feature = "edge-proxy")]
3098    async fn connect_admin(state: Arc<AdminState>) -> tokio::net::TcpStream {
3099        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3100        let addr = listener.local_addr().unwrap();
3101        tokio::spawn(async move {
3102            let (stream, peer) = listener.accept().await.unwrap();
3103            let _ = AdminServer::handle_connection(stream, peer, state).await;
3104        });
3105        tokio::net::TcpStream::connect(addr).await.unwrap()
3106    }
3107
3108    /// Read from `stream` until `needle` appears (or a 2s deadline)
3109    /// and return everything read so far.
3110    #[cfg(feature = "edge-proxy")]
3111    async fn read_until(stream: &mut tokio::net::TcpStream, needle: &str) -> String {
3112        let mut buf = Vec::new();
3113        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
3114        loop {
3115            let mut chunk = [0u8; 1024];
3116            let n = tokio::time::timeout_at(deadline, stream.read(&mut chunk))
3117                .await
3118                .expect("read timed out")
3119                .expect("read failed");
3120            if n == 0 {
3121                break;
3122            }
3123            buf.extend_from_slice(&chunk[..n]);
3124            if String::from_utf8_lossy(&buf).contains(needle) {
3125                break;
3126            }
3127        }
3128        String::from_utf8_lossy(&buf).into_owned()
3129    }
3130
3131    #[cfg(feature = "edge-proxy")]
3132    #[tokio::test]
3133    async fn test_edge_subscribe_unauthenticated_gets_401() {
3134        let s = edge_state().await;
3135        *s.auth_token.write().await = Some("s3cret".to_string());
3136        let mut c = connect_admin(s).await;
3137        c.write_all(b"GET /api/edge/subscribe?edge_id=e1 HTTP/1.1\r\n\r\n")
3138            .await
3139            .unwrap();
3140        let resp = read_until(&mut c, "401").await;
3141        assert!(resp.starts_with("HTTP/1.1 401"), "got: {resp}");
3142    }
3143
3144    #[cfg(feature = "edge-proxy")]
3145    #[tokio::test]
3146    async fn test_edge_subscribe_400_without_edge_id() {
3147        let s = edge_state().await;
3148        let mut c = connect_admin(s).await;
3149        c.write_all(b"GET /api/edge/subscribe?region=us-east HTTP/1.1\r\n\r\n")
3150            .await
3151            .unwrap();
3152        let resp = read_until(&mut c, "edge_id").await;
3153        assert!(resp.starts_with("HTTP/1.1 400"), "got: {resp}");
3154        assert!(resp.contains("edge_id query parameter is required"));
3155    }
3156
3157    #[cfg(feature = "edge-proxy")]
3158    #[tokio::test]
3159    async fn test_edge_subscribe_503_when_registry_full() {
3160        use crate::edge::{EdgeCache, EdgeRegistry};
3161        let s = Arc::new(AdminState::new());
3162        s.with_edge(
3163            Arc::new(EdgeCache::new(16)),
3164            Arc::new(EdgeRegistry::new(1, std::time::Duration::from_secs(60))),
3165        )
3166        .await;
3167        let registry = s.edge_registry.read().await.clone().unwrap();
3168        // Occupy the single slot; the receiver must stay alive so the
3169        // subscribe below hits CapacityExceeded, not a pruned slot.
3170        let _held = registry.register("occupant", "r", "u", "ts").unwrap();
3171        let mut c = connect_admin(s).await;
3172        c.write_all(b"GET /api/edge/subscribe?edge_id=e2 HTTP/1.1\r\n\r\n")
3173            .await
3174            .unwrap();
3175        let resp = read_until(&mut c, "full").await;
3176        assert!(resp.starts_with("HTTP/1.1 503"), "got: {resp}");
3177    }
3178
3179    #[cfg(feature = "edge-proxy")]
3180    #[tokio::test]
3181    async fn test_edge_subscribe_streams_preamble_and_invalidations() {
3182        let s = edge_state().await;
3183        *s.auth_token.write().await = Some("s3cret".to_string());
3184        let registry = s.edge_registry.read().await.clone().unwrap();
3185        let mut c = connect_admin(s).await;
3186        c.write_all(
3187            b"GET /api/edge/subscribe?edge_id=e1&region=eu&base_url=http%3A%2F%2Fe1 HTTP/1.1\r\nAuthorization: Bearer s3cret\r\n\r\n",
3188        )
3189        .await
3190        .unwrap();
3191        let preamble = read_until(&mut c, "\r\n\r\n").await;
3192        assert!(preamble.starts_with("HTTP/1.1 200 OK"), "got: {preamble}");
3193        assert!(preamble.contains("Content-Type: text/event-stream"));
3194
3195        // Registration (with percent-decoded params) happened before the
3196        // preamble was written, so it's visible once we've read it — and
3197        // the receiver is held open by the handler.
3198        let nodes = registry.list();
3199        assert_eq!(nodes.len(), 1);
3200        assert_eq!(nodes[0].edge_id, "e1");
3201        assert_eq!(nodes[0].region, "eu");
3202        assert_eq!(nodes[0].base_url, "http://e1");
3203
3204        // The subscribe-time hello frame arrives first: an invalidate
3205        // event carrying the home's per-boot epoch (never 0) so a
3206        // reconnecting edge detects restarts and re-syncs immediately.
3207        // (Its bytes may already have ridden along with the preamble
3208        // read — accumulate until the frame's closing brace.)
3209        let mut hello = preamble;
3210        if !hello.contains("}\n\n") {
3211            hello.push_str(&read_until(&mut c, "}\n\n").await);
3212        }
3213        assert!(hello.contains("event: invalidate"), "got: {hello}");
3214        assert!(hello.contains("\"epoch\":"), "got: {hello}");
3215        assert!(
3216            !hello.contains("\"epoch\":0}"),
3217            "hello epoch must be non-zero: {hello}"
3218        );
3219
3220        // A broadcast arrives as an SSE invalidate frame.
3221        let (sent, _) = registry
3222            .broadcast(InvalidationEvent {
3223                up_to_version: 7,
3224                tables: vec!["users".into()],
3225                committed_at: "ts".into(),
3226                epoch: 0,
3227            })
3228            .await;
3229        assert_eq!(sent, 1);
3230        let frame = read_until(&mut c, "\"up_to_version\":7").await;
3231        assert!(frame.contains("event: invalidate"), "got: {frame}");
3232        assert!(frame.contains("\"up_to_version\":7"), "got: {frame}");
3233    }
3234}