Skip to main content

netget/server/couchdb/
mod.rs

1//! CouchDB server implementation
2//!
3//! Implements a CouchDB-compatible HTTP/JSON REST API on port 5984.
4//! The LLM controls all database operations, document management, views, changes feed,
5//! and maintains "virtual" data through conversation context.
6
7pub mod actions;
8
9use std::collections::HashMap;
10use std::net::SocketAddr;
11use std::sync::Arc;
12
13use bytes::Bytes;
14use http_body_util::{BodyExt, Full};
15use hyper::body::Incoming;
16use hyper::server::conn::http1;
17use hyper::service::service_fn;
18use hyper::{Request, Response};
19use hyper_util::rt::TokioIo;
20use tokio::sync::mpsc;
21use tracing::{debug, error, info};
22
23use crate::llm::ollama_client::OllamaClient;
24use crate::llm::ActionResult;
25use crate::logging::emit::Log;
26use crate::server::connection::ConnectionId;
27use crate::server::CouchDbProtocol;
28use crate::state::app_state::AppState;
29use crate::{console_error, console_info};
30
31/// How much of a request body is read before the request is refused with 413.
32///
33/// Same value and same reasoning as `http_common::MAX_REQUEST_BODY_BYTES`, defined locally
34/// because `server::http_common` is gated on `any(feature = "http", "http2", "oauth2", …)`
35/// and `couchdb` is not in that list — the same exit `xmlrpc` takes. Adding `couchdb` to the
36/// gate in `src/server/mod.rs` would let this share the constant.
37///
38/// The body is buffered whole and then embedded in an LLM prompt, so there is no legitimate
39/// use for a large one: a model cannot read 8 MB, and every byte past a few kilobytes is cost
40/// without benefit. Without a cap the buffer is whatever the peer chooses to send.
41pub const MAX_REQUEST_BODY_BYTES: usize = 8 * 1024 * 1024;
42
43/// How long a peer that has connected and produced nothing may hold a slot.
44///
45/// HTTP is client-speaks-first: the server says nothing until a request line arrives, so a peer
46/// that has completed the TCP handshake and sent no byte has asked nothing and negotiated
47/// nothing. That state carries no protocol yet, which is why this number is the same across
48/// netget's HTTP-shaped servers while the idle bound below is not. Apache's `mod_reqtimeout`
49/// gives the request header 20s and nginx's `client_header_timeout` 60s; 30s sits between the
50/// two deployed norms.
51///
52/// hyper's own `header_read_timeout` is **not** this bound: its 30-second default is inert
53/// unless `http1::Builder::timer` is also set, which nothing here does — hyper downgrades a
54/// defaulted duration to `None` when no timer is present and applies no deadline at all.
55const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
56
57/// How long a connection with nothing outstanding may sit idle between requests.
58///
59/// nginx's `keepalive_timeout` default. CouchDB replication and `_changes` consumers hold one
60/// pooled connection across a whole replication, which looks like an argument for minutes, and is
61/// not: a `_changes` feed held open — continuous or long-poll — is a request **in flight**, and
62/// the watchdog reads
63/// [`ConnectionActivity`](crate::server::accept_bounded::ConnectionActivity), which reports a
64/// connection with work in flight — a model round-trip, or an event a `manual` rule parked for a
65/// human — as not idle at all. What this bound measures is a pooled connection with nothing
66/// outstanding, and reopening one of those costs a loopback handshake.
67const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(75);
68
69/// Concurrent connections this server admits.
70///
71/// Below the shared `DEFAULT_MAX_CONNECTIONS` of 256 on purpose: each admitted connection may
72/// buffer one body of up to [`MAX_REQUEST_BODY_BYTES`] (8 MiB), which is the largest
73/// per-connection cost in netget's HTTP family, and the cap is what turns that per-connection
74/// bound into a total one. 128 holds the worst case to the same ~1 GiB ceiling the 4 MiB and
75/// 64 KiB servers reach at 256, so the number that varies is the one that has to.
76const MAX_CONNECTIONS: usize = 128;
77
78/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
79///
80/// `503 Service Unavailable` with a `Retry-After`, written directly onto the socket because the
81/// peer has not sent a request line for hyper to answer. Fixed bytes: nothing derived from an
82/// error reaches the wire (see `crate::utils::wire_failure`).
83const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
84    Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
85
86/// CouchDB server that delegates all operations to LLM
87pub struct CouchDbServer;
88
89impl CouchDbServer {
90    /// Spawn the CouchDB server with integrated LLM actions
91    #[allow(clippy::too_many_arguments)]
92    pub async fn spawn_with_llm_actions(
93        listen_addr: SocketAddr,
94        llm_client: OllamaClient,
95        app_state: Arc<AppState>,
96        status_tx: mpsc::UnboundedSender<String>,
97        server_id: crate::state::ServerId,
98        enable_auth: bool,
99        admin_username: String,
100        admin_password: String,
101    ) -> anyhow::Result<SocketAddr> {
102        let listener =
103            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
104        let local_addr = listener.local_addr()?;
105        console_info!(
106            status_tx,
107            "CouchDB server listening on {} (auth: {})",
108            local_addr,
109            if enable_auth { "enabled" } else { "disabled" }
110        );
111
112        let protocol = Arc::new(CouchDbProtocol::new());
113        let auth_config = Arc::new(AuthConfig {
114            enabled: enable_auth,
115            admin_username,
116            admin_password,
117        });
118
119        // Spawn server loop
120        let task_registrar = app_state.clone();
121        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
122        let accept_handle = tokio::spawn(async move {
123            loop {
124                match crate::server::accept_bounded::accept_bounded(
125                    &listener,
126                    &limiter,
127                    CONNECTION_CAP_REFUSAL,
128                    "CouchDB",
129                    Some(&status_tx),
130                )
131                .await
132                {
133                    Ok((stream, remote_addr, permit)) => {
134                        let connection_id =
135                            ConnectionId::new(app_state.get_next_unified_id().await);
136                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
137                        info!("CouchDB connection {} from {}", connection_id, remote_addr);
138                        Log::new(Some(&status_tx))
139                            .info(format!("CouchDB connection from {}", remote_addr));
140
141                        // Add connection to ServerInstance
142                        use crate::state::server::{
143                            ConnectionState as ServerConnectionState, ConnectionStatus,
144                            ProtocolConnectionInfo,
145                        };
146                        let now = crate::utils::clock::Instant::now();
147                        let conn_state = ServerConnectionState {
148                            id: connection_id,
149                            remote_addr,
150                            local_addr: local_addr_conn,
151                            bytes_sent: 0,
152                            bytes_received: 0,
153                            packets_sent: 0,
154                            packets_received: 0,
155                            last_activity: now,
156                            status: ConnectionStatus::Active,
157                            status_changed_at: now,
158                            protocol_info: ProtocolConnectionInfo::empty(),
159                        };
160                        app_state
161                            .add_connection_to_server(server_id, conn_state)
162                            .await;
163                        let _ = status_tx.send("__UPDATE_UI__".to_string());
164
165                        let llm_client_clone = llm_client.clone();
166                        let app_state_clone = app_state.clone();
167                        let status_tx_clone = status_tx.clone();
168                        let protocol_clone = protocol.clone();
169                        let auth_config_clone = auth_config.clone();
170
171                        // Spawn a task to handle this connection
172                        // Tracked, not detached: stop_server must abort this task too.
173                        let task_owner = app_state.clone();
174                        task_owner
175                            .spawn_server_task(server_id, async move {
176                                // Held for the life of the connection, so the cap counts live
177                                // clients rather than accepts.
178                                let _permit = permit;
179
180                                // First-byte bound, before hyper sees the socket.
181                                //
182                                // hyper owns every read once `serve_connection` starts, and a
183                                // deadline on those reads would be wrong here rather than merely
184                                // awkward: hyper keeps polling the connection for more input
185                                // while a request is being answered, so such a deadline would
186                                // fire in the middle of a model round-trip. `peek` waits for data
187                                // without consuming it, so the request line is still there for
188                                // hyper afterwards, and it bounds exactly the case that needs
189                                // bounding - a peer that has connected and sent nothing at all.
190                                let spoke = matches!(
191                                    tokio::time::timeout(
192                                        FIRST_BYTE_READ_TIMEOUT,
193                                        stream.peek(&mut [0u8; 1]),
194                                    )
195                                    .await,
196                                    Ok(Ok(n)) if n > 0
197                                );
198
199                                let io = TokioIo::new(stream);
200
201                                // Clone for service closure
202                                let status_for_service = status_tx_clone.clone();
203                                let app_state_for_service = app_state_clone.clone();
204
205                                // Tracks whether this connection is answering anything. A
206                                // request waiting on the model, or parked for a human by a
207                                // `manual` rule, holds the count above zero, so the idle watchdog
208                                // below cannot close the connection the answer belongs to however
209                                // long it takes - only genuine silence counts.
210                                let activity = std::sync::Arc::new(
211                                    crate::server::accept_bounded::ConnectionActivity::new(),
212                                );
213                                let activity_for_service = std::sync::Arc::clone(&activity);
214
215                                // Create a service that handles CouchDB requests with LLM
216                                let service = service_fn(move |req: Request<Incoming>| {
217                                    let llm_clone = llm_client_clone.clone();
218                                    let state_clone = app_state_for_service.clone();
219                                    let status_clone = status_for_service.clone();
220                                    let protocol_clone = protocol_clone.clone();
221                                    let auth_clone = auth_config_clone.clone();
222                                    let activity = std::sync::Arc::clone(&activity_for_service);
223                                    async move {
224                                        let _busy = activity.busy();
225                                        handle_couchdb_request_with_llm(
226                                            req,
227                                            connection_id,
228                                            llm_clone,
229                                            state_clone,
230                                            status_clone,
231                                            protocol_clone,
232                                            server_id,
233                                            auth_clone,
234                                        )
235                                        .await
236                                    }
237                                });
238
239                                // Serve HTTP/1 on this connection, bounded at both ends.
240                                if !spoke {
241                                    Log::new(Some(&status_tx_clone)).debug(format!(
242                                        "CouchDB peer {} sent nothing for {}s; closing before \
243                                         any request",
244                                        remote_addr,
245                                        FIRST_BYTE_READ_TIMEOUT.as_secs()
246                                    ));
247                                } else {
248                                    let conn = http1::Builder::new().serve_connection(io, service);
249                                    tokio::pin!(conn);
250                                    tokio::select! {
251                                        result = &mut conn => {
252                                            if let Err(err) = result {
253                                                error!(
254                                                    "Error serving CouchDB connection: \
255                                                     {:?}",
256                                                    err
257                                                );
258                                            }
259                                        }
260                                        _ = crate::server::accept_bounded::watch_idle(
261                                            std::sync::Arc::clone(&activity),
262                                            IDLE_BETWEEN_REQUESTS_TIMEOUT,
263                                        ) => {
264                                            Log::new(Some(&status_tx_clone)).debug(format!(
265                                                "CouchDB connection {} idle for {}s; closing",
266                                                connection_id,
267                                                IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
268                                            ));
269                                        }
270                                    }
271                                }
272
273                                // Mark connection as closed
274                                app_state_clone
275                                    .close_connection_on_server(server_id, connection_id)
276                                    .await;
277                                Log::new(Some(&status_tx_clone))
278                                    .info(format!("CouchDB connection {} closed", connection_id));
279                                let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
280                            })
281                            .await;
282                    }
283                    Err(e) => {
284                        console_error!(status_tx, "Failed to accept CouchDB connection: {}", e);
285                        break;
286                    }
287                }
288            }
289        });
290
291        // Register the accept loop so stop_server can abort it and release the port.
292        task_registrar
293            .register_server_task(server_id, accept_handle)
294            .await;
295
296        Ok(local_addr)
297    }
298}
299
300/// Authentication configuration
301struct AuthConfig {
302    enabled: bool,
303    admin_username: String,
304    admin_password: String,
305}
306
307/// Handle a single CouchDB request with LLM
308#[allow(clippy::too_many_arguments)]
309async fn handle_couchdb_request_with_llm(
310    req: Request<Incoming>,
311    _connection_id: ConnectionId,
312    llm_client: OllamaClient,
313    app_state: Arc<AppState>,
314    status_tx: mpsc::UnboundedSender<String>,
315    protocol: Arc<CouchDbProtocol>,
316    server_id: crate::state::ServerId,
317    auth_config: Arc<AuthConfig>,
318) -> Result<Response<Full<Bytes>>, std::convert::Infallible> {
319    // Extract request details
320    let method = req.method().to_string();
321    let uri = req.uri().to_string();
322    let path = req.uri().path().to_string();
323    let query = req.uri().query().map(|q| q.to_string());
324
325    // Extract authorization header
326    let authorization = req
327        .headers()
328        .get("authorization")
329        .and_then(|h| h.to_str().ok())
330        .map(|s| s.to_string());
331
332    // Check authentication if enabled
333    if auth_config.enabled {
334        if let Some(auth_header) = &authorization {
335            if !check_basic_auth(
336                auth_header,
337                &auth_config.admin_username,
338                &auth_config.admin_password,
339            ) {
340                Log::new(Some(&status_tx)).debug("CouchDB authentication failed");
341                return Ok(create_auth_required_response());
342            }
343        } else {
344            // No auth header provided
345            Log::new(Some(&status_tx)).debug("CouchDB authentication required");
346            return Ok(create_auth_required_response());
347        }
348    }
349
350    // Read the JSON body, bounded.
351    //
352    // `Incoming` has no default limit, so this used to buffer whatever an unauthenticated
353    // peer chose to send — one `POST /db/_bulk_docs` was enough to exhaust the process. The
354    // body is then embedded whole in an LLM prompt, so there is no legitimate large one
355    // either. `Limited` errors as soon as the cap is passed rather than after buffering it,
356    // and 413 says exactly what happened. This is the bound `http`, `http2` and `xmlrpc`
357    // already had; CouchDB and Elasticsearch were the two HTTP servers still without it.
358    //
359    // An unreadable body must **not** fall through as empty, which is what the old `Err` arm
360    // did: the handler was then shown a request with no body and answered it as though the
361    // client had sent none, so a truncated bulk update read as an empty one.
362    let limit = MAX_REQUEST_BODY_BYTES;
363    let body_bytes = match http_body_util::Limited::new(req.into_body(), limit)
364        .collect()
365        .await
366    {
367        Ok(collected) => collected.to_bytes(),
368        Err(e) => {
369            error!(
370                "CouchDB {} {}: refusing request body ({}); limit is {} bytes",
371                method, path, e, limit
372            );
373            console_error!(
374                status_tx,
375                "CouchDB {} {} → 413 (request body over {} bytes)",
376                method,
377                path,
378                limit
379            );
380            let body = serde_json::json!({
381                "error": "too_large",
382                "reason": format!("netget: request body exceeds {} bytes", limit)
383            })
384            .to_string();
385            return Ok(couchdb_response_builder(413)
386                .body(Full::new(Bytes::from(body)))
387                .unwrap_or_else(|_| {
388                    couchdb_infallible_error(
389                        hyper::StatusCode::PAYLOAD_TOO_LARGE,
390                        r#"{"error":"too_large","reason":"netget: request body too large"}"#,
391                    )
392                }));
393        }
394    };
395
396    let body_str = String::from_utf8_lossy(&body_bytes).to_string();
397
398    debug!(
399        "CouchDB request: {} {} ({} bytes)",
400        method,
401        uri,
402        body_bytes.len()
403    );
404    Log::new(Some(&status_tx)).debug(format!(
405        "CouchDB {} {} ({} bytes)",
406        method,
407        path,
408        body_bytes.len()
409    ));
410
411    // Parse query parameters
412    let query_params: HashMap<String, String> = query
413        .as_ref()
414        .map(|q| {
415            form_urlencoded::parse(q.as_bytes())
416                .map(|(k, v)| (k.into_owned(), v.into_owned()))
417                .collect()
418        })
419        .unwrap_or_default();
420
421    // Detect operation type from path and method
422    let (operation, database, doc_id) = detect_couchdb_operation(&method, &path);
423
424    if !body_str.is_empty() {
425        Log::new(Some(&status_tx)).trace(format!("CouchDB request body: {}", body_str));
426    }
427
428    // Create CouchDB request event
429    let event = crate::protocol::Event::new(
430        &actions::COUCHDB_REQUEST_EVENT,
431        serde_json::json!({
432            "method": method,
433            "path": path,
434            "operation": operation,
435            "database": database,
436            "doc_id": doc_id,
437            "query_params": query_params,
438            "request_body": body_str,
439            "authorization": authorization.map(|_| "***"),  // Don't log credentials
440        }),
441    );
442
443    let llm_result = crate::llm::action_helper::call_llm(
444        &llm_client,
445        &app_state,
446        server_id,
447        None, // Connection ID not needed for stateless HTTP
448        &event,
449        protocol.as_ref(),
450    )
451    .await;
452
453    // Process action results to build HTTP response
454    match llm_result {
455        Ok(execution_result) => {
456            // Look for CouchDB-specific response actions
457            for result in execution_result.protocol_results {
458                match result {
459                    ActionResult::Custom { name, data } => {
460                        if name == "couchdb_response" {
461                            let status =
462                                data.get("status").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
463                            let body = data.get("body").and_then(|v| v.as_str()).unwrap_or("{}");
464                            let etag = data.get("etag").and_then(|v| v.as_str());
465                            let www_authenticate =
466                                data.get("www_authenticate").and_then(|v| v.as_str());
467
468                            debug!("CouchDB response: status={}", status);
469                            let log = Log::new(Some(&status_tx));
470                            log.debug(format!("CouchDB → {} response", status));
471                            log.trace(format!("CouchDB response body: {}", body));
472
473                            let mut builder = couchdb_response_builder(status);
474
475                            if let Some(etag_value) = etag {
476                                builder = header_or_skip(builder, "ETag", etag_value);
477                            }
478
479                            if let Some(www_auth) = www_authenticate {
480                                builder = header_or_skip(builder, "WWW-Authenticate", www_auth);
481                            }
482
483                            return Ok(builder
484                                .body(Full::new(Bytes::from(body.to_string())))
485                                .unwrap_or_else(|_| {
486                                    couchdb_infallible_error(
487                                        hyper::StatusCode::INTERNAL_SERVER_ERROR,
488                                        r#"{"error":"internal_server_error","reason":"netget: the response headers chosen for this reply could not be encoded"}"#,
489                                    )
490                                }));
491                        }
492                    }
493                    _ => {
494                        // Other actions don't affect HTTP response
495                    }
496                }
497            }
498
499            // The handler ran and produced no CouchDB response. This used to answer
500            // 200 `{"ok": true}`, which is the exact claim the `Err` branch below refuses to
501            // make: on a PUT it tells the client the document was written, on a `_session`
502            // request it tells the client the login succeeded. Nothing here knows either.
503            //
504            // `no_response` is deliberately a different `error` string from the backend-failure
505            // branch's `internal_server_error`, so a handler that stayed silent and a backend
506            // that fell over are distinguishable in the client's error and in the log rather
507            // than both reading as a generic 500.
508            let kind = "no_response";
509            Log::new(Some(&status_tx)).warn(
510                "CouchDB handler produced no response for this request; answering 500 \
511                 (decision=no_response) rather than a success it cannot vouch for"
512                    .to_string(),
513            );
514            let error_response = serde_json::json!({
515                "error": kind,
516                "reason": "netget: the handler returned no CouchDB response for this request"
517            })
518            .to_string();
519
520            Ok(couchdb_response_builder(500)
521                .body(Full::new(Bytes::from(error_response)))
522                .unwrap_or_else(|_| {
523                    couchdb_infallible_error(
524                        hyper::StatusCode::INTERNAL_SERVER_ERROR,
525                        r#"{"error":"no_response","reason":"netget: the handler returned no CouchDB response for this request"}"#,
526                    )
527                }))
528        }
529        Err(e) => {
530            // CouchDB reports errors as `{"error": ..., "reason": ...}` with a matching HTTP
531            // status, and clients raise on it. What matters is that it is never a 2xx: a 200
532            // with `{"rows": []}` means the view returned nothing, and a 201 with `{"ok":
533            // true}` means the document was written - both statements about the database that
534            // nothing here is in a position to make.
535            let overloaded = crate::llm::is_overload_error(&e);
536            let (status, kind) = if overloaded {
537                (503u16, "unavailable")
538            } else {
539                (500u16, "internal_server_error")
540            };
541            error!(
542                "LLM error for CouchDB request (overload={}, status {}): {}",
543                overloaded, status, e
544            );
545            console_error!(status_tx, "CouchDB answering {} {}: {}", status, kind, e);
546
547            let error_response = serde_json::json!({
548                "error": kind,
549                "reason": crate::utils::WireFailure::classify(&e).prefixed_text()
550            })
551            .to_string();
552
553            Ok(couchdb_response_builder(status)
554                .body(Full::new(Bytes::from(error_response)))
555                .unwrap_or_else(|_| {
556                    couchdb_infallible_error(
557                        hyper::StatusCode::INTERNAL_SERVER_ERROR,
558                        r#"{"error":"internal_server_error","reason":"netget: no response could be obtained"}"#,
559                    )
560                }))
561        }
562    }
563}
564
565/// A CouchDB error response that cannot itself fail to build.
566///
567/// Every `.body()` above can fail - it surfaces an invalid header value assembled earlier in
568/// the builder - and every fallback for that used to be `Response::new(...)`, which is
569/// **200 OK**. So a model answering `401` with a malformed `WWW-Authenticate` had its refusal
570/// turned into a success, and the LLM-error branch answered 2xx in defiance of its own comment
571/// that it never may. Setting the status on an already-constructed response leaves no builder
572/// to fail, so a non-2xx here is guaranteed rather than hoped for.
573fn couchdb_infallible_error(status: hyper::StatusCode, body: &str) -> Response<Full<Bytes>> {
574    let mut response = Response::new(Full::new(Bytes::from(body.to_string())));
575    *response.status_mut() = status;
576    response
577}
578
579/// Start a CouchDB response with the standard headers and a validated status.
580///
581/// `status` originates in model output. `Response::builder().status()` rejects anything
582/// outside 100-999 and the previous `.unwrap()` turned that into a panic, killing the
583/// hyper connection task and leaving the client waiting on a socket that never answers.
584/// `CouchDbProtocol::execute_action` already rejects out-of-range values with a message the
585/// model sees; this is the belt-and-braces path.
586fn couchdb_response_builder(status: u16) -> hyper::http::response::Builder {
587    let status = hyper::StatusCode::from_u16(status).unwrap_or_else(|_| {
588        error!(
589            "Invalid CouchDB status code {}, sending 500 instead",
590            status
591        );
592        hyper::StatusCode::INTERNAL_SERVER_ERROR
593    });
594
595    Response::builder()
596        .status(status)
597        .header("Content-Type", "application/json")
598        .header("Server", "CouchDB/3.5.1 (NetGet LLM)")
599}
600
601/// Attach a header whose value came from model output.
602///
603/// `ETag` carries a document revision and `WWW-Authenticate` an auth realm, both chosen by
604/// the model. `HeaderValue` rejects control characters and non-ASCII bytes, which
605/// `Builder::body()` reports as an error - previously `.unwrap()`ed into a panic. Skip the
606/// header instead and say so in the log.
607fn header_or_skip(
608    builder: hyper::http::response::Builder,
609    name: &'static str,
610    value: &str,
611) -> hyper::http::response::Builder {
612    match hyper::header::HeaderValue::from_str(value) {
613        Ok(v) => builder.header(name, v),
614        Err(_) => {
615            error!(
616                "Dropping CouchDB {} header: {:?} is not a valid HTTP header value",
617                name, value
618            );
619            builder
620        }
621    }
622}
623
624/// Detect CouchDB operation from HTTP method and path
625fn detect_couchdb_operation(method: &str, path: &str) -> (String, Option<String>, Option<String>) {
626    let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
627
628    match (method, parts.as_slice()) {
629        // Root endpoint - server info
630        ("GET", [""]) => ("server_info".to_string(), None, None),
631
632        // Special endpoints
633        ("GET", ["_all_dbs"]) => ("all_dbs".to_string(), None, None),
634        ("GET", ["_active_tasks"]) => ("active_tasks".to_string(), None, None),
635        ("GET", ["_uuids"]) => ("uuids".to_string(), None, None),
636        ("POST", ["_replicate"]) => ("replicate".to_string(), None, None),
637        ("GET", ["_session"]) => ("session".to_string(), None, None),
638
639        // Database operations
640        ("PUT", [db]) if !db.starts_with('_') => {
641            ("db_create".to_string(), Some(db.to_string()), None)
642        }
643        ("DELETE", [db]) if !db.starts_with('_') => {
644            ("db_delete".to_string(), Some(db.to_string()), None)
645        }
646        ("GET", [db]) if !db.starts_with('_') => {
647            ("db_info".to_string(), Some(db.to_string()), None)
648        }
649        ("POST", [db]) if !db.starts_with('_') => {
650            ("doc_create".to_string(), Some(db.to_string()), None)
651        }
652
653        // Database special endpoints
654        ("GET", [db, "_all_docs"]) => ("all_docs".to_string(), Some(db.to_string()), None),
655        ("POST", [db, "_all_docs"]) => ("all_docs".to_string(), Some(db.to_string()), None),
656        ("POST", [db, "_bulk_docs"]) => ("bulk_docs".to_string(), Some(db.to_string()), None),
657        ("GET", [db, "_changes"]) => ("changes".to_string(), Some(db.to_string()), None),
658        ("POST", [db, "_ensure_full_commit"]) => {
659            ("ensure_full_commit".to_string(), Some(db.to_string()), None)
660        }
661        ("POST", [db, "_compact"]) => ("compact".to_string(), Some(db.to_string()), None),
662        ("POST", [db, "_purge"]) => ("purge".to_string(), Some(db.to_string()), None),
663
664        // Design document operations (views)
665        ("GET", [db, "_design", ddoc]) => (
666            "design_get".to_string(),
667            Some(db.to_string()),
668            Some(format!("_design/{}", ddoc)),
669        ),
670        ("PUT", [db, "_design", ddoc]) => (
671            "design_put".to_string(),
672            Some(db.to_string()),
673            Some(format!("_design/{}", ddoc)),
674        ),
675        ("DELETE", [db, "_design", ddoc]) => (
676            "design_delete".to_string(),
677            Some(db.to_string()),
678            Some(format!("_design/{}", ddoc)),
679        ),
680
681        // View query
682        ("GET", [db, "_design", ddoc, "_view", view]) => (
683            "view_query".to_string(),
684            Some(db.to_string()),
685            Some(format!("_design/{}/{}", ddoc, view)),
686        ),
687        ("POST", [db, "_design", ddoc, "_view", view]) => (
688            "view_query".to_string(),
689            Some(db.to_string()),
690            Some(format!("_design/{}/{}", ddoc, view)),
691        ),
692
693        // Document operations
694        ("GET", [db, doc_id]) if !doc_id.starts_with('_') => (
695            "doc_get".to_string(),
696            Some(db.to_string()),
697            Some(doc_id.to_string()),
698        ),
699        ("PUT", [db, doc_id]) if !doc_id.starts_with('_') => (
700            "doc_put".to_string(),
701            Some(db.to_string()),
702            Some(doc_id.to_string()),
703        ),
704        ("DELETE", [db, doc_id]) if !doc_id.starts_with('_') => (
705            "doc_delete".to_string(),
706            Some(db.to_string()),
707            Some(doc_id.to_string()),
708        ),
709        ("HEAD", [db, doc_id]) if !doc_id.starts_with('_') => (
710            "doc_head".to_string(),
711            Some(db.to_string()),
712            Some(doc_id.to_string()),
713        ),
714
715        // Attachment operations
716        ("GET", [db, doc_id, attachment]) if !doc_id.starts_with('_') => (
717            "attachment_get".to_string(),
718            Some(db.to_string()),
719            Some(format!("{}/{}", doc_id, attachment)),
720        ),
721        ("PUT", [db, doc_id, attachment]) if !doc_id.starts_with('_') => (
722            "attachment_put".to_string(),
723            Some(db.to_string()),
724            Some(format!("{}/{}", doc_id, attachment)),
725        ),
726        ("DELETE", [db, doc_id, attachment]) if !doc_id.starts_with('_') => (
727            "attachment_delete".to_string(),
728            Some(db.to_string()),
729            Some(format!("{}/{}", doc_id, attachment)),
730        ),
731
732        // Replication endpoints
733        ("GET", [db, "_local", doc_id]) => (
734            "local_doc_get".to_string(),
735            Some(db.to_string()),
736            Some(format!("_local/{}", doc_id)),
737        ),
738        ("PUT", [db, "_local", doc_id]) => (
739            "local_doc_put".to_string(),
740            Some(db.to_string()),
741            Some(format!("_local/{}", doc_id)),
742        ),
743        ("POST", [db, "_revs_diff"]) => ("revs_diff".to_string(), Some(db.to_string()), None),
744        ("POST", [db, "_bulk_get"]) => ("bulk_get".to_string(), Some(db.to_string()), None),
745
746        // Default
747        _ => ("unknown".to_string(), None, None),
748    }
749}
750
751/// Check HTTP Basic Authentication
752fn check_basic_auth(auth_header: &str, expected_username: &str, expected_password: &str) -> bool {
753    // Format: "Basic base64(username:password)"
754    if !auth_header.starts_with("Basic ") {
755        return false;
756    }
757
758    let encoded = &auth_header[6..]; // Skip "Basic "
759
760    // Decode base64
761    use base64::Engine;
762    let decoded = match base64::engine::general_purpose::STANDARD.decode(encoded) {
763        Ok(d) => d,
764        Err(_) => return false,
765    };
766
767    let credentials = match String::from_utf8(decoded) {
768        Ok(c) => c,
769        Err(_) => return false,
770    };
771
772    // Split username:password
773    let parts: Vec<&str> = credentials.splitn(2, ':').collect();
774    if parts.len() != 2 {
775        return false;
776    }
777
778    parts[0] == expected_username && parts[1] == expected_password
779}
780
781/// Create 401 Unauthorized response
782fn create_auth_required_response() -> Response<Full<Bytes>> {
783    let error_response = serde_json::json!({
784        "error": "unauthorized",
785        "reason": "Authentication required"
786    })
787    .to_string();
788
789    Response::builder()
790        .status(401)
791        .header("Content-Type", "application/json")
792        .header("Server", "CouchDB/3.5.1 (NetGet LLM)")
793        .header("WWW-Authenticate", "Basic realm=\"CouchDB\"")
794        .body(Full::new(Bytes::from(error_response)))
795        .unwrap()
796}